From 21a844cb8ab71b241219b83fc92f8ab1dc008313 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Mon, 9 Feb 2026 09:51:32 +0100 Subject: [PATCH 01/53] fix: Restore all files lost during destructive rebase A previous `git pull --rebase origin main` dropped 177 local commits, losing 3400+ files across admin-v2, backend, studio-v2, website, klausur-service, and many other services. The partial restore attempt (660295e2) only recovered some files. This commit restores all missing files from pre-rebase ref 98933f5e while preserving post-rebase additions (night-scheduler, night-mode UI, NightModeWidget dashboard integration). Restored features include: - AI Module Sidebar (FAB), OCR Labeling, OCR Compare - GPU Dashboard, RAG Pipeline, Magic Help - Klausur-Korrektur (8 files), Abitur-Archiv (5+ files) - Companion, Zeugnisse-Crawler, Screen Flow - Full backend, studio-v2, website, klausur-service - All compliance SDKs, agent-core, voice-service - CI/CD configs, documentation, scripts Co-Authored-By: Claude Opus 4.6 --- .claude/rules/abiturkorrektur.md | 614 + .claude/rules/experimental-dashboard.md | 250 + .claude/rules/multi-agent-architecture.md | 295 + .claude/rules/vocab-worksheet.md | 205 + .claude/session-status-2026-01-25.md | 117 + .claude/settings.local.json | 82 + .docker/build-ci-images.sh | 31 + .docker/python-ci.Dockerfile | 51 + .env.dev | 115 + .env.example | 124 + .env.staging | 113 + .github/dependabot.yml | 132 + .github/workflows/ci.yml | 503 + .github/workflows/security.yml | 222 + .github/workflows/test.yml | 244 + .gitleaks.toml | 77 + .pre-commit-config.yaml | 152 + .semgrep.yml | 147 + .trivy.yaml | 66 + .trivyignore | 9 + .woodpecker/auto-fix.yml | 132 + .woodpecker/build-ci-image.yml | 37 + .woodpecker/integration.yml | 161 + .woodpecker/main.yml | 669 + .woodpecker/security.yml | 314 + AI_COMPLIANCE_SDK_IMPLEMENTATION_PLAN.md | 2029 +++ BREAKPILOT_CONSENT_MANAGEMENT_PLAN.md | 566 + CONTENT_SERVICE_SETUP.md | 473 + IMPLEMENTATION_SUMMARY.md | 427 + LICENSES/THIRD_PARTY_LICENSES.md | 371 + MAC_MINI_SETUP.md | 95 + Makefile | 80 + POLICY_VAULT_OVERVIEW.md | 794 + SOURCE_POLICY_IMPLEMENTATION_PLAN.md | 530 + admin-v2/Dockerfile | 2 + admin-v2/ai-compliance-sdk/Dockerfile | 45 + admin-v2/ai-compliance-sdk/cmd/server/main.go | 160 + .../ai-compliance-sdk/configs/config.yaml | 42 + admin-v2/ai-compliance-sdk/go.mod | 11 + .../internal/api/checkpoint.go | 327 + .../internal/api/generate.go | 365 + .../ai-compliance-sdk/internal/api/rag.go | 182 + .../ai-compliance-sdk/internal/api/router.go | 96 + .../ai-compliance-sdk/internal/api/state.go | 171 + .../ai-compliance-sdk/internal/db/postgres.go | 173 + .../ai-compliance-sdk/internal/llm/service.go | 384 + .../ai-compliance-sdk/internal/rag/service.go | 208 + admin-v2/app/(admin)/ai/gpu/page.tsx | 396 + admin-v2/app/(admin)/ai/llm-compare/page.tsx | 12 +- admin-v2/app/(admin)/ai/magic-help/page.tsx | 1604 ++ admin-v2/app/(admin)/ai/ocr-compare/page.tsx | 1412 ++ admin-v2/app/(admin)/ai/ocr-labeling/page.tsx | 987 ++ admin-v2/app/(admin)/ai/ocr-labeling/types.ts | 123 + admin-v2/app/(admin)/ai/rag-pipeline/page.tsx | 1443 ++ admin-v2/app/(admin)/ai/rag/page.tsx | 14 +- admin-v2/app/(admin)/ai/test-quality/page.tsx | 8 +- admin-v2/app/(admin)/architecture/page.tsx | 52 +- admin-v2/app/(admin)/dashboard/page.tsx | 15 +- .../app/(admin)/development/content/page.tsx | 769 + .../(admin)/development/screen-flow/page.tsx | 797 + .../app/(admin)/development/workflow/page.tsx | 165 +- .../components/AehnlicheDokumente.tsx | 223 + .../abitur-archiv/components/DokumentCard.tsx | 203 + .../components/FullscreenViewer.tsx | 456 + .../abitur-archiv/components/ThemenSuche.tsx | 243 + .../(admin)/education/abitur-archiv/page.tsx | 516 + .../app/(admin)/education/companion/page.tsx | 76 + .../app/(admin)/education/edu-search/page.tsx | 39 +- .../[klausurId]/[studentId]/page.tsx | 1320 ++ .../[klausurId]/fairness/page.tsx | 484 + .../klausur-korrektur/[klausurId]/page.tsx | 489 + .../components/AnnotationLayer.tsx | 281 + .../components/AnnotationPanel.tsx | 267 + .../components/AnnotationToolbar.tsx | 139 + .../components/EHSuggestionPanel.tsx | 279 + .../klausur-korrektur/components/index.ts | 4 + .../education/klausur-korrektur/page.tsx | 1121 ++ .../education/klausur-korrektur/types.ts | 195 + .../education/zeugnisse-crawler/page.tsx | 181 + .../app/(admin)/infrastructure/ci-cd/page.tsx | 4 + .../app/(admin)/infrastructure/sbom/page.tsx | 11 + .../(admin)/infrastructure/security/page.tsx | 4 + .../app/(admin)/infrastructure/tests/page.tsx | 192 +- .../components/DataPointsPreview.tsx | 405 +- .../components/DocumentValidation.tsx | 252 +- admin-v2/app/(sdk)/sdk/dsfa/[id]/page.tsx | 1340 ++ admin-v2/app/(sdk)/sdk/dsfa/page.tsx | 591 +- admin-v2/app/(sdk)/sdk/page.tsx | 241 +- .../app/api/admin/companion/feedback/route.ts | 129 + .../app/api/admin/companion/lesson/route.ts | 194 + admin-v2/app/api/admin/companion/route.ts | 102 + .../app/api/admin/companion/settings/route.ts | 137 + admin-v2/app/api/ai/rag-pipeline/route.ts | 215 + admin-v2/app/api/development/content/route.ts | 68 + .../app/api/education/abitur-archiv/route.ts | 246 + .../education/abitur-archiv/suggest/route.ts | 105 + .../app/api/education/abitur-docs/route.ts | 139 + .../log-extract/extract/route.ts | 395 + admin-v2/app/api/sdk/v1/checkpoints/route.ts | 12 +- admin-v2/app/api/sdk/v1/flow/route.ts | 19 +- admin-v2/app/api/webhooks/woodpecker/route.ts | 273 + admin-v2/components/ai/AIModuleSidebar.tsx | 405 + admin-v2/components/ai/AIToolsSidebar.tsx | 406 + admin-v2/components/ai/BatchUploader.tsx | 454 + admin-v2/components/ai/ConfidenceHeatmap.tsx | 439 + admin-v2/components/ai/TrainingMetrics.tsx | 613 + admin-v2/components/ai/index.ts | 13 + admin-v2/components/common/Breadcrumbs.tsx | 6 +- admin-v2/components/common/SkeletonText.tsx | 217 + .../companion/CompanionDashboard.tsx | 312 + admin-v2/components/companion/ModeToggle.tsx | 61 + .../companion/companion-mode/EventsCard.tsx | 173 + .../companion-mode/PhaseTimeline.tsx | 203 + .../companion/companion-mode/StatsGrid.tsx | 114 + .../companion-mode/SuggestionList.tsx | 170 + admin-v2/components/companion/index.ts | 24 + .../companion/lesson-mode/HomeworkSection.tsx | 153 + .../lesson-mode/LessonActiveView.tsx | 172 + .../companion/lesson-mode/LessonContainer.tsx | 80 + .../companion/lesson-mode/LessonEndedView.tsx | 209 + .../companion/lesson-mode/LessonStartForm.tsx | 269 + .../companion/lesson-mode/QuickActionsBar.tsx | 194 + .../lesson-mode/ReflectionSection.tsx | 146 + .../companion/lesson-mode/VisualPieTimer.tsx | 220 + .../companion/modals/FeedbackModal.tsx | 201 + .../companion/modals/OnboardingModal.tsx | 280 + .../companion/modals/SettingsModal.tsx | 248 + .../components/dashboard/NightModeWidget.tsx | 231 + .../components/education/DokumenteTab.tsx | 366 + .../components/education/PDFPreviewModal.tsx | 207 + .../infrastructure/DevOpsPipelineSidebar.tsx | 533 + admin-v2/components/layout/Sidebar.tsx | 63 +- admin-v2/components/ocr/BlockReviewPanel.tsx | 458 + .../components/ocr/CellCorrectionDialog.tsx | 294 + admin-v2/components/ocr/GridOverlay.tsx | 357 + .../ocr/__tests__/BlockReviewPanel.test.tsx | 390 + .../ocr/__tests__/GridOverlay.test.tsx | 267 + admin-v2/components/ocr/index.ts | 13 + .../components/sdk/CommandBar/CommandBar.tsx | 6 +- .../DocumentUpload/DocumentUploadSection.tsx | 6 - .../SDKPipelineSidebar/SDKPipelineSidebar.tsx | 245 +- .../components/sdk/Sidebar/SDKSidebar.tsx | 343 +- admin-v2/e2e/fixtures/sdk-fixtures.ts | 241 + admin-v2/e2e/specs/command-bar.spec.ts | 141 + admin-v2/e2e/specs/dsr.spec.ts | 373 + admin-v2/e2e/specs/export.spec.ts | 153 + admin-v2/e2e/specs/sdk-navigation.spec.ts | 132 + admin-v2/e2e/specs/sdk-workflow.spec.ts | 209 + admin-v2/e2e/utils/test-helpers.ts | 160 + admin-v2/hooks/companion/index.ts | 3 + admin-v2/hooks/companion/useCompanionData.ts | 156 + .../hooks/companion/useKeyboardShortcuts.ts | 113 + admin-v2/hooks/companion/useLessonSession.ts | 446 + admin-v2/lib/companion/constants.ts | 364 + admin-v2/lib/companion/index.ts | 2 + admin-v2/lib/companion/types.ts | 329 + admin-v2/lib/content-types.ts | 60 + admin-v2/lib/content.ts | 284 + admin-v2/lib/education/abitur-archiv-types.ts | 164 + admin-v2/lib/education/abitur-docs-types.ts | 84 + admin-v2/lib/module-registry.ts | 176 +- admin-v2/lib/navigation.ts | 287 +- admin-v2/lib/sdk/context.tsx | 121 +- admin-v2/lib/sdk/demo-data/index.ts | 36 +- admin-v2/lib/sdk/dsfa/__tests__/api.test.ts | 355 + admin-v2/lib/sdk/dsfa/__tests__/types.test.ts | 255 + admin-v2/lib/sdk/dsfa/api.ts | 399 + admin-v2/lib/sdk/dsfa/index.ts | 8 + admin-v2/lib/sdk/dsfa/types.ts | 365 + admin-v2/lib/sdk/types.ts | 13 - admin-v2/playwright.config.ts | 93 + admin-v2/tsconfig.json | 4 +- admin-v2/types/ai-modules.ts | 359 + admin-v2/types/infrastructure-modules.ts | 396 + admin-v2/vitest.config.ts | 30 + admin-v2/vitest.setup.ts | 48 + agent-core/README.md | 416 + agent-core/__init__.py | 24 + agent-core/brain/__init__.py | 22 + agent-core/brain/context_manager.py | 520 + agent-core/brain/knowledge_graph.py | 563 + agent-core/brain/memory_store.py | 568 + agent-core/orchestrator/__init__.py | 36 + agent-core/orchestrator/message_bus.py | 479 + agent-core/orchestrator/supervisor.py | 553 + agent-core/orchestrator/task_router.py | 436 + agent-core/pytest.ini | 10 + agent-core/requirements.txt | 19 + agent-core/sessions/__init__.py | 25 + agent-core/sessions/checkpoint.py | 362 + agent-core/sessions/heartbeat.py | 361 + agent-core/sessions/session_manager.py | 540 + agent-core/soul/alert-agent.soul.md | 120 + agent-core/soul/grader-agent.soul.md | 76 + agent-core/soul/orchestrator.soul.md | 150 + agent-core/soul/quality-judge.soul.md | 106 + agent-core/soul/tutor-agent.soul.md | 62 + agent-core/tests/__init__.py | 3 + agent-core/tests/conftest.py | 57 + agent-core/tests/test_heartbeat.py | 201 + agent-core/tests/test_memory_store.py | 207 + agent-core/tests/test_message_bus.py | 224 + agent-core/tests/test_session_manager.py | 270 + agent-core/tests/test_task_router.py | 203 + ai-compliance-sdk/Dockerfile | 48 + ai-compliance-sdk/cmd/server/main.go | 465 + ai-compliance-sdk/docs/ARCHITECTURE.md | 1098 ++ .../docs/AUDITOR_DOCUMENTATION.md | 387 + ai-compliance-sdk/docs/SBOM.md | 220 + ai-compliance-sdk/go.mod | 65 + ai-compliance-sdk/go.sum | 157 + .../internal/api/handlers/audit_handlers.go | 445 + .../internal/api/handlers/dsgvo_handlers.go | 779 + .../api/handlers/escalation_handlers.go | 421 + .../internal/api/handlers/funding_handlers.go | 638 + .../internal/api/handlers/llm_handlers.go | 345 + .../api/handlers/obligations_handlers.go | 539 + .../api/handlers/portfolio_handlers.go | 625 + .../internal/api/handlers/rbac_handlers.go | 548 + .../internal/api/handlers/roadmap_handlers.go | 740 + .../internal/api/handlers/ucca_handlers.go | 1055 ++ .../api/handlers/ucca_handlers_test.go | 626 + .../api/handlers/workshop_handlers.go | 923 ++ ai-compliance-sdk/internal/audit/exporter.go | 444 + ai-compliance-sdk/internal/audit/store.go | 472 + .../internal/audit/trail_builder.go | 337 + ai-compliance-sdk/internal/config/config.go | 182 + ai-compliance-sdk/internal/dsgvo/models.go | 235 + ai-compliance-sdk/internal/dsgvo/store.go | 664 + ai-compliance-sdk/internal/funding/export.go | 395 + ai-compliance-sdk/internal/funding/models.go | 394 + .../internal/funding/postgres_store.go | 652 + ai-compliance-sdk/internal/funding/store.go | 81 + ai-compliance-sdk/internal/llm/access_gate.go | 367 + .../internal/llm/anthropic_adapter.go | 250 + .../internal/llm/ollama_adapter.go | 350 + .../internal/llm/pii_detector.go | 276 + ai-compliance-sdk/internal/llm/provider.go | 239 + .../internal/portfolio/models.go | 278 + ai-compliance-sdk/internal/portfolio/store.go | 818 + ai-compliance-sdk/internal/rbac/middleware.go | 459 + ai-compliance-sdk/internal/rbac/models.go | 197 + .../internal/rbac/policy_engine.go | 395 + ai-compliance-sdk/internal/rbac/service.go | 360 + ai-compliance-sdk/internal/rbac/store.go | 651 + ai-compliance-sdk/internal/roadmap/models.go | 308 + ai-compliance-sdk/internal/roadmap/parser.go | 540 + ai-compliance-sdk/internal/roadmap/store.go | 757 + .../internal/ucca/ai_act_module.go | 769 + .../internal/ucca/ai_act_module_test.go | 343 + .../internal/ucca/dsgvo_module.go | 719 + .../internal/ucca/escalation_models.go | 286 + .../internal/ucca/escalation_store.go | 502 + .../internal/ucca/escalation_test.go | 446 + ai-compliance-sdk/internal/ucca/examples.go | 286 + .../internal/ucca/financial_policy.go | 734 + .../internal/ucca/financial_policy_test.go | 618 + ai-compliance-sdk/internal/ucca/legal_rag.go | 394 + .../internal/ucca/license_policy.go | 583 + .../internal/ucca/license_policy_test.go | 940 ++ ai-compliance-sdk/internal/ucca/models.go | 523 + .../internal/ucca/nis2_module.go | 762 + .../internal/ucca/nis2_module_test.go | 556 + .../internal/ucca/obligations_framework.go | 381 + .../internal/ucca/obligations_registry.go | 480 + .../internal/ucca/obligations_store.go | 216 + ai-compliance-sdk/internal/ucca/patterns.go | 279 + ai-compliance-sdk/internal/ucca/pdf_export.go | 510 + .../internal/ucca/pdf_export_test.go | 238 + .../internal/ucca/policy_engine.go | 882 ++ .../internal/ucca/policy_engine_test.go | 940 ++ ai-compliance-sdk/internal/ucca/rules.go | 1231 ++ ai-compliance-sdk/internal/ucca/store.go | 313 + .../internal/ucca/unified_facts.go | 439 + ai-compliance-sdk/internal/workshop/models.go | 290 + ai-compliance-sdk/internal/workshop/store.go | 793 + .../policies/controls_catalog.yaml | 889 ++ .../policies/eprivacy_corpus.yaml | 801 + .../financial_regulations_corpus.yaml | 613 + .../financial_regulations_policy.yaml | 946 ++ .../policies/funding/bundesland_profiles.yaml | 598 + .../funding/foerderantrag_wizard_v1.yaml | 893 ++ ai-compliance-sdk/policies/gap_mapping.yaml | 794 + .../policies/licensed_content_policy.yaml | 655 + .../obligations/ai_act_obligations.yaml | 430 + .../obligations/dsgvo_obligations.yaml | 321 + .../obligations/nis2_obligations.yaml | 556 + .../policies/scc_legal_corpus.yaml | 458 + .../policies/ucca_policy_v1.yaml | 1144 ++ .../policies/wizard_schema_v1.yaml | 1396 ++ ai-content-generator/.env.example | 23 + ai-content-generator/Dockerfile | 34 + ai-content-generator/README.md | 364 + ai-content-generator/app/__init__.py | 6 + ai-content-generator/app/main.py | 270 + ai-content-generator/app/models/__init__.py | 11 + .../app/models/generation_job.py | 101 + ai-content-generator/app/services/__init__.py | 16 + .../app/services/claude_service.py | 364 + .../app/services/content_generator.py | 341 + .../app/services/material_analyzer.py | 197 + .../app/services/youtube_service.py | 243 + ai-content-generator/app/utils/__init__.py | 10 + ai-content-generator/app/utils/job_store.py | 36 + ai-content-generator/requirements.txt | 37 + backend/.dockerignore | 70 + backend/.env.example | 135 + backend/Dockerfile | 63 + backend/Dockerfile.worker | 49 + backend/PLAN_KLAUSURKORREKTUR.md | 202 + backend/PROJEKT_STRUKTUR.md | 47 + backend/abitur_docs_api.py | 956 ++ backend/ai_processing/__init__.py | 126 + backend/ai_processing/analysis.py | 209 + backend/ai_processing/cloze_generator.py | 328 + backend/ai_processing/core.py | 71 + backend/ai_processing/html_generator.py | 211 + backend/ai_processing/image_processor.py | 78 + backend/ai_processing/leitner.py | 155 + backend/ai_processing/mc_generator.py | 316 + backend/ai_processing/mindmap.py | 472 + backend/ai_processing/print_generator.py | 824 + backend/ai_processing/qa_generator.py | 333 + backend/ai_processor.py | 81 + backend/ai_processor/__init__.py | 106 + backend/ai_processor/config.py | 43 + backend/ai_processor/export/__init__.py | 19 + backend/ai_processor/export/print_versions.py | 508 + backend/ai_processor/export/worksheet.py | 286 + backend/ai_processor/generators/__init__.py | 21 + backend/ai_processor/generators/cloze.py | 312 + .../generators/multiple_choice.py | 291 + backend/ai_processor/generators/qa.py | 458 + backend/ai_processor/utils.py | 83 + backend/ai_processor/vision/__init__.py | 19 + backend/ai_processor/vision/html_builder.py | 218 + backend/ai_processor/vision/scan_analyzer.py | 307 + .../ai_processor/visualization/__init__.py | 17 + backend/ai_processor/visualization/mindmap.py | 471 + backend/alembic.ini | 72 + backend/alembic/__init__.py | 0 backend/alembic/env.py | 107 + backend/alembic/script.py.mako | 26 + ...60115_1200_001_initial_classroom_tables.py | 123 + .../20260115_1400_002_lesson_templates.py | 52 + .../20260115_1600_003_homework_assignments.py | 56 + .../20260115_1700_004_phase_materials.py | 69 + .../20260115_1800_005_lesson_reflections.py | 40 + .../20260115_1900_006_teacher_feedback.py | 45 + .../20260115_2000_007_teacher_context.py | 111 + .../20260115_2100_008_alerts_agent_tables.py | 255 + .../20260202_1000_009_test_registry_tables.py | 143 + backend/alembic/versions/__init__.py | 0 backend/alerts_agent/__init__.py | 14 + backend/alerts_agent/actions/__init__.py | 20 + backend/alerts_agent/actions/base.py | 123 + backend/alerts_agent/actions/dispatcher.py | 232 + backend/alerts_agent/actions/email_action.py | 251 + backend/alerts_agent/actions/slack_action.py | 198 + .../alerts_agent/actions/webhook_action.py | 135 + backend/alerts_agent/api/__init__.py | 17 + backend/alerts_agent/api/digests.py | 551 + backend/alerts_agent/api/routes.py | 510 + backend/alerts_agent/api/rules.py | 473 + backend/alerts_agent/api/subscriptions.py | 421 + backend/alerts_agent/api/templates.py | 410 + backend/alerts_agent/api/topics.py | 405 + backend/alerts_agent/api/wizard.py | 554 + backend/alerts_agent/data/__init__.py | 8 + backend/alerts_agent/data/templates.py | 492 + backend/alerts_agent/db/__init__.py | 34 + backend/alerts_agent/db/database.py | 19 + backend/alerts_agent/db/models.py | 636 + backend/alerts_agent/db/repository.py | 992 ++ backend/alerts_agent/ingestion/__init__.py | 8 + .../alerts_agent/ingestion/email_parser.py | 356 + backend/alerts_agent/ingestion/rss_fetcher.py | 383 + backend/alerts_agent/ingestion/scheduler.py | 279 + backend/alerts_agent/models/__init__.py | 12 + backend/alerts_agent/models/alert_item.py | 174 + .../alerts_agent/models/relevance_profile.py | 288 + backend/alerts_agent/processing/__init__.py | 12 + backend/alerts_agent/processing/dedup.py | 239 + .../processing/digest_generator.py | 458 + backend/alerts_agent/processing/importance.py | 341 + .../processing/relevance_scorer.py | 390 + .../alerts_agent/processing/rule_engine.py | 512 + backend/api/__init__.py | 9 + backend/api/classroom/__init__.py | 70 + backend/api/classroom/analytics.py | 343 + backend/api/classroom/context.py | 687 + backend/api/classroom/feedback.py | 271 + backend/api/classroom/homework.py | 281 + backend/api/classroom/materials.py | 343 + backend/api/classroom/models.py | 489 + backend/api/classroom/sessions.py | 434 + backend/api/classroom/settings.py | 201 + backend/api/classroom/shared.py | 341 + backend/api/classroom/templates.py | 392 + backend/api/classroom/utility.py | 185 + backend/api/tests/__init__.py | 35 + backend/api/tests/database.py | 91 + backend/api/tests/db_models.py | 227 + backend/api/tests/models.py | 277 + backend/api/tests/registry.py | 84 + backend/api/tests/registry/__init__.py | 129 + backend/api/tests/registry/api_models.py | 73 + backend/api/tests/registry/config.py | 230 + .../api/tests/registry/discovery/__init__.py | 16 + .../tests/registry/discovery/go_discovery.py | 45 + .../registry/discovery/python_discovery.py | 86 + .../registry/discovery/service_builder.py | 115 + .../api/tests/registry/executors/__init__.py | 23 + .../tests/registry/executors/bqas_executor.py | 44 + .../registry/executors/container_executor.py | 106 + .../tests/registry/executors/go_executor.py | 137 + .../tests/registry/executors/jest_executor.py | 130 + .../registry/executors/playwright_executor.py | 101 + .../registry/executors/python_executor.py | 187 + .../tests/registry/executors/test_runner.py | 192 + backend/api/tests/registry/routes/__init__.py | 21 + backend/api/tests/registry/routes/backlog.py | 580 + backend/api/tests/registry/routes/ci.py | 295 + backend/api/tests/registry/routes/tests.py | 335 + .../api/tests/registry/services/__init__.py | 23 + .../tests/registry/services/error_handling.py | 137 + backend/api/tests/repository.py | 500 + backend/api/tests/runners/__init__.py | 11 + backend/api/tests/runners/bqas_runner.py | 285 + backend/api/tests/runners/go_runner.py | 229 + backend/api/tests/runners/python_runner.py | 266 + backend/auth/__init__.py | 55 + backend/auth/keycloak_auth.py | 515 + backend/auth_api.py | 373 + backend/billing_client.py | 554 + backend/camunda_proxy.py | 441 + backend/certificates_api.py | 636 + backend/classroom/__init__.py | 110 + backend/classroom/models.py | 568 + backend/classroom/routes/__init__.py | 29 + backend/classroom/routes/analytics.py | 369 + backend/classroom/routes/context.py | 726 + backend/classroom/routes/export.py | 358 + backend/classroom/routes/feedback.py | 280 + backend/classroom/routes/homework.py | 284 + backend/classroom/routes/materials.py | 329 + backend/classroom/routes/sessions.py | 525 + backend/classroom/routes/settings.py | 184 + backend/classroom/routes/templates.py | 382 + backend/classroom/routes/websocket_routes.py | 143 + backend/classroom/services/__init__.py | 23 + backend/classroom/services/persistence.py | 131 + backend/classroom/websocket_manager.py | 204 + backend/classroom_api.py | 84 + backend/classroom_engine/__init__.py | 91 + backend/classroom_engine/analytics.py | 520 + backend/classroom_engine/antizipation.py | 676 + backend/classroom_engine/context_models.py | 291 + backend/classroom_engine/database.py | 49 + backend/classroom_engine/db_models.py | 429 + backend/classroom_engine/fsm.py | 222 + backend/classroom_engine/models.py | 407 + backend/classroom_engine/repository.py | 1705 +++ backend/classroom_engine/suggestions.py | 668 + backend/classroom_engine/timer.py | 272 + backend/claude_vision.py | 299 + backend/communication_test_api.py | 478 + backend/compliance/README.md | 307 + backend/compliance/README_AI.md | 275 + backend/compliance/SERVICE_COVERAGE.md | 297 + backend/compliance/SPRINT3_INTEGRATION.md | 393 + backend/compliance/SPRINT_4_SUMMARY.md | 285 + backend/compliance/__init__.py | 18 + backend/compliance/api/__init__.py | 33 + backend/compliance/api/ai_routes.py | 909 ++ backend/compliance/api/audit_routes.py | 637 + backend/compliance/api/dashboard_routes.py | 384 + backend/compliance/api/evidence_routes.py | 530 + backend/compliance/api/isms_routes.py | 1649 ++ backend/compliance/api/module_routes.py | 250 + backend/compliance/api/risk_routes.py | 200 + backend/compliance/api/routes.py | 914 ++ backend/compliance/api/routes.py.backup | 2512 ++++ backend/compliance/api/schemas.py | 1805 +++ backend/compliance/api/scraper_routes.py | 296 + backend/compliance/data/README.md | 218 + backend/compliance/data/__init__.py | 22 + backend/compliance/data/controls.py | 624 + backend/compliance/data/iso27001_annex_a.py | 986 ++ backend/compliance/data/regulations.py | 247 + backend/compliance/data/requirements.py | 391 + backend/compliance/data/risks.py | 309 + backend/compliance/data/service_modules.py | 834 + backend/compliance/db/__init__.py | 50 + backend/compliance/db/isms_repository.py | 839 ++ backend/compliance/db/models.py | 1413 ++ backend/compliance/db/repository.py | 1538 ++ backend/compliance/scripts/__init__.py | 3 + .../scripts/seed_service_modules.py | 97 + .../scripts/validate_service_modules.py | 207 + backend/compliance/services/__init__.py | 17 + .../services/ai_compliance_assistant.py | 500 + .../services/audit_pdf_generator.py | 880 ++ .../compliance/services/auto_risk_updater.py | 383 + .../compliance/services/export_generator.py | 616 + backend/compliance/services/llm_provider.py | 622 + backend/compliance/services/pdf_extractor.py | 602 + .../compliance/services/regulation_scraper.py | 876 ++ .../compliance/services/report_generator.py | 442 + backend/compliance/services/seeder.py | 488 + backend/compliance/tests/__init__.py | 1 + backend/compliance/tests/test_audit_routes.py | 591 + .../tests/test_auto_risk_updater.py | 434 + backend/compliance/tests/test_isms_routes.py | 696 + backend/config.py | 18 + backend/consent_admin_api.py | 446 + backend/consent_api.py | 276 + backend/consent_client.py | 359 + backend/consent_test_api.py | 752 + backend/content_generators/__init__.py | 34 + backend/content_generators/h5p_generator.py | 429 + backend/content_generators/pdf_generator.py | 529 + backend/content_service/Dockerfile | 32 + backend/content_service/__init__.py | 5 + backend/content_service/database.py | 38 + backend/content_service/main.py | 544 + backend/content_service/matrix_client.py | 249 + backend/content_service/models.py | 164 + backend/content_service/requirements.txt | 35 + backend/content_service/schemas.py | 191 + backend/content_service/storage.py | 161 + backend/correction_api.py | 683 + backend/data/messenger/templates.json | 32 + backend/data/units/bio_eye_lightpath_v1.json | 402 + backend/data/units/demo_unit_v1.json | 58 + backend/data/units/dup_test_5887b6a9.json | 58 + backend/data/units/dup_test_5f5e6e8d.json | 58 + backend/data/units/dup_test_73fbc83c.json | 58 + backend/data/units/minimal_unit_060e61ec.json | 58 + backend/data/units/minimal_unit_556984ab.json | 58 + backend/data/units/minimal_unit_810347e8.json | 58 + backend/data/units/minimal_unit_8e3cdd85.json | 58 + backend/data/units/test_unit_58fb22b1.json | 78 + backend/data/units/test_unit_5983ef0c.json | 78 + backend/data/units/test_unit_ad7e29f2.json | 78 + backend/data/units/test_unit_bdac7076.json | 78 + backend/data/units/test_unit_f298350b.json | 78 + backend/data/units/update_test_092963f2.json | 58 + backend/data/units/update_test_26763076.json | 58 + backend/data/units/update_test_8d6d1ee0.json | 58 + backend/data/units/update_test_caa8de53.json | 58 + backend/deadline_api.py | 79 + backend/debug_visualization.py | 64 + backend/docs/DSGVO_Datenkategorien.html | 340 + backend/docs/DSGVO_Datenkategorien.md | 159 + backend/docs/compliance_ai_integration.md | 447 + backend/docs/llm-platform/api/vast-ai-api.md | 385 + backend/dsr_admin_api.py | 415 + backend/dsr_api.py | 111 + backend/dsr_test_api.py | 660 + backend/email_service.py | 395 + backend/email_template_api.py | 252 + backend/frontend/__init__.py | 0 backend/frontend/app.py | 26 + backend/frontend/archive/studio.css.full.bak | 3459 +++++ backend/frontend/archive/studio.html.full.bak | 1443 ++ backend/frontend/archive/studio.js.full.bak | 7767 ++++++++++ backend/frontend/auth.py | 1457 ++ backend/frontend/components/README.md | 232 + .../frontend/components/REFACTORING_STATUS.md | 227 + backend/frontend/components/__init__.py | 30 + backend/frontend/components/admin_dsms.py | 1632 ++ backend/frontend/components/admin_email.py | 507 + backend/frontend/components/admin_gpu.py | 640 + .../frontend/components/admin_klausur_docs.py | 629 + backend/frontend/components/admin_panel.py | 24 + .../components/admin_panel/__init__.py | 22 + .../frontend/components/admin_panel/markup.py | 831 + .../components/admin_panel/scripts.py | 1428 ++ .../frontend/components/admin_panel/styles.py | 803 + .../frontend/components/admin_panel_css.py | 1066 ++ .../frontend/components/admin_panel_html.py | 1280 ++ backend/frontend/components/admin_panel_js.py | 1710 +++ backend/frontend/components/admin_stats.py | 214 + backend/frontend/components/auth_modal.py | 1405 ++ backend/frontend/components/base.py | 320 + .../frontend/components/extract_components.py | 191 + backend/frontend/components/legal_modal.py | 514 + backend/frontend/components/local_llm.py | 743 + backend/frontend/customer.py | 54 + backend/frontend/dev_admin.py | 1093 ++ backend/frontend/home.py | 24 + backend/frontend/meetings.py | 16 + backend/frontend/meetings/__init__.py | 105 + backend/frontend/meetings/pages/__init__.py | 27 + backend/frontend/meetings/pages/active.py | 76 + backend/frontend/meetings/pages/breakout.py | 136 + backend/frontend/meetings/pages/dashboard.py | 298 + .../frontend/meetings/pages/meeting_room.py | 266 + .../frontend/meetings/pages/quick_actions.py | 138 + backend/frontend/meetings/pages/recordings.py | 284 + backend/frontend/meetings/pages/schedule.py | 206 + backend/frontend/meetings/pages/trainings.py | 267 + backend/frontend/meetings/styles.py | 918 ++ backend/frontend/meetings/templates.py | 116 + backend/frontend/meetings_styles.py | 955 ++ backend/frontend/meetings_templates.py | 81 + backend/frontend/modules/__init__.py | 55 + backend/frontend/modules/abitur_docs_admin.py | 1170 ++ backend/frontend/modules/alerts.py | 60 + backend/frontend/modules/alerts_css.py | 1420 ++ backend/frontend/modules/alerts_guided.py | 1582 ++ backend/frontend/modules/alerts_html.py | 393 + backend/frontend/modules/alerts_js.py | 1107 ++ backend/frontend/modules/base.py | 926 ++ backend/frontend/modules/companion.py | 770 + backend/frontend/modules/companion_css.py | 2469 +++ backend/frontend/modules/companion_html.py | 630 + backend/frontend/modules/companion_js.py | 2370 +++ backend/frontend/modules/content_creator.py | 1134 ++ backend/frontend/modules/content_feed.py | 1048 ++ backend/frontend/modules/correction.py | 1481 ++ backend/frontend/modules/dashboard.py | 954 ++ backend/frontend/modules/gradebook.py | 933 ++ backend/frontend/modules/hilfe.py | 740 + backend/frontend/modules/jitsi.py | 687 + backend/frontend/modules/klausur_korrektur.py | 113 + backend/frontend/modules/lehrer_dashboard.py | 889 ++ backend/frontend/modules/lehrer_onboarding.py | 654 + backend/frontend/modules/letters.py | 1952 +++ backend/frontend/modules/mac_mini.py | 808 + backend/frontend/modules/mac_mini_control.py | 876 ++ backend/frontend/modules/mail_inbox.py | 1998 +++ backend/frontend/modules/messenger.py | 2148 +++ backend/frontend/modules/rbac_admin.py | 1472 ++ backend/frontend/modules/school.py | 2466 +++ backend/frontend/modules/security.py | 1461 ++ backend/frontend/modules/system_info.py | 966 ++ backend/frontend/modules/unit_creator.py | 2141 +++ backend/frontend/modules/widgets/__init__.py | 50 + .../frontend/modules/widgets/alerts_widget.py | 272 + .../modules/widgets/arbeiten_widget.py | 341 + .../modules/widgets/fehlzeiten_widget.py | 302 + .../modules/widgets/kalender_widget.py | 313 + .../modules/widgets/klassen_widget.py | 263 + .../frontend/modules/widgets/matrix_widget.py | 289 + .../modules/widgets/nachrichten_widget.py | 317 + .../modules/widgets/notizen_widget.py | 182 + .../modules/widgets/schnellzugriff_widget.py | 196 + .../modules/widgets/statistik_widget.py | 311 + .../modules/widgets/stundenplan_widget.py | 323 + .../frontend/modules/widgets/todos_widget.py | 316 + backend/frontend/modules/workflow.py | 914 ++ backend/frontend/modules/worksheets.py | 1918 +++ backend/frontend/paths.py | 7 + backend/frontend/preview.py | 27 + backend/frontend/school.py | 16 + backend/frontend/school/__init__.py | 63 + backend/frontend/school/pages/__init__.py | 18 + backend/frontend/school/pages/attendance.py | 249 + backend/frontend/school/pages/dashboard.py | 183 + backend/frontend/school/pages/grades.py | 341 + .../school/pages/parent_onboarding.py | 180 + backend/frontend/school/pages/timetable.py | 304 + backend/frontend/school/styles.py | 1237 ++ backend/frontend/school/templates.py | 186 + backend/frontend/school_styles.py | 268 + backend/frontend/school_templates.py | 109 + backend/frontend/static/css/customer.css | 815 + .../static/css/modules/admin/content.css | 347 + .../static/css/modules/admin/dsms.css | 183 + .../static/css/modules/admin/learning.css | 323 + .../static/css/modules/admin/modal.css | 132 + .../static/css/modules/admin/preview.css | 202 + .../static/css/modules/admin/sidebar.css | 267 + .../static/css/modules/admin/tables.css | 115 + .../static/css/modules/base/layout.css | 163 + .../static/css/modules/base/variables.css | 69 + .../css/modules/components/auth-modal.css | 261 + .../css/modules/components/communication.css | 155 + .../static/css/modules/components/editor.css | 167 + .../css/modules/components/legal-modal.css | 205 + .../css/modules/components/notifications.css | 315 + .../css/modules/components/suspension.css | 124 + backend/frontend/static/css/studio.css | 52 + .../frontend/static/css/studio_original.css | 3711 +++++ backend/frontend/static/js/customer.js | 632 + backend/frontend/static/js/modules/README.md | 154 + .../frontend/static/js/modules/api-helpers.js | 360 + .../static/js/modules/cloze-module.js | 430 + .../static/js/modules/file-manager.js | 614 + backend/frontend/static/js/modules/i18n.js | 250 + .../js/modules/learning-units-module.js | 517 + .../frontend/static/js/modules/lightbox.js | 234 + .../frontend/static/js/modules/mc-module.js | 474 + .../static/js/modules/mindmap-module.js | 223 + .../static/js/modules/qa-leitner-module.js | 444 + backend/frontend/static/js/modules/theme.js | 105 + .../static/js/modules/translations.js | 971 ++ backend/frontend/static/js/studio.js | 9788 ++++++++++++ backend/frontend/static/manifest.json | 124 + backend/frontend/static/service-worker.js | 343 + backend/frontend/studio.py | 39 + backend/frontend/studio.py.backup | 11703 +++++++++++++++ backend/frontend/studio.py.monolithic.bak | 12524 ++++++++++++++++ backend/frontend/studio_modular.py | 153 + backend/frontend/studio_new.py | 145 + backend/frontend/studio_refactored_demo.py | 184 + backend/frontend/teacher_units.py | 1234 ++ backend/frontend/templates/customer.html | 317 + backend/frontend/templates/studio.html | 2372 +++ backend/frontend/tests/studio-panels.test.js | 849 ++ backend/frontend_app.py.save | 1344 ++ backend/frontend_paths.py.save | 7 + backend/game/__init__.py | 63 + backend/game/database.py | 785 + backend/game/learning_rules.py | 439 + backend/game/quiz_generator.py | 439 + backend/game_api.py | 1129 ++ backend/gdpr_api.py | 363 + backend/gdpr_export_service.py | 460 + backend/generators/__init__.py | 14 + backend/generators/cloze_generator.py | 380 + backend/generators/mc_generator.py | 277 + backend/generators/mindmap_generator.py | 380 + backend/generators/quiz_generator.py | 594 + backend/gpu_test_api.py | 455 + backend/image_cleaner.py | 345 + backend/infra/__init__.py | 10 + backend/infra/vast_client.py | 419 + backend/infra/vast_power.py | 618 + backend/jitsi_api.py | 199 + backend/jitsi_proxy.py | 96 + backend/klausur/__init__.py | 54 + backend/klausur/database.py | 47 + backend/klausur/db_models.py | 377 + backend/klausur/repository.py | 377 + backend/klausur/routes.py | 1970 +++ backend/klausur/services/__init__.py | 28 + .../klausur/services/correction_service.py | 379 + backend/klausur/services/module_linker.py | 630 + .../klausur/services/processing_service.py | 424 + backend/klausur/services/pseudonymizer.py | 376 + backend/klausur/services/roster_parser.py | 502 + backend/klausur/services/school_resolver.py | 613 + backend/klausur/services/storage_service.py | 197 + backend/klausur/services/trocr_client.py | 214 + backend/klausur/services/trocr_service.py | 577 + .../klausur/services/vision_ocr_service.py | 309 + backend/klausur/tests/__init__.py | 9 + .../klausur/tests/test_magic_onboarding.py | 455 + backend/klausur/tests/test_pseudonymizer.py | 209 + backend/klausur/tests/test_repository.py | 248 + backend/klausur/tests/test_routes.py | 346 + backend/klausur_korrektur_api.py | 1859 +++ backend/klausur_service_proxy.py | 135 + backend/learning_units.py | 178 + backend/learning_units_api.py | 197 + backend/letters_api.py | 641 + backend/llm_gateway/__init__.py | 8 + backend/llm_gateway/config.py | 122 + backend/llm_gateway/main.py | 85 + backend/llm_gateway/middleware/__init__.py | 7 + backend/llm_gateway/middleware/auth.py | 96 + backend/llm_gateway/models/__init__.py | 31 + backend/llm_gateway/models/chat.py | 135 + backend/llm_gateway/routes/__init__.py | 21 + backend/llm_gateway/routes/chat.py | 112 + backend/llm_gateway/routes/communication.py | 403 + backend/llm_gateway/routes/comparison.py | 584 + .../llm_gateway/routes/edu_search_seeds.py | 710 + backend/llm_gateway/routes/health.py | 127 + backend/llm_gateway/routes/legal_crawler.py | 173 + backend/llm_gateway/routes/playbooks.py | 96 + backend/llm_gateway/routes/schools.py | 867 ++ backend/llm_gateway/routes/tools.py | 174 + backend/llm_gateway/services/__init__.py | 21 + .../services/communication_service.py | 614 + backend/llm_gateway/services/inference.py | 522 + backend/llm_gateway/services/legal_crawler.py | 290 + backend/llm_gateway/services/pii_detector.py | 249 + .../llm_gateway/services/playbook_service.py | 322 + backend/llm_gateway/services/tool_gateway.py | 285 + backend/llm_test_api.py | 455 + backend/mac_mini_api.py | 574 + backend/mail_test_api.py | 542 + backend/main.py | 148 + backend/main_backup.py | 223 + backend/main_before_d.py | 297 + backend/meeting_consent_api.py | 437 + backend/meeting_minutes_generator.py | 536 + backend/meetings_api.py | 443 + backend/messenger_api.py | 840 ++ backend/middleware/__init__.py | 26 + backend/middleware/input_gate.py | 260 + backend/middleware/pii_redactor.py | 316 + backend/middleware/rate_limiter.py | 363 + backend/middleware/request_id.py | 138 + backend/middleware/security_headers.py | 202 + backend/middleware_admin_api.py | 535 + backend/notification_api.py | 142 + backend/original_service.py | 1041 ++ backend/pyproject.toml | 36 + backend/rag_test_api.py | 506 + backend/rbac_api.py | 819 + backend/rbac_test_api.py | 722 + backend/recording_api.py | 848 ++ backend/requirements-worker.txt | 38 + backend/requirements.txt | 60 + backend/school_api.py | 250 + backend/scripts/import_dsr_templates.py | 369 + backend/scripts/load_initial_seeds.py | 218 + backend/scripts/load_university_seeds.py | 538 + .../scripts/test_compliance_ai_endpoints.py | 314 + backend/scripts/verify_sprint4.sh | 141 + backend/secret_store/__init__.py | 34 + backend/secret_store/vault_client.py | 515 + backend/security_api.py | 995 ++ backend/security_test_api.py | 659 + backend/services/__init__.py | 22 + backend/services/file_processor.py | 563 + backend/services/pdf_service.py | 916 ++ backend/session/__init__.py | 52 + backend/session/cleanup_job.py | 141 + backend/session/protected_routes.py | 389 + backend/session/rbac_middleware.py | 428 + backend/session/session_middleware.py | 240 + backend/session/session_store.py | 550 + backend/state_engine/__init__.py | 43 + backend/state_engine/engine.py | 367 + backend/state_engine/models.py | 317 + backend/state_engine/rules.py | 484 + backend/state_engine_api.py | 583 + backend/system_api.py | 66 + backend/teacher_dashboard_api.py | 951 ++ backend/templates/gdpr/gdpr_export.html | 517 + backend/templates/pdf/certificate.html | 115 + backend/templates/pdf/correction.html | 90 + backend/templates/pdf/letter.html | 73 + backend/test_api_comparison.py | 180 + backend/test_cleaning.py | 109 + backend/test_environment_config.py | 325 + backend/tests/__init__.py | 1 + backend/tests/conftest.py | 129 + backend/tests/test_abitur_docs_api.py | 397 + backend/tests/test_alerts_agent/__init__.py | 1 + backend/tests/test_alerts_agent/conftest.py | 106 + .../test_alerts_agent/test_alert_item.py | 183 + .../test_alerts_agent/test_api_routes.py | 594 + backend/tests/test_alerts_agent/test_dedup.py | 224 + .../test_feedback_learning.py | 262 + .../test_relevance_profile.py | 296 + .../test_relevance_scorer.py | 403 + backend/tests/test_alerts_module.py | 172 + backend/tests/test_alerts_repository.py | 466 + backend/tests/test_alerts_topics_api.py | 435 + backend/tests/test_certificates_api.py | 400 + backend/tests/test_classroom_api.py | 1203 ++ backend/tests/test_comparison.py | 377 + backend/tests/test_compliance_ai.py | 429 + backend/tests/test_compliance_api.py | 618 + .../tests/test_compliance_pdf_extractor.py | 476 + backend/tests/test_compliance_repository.py | 686 + backend/tests/test_consent_client.py | 407 + backend/tests/test_correction_api.py | 543 + backend/tests/test_customer_frontend.py | 213 + backend/tests/test_design_system.py | 253 + backend/tests/test_dsms_webui.py | 376 + backend/tests/test_dsr_api.py | 423 + backend/tests/test_edu_search_seeds.py | 589 + backend/tests/test_email_service.py | 190 + backend/tests/test_frontend_integration.py | 437 + backend/tests/test_gdpr_api.py | 287 + backend/tests/test_gdpr_ui.py | 252 + backend/tests/test_infra/__init__.py | 1 + backend/tests/test_infra/test_vast_client.py | 547 + backend/tests/test_infra/test_vast_power.py | 510 + backend/tests/test_integration/__init__.py | 16 + .../test_integration/test_db_connection.py | 186 + .../test_edu_search_seeds_integration.py | 352 + .../test_integration/test_librechat_tavily.py | 301 + backend/tests/test_jitsi_api.py | 188 + backend/tests/test_keycloak_auth.py | 516 + backend/tests/test_klausur_korrektur_api.py | 346 + backend/tests/test_letters_api.py | 360 + backend/tests/test_llm_gateway/__init__.py | 3 + .../test_communication_service.py | 501 + backend/tests/test_llm_gateway/test_config.py | 175 + .../test_inference_service.py | 195 + .../test_llm_gateway/test_legal_crawler.py | 237 + backend/tests/test_llm_gateway/test_models.py | 204 + .../test_llm_gateway/test_pii_detector.py | 296 + .../test_llm_gateway/test_playbook_service.py | 199 + .../test_llm_gateway/test_tool_gateway.py | 369 + .../test_llm_gateway/test_tools_routes.py | 366 + backend/tests/test_meeting_consent_api.py | 357 + backend/tests/test_meetings_api.py | 547 + backend/tests/test_meetings_frontend.py | 235 + backend/tests/test_messenger_api.py | 932 ++ backend/tests/test_middleware.py | 577 + backend/tests/test_recording_api.py | 294 + backend/tests/test_school_frontend.py | 153 + backend/tests/test_security_api.py | 413 + backend/tests/test_services/__init__.py | 1 + .../tests/test_services/test_pdf_service.py | 482 + backend/tests/test_session_middleware.py | 613 + backend/tests/test_state_engine.py | 463 + backend/tests/test_studio_frontend.py | 254 + backend/tests/test_studio_modular.py | 285 + backend/tests/test_system_api.py | 319 + backend/tests/test_teacher_dashboard_api.py | 508 + backend/tests/test_transcription_worker.py | 325 + backend/tests/test_unit_analytics_api.py | 635 + backend/tests/test_unit_api.py | 1222 ++ backend/tests/test_worksheets_api.py | 528 + backend/tools/frontend_app_backup.py | 1344 ++ backend/tools/migrate_frontend_ui.py | 73 + backend/transcription_worker/__init__.py | 24 + backend/transcription_worker/aligner.py | 202 + backend/transcription_worker/diarizer.py | 197 + backend/transcription_worker/export.py | 291 + backend/transcription_worker/storage.py | 359 + backend/transcription_worker/tasks.py | 230 + backend/transcription_worker/transcriber.py | 211 + backend/transcription_worker/worker.py | 129 + backend/ui_test_api.py | 738 + backend/unit_analytics_api.py | 751 + backend/unit_api.py | 1226 ++ backend/worksheets_api.py | 592 + bpmn-processes/README.md | 171 + bpmn-processes/classroom-lesson.bpmn | 181 + bpmn-processes/consent-document.bpmn | 206 + bpmn-processes/dsr-request.bpmn | 222 + bpmn-processes/klausur-korrektur.bpmn | 215 + .../.changeset/config.json | 13 + .../apps/admin-dashboard/next.config.js | 14 + .../apps/admin-dashboard/package.json | 51 + .../apps/admin-dashboard/postcss.config.js | 6 + .../src/app/compliance/page.tsx | 240 + .../src/app/dsgvo/consent/page.tsx | 209 + .../admin-dashboard/src/app/dsgvo/page.tsx | 188 + .../apps/admin-dashboard/src/app/globals.css | 59 + .../apps/admin-dashboard/src/app/layout.tsx | 25 + .../apps/admin-dashboard/src/app/page.tsx | 265 + .../admin-dashboard/src/app/providers.tsx | 23 + .../apps/admin-dashboard/src/app/rag/page.tsx | 245 + .../admin-dashboard/src/app/security/page.tsx | 318 + .../apps/admin-dashboard/tailwind.config.ts | 57 + .../apps/admin-dashboard/tsconfig.json | 27 + .../apps/embed-demo/index.html | 475 + .../apps/embed-demo/package.json | 13 + .../apps/embed-demo/vite.config.js | 10 + .../hardware/mac-mini/docker-compose.yml | 204 + .../hardware/mac-mini/setup.sh | 159 + .../hardware/mac-studio/docker-compose.yml | 328 + .../hardware/mac-studio/setup.sh | 253 + .../legal-corpus/README.md | 98 + .../legal-corpus/de/tdddg/metadata.json | 53 + .../legal-corpus/eu/ai-act/metadata.json | 110 + .../legal-corpus/eu/dsgvo/metadata.json | 95 + .../legal-corpus/eu/nis2/metadata.json | 102 + breakpilot-compliance-sdk/package.json | 36 + .../packages/cli/package.json | 49 + .../packages/cli/src/cli.ts | 35 + .../packages/cli/src/commands/deploy.ts | 217 + .../packages/cli/src/commands/export.ts | 222 + .../packages/cli/src/commands/init.ts | 221 + .../packages/cli/src/commands/scan.ts | 228 + .../packages/cli/src/commands/status.ts | 161 + .../packages/cli/src/index.ts | 18 + .../packages/cli/tsconfig.json | 11 + .../packages/cli/tsup.config.ts | 18 + .../packages/core/package.json | 52 + .../packages/core/src/auth.ts | 295 + .../packages/core/src/client.ts | 521 + .../packages/core/src/index.ts | 41 + .../packages/core/src/modules/compliance.ts | 246 + .../packages/core/src/modules/dsgvo.ts | 155 + .../packages/core/src/modules/rag.ts | 206 + .../packages/core/src/modules/security.ts | 241 + .../packages/core/src/state.ts | 414 + .../packages/core/src/sync.ts | 435 + .../packages/core/src/utils.ts | 262 + .../packages/core/tsconfig.json | 9 + .../packages/core/tsup.config.ts | 11 + .../packages/react/package.json | 55 + .../packages/react/src/components.ts | 12 + .../src/components/ComplianceDashboard.tsx | 264 + .../react/src/components/ComplianceScore.tsx | 111 + .../react/src/components/ConsentBanner.tsx | 213 + .../react/src/components/DSRPortal.tsx | 240 + .../react/src/components/RiskMatrix.tsx | 193 + .../packages/react/src/hooks.ts | 474 + .../packages/react/src/index.ts | 14 + .../packages/react/src/provider.tsx | 539 + .../packages/react/tsconfig.json | 10 + .../packages/react/tsup.config.ts | 14 + .../packages/types/package.json | 49 + .../packages/types/src/api.ts | 318 + .../packages/types/src/base.ts | 220 + .../packages/types/src/compliance.ts | 396 + .../packages/types/src/dsgvo.ts | 394 + .../packages/types/src/index.ts | 14 + .../packages/types/src/rag.ts | 305 + .../packages/types/src/security.ts | 381 + .../packages/types/src/state.ts | 505 + .../packages/types/tsconfig.json | 9 + .../packages/types/tsup.config.ts | 16 + .../packages/vanilla/package.json | 55 + .../packages/vanilla/src/embed.ts | 611 + .../packages/vanilla/src/index.ts | 29 + .../vanilla/src/web-components/base.ts | 95 + .../src/web-components/compliance-score.ts | 136 + .../src/web-components/consent-banner.ts | 379 + .../vanilla/src/web-components/dsr-portal.ts | 464 + .../vanilla/src/web-components/index.ts | 13 + .../packages/vanilla/tsconfig.json | 11 + .../packages/vanilla/tsup.config.ts | 32 + .../packages/vue/package.json | 53 + .../packages/vue/src/composables/index.ts | 9 + .../vue/src/composables/useCompliance.ts | 140 + .../vue/src/composables/useControls.ts | 258 + .../packages/vue/src/composables/useDSGVO.ts | 246 + .../packages/vue/src/composables/useRAG.ts | 136 + .../vue/src/composables/useSecurity.ts | 185 + .../packages/vue/src/index.ts | 64 + .../packages/vue/src/plugin.ts | 164 + .../packages/vue/tsconfig.json | 11 + .../packages/vue/tsup.config.ts | 12 + breakpilot-compliance-sdk/pnpm-workspace.yaml | 3 + .../services/api-gateway/Dockerfile | 42 + .../services/api-gateway/go.mod | 15 + .../api-gateway/internal/api/compliance.go | 166 + .../api-gateway/internal/api/handlers.go | 363 + .../services/api-gateway/internal/api/rag.go | 115 + .../api-gateway/internal/api/security.go | 206 + .../api-gateway/internal/config/config.go | 93 + .../api-gateway/internal/middleware/auth.go | 117 + .../internal/middleware/middleware.go | 119 + .../services/api-gateway/main.go | 188 + .../services/compliance-engine/Dockerfile | 33 + .../services/compliance-engine/go.mod | 12 + .../internal/api/handlers.go | 329 + .../internal/ucca/builtin.go | 443 + .../compliance-engine/internal/ucca/engine.go | 428 + .../services/compliance-engine/main.go | 103 + .../services/rag-service/Dockerfile | 39 + .../services/rag-service/config.py | 34 + .../services/rag-service/main.py | 245 + .../services/rag-service/rag/__init__.py | 7 + .../services/rag-service/rag/assistant.py | 139 + .../services/rag-service/rag/documents.py | 153 + .../services/rag-service/rag/search.py | 235 + .../services/rag-service/requirements.txt | 31 + .../services/security-scanner/Dockerfile | 45 + .../services/security-scanner/go.mod | 9 + .../security-scanner/internal/api/handlers.go | 216 + .../internal/scanner/manager.go | 409 + .../services/security-scanner/main.go | 96 + breakpilot-compliance-sdk/tsconfig.json | 20 + breakpilot-compliance-sdk/turbo.json | 26 + breakpilot-drive/Dockerfile | 24 + breakpilot-drive/PREFAB_CONFIG.md | 377 + breakpilot-drive/README.md | 213 + breakpilot-drive/TemplateData/.gitkeep | 2 + breakpilot-drive/ThirdPartyNotices.md | 137 + breakpilot-drive/UNITY_SETUP.md | 278 + .../UnityScripts/BreakpilotAPI.cs | 599 + .../UnityScripts/Core/AchievementManager.cs | 348 + .../UnityScripts/Core/AudioManager.cs | 290 + .../UnityScripts/Core/AuthManager.cs | 247 + .../UnityScripts/Core/DifficultyManager.cs | 292 + .../UnityScripts/Core/GameManager.cs | 317 + .../UnityScripts/Player/PlayerController.cs | 373 + .../Plugins/WebGL/AuthPlugin.jslib | 54 + .../UnityScripts/Plugins/WebSpeech.jslib | 98 + breakpilot-drive/UnityScripts/QuizManager.cs | 318 + .../UnityScripts/Track/ObstacleSpawner.cs | 248 + .../UnityScripts/Track/TrackGenerator.cs | 233 + .../UnityScripts/Track/VisualTrigger.cs | 57 + breakpilot-drive/UnityScripts/UI/GameHUD.cs | 144 + breakpilot-drive/UnityScripts/UI/MainMenu.cs | 180 + .../UnityScripts/UI/QuizOverlay.cs | 340 + .../WebGLTemplate/Breakpilot/index.html | 298 + breakpilot-drive/index.html | 239 + breakpilot-drive/nginx.conf | 76 + consent-sdk/.gitignore | 26 + consent-sdk/LICENSE | 190 + consent-sdk/README.md | 243 + consent-sdk/package-lock.json | 5403 +++++++ consent-sdk/package.json | 94 + consent-sdk/src/angular/index.ts | 509 + consent-sdk/src/core/ConsentAPI.test.ts | 312 + consent-sdk/src/core/ConsentAPI.ts | 212 + consent-sdk/src/core/ConsentManager.test.ts | 605 + consent-sdk/src/core/ConsentManager.ts | 525 + consent-sdk/src/core/ConsentStorage.test.ts | 212 + consent-sdk/src/core/ConsentStorage.ts | 203 + consent-sdk/src/core/ScriptBlocker.test.ts | 305 + consent-sdk/src/core/ScriptBlocker.ts | 367 + consent-sdk/src/core/index.ts | 7 + consent-sdk/src/index.ts | 81 + consent-sdk/src/mobile/README.md | 182 + .../src/mobile/android/ConsentManager.kt | 499 + .../src/mobile/flutter/consent_sdk.dart | 658 + .../src/mobile/ios/ConsentManager.swift | 517 + consent-sdk/src/react/index.tsx | 511 + consent-sdk/src/types/index.ts | 438 + consent-sdk/src/utils/EventEmitter.test.ts | 177 + consent-sdk/src/utils/EventEmitter.ts | 89 + consent-sdk/src/utils/fingerprint.test.ts | 230 + consent-sdk/src/utils/fingerprint.ts | 176 + consent-sdk/src/version.ts | 6 + consent-sdk/src/vue/index.ts | 511 + consent-sdk/test-setup.ts | 137 + consent-sdk/tsconfig.json | 27 + consent-sdk/tsup.config.ts | 44 + consent-sdk/vitest.config.ts | 34 + consent-service/.dockerignore | 48 + consent-service/.env.example | 21 + consent-service/Dockerfile | 42 + consent-service/cmd/server/main.go | 471 + consent-service/docker-compose.yml | 41 + consent-service/go.mod | 49 + consent-service/go.sum | 105 + consent-service/internal/config/config.go | 170 + .../internal/config/config_test.go | 322 + consent-service/internal/database/database.go | 1317 ++ .../internal/handlers/auth_handlers.go | 442 + .../internal/handlers/banner_handlers.go | 561 + .../handlers/communication_handlers.go | 511 + .../handlers/communication_handlers_test.go | 407 + .../internal/handlers/deadline_handlers.go | 92 + .../internal/handlers/dsr_handlers.go | 948 ++ .../internal/handlers/dsr_handlers_test.go | 448 + .../handlers/email_template_handlers.go | 528 + consent-service/internal/handlers/handlers.go | 1783 +++ .../internal/handlers/handlers_test.go | 805 + .../handlers/notification_handlers.go | 203 + .../internal/handlers/oauth_handlers.go | 743 + .../internal/handlers/school_handlers.go | 933 ++ .../internal/middleware/input_gate.go | 247 + .../internal/middleware/input_gate_test.go | 421 + .../internal/middleware/middleware.go | 379 + .../internal/middleware/middleware_test.go | 546 + .../internal/middleware/pii_redactor.go | 197 + .../internal/middleware/pii_redactor_test.go | 228 + .../internal/middleware/request_id.go | 75 + .../internal/middleware/request_id_test.go | 152 + .../internal/middleware/security_headers.go | 167 + .../middleware/security_headers_test.go | 377 + consent-service/internal/models/models.go | 1797 +++ .../internal/services/attendance_service.go | 505 + .../services/attendance_service_test.go | 388 + .../internal/services/auth_service.go | 568 + .../internal/services/auth_service_test.go | 367 + .../internal/services/consent_service_test.go | 518 + .../internal/services/deadline_service.go | 434 + .../services/deadline_service_test.go | 439 + .../services/document_service_test.go | 728 + .../internal/services/dsr_service.go | 947 ++ .../internal/services/dsr_service_test.go | 420 + .../internal/services/email_service.go | 554 + .../internal/services/email_service_test.go | 624 + .../services/email_template_service.go | 1673 +++ .../services/email_template_service_test.go | 698 + .../internal/services/grade_service.go | 543 + .../internal/services/grade_service_test.go | 532 + .../internal/services/jitsi/game_meetings.go | 340 + .../internal/services/jitsi/jitsi_service.go | 566 + .../services/jitsi/jitsi_service_test.go | 687 + .../internal/services/matrix/game_rooms.go | 368 + .../services/matrix/matrix_service.go | 548 + .../services/matrix/matrix_service_test.go | 791 + .../internal/services/notification_service.go | 347 + .../services/notification_service_test.go | 660 + .../internal/services/oauth_service.go | 524 + .../internal/services/oauth_service_test.go | 855 ++ .../internal/services/school_service.go | 698 + .../internal/services/school_service_test.go | 424 + .../internal/services/test_helpers.go | 15 + .../internal/services/totp_service.go | 485 + .../internal/services/totp_service_test.go | 378 + .../internal/session/rbac_middleware.go | 403 + .../internal/session/session_middleware.go | 196 + .../internal/session/session_store.go | 463 + .../internal/session/session_test.go | 342 + .../migrations/005_banner_consent_tables.sql | 223 + consent-service/tests/integration_test.go | 739 + docker-compose.content.yml | 135 + docker-compose.dev.yml | 28 + docker-compose.override.yml | 108 + docker-compose.staging.yml | 133 + docker-compose.test.yml | 153 + docker-compose.vault.yml | 98 + docker/jibri/Dockerfile | 39 + docker/jibri/docker-entrypoint.sh | 27 + docker/jibri/finalize.sh | 92 + docker/jibri/start-xvfb.sh | 26 + docs-src/Dockerfile | 58 + docs-src/api/backend-api.md | 263 + docs-src/architecture/auth-system.md | 294 + docs-src/architecture/devsecops.md | 215 + docs-src/architecture/environments.md | 197 + .../architecture/mail-rbac-architecture.md | 215 + docs-src/architecture/multi-agent.md | 286 + docs-src/architecture/secrets-management.md | 251 + docs-src/architecture/system-architecture.md | 311 + docs-src/architecture/zeugnis-system.md | 169 + docs-src/development/ci-cd-pipeline.md | 402 + docs-src/development/documentation.md | 159 + docs-src/development/testing.md | 211 + docs-src/getting-started/environment-setup.md | 258 + docs-src/getting-started/mac-mini-setup.md | 109 + docs-src/index.md | 124 + docs-src/services/agent-core/index.md | 420 + .../ai-compliance-sdk/ARCHITECTURE.md | 947 ++ .../AUDITOR_DOCUMENTATION.md | 387 + .../services/ai-compliance-sdk/DEVELOPER.md | 746 + docs-src/services/ai-compliance-sdk/SBOM.md | 220 + docs-src/services/ai-compliance-sdk/index.md | 97 + .../ki-daten-pipeline/architecture.md | 353 + docs-src/services/ki-daten-pipeline/index.md | 215 + .../klausur-service/BYOEH-Architecture.md | 322 + .../klausur-service/BYOEH-Developer-Guide.md | 481 + .../NiBiS-Ingestion-Pipeline.md | 227 + .../services/klausur-service/OCR-Compare.md | 447 +- .../klausur-service/OCR-Labeling-Spec.md | 445 + .../klausur-service/RAG-Admin-Spec.md | 472 + .../Worksheet-Editor-Architecture.md | 409 + docs-src/services/klausur-service/index.md | 173 + docs-src/services/voice-service/index.md | 160 + docs/ai-content-generator.md | 1010 ++ docs/api/backend-api.md | 1361 ++ docs/architecture/auth-system.md | 294 + docs/architecture/devsecops.md | 314 + docs/architecture/environments.md | 197 + docs/architecture/mail-rbac-architecture.md | 722 + docs/architecture/secrets-management.md | 277 + docs/architecture/system-architecture.md | 399 + docs/architecture/zeugnis-system.md | 411 + docs/ci-cd/TEST-PIPELINE-DEVELOPER-GUIDE.md | 1081 ++ docs/consent-banner/SPECIFICATION.md | 1737 +++ docs/guides/environment-setup.md | 258 + docs/klausur-modul/DEVELOPER_SPECIFICATION.md | 1177 ++ docs/klausur-modul/MAIL-DEVELOPER-GUIDE.md | 463 + .../MAIL-RBAC-DEVELOPER-SPECIFICATION.md | 1834 +++ .../UNIFIED-INBOX-SPECIFICATION.md | 1555 ++ docs/testing/h5p-service-tests.md | 184 + docs/testing/integration-test-environment.md | 333 + .../Anlagen/Baumdigramm.jpg | Bin 0 -> 32682 bytes .../Anlagen/Bild 1 Plattform.jpg | Bin 0 -> 10039 bytes .../Anlagen/Bild 2 statisches System ga.png | Bin 0 -> 5764 bytes .../Anlagen/Bild 2 statisches System.jpg | Bin 0 -> 10289 bytes .../Anlagen/Bild 3 Seilbahn.jpg | Bin 0 -> 8591 bytes .../Anlagen/Boxplot GPS Lsg.jpg | Bin 0 -> 72297 bytes .../Anlagen/Boxplot GPS.jpg | Bin 0 -> 42214 bytes .../Hinweis_Biologie_Leerfassung.txt | 3 + .../Hinweis_Informatik_Leerfassung.txt | 2 + docs/zeugnis-system/README.md | 400 + dsms-gateway/Dockerfile | 32 + dsms-gateway/main.py | 467 + dsms-gateway/requirements.txt | 9 + dsms-gateway/test_main.py | 612 + dsms-node/Dockerfile | 32 + dsms-node/init-dsms.sh | 57 + edu-search-service/Dockerfile | 48 + edu-search-service/README.md | 409 + edu-search-service/cmd/server/main.go | 187 + edu-search-service/docker-compose.yml | 89 + edu-search-service/go.mod | 46 + edu-search-service/go.sum | 165 + .../internal/api/handlers/admin_handlers.go | 406 + .../api/handlers/ai_extraction_handlers.go | 554 + .../api/handlers/audience_handlers.go | 314 + .../api/handlers/audience_handlers_test.go | 630 + .../internal/api/handlers/handlers.go | 146 + .../internal/api/handlers/handlers_test.go | 645 + .../api/handlers/orchestrator_handlers.go | 207 + .../handlers/orchestrator_handlers_test.go | 659 + .../internal/api/handlers/policy_handlers.go | 700 + .../internal/api/handlers/staff_handlers.go | 374 + edu-search-service/internal/config/config.go | 127 + .../internal/crawler/api_client.go | 183 + .../internal/crawler/api_client_test.go | 428 + .../internal/crawler/crawler.go | 364 + .../internal/crawler/crawler_test.go | 639 + .../internal/database/database.go | 133 + .../internal/database/models.go | 205 + .../internal/database/repository.go | 684 + .../internal/embedding/embedding.go | 332 + .../internal/embedding/embedding_test.go | 319 + .../internal/extractor/extractor.go | 464 + .../internal/extractor/extractor_test.go | 802 + .../internal/indexer/mapping.go | 243 + .../internal/orchestrator/audiences.go | 424 + .../internal/orchestrator/orchestrator.go | 407 + .../internal/orchestrator/repository.go | 316 + .../internal/pipeline/pipeline.go | 301 + edu-search-service/internal/policy/audit.go | 255 + .../internal/policy/enforcer.go | 281 + edu-search-service/internal/policy/loader.go | 255 + edu-search-service/internal/policy/models.go | 445 + .../internal/policy/pii_detector.go | 350 + .../internal/policy/policy_test.go | 489 + edu-search-service/internal/policy/store.go | 1168 ++ .../internal/publications/crossref_client.go | 369 + .../internal/publications/pub_crawler.go | 268 + .../internal/publications/pub_crawler_test.go | 188 + .../internal/quality/quality.go | 326 + .../internal/quality/quality_test.go | 333 + edu-search-service/internal/robots/robots.go | 282 + .../internal/robots/robots_test.go | 324 + .../internal/scheduler/scheduler.go | 222 + .../internal/scheduler/scheduler_test.go | 294 + edu-search-service/internal/search/search.go | 592 + .../internal/staff/orchestrator_adapter.go | 217 + edu-search-service/internal/staff/patterns.go | 342 + .../internal/staff/publication_adapter.go | 78 + .../internal/staff/staff_crawler.go | 1402 ++ .../internal/staff/staff_crawler_test.go | 348 + edu-search-service/internal/tagger/tagger.go | 455 + .../internal/tagger/tagger_test.go | 557 + .../policies/bundeslaender.yaml | 347 + edu-search-service/rules/doc_type_rules.yaml | 178 + edu-search-service/rules/level_rules.yaml | 121 + edu-search-service/rules/subject_rules.yaml | 285 + edu-search-service/rules/trust_rules.yaml | 117 + .../scripts/add_german_universities.py | 282 + .../scripts/fix_university_types.py | 125 + .../scripts/seed_universities.py | 147 + .../scripts/vast_ai_extractor.py | 320 + edu-search-service/seeds/de_federal.txt | 25 + edu-search-service/seeds/de_laender.txt | 74 + edu-search-service/seeds/de_portals.txt | 20 + edu-search-service/seeds/denylist.txt | 23 + frontend/creator-studio/package.json | 30 + frontend/creator-studio/vite.config.js | 21 + geo-service/.env.example | 81 + geo-service/Dockerfile | 69 + geo-service/STATUS.md | 196 + geo-service/api/__init__.py | 9 + geo-service/api/aoi.py | 304 + geo-service/api/learning.py | 289 + geo-service/api/terrain.py | 230 + geo-service/api/tiles.py | 221 + geo-service/config.py | 99 + geo-service/main.py | 192 + geo-service/models/__init__.py | 19 + geo-service/models/aoi.py | 162 + geo-service/models/attribution.py | 97 + geo-service/models/learning_node.py | 120 + geo-service/requirements.txt | 45 + geo-service/scripts/download_dem.sh | 198 + geo-service/scripts/download_osm.sh | 113 + geo-service/scripts/generate_tiles.sh | 184 + geo-service/scripts/import_osm.sh | 236 + geo-service/services/__init__.py | 16 + geo-service/services/aoi_packager.py | 420 + geo-service/services/dem_service.py | 338 + geo-service/services/learning_generator.py | 355 + geo-service/services/osm_extractor.py | 217 + geo-service/services/tile_server.py | 186 + geo-service/tests/__init__.py | 3 + geo-service/tests/test_aoi.py | 271 + geo-service/tests/test_learning.py | 194 + geo-service/tests/test_tiles.py | 283 + geo-service/utils/__init__.py | 20 + geo-service/utils/geo_utils.py | 262 + geo-service/utils/license_checker.py | 223 + geo-service/utils/minio_client.py | 237 + gitea/runner-config.yaml | 46 + h5p-service/.gitignore | 34 + h5p-service/Dockerfile | 31 + .../editors/course-presentation-editor.html | 390 + h5p-service/editors/drag-drop-editor.html | 407 + h5p-service/editors/fill-blanks-editor.html | 312 + h5p-service/editors/flashcards-editor.html | 291 + .../editors/interactive-video-editor.html | 441 + h5p-service/editors/memory-editor.html | 330 + h5p-service/editors/quiz-editor.html | 380 + h5p-service/editors/timeline-editor.html | 329 + h5p-service/jest.config.js | 22 + h5p-service/package.json | 24 + .../players/course-presentation-player.html | 358 + h5p-service/players/drag-drop-player.html | 439 + h5p-service/players/fill-blanks-player.html | 314 + .../players/interactive-video-player.html | 425 + h5p-service/players/memory-player.html | 340 + h5p-service/players/quiz-player.html | 380 + h5p-service/players/timeline-player.html | 210 + h5p-service/server-simple.js | 377 + h5p-service/server.js | 438 + h5p-service/setup-h5p.js | 213 + h5p-service/tests/README.md | 170 + h5p-service/tests/server.test.js | 236 + h5p-service/tests/setup.js | 22 + klausur-service/Dockerfile | 42 + klausur-service/backend/admin_api.py | 1012 ++ klausur-service/backend/config.py | 51 + klausur-service/backend/eh_pipeline.py | 420 + klausur-service/backend/eh_templates.py | 658 + klausur-service/backend/embedding_client.py | 314 + .../backend/full_compliance_pipeline.py | 723 + klausur-service/backend/full_reingestion.py | 116 + klausur-service/backend/github_crawler.py | 767 + klausur-service/backend/hybrid_search.py | 285 + .../backend/hybrid_vocab_extractor.py | 664 + klausur-service/backend/hyde.py | 209 + klausur-service/backend/legal_corpus_api.py | 790 + .../backend/legal_corpus_ingestion.py | 937 ++ .../backend/legal_corpus_robust.py | 455 + .../backend/legal_templates_ingestion.py | 942 ++ klausur-service/backend/mail/__init__.py | 106 + klausur-service/backend/mail/aggregator.py | 541 + klausur-service/backend/mail/ai_service.py | 747 + klausur-service/backend/mail/api.py | 651 + klausur-service/backend/mail/credentials.py | 373 + klausur-service/backend/mail/mail_db.py | 987 ++ klausur-service/backend/mail/models.py | 455 + klausur-service/backend/mail/task_service.py | 421 + klausur-service/backend/main.py | 133 + klausur-service/backend/metrics_db.py | 833 + klausur-service/backend/minio_storage.py | 360 + klausur-service/backend/models/__init__.py | 74 + klausur-service/backend/models/eh.py | 197 + klausur-service/backend/models/enums.py | 33 + klausur-service/backend/models/exam.py | 68 + klausur-service/backend/models/grading.py | 71 + klausur-service/backend/models/requests.py | 152 + klausur-service/backend/nibis_ingestion.py | 516 + .../backend/nru_worksheet_generator.py | 557 + klausur-service/backend/ocr_labeling_api.py | 845 ++ klausur-service/backend/pdf_export.py | 677 + klausur-service/backend/pdf_extraction.py | 164 + .../backend/pipeline_checkpoints.py | 276 + .../backend/policies/bundeslaender.json | 753 + klausur-service/backend/pyproject.toml | 25 + klausur-service/backend/qdrant_service.py | 638 + klausur-service/backend/rag_evaluation.py | 393 + klausur-service/backend/rbac.py | 1132 ++ klausur-service/backend/requirements.txt | 35 + klausur-service/backend/reranker.py | 148 + klausur-service/backend/routes/__init__.py | 35 + klausur-service/backend/routes/archiv.py | 490 + klausur-service/backend/routes/eh.py | 1111 ++ klausur-service/backend/routes/exams.py | 109 + klausur-service/backend/routes/fairness.py | 248 + klausur-service/backend/routes/grading.py | 439 + klausur-service/backend/routes/students.py | 131 + klausur-service/backend/self_rag.py | 529 + klausur-service/backend/services/__init__.py | 80 + .../backend/services/auth_service.py | 46 + .../backend/services/donut_ocr_service.py | 254 + .../backend/services/eh_service.py | 97 + .../backend/services/grading_service.py | 43 + .../backend/services/handwriting_detection.py | 359 + .../backend/services/inpainting_service.py | 383 + .../services/layout_reconstruction_service.py | 375 + .../backend/services/trocr_service.py | 586 + klausur-service/backend/storage.py | 61 + klausur-service/backend/template_sources.py | 459 + klausur-service/backend/tests/__init__.py | 1 + klausur-service/backend/tests/conftest.py | 14 + .../backend/tests/test_advanced_rag.py | 769 + klausur-service/backend/tests/test_byoeh.py | 937 ++ .../backend/tests/test_legal_templates.py | 623 + .../backend/tests/test_mail_service.py | 349 + .../backend/tests/test_ocr_labeling.py | 799 + .../backend/tests/test_rag_admin.py | 356 + klausur-service/backend/tests/test_rbac.py | 1236 ++ .../backend/tests/test_vocab_worksheet.py | 623 + .../backend/tests/test_worksheet_editor.py | 539 + klausur-service/backend/training_api.py | 625 + .../backend/training_export_service.py | 448 + klausur-service/backend/upload_api.py | 602 + .../backend/vocab_worksheet_api.py | 2065 +++ .../backend/worksheet_cleanup_api.py | 491 + .../backend/worksheet_editor_api.py | 1305 ++ klausur-service/backend/zeugnis_api.py | 537 + klausur-service/backend/zeugnis_crawler.py | 676 + klausur-service/backend/zeugnis_models.py | 340 + klausur-service/backend/zeugnis_seed_data.py | 415 + klausur-service/docs/BYOEH-Architecture.md | 468 + klausur-service/docs/BYOEH-Developer-Guide.md | 481 + .../docs/DSGVO-Audit-OCR-Labeling.md | 788 + .../docs/NiBiS-Ingestion-Pipeline.md | 227 + klausur-service/docs/OCR-Labeling-Spec.md | 446 + klausur-service/docs/RAG-Admin-Spec.md | 472 + .../docs/Vocab-Worksheet-Architecture.md | 293 + .../docs/Vocab-Worksheet-Developer-Guide.md | 425 + .../docs/Worksheet-Editor-Architecture.md | 410 + .../docs/Worksheet-Editor-Developer-Guide.md | 480 + .../docs/legal_corpus/EPRIVACY.txt | 604 + klausur-service/embedding-service/Dockerfile | 36 + klausur-service/embedding-service/config.py | 86 + klausur-service/embedding-service/main.py | 696 + .../embedding-service/requirements.txt | 23 + klausur-service/frontend/index.html | 13 + klausur-service/frontend/package-lock.json | 1774 +++ klausur-service/frontend/package.json | 23 + klausur-service/frontend/src/App.tsx | 23 + .../src/components/EHUploadWizard.tsx | 591 + .../frontend/src/components/Layout.tsx | 38 + .../src/components/RAGSearchPanel.tsx | 255 + .../frontend/src/hooks/useKlausur.tsx | 175 + klausur-service/frontend/src/main.tsx | 19 + .../frontend/src/pages/KorrekturPage.tsx | 956 ++ .../frontend/src/pages/OnboardingPage.tsx | 247 + klausur-service/frontend/src/services/api.ts | 620 + .../frontend/src/services/encryption.ts | 298 + .../frontend/src/styles/eh-wizard.css | 468 + .../frontend/src/styles/global.css | 924 ++ .../frontend/src/styles/rag-search.css | 407 + klausur-service/frontend/tsconfig.json | 21 + klausur-service/frontend/tsconfig.node.json | 10 + klausur-service/frontend/vite.config.ts | 19 + .../scripts/run_nibis_ingestion.sh | 85 + librechat/docker-compose.yml | 49 + librechat/librechat.yaml | 229 + mkdocs.yml | 100 + nginx/conf.d/default.conf | 434 + package-lock.json | 59 + package.json | 5 + pca-platform/README.md | 243 + pca-platform/ai-access.json | 82 + pca-platform/demo/index.html | 444 + pca-platform/docker-compose.yml | 81 + pca-platform/heuristic-service/Dockerfile | 41 + .../heuristic-service/cmd/server/main.go | 84 + pca-platform/heuristic-service/go.mod | 36 + pca-platform/heuristic-service/go.sum | 89 + .../internal/api/handlers.go | 285 + .../internal/config/config.go | 151 + .../internal/heuristics/scorer.go | 340 + .../internal/heuristics/scorer_test.go | 250 + .../heuristic-service/internal/stepup/pow.go | 180 + .../internal/stepup/pow_test.go | 235 + .../internal/stepup/webauthn.go | 172 + pca-platform/sdk/js/src/pca-sdk.js | 473 + school-service/Dockerfile | 54 + school-service/cmd/seed/main.go | 92 + school-service/cmd/server/main.go | 133 + school-service/go.mod | 54 + school-service/go.sum | 112 + school-service/internal/config/config.go | 103 + school-service/internal/database/database.go | 225 + .../internal/handlers/certificate_handlers.go | 199 + .../internal/handlers/class_handlers.go | 242 + .../internal/handlers/exam_handlers.go | 208 + .../internal/handlers/grade_handlers.go | 216 + .../internal/handlers/gradebook_handlers.go | 209 + school-service/internal/handlers/handlers.go | 66 + .../internal/middleware/middleware.go | 166 + school-service/internal/models/models.go | 329 + school-service/internal/seed/seed_data.go | 591 + .../internal/services/ai_service.go | 218 + .../internal/services/ai_service_test.go | 540 + .../internal/services/certificate_service.go | 251 + .../services/certificate_service_test.go | 563 + .../internal/services/class_service.go | 236 + .../internal/services/class_service_test.go | 439 + .../internal/services/exam_service.go | 248 + .../internal/services/exam_service_test.go | 451 + .../internal/services/grade_service.go | 646 + .../internal/services/grade_service_test.go | 487 + .../internal/services/gradebook_service.go | 261 + .../services/gradebook_service_test.go | 465 + school-service/templates/.gitkeep | 0 .../templates/certificates/generic/.gitkeep | 0 scripts/REFACTORING_STRATEGY.md | 247 + scripts/backup-cron.sh | 17 + scripts/backup.sh | 61 + scripts/daily-backup.sh | 71 + scripts/env-switch.sh | 91 + scripts/integration-tests.sh | 287 + scripts/mac-mini/backup.sh | 13 + scripts/mac-mini/docker.sh | 39 + scripts/mac-mini/start-services.sh | 99 + scripts/mac-mini/status.sh | 40 + scripts/mac-mini/sync.sh | 11 + scripts/pre-commit-check.py | 258 + scripts/promote.sh | 154 + scripts/qwen_refactor_orchestrator.py | 478 + scripts/restore.sh | 94 + scripts/run_full_compliance_update.sh | 91 + scripts/security-scan.sh | 217 + scripts/server-backup.sh | 78 + scripts/setup-branch-protection.sh | 130 + scripts/setup-gitea.sh | 102 + scripts/start-content-services.sh | 102 + scripts/start.sh | 52 + scripts/status.sh | 132 + scripts/stop.sh | 40 + scripts/sync-woodpecker-credentials.sh | 167 + scripts/test-environment-setup.sh | 239 + studio-v2/.dockerignore | 5 + studio-v2/.git-hooks/pre-push | 55 + studio-v2/.git-hooks/setup.sh | 21 + studio-v2/Dockerfile | 49 + studio-v2/app/agb/page.tsx | 112 + studio-v2/app/alerts-b2b/page.tsx | 1019 ++ studio-v2/app/alerts/page.tsx | 466 + studio-v2/app/api/meetings/[...path]/route.ts | 96 + .../app/api/recordings/[...path]/route.ts | 106 + studio-v2/app/api/recordings/route.ts | 34 + studio-v2/app/api/uploads/route.ts | 115 + studio-v2/app/dashboard-experimental/page.tsx | 739 + studio-v2/app/datenschutz/page.tsx | 116 + studio-v2/app/geo-lernwelt/page.tsx | 501 + studio-v2/app/geo-lernwelt/types.ts | 282 + studio-v2/app/globals.css | 37 + studio-v2/app/impressum/page.tsx | 109 + studio-v2/app/kontakt/page.tsx | 165 + .../[klausurId]/[studentId]/page.tsx | 492 + .../korrektur/[klausurId]/fairness/page.tsx | 569 + studio-v2/app/korrektur/[klausurId]/page.tsx | 578 + studio-v2/app/korrektur/archiv/page.tsx | 1001 ++ studio-v2/app/korrektur/page.tsx | 914 ++ studio-v2/app/korrektur/types.ts | 257 + studio-v2/app/layout.tsx | 39 + studio-v2/app/magic-help/layout.tsx | 9 + studio-v2/app/magic-help/page.tsx | 266 + studio-v2/app/meet/page.tsx | 1481 ++ studio-v2/app/messages/page.tsx | 1166 ++ studio-v2/app/page-original.tsx | 934 ++ studio-v2/app/page.tsx | 946 ++ studio-v2/app/upload/[sessionId]/page.tsx | 235 + studio-v2/app/vocab-worksheet/page.tsx | 2047 +++ studio-v2/app/voice-test/page.tsx | 235 + studio-v2/app/worksheet-cleanup/page.tsx | 899 ++ studio-v2/app/worksheet-editor/page.tsx | 484 + studio-v2/app/worksheet-editor/types.ts | 237 + studio-v2/components/AiPrompt.tsx | 261 + studio-v2/components/AlertsWizard.tsx | 552 + studio-v2/components/B2BMigrationWizard.tsx | 848 ++ studio-v2/components/ChatOverlay.tsx | 413 + studio-v2/components/CityMap.tsx | 266 + studio-v2/components/CityMapLeaflet.tsx | 77 + studio-v2/components/DocumentSpace.tsx | 405 + studio-v2/components/DocumentUpload.tsx | 363 + studio-v2/components/Footer.tsx | 90 + studio-v2/components/GermanyMap.tsx | 214 + studio-v2/components/InfoBox.tsx | 212 + studio-v2/components/LanguageDropdown.tsx | 113 + studio-v2/components/Layout.tsx | 193 + studio-v2/components/Logo.tsx | 288 + studio-v2/components/OnboardingWizard.tsx | 513 + studio-v2/components/QRCodeUpload.tsx | 334 + studio-v2/components/SchoolSearch.tsx | 339 + studio-v2/components/Sidebar.tsx | 230 + studio-v2/components/ThemeToggle.tsx | 46 + studio-v2/components/UserMenu.tsx | 163 + .../components/geo-lernwelt/AOISelector.tsx | 438 + .../components/geo-lernwelt/UnityViewer.tsx | 329 + studio-v2/components/geo-lernwelt/index.ts | 7 + .../components/korrektur/AnnotationLayer.tsx | 310 + .../korrektur/AnnotationToolbar.tsx | 159 + .../components/korrektur/CriteriaPanel.tsx | 258 + .../components/korrektur/DocumentViewer.tsx | 221 + .../korrektur/EHSuggestionPanel.tsx | 293 + .../components/korrektur/GutachtenEditor.tsx | 194 + studio-v2/components/korrektur/index.ts | 6 + .../components/spatial-ui/FloatingMessage.tsx | 496 + .../components/spatial-ui/SpatialCard.tsx | 314 + studio-v2/components/spatial-ui/index.ts | 13 + studio-v2/components/voice/VoiceCapture.tsx | 244 + .../components/voice/VoiceCommandBar.tsx | 337 + studio-v2/components/voice/VoiceIndicator.tsx | 90 + studio-v2/components/voice/index.ts | 6 + .../worksheet-editor/AIImageGenerator.tsx | 296 + .../worksheet-editor/AIPromptBar.tsx | 257 + .../worksheet-editor/CanvasControls.tsx | 136 + .../worksheet-editor/CleanupPanel.tsx | 765 + .../worksheet-editor/DocumentImporter.tsx | 363 + .../worksheet-editor/EditorToolbar.tsx | 312 + .../worksheet-editor/ExportPanel.tsx | 216 + .../worksheet-editor/FabricCanvas.tsx | 434 + .../worksheet-editor/PageNavigator.tsx | 126 + .../worksheet-editor/PropertiesPanel.tsx | 443 + .../components/worksheet-editor/index.ts | 15 + studio-v2/dev-server.sh | 14 + studio-v2/e2e/korrektur-archiv.spec.ts | 391 + studio-v2/e2e/korrektur.spec.ts | 230 + studio-v2/lib/ActivityContext.tsx | 361 + studio-v2/lib/AlertsB2BContext.tsx | 1165 ++ studio-v2/lib/AlertsContext.tsx | 334 + studio-v2/lib/LanguageContext.tsx | 90 + studio-v2/lib/MessagesContext.tsx | 925 ++ studio-v2/lib/ThemeContext.tsx | 71 + studio-v2/lib/geo-lernwelt/GeoContext.tsx | 276 + studio-v2/lib/geo-lernwelt/index.ts | 29 + studio-v2/lib/geo-lernwelt/mapStyles.ts | 369 + studio-v2/lib/geo-lernwelt/unityBridge.ts | 359 + studio-v2/lib/i18n.ts | 399 + studio-v2/lib/korrektur/api.ts | 506 + studio-v2/lib/korrektur/index.ts | 1 + studio-v2/lib/spatial-ui/FocusContext.tsx | 188 + .../lib/spatial-ui/PerformanceContext.tsx | 374 + studio-v2/lib/spatial-ui/depth-system.ts | 335 + studio-v2/lib/spatial-ui/index.ts | 16 + studio-v2/lib/voice/index.ts | 13 + studio-v2/lib/voice/voice-api.ts | 400 + studio-v2/lib/voice/voice-encryption.ts | 334 + .../lib/worksheet-editor/WorksheetContext.tsx | 419 + .../lib/worksheet-editor/cleanup-service.ts | 272 + studio-v2/lib/worksheet-editor/index.ts | 10 + studio-v2/next-env.d.ts | 6 + studio-v2/next.config.js | 9 + studio-v2/package-lock.json | 4102 +++++ studio-v2/package.json | 37 + studio-v2/playwright.config.ts | 60 + studio-v2/postcss.config.js | 6 + studio-v2/tailwind.config.js | 40 + studio-v2/tsconfig.json | 41 + test-data/fake-klausur.html | 174 + vault/agent/config.hcl | 44 + vault/agent/split-certs.sh | 28 + vault/agent/templates/all.tpl | 9 + vault/agent/templates/ca-chain.tpl | 4 + vault/agent/templates/cert.tpl | 5 + vault/agent/templates/key.tpl | 4 + vault/init-pki.sh | 188 + vault/init-secrets.sh | 176 + voice-service/.env.example | 59 + voice-service/Dockerfile | 59 + voice-service/api/__init__.py | 12 + voice-service/api/bqas.py | 365 + voice-service/api/sessions.py | 220 + voice-service/api/streaming.py | 325 + voice-service/api/tasks.py | 262 + voice-service/bqas/__init__.py | 49 + voice-service/bqas/backlog_generator.py | 324 + voice-service/bqas/config.py | 77 + voice-service/bqas/judge.py | 271 + voice-service/bqas/metrics.py | 208 + voice-service/bqas/notifier.py | 299 + voice-service/bqas/prompts.py | 323 + voice-service/bqas/quality_judge_agent.py | 380 + voice-service/bqas/rag_judge.py | 618 + voice-service/bqas/regression_tracker.py | 340 + voice-service/bqas/runner.py | 529 + voice-service/bqas/synthetic_generator.py | 301 + voice-service/config.py | 117 + voice-service/main.py | 225 + voice-service/models/__init__.py | 40 + voice-service/models/audit.py | 149 + voice-service/models/session.py | 152 + voice-service/models/task.py | 217 + voice-service/personas/lehrer_persona.json | 127 + voice-service/pyproject.toml | 25 + voice-service/requirements.txt | 43 + .../scripts/com.breakpilot.bqas.plist | 77 + .../scripts/install_bqas_scheduler.sh | 318 + voice-service/scripts/post-commit.hook | 53 + voice-service/scripts/run_bqas.py | 286 + voice-service/scripts/run_bqas.sh | 270 + voice-service/services/__init__.py | 18 + voice-service/services/audio_processor.py | 303 + voice-service/services/encryption_service.py | 231 + .../services/enhanced_task_orchestrator.py | 519 + voice-service/services/fallback_llm_client.py | 248 + voice-service/services/intent_router.py | 368 + voice-service/services/personaplex_client.py | 286 + voice-service/services/task_orchestrator.py | 382 + voice-service/tests/__init__.py | 3 + voice-service/tests/bqas/__init__.py | 4 + voice-service/tests/bqas/conftest.py | 197 + .../tests/bqas/golden_tests/edge_cases.yaml | 150 + .../golden_rag_correction_v1.yaml | 553 + .../tests/bqas/golden_tests/intent_tests.yaml | 183 + .../bqas/golden_tests/workflow_tests.yaml | 161 + voice-service/tests/bqas/test_golden.py | 187 + voice-service/tests/bqas/test_notifier.py | 407 + voice-service/tests/bqas/test_rag.py | 412 + voice-service/tests/bqas/test_regression.py | 207 + voice-service/tests/bqas/test_synthetic.py | 128 + voice-service/tests/conftest.py | 93 + voice-service/tests/test_encryption.py | 111 + voice-service/tests/test_intent_router.py | 185 + voice-service/tests/test_sessions.py | 94 + voice-service/tests/test_tasks.py | 184 + website/.dockerignore | 8 + website/Dockerfile | 55 + website/README.md | 144 + .../compliance/DependencyMap.test.tsx | 311 + .../compliance/ExpiredEvidenceAlert.test.tsx | 173 + .../__tests__/compliance/MyTasksPage.test.tsx | 234 + .../__tests__/compliance/RiskHeatmap.test.tsx | 261 + .../compliance/RoleSelectPage.test.tsx | 164 + website/app/admin/alerts/page.tsx | 1203 ++ website/app/admin/backlog/page.tsx | 1087 ++ website/app/admin/brandbook/page.tsx | 629 + website/app/admin/builds/wizard/page.tsx | 602 + website/app/admin/communication/page.tsx | 612 + .../app/admin/communication/wizard/page.tsx | 362 + website/app/admin/companion/page.tsx | 1057 ++ .../admin/compliance/audit-checklist/page.tsx | 867 ++ .../admin/compliance/audit-workspace/page.tsx | 871 ++ .../app/admin/compliance/controls/page.tsx | 387 + .../app/admin/compliance/evidence/page.tsx | 522 + website/app/admin/compliance/export/page.tsx | 502 + website/app/admin/compliance/modules/page.tsx | 745 + .../app/admin/compliance/my-tasks/page.tsx | 413 + website/app/admin/compliance/page.tsx | 1519 ++ website/app/admin/compliance/risks/page.tsx | 622 + .../app/admin/compliance/role-select/page.tsx | 384 + website/app/admin/compliance/scraper/page.tsx | 789 + website/app/admin/consent/page.tsx | 628 + website/app/admin/consent/wizard/page.tsx | 383 + website/app/admin/content/page.tsx | 806 + website/app/admin/docs/audit/page.tsx | 1262 ++ website/app/admin/docs/page.tsx | 1202 ++ website/app/admin/dsms/page.tsx | 316 + website/app/admin/dsr/page.tsx | 403 + website/app/admin/dsr/wizard/page.tsx | 407 + website/app/admin/edu-search/page.tsx | 958 ++ website/app/admin/game/page.tsx | 480 + website/app/admin/game/wizard/page.tsx | 739 + website/app/admin/gpu/page.tsx | 394 + website/app/admin/gpu/wizard/page.tsx | 365 + .../[klausurId]/[studentId]/page.tsx | 1328 ++ .../[klausurId]/fairness/page.tsx | 497 + .../klausur-korrektur/[klausurId]/page.tsx | 499 + .../components/AnnotationLayer.tsx | 281 + .../components/AnnotationPanel.tsx | 267 + .../components/AnnotationToolbar.tsx | 139 + .../components/EHSuggestionPanel.tsx | 279 + website/app/admin/klausur-korrektur/page.tsx | 1252 ++ website/app/admin/klausur-korrektur/types.ts | 195 + website/app/admin/llm-compare/page.tsx | 761 + website/app/admin/llm-compare/wizard/page.tsx | 342 + website/app/admin/mac-mini/page.tsx | 950 ++ website/app/admin/magic-help/page.tsx | 1017 ++ website/app/admin/mail/page.tsx | 985 ++ website/app/admin/mail/wizard/page.tsx | 393 + website/app/admin/middleware/page.tsx | 696 + .../app/admin/middleware/test-wizard/page.tsx | 617 + website/app/admin/middleware/wizard/page.tsx | 617 + website/app/admin/multiplayer/wizard/page.tsx | 663 + website/app/admin/ocr-labeling/page.tsx | 946 ++ website/app/admin/ocr-labeling/types.ts | 123 + website/app/admin/onboarding/page.tsx | 243 + website/app/admin/page.tsx | 236 + website/app/admin/pca-platform/page.tsx | 482 + website/app/admin/quality/page.tsx | 1231 ++ website/app/admin/rag/README.md | 59 + .../admin/rag/components/CollectionsTab.tsx | 593 + .../app/admin/rag/components/DocumentsTab.tsx | 425 + .../app/admin/rag/components/IngestionTab.tsx | 512 + .../app/admin/rag/components/UploadTab.tsx | 327 + website/app/admin/rag/components/index.ts | 8 + website/app/admin/rag/page.tsx | 1063 ++ website/app/admin/rag/types.ts | 211 + website/app/admin/rag/wizard/page.tsx | 395 + website/app/admin/rbac/wizard/page.tsx | 397 + website/app/admin/sbom/page.tsx | 524 + website/app/admin/sbom/wizard/page.tsx | 556 + website/app/admin/screen-flow/page.tsx | 793 + website/app/admin/security/page.tsx | 402 + website/app/admin/security/wizard/page.tsx | 407 + website/app/admin/staff-search/page.tsx | 581 + website/app/admin/training/page.tsx | 1066 ++ website/app/admin/uni-crawler/page.tsx | 535 + website/app/admin/unity-bridge/page.tsx | 1094 ++ .../app/admin/unity-bridge/wizard/page.tsx | 479 + website/app/admin/voice/page.tsx | 575 + website/app/admin/workflow/page.tsx | 625 + website/app/admin/zeugnisse-crawler/page.tsx | 485 + website/app/api/admin/cicd/route.ts | 143 + .../api/admin/communication/stats/route.ts | 204 + .../consent/documents/[id]/versions/route.ts | 82 + .../app/api/admin/consent/documents/route.ts | 77 + website/app/api/admin/edu-search/route.ts | 354 + website/app/api/admin/gpu/route.ts | 82 + website/app/api/admin/pca/route.ts | 287 + website/app/api/admin/training/route.ts | 215 + website/app/api/admin/uni-crawler/route.ts | 163 + website/app/api/admin/unity-bridge/route.ts | 451 + .../app/api/admin/zeugnisse-crawler/route.ts | 219 + website/app/api/content/route.ts | 67 + website/app/cancel/page.tsx | 67 + website/app/faq/page.tsx | 232 + website/app/globals.css | 55 + website/app/ki-konzept/page.tsx | 484 + website/app/layout.tsx | 34 + website/app/lehrer/abitur-archiv/page.tsx | 504 + .../[klausurId]/[studentId]/page.tsx | 1328 ++ .../[klausurId]/fairness/page.tsx | 497 + .../klausur-korrektur/[klausurId]/page.tsx | 499 + .../components/AnnotationLayer.tsx | 281 + .../components/AnnotationPanel.tsx | 267 + .../components/AnnotationToolbar.tsx | 139 + .../components/EHSuggestionPanel.tsx | 279 + website/app/lehrer/klausur-korrektur/page.tsx | 1249 ++ website/app/lehrer/klausur-korrektur/types.ts | 195 + website/app/lehrer/layout.tsx | 19 + website/app/lehrer/page.tsx | 136 + website/app/mail/page.tsx | 733 + website/app/mail/tasks/page.tsx | 533 + website/app/page.tsx | 17 + website/app/success/page.tsx | 108 + website/app/tools/communication/page.tsx | 527 + website/app/upload/page.tsx | 370 + website/app/zeugnisse/page.tsx | 776 + website/components/ClientProviders.tsx | 15 + website/components/Footer.tsx | 79 + website/components/Header.tsx | 102 + website/components/LandingContent.tsx | 321 + website/components/LanguageSelector.tsx | 89 + website/components/PricingSection.tsx | 292 + website/components/admin/AdminLayout.tsx | 347 + website/components/admin/AiPrompt.tsx | 232 + website/components/admin/CICDStatusWidget.tsx | 311 + website/components/admin/GameView.tsx | 316 + website/components/admin/GermanySchoolMap.tsx | 244 + website/components/admin/LLMModeSwitcher.tsx | 243 + .../components/admin/SystemInfoSection.tsx | 362 + .../system-info-configs/backlog-config.ts | 183 + .../system-info-configs/brandbook-config.ts | 170 + .../communication-config.ts | 134 + .../system-info-configs/consent-config.ts | 151 + .../system-info-configs/content-config.ts | 85 + .../system-info-configs/dashboard-config.ts | 32 + .../admin/system-info-configs/docs-config.ts | 92 + .../admin/system-info-configs/dsms-config.ts | 207 + .../admin/system-info-configs/dsr-config.ts | 180 + .../system-info-configs/edu-search-config.ts | 123 + .../admin/system-info-configs/game-config.ts | 217 + .../admin/system-info-configs/gpu-config.ts | 142 + .../admin/system-info-configs/index.ts | 117 + .../system-info-configs/index_original.ts | 4899 ++++++ .../system-info-configs/llm-compare-config.ts | 164 + .../admin/system-info-configs/mail-config.ts | 157 + .../system-info-configs/middleware-config.ts | 207 + .../system-info-configs/onboarding-config.ts | 194 + .../pca-platform-config.ts | 213 + .../admin/system-info-configs/rag-config.ts | 241 + .../admin/system-info-configs/sbom-config.ts | 176 + .../system-info-configs/security-config.ts | 200 + .../system-info-configs/training-config.ts | 91 + .../admin/system-info-configs/types.ts | 50 + .../unity-bridge-config.ts | 233 + .../system-info-configs/workflow-config.ts | 115 + .../zeugnisse-crawler-config.ts | 96 + .../compliance/ExpiredEvidenceAlert.tsx | 370 + .../components/compliance/GlossaryTooltip.tsx | 165 + .../compliance/LLMProviderToggle.tsx | 252 + .../components/compliance/LanguageSwitch.tsx | 109 + .../charts/ComplianceTrendChart.tsx | 238 + .../compliance/charts/DependencyMap.tsx | 566 + .../compliance/charts/RiskHeatmap.tsx | 680 + website/components/compliance/charts/index.ts | 24 + website/components/lehrer/LehrerLayout.tsx | 189 + .../components/wizard/ArchitectureContext.tsx | 100 + website/components/wizard/EducationCard.tsx | 28 + website/components/wizard/TestResultCard.tsx | 57 + website/components/wizard/TestRunner.tsx | 57 + website/components/wizard/TestSummary.tsx | 65 + website/components/wizard/WizardBanner.tsx | 31 + .../components/wizard/WizardNavigation.tsx | 53 + website/components/wizard/WizardProvider.tsx | 72 + website/components/wizard/WizardStepper.tsx | 43 + website/components/wizard/index.ts | 34 + website/components/wizard/types.ts | 104 + website/content/ki-konzept.json | 343 + website/jest.config.js | 23 + website/lib/LanguageContext.tsx | 70 + website/lib/architecture-data.ts | 267 + website/lib/compliance-i18n.ts | 361 + website/lib/content-types.ts | 60 + website/lib/content.ts | 284 + website/lib/i18n.ts | 628 + website/lib/llm-mode-context.tsx | 211 + website/next-env.d.ts | 6 + website/next.config.mjs | 11 + website/package-lock.json | 6797 +++++++++ website/package.json | 35 + website/postcss.config.mjs | 9 + website/public/germany-states.json | 85 + website/tailwind.config.ts | 47 + website/tests/quality-dashboard.test.ts | 343 + website/tests/structure.test.ts | 317 + website/tests/unity-bridge.test.ts | 932 ++ website/tsconfig.json | 40 + website/types/react-simple-maps.d.ts | 13 + 1986 files changed, 744143 insertions(+), 1731 deletions(-) create mode 100644 .claude/rules/abiturkorrektur.md create mode 100644 .claude/rules/experimental-dashboard.md create mode 100644 .claude/rules/multi-agent-architecture.md create mode 100644 .claude/rules/vocab-worksheet.md create mode 100644 .claude/session-status-2026-01-25.md create mode 100644 .claude/settings.local.json create mode 100755 .docker/build-ci-images.sh create mode 100644 .docker/python-ci.Dockerfile create mode 100644 .env.dev create mode 100644 .env.example create mode 100644 .env.staging create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/security.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitleaks.toml create mode 100644 .pre-commit-config.yaml create mode 100644 .semgrep.yml create mode 100644 .trivy.yaml create mode 100644 .trivyignore create mode 100644 .woodpecker/auto-fix.yml create mode 100644 .woodpecker/build-ci-image.yml create mode 100644 .woodpecker/integration.yml create mode 100644 .woodpecker/main.yml create mode 100644 .woodpecker/security.yml create mode 100644 AI_COMPLIANCE_SDK_IMPLEMENTATION_PLAN.md create mode 100644 BREAKPILOT_CONSENT_MANAGEMENT_PLAN.md create mode 100644 CONTENT_SERVICE_SETUP.md create mode 100644 IMPLEMENTATION_SUMMARY.md create mode 100644 LICENSES/THIRD_PARTY_LICENSES.md create mode 100644 MAC_MINI_SETUP.md create mode 100644 Makefile create mode 100644 POLICY_VAULT_OVERVIEW.md create mode 100644 SOURCE_POLICY_IMPLEMENTATION_PLAN.md create mode 100644 admin-v2/ai-compliance-sdk/Dockerfile create mode 100644 admin-v2/ai-compliance-sdk/cmd/server/main.go create mode 100644 admin-v2/ai-compliance-sdk/configs/config.yaml create mode 100644 admin-v2/ai-compliance-sdk/go.mod create mode 100644 admin-v2/ai-compliance-sdk/internal/api/checkpoint.go create mode 100644 admin-v2/ai-compliance-sdk/internal/api/generate.go create mode 100644 admin-v2/ai-compliance-sdk/internal/api/rag.go create mode 100644 admin-v2/ai-compliance-sdk/internal/api/router.go create mode 100644 admin-v2/ai-compliance-sdk/internal/api/state.go create mode 100644 admin-v2/ai-compliance-sdk/internal/db/postgres.go create mode 100644 admin-v2/ai-compliance-sdk/internal/llm/service.go create mode 100644 admin-v2/ai-compliance-sdk/internal/rag/service.go create mode 100644 admin-v2/app/(admin)/ai/gpu/page.tsx create mode 100644 admin-v2/app/(admin)/ai/magic-help/page.tsx create mode 100644 admin-v2/app/(admin)/ai/ocr-compare/page.tsx create mode 100644 admin-v2/app/(admin)/ai/ocr-labeling/page.tsx create mode 100644 admin-v2/app/(admin)/ai/ocr-labeling/types.ts create mode 100644 admin-v2/app/(admin)/ai/rag-pipeline/page.tsx create mode 100644 admin-v2/app/(admin)/development/content/page.tsx create mode 100644 admin-v2/app/(admin)/development/screen-flow/page.tsx create mode 100644 admin-v2/app/(admin)/education/abitur-archiv/components/AehnlicheDokumente.tsx create mode 100644 admin-v2/app/(admin)/education/abitur-archiv/components/DokumentCard.tsx create mode 100644 admin-v2/app/(admin)/education/abitur-archiv/components/FullscreenViewer.tsx create mode 100644 admin-v2/app/(admin)/education/abitur-archiv/components/ThemenSuche.tsx create mode 100644 admin-v2/app/(admin)/education/abitur-archiv/page.tsx create mode 100644 admin-v2/app/(admin)/education/companion/page.tsx create mode 100644 admin-v2/app/(admin)/education/klausur-korrektur/[klausurId]/[studentId]/page.tsx create mode 100644 admin-v2/app/(admin)/education/klausur-korrektur/[klausurId]/fairness/page.tsx create mode 100644 admin-v2/app/(admin)/education/klausur-korrektur/[klausurId]/page.tsx create mode 100644 admin-v2/app/(admin)/education/klausur-korrektur/components/AnnotationLayer.tsx create mode 100644 admin-v2/app/(admin)/education/klausur-korrektur/components/AnnotationPanel.tsx create mode 100644 admin-v2/app/(admin)/education/klausur-korrektur/components/AnnotationToolbar.tsx create mode 100644 admin-v2/app/(admin)/education/klausur-korrektur/components/EHSuggestionPanel.tsx create mode 100644 admin-v2/app/(admin)/education/klausur-korrektur/components/index.ts create mode 100644 admin-v2/app/(admin)/education/klausur-korrektur/page.tsx create mode 100644 admin-v2/app/(admin)/education/klausur-korrektur/types.ts create mode 100644 admin-v2/app/(admin)/education/zeugnisse-crawler/page.tsx create mode 100644 admin-v2/app/(sdk)/sdk/dsfa/[id]/page.tsx create mode 100644 admin-v2/app/api/admin/companion/feedback/route.ts create mode 100644 admin-v2/app/api/admin/companion/lesson/route.ts create mode 100644 admin-v2/app/api/admin/companion/route.ts create mode 100644 admin-v2/app/api/admin/companion/settings/route.ts create mode 100644 admin-v2/app/api/ai/rag-pipeline/route.ts create mode 100644 admin-v2/app/api/development/content/route.ts create mode 100644 admin-v2/app/api/education/abitur-archiv/route.ts create mode 100644 admin-v2/app/api/education/abitur-archiv/suggest/route.ts create mode 100644 admin-v2/app/api/education/abitur-docs/route.ts create mode 100644 admin-v2/app/api/infrastructure/log-extract/extract/route.ts create mode 100644 admin-v2/app/api/webhooks/woodpecker/route.ts create mode 100644 admin-v2/components/ai/AIModuleSidebar.tsx create mode 100644 admin-v2/components/ai/AIToolsSidebar.tsx create mode 100644 admin-v2/components/ai/BatchUploader.tsx create mode 100644 admin-v2/components/ai/ConfidenceHeatmap.tsx create mode 100644 admin-v2/components/ai/TrainingMetrics.tsx create mode 100644 admin-v2/components/ai/index.ts create mode 100644 admin-v2/components/common/SkeletonText.tsx create mode 100644 admin-v2/components/companion/CompanionDashboard.tsx create mode 100644 admin-v2/components/companion/ModeToggle.tsx create mode 100644 admin-v2/components/companion/companion-mode/EventsCard.tsx create mode 100644 admin-v2/components/companion/companion-mode/PhaseTimeline.tsx create mode 100644 admin-v2/components/companion/companion-mode/StatsGrid.tsx create mode 100644 admin-v2/components/companion/companion-mode/SuggestionList.tsx create mode 100644 admin-v2/components/companion/index.ts create mode 100644 admin-v2/components/companion/lesson-mode/HomeworkSection.tsx create mode 100644 admin-v2/components/companion/lesson-mode/LessonActiveView.tsx create mode 100644 admin-v2/components/companion/lesson-mode/LessonContainer.tsx create mode 100644 admin-v2/components/companion/lesson-mode/LessonEndedView.tsx create mode 100644 admin-v2/components/companion/lesson-mode/LessonStartForm.tsx create mode 100644 admin-v2/components/companion/lesson-mode/QuickActionsBar.tsx create mode 100644 admin-v2/components/companion/lesson-mode/ReflectionSection.tsx create mode 100644 admin-v2/components/companion/lesson-mode/VisualPieTimer.tsx create mode 100644 admin-v2/components/companion/modals/FeedbackModal.tsx create mode 100644 admin-v2/components/companion/modals/OnboardingModal.tsx create mode 100644 admin-v2/components/companion/modals/SettingsModal.tsx create mode 100644 admin-v2/components/dashboard/NightModeWidget.tsx create mode 100644 admin-v2/components/education/DokumenteTab.tsx create mode 100644 admin-v2/components/education/PDFPreviewModal.tsx create mode 100644 admin-v2/components/infrastructure/DevOpsPipelineSidebar.tsx create mode 100644 admin-v2/components/ocr/BlockReviewPanel.tsx create mode 100644 admin-v2/components/ocr/CellCorrectionDialog.tsx create mode 100644 admin-v2/components/ocr/GridOverlay.tsx create mode 100644 admin-v2/components/ocr/__tests__/BlockReviewPanel.test.tsx create mode 100644 admin-v2/components/ocr/__tests__/GridOverlay.test.tsx create mode 100644 admin-v2/components/ocr/index.ts create mode 100644 admin-v2/e2e/fixtures/sdk-fixtures.ts create mode 100644 admin-v2/e2e/specs/command-bar.spec.ts create mode 100644 admin-v2/e2e/specs/dsr.spec.ts create mode 100644 admin-v2/e2e/specs/export.spec.ts create mode 100644 admin-v2/e2e/specs/sdk-navigation.spec.ts create mode 100644 admin-v2/e2e/specs/sdk-workflow.spec.ts create mode 100644 admin-v2/e2e/utils/test-helpers.ts create mode 100644 admin-v2/hooks/companion/index.ts create mode 100644 admin-v2/hooks/companion/useCompanionData.ts create mode 100644 admin-v2/hooks/companion/useKeyboardShortcuts.ts create mode 100644 admin-v2/hooks/companion/useLessonSession.ts create mode 100644 admin-v2/lib/companion/constants.ts create mode 100644 admin-v2/lib/companion/index.ts create mode 100644 admin-v2/lib/companion/types.ts create mode 100644 admin-v2/lib/content-types.ts create mode 100644 admin-v2/lib/content.ts create mode 100644 admin-v2/lib/education/abitur-archiv-types.ts create mode 100644 admin-v2/lib/education/abitur-docs-types.ts create mode 100644 admin-v2/lib/sdk/dsfa/__tests__/api.test.ts create mode 100644 admin-v2/lib/sdk/dsfa/__tests__/types.test.ts create mode 100644 admin-v2/lib/sdk/dsfa/api.ts create mode 100644 admin-v2/lib/sdk/dsfa/index.ts create mode 100644 admin-v2/lib/sdk/dsfa/types.ts create mode 100644 admin-v2/playwright.config.ts create mode 100644 admin-v2/types/ai-modules.ts create mode 100644 admin-v2/types/infrastructure-modules.ts create mode 100644 admin-v2/vitest.config.ts create mode 100644 admin-v2/vitest.setup.ts create mode 100644 agent-core/README.md create mode 100644 agent-core/__init__.py create mode 100644 agent-core/brain/__init__.py create mode 100644 agent-core/brain/context_manager.py create mode 100644 agent-core/brain/knowledge_graph.py create mode 100644 agent-core/brain/memory_store.py create mode 100644 agent-core/orchestrator/__init__.py create mode 100644 agent-core/orchestrator/message_bus.py create mode 100644 agent-core/orchestrator/supervisor.py create mode 100644 agent-core/orchestrator/task_router.py create mode 100644 agent-core/pytest.ini create mode 100644 agent-core/requirements.txt create mode 100644 agent-core/sessions/__init__.py create mode 100644 agent-core/sessions/checkpoint.py create mode 100644 agent-core/sessions/heartbeat.py create mode 100644 agent-core/sessions/session_manager.py create mode 100644 agent-core/soul/alert-agent.soul.md create mode 100644 agent-core/soul/grader-agent.soul.md create mode 100644 agent-core/soul/orchestrator.soul.md create mode 100644 agent-core/soul/quality-judge.soul.md create mode 100644 agent-core/soul/tutor-agent.soul.md create mode 100644 agent-core/tests/__init__.py create mode 100644 agent-core/tests/conftest.py create mode 100644 agent-core/tests/test_heartbeat.py create mode 100644 agent-core/tests/test_memory_store.py create mode 100644 agent-core/tests/test_message_bus.py create mode 100644 agent-core/tests/test_session_manager.py create mode 100644 agent-core/tests/test_task_router.py create mode 100644 ai-compliance-sdk/Dockerfile create mode 100644 ai-compliance-sdk/cmd/server/main.go create mode 100644 ai-compliance-sdk/docs/ARCHITECTURE.md create mode 100644 ai-compliance-sdk/docs/AUDITOR_DOCUMENTATION.md create mode 100644 ai-compliance-sdk/docs/SBOM.md create mode 100644 ai-compliance-sdk/go.mod create mode 100644 ai-compliance-sdk/go.sum create mode 100644 ai-compliance-sdk/internal/api/handlers/audit_handlers.go create mode 100644 ai-compliance-sdk/internal/api/handlers/dsgvo_handlers.go create mode 100644 ai-compliance-sdk/internal/api/handlers/escalation_handlers.go create mode 100644 ai-compliance-sdk/internal/api/handlers/funding_handlers.go create mode 100644 ai-compliance-sdk/internal/api/handlers/llm_handlers.go create mode 100644 ai-compliance-sdk/internal/api/handlers/obligations_handlers.go create mode 100644 ai-compliance-sdk/internal/api/handlers/portfolio_handlers.go create mode 100644 ai-compliance-sdk/internal/api/handlers/rbac_handlers.go create mode 100644 ai-compliance-sdk/internal/api/handlers/roadmap_handlers.go create mode 100644 ai-compliance-sdk/internal/api/handlers/ucca_handlers.go create mode 100644 ai-compliance-sdk/internal/api/handlers/ucca_handlers_test.go create mode 100644 ai-compliance-sdk/internal/api/handlers/workshop_handlers.go create mode 100644 ai-compliance-sdk/internal/audit/exporter.go create mode 100644 ai-compliance-sdk/internal/audit/store.go create mode 100644 ai-compliance-sdk/internal/audit/trail_builder.go create mode 100644 ai-compliance-sdk/internal/config/config.go create mode 100644 ai-compliance-sdk/internal/dsgvo/models.go create mode 100644 ai-compliance-sdk/internal/dsgvo/store.go create mode 100644 ai-compliance-sdk/internal/funding/export.go create mode 100644 ai-compliance-sdk/internal/funding/models.go create mode 100644 ai-compliance-sdk/internal/funding/postgres_store.go create mode 100644 ai-compliance-sdk/internal/funding/store.go create mode 100644 ai-compliance-sdk/internal/llm/access_gate.go create mode 100644 ai-compliance-sdk/internal/llm/anthropic_adapter.go create mode 100644 ai-compliance-sdk/internal/llm/ollama_adapter.go create mode 100644 ai-compliance-sdk/internal/llm/pii_detector.go create mode 100644 ai-compliance-sdk/internal/llm/provider.go create mode 100644 ai-compliance-sdk/internal/portfolio/models.go create mode 100644 ai-compliance-sdk/internal/portfolio/store.go create mode 100644 ai-compliance-sdk/internal/rbac/middleware.go create mode 100644 ai-compliance-sdk/internal/rbac/models.go create mode 100644 ai-compliance-sdk/internal/rbac/policy_engine.go create mode 100644 ai-compliance-sdk/internal/rbac/service.go create mode 100644 ai-compliance-sdk/internal/rbac/store.go create mode 100644 ai-compliance-sdk/internal/roadmap/models.go create mode 100644 ai-compliance-sdk/internal/roadmap/parser.go create mode 100644 ai-compliance-sdk/internal/roadmap/store.go create mode 100644 ai-compliance-sdk/internal/ucca/ai_act_module.go create mode 100644 ai-compliance-sdk/internal/ucca/ai_act_module_test.go create mode 100644 ai-compliance-sdk/internal/ucca/dsgvo_module.go create mode 100644 ai-compliance-sdk/internal/ucca/escalation_models.go create mode 100644 ai-compliance-sdk/internal/ucca/escalation_store.go create mode 100644 ai-compliance-sdk/internal/ucca/escalation_test.go create mode 100644 ai-compliance-sdk/internal/ucca/examples.go create mode 100644 ai-compliance-sdk/internal/ucca/financial_policy.go create mode 100644 ai-compliance-sdk/internal/ucca/financial_policy_test.go create mode 100644 ai-compliance-sdk/internal/ucca/legal_rag.go create mode 100644 ai-compliance-sdk/internal/ucca/license_policy.go create mode 100644 ai-compliance-sdk/internal/ucca/license_policy_test.go create mode 100644 ai-compliance-sdk/internal/ucca/models.go create mode 100644 ai-compliance-sdk/internal/ucca/nis2_module.go create mode 100644 ai-compliance-sdk/internal/ucca/nis2_module_test.go create mode 100644 ai-compliance-sdk/internal/ucca/obligations_framework.go create mode 100644 ai-compliance-sdk/internal/ucca/obligations_registry.go create mode 100644 ai-compliance-sdk/internal/ucca/obligations_store.go create mode 100644 ai-compliance-sdk/internal/ucca/patterns.go create mode 100644 ai-compliance-sdk/internal/ucca/pdf_export.go create mode 100644 ai-compliance-sdk/internal/ucca/pdf_export_test.go create mode 100644 ai-compliance-sdk/internal/ucca/policy_engine.go create mode 100644 ai-compliance-sdk/internal/ucca/policy_engine_test.go create mode 100644 ai-compliance-sdk/internal/ucca/rules.go create mode 100644 ai-compliance-sdk/internal/ucca/store.go create mode 100644 ai-compliance-sdk/internal/ucca/unified_facts.go create mode 100644 ai-compliance-sdk/internal/workshop/models.go create mode 100644 ai-compliance-sdk/internal/workshop/store.go create mode 100644 ai-compliance-sdk/policies/controls_catalog.yaml create mode 100644 ai-compliance-sdk/policies/eprivacy_corpus.yaml create mode 100644 ai-compliance-sdk/policies/financial_regulations_corpus.yaml create mode 100644 ai-compliance-sdk/policies/financial_regulations_policy.yaml create mode 100644 ai-compliance-sdk/policies/funding/bundesland_profiles.yaml create mode 100644 ai-compliance-sdk/policies/funding/foerderantrag_wizard_v1.yaml create mode 100644 ai-compliance-sdk/policies/gap_mapping.yaml create mode 100644 ai-compliance-sdk/policies/licensed_content_policy.yaml create mode 100644 ai-compliance-sdk/policies/obligations/ai_act_obligations.yaml create mode 100644 ai-compliance-sdk/policies/obligations/dsgvo_obligations.yaml create mode 100644 ai-compliance-sdk/policies/obligations/nis2_obligations.yaml create mode 100644 ai-compliance-sdk/policies/scc_legal_corpus.yaml create mode 100644 ai-compliance-sdk/policies/ucca_policy_v1.yaml create mode 100644 ai-compliance-sdk/policies/wizard_schema_v1.yaml create mode 100644 ai-content-generator/.env.example create mode 100644 ai-content-generator/Dockerfile create mode 100644 ai-content-generator/README.md create mode 100644 ai-content-generator/app/__init__.py create mode 100644 ai-content-generator/app/main.py create mode 100644 ai-content-generator/app/models/__init__.py create mode 100644 ai-content-generator/app/models/generation_job.py create mode 100644 ai-content-generator/app/services/__init__.py create mode 100644 ai-content-generator/app/services/claude_service.py create mode 100644 ai-content-generator/app/services/content_generator.py create mode 100644 ai-content-generator/app/services/material_analyzer.py create mode 100644 ai-content-generator/app/services/youtube_service.py create mode 100644 ai-content-generator/app/utils/__init__.py create mode 100644 ai-content-generator/app/utils/job_store.py create mode 100644 ai-content-generator/requirements.txt create mode 100644 backend/.dockerignore create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/Dockerfile.worker create mode 100644 backend/PLAN_KLAUSURKORREKTUR.md create mode 100644 backend/PROJEKT_STRUKTUR.md create mode 100644 backend/abitur_docs_api.py create mode 100644 backend/ai_processing/__init__.py create mode 100644 backend/ai_processing/analysis.py create mode 100644 backend/ai_processing/cloze_generator.py create mode 100644 backend/ai_processing/core.py create mode 100644 backend/ai_processing/html_generator.py create mode 100644 backend/ai_processing/image_processor.py create mode 100644 backend/ai_processing/leitner.py create mode 100644 backend/ai_processing/mc_generator.py create mode 100644 backend/ai_processing/mindmap.py create mode 100644 backend/ai_processing/print_generator.py create mode 100644 backend/ai_processing/qa_generator.py create mode 100644 backend/ai_processor.py create mode 100644 backend/ai_processor/__init__.py create mode 100644 backend/ai_processor/config.py create mode 100644 backend/ai_processor/export/__init__.py create mode 100644 backend/ai_processor/export/print_versions.py create mode 100644 backend/ai_processor/export/worksheet.py create mode 100644 backend/ai_processor/generators/__init__.py create mode 100644 backend/ai_processor/generators/cloze.py create mode 100644 backend/ai_processor/generators/multiple_choice.py create mode 100644 backend/ai_processor/generators/qa.py create mode 100644 backend/ai_processor/utils.py create mode 100644 backend/ai_processor/vision/__init__.py create mode 100644 backend/ai_processor/vision/html_builder.py create mode 100644 backend/ai_processor/vision/scan_analyzer.py create mode 100644 backend/ai_processor/visualization/__init__.py create mode 100644 backend/ai_processor/visualization/mindmap.py create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/__init__.py create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/20260115_1200_001_initial_classroom_tables.py create mode 100644 backend/alembic/versions/20260115_1400_002_lesson_templates.py create mode 100644 backend/alembic/versions/20260115_1600_003_homework_assignments.py create mode 100644 backend/alembic/versions/20260115_1700_004_phase_materials.py create mode 100644 backend/alembic/versions/20260115_1800_005_lesson_reflections.py create mode 100644 backend/alembic/versions/20260115_1900_006_teacher_feedback.py create mode 100644 backend/alembic/versions/20260115_2000_007_teacher_context.py create mode 100644 backend/alembic/versions/20260115_2100_008_alerts_agent_tables.py create mode 100644 backend/alembic/versions/20260202_1000_009_test_registry_tables.py create mode 100644 backend/alembic/versions/__init__.py create mode 100644 backend/alerts_agent/__init__.py create mode 100644 backend/alerts_agent/actions/__init__.py create mode 100644 backend/alerts_agent/actions/base.py create mode 100644 backend/alerts_agent/actions/dispatcher.py create mode 100644 backend/alerts_agent/actions/email_action.py create mode 100644 backend/alerts_agent/actions/slack_action.py create mode 100644 backend/alerts_agent/actions/webhook_action.py create mode 100644 backend/alerts_agent/api/__init__.py create mode 100644 backend/alerts_agent/api/digests.py create mode 100644 backend/alerts_agent/api/routes.py create mode 100644 backend/alerts_agent/api/rules.py create mode 100644 backend/alerts_agent/api/subscriptions.py create mode 100644 backend/alerts_agent/api/templates.py create mode 100644 backend/alerts_agent/api/topics.py create mode 100644 backend/alerts_agent/api/wizard.py create mode 100644 backend/alerts_agent/data/__init__.py create mode 100644 backend/alerts_agent/data/templates.py create mode 100644 backend/alerts_agent/db/__init__.py create mode 100644 backend/alerts_agent/db/database.py create mode 100644 backend/alerts_agent/db/models.py create mode 100644 backend/alerts_agent/db/repository.py create mode 100644 backend/alerts_agent/ingestion/__init__.py create mode 100644 backend/alerts_agent/ingestion/email_parser.py create mode 100644 backend/alerts_agent/ingestion/rss_fetcher.py create mode 100644 backend/alerts_agent/ingestion/scheduler.py create mode 100644 backend/alerts_agent/models/__init__.py create mode 100644 backend/alerts_agent/models/alert_item.py create mode 100644 backend/alerts_agent/models/relevance_profile.py create mode 100644 backend/alerts_agent/processing/__init__.py create mode 100644 backend/alerts_agent/processing/dedup.py create mode 100644 backend/alerts_agent/processing/digest_generator.py create mode 100644 backend/alerts_agent/processing/importance.py create mode 100644 backend/alerts_agent/processing/relevance_scorer.py create mode 100644 backend/alerts_agent/processing/rule_engine.py create mode 100644 backend/api/__init__.py create mode 100644 backend/api/classroom/__init__.py create mode 100644 backend/api/classroom/analytics.py create mode 100644 backend/api/classroom/context.py create mode 100644 backend/api/classroom/feedback.py create mode 100644 backend/api/classroom/homework.py create mode 100644 backend/api/classroom/materials.py create mode 100644 backend/api/classroom/models.py create mode 100644 backend/api/classroom/sessions.py create mode 100644 backend/api/classroom/settings.py create mode 100644 backend/api/classroom/shared.py create mode 100644 backend/api/classroom/templates.py create mode 100644 backend/api/classroom/utility.py create mode 100644 backend/api/tests/__init__.py create mode 100644 backend/api/tests/database.py create mode 100644 backend/api/tests/db_models.py create mode 100644 backend/api/tests/models.py create mode 100644 backend/api/tests/registry.py create mode 100644 backend/api/tests/registry/__init__.py create mode 100644 backend/api/tests/registry/api_models.py create mode 100644 backend/api/tests/registry/config.py create mode 100644 backend/api/tests/registry/discovery/__init__.py create mode 100644 backend/api/tests/registry/discovery/go_discovery.py create mode 100644 backend/api/tests/registry/discovery/python_discovery.py create mode 100644 backend/api/tests/registry/discovery/service_builder.py create mode 100644 backend/api/tests/registry/executors/__init__.py create mode 100644 backend/api/tests/registry/executors/bqas_executor.py create mode 100644 backend/api/tests/registry/executors/container_executor.py create mode 100644 backend/api/tests/registry/executors/go_executor.py create mode 100644 backend/api/tests/registry/executors/jest_executor.py create mode 100644 backend/api/tests/registry/executors/playwright_executor.py create mode 100644 backend/api/tests/registry/executors/python_executor.py create mode 100644 backend/api/tests/registry/executors/test_runner.py create mode 100644 backend/api/tests/registry/routes/__init__.py create mode 100644 backend/api/tests/registry/routes/backlog.py create mode 100644 backend/api/tests/registry/routes/ci.py create mode 100644 backend/api/tests/registry/routes/tests.py create mode 100644 backend/api/tests/registry/services/__init__.py create mode 100644 backend/api/tests/registry/services/error_handling.py create mode 100644 backend/api/tests/repository.py create mode 100644 backend/api/tests/runners/__init__.py create mode 100644 backend/api/tests/runners/bqas_runner.py create mode 100644 backend/api/tests/runners/go_runner.py create mode 100644 backend/api/tests/runners/python_runner.py create mode 100644 backend/auth/__init__.py create mode 100644 backend/auth/keycloak_auth.py create mode 100644 backend/auth_api.py create mode 100644 backend/billing_client.py create mode 100644 backend/camunda_proxy.py create mode 100644 backend/certificates_api.py create mode 100644 backend/classroom/__init__.py create mode 100644 backend/classroom/models.py create mode 100644 backend/classroom/routes/__init__.py create mode 100644 backend/classroom/routes/analytics.py create mode 100644 backend/classroom/routes/context.py create mode 100644 backend/classroom/routes/export.py create mode 100644 backend/classroom/routes/feedback.py create mode 100644 backend/classroom/routes/homework.py create mode 100644 backend/classroom/routes/materials.py create mode 100644 backend/classroom/routes/sessions.py create mode 100644 backend/classroom/routes/settings.py create mode 100644 backend/classroom/routes/templates.py create mode 100644 backend/classroom/routes/websocket_routes.py create mode 100644 backend/classroom/services/__init__.py create mode 100644 backend/classroom/services/persistence.py create mode 100644 backend/classroom/websocket_manager.py create mode 100644 backend/classroom_api.py create mode 100644 backend/classroom_engine/__init__.py create mode 100644 backend/classroom_engine/analytics.py create mode 100644 backend/classroom_engine/antizipation.py create mode 100644 backend/classroom_engine/context_models.py create mode 100644 backend/classroom_engine/database.py create mode 100644 backend/classroom_engine/db_models.py create mode 100644 backend/classroom_engine/fsm.py create mode 100644 backend/classroom_engine/models.py create mode 100644 backend/classroom_engine/repository.py create mode 100644 backend/classroom_engine/suggestions.py create mode 100644 backend/classroom_engine/timer.py create mode 100644 backend/claude_vision.py create mode 100644 backend/communication_test_api.py create mode 100644 backend/compliance/README.md create mode 100644 backend/compliance/README_AI.md create mode 100644 backend/compliance/SERVICE_COVERAGE.md create mode 100644 backend/compliance/SPRINT3_INTEGRATION.md create mode 100644 backend/compliance/SPRINT_4_SUMMARY.md create mode 100644 backend/compliance/__init__.py create mode 100644 backend/compliance/api/__init__.py create mode 100644 backend/compliance/api/ai_routes.py create mode 100644 backend/compliance/api/audit_routes.py create mode 100644 backend/compliance/api/dashboard_routes.py create mode 100644 backend/compliance/api/evidence_routes.py create mode 100644 backend/compliance/api/isms_routes.py create mode 100644 backend/compliance/api/module_routes.py create mode 100644 backend/compliance/api/risk_routes.py create mode 100644 backend/compliance/api/routes.py create mode 100644 backend/compliance/api/routes.py.backup create mode 100644 backend/compliance/api/schemas.py create mode 100644 backend/compliance/api/scraper_routes.py create mode 100644 backend/compliance/data/README.md create mode 100644 backend/compliance/data/__init__.py create mode 100644 backend/compliance/data/controls.py create mode 100644 backend/compliance/data/iso27001_annex_a.py create mode 100644 backend/compliance/data/regulations.py create mode 100644 backend/compliance/data/requirements.py create mode 100644 backend/compliance/data/risks.py create mode 100644 backend/compliance/data/service_modules.py create mode 100644 backend/compliance/db/__init__.py create mode 100644 backend/compliance/db/isms_repository.py create mode 100644 backend/compliance/db/models.py create mode 100644 backend/compliance/db/repository.py create mode 100644 backend/compliance/scripts/__init__.py create mode 100644 backend/compliance/scripts/seed_service_modules.py create mode 100644 backend/compliance/scripts/validate_service_modules.py create mode 100644 backend/compliance/services/__init__.py create mode 100644 backend/compliance/services/ai_compliance_assistant.py create mode 100644 backend/compliance/services/audit_pdf_generator.py create mode 100644 backend/compliance/services/auto_risk_updater.py create mode 100644 backend/compliance/services/export_generator.py create mode 100644 backend/compliance/services/llm_provider.py create mode 100644 backend/compliance/services/pdf_extractor.py create mode 100644 backend/compliance/services/regulation_scraper.py create mode 100644 backend/compliance/services/report_generator.py create mode 100644 backend/compliance/services/seeder.py create mode 100644 backend/compliance/tests/__init__.py create mode 100644 backend/compliance/tests/test_audit_routes.py create mode 100644 backend/compliance/tests/test_auto_risk_updater.py create mode 100644 backend/compliance/tests/test_isms_routes.py create mode 100644 backend/config.py create mode 100644 backend/consent_admin_api.py create mode 100644 backend/consent_api.py create mode 100644 backend/consent_client.py create mode 100644 backend/consent_test_api.py create mode 100644 backend/content_generators/__init__.py create mode 100644 backend/content_generators/h5p_generator.py create mode 100644 backend/content_generators/pdf_generator.py create mode 100644 backend/content_service/Dockerfile create mode 100644 backend/content_service/__init__.py create mode 100644 backend/content_service/database.py create mode 100644 backend/content_service/main.py create mode 100644 backend/content_service/matrix_client.py create mode 100644 backend/content_service/models.py create mode 100644 backend/content_service/requirements.txt create mode 100644 backend/content_service/schemas.py create mode 100644 backend/content_service/storage.py create mode 100644 backend/correction_api.py create mode 100644 backend/data/messenger/templates.json create mode 100644 backend/data/units/bio_eye_lightpath_v1.json create mode 100644 backend/data/units/demo_unit_v1.json create mode 100644 backend/data/units/dup_test_5887b6a9.json create mode 100644 backend/data/units/dup_test_5f5e6e8d.json create mode 100644 backend/data/units/dup_test_73fbc83c.json create mode 100644 backend/data/units/minimal_unit_060e61ec.json create mode 100644 backend/data/units/minimal_unit_556984ab.json create mode 100644 backend/data/units/minimal_unit_810347e8.json create mode 100644 backend/data/units/minimal_unit_8e3cdd85.json create mode 100644 backend/data/units/test_unit_58fb22b1.json create mode 100644 backend/data/units/test_unit_5983ef0c.json create mode 100644 backend/data/units/test_unit_ad7e29f2.json create mode 100644 backend/data/units/test_unit_bdac7076.json create mode 100644 backend/data/units/test_unit_f298350b.json create mode 100644 backend/data/units/update_test_092963f2.json create mode 100644 backend/data/units/update_test_26763076.json create mode 100644 backend/data/units/update_test_8d6d1ee0.json create mode 100644 backend/data/units/update_test_caa8de53.json create mode 100644 backend/deadline_api.py create mode 100644 backend/debug_visualization.py create mode 100644 backend/docs/DSGVO_Datenkategorien.html create mode 100644 backend/docs/DSGVO_Datenkategorien.md create mode 100644 backend/docs/compliance_ai_integration.md create mode 100644 backend/docs/llm-platform/api/vast-ai-api.md create mode 100644 backend/dsr_admin_api.py create mode 100644 backend/dsr_api.py create mode 100644 backend/dsr_test_api.py create mode 100644 backend/email_service.py create mode 100644 backend/email_template_api.py create mode 100644 backend/frontend/__init__.py create mode 100644 backend/frontend/app.py create mode 100644 backend/frontend/archive/studio.css.full.bak create mode 100644 backend/frontend/archive/studio.html.full.bak create mode 100644 backend/frontend/archive/studio.js.full.bak create mode 100644 backend/frontend/auth.py create mode 100644 backend/frontend/components/README.md create mode 100644 backend/frontend/components/REFACTORING_STATUS.md create mode 100644 backend/frontend/components/__init__.py create mode 100644 backend/frontend/components/admin_dsms.py create mode 100644 backend/frontend/components/admin_email.py create mode 100644 backend/frontend/components/admin_gpu.py create mode 100644 backend/frontend/components/admin_klausur_docs.py create mode 100644 backend/frontend/components/admin_panel.py create mode 100644 backend/frontend/components/admin_panel/__init__.py create mode 100644 backend/frontend/components/admin_panel/markup.py create mode 100644 backend/frontend/components/admin_panel/scripts.py create mode 100644 backend/frontend/components/admin_panel/styles.py create mode 100644 backend/frontend/components/admin_panel_css.py create mode 100644 backend/frontend/components/admin_panel_html.py create mode 100644 backend/frontend/components/admin_panel_js.py create mode 100644 backend/frontend/components/admin_stats.py create mode 100644 backend/frontend/components/auth_modal.py create mode 100644 backend/frontend/components/base.py create mode 100644 backend/frontend/components/extract_components.py create mode 100644 backend/frontend/components/legal_modal.py create mode 100644 backend/frontend/components/local_llm.py create mode 100644 backend/frontend/customer.py create mode 100644 backend/frontend/dev_admin.py create mode 100644 backend/frontend/home.py create mode 100644 backend/frontend/meetings.py create mode 100644 backend/frontend/meetings/__init__.py create mode 100644 backend/frontend/meetings/pages/__init__.py create mode 100644 backend/frontend/meetings/pages/active.py create mode 100644 backend/frontend/meetings/pages/breakout.py create mode 100644 backend/frontend/meetings/pages/dashboard.py create mode 100644 backend/frontend/meetings/pages/meeting_room.py create mode 100644 backend/frontend/meetings/pages/quick_actions.py create mode 100644 backend/frontend/meetings/pages/recordings.py create mode 100644 backend/frontend/meetings/pages/schedule.py create mode 100644 backend/frontend/meetings/pages/trainings.py create mode 100644 backend/frontend/meetings/styles.py create mode 100644 backend/frontend/meetings/templates.py create mode 100644 backend/frontend/meetings_styles.py create mode 100644 backend/frontend/meetings_templates.py create mode 100644 backend/frontend/modules/__init__.py create mode 100644 backend/frontend/modules/abitur_docs_admin.py create mode 100644 backend/frontend/modules/alerts.py create mode 100644 backend/frontend/modules/alerts_css.py create mode 100644 backend/frontend/modules/alerts_guided.py create mode 100644 backend/frontend/modules/alerts_html.py create mode 100644 backend/frontend/modules/alerts_js.py create mode 100644 backend/frontend/modules/base.py create mode 100644 backend/frontend/modules/companion.py create mode 100644 backend/frontend/modules/companion_css.py create mode 100644 backend/frontend/modules/companion_html.py create mode 100644 backend/frontend/modules/companion_js.py create mode 100644 backend/frontend/modules/content_creator.py create mode 100644 backend/frontend/modules/content_feed.py create mode 100644 backend/frontend/modules/correction.py create mode 100644 backend/frontend/modules/dashboard.py create mode 100644 backend/frontend/modules/gradebook.py create mode 100644 backend/frontend/modules/hilfe.py create mode 100644 backend/frontend/modules/jitsi.py create mode 100644 backend/frontend/modules/klausur_korrektur.py create mode 100644 backend/frontend/modules/lehrer_dashboard.py create mode 100644 backend/frontend/modules/lehrer_onboarding.py create mode 100644 backend/frontend/modules/letters.py create mode 100644 backend/frontend/modules/mac_mini.py create mode 100644 backend/frontend/modules/mac_mini_control.py create mode 100644 backend/frontend/modules/mail_inbox.py create mode 100644 backend/frontend/modules/messenger.py create mode 100644 backend/frontend/modules/rbac_admin.py create mode 100644 backend/frontend/modules/school.py create mode 100644 backend/frontend/modules/security.py create mode 100644 backend/frontend/modules/system_info.py create mode 100644 backend/frontend/modules/unit_creator.py create mode 100644 backend/frontend/modules/widgets/__init__.py create mode 100644 backend/frontend/modules/widgets/alerts_widget.py create mode 100644 backend/frontend/modules/widgets/arbeiten_widget.py create mode 100644 backend/frontend/modules/widgets/fehlzeiten_widget.py create mode 100644 backend/frontend/modules/widgets/kalender_widget.py create mode 100644 backend/frontend/modules/widgets/klassen_widget.py create mode 100644 backend/frontend/modules/widgets/matrix_widget.py create mode 100644 backend/frontend/modules/widgets/nachrichten_widget.py create mode 100644 backend/frontend/modules/widgets/notizen_widget.py create mode 100644 backend/frontend/modules/widgets/schnellzugriff_widget.py create mode 100644 backend/frontend/modules/widgets/statistik_widget.py create mode 100644 backend/frontend/modules/widgets/stundenplan_widget.py create mode 100644 backend/frontend/modules/widgets/todos_widget.py create mode 100644 backend/frontend/modules/workflow.py create mode 100644 backend/frontend/modules/worksheets.py create mode 100644 backend/frontend/paths.py create mode 100644 backend/frontend/preview.py create mode 100644 backend/frontend/school.py create mode 100644 backend/frontend/school/__init__.py create mode 100644 backend/frontend/school/pages/__init__.py create mode 100644 backend/frontend/school/pages/attendance.py create mode 100644 backend/frontend/school/pages/dashboard.py create mode 100644 backend/frontend/school/pages/grades.py create mode 100644 backend/frontend/school/pages/parent_onboarding.py create mode 100644 backend/frontend/school/pages/timetable.py create mode 100644 backend/frontend/school/styles.py create mode 100644 backend/frontend/school/templates.py create mode 100644 backend/frontend/school_styles.py create mode 100644 backend/frontend/school_templates.py create mode 100644 backend/frontend/static/css/customer.css create mode 100644 backend/frontend/static/css/modules/admin/content.css create mode 100644 backend/frontend/static/css/modules/admin/dsms.css create mode 100644 backend/frontend/static/css/modules/admin/learning.css create mode 100644 backend/frontend/static/css/modules/admin/modal.css create mode 100644 backend/frontend/static/css/modules/admin/preview.css create mode 100644 backend/frontend/static/css/modules/admin/sidebar.css create mode 100644 backend/frontend/static/css/modules/admin/tables.css create mode 100644 backend/frontend/static/css/modules/base/layout.css create mode 100644 backend/frontend/static/css/modules/base/variables.css create mode 100644 backend/frontend/static/css/modules/components/auth-modal.css create mode 100644 backend/frontend/static/css/modules/components/communication.css create mode 100644 backend/frontend/static/css/modules/components/editor.css create mode 100644 backend/frontend/static/css/modules/components/legal-modal.css create mode 100644 backend/frontend/static/css/modules/components/notifications.css create mode 100644 backend/frontend/static/css/modules/components/suspension.css create mode 100644 backend/frontend/static/css/studio.css create mode 100644 backend/frontend/static/css/studio_original.css create mode 100644 backend/frontend/static/js/customer.js create mode 100644 backend/frontend/static/js/modules/README.md create mode 100644 backend/frontend/static/js/modules/api-helpers.js create mode 100644 backend/frontend/static/js/modules/cloze-module.js create mode 100644 backend/frontend/static/js/modules/file-manager.js create mode 100644 backend/frontend/static/js/modules/i18n.js create mode 100644 backend/frontend/static/js/modules/learning-units-module.js create mode 100644 backend/frontend/static/js/modules/lightbox.js create mode 100644 backend/frontend/static/js/modules/mc-module.js create mode 100644 backend/frontend/static/js/modules/mindmap-module.js create mode 100644 backend/frontend/static/js/modules/qa-leitner-module.js create mode 100644 backend/frontend/static/js/modules/theme.js create mode 100644 backend/frontend/static/js/modules/translations.js create mode 100644 backend/frontend/static/js/studio.js create mode 100644 backend/frontend/static/manifest.json create mode 100644 backend/frontend/static/service-worker.js create mode 100644 backend/frontend/studio.py create mode 100644 backend/frontend/studio.py.backup create mode 100644 backend/frontend/studio.py.monolithic.bak create mode 100644 backend/frontend/studio_modular.py create mode 100644 backend/frontend/studio_new.py create mode 100644 backend/frontend/studio_refactored_demo.py create mode 100644 backend/frontend/teacher_units.py create mode 100644 backend/frontend/templates/customer.html create mode 100644 backend/frontend/templates/studio.html create mode 100644 backend/frontend/tests/studio-panels.test.js create mode 100644 backend/frontend_app.py.save create mode 100644 backend/frontend_paths.py.save create mode 100644 backend/game/__init__.py create mode 100644 backend/game/database.py create mode 100644 backend/game/learning_rules.py create mode 100644 backend/game/quiz_generator.py create mode 100644 backend/game_api.py create mode 100644 backend/gdpr_api.py create mode 100644 backend/gdpr_export_service.py create mode 100644 backend/generators/__init__.py create mode 100644 backend/generators/cloze_generator.py create mode 100644 backend/generators/mc_generator.py create mode 100644 backend/generators/mindmap_generator.py create mode 100644 backend/generators/quiz_generator.py create mode 100644 backend/gpu_test_api.py create mode 100644 backend/image_cleaner.py create mode 100644 backend/infra/__init__.py create mode 100644 backend/infra/vast_client.py create mode 100644 backend/infra/vast_power.py create mode 100644 backend/jitsi_api.py create mode 100644 backend/jitsi_proxy.py create mode 100644 backend/klausur/__init__.py create mode 100644 backend/klausur/database.py create mode 100644 backend/klausur/db_models.py create mode 100644 backend/klausur/repository.py create mode 100644 backend/klausur/routes.py create mode 100644 backend/klausur/services/__init__.py create mode 100644 backend/klausur/services/correction_service.py create mode 100644 backend/klausur/services/module_linker.py create mode 100644 backend/klausur/services/processing_service.py create mode 100644 backend/klausur/services/pseudonymizer.py create mode 100644 backend/klausur/services/roster_parser.py create mode 100644 backend/klausur/services/school_resolver.py create mode 100644 backend/klausur/services/storage_service.py create mode 100644 backend/klausur/services/trocr_client.py create mode 100644 backend/klausur/services/trocr_service.py create mode 100644 backend/klausur/services/vision_ocr_service.py create mode 100644 backend/klausur/tests/__init__.py create mode 100644 backend/klausur/tests/test_magic_onboarding.py create mode 100644 backend/klausur/tests/test_pseudonymizer.py create mode 100644 backend/klausur/tests/test_repository.py create mode 100644 backend/klausur/tests/test_routes.py create mode 100644 backend/klausur_korrektur_api.py create mode 100644 backend/klausur_service_proxy.py create mode 100644 backend/learning_units.py create mode 100644 backend/learning_units_api.py create mode 100644 backend/letters_api.py create mode 100644 backend/llm_gateway/__init__.py create mode 100644 backend/llm_gateway/config.py create mode 100644 backend/llm_gateway/main.py create mode 100644 backend/llm_gateway/middleware/__init__.py create mode 100644 backend/llm_gateway/middleware/auth.py create mode 100644 backend/llm_gateway/models/__init__.py create mode 100644 backend/llm_gateway/models/chat.py create mode 100644 backend/llm_gateway/routes/__init__.py create mode 100644 backend/llm_gateway/routes/chat.py create mode 100644 backend/llm_gateway/routes/communication.py create mode 100644 backend/llm_gateway/routes/comparison.py create mode 100644 backend/llm_gateway/routes/edu_search_seeds.py create mode 100644 backend/llm_gateway/routes/health.py create mode 100644 backend/llm_gateway/routes/legal_crawler.py create mode 100644 backend/llm_gateway/routes/playbooks.py create mode 100644 backend/llm_gateway/routes/schools.py create mode 100644 backend/llm_gateway/routes/tools.py create mode 100644 backend/llm_gateway/services/__init__.py create mode 100644 backend/llm_gateway/services/communication_service.py create mode 100644 backend/llm_gateway/services/inference.py create mode 100644 backend/llm_gateway/services/legal_crawler.py create mode 100644 backend/llm_gateway/services/pii_detector.py create mode 100644 backend/llm_gateway/services/playbook_service.py create mode 100644 backend/llm_gateway/services/tool_gateway.py create mode 100644 backend/llm_test_api.py create mode 100644 backend/mac_mini_api.py create mode 100644 backend/mail_test_api.py create mode 100644 backend/main.py create mode 100644 backend/main_backup.py create mode 100644 backend/main_before_d.py create mode 100644 backend/meeting_consent_api.py create mode 100644 backend/meeting_minutes_generator.py create mode 100644 backend/meetings_api.py create mode 100644 backend/messenger_api.py create mode 100644 backend/middleware/__init__.py create mode 100644 backend/middleware/input_gate.py create mode 100644 backend/middleware/pii_redactor.py create mode 100644 backend/middleware/rate_limiter.py create mode 100644 backend/middleware/request_id.py create mode 100644 backend/middleware/security_headers.py create mode 100644 backend/middleware_admin_api.py create mode 100644 backend/notification_api.py create mode 100644 backend/original_service.py create mode 100644 backend/pyproject.toml create mode 100644 backend/rag_test_api.py create mode 100644 backend/rbac_api.py create mode 100644 backend/rbac_test_api.py create mode 100644 backend/recording_api.py create mode 100644 backend/requirements-worker.txt create mode 100644 backend/requirements.txt create mode 100644 backend/school_api.py create mode 100644 backend/scripts/import_dsr_templates.py create mode 100644 backend/scripts/load_initial_seeds.py create mode 100644 backend/scripts/load_university_seeds.py create mode 100755 backend/scripts/test_compliance_ai_endpoints.py create mode 100755 backend/scripts/verify_sprint4.sh create mode 100644 backend/secret_store/__init__.py create mode 100644 backend/secret_store/vault_client.py create mode 100644 backend/security_api.py create mode 100644 backend/security_test_api.py create mode 100644 backend/services/__init__.py create mode 100644 backend/services/file_processor.py create mode 100644 backend/services/pdf_service.py create mode 100644 backend/session/__init__.py create mode 100644 backend/session/cleanup_job.py create mode 100644 backend/session/protected_routes.py create mode 100644 backend/session/rbac_middleware.py create mode 100644 backend/session/session_middleware.py create mode 100644 backend/session/session_store.py create mode 100644 backend/state_engine/__init__.py create mode 100644 backend/state_engine/engine.py create mode 100644 backend/state_engine/models.py create mode 100644 backend/state_engine/rules.py create mode 100644 backend/state_engine_api.py create mode 100644 backend/system_api.py create mode 100644 backend/teacher_dashboard_api.py create mode 100644 backend/templates/gdpr/gdpr_export.html create mode 100644 backend/templates/pdf/certificate.html create mode 100644 backend/templates/pdf/correction.html create mode 100644 backend/templates/pdf/letter.html create mode 100644 backend/test_api_comparison.py create mode 100644 backend/test_cleaning.py create mode 100644 backend/test_environment_config.py create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_abitur_docs_api.py create mode 100644 backend/tests/test_alerts_agent/__init__.py create mode 100644 backend/tests/test_alerts_agent/conftest.py create mode 100644 backend/tests/test_alerts_agent/test_alert_item.py create mode 100644 backend/tests/test_alerts_agent/test_api_routes.py create mode 100644 backend/tests/test_alerts_agent/test_dedup.py create mode 100644 backend/tests/test_alerts_agent/test_feedback_learning.py create mode 100644 backend/tests/test_alerts_agent/test_relevance_profile.py create mode 100644 backend/tests/test_alerts_agent/test_relevance_scorer.py create mode 100644 backend/tests/test_alerts_module.py create mode 100644 backend/tests/test_alerts_repository.py create mode 100644 backend/tests/test_alerts_topics_api.py create mode 100644 backend/tests/test_certificates_api.py create mode 100644 backend/tests/test_classroom_api.py create mode 100644 backend/tests/test_comparison.py create mode 100644 backend/tests/test_compliance_ai.py create mode 100644 backend/tests/test_compliance_api.py create mode 100644 backend/tests/test_compliance_pdf_extractor.py create mode 100644 backend/tests/test_compliance_repository.py create mode 100644 backend/tests/test_consent_client.py create mode 100644 backend/tests/test_correction_api.py create mode 100644 backend/tests/test_customer_frontend.py create mode 100644 backend/tests/test_design_system.py create mode 100644 backend/tests/test_dsms_webui.py create mode 100644 backend/tests/test_dsr_api.py create mode 100644 backend/tests/test_edu_search_seeds.py create mode 100644 backend/tests/test_email_service.py create mode 100644 backend/tests/test_frontend_integration.py create mode 100644 backend/tests/test_gdpr_api.py create mode 100644 backend/tests/test_gdpr_ui.py create mode 100644 backend/tests/test_infra/__init__.py create mode 100644 backend/tests/test_infra/test_vast_client.py create mode 100644 backend/tests/test_infra/test_vast_power.py create mode 100644 backend/tests/test_integration/__init__.py create mode 100644 backend/tests/test_integration/test_db_connection.py create mode 100644 backend/tests/test_integration/test_edu_search_seeds_integration.py create mode 100644 backend/tests/test_integration/test_librechat_tavily.py create mode 100644 backend/tests/test_jitsi_api.py create mode 100644 backend/tests/test_keycloak_auth.py create mode 100644 backend/tests/test_klausur_korrektur_api.py create mode 100644 backend/tests/test_letters_api.py create mode 100644 backend/tests/test_llm_gateway/__init__.py create mode 100644 backend/tests/test_llm_gateway/test_communication_service.py create mode 100644 backend/tests/test_llm_gateway/test_config.py create mode 100644 backend/tests/test_llm_gateway/test_inference_service.py create mode 100644 backend/tests/test_llm_gateway/test_legal_crawler.py create mode 100644 backend/tests/test_llm_gateway/test_models.py create mode 100644 backend/tests/test_llm_gateway/test_pii_detector.py create mode 100644 backend/tests/test_llm_gateway/test_playbook_service.py create mode 100644 backend/tests/test_llm_gateway/test_tool_gateway.py create mode 100644 backend/tests/test_llm_gateway/test_tools_routes.py create mode 100644 backend/tests/test_meeting_consent_api.py create mode 100644 backend/tests/test_meetings_api.py create mode 100644 backend/tests/test_meetings_frontend.py create mode 100644 backend/tests/test_messenger_api.py create mode 100644 backend/tests/test_middleware.py create mode 100644 backend/tests/test_recording_api.py create mode 100644 backend/tests/test_school_frontend.py create mode 100644 backend/tests/test_security_api.py create mode 100644 backend/tests/test_services/__init__.py create mode 100644 backend/tests/test_services/test_pdf_service.py create mode 100644 backend/tests/test_session_middleware.py create mode 100644 backend/tests/test_state_engine.py create mode 100644 backend/tests/test_studio_frontend.py create mode 100644 backend/tests/test_studio_modular.py create mode 100644 backend/tests/test_system_api.py create mode 100644 backend/tests/test_teacher_dashboard_api.py create mode 100644 backend/tests/test_transcription_worker.py create mode 100644 backend/tests/test_unit_analytics_api.py create mode 100644 backend/tests/test_unit_api.py create mode 100644 backend/tests/test_worksheets_api.py create mode 100644 backend/tools/frontend_app_backup.py create mode 100644 backend/tools/migrate_frontend_ui.py create mode 100644 backend/transcription_worker/__init__.py create mode 100644 backend/transcription_worker/aligner.py create mode 100644 backend/transcription_worker/diarizer.py create mode 100644 backend/transcription_worker/export.py create mode 100644 backend/transcription_worker/storage.py create mode 100644 backend/transcription_worker/tasks.py create mode 100644 backend/transcription_worker/transcriber.py create mode 100644 backend/transcription_worker/worker.py create mode 100644 backend/ui_test_api.py create mode 100644 backend/unit_analytics_api.py create mode 100644 backend/unit_api.py create mode 100644 backend/worksheets_api.py create mode 100644 bpmn-processes/README.md create mode 100644 bpmn-processes/classroom-lesson.bpmn create mode 100644 bpmn-processes/consent-document.bpmn create mode 100644 bpmn-processes/dsr-request.bpmn create mode 100644 bpmn-processes/klausur-korrektur.bpmn create mode 100644 breakpilot-compliance-sdk/.changeset/config.json create mode 100644 breakpilot-compliance-sdk/apps/admin-dashboard/next.config.js create mode 100644 breakpilot-compliance-sdk/apps/admin-dashboard/package.json create mode 100644 breakpilot-compliance-sdk/apps/admin-dashboard/postcss.config.js create mode 100644 breakpilot-compliance-sdk/apps/admin-dashboard/src/app/compliance/page.tsx create mode 100644 breakpilot-compliance-sdk/apps/admin-dashboard/src/app/dsgvo/consent/page.tsx create mode 100644 breakpilot-compliance-sdk/apps/admin-dashboard/src/app/dsgvo/page.tsx create mode 100644 breakpilot-compliance-sdk/apps/admin-dashboard/src/app/globals.css create mode 100644 breakpilot-compliance-sdk/apps/admin-dashboard/src/app/layout.tsx create mode 100644 breakpilot-compliance-sdk/apps/admin-dashboard/src/app/page.tsx create mode 100644 breakpilot-compliance-sdk/apps/admin-dashboard/src/app/providers.tsx create mode 100644 breakpilot-compliance-sdk/apps/admin-dashboard/src/app/rag/page.tsx create mode 100644 breakpilot-compliance-sdk/apps/admin-dashboard/src/app/security/page.tsx create mode 100644 breakpilot-compliance-sdk/apps/admin-dashboard/tailwind.config.ts create mode 100644 breakpilot-compliance-sdk/apps/admin-dashboard/tsconfig.json create mode 100644 breakpilot-compliance-sdk/apps/embed-demo/index.html create mode 100644 breakpilot-compliance-sdk/apps/embed-demo/package.json create mode 100644 breakpilot-compliance-sdk/apps/embed-demo/vite.config.js create mode 100644 breakpilot-compliance-sdk/hardware/mac-mini/docker-compose.yml create mode 100644 breakpilot-compliance-sdk/hardware/mac-mini/setup.sh create mode 100644 breakpilot-compliance-sdk/hardware/mac-studio/docker-compose.yml create mode 100644 breakpilot-compliance-sdk/hardware/mac-studio/setup.sh create mode 100644 breakpilot-compliance-sdk/legal-corpus/README.md create mode 100644 breakpilot-compliance-sdk/legal-corpus/de/tdddg/metadata.json create mode 100644 breakpilot-compliance-sdk/legal-corpus/eu/ai-act/metadata.json create mode 100644 breakpilot-compliance-sdk/legal-corpus/eu/dsgvo/metadata.json create mode 100644 breakpilot-compliance-sdk/legal-corpus/eu/nis2/metadata.json create mode 100644 breakpilot-compliance-sdk/package.json create mode 100644 breakpilot-compliance-sdk/packages/cli/package.json create mode 100644 breakpilot-compliance-sdk/packages/cli/src/cli.ts create mode 100644 breakpilot-compliance-sdk/packages/cli/src/commands/deploy.ts create mode 100644 breakpilot-compliance-sdk/packages/cli/src/commands/export.ts create mode 100644 breakpilot-compliance-sdk/packages/cli/src/commands/init.ts create mode 100644 breakpilot-compliance-sdk/packages/cli/src/commands/scan.ts create mode 100644 breakpilot-compliance-sdk/packages/cli/src/commands/status.ts create mode 100644 breakpilot-compliance-sdk/packages/cli/src/index.ts create mode 100644 breakpilot-compliance-sdk/packages/cli/tsconfig.json create mode 100644 breakpilot-compliance-sdk/packages/cli/tsup.config.ts create mode 100644 breakpilot-compliance-sdk/packages/core/package.json create mode 100644 breakpilot-compliance-sdk/packages/core/src/auth.ts create mode 100644 breakpilot-compliance-sdk/packages/core/src/client.ts create mode 100644 breakpilot-compliance-sdk/packages/core/src/index.ts create mode 100644 breakpilot-compliance-sdk/packages/core/src/modules/compliance.ts create mode 100644 breakpilot-compliance-sdk/packages/core/src/modules/dsgvo.ts create mode 100644 breakpilot-compliance-sdk/packages/core/src/modules/rag.ts create mode 100644 breakpilot-compliance-sdk/packages/core/src/modules/security.ts create mode 100644 breakpilot-compliance-sdk/packages/core/src/state.ts create mode 100644 breakpilot-compliance-sdk/packages/core/src/sync.ts create mode 100644 breakpilot-compliance-sdk/packages/core/src/utils.ts create mode 100644 breakpilot-compliance-sdk/packages/core/tsconfig.json create mode 100644 breakpilot-compliance-sdk/packages/core/tsup.config.ts create mode 100644 breakpilot-compliance-sdk/packages/react/package.json create mode 100644 breakpilot-compliance-sdk/packages/react/src/components.ts create mode 100644 breakpilot-compliance-sdk/packages/react/src/components/ComplianceDashboard.tsx create mode 100644 breakpilot-compliance-sdk/packages/react/src/components/ComplianceScore.tsx create mode 100644 breakpilot-compliance-sdk/packages/react/src/components/ConsentBanner.tsx create mode 100644 breakpilot-compliance-sdk/packages/react/src/components/DSRPortal.tsx create mode 100644 breakpilot-compliance-sdk/packages/react/src/components/RiskMatrix.tsx create mode 100644 breakpilot-compliance-sdk/packages/react/src/hooks.ts create mode 100644 breakpilot-compliance-sdk/packages/react/src/index.ts create mode 100644 breakpilot-compliance-sdk/packages/react/src/provider.tsx create mode 100644 breakpilot-compliance-sdk/packages/react/tsconfig.json create mode 100644 breakpilot-compliance-sdk/packages/react/tsup.config.ts create mode 100644 breakpilot-compliance-sdk/packages/types/package.json create mode 100644 breakpilot-compliance-sdk/packages/types/src/api.ts create mode 100644 breakpilot-compliance-sdk/packages/types/src/base.ts create mode 100644 breakpilot-compliance-sdk/packages/types/src/compliance.ts create mode 100644 breakpilot-compliance-sdk/packages/types/src/dsgvo.ts create mode 100644 breakpilot-compliance-sdk/packages/types/src/index.ts create mode 100644 breakpilot-compliance-sdk/packages/types/src/rag.ts create mode 100644 breakpilot-compliance-sdk/packages/types/src/security.ts create mode 100644 breakpilot-compliance-sdk/packages/types/src/state.ts create mode 100644 breakpilot-compliance-sdk/packages/types/tsconfig.json create mode 100644 breakpilot-compliance-sdk/packages/types/tsup.config.ts create mode 100644 breakpilot-compliance-sdk/packages/vanilla/package.json create mode 100644 breakpilot-compliance-sdk/packages/vanilla/src/embed.ts create mode 100644 breakpilot-compliance-sdk/packages/vanilla/src/index.ts create mode 100644 breakpilot-compliance-sdk/packages/vanilla/src/web-components/base.ts create mode 100644 breakpilot-compliance-sdk/packages/vanilla/src/web-components/compliance-score.ts create mode 100644 breakpilot-compliance-sdk/packages/vanilla/src/web-components/consent-banner.ts create mode 100644 breakpilot-compliance-sdk/packages/vanilla/src/web-components/dsr-portal.ts create mode 100644 breakpilot-compliance-sdk/packages/vanilla/src/web-components/index.ts create mode 100644 breakpilot-compliance-sdk/packages/vanilla/tsconfig.json create mode 100644 breakpilot-compliance-sdk/packages/vanilla/tsup.config.ts create mode 100644 breakpilot-compliance-sdk/packages/vue/package.json create mode 100644 breakpilot-compliance-sdk/packages/vue/src/composables/index.ts create mode 100644 breakpilot-compliance-sdk/packages/vue/src/composables/useCompliance.ts create mode 100644 breakpilot-compliance-sdk/packages/vue/src/composables/useControls.ts create mode 100644 breakpilot-compliance-sdk/packages/vue/src/composables/useDSGVO.ts create mode 100644 breakpilot-compliance-sdk/packages/vue/src/composables/useRAG.ts create mode 100644 breakpilot-compliance-sdk/packages/vue/src/composables/useSecurity.ts create mode 100644 breakpilot-compliance-sdk/packages/vue/src/index.ts create mode 100644 breakpilot-compliance-sdk/packages/vue/src/plugin.ts create mode 100644 breakpilot-compliance-sdk/packages/vue/tsconfig.json create mode 100644 breakpilot-compliance-sdk/packages/vue/tsup.config.ts create mode 100644 breakpilot-compliance-sdk/pnpm-workspace.yaml create mode 100644 breakpilot-compliance-sdk/services/api-gateway/Dockerfile create mode 100644 breakpilot-compliance-sdk/services/api-gateway/go.mod create mode 100644 breakpilot-compliance-sdk/services/api-gateway/internal/api/compliance.go create mode 100644 breakpilot-compliance-sdk/services/api-gateway/internal/api/handlers.go create mode 100644 breakpilot-compliance-sdk/services/api-gateway/internal/api/rag.go create mode 100644 breakpilot-compliance-sdk/services/api-gateway/internal/api/security.go create mode 100644 breakpilot-compliance-sdk/services/api-gateway/internal/config/config.go create mode 100644 breakpilot-compliance-sdk/services/api-gateway/internal/middleware/auth.go create mode 100644 breakpilot-compliance-sdk/services/api-gateway/internal/middleware/middleware.go create mode 100644 breakpilot-compliance-sdk/services/api-gateway/main.go create mode 100644 breakpilot-compliance-sdk/services/compliance-engine/Dockerfile create mode 100644 breakpilot-compliance-sdk/services/compliance-engine/go.mod create mode 100644 breakpilot-compliance-sdk/services/compliance-engine/internal/api/handlers.go create mode 100644 breakpilot-compliance-sdk/services/compliance-engine/internal/ucca/builtin.go create mode 100644 breakpilot-compliance-sdk/services/compliance-engine/internal/ucca/engine.go create mode 100644 breakpilot-compliance-sdk/services/compliance-engine/main.go create mode 100644 breakpilot-compliance-sdk/services/rag-service/Dockerfile create mode 100644 breakpilot-compliance-sdk/services/rag-service/config.py create mode 100644 breakpilot-compliance-sdk/services/rag-service/main.py create mode 100644 breakpilot-compliance-sdk/services/rag-service/rag/__init__.py create mode 100644 breakpilot-compliance-sdk/services/rag-service/rag/assistant.py create mode 100644 breakpilot-compliance-sdk/services/rag-service/rag/documents.py create mode 100644 breakpilot-compliance-sdk/services/rag-service/rag/search.py create mode 100644 breakpilot-compliance-sdk/services/rag-service/requirements.txt create mode 100644 breakpilot-compliance-sdk/services/security-scanner/Dockerfile create mode 100644 breakpilot-compliance-sdk/services/security-scanner/go.mod create mode 100644 breakpilot-compliance-sdk/services/security-scanner/internal/api/handlers.go create mode 100644 breakpilot-compliance-sdk/services/security-scanner/internal/scanner/manager.go create mode 100644 breakpilot-compliance-sdk/services/security-scanner/main.go create mode 100644 breakpilot-compliance-sdk/tsconfig.json create mode 100644 breakpilot-compliance-sdk/turbo.json create mode 100644 breakpilot-drive/Dockerfile create mode 100644 breakpilot-drive/PREFAB_CONFIG.md create mode 100644 breakpilot-drive/README.md create mode 100644 breakpilot-drive/TemplateData/.gitkeep create mode 100644 breakpilot-drive/ThirdPartyNotices.md create mode 100644 breakpilot-drive/UNITY_SETUP.md create mode 100644 breakpilot-drive/UnityScripts/BreakpilotAPI.cs create mode 100644 breakpilot-drive/UnityScripts/Core/AchievementManager.cs create mode 100644 breakpilot-drive/UnityScripts/Core/AudioManager.cs create mode 100644 breakpilot-drive/UnityScripts/Core/AuthManager.cs create mode 100644 breakpilot-drive/UnityScripts/Core/DifficultyManager.cs create mode 100644 breakpilot-drive/UnityScripts/Core/GameManager.cs create mode 100644 breakpilot-drive/UnityScripts/Player/PlayerController.cs create mode 100644 breakpilot-drive/UnityScripts/Plugins/WebGL/AuthPlugin.jslib create mode 100644 breakpilot-drive/UnityScripts/Plugins/WebSpeech.jslib create mode 100644 breakpilot-drive/UnityScripts/QuizManager.cs create mode 100644 breakpilot-drive/UnityScripts/Track/ObstacleSpawner.cs create mode 100644 breakpilot-drive/UnityScripts/Track/TrackGenerator.cs create mode 100644 breakpilot-drive/UnityScripts/Track/VisualTrigger.cs create mode 100644 breakpilot-drive/UnityScripts/UI/GameHUD.cs create mode 100644 breakpilot-drive/UnityScripts/UI/MainMenu.cs create mode 100644 breakpilot-drive/UnityScripts/UI/QuizOverlay.cs create mode 100644 breakpilot-drive/UnityScripts/WebGLTemplate/Breakpilot/index.html create mode 100644 breakpilot-drive/index.html create mode 100644 breakpilot-drive/nginx.conf create mode 100644 consent-sdk/.gitignore create mode 100644 consent-sdk/LICENSE create mode 100644 consent-sdk/README.md create mode 100644 consent-sdk/package-lock.json create mode 100644 consent-sdk/package.json create mode 100644 consent-sdk/src/angular/index.ts create mode 100644 consent-sdk/src/core/ConsentAPI.test.ts create mode 100644 consent-sdk/src/core/ConsentAPI.ts create mode 100644 consent-sdk/src/core/ConsentManager.test.ts create mode 100644 consent-sdk/src/core/ConsentManager.ts create mode 100644 consent-sdk/src/core/ConsentStorage.test.ts create mode 100644 consent-sdk/src/core/ConsentStorage.ts create mode 100644 consent-sdk/src/core/ScriptBlocker.test.ts create mode 100644 consent-sdk/src/core/ScriptBlocker.ts create mode 100644 consent-sdk/src/core/index.ts create mode 100644 consent-sdk/src/index.ts create mode 100644 consent-sdk/src/mobile/README.md create mode 100644 consent-sdk/src/mobile/android/ConsentManager.kt create mode 100644 consent-sdk/src/mobile/flutter/consent_sdk.dart create mode 100644 consent-sdk/src/mobile/ios/ConsentManager.swift create mode 100644 consent-sdk/src/react/index.tsx create mode 100644 consent-sdk/src/types/index.ts create mode 100644 consent-sdk/src/utils/EventEmitter.test.ts create mode 100644 consent-sdk/src/utils/EventEmitter.ts create mode 100644 consent-sdk/src/utils/fingerprint.test.ts create mode 100644 consent-sdk/src/utils/fingerprint.ts create mode 100644 consent-sdk/src/version.ts create mode 100644 consent-sdk/src/vue/index.ts create mode 100644 consent-sdk/test-setup.ts create mode 100644 consent-sdk/tsconfig.json create mode 100644 consent-sdk/tsup.config.ts create mode 100644 consent-sdk/vitest.config.ts create mode 100644 consent-service/.dockerignore create mode 100644 consent-service/.env.example create mode 100644 consent-service/Dockerfile create mode 100644 consent-service/cmd/server/main.go create mode 100644 consent-service/docker-compose.yml create mode 100644 consent-service/go.mod create mode 100644 consent-service/go.sum create mode 100644 consent-service/internal/config/config.go create mode 100644 consent-service/internal/config/config_test.go create mode 100644 consent-service/internal/database/database.go create mode 100644 consent-service/internal/handlers/auth_handlers.go create mode 100644 consent-service/internal/handlers/banner_handlers.go create mode 100644 consent-service/internal/handlers/communication_handlers.go create mode 100644 consent-service/internal/handlers/communication_handlers_test.go create mode 100644 consent-service/internal/handlers/deadline_handlers.go create mode 100644 consent-service/internal/handlers/dsr_handlers.go create mode 100644 consent-service/internal/handlers/dsr_handlers_test.go create mode 100644 consent-service/internal/handlers/email_template_handlers.go create mode 100644 consent-service/internal/handlers/handlers.go create mode 100644 consent-service/internal/handlers/handlers_test.go create mode 100644 consent-service/internal/handlers/notification_handlers.go create mode 100644 consent-service/internal/handlers/oauth_handlers.go create mode 100644 consent-service/internal/handlers/school_handlers.go create mode 100644 consent-service/internal/middleware/input_gate.go create mode 100644 consent-service/internal/middleware/input_gate_test.go create mode 100644 consent-service/internal/middleware/middleware.go create mode 100644 consent-service/internal/middleware/middleware_test.go create mode 100644 consent-service/internal/middleware/pii_redactor.go create mode 100644 consent-service/internal/middleware/pii_redactor_test.go create mode 100644 consent-service/internal/middleware/request_id.go create mode 100644 consent-service/internal/middleware/request_id_test.go create mode 100644 consent-service/internal/middleware/security_headers.go create mode 100644 consent-service/internal/middleware/security_headers_test.go create mode 100644 consent-service/internal/models/models.go create mode 100644 consent-service/internal/services/attendance_service.go create mode 100644 consent-service/internal/services/attendance_service_test.go create mode 100644 consent-service/internal/services/auth_service.go create mode 100644 consent-service/internal/services/auth_service_test.go create mode 100644 consent-service/internal/services/consent_service_test.go create mode 100644 consent-service/internal/services/deadline_service.go create mode 100644 consent-service/internal/services/deadline_service_test.go create mode 100644 consent-service/internal/services/document_service_test.go create mode 100644 consent-service/internal/services/dsr_service.go create mode 100644 consent-service/internal/services/dsr_service_test.go create mode 100644 consent-service/internal/services/email_service.go create mode 100644 consent-service/internal/services/email_service_test.go create mode 100644 consent-service/internal/services/email_template_service.go create mode 100644 consent-service/internal/services/email_template_service_test.go create mode 100644 consent-service/internal/services/grade_service.go create mode 100644 consent-service/internal/services/grade_service_test.go create mode 100644 consent-service/internal/services/jitsi/game_meetings.go create mode 100644 consent-service/internal/services/jitsi/jitsi_service.go create mode 100644 consent-service/internal/services/jitsi/jitsi_service_test.go create mode 100644 consent-service/internal/services/matrix/game_rooms.go create mode 100644 consent-service/internal/services/matrix/matrix_service.go create mode 100644 consent-service/internal/services/matrix/matrix_service_test.go create mode 100644 consent-service/internal/services/notification_service.go create mode 100644 consent-service/internal/services/notification_service_test.go create mode 100644 consent-service/internal/services/oauth_service.go create mode 100644 consent-service/internal/services/oauth_service_test.go create mode 100644 consent-service/internal/services/school_service.go create mode 100644 consent-service/internal/services/school_service_test.go create mode 100644 consent-service/internal/services/test_helpers.go create mode 100644 consent-service/internal/services/totp_service.go create mode 100644 consent-service/internal/services/totp_service_test.go create mode 100644 consent-service/internal/session/rbac_middleware.go create mode 100644 consent-service/internal/session/session_middleware.go create mode 100644 consent-service/internal/session/session_store.go create mode 100644 consent-service/internal/session/session_test.go create mode 100644 consent-service/migrations/005_banner_consent_tables.sql create mode 100644 consent-service/tests/integration_test.go create mode 100644 docker-compose.content.yml create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.override.yml create mode 100644 docker-compose.staging.yml create mode 100644 docker-compose.test.yml create mode 100644 docker-compose.vault.yml create mode 100644 docker/jibri/Dockerfile create mode 100644 docker/jibri/docker-entrypoint.sh create mode 100755 docker/jibri/finalize.sh create mode 100644 docker/jibri/start-xvfb.sh create mode 100644 docs-src/Dockerfile create mode 100644 docs-src/api/backend-api.md create mode 100644 docs-src/architecture/auth-system.md create mode 100644 docs-src/architecture/devsecops.md create mode 100644 docs-src/architecture/environments.md create mode 100644 docs-src/architecture/mail-rbac-architecture.md create mode 100644 docs-src/architecture/multi-agent.md create mode 100644 docs-src/architecture/secrets-management.md create mode 100644 docs-src/architecture/system-architecture.md create mode 100644 docs-src/architecture/zeugnis-system.md create mode 100644 docs-src/development/ci-cd-pipeline.md create mode 100644 docs-src/development/documentation.md create mode 100644 docs-src/development/testing.md create mode 100644 docs-src/getting-started/environment-setup.md create mode 100644 docs-src/getting-started/mac-mini-setup.md create mode 100644 docs-src/index.md create mode 100644 docs-src/services/agent-core/index.md create mode 100644 docs-src/services/ai-compliance-sdk/ARCHITECTURE.md create mode 100644 docs-src/services/ai-compliance-sdk/AUDITOR_DOCUMENTATION.md create mode 100644 docs-src/services/ai-compliance-sdk/DEVELOPER.md create mode 100644 docs-src/services/ai-compliance-sdk/SBOM.md create mode 100644 docs-src/services/ai-compliance-sdk/index.md create mode 100644 docs-src/services/ki-daten-pipeline/architecture.md create mode 100644 docs-src/services/ki-daten-pipeline/index.md create mode 100644 docs-src/services/klausur-service/BYOEH-Architecture.md create mode 100644 docs-src/services/klausur-service/BYOEH-Developer-Guide.md create mode 100644 docs-src/services/klausur-service/NiBiS-Ingestion-Pipeline.md create mode 100644 docs-src/services/klausur-service/OCR-Labeling-Spec.md create mode 100644 docs-src/services/klausur-service/RAG-Admin-Spec.md create mode 100644 docs-src/services/klausur-service/Worksheet-Editor-Architecture.md create mode 100644 docs-src/services/klausur-service/index.md create mode 100644 docs-src/services/voice-service/index.md create mode 100644 docs/ai-content-generator.md create mode 100644 docs/api/backend-api.md create mode 100644 docs/architecture/auth-system.md create mode 100644 docs/architecture/devsecops.md create mode 100644 docs/architecture/environments.md create mode 100644 docs/architecture/mail-rbac-architecture.md create mode 100644 docs/architecture/secrets-management.md create mode 100644 docs/architecture/system-architecture.md create mode 100644 docs/architecture/zeugnis-system.md create mode 100644 docs/ci-cd/TEST-PIPELINE-DEVELOPER-GUIDE.md create mode 100644 docs/consent-banner/SPECIFICATION.md create mode 100644 docs/guides/environment-setup.md create mode 100644 docs/klausur-modul/DEVELOPER_SPECIFICATION.md create mode 100644 docs/klausur-modul/MAIL-DEVELOPER-GUIDE.md create mode 100644 docs/klausur-modul/MAIL-RBAC-DEVELOPER-SPECIFICATION.md create mode 100644 docs/klausur-modul/UNIFIED-INBOX-SPECIFICATION.md create mode 100644 docs/testing/h5p-service-tests.md create mode 100644 docs/testing/integration-test-environment.md create mode 100644 docs/za-download-2/2016/2016MatheBG/2016MatheTech/2016MatheTechCAS/2016MatheTechCASEA/Anlagen/Baumdigramm.jpg create mode 100644 docs/za-download-2/2016/2016MatheBG/2016MatheTech/2016MatheTechCAS/2016MatheTechCASEA/Anlagen/Bild 1 Plattform.jpg create mode 100644 docs/za-download-2/2016/2016MatheBG/2016MatheTech/2016MatheTechCAS/2016MatheTechCASEA/Anlagen/Bild 2 statisches System ga.png create mode 100644 docs/za-download-2/2016/2016MatheBG/2016MatheTech/2016MatheTechCAS/2016MatheTechCASEA/Anlagen/Bild 2 statisches System.jpg create mode 100644 docs/za-download-2/2016/2016MatheBG/2016MatheTech/2016MatheTechCAS/2016MatheTechCASEA/Anlagen/Bild 3 Seilbahn.jpg create mode 100644 docs/za-download-2/2016/2016MatheBG/2016MatheTech/2016MatheTechCAS/2016MatheTechCASEA/Anlagen/Boxplot GPS Lsg.jpg create mode 100644 docs/za-download-2/2016/2016MatheBG/2016MatheTech/2016MatheTechCAS/2016MatheTechCASEA/Anlagen/Boxplot GPS.jpg create mode 100644 docs/za-download/2024/2024Biologie/2024_Biologie/Hinweis_Biologie_Leerfassung.txt create mode 100644 docs/za-download/2024/2024Informatik/2024_Informatik/Hinweis_Informatik_Leerfassung.txt create mode 100644 docs/zeugnis-system/README.md create mode 100644 dsms-gateway/Dockerfile create mode 100644 dsms-gateway/main.py create mode 100644 dsms-gateway/requirements.txt create mode 100644 dsms-gateway/test_main.py create mode 100644 dsms-node/Dockerfile create mode 100644 dsms-node/init-dsms.sh create mode 100644 edu-search-service/Dockerfile create mode 100644 edu-search-service/README.md create mode 100644 edu-search-service/cmd/server/main.go create mode 100644 edu-search-service/docker-compose.yml create mode 100644 edu-search-service/go.mod create mode 100644 edu-search-service/go.sum create mode 100644 edu-search-service/internal/api/handlers/admin_handlers.go create mode 100644 edu-search-service/internal/api/handlers/ai_extraction_handlers.go create mode 100644 edu-search-service/internal/api/handlers/audience_handlers.go create mode 100644 edu-search-service/internal/api/handlers/audience_handlers_test.go create mode 100644 edu-search-service/internal/api/handlers/handlers.go create mode 100644 edu-search-service/internal/api/handlers/handlers_test.go create mode 100644 edu-search-service/internal/api/handlers/orchestrator_handlers.go create mode 100644 edu-search-service/internal/api/handlers/orchestrator_handlers_test.go create mode 100644 edu-search-service/internal/api/handlers/policy_handlers.go create mode 100644 edu-search-service/internal/api/handlers/staff_handlers.go create mode 100644 edu-search-service/internal/config/config.go create mode 100644 edu-search-service/internal/crawler/api_client.go create mode 100644 edu-search-service/internal/crawler/api_client_test.go create mode 100644 edu-search-service/internal/crawler/crawler.go create mode 100644 edu-search-service/internal/crawler/crawler_test.go create mode 100644 edu-search-service/internal/database/database.go create mode 100644 edu-search-service/internal/database/models.go create mode 100644 edu-search-service/internal/database/repository.go create mode 100644 edu-search-service/internal/embedding/embedding.go create mode 100644 edu-search-service/internal/embedding/embedding_test.go create mode 100644 edu-search-service/internal/extractor/extractor.go create mode 100644 edu-search-service/internal/extractor/extractor_test.go create mode 100644 edu-search-service/internal/indexer/mapping.go create mode 100644 edu-search-service/internal/orchestrator/audiences.go create mode 100644 edu-search-service/internal/orchestrator/orchestrator.go create mode 100644 edu-search-service/internal/orchestrator/repository.go create mode 100644 edu-search-service/internal/pipeline/pipeline.go create mode 100644 edu-search-service/internal/policy/audit.go create mode 100644 edu-search-service/internal/policy/enforcer.go create mode 100644 edu-search-service/internal/policy/loader.go create mode 100644 edu-search-service/internal/policy/models.go create mode 100644 edu-search-service/internal/policy/pii_detector.go create mode 100644 edu-search-service/internal/policy/policy_test.go create mode 100644 edu-search-service/internal/policy/store.go create mode 100644 edu-search-service/internal/publications/crossref_client.go create mode 100644 edu-search-service/internal/publications/pub_crawler.go create mode 100644 edu-search-service/internal/publications/pub_crawler_test.go create mode 100644 edu-search-service/internal/quality/quality.go create mode 100644 edu-search-service/internal/quality/quality_test.go create mode 100644 edu-search-service/internal/robots/robots.go create mode 100644 edu-search-service/internal/robots/robots_test.go create mode 100644 edu-search-service/internal/scheduler/scheduler.go create mode 100644 edu-search-service/internal/scheduler/scheduler_test.go create mode 100644 edu-search-service/internal/search/search.go create mode 100644 edu-search-service/internal/staff/orchestrator_adapter.go create mode 100644 edu-search-service/internal/staff/patterns.go create mode 100644 edu-search-service/internal/staff/publication_adapter.go create mode 100644 edu-search-service/internal/staff/staff_crawler.go create mode 100644 edu-search-service/internal/staff/staff_crawler_test.go create mode 100644 edu-search-service/internal/tagger/tagger.go create mode 100644 edu-search-service/internal/tagger/tagger_test.go create mode 100644 edu-search-service/policies/bundeslaender.yaml create mode 100644 edu-search-service/rules/doc_type_rules.yaml create mode 100644 edu-search-service/rules/level_rules.yaml create mode 100644 edu-search-service/rules/subject_rules.yaml create mode 100644 edu-search-service/rules/trust_rules.yaml create mode 100644 edu-search-service/scripts/add_german_universities.py create mode 100644 edu-search-service/scripts/fix_university_types.py create mode 100644 edu-search-service/scripts/seed_universities.py create mode 100644 edu-search-service/scripts/vast_ai_extractor.py create mode 100644 edu-search-service/seeds/de_federal.txt create mode 100644 edu-search-service/seeds/de_laender.txt create mode 100644 edu-search-service/seeds/de_portals.txt create mode 100644 edu-search-service/seeds/denylist.txt create mode 100644 frontend/creator-studio/package.json create mode 100644 frontend/creator-studio/vite.config.js create mode 100644 geo-service/.env.example create mode 100644 geo-service/Dockerfile create mode 100644 geo-service/STATUS.md create mode 100644 geo-service/api/__init__.py create mode 100644 geo-service/api/aoi.py create mode 100644 geo-service/api/learning.py create mode 100644 geo-service/api/terrain.py create mode 100644 geo-service/api/tiles.py create mode 100644 geo-service/config.py create mode 100644 geo-service/main.py create mode 100644 geo-service/models/__init__.py create mode 100644 geo-service/models/aoi.py create mode 100644 geo-service/models/attribution.py create mode 100644 geo-service/models/learning_node.py create mode 100644 geo-service/requirements.txt create mode 100755 geo-service/scripts/download_dem.sh create mode 100755 geo-service/scripts/download_osm.sh create mode 100755 geo-service/scripts/generate_tiles.sh create mode 100755 geo-service/scripts/import_osm.sh create mode 100644 geo-service/services/__init__.py create mode 100644 geo-service/services/aoi_packager.py create mode 100644 geo-service/services/dem_service.py create mode 100644 geo-service/services/learning_generator.py create mode 100644 geo-service/services/osm_extractor.py create mode 100644 geo-service/services/tile_server.py create mode 100644 geo-service/tests/__init__.py create mode 100644 geo-service/tests/test_aoi.py create mode 100644 geo-service/tests/test_learning.py create mode 100644 geo-service/tests/test_tiles.py create mode 100644 geo-service/utils/__init__.py create mode 100644 geo-service/utils/geo_utils.py create mode 100644 geo-service/utils/license_checker.py create mode 100644 geo-service/utils/minio_client.py create mode 100644 gitea/runner-config.yaml create mode 100644 h5p-service/.gitignore create mode 100644 h5p-service/Dockerfile create mode 100644 h5p-service/editors/course-presentation-editor.html create mode 100644 h5p-service/editors/drag-drop-editor.html create mode 100644 h5p-service/editors/fill-blanks-editor.html create mode 100644 h5p-service/editors/flashcards-editor.html create mode 100644 h5p-service/editors/interactive-video-editor.html create mode 100644 h5p-service/editors/memory-editor.html create mode 100644 h5p-service/editors/quiz-editor.html create mode 100644 h5p-service/editors/timeline-editor.html create mode 100644 h5p-service/jest.config.js create mode 100644 h5p-service/package.json create mode 100644 h5p-service/players/course-presentation-player.html create mode 100644 h5p-service/players/drag-drop-player.html create mode 100644 h5p-service/players/fill-blanks-player.html create mode 100644 h5p-service/players/interactive-video-player.html create mode 100644 h5p-service/players/memory-player.html create mode 100644 h5p-service/players/quiz-player.html create mode 100644 h5p-service/players/timeline-player.html create mode 100644 h5p-service/server-simple.js create mode 100644 h5p-service/server.js create mode 100644 h5p-service/setup-h5p.js create mode 100644 h5p-service/tests/README.md create mode 100644 h5p-service/tests/server.test.js create mode 100644 h5p-service/tests/setup.js create mode 100644 klausur-service/Dockerfile create mode 100644 klausur-service/backend/admin_api.py create mode 100644 klausur-service/backend/config.py create mode 100644 klausur-service/backend/eh_pipeline.py create mode 100644 klausur-service/backend/eh_templates.py create mode 100644 klausur-service/backend/embedding_client.py create mode 100644 klausur-service/backend/full_compliance_pipeline.py create mode 100644 klausur-service/backend/full_reingestion.py create mode 100644 klausur-service/backend/github_crawler.py create mode 100644 klausur-service/backend/hybrid_search.py create mode 100644 klausur-service/backend/hybrid_vocab_extractor.py create mode 100644 klausur-service/backend/hyde.py create mode 100644 klausur-service/backend/legal_corpus_api.py create mode 100644 klausur-service/backend/legal_corpus_ingestion.py create mode 100644 klausur-service/backend/legal_corpus_robust.py create mode 100644 klausur-service/backend/legal_templates_ingestion.py create mode 100644 klausur-service/backend/mail/__init__.py create mode 100644 klausur-service/backend/mail/aggregator.py create mode 100644 klausur-service/backend/mail/ai_service.py create mode 100644 klausur-service/backend/mail/api.py create mode 100644 klausur-service/backend/mail/credentials.py create mode 100644 klausur-service/backend/mail/mail_db.py create mode 100644 klausur-service/backend/mail/models.py create mode 100644 klausur-service/backend/mail/task_service.py create mode 100644 klausur-service/backend/main.py create mode 100644 klausur-service/backend/metrics_db.py create mode 100644 klausur-service/backend/minio_storage.py create mode 100644 klausur-service/backend/models/__init__.py create mode 100644 klausur-service/backend/models/eh.py create mode 100644 klausur-service/backend/models/enums.py create mode 100644 klausur-service/backend/models/exam.py create mode 100644 klausur-service/backend/models/grading.py create mode 100644 klausur-service/backend/models/requests.py create mode 100644 klausur-service/backend/nibis_ingestion.py create mode 100644 klausur-service/backend/nru_worksheet_generator.py create mode 100644 klausur-service/backend/ocr_labeling_api.py create mode 100644 klausur-service/backend/pdf_export.py create mode 100644 klausur-service/backend/pdf_extraction.py create mode 100644 klausur-service/backend/pipeline_checkpoints.py create mode 100644 klausur-service/backend/policies/bundeslaender.json create mode 100644 klausur-service/backend/pyproject.toml create mode 100644 klausur-service/backend/qdrant_service.py create mode 100644 klausur-service/backend/rag_evaluation.py create mode 100644 klausur-service/backend/rbac.py create mode 100644 klausur-service/backend/requirements.txt create mode 100644 klausur-service/backend/reranker.py create mode 100644 klausur-service/backend/routes/__init__.py create mode 100644 klausur-service/backend/routes/archiv.py create mode 100644 klausur-service/backend/routes/eh.py create mode 100644 klausur-service/backend/routes/exams.py create mode 100644 klausur-service/backend/routes/fairness.py create mode 100644 klausur-service/backend/routes/grading.py create mode 100644 klausur-service/backend/routes/students.py create mode 100644 klausur-service/backend/self_rag.py create mode 100644 klausur-service/backend/services/__init__.py create mode 100644 klausur-service/backend/services/auth_service.py create mode 100644 klausur-service/backend/services/donut_ocr_service.py create mode 100644 klausur-service/backend/services/eh_service.py create mode 100644 klausur-service/backend/services/grading_service.py create mode 100644 klausur-service/backend/services/handwriting_detection.py create mode 100644 klausur-service/backend/services/inpainting_service.py create mode 100644 klausur-service/backend/services/layout_reconstruction_service.py create mode 100644 klausur-service/backend/services/trocr_service.py create mode 100644 klausur-service/backend/storage.py create mode 100644 klausur-service/backend/template_sources.py create mode 100644 klausur-service/backend/tests/__init__.py create mode 100644 klausur-service/backend/tests/conftest.py create mode 100644 klausur-service/backend/tests/test_advanced_rag.py create mode 100644 klausur-service/backend/tests/test_byoeh.py create mode 100644 klausur-service/backend/tests/test_legal_templates.py create mode 100644 klausur-service/backend/tests/test_mail_service.py create mode 100644 klausur-service/backend/tests/test_ocr_labeling.py create mode 100644 klausur-service/backend/tests/test_rag_admin.py create mode 100644 klausur-service/backend/tests/test_rbac.py create mode 100644 klausur-service/backend/tests/test_vocab_worksheet.py create mode 100644 klausur-service/backend/tests/test_worksheet_editor.py create mode 100644 klausur-service/backend/training_api.py create mode 100644 klausur-service/backend/training_export_service.py create mode 100644 klausur-service/backend/upload_api.py create mode 100644 klausur-service/backend/vocab_worksheet_api.py create mode 100644 klausur-service/backend/worksheet_cleanup_api.py create mode 100644 klausur-service/backend/worksheet_editor_api.py create mode 100644 klausur-service/backend/zeugnis_api.py create mode 100644 klausur-service/backend/zeugnis_crawler.py create mode 100644 klausur-service/backend/zeugnis_models.py create mode 100644 klausur-service/backend/zeugnis_seed_data.py create mode 100644 klausur-service/docs/BYOEH-Architecture.md create mode 100644 klausur-service/docs/BYOEH-Developer-Guide.md create mode 100644 klausur-service/docs/DSGVO-Audit-OCR-Labeling.md create mode 100644 klausur-service/docs/NiBiS-Ingestion-Pipeline.md create mode 100644 klausur-service/docs/OCR-Labeling-Spec.md create mode 100644 klausur-service/docs/RAG-Admin-Spec.md create mode 100644 klausur-service/docs/Vocab-Worksheet-Architecture.md create mode 100644 klausur-service/docs/Vocab-Worksheet-Developer-Guide.md create mode 100644 klausur-service/docs/Worksheet-Editor-Architecture.md create mode 100644 klausur-service/docs/Worksheet-Editor-Developer-Guide.md create mode 100644 klausur-service/docs/legal_corpus/EPRIVACY.txt create mode 100644 klausur-service/embedding-service/Dockerfile create mode 100644 klausur-service/embedding-service/config.py create mode 100644 klausur-service/embedding-service/main.py create mode 100644 klausur-service/embedding-service/requirements.txt create mode 100644 klausur-service/frontend/index.html create mode 100644 klausur-service/frontend/package-lock.json create mode 100644 klausur-service/frontend/package.json create mode 100644 klausur-service/frontend/src/App.tsx create mode 100644 klausur-service/frontend/src/components/EHUploadWizard.tsx create mode 100644 klausur-service/frontend/src/components/Layout.tsx create mode 100644 klausur-service/frontend/src/components/RAGSearchPanel.tsx create mode 100644 klausur-service/frontend/src/hooks/useKlausur.tsx create mode 100644 klausur-service/frontend/src/main.tsx create mode 100644 klausur-service/frontend/src/pages/KorrekturPage.tsx create mode 100644 klausur-service/frontend/src/pages/OnboardingPage.tsx create mode 100644 klausur-service/frontend/src/services/api.ts create mode 100644 klausur-service/frontend/src/services/encryption.ts create mode 100644 klausur-service/frontend/src/styles/eh-wizard.css create mode 100644 klausur-service/frontend/src/styles/global.css create mode 100644 klausur-service/frontend/src/styles/rag-search.css create mode 100644 klausur-service/frontend/tsconfig.json create mode 100644 klausur-service/frontend/tsconfig.node.json create mode 100644 klausur-service/frontend/vite.config.ts create mode 100644 klausur-service/scripts/run_nibis_ingestion.sh create mode 100644 librechat/docker-compose.yml create mode 100644 librechat/librechat.yaml create mode 100644 mkdocs.yml create mode 100644 nginx/conf.d/default.conf create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 pca-platform/README.md create mode 100644 pca-platform/ai-access.json create mode 100644 pca-platform/demo/index.html create mode 100644 pca-platform/docker-compose.yml create mode 100644 pca-platform/heuristic-service/Dockerfile create mode 100644 pca-platform/heuristic-service/cmd/server/main.go create mode 100644 pca-platform/heuristic-service/go.mod create mode 100644 pca-platform/heuristic-service/go.sum create mode 100644 pca-platform/heuristic-service/internal/api/handlers.go create mode 100644 pca-platform/heuristic-service/internal/config/config.go create mode 100644 pca-platform/heuristic-service/internal/heuristics/scorer.go create mode 100644 pca-platform/heuristic-service/internal/heuristics/scorer_test.go create mode 100644 pca-platform/heuristic-service/internal/stepup/pow.go create mode 100644 pca-platform/heuristic-service/internal/stepup/pow_test.go create mode 100644 pca-platform/heuristic-service/internal/stepup/webauthn.go create mode 100644 pca-platform/sdk/js/src/pca-sdk.js create mode 100644 school-service/Dockerfile create mode 100644 school-service/cmd/seed/main.go create mode 100644 school-service/cmd/server/main.go create mode 100644 school-service/go.mod create mode 100644 school-service/go.sum create mode 100644 school-service/internal/config/config.go create mode 100644 school-service/internal/database/database.go create mode 100644 school-service/internal/handlers/certificate_handlers.go create mode 100644 school-service/internal/handlers/class_handlers.go create mode 100644 school-service/internal/handlers/exam_handlers.go create mode 100644 school-service/internal/handlers/grade_handlers.go create mode 100644 school-service/internal/handlers/gradebook_handlers.go create mode 100644 school-service/internal/handlers/handlers.go create mode 100644 school-service/internal/middleware/middleware.go create mode 100644 school-service/internal/models/models.go create mode 100644 school-service/internal/seed/seed_data.go create mode 100644 school-service/internal/services/ai_service.go create mode 100644 school-service/internal/services/ai_service_test.go create mode 100644 school-service/internal/services/certificate_service.go create mode 100644 school-service/internal/services/certificate_service_test.go create mode 100644 school-service/internal/services/class_service.go create mode 100644 school-service/internal/services/class_service_test.go create mode 100644 school-service/internal/services/exam_service.go create mode 100644 school-service/internal/services/exam_service_test.go create mode 100644 school-service/internal/services/grade_service.go create mode 100644 school-service/internal/services/grade_service_test.go create mode 100644 school-service/internal/services/gradebook_service.go create mode 100644 school-service/internal/services/gradebook_service_test.go create mode 100644 school-service/templates/.gitkeep create mode 100644 school-service/templates/certificates/generic/.gitkeep create mode 100644 scripts/REFACTORING_STRATEGY.md create mode 100755 scripts/backup-cron.sh create mode 100755 scripts/backup.sh create mode 100755 scripts/daily-backup.sh create mode 100755 scripts/env-switch.sh create mode 100755 scripts/integration-tests.sh create mode 100755 scripts/mac-mini/backup.sh create mode 100755 scripts/mac-mini/docker.sh create mode 100644 scripts/mac-mini/start-services.sh create mode 100755 scripts/mac-mini/status.sh create mode 100755 scripts/mac-mini/sync.sh create mode 100755 scripts/pre-commit-check.py create mode 100755 scripts/promote.sh create mode 100755 scripts/qwen_refactor_orchestrator.py create mode 100755 scripts/restore.sh create mode 100644 scripts/run_full_compliance_update.sh create mode 100755 scripts/security-scan.sh create mode 100755 scripts/server-backup.sh create mode 100755 scripts/setup-branch-protection.sh create mode 100755 scripts/setup-gitea.sh create mode 100755 scripts/start-content-services.sh create mode 100755 scripts/start.sh create mode 100755 scripts/status.sh create mode 100755 scripts/stop.sh create mode 100755 scripts/sync-woodpecker-credentials.sh create mode 100755 scripts/test-environment-setup.sh create mode 100644 studio-v2/.dockerignore create mode 100755 studio-v2/.git-hooks/pre-push create mode 100755 studio-v2/.git-hooks/setup.sh create mode 100644 studio-v2/Dockerfile create mode 100644 studio-v2/app/agb/page.tsx create mode 100644 studio-v2/app/alerts-b2b/page.tsx create mode 100644 studio-v2/app/alerts/page.tsx create mode 100644 studio-v2/app/api/meetings/[...path]/route.ts create mode 100644 studio-v2/app/api/recordings/[...path]/route.ts create mode 100644 studio-v2/app/api/recordings/route.ts create mode 100644 studio-v2/app/api/uploads/route.ts create mode 100644 studio-v2/app/dashboard-experimental/page.tsx create mode 100644 studio-v2/app/datenschutz/page.tsx create mode 100644 studio-v2/app/geo-lernwelt/page.tsx create mode 100644 studio-v2/app/geo-lernwelt/types.ts create mode 100644 studio-v2/app/globals.css create mode 100644 studio-v2/app/impressum/page.tsx create mode 100644 studio-v2/app/kontakt/page.tsx create mode 100644 studio-v2/app/korrektur/[klausurId]/[studentId]/page.tsx create mode 100644 studio-v2/app/korrektur/[klausurId]/fairness/page.tsx create mode 100644 studio-v2/app/korrektur/[klausurId]/page.tsx create mode 100644 studio-v2/app/korrektur/archiv/page.tsx create mode 100644 studio-v2/app/korrektur/page.tsx create mode 100644 studio-v2/app/korrektur/types.ts create mode 100644 studio-v2/app/layout.tsx create mode 100644 studio-v2/app/magic-help/layout.tsx create mode 100644 studio-v2/app/magic-help/page.tsx create mode 100644 studio-v2/app/meet/page.tsx create mode 100644 studio-v2/app/messages/page.tsx create mode 100644 studio-v2/app/page-original.tsx create mode 100644 studio-v2/app/page.tsx create mode 100644 studio-v2/app/upload/[sessionId]/page.tsx create mode 100644 studio-v2/app/vocab-worksheet/page.tsx create mode 100644 studio-v2/app/voice-test/page.tsx create mode 100644 studio-v2/app/worksheet-cleanup/page.tsx create mode 100644 studio-v2/app/worksheet-editor/page.tsx create mode 100644 studio-v2/app/worksheet-editor/types.ts create mode 100644 studio-v2/components/AiPrompt.tsx create mode 100644 studio-v2/components/AlertsWizard.tsx create mode 100644 studio-v2/components/B2BMigrationWizard.tsx create mode 100644 studio-v2/components/ChatOverlay.tsx create mode 100644 studio-v2/components/CityMap.tsx create mode 100644 studio-v2/components/CityMapLeaflet.tsx create mode 100644 studio-v2/components/DocumentSpace.tsx create mode 100644 studio-v2/components/DocumentUpload.tsx create mode 100644 studio-v2/components/Footer.tsx create mode 100644 studio-v2/components/GermanyMap.tsx create mode 100644 studio-v2/components/InfoBox.tsx create mode 100644 studio-v2/components/LanguageDropdown.tsx create mode 100644 studio-v2/components/Layout.tsx create mode 100644 studio-v2/components/Logo.tsx create mode 100644 studio-v2/components/OnboardingWizard.tsx create mode 100644 studio-v2/components/QRCodeUpload.tsx create mode 100644 studio-v2/components/SchoolSearch.tsx create mode 100644 studio-v2/components/Sidebar.tsx create mode 100644 studio-v2/components/ThemeToggle.tsx create mode 100644 studio-v2/components/UserMenu.tsx create mode 100644 studio-v2/components/geo-lernwelt/AOISelector.tsx create mode 100644 studio-v2/components/geo-lernwelt/UnityViewer.tsx create mode 100644 studio-v2/components/geo-lernwelt/index.ts create mode 100644 studio-v2/components/korrektur/AnnotationLayer.tsx create mode 100644 studio-v2/components/korrektur/AnnotationToolbar.tsx create mode 100644 studio-v2/components/korrektur/CriteriaPanel.tsx create mode 100644 studio-v2/components/korrektur/DocumentViewer.tsx create mode 100644 studio-v2/components/korrektur/EHSuggestionPanel.tsx create mode 100644 studio-v2/components/korrektur/GutachtenEditor.tsx create mode 100644 studio-v2/components/korrektur/index.ts create mode 100644 studio-v2/components/spatial-ui/FloatingMessage.tsx create mode 100644 studio-v2/components/spatial-ui/SpatialCard.tsx create mode 100644 studio-v2/components/spatial-ui/index.ts create mode 100644 studio-v2/components/voice/VoiceCapture.tsx create mode 100644 studio-v2/components/voice/VoiceCommandBar.tsx create mode 100644 studio-v2/components/voice/VoiceIndicator.tsx create mode 100644 studio-v2/components/voice/index.ts create mode 100644 studio-v2/components/worksheet-editor/AIImageGenerator.tsx create mode 100644 studio-v2/components/worksheet-editor/AIPromptBar.tsx create mode 100644 studio-v2/components/worksheet-editor/CanvasControls.tsx create mode 100644 studio-v2/components/worksheet-editor/CleanupPanel.tsx create mode 100644 studio-v2/components/worksheet-editor/DocumentImporter.tsx create mode 100644 studio-v2/components/worksheet-editor/EditorToolbar.tsx create mode 100644 studio-v2/components/worksheet-editor/ExportPanel.tsx create mode 100644 studio-v2/components/worksheet-editor/FabricCanvas.tsx create mode 100644 studio-v2/components/worksheet-editor/PageNavigator.tsx create mode 100644 studio-v2/components/worksheet-editor/PropertiesPanel.tsx create mode 100644 studio-v2/components/worksheet-editor/index.ts create mode 100644 studio-v2/dev-server.sh create mode 100644 studio-v2/e2e/korrektur-archiv.spec.ts create mode 100644 studio-v2/e2e/korrektur.spec.ts create mode 100644 studio-v2/lib/ActivityContext.tsx create mode 100644 studio-v2/lib/AlertsB2BContext.tsx create mode 100644 studio-v2/lib/AlertsContext.tsx create mode 100644 studio-v2/lib/LanguageContext.tsx create mode 100644 studio-v2/lib/MessagesContext.tsx create mode 100644 studio-v2/lib/ThemeContext.tsx create mode 100644 studio-v2/lib/geo-lernwelt/GeoContext.tsx create mode 100644 studio-v2/lib/geo-lernwelt/index.ts create mode 100644 studio-v2/lib/geo-lernwelt/mapStyles.ts create mode 100644 studio-v2/lib/geo-lernwelt/unityBridge.ts create mode 100644 studio-v2/lib/i18n.ts create mode 100644 studio-v2/lib/korrektur/api.ts create mode 100644 studio-v2/lib/korrektur/index.ts create mode 100644 studio-v2/lib/spatial-ui/FocusContext.tsx create mode 100644 studio-v2/lib/spatial-ui/PerformanceContext.tsx create mode 100644 studio-v2/lib/spatial-ui/depth-system.ts create mode 100644 studio-v2/lib/spatial-ui/index.ts create mode 100644 studio-v2/lib/voice/index.ts create mode 100644 studio-v2/lib/voice/voice-api.ts create mode 100644 studio-v2/lib/voice/voice-encryption.ts create mode 100644 studio-v2/lib/worksheet-editor/WorksheetContext.tsx create mode 100644 studio-v2/lib/worksheet-editor/cleanup-service.ts create mode 100644 studio-v2/lib/worksheet-editor/index.ts create mode 100644 studio-v2/next-env.d.ts create mode 100644 studio-v2/next.config.js create mode 100644 studio-v2/package-lock.json create mode 100644 studio-v2/package.json create mode 100644 studio-v2/playwright.config.ts create mode 100644 studio-v2/postcss.config.js create mode 100644 studio-v2/tailwind.config.js create mode 100644 studio-v2/tsconfig.json create mode 100644 test-data/fake-klausur.html create mode 100644 vault/agent/config.hcl create mode 100644 vault/agent/split-certs.sh create mode 100644 vault/agent/templates/all.tpl create mode 100644 vault/agent/templates/ca-chain.tpl create mode 100644 vault/agent/templates/cert.tpl create mode 100644 vault/agent/templates/key.tpl create mode 100755 vault/init-pki.sh create mode 100755 vault/init-secrets.sh create mode 100644 voice-service/.env.example create mode 100644 voice-service/Dockerfile create mode 100644 voice-service/api/__init__.py create mode 100644 voice-service/api/bqas.py create mode 100644 voice-service/api/sessions.py create mode 100644 voice-service/api/streaming.py create mode 100644 voice-service/api/tasks.py create mode 100644 voice-service/bqas/__init__.py create mode 100644 voice-service/bqas/backlog_generator.py create mode 100644 voice-service/bqas/config.py create mode 100644 voice-service/bqas/judge.py create mode 100644 voice-service/bqas/metrics.py create mode 100644 voice-service/bqas/notifier.py create mode 100644 voice-service/bqas/prompts.py create mode 100644 voice-service/bqas/quality_judge_agent.py create mode 100644 voice-service/bqas/rag_judge.py create mode 100644 voice-service/bqas/regression_tracker.py create mode 100644 voice-service/bqas/runner.py create mode 100644 voice-service/bqas/synthetic_generator.py create mode 100644 voice-service/config.py create mode 100644 voice-service/main.py create mode 100644 voice-service/models/__init__.py create mode 100644 voice-service/models/audit.py create mode 100644 voice-service/models/session.py create mode 100644 voice-service/models/task.py create mode 100644 voice-service/personas/lehrer_persona.json create mode 100644 voice-service/pyproject.toml create mode 100644 voice-service/requirements.txt create mode 100644 voice-service/scripts/com.breakpilot.bqas.plist create mode 100755 voice-service/scripts/install_bqas_scheduler.sh create mode 100644 voice-service/scripts/post-commit.hook create mode 100755 voice-service/scripts/run_bqas.py create mode 100755 voice-service/scripts/run_bqas.sh create mode 100644 voice-service/services/__init__.py create mode 100644 voice-service/services/audio_processor.py create mode 100644 voice-service/services/encryption_service.py create mode 100644 voice-service/services/enhanced_task_orchestrator.py create mode 100644 voice-service/services/fallback_llm_client.py create mode 100644 voice-service/services/intent_router.py create mode 100644 voice-service/services/personaplex_client.py create mode 100644 voice-service/services/task_orchestrator.py create mode 100644 voice-service/tests/__init__.py create mode 100644 voice-service/tests/bqas/__init__.py create mode 100644 voice-service/tests/bqas/conftest.py create mode 100644 voice-service/tests/bqas/golden_tests/edge_cases.yaml create mode 100644 voice-service/tests/bqas/golden_tests/golden_rag_correction_v1.yaml create mode 100644 voice-service/tests/bqas/golden_tests/intent_tests.yaml create mode 100644 voice-service/tests/bqas/golden_tests/workflow_tests.yaml create mode 100644 voice-service/tests/bqas/test_golden.py create mode 100644 voice-service/tests/bqas/test_notifier.py create mode 100644 voice-service/tests/bqas/test_rag.py create mode 100644 voice-service/tests/bqas/test_regression.py create mode 100644 voice-service/tests/bqas/test_synthetic.py create mode 100644 voice-service/tests/conftest.py create mode 100644 voice-service/tests/test_encryption.py create mode 100644 voice-service/tests/test_intent_router.py create mode 100644 voice-service/tests/test_sessions.py create mode 100644 voice-service/tests/test_tasks.py create mode 100644 website/.dockerignore create mode 100644 website/Dockerfile create mode 100644 website/README.md create mode 100644 website/__tests__/compliance/DependencyMap.test.tsx create mode 100644 website/__tests__/compliance/ExpiredEvidenceAlert.test.tsx create mode 100644 website/__tests__/compliance/MyTasksPage.test.tsx create mode 100644 website/__tests__/compliance/RiskHeatmap.test.tsx create mode 100644 website/__tests__/compliance/RoleSelectPage.test.tsx create mode 100644 website/app/admin/alerts/page.tsx create mode 100644 website/app/admin/backlog/page.tsx create mode 100644 website/app/admin/brandbook/page.tsx create mode 100644 website/app/admin/builds/wizard/page.tsx create mode 100644 website/app/admin/communication/page.tsx create mode 100644 website/app/admin/communication/wizard/page.tsx create mode 100644 website/app/admin/companion/page.tsx create mode 100644 website/app/admin/compliance/audit-checklist/page.tsx create mode 100644 website/app/admin/compliance/audit-workspace/page.tsx create mode 100644 website/app/admin/compliance/controls/page.tsx create mode 100644 website/app/admin/compliance/evidence/page.tsx create mode 100644 website/app/admin/compliance/export/page.tsx create mode 100644 website/app/admin/compliance/modules/page.tsx create mode 100644 website/app/admin/compliance/my-tasks/page.tsx create mode 100644 website/app/admin/compliance/page.tsx create mode 100644 website/app/admin/compliance/risks/page.tsx create mode 100644 website/app/admin/compliance/role-select/page.tsx create mode 100644 website/app/admin/compliance/scraper/page.tsx create mode 100644 website/app/admin/consent/page.tsx create mode 100644 website/app/admin/consent/wizard/page.tsx create mode 100644 website/app/admin/content/page.tsx create mode 100644 website/app/admin/docs/audit/page.tsx create mode 100644 website/app/admin/docs/page.tsx create mode 100644 website/app/admin/dsms/page.tsx create mode 100644 website/app/admin/dsr/page.tsx create mode 100644 website/app/admin/dsr/wizard/page.tsx create mode 100644 website/app/admin/edu-search/page.tsx create mode 100644 website/app/admin/game/page.tsx create mode 100644 website/app/admin/game/wizard/page.tsx create mode 100644 website/app/admin/gpu/page.tsx create mode 100644 website/app/admin/gpu/wizard/page.tsx create mode 100644 website/app/admin/klausur-korrektur/[klausurId]/[studentId]/page.tsx create mode 100644 website/app/admin/klausur-korrektur/[klausurId]/fairness/page.tsx create mode 100644 website/app/admin/klausur-korrektur/[klausurId]/page.tsx create mode 100644 website/app/admin/klausur-korrektur/components/AnnotationLayer.tsx create mode 100644 website/app/admin/klausur-korrektur/components/AnnotationPanel.tsx create mode 100644 website/app/admin/klausur-korrektur/components/AnnotationToolbar.tsx create mode 100644 website/app/admin/klausur-korrektur/components/EHSuggestionPanel.tsx create mode 100644 website/app/admin/klausur-korrektur/page.tsx create mode 100644 website/app/admin/klausur-korrektur/types.ts create mode 100644 website/app/admin/llm-compare/page.tsx create mode 100644 website/app/admin/llm-compare/wizard/page.tsx create mode 100644 website/app/admin/mac-mini/page.tsx create mode 100644 website/app/admin/magic-help/page.tsx create mode 100644 website/app/admin/mail/page.tsx create mode 100644 website/app/admin/mail/wizard/page.tsx create mode 100644 website/app/admin/middleware/page.tsx create mode 100644 website/app/admin/middleware/test-wizard/page.tsx create mode 100644 website/app/admin/middleware/wizard/page.tsx create mode 100644 website/app/admin/multiplayer/wizard/page.tsx create mode 100644 website/app/admin/ocr-labeling/page.tsx create mode 100644 website/app/admin/ocr-labeling/types.ts create mode 100644 website/app/admin/onboarding/page.tsx create mode 100644 website/app/admin/page.tsx create mode 100644 website/app/admin/pca-platform/page.tsx create mode 100644 website/app/admin/quality/page.tsx create mode 100644 website/app/admin/rag/README.md create mode 100644 website/app/admin/rag/components/CollectionsTab.tsx create mode 100644 website/app/admin/rag/components/DocumentsTab.tsx create mode 100644 website/app/admin/rag/components/IngestionTab.tsx create mode 100644 website/app/admin/rag/components/UploadTab.tsx create mode 100644 website/app/admin/rag/components/index.ts create mode 100644 website/app/admin/rag/page.tsx create mode 100644 website/app/admin/rag/types.ts create mode 100644 website/app/admin/rag/wizard/page.tsx create mode 100644 website/app/admin/rbac/wizard/page.tsx create mode 100644 website/app/admin/sbom/page.tsx create mode 100644 website/app/admin/sbom/wizard/page.tsx create mode 100644 website/app/admin/screen-flow/page.tsx create mode 100644 website/app/admin/security/page.tsx create mode 100644 website/app/admin/security/wizard/page.tsx create mode 100644 website/app/admin/staff-search/page.tsx create mode 100644 website/app/admin/training/page.tsx create mode 100644 website/app/admin/uni-crawler/page.tsx create mode 100644 website/app/admin/unity-bridge/page.tsx create mode 100644 website/app/admin/unity-bridge/wizard/page.tsx create mode 100644 website/app/admin/voice/page.tsx create mode 100644 website/app/admin/workflow/page.tsx create mode 100644 website/app/admin/zeugnisse-crawler/page.tsx create mode 100644 website/app/api/admin/cicd/route.ts create mode 100644 website/app/api/admin/communication/stats/route.ts create mode 100644 website/app/api/admin/consent/documents/[id]/versions/route.ts create mode 100644 website/app/api/admin/consent/documents/route.ts create mode 100644 website/app/api/admin/edu-search/route.ts create mode 100644 website/app/api/admin/gpu/route.ts create mode 100644 website/app/api/admin/pca/route.ts create mode 100644 website/app/api/admin/training/route.ts create mode 100644 website/app/api/admin/uni-crawler/route.ts create mode 100644 website/app/api/admin/unity-bridge/route.ts create mode 100644 website/app/api/admin/zeugnisse-crawler/route.ts create mode 100644 website/app/api/content/route.ts create mode 100644 website/app/cancel/page.tsx create mode 100644 website/app/faq/page.tsx create mode 100644 website/app/globals.css create mode 100644 website/app/ki-konzept/page.tsx create mode 100644 website/app/layout.tsx create mode 100644 website/app/lehrer/abitur-archiv/page.tsx create mode 100644 website/app/lehrer/klausur-korrektur/[klausurId]/[studentId]/page.tsx create mode 100644 website/app/lehrer/klausur-korrektur/[klausurId]/fairness/page.tsx create mode 100644 website/app/lehrer/klausur-korrektur/[klausurId]/page.tsx create mode 100644 website/app/lehrer/klausur-korrektur/components/AnnotationLayer.tsx create mode 100644 website/app/lehrer/klausur-korrektur/components/AnnotationPanel.tsx create mode 100644 website/app/lehrer/klausur-korrektur/components/AnnotationToolbar.tsx create mode 100644 website/app/lehrer/klausur-korrektur/components/EHSuggestionPanel.tsx create mode 100644 website/app/lehrer/klausur-korrektur/page.tsx create mode 100644 website/app/lehrer/klausur-korrektur/types.ts create mode 100644 website/app/lehrer/layout.tsx create mode 100644 website/app/lehrer/page.tsx create mode 100644 website/app/mail/page.tsx create mode 100644 website/app/mail/tasks/page.tsx create mode 100644 website/app/page.tsx create mode 100644 website/app/success/page.tsx create mode 100644 website/app/tools/communication/page.tsx create mode 100644 website/app/upload/page.tsx create mode 100644 website/app/zeugnisse/page.tsx create mode 100644 website/components/ClientProviders.tsx create mode 100644 website/components/Footer.tsx create mode 100644 website/components/Header.tsx create mode 100644 website/components/LandingContent.tsx create mode 100644 website/components/LanguageSelector.tsx create mode 100644 website/components/PricingSection.tsx create mode 100644 website/components/admin/AdminLayout.tsx create mode 100644 website/components/admin/AiPrompt.tsx create mode 100644 website/components/admin/CICDStatusWidget.tsx create mode 100644 website/components/admin/GameView.tsx create mode 100644 website/components/admin/GermanySchoolMap.tsx create mode 100644 website/components/admin/LLMModeSwitcher.tsx create mode 100644 website/components/admin/SystemInfoSection.tsx create mode 100644 website/components/admin/system-info-configs/backlog-config.ts create mode 100644 website/components/admin/system-info-configs/brandbook-config.ts create mode 100644 website/components/admin/system-info-configs/communication-config.ts create mode 100644 website/components/admin/system-info-configs/consent-config.ts create mode 100644 website/components/admin/system-info-configs/content-config.ts create mode 100644 website/components/admin/system-info-configs/dashboard-config.ts create mode 100644 website/components/admin/system-info-configs/docs-config.ts create mode 100644 website/components/admin/system-info-configs/dsms-config.ts create mode 100644 website/components/admin/system-info-configs/dsr-config.ts create mode 100644 website/components/admin/system-info-configs/edu-search-config.ts create mode 100644 website/components/admin/system-info-configs/game-config.ts create mode 100644 website/components/admin/system-info-configs/gpu-config.ts create mode 100644 website/components/admin/system-info-configs/index.ts create mode 100644 website/components/admin/system-info-configs/index_original.ts create mode 100644 website/components/admin/system-info-configs/llm-compare-config.ts create mode 100644 website/components/admin/system-info-configs/mail-config.ts create mode 100644 website/components/admin/system-info-configs/middleware-config.ts create mode 100644 website/components/admin/system-info-configs/onboarding-config.ts create mode 100644 website/components/admin/system-info-configs/pca-platform-config.ts create mode 100644 website/components/admin/system-info-configs/rag-config.ts create mode 100644 website/components/admin/system-info-configs/sbom-config.ts create mode 100644 website/components/admin/system-info-configs/security-config.ts create mode 100644 website/components/admin/system-info-configs/training-config.ts create mode 100644 website/components/admin/system-info-configs/types.ts create mode 100644 website/components/admin/system-info-configs/unity-bridge-config.ts create mode 100644 website/components/admin/system-info-configs/workflow-config.ts create mode 100644 website/components/admin/system-info-configs/zeugnisse-crawler-config.ts create mode 100644 website/components/compliance/ExpiredEvidenceAlert.tsx create mode 100644 website/components/compliance/GlossaryTooltip.tsx create mode 100644 website/components/compliance/LLMProviderToggle.tsx create mode 100644 website/components/compliance/LanguageSwitch.tsx create mode 100644 website/components/compliance/charts/ComplianceTrendChart.tsx create mode 100644 website/components/compliance/charts/DependencyMap.tsx create mode 100644 website/components/compliance/charts/RiskHeatmap.tsx create mode 100644 website/components/compliance/charts/index.ts create mode 100644 website/components/lehrer/LehrerLayout.tsx create mode 100644 website/components/wizard/ArchitectureContext.tsx create mode 100644 website/components/wizard/EducationCard.tsx create mode 100644 website/components/wizard/TestResultCard.tsx create mode 100644 website/components/wizard/TestRunner.tsx create mode 100644 website/components/wizard/TestSummary.tsx create mode 100644 website/components/wizard/WizardBanner.tsx create mode 100644 website/components/wizard/WizardNavigation.tsx create mode 100644 website/components/wizard/WizardProvider.tsx create mode 100644 website/components/wizard/WizardStepper.tsx create mode 100644 website/components/wizard/index.ts create mode 100644 website/components/wizard/types.ts create mode 100644 website/content/ki-konzept.json create mode 100644 website/jest.config.js create mode 100644 website/lib/LanguageContext.tsx create mode 100644 website/lib/architecture-data.ts create mode 100644 website/lib/compliance-i18n.ts create mode 100644 website/lib/content-types.ts create mode 100644 website/lib/content.ts create mode 100644 website/lib/i18n.ts create mode 100644 website/lib/llm-mode-context.tsx create mode 100644 website/next-env.d.ts create mode 100644 website/next.config.mjs create mode 100644 website/package-lock.json create mode 100644 website/package.json create mode 100644 website/postcss.config.mjs create mode 100644 website/public/germany-states.json create mode 100644 website/tailwind.config.ts create mode 100644 website/tests/quality-dashboard.test.ts create mode 100644 website/tests/structure.test.ts create mode 100644 website/tests/unity-bridge.test.ts create mode 100644 website/tsconfig.json create mode 100644 website/types/react-simple-maps.d.ts diff --git a/.claude/rules/abiturkorrektur.md b/.claude/rules/abiturkorrektur.md new file mode 100644 index 0000000..6353a10 --- /dev/null +++ b/.claude/rules/abiturkorrektur.md @@ -0,0 +1,614 @@ +# Abiturkorrektur-System - Entwicklerdokumentation + +**WICHTIG: Diese Datei wird bei jedem Compacting gelesen. Alle Implementierungsdetails hier dokumentieren!** + +--- + +## 1. Projektziel + +Entwicklung eines KI-gestützten Korrektur-Systems für Deutsch-Abiturklausuren: +- **Zielgruppe**: Lehrer in Niedersachsen (Pilot), später alle Bundesländer +- **Kernproblem**: Erstkorrektur dauert 6 Stunden pro Arbeit +- **Lösung**: KI schlägt Bewertungen vor, Lehrer bestätigt/korrigiert + +--- + +## 2. Architektur-Übersicht + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Frontend (Next.js) │ +│ /website/app/admin/klausur-korrektur/ │ +│ - page.tsx (Klausur-Liste) │ +│ - [klausurId]/page.tsx (Studenten-Liste) │ +│ - [klausurId]/[studentId]/page.tsx (Korrektur-Workspace) │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ klausur-service (FastAPI) │ +│ Port 8086 - /klausur-service/backend/main.py │ +│ - Klausur CRUD (/api/v1/klausuren) │ +│ - Student Work (/api/v1/students) │ +│ - Annotations (/api/v1/annotations) [NEU] │ +│ - Gutachten Generation │ +│ - Fairness Analysis │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Infrastruktur │ +│ - Qdrant (Vektor-DB für RAG) │ +│ - MinIO (Datei-Storage) │ +│ - PostgreSQL (Metadaten) │ +│ - Embedding-Service (Port 8087) │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. Bestehende Backend-Komponenten (NUTZEN!) + +### 3.1 Klausur-Service API (main.py) + +```python +# Bereits implementiert: +GET/POST /api/v1/klausuren # Klausur CRUD +GET /api/v1/klausuren/{id} # Klausur Details +POST /api/v1/klausuren/{id}/students # Student Work hochladen +GET /api/v1/klausuren/{id}/students # Studenten-Liste +PUT /api/v1/students/{id}/criteria # Kriterien bewerten +PUT /api/v1/students/{id}/gutachten # Gutachten speichern +POST /api/v1/students/{id}/gutachten/generate # Gutachten generieren (KI) +GET /api/v1/klausuren/{id}/fairness # Fairness-Analyse +GET /api/v1/grade-info # Notensystem-Info +``` + +### 3.2 Datenmodelle (main.py) + +```python +@dataclass +class Klausur: + id: str + title: str + subject: str = "Deutsch" + year: int = 2025 + semester: str = "Abitur" + modus: str = "abitur" # oder "vorabitur" + eh_id: Optional[str] = None # Erwartungshorizont-Referenz + +@dataclass +class StudentKlausur: + id: str + klausur_id: str + anonym_id: str + file_path: str + ocr_text: str = "" + criteria_scores: Dict[str, int] = field(default_factory=dict) + gutachten: str = "" + status: str = "UPLOADED" + raw_points: int = 0 + grade_points: int = 0 + +# Status-Workflow: +# UPLOADED → OCR_PROCESSING → OCR_COMPLETE → ANALYZING → +# FIRST_EXAMINER → SECOND_EXAMINER → COMPLETED +``` + +### 3.3 Notensystem (15-Punkte) + +```python +GRADE_THRESHOLDS = { + 15: 95, 14: 90, 13: 85, 12: 80, 11: 75, + 10: 70, 9: 65, 8: 60, 7: 55, 6: 50, + 5: 45, 4: 40, 3: 33, 2: 27, 1: 20, 0: 0 +} + +DEFAULT_CRITERIA = { + "rechtschreibung": {"name": "Rechtschreibung", "weight": 15}, + "grammatik": {"name": "Grammatik", "weight": 15}, + "inhalt": {"name": "Inhalt", "weight": 40}, + "struktur": {"name": "Struktur", "weight": 15}, + "stil": {"name": "Stil", "weight": 15} +} +``` + +--- + +## 4. NEU ZU IMPLEMENTIEREN + +### Phase 1: Korrektur-Workspace MVP + +#### 4.1 Frontend-Struktur + +``` +/website/app/admin/klausur-korrektur/ +├── page.tsx # Klausur-Übersicht (Liste aller Klausuren) +├── types.ts # TypeScript Interfaces +├── [klausurId]/ +│ ├── page.tsx # Studenten-Liste einer Klausur +│ └── [studentId]/ +│ └── page.tsx # Korrektur-Workspace (2/3-1/3) +└── components/ + ├── KlausurCard.tsx # Klausur in Liste + ├── StudentList.tsx # Studenten-Übersicht + ├── DocumentViewer.tsx # PDF/Bild-Anzeige (links, 2/3) + ├── AnnotationLayer.tsx # SVG-Overlay für Markierungen + ├── AnnotationToolbar.tsx # Werkzeuge + ├── CorrectionPanel.tsx # Bewertungs-Panel (rechts, 1/3) + ├── CriteriaScoreCard.tsx # Einzelnes Kriterium + ├── EHSuggestionPanel.tsx # EH-Vorschläge via RAG + ├── GutachtenEditor.tsx # Gutachten bearbeiten + └── StudentNavigation.tsx # Prev/Next Navigation +``` + +#### 4.2 Annotations-Backend (NEU in main.py) + +```python +# Neues Datenmodell: +@dataclass +class Annotation: + id: str + student_work_id: str + page: int + position: dict # {x, y, width, height} in % (0-100) + type: str # 'rechtschreibung' | 'grammatik' | 'inhalt' | 'struktur' | 'stil' | 'comment' + text: str # Kommentar-Text + severity: str # 'minor' | 'major' | 'critical' + suggestion: str # Korrekturvorschlag (bei RS/Gram) + created_by: str # User-ID (EK oder ZK) + created_at: datetime + role: str # 'first_examiner' | 'second_examiner' + linked_criterion: Optional[str] # Verknüpfung zu Kriterium + +# Neue Endpoints: +POST /api/v1/students/{id}/annotations # Erstellen +GET /api/v1/students/{id}/annotations # Abrufen +PUT /api/v1/annotations/{id} # Ändern +DELETE /api/v1/annotations/{id} # Löschen +``` + +#### 4.3 UI-Layout Spezifikation + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ Header: Klausur-Titel | Student: Anonym-123 | [← Prev] [5/24] [Next →]│ +├─────────────────────────────────────────┬────────────────────────────┤ +│ │ Tabs: [Kriterien] [Gutachten]│ +│ ┌─────────────────────────────────┐ │ │ +│ │ │ │ ▼ Rechtschreibung (15%) │ +│ │ Dokument-Anzeige │ │ [====|====] 70/100 │ +│ │ (PDF/Bild mit Zoom) │ │ 12 Fehler markiert │ +│ │ │ │ │ +│ │ + Annotation-Overlay │ │ ▼ Grammatik (15%) │ +│ │ (SVG Layer) │ │ [====|====] 80/100 │ +│ │ │ │ │ +│ │ │ │ ▼ Inhalt (40%) │ +│ │ │ │ [====|====] 65/100 │ +│ │ │ │ EH-Vorschläge: [Laden] │ +│ └─────────────────────────────────┘ │ │ +│ │ ▼ Struktur (15%) │ +│ Toolbar: [RS] [Gram] [Kommentar] │ [====|====] 75/100 │ +│ [Zoom+] [Zoom-] [Fit] │ │ +│ │ ▼ Stil (15%) │ +│ Seiten: [1] [2] [3] [4] [5] │ [====|====] 70/100 │ +│ │ │ +│ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━ │ +│ │ Gesamtnote: 10 Punkte (2-) │ +│ │ [Gutachten generieren] │ +│ │ [Speichern] [Abschließen] │ +├─────────────────────────────────────────┴────────────────────────────┤ +│ 2/3 Breite │ 1/3 Breite │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 5. Implementierungs-Reihenfolge + +### Phase 1.1: Grundgerüst (AKTUELL) +1. ✅ Dokumentation erstellen +2. [ ] `/website/app/admin/klausur-korrektur/page.tsx` - Klausur-Liste +3. [ ] `/website/app/admin/klausur-korrektur/types.ts` - TypeScript Types +4. [ ] Navigation in AdminLayout.tsx hinzufügen +5. [ ] Deploy + Test + +### Phase 1.2: Korrektur-Workspace +1. [ ] `[klausurId]/page.tsx` - Studenten-Liste +2. [ ] `[klausurId]/[studentId]/page.tsx` - Workspace +3. [ ] `components/DocumentViewer.tsx` - Bild/PDF Anzeige +4. [ ] `components/CorrectionPanel.tsx` - Bewertungs-Panel +5. [ ] Deploy + Test mit Lehrer + +### Phase 1.3: Annotations-System +1. [ ] Backend: Annotations-Endpoints in main.py +2. [ ] `components/AnnotationLayer.tsx` - SVG Overlay +3. [ ] `components/AnnotationToolbar.tsx` - Werkzeuge +4. [ ] Farbkodierung: RS=rot, Gram=blau, Inhalt=grün +5. [ ] Deploy + Test + +### Phase 1.4: EH-Integration +1. [ ] `components/EHSuggestionPanel.tsx` +2. [ ] Backend: `/api/v1/students/{id}/eh-suggestions` +3. [ ] RAG-Query mit Student-Text +4. [ ] Deploy + Test + +### Phase 1.5: Gutachten-Editor +1. [ ] `components/GutachtenEditor.tsx` +2. [ ] Beleg-Verlinkung zu Annotations +3. [ ] Gutachten-Generierung Button +4. [ ] Deploy + Test + +--- + +## 6. API-Konfiguration + +```typescript +// Frontend API Base URLs +const KLAUSUR_SERVICE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086' + +// Endpoints: +// Klausuren +GET ${KLAUSUR_SERVICE}/api/v1/klausuren +POST ${KLAUSUR_SERVICE}/api/v1/klausuren +GET ${KLAUSUR_SERVICE}/api/v1/klausuren/{id} +GET ${KLAUSUR_SERVICE}/api/v1/klausuren/{id}/students + +// Studenten +GET ${KLAUSUR_SERVICE}/api/v1/students/{id} +GET ${KLAUSUR_SERVICE}/api/v1/students/{id}/file // Dokument-Download +PUT ${KLAUSUR_SERVICE}/api/v1/students/{id}/criteria +PUT ${KLAUSUR_SERVICE}/api/v1/students/{id}/gutachten +POST ${KLAUSUR_SERVICE}/api/v1/students/{id}/gutachten/generate + +// Annotations (NEU) +GET ${KLAUSUR_SERVICE}/api/v1/students/{id}/annotations +POST ${KLAUSUR_SERVICE}/api/v1/students/{id}/annotations +PUT ${KLAUSUR_SERVICE}/api/v1/annotations/{id} +DELETE ${KLAUSUR_SERVICE}/api/v1/annotations/{id} + +// System +GET ${KLAUSUR_SERVICE}/api/v1/grade-info +``` + +--- + +## 7. Deployment-Prozess + +```bash +# 1. Dateien auf Mac Mini synchronisieren +rsync -avz --delete \ + --exclude 'node_modules' --exclude '.next' --exclude '.git' \ + /Users/benjaminadmin/Projekte/breakpilot-pwa/website/ \ + macmini:/Users/benjaminadmin/Projekte/breakpilot-pwa/website/ + +# 2. Website-Container neu bauen +ssh macmini "/usr/local/bin/docker compose \ + -f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \ + build --no-cache website" + +# 3. Container neu starten +ssh macmini "/usr/local/bin/docker compose \ + -f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \ + up -d website" + +# 4. Testen unter: +# http://macmini:3000/admin/klausur-korrektur +``` + +--- + +## 8. Bundesland-Spezifika (Niedersachsen Pilot) + +```json +// /klausur-service/backend/policies/bundeslaender.json +{ + "NI": { + "name": "Niedersachsen", + "grading_mode": "points_15", + "requires_gutachten": true, + "zk_visibility": "full", // ZK sieht EK-Korrektur + "third_correction_threshold": 4, // Ab 4 Punkte Diff + "colors": { + "first_examiner": "#dc2626", // Rot + "second_examiner": "#16a34a" // Grün + }, + "criteria_weights": { + "rechtschreibung": 15, + "grammatik": 15, + "inhalt": 40, + "struktur": 15, + "stil": 15 + } + } +} +``` + +--- + +## 9. Wichtige Dateien (Referenz) + +| Datei | Beschreibung | +|-------|--------------| +| `/klausur-service/backend/main.py` | Haupt-API, alle Endpoints | +| `/klausur-service/backend/eh_pipeline.py` | BYOEH Verarbeitung | +| `/klausur-service/backend/qdrant_service.py` | RAG Vector-Suche | +| `/klausur-service/backend/hybrid_search.py` | Hybrid Search | +| `/website/components/admin/AdminLayout.tsx` | Admin Navigation | +| `/website/app/admin/ocr-labeling/page.tsx` | Referenz für 2/3-1/3 Layout | + +--- + +## 10. Testing-Checkliste + +### Nach jeder Phase: +- [ ] Seite lädt ohne Fehler +- [ ] API-Calls funktionieren (DevTools Network) +- [ ] Responsives Layout korrekt +- [ ] Lehrer kann Workflow durchführen + +### Lehrer-Test-Szenarien: +1. Klausur erstellen +2. 3+ Studentenarbeiten hochladen +3. Erste Arbeit korrigieren (alle Kriterien) +4. Annotations setzen +5. Gutachten generieren +6. Zur nächsten Arbeit navigieren +7. Fairness-Check nach allen Arbeiten + +--- + +## 11. Phase 2: Zweitkorrektur-System (NEU) + +### 11.1 Neue Backend-Endpoints (main.py) + +```python +# Zweitkorrektur Workflow +POST /api/v1/students/{id}/start-zweitkorrektur # ZK starten (nach EK) +POST /api/v1/students/{id}/submit-zweitkorrektur # ZK-Ergebnis abgeben + +# Einigung (bei Diff 3 Punkte) +POST /api/v1/students/{id}/einigung # Einigung einreichen + +# Drittkorrektur (bei Diff >= 4 Punkte) +POST /api/v1/students/{id}/assign-drittkorrektor # DK zuweisen +POST /api/v1/students/{id}/submit-drittkorrektur # DK-Ergebnis (final) + +# Workflow-Status & Visibility-Filtering +GET /api/v1/students/{id}/examiner-workflow # Workflow-Status abrufen +GET /api/v1/students/{id}/annotations-filtered # Policy-gefilterte Annotations +``` + +### 11.2 Workflow-Status + +```python +class ExaminerWorkflowStatus(str, Enum): + NOT_STARTED = "not_started" + EK_IN_PROGRESS = "ek_in_progress" + EK_COMPLETED = "ek_completed" + ZK_ASSIGNED = "zk_assigned" + ZK_IN_PROGRESS = "zk_in_progress" + ZK_COMPLETED = "zk_completed" + EINIGUNG_REQUIRED = "einigung_required" + EINIGUNG_COMPLETED = "einigung_completed" + DRITTKORREKTUR_REQUIRED = "drittkorrektur_required" + DRITTKORREKTUR_ASSIGNED = "drittkorrektur_assigned" + DRITTKORREKTUR_IN_PROGRESS = "drittkorrektur_in_progress" + COMPLETED = "completed" +``` + +### 11.3 Visibility-Regeln (aus bundeslaender.json) + +| Modus | ZK sieht EK-Annotations | ZK sieht EK-Note | ZK sieht EK-Gutachten | +|-------|-------------------------|------------------|----------------------| +| `blind` | Nein | Nein | Nein | +| `semi` (Bayern) | Ja | Nein | Nein | +| `full` (NI, Default) | Ja | Ja | Ja | + +### 11.4 Konsens-Regeln + +| Differenz EK-ZK | Aktion | +|-----------------|--------| +| 0-2 Punkte | Auto-Konsens (Durchschnitt) | +| 3 Punkte | Einigung erforderlich | +| >= 4 Punkte | Drittkorrektur erforderlich | + +--- + +## 12. Aktueller Stand + +**Datum**: 2026-01-21 +**Phase**: Alle Phasen abgeschlossen +**Status**: MVP komplett - bereit fuer Produktionstest + +### Abgeschlossen: +- [x] Phase 1: Korrektur-Workspace MVP +- [x] Phase 1.1: Grundgerüst (Klausur-Liste, Studenten-Liste) +- [x] Phase 1.2: Annotations-System +- [x] Phase 1.3: RS/Grammatik Overlays +- [x] Phase 1.4: EH-Vorschläge via RAG +- [x] Phase 2.1 Backend: Zweitkorrektur-Endpoints +- [x] Phase 2.2 Backend: Einigung-Endpoint +- [x] Phase 2.3 Backend: Drittkorrektur-Trigger +- [x] Phase 2.1 Frontend: ZK-Modus UI +- [x] Phase 2.2 Frontend: Einigung-Screen +- [x] Phase 3.1: Fairness-Dashboard Frontend +- [x] Phase 3.2: Ausreißer-Liste mit Quick-Adjust +- [x] Phase 3.3: Noten-Histogramm & Heatmap +- [x] Phase 4.1: PDF-Export Backend (reportlab) +- [x] Phase 4.2: PDF-Export Frontend +- [x] Phase 4.3: Vorabitur-Modus mit EH-Templates + +### URLs: +- Klausur-Korrektur: `/admin/klausur-korrektur` +- Fairness-Dashboard: `/admin/klausur-korrektur/[klausurId]/fairness` + +### PDF-Export Endpoints: +- `GET /api/v1/students/{id}/export/gutachten` - Einzelnes Gutachten als PDF +- `GET /api/v1/students/{id}/export/annotations` - Anmerkungen als PDF +- `GET /api/v1/klausuren/{id}/export/overview` - Notenübersicht als PDF +- `GET /api/v1/klausuren/{id}/export/all-gutachten` - Alle Gutachten als PDF + +### Vorabitur-Modus Endpoints: +- `GET /api/v1/vorabitur/templates` - Liste aller EH-Templates +- `GET /api/v1/vorabitur/templates/{aufgabentyp}` - Template-Details +- `POST /api/v1/klausuren/{id}/vorabitur-eh` - Custom EH erstellen +- `GET /api/v1/klausuren/{id}/vorabitur-eh` - Verknuepften EH abrufen +- `PUT /api/v1/klausuren/{id}/vorabitur-eh` - EH aktualisieren + +### Verfuegbare Aufgabentypen: +- `textanalyse_pragmatisch` - Sachtexte, Reden, Kommentare +- `gedichtanalyse` - Lyrik/Gedichte +- `prosaanalyse` - Romane, Kurzgeschichten +- `dramenanalyse` - Dramatische Texte +- `eroerterung_textgebunden` - Textgebundene Eroerterung + +--- + +## 13. Lehrer-Anleitung (Schritt-fuer-Schritt) + +### 13.1 Zugang zum System + +**Weg 1: Ueber das Haupt-Dashboard** +1. Oeffnen Sie `http://macmini:8000/app` im Browser +2. Klicken Sie auf die Kachel "Abiturklausuren" +3. Sie werden automatisch zur Korrektur-Oberflaeche weitergeleitet + +**Weg 2: Direkter Zugang** +1. Oeffnen Sie direkt `http://macmini:3000/admin/klausur-korrektur` + +### 13.2 Zwei Einstiegs-Optionen + +Beim ersten Besuch sehen Sie die Willkommens-Seite mit zwei Optionen: + +#### Option A: Schnellstart (Direkt hochladen) +- Ideal wenn Sie sofort loslegen moechten +- Keine manuelle Klausur-Erstellung erforderlich +- System erstellt automatisch eine Klausur im Hintergrund + +**Schritte:** +1. Klicken Sie auf "Schnellstart - Direkt hochladen" +2. **Schritt 1**: Ziehen Sie Ihre eingescannten Arbeiten (PDF/JPG/PNG) in den Upload-Bereich +3. **Schritt 2**: Optional - Waehlen Sie den Aufgabentyp und beschreiben Sie die Aufgabenstellung +4. **Schritt 3**: Pruefen Sie die Zusammenfassung und klicken "Korrektur starten" +5. Sie werden automatisch zur Korrektur-Ansicht weitergeleitet + +#### Option B: Neue Klausur erstellen (Standard) +- Empfohlen fuer regelmaessige Nutzung +- Volle Metadaten (Fach, Jahr, Kurs, Modus) +- Unterstuetzt Zweitkorrektur-Workflow + +**Schritte:** +1. Klicken Sie auf "Neue Klausur erstellen" +2. Geben Sie Titel, Fach, Jahr und Semester ein +3. Waehlen Sie den Modus: + - **Abitur**: Fuer offizielle Abitur-Pruefungen mit NiBiS-EH + - **Vorabitur**: Fuer Uebungsklausuren mit eigenem EH +4. Bei Vorabitur: Waehlen Sie Aufgabentyp und beschreiben Sie die Aufgabenstellung +5. Klicken Sie "Klausur erstellen" + +### 13.3 Arbeiten hochladen + +Nach Erstellung der Klausur: +1. Oeffnen Sie die Klausur aus der Liste +2. Klicken Sie "Arbeiten hochladen" +3. Waehlen Sie die eingescannten Dateien (PDF oder Bilder) +4. Geben Sie optional anonyme IDs (z.B. "Arbeit-1", "Arbeit-2") +5. Das System startet automatisch die OCR-Erkennung + +### 13.4 Korrigieren + +**Korrektur-Workspace (2/3-1/3 Layout):** +- Links (2/3): Das Originaldokument mit Zoom-Funktion +- Rechts (1/3): Bewertungspanel mit Kriterien + +**Schritt fuer Schritt:** +1. Oeffnen Sie eine Arbeit durch Klick auf "Korrigieren" +2. Lesen Sie die Arbeit im linken Bereich (Zoom mit +/-) +3. Setzen Sie Anmerkungen durch Klick auf das Dokument +4. Waehlen Sie den Anmerkungstyp: + - **RS** (rot): Rechtschreibfehler + - **Gram** (blau): Grammatikfehler + - **Inhalt** (gruen): Inhaltliche Anmerkungen + - **Kommentar**: Allgemeine Bemerkungen +5. Bewerten Sie die 5 Kriterien im rechten Panel: + - Rechtschreibung (15%) + - Grammatik (15%) + - Inhalt (40%) + - Struktur (15%) + - Stil (15%) +6. Klicken Sie "EH-Vorschlaege laden" fuer KI-Unterstuetzung +7. Klicken Sie "Gutachten generieren" fuer einen KI-Vorschlag +8. Bearbeiten Sie das Gutachten nach Bedarf +9. Klicken Sie "Speichern" und dann "Naechste Arbeit" + +### 13.5 Fairness-Analyse + +Nach Korrektur mehrerer Arbeiten: +1. Klicken Sie auf "Fairness-Dashboard" in der Klausur-Ansicht +2. Pruefen Sie: + - **Noten-Histogramm**: Ist die Verteilung realistisch? + - **Ausreisser**: Gibt es ungewoehnlich hohe/niedrige Noten? + - **Kriterien-Heatmap**: Sind Kriterien konsistent bewertet? +3. Nutzen Sie "Quick-Adjust" um Anpassungen vorzunehmen + +### 13.6 PDF-Export + +1. In der Klausur-Ansicht klicken Sie "PDF-Export" +2. Waehlen Sie: + - **Einzelgutachten**: PDF fuer einen Schueler + - **Alle Gutachten**: Gesamtes PDF fuer alle Arbeiten + - **Notenuebersicht**: Uebersicht aller Noten + - **Anmerkungen**: Alle Annotationen als PDF + +### 13.7 Zweitkorrektur (Optional) + +Fuer offizielle Abitur-Klausuren: +1. Erstkorrektur abschliessen (Status: "Abgeschlossen") +2. Klicken Sie "Zweitkorrektur starten" +3. Der Zweitkorrektor bewertet unabhaengig +4. Bei Differenz >= 3 Punkte: Einigung erforderlich +5. Bei Differenz >= 4 Punkte: Drittkorrektur wird automatisch ausgeloest + +### 13.8 Haeufige Fragen + +**F: Kann ich eine Korrektur unterbrechen und spaeter fortsetzen?** +A: Ja, alle Aenderungen werden automatisch gespeichert. + +**F: Was passiert mit meinen Daten?** +A: Alle Daten werden lokal auf dem Schulserver gespeichert. Keine Cloud-Speicherung. + +**F: Kann ich den KI-Vorschlag komplett ueberschreiben?** +A: Ja, das Gutachten ist frei editierbar. Der KI-Vorschlag ist nur ein Startpunkt. + +**F: Wie funktioniert die OCR-Erkennung?** +A: Das System erkennt Handschrift automatisch. Bei schlechter Lesbarkeit koennen Sie manuell nachbessern. + +--- + +## 14. Integration Dashboard (Port 8000) + +### 14.1 Aenderungen in dashboard.py + +Die Funktion `openKlausurService()` wurde aktualisiert: + +```javascript +// Alte Version: Oeffnete Port 8086 (Backend) +// Neue Version: Oeffnet Port 3000 (Next.js Frontend) +function openKlausurService() { + let baseUrl; + if (window.location.hostname === 'macmini') { + baseUrl = 'http://macmini:3000'; + } else { + baseUrl = 'http://localhost:3000'; + } + window.open(baseUrl + '/admin/klausur-korrektur', '_blank'); +} +``` + +### 14.2 Neue Frontend-Features + +- **Willkommens-Tab**: Erster Tab fuer neue Benutzer mit Workflow-Erklaerung +- **Direktupload-Wizard**: 3-Schritt-Wizard fuer Schnellstart +- **Drag & Drop**: Arbeiten per Drag & Drop hochladen +- **localStorage-Persistenz**: System merkt sich wiederkehrende Benutzer diff --git a/.claude/rules/experimental-dashboard.md b/.claude/rules/experimental-dashboard.md new file mode 100644 index 0000000..2206259 --- /dev/null +++ b/.claude/rules/experimental-dashboard.md @@ -0,0 +1,250 @@ +# Experimental Dashboard - Apple Weather Style UI + +**Status:** In Entwicklung +**Letzte Aktualisierung:** 2026-01-24 +**URL:** http://macmini:3001/dashboard-experimental + +--- + +## Uebersicht + +Das Experimental Dashboard implementiert einen **Apple Weather App Style** mit: +- Ultra-transparenten Glassmorphism-Cards (~8% Opacity) +- Dunklem Sternenhimmel-Hintergrund mit Parallax +- Weisser Schrift auf monochromem Design +- Schwebenden Nachrichten (FloatingMessage) mit ~4% Background +- Nuetzlichen Widgets: Uhr, Wetter, Kompass, Diagramme + +--- + +## Design-Prinzipien + +| Prinzip | Umsetzung | +|---------|-----------| +| **Transparenz** | Cards mit 8% Opacity, Messages mit 4% | +| **Verschmelzung** | Elemente verschmelzen mit dem Hintergrund | +| **Monochrom** | Weisse Schrift, keine bunten Akzente | +| **Subtilitaet** | Dezente Hover-Effekte, sanfte Animationen | +| **Nuetzlichkeit** | Echte Informationen (Uhrzeit, Wetter) | + +--- + +## Dateistruktur + +``` +/studio-v2/ +├── app/ +│ └── dashboard-experimental/ +│ └── page.tsx # Haupt-Dashboard (740 Zeilen) +│ +├── components/ +│ └── spatial-ui/ +│ ├── index.ts # Exports +│ ├── SpatialCard.tsx # Original SpatialCard (nicht verwendet) +│ └── FloatingMessage.tsx # Schwebende Nachrichten +│ +└── lib/ + └── spatial-ui/ + ├── index.ts # Exports + ├── depth-system.ts # Design Tokens + ├── PerformanceContext.tsx # Adaptive Qualitaet + └── FocusContext.tsx # Focus-Modus +``` + +--- + +## Komponenten + +### GlassCard +Ultra-transparente Card fuer alle Inhalte. + +```typescript +interface GlassCardProps { + children: React.ReactNode + className?: string + onClick?: () => void + size?: 'sm' | 'md' | 'lg' // Padding: 16px, 20px, 24px + delay?: number // Einblend-Verzoegerung in ms +} +``` + +**Styling:** +- Background: `rgba(255, 255, 255, 0.08)` (8%) +- Hover: `rgba(255, 255, 255, 0.12)` (12%) +- Border: `1px solid rgba(255, 255, 255, 0.1)` +- Blur: 24px (adaptiv) +- Border-Radius: 24px (rounded-3xl) + +### AnalogClock +Analoge Uhr mit Sekundenzeiger. + +- Stunden-Zeiger: Weiss, dick +- Minuten-Zeiger: Weiss/80%, duenn +- Sekunden-Zeiger: Orange (#fb923c) +- 12 Stundenmarkierungen +- Aktualisiert jede Sekunde + +### Compass +Kompass im Apple Weather Style. + +```typescript +interface CompassProps { + direction?: number // Grad (0 = Nord, 90 = Ost, etc.) +} +``` + +- Nord-Nadel: Rot (#ef4444) +- Sued-Nadel: Weiss +- Kardinalrichtungen: N (rot), S, W, O + +### BarChart +Balkendiagramm fuer Wochen-Statistiken. + +```typescript +interface BarChartProps { + data: { label: string; value: number; highlight?: boolean }[] + maxValue?: number +} +``` + +- Highlight-Balken mit Gradient (blau → lila) +- Normale Balken: 20% weiss +- Labels unten, Werte oben + +### ProgressRing +Kreisfoermiger Fortschrittsanzeiger. + +```typescript +interface ProgressRingProps { + progress: number // 0-100 + size?: number // Default: 80px + strokeWidth?: number // Default: 6px + label: string + value: string + color?: string // Farbe des Fortschritts +} +``` + +### TemperatureDisplay +Wetter-Anzeige mit Icon und Temperatur. + +```typescript +interface TemperatureDisplayProps { + temp: number + condition: 'sunny' | 'cloudy' | 'rainy' | 'snowy' | 'partly_cloudy' +} +``` + +### FloatingMessage +Schwebende Benachrichtigungen von rechts. + +**Aktuell:** +- Background: 4% Opacity +- Blur: 24px +- Border: `1px solid rgba(255, 255, 255, 0.12)` +- Auto-Dismiss mit Progress-Bar +- 3 Antwort-Optionen: Antworten, Oeffnen, Spaeter +- Typewriter-Effekt fuer Text + +--- + +## Farbpalette + +| Element | Wert | +|---------|------| +| Background | `from-slate-900 via-indigo-950 to-slate-900` | +| Card Background | `rgba(255, 255, 255, 0.08)` | +| Card Hover | `rgba(255, 255, 255, 0.12)` | +| Message Background | `rgba(255, 255, 255, 0.04)` | +| Border | `rgba(255, 255, 255, 0.1)` | +| Text Primary | `text-white` | +| Text Secondary | `text-white/50` bis `text-white/40` | +| Accent Blue | `#60a5fa` | +| Accent Purple | `#a78bfa` | +| Accent Orange | `#fb923c` (Sekundenzeiger) | +| Accent Red | `#ef4444` (Kompass Nord) | + +--- + +## Performance-System + +Das Dashboard nutzt das **PerformanceContext** fuer adaptive Qualitaet: + +| Quality Level | Blur | Parallax | Animationen | +|---------------|------|----------|-------------| +| high | 24px | Ja | Spring | +| medium | 17px | Ja | Standard | +| low | 0px | Nein | Reduziert | +| minimal | 0px | Nein | Keine | + +**FPS-Monitor** unten links zeigt: +- Aktuelle FPS +- Quality Level +- Blur/Parallax Status + +--- + +## Deployment + +```bash +# 1. Sync zu Mac Mini +rsync -avz --delete \ + --exclude 'node_modules' --exclude '.next' --exclude '.git' \ + /Users/benjaminadmin/Projekte/breakpilot-pwa/studio-v2/ \ + macmini:/Users/benjaminadmin/Projekte/breakpilot-pwa/studio-v2/ + +# 2. Build +ssh macmini "/usr/local/bin/docker compose \ + -f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \ + build --no-cache studio-v2" + +# 3. Deploy +ssh macmini "/usr/local/bin/docker compose \ + -f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \ + up -d studio-v2" + +# 4. Testen +http://macmini:3001/dashboard-experimental +``` + +--- + +## Offene Punkte / Ideen + +### Kurzfristig +- [ ] Echte Wetterdaten via API integrieren +- [ ] Kompass-Richtung dynamisch (GPS oder manuell) +- [ ] Klick auf Cards fuehrt zu Detailseiten +- [ ] Light Mode Support (aktuell nur Dark) + +### Mittelfristig +- [ ] Drag & Drop fuer Card-Anordnung +- [ ] Weitere Widgets: Kalender, Termine, Erinnerungen +- [ ] Animierte Uebergaenge zwischen Seiten +- [ ] Sound-Feedback bei Interaktionen + +### Langfristig +- [ ] Personalisierbare Widgets +- [ ] Dashboard als Standard-Startseite +- [ ] Mobile-optimierte Version +- [ ] Integration mit Apple Health / Fitness Daten + +--- + +## Referenzen + +- **Apple Weather App** (iOS) - Hauptinspiration +- **Dribbble Shot:** https://dribbble.com/shots/26339637-Smart-Home-Dashboard-Glassmorphism-UI +- **Design Tokens:** `/studio-v2/lib/spatial-ui/depth-system.ts` + +--- + +## Aenderungshistorie + +| Datum | Aenderung | +|-------|-----------| +| 2026-01-24 | FloatingMessage auf 4% Opacity reduziert | +| 2026-01-24 | Kompass, Balkendiagramm, Analog-Uhr hinzugefuegt | +| 2026-01-24 | Cards auf 8% Opacity reduziert | +| 2026-01-24 | Apple Weather Style implementiert | +| 2026-01-24 | Erstes Spatial UI System erstellt | diff --git a/.claude/rules/multi-agent-architecture.md b/.claude/rules/multi-agent-architecture.md new file mode 100644 index 0000000..014f27c --- /dev/null +++ b/.claude/rules/multi-agent-architecture.md @@ -0,0 +1,295 @@ +# Multi-Agent Architektur - Entwicklerdokumentation + +**Status:** Implementiert +**Letzte Aktualisierung:** 2025-01-15 +**Modul:** `/agent-core/` + +--- + +## 1. Übersicht + +Die Multi-Agent-Architektur erweitert Breakpilot um ein verteiltes Agent-System basierend auf Mission Control Konzepten. + +### Kernkomponenten + +| Komponente | Pfad | Beschreibung | +|------------|------|--------------| +| Session Management | `/agent-core/sessions/` | Lifecycle & Recovery | +| Shared Brain | `/agent-core/brain/` | Langzeit-Gedächtnis | +| Orchestrator | `/agent-core/orchestrator/` | Koordination | +| SOUL Files | `/agent-core/soul/` | Agent-Persönlichkeiten | + +--- + +## 2. Agent-Typen + +| Agent | Aufgabe | SOUL-Datei | +|-------|---------|------------| +| **TutorAgent** | Lernbegleitung, Fragen beantworten | `tutor-agent.soul.md` | +| **GraderAgent** | Klausur-Korrektur, Bewertung | `grader-agent.soul.md` | +| **QualityJudge** | BQAS Qualitätsprüfung | `quality-judge.soul.md` | +| **AlertAgent** | Monitoring, Benachrichtigungen | `alert-agent.soul.md` | +| **Orchestrator** | Task-Koordination | `orchestrator.soul.md` | + +--- + +## 3. Wichtige Dateien + +### Session Management +``` +agent-core/sessions/ +├── session_manager.py # AgentSession, SessionManager, SessionState +├── heartbeat.py # HeartbeatMonitor, HeartbeatClient +└── checkpoint.py # CheckpointManager +``` + +### Shared Brain +``` +agent-core/brain/ +├── memory_store.py # MemoryStore, Memory (mit TTL) +├── context_manager.py # ConversationContext, ContextManager +└── knowledge_graph.py # KnowledgeGraph, Entity, Relationship +``` + +### Orchestrator +``` +agent-core/orchestrator/ +├── message_bus.py # MessageBus, AgentMessage, MessagePriority +├── supervisor.py # AgentSupervisor, AgentInfo, AgentStatus +└── task_router.py # TaskRouter, RoutingRule, RoutingResult +``` + +--- + +## 4. Datenbank-Schema + +Die Migration befindet sich in: +`/backend/migrations/add_agent_core_tables.sql` + +### Tabellen + +1. **agent_sessions** - Session-Daten mit Checkpoints +2. **agent_memory** - Langzeit-Gedächtnis mit TTL +3. **agent_messages** - Audit-Trail für Inter-Agent Kommunikation + +### Helper-Funktionen + +```sql +-- Abgelaufene Memories bereinigen +SELECT cleanup_expired_agent_memory(); + +-- Inaktive Sessions bereinigen +SELECT cleanup_stale_agent_sessions(48); -- 48 Stunden +``` + +--- + +## 5. Integration Voice-Service + +Der `EnhancedTaskOrchestrator` erweitert den bestehenden `TaskOrchestrator`: + +```python +# voice-service/services/enhanced_task_orchestrator.py + +from agent_core.sessions import SessionManager +from agent_core.orchestrator import MessageBus + +class EnhancedTaskOrchestrator(TaskOrchestrator): + # Nutzt Session-Checkpoints für Recovery + # Routet komplexe Tasks an spezialisierte Agents + # Führt Quality-Checks via BQAS durch +``` + +**Wichtig:** Der Enhanced Orchestrator ist abwärtskompatibel und kann parallel zum Original verwendet werden. + +--- + +## 6. Integration BQAS + +Der `QualityJudgeAgent` integriert BQAS mit dem Multi-Agent-System: + +```python +# voice-service/bqas/quality_judge_agent.py + +from bqas.judge import LLMJudge +from agent_core.orchestrator import MessageBus + +class QualityJudgeAgent: + # Wertet Responses in Echtzeit aus + # Nutzt Memory für konsistente Bewertungen + # Empfängt Evaluierungs-Requests via Message Bus +``` + +--- + +## 7. Code-Beispiele + +### Session erstellen + +```python +from agent_core.sessions import SessionManager + +manager = SessionManager(redis_client=redis, db_pool=pool) +session = await manager.create_session( + agent_type="tutor-agent", + user_id="user-123" +) +``` + +### Memory speichern + +```python +from agent_core.brain import MemoryStore + +store = MemoryStore(redis_client=redis, db_pool=pool) +await store.remember( + key="student:123:progress", + value={"level": 5, "score": 85}, + agent_id="tutor-agent", + ttl_days=30 +) +``` + +### Nachricht senden + +```python +from agent_core.orchestrator import MessageBus, AgentMessage + +bus = MessageBus(redis_client=redis) +await bus.publish(AgentMessage( + sender="orchestrator", + receiver="grader-agent", + message_type="grade_request", + payload={"exam_id": "exam-1"} +)) +``` + +--- + +## 8. Tests ausführen + +```bash +# Alle Agent-Core Tests +cd agent-core && pytest -v + +# Mit Coverage-Report +pytest --cov=. --cov-report=html + +# Einzelne Module +pytest tests/test_session_manager.py -v +pytest tests/test_message_bus.py -v +``` + +--- + +## 9. Deployment-Schritte + +### 1. Migration ausführen + +```bash +psql -h localhost -U breakpilot -d breakpilot \ + -f backend/migrations/add_agent_core_tables.sql +``` + +### 2. Voice-Service aktualisieren + +```bash +# Sync zu Server +rsync -avz --exclude 'node_modules' --exclude '.git' \ + /path/to/breakpilot-pwa/ server:/path/to/breakpilot-pwa/ + +# Container neu bauen +docker compose build --no-cache voice-service + +# Starten +docker compose up -d voice-service +``` + +### 3. Verifizieren + +```bash +# Session-Tabelle prüfen +psql -c "SELECT COUNT(*) FROM agent_sessions;" + +# Memory-Tabelle prüfen +psql -c "SELECT COUNT(*) FROM agent_memory;" +``` + +--- + +## 10. Monitoring + +### Metriken + +| Metrik | Beschreibung | +|--------|--------------| +| `agent_session_count` | Anzahl aktiver Sessions | +| `agent_heartbeat_delay_ms` | Zeit seit letztem Heartbeat | +| `agent_message_latency_ms` | Nachrichtenlatenz | +| `agent_memory_count` | Gespeicherte Memories | +| `agent_routing_success_rate` | Erfolgreiche Routings | + +### Health-Check-Endpunkte + +``` +GET /api/v1/agents/health # Supervisor Status +GET /api/v1/agents/sessions # Aktive Sessions +GET /api/v1/agents/memory/stats # Memory-Statistiken +``` + +--- + +## 11. Troubleshooting + +### Problem: Session nicht gefunden + +1. Prüfen ob Valkey läuft: `redis-cli ping` +2. Session-Timeout prüfen (default 24h) +3. Heartbeat-Status checken + +### Problem: Message Bus Timeout + +1. Redis Pub/Sub Status prüfen +2. Ziel-Agent registriert? +3. Timeout erhöhen (default 30s) + +### Problem: Memory nicht gefunden + +1. Namespace korrekt? +2. TTL abgelaufen? +3. Cleanup-Job gelaufen? + +--- + +## 12. Erweiterungen + +### Neuen Agent hinzufügen + +1. SOUL-Datei erstellen in `/agent-core/soul/` +2. Routing-Regel in `task_router.py` hinzufügen +3. Handler beim Supervisor registrieren +4. Tests schreiben + +### Neuen Memory-Typ hinzufügen + +1. Key-Schema definieren (z.B. `student:*:progress`) +2. TTL festlegen +3. Access-Pattern dokumentieren + +--- + +## 13. Referenzen + +- **Agent-Core README:** `/agent-core/README.md` +- **Migration:** `/backend/migrations/add_agent_core_tables.sql` +- **Voice-Service Integration:** `/voice-service/services/enhanced_task_orchestrator.py` +- **BQAS Integration:** `/voice-service/bqas/quality_judge_agent.py` +- **Tests:** `/agent-core/tests/` + +--- + +## 14. Änderungshistorie + +| Datum | Version | Änderung | +|-------|---------|----------| +| 2025-01-15 | 1.0.0 | Initial Release | diff --git a/.claude/rules/vocab-worksheet.md b/.claude/rules/vocab-worksheet.md new file mode 100644 index 0000000..2268ab3 --- /dev/null +++ b/.claude/rules/vocab-worksheet.md @@ -0,0 +1,205 @@ +# Vokabel-Arbeitsblatt Generator - Entwicklerdokumentation + +**Status:** Produktiv +**Letzte Aktualisierung:** 2026-02-08 +**URL:** https://macmini/vocab-worksheet + +--- + +## Uebersicht + +Der Vokabel-Arbeitsblatt Generator ermoeglicht Lehrern: +- Schulbuchseiten (PDF/Bild) zu scannen +- Vokabeln automatisch per OCR zu extrahieren +- Druckfertige Arbeitsblaetter in verschiedenen Formaten zu generieren + +--- + +## Architektur + +``` +Browser (studio-v2) klausur-service (Port 8086) PostgreSQL + │ │ │ + │ POST /upload-pdf-info │ │ + │ POST /process-single-page │ │ + │ POST /generate │ │ + │ POST /generate-nru │ ──── vocab_sessions ──────▶│ + │ GET /worksheets/{id}/pdf │ ──── vocab_entries ───────▶│ + │ │ ──── vocab_worksheets ────▶│ + └────────────────────────────┘ │ +``` + +--- + +## Arbeitsblatt-Formate + +### Standard-Format + +Klassisches Arbeitsblatt mit waehlbaren Uebungstypen: +- **Englisch → Deutsch**: Englische Woerter uebersetzen +- **Deutsch → Englisch**: Deutsche Woerter uebersetzen +- **Abschreibuebung**: Woerter mehrfach schreiben +- **Lueckensaetze**: Saetze mit Luecken ausfuellen + +### NRU-Format (Neu: 2026-02-08) + +Spezielles Format fuer strukturiertes Vokabellernen: + +**Seite 1 (pro gescannter Seite): Vokabeltabelle** +| Englisch | Deutsch | Korrektur | +|----------|---------|-----------| +| word | (leer) | (leer) | + +- Kind schreibt deutsche Uebersetzung +- Eltern korrigieren, Kind schreibt ggf. korrigierte Version + +**Seite 2 (pro gescannter Seite): Lernsaetze** +| Deutscher Satz | +|-----------------------------------| +| (2 leere Zeilen fuer EN-Uebersetzung) | + +- Deutscher Satz vorgegeben +- Kind schreibt englische Uebersetzung + +**Automatische Trennung:** +- Einzelwoerter/Phrasen → Vokabeltabelle +- Saetze (enden mit `.!?` oder > 50 Zeichen) → Lernsaetze + +--- + +## API-Endpoints + +### Standard-Format +``` +POST /api/v1/vocab/sessions/{session_id}/generate +Body: { + "worksheet_types": ["en_to_de", "de_to_en", "copy", "gap_fill"], + "title": "Vokabeln Unit 3", + "include_solutions": true, + "line_height": "normal" | "large" | "extra-large" +} +Response: { "id": "worksheet-uuid", ... } +``` + +### NRU-Format +``` +POST /api/v1/vocab/sessions/{session_id}/generate-nru +Body: { + "title": "Vokabeltest", + "include_solutions": true, + "specific_pages": [1, 2] // optional, 1-indexed +} +Response: { + "worksheet_id": "uuid", + "statistics": { + "total_entries": 96, + "vocabulary_count": 75, + "sentence_count": 21, + "source_pages": [1, 2, 3], + "worksheet_pages": 6 + }, + "download_url": "/api/v1/vocab/worksheets/{id}/pdf", + "solution_url": "/api/v1/vocab/worksheets/{id}/solution" +} +``` + +### PDF-Download +``` +GET /api/v1/vocab/worksheets/{worksheet_id}/pdf +GET /api/v1/vocab/worksheets/{worksheet_id}/solution +``` + +--- + +## Dateien + +### Backend (klausur-service) + +| Datei | Beschreibung | +|-------|--------------| +| `vocab_worksheet_api.py` | Haupt-API Router mit allen Endpoints | +| `nru_worksheet_generator.py` | NRU-Format HTML/PDF Generator | +| `vocab_session_store.py` | PostgreSQL Datenbankoperationen | +| `hybrid_vocab_extractor.py` | OCR-Extraktion (PaddleOCR + LLM) | +| `tesseract_vocab_extractor.py` | Tesseract OCR Fallback | + +### Frontend (studio-v2) + +| Datei | Beschreibung | +|-------|--------------| +| `app/vocab-worksheet/page.tsx` | Haupt-UI mit Template-Auswahl | + +--- + +## Datenbank-Schema + +```sql +-- Sessions +CREATE TABLE vocab_sessions ( + id UUID PRIMARY KEY, + name VARCHAR(255), + status VARCHAR(50), + vocabulary_count INT, + source_language VARCHAR(10), + target_language VARCHAR(10), + created_at TIMESTAMP +); + +-- Vokabeln +CREATE TABLE vocab_entries ( + id UUID PRIMARY KEY, + session_id UUID REFERENCES vocab_sessions(id), + english TEXT, + german TEXT, + example_sentence TEXT, + source_page INT, + source_row INT, + source_column INT +); + +-- Generierte Arbeitsblaetter +CREATE TABLE vocab_worksheets ( + id UUID PRIMARY KEY, + session_id UUID REFERENCES vocab_sessions(id), + worksheet_types JSONB, + pdf_path VARCHAR(500), + solution_path VARCHAR(500), + generated_at TIMESTAMP +); +``` + +--- + +## Deployment + +```bash +# 1. Backend synchronisieren +rsync -avz klausur-service/backend/ macmini:.../klausur-service/backend/ + +# 2. Frontend synchronisieren +rsync -avz studio-v2/app/vocab-worksheet/ macmini:.../studio-v2/app/vocab-worksheet/ + +# 3. Container neu bauen +ssh macmini "docker compose build --no-cache klausur-service studio-v2" + +# 4. Container starten +ssh macmini "docker compose up -d klausur-service studio-v2" +``` + +--- + +## Erweiterung: Neue Formate hinzufuegen + +1. **Backend**: Neuen Generator in `klausur-service/backend/` erstellen +2. **API**: Neuen Endpoint in `vocab_worksheet_api.py` hinzufuegen +3. **Frontend**: Format zu `worksheetFormats` Array in `page.tsx` hinzufuegen +4. **Doku**: Diese Datei aktualisieren + +--- + +## Aenderungshistorie + +| Datum | Aenderung | +|-------|-----------| +| 2026-02-08 | NRU-Format und Template-Auswahl hinzugefuegt | +| 2026-02-07 | Initiale Implementierung mit Standard-Format | diff --git a/.claude/session-status-2026-01-25.md b/.claude/session-status-2026-01-25.md new file mode 100644 index 0000000..4162563 --- /dev/null +++ b/.claude/session-status-2026-01-25.md @@ -0,0 +1,117 @@ +# Session Status - 25. Januar 2026 (Aktualisiert) + +## Zusammenfassung + +Open Data School Import erfolgreich implementiert. Schulbestand von 17,610 auf 30,355 erhoeht. + +--- + +## Erledigte Aufgaben + +### 1. Studio-v2 Build-Fehler (Vorherige Session) +- **Status:** Erledigt +- **Problem:** `Module not found: Can't resolve 'pdf-lib'` +- **Loesung:** Falsches package.json auf macmini ersetzt, rsync mit --delete + +### 2. Open Data School Importer +- **Status:** Erledigt +- **Datei:** `/edu-search-service/scripts/import_open_data.py` +- **Erfolgreich importiert:** + - **NRW:** 5,637 Schulen (CSV von schulministerium.nrw.de) + - **Berlin:** 930 Schulen (WFS/GeoJSON von gdi.berlin.de) + - **Hamburg:** 543 Schulen (WFS/GML von geodienste.hamburg.de) + +--- + +## Aktuelle Schulstatistiken + +``` +Total: 30,355 Schulen + +Nach Bundesland: + NW: 14,962 (inkl. Open Data Import) + BY: 2,803 + NI: 2,192 + BE: 1,475 (inkl. WFS Import) + SN: 1,425 + SH: 1,329 + HE: 1,290 + RP: 1,066 + HH: 902 (inkl. WFS Import) + TH: 799 + BB: 562 + SL: 533 + MV: 367 + ST: 250 + BW: 200 (nur JedeSchule.de - BW Daten kostenpflichtig!) + HB: 200 +``` + +--- + +## Open Data Importer - Verfuegbare Quellen + +| Bundesland | Status | Quelle | Format | +|------------|--------|--------|--------| +| NW | Funktioniert | schulministerium.nrw.de | CSV | +| BE | Funktioniert | gdi.berlin.de | WFS/GeoJSON | +| HH | Funktioniert | geodienste.hamburg.de | WFS/GML | +| SN | 404 Error | schuldatenbank.sachsen.de | API | +| BW | Kostenpflichtig | LOBW | - | +| BY | Kein Open Data | - | - | + +--- + +## Importer-Nutzung + +```bash +# Alle verfuegbaren Quellen importieren +cd /Users/benjaminadmin/Projekte/breakpilot-pwa/edu-search-service/scripts +python3 import_open_data.py --all --url http://macmini:8088 + +# Einzelnes Bundesland (Dry-Run) +python3 import_open_data.py --state NW --dry-run + +# Mit Server-URL +python3 import_open_data.py --state HH --url http://macmini:8088 +``` + +--- + +## Offene Punkte + +### Bundeslaender ohne Open Data +- **BW:** Schuldaten muessen GEKAUFT werden (LOBW) +- **BY:** Keine Open Data API gefunden +- **NI, HE, RP, etc.:** Keine zentralen Open Data Quellen bekannt + +### Moegliche weitere Quellen +- OSM (OpenStreetMap) - amenity=school +- Statistisches Bundesamt +- Lokale Schultraeger-Verzeichnisse + +--- + +## Container-Status auf macmini + +| Container | Port | Status | +|-----------|------|--------| +| website | 3000 | Laeuft | +| studio-v2 | 3001 | Laeuft | +| edu-search-service | 8088 | Laeuft | + +--- + +## Wichtige URLs + +- School Directory: http://macmini:3000/admin/school-directory +- School Stats API: http://macmini:8088/api/v1/schools/stats +- School Search API: http://macmini:8088/api/v1/schools?q=NAME + +--- + +## Naechste moegliche Schritte + +1. **OSM Import testen** - OpenStreetMap hat Schuldaten (amenity=school) +2. **Weitere WFS-Quellen suchen** - Andere Bundeslaender koennten Geo-Portale haben +3. **Deduplizierung** - Pruefen ob durch multiple Imports Duplikate entstanden sind diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..28fff71 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,82 @@ +{ + "permissions": { + "allow": [ + "Bash(textutil -convert txt:*)", + "Bash(find:*)", + "Bash(grep:*)", + "Bash(wc:*)", + "Bash(/bin/bash -c \"source venv/bin/activate && pip install pyjwt --quiet 2>/dev/null && python -c \"\"import sys; sys.path.insert(0, ''.''); from llm_gateway.models.chat import ChatMessage; print(''Models import OK'')\"\"\")", + "Bash(/Users/benjaminadmin/Projekte/breakpilot-pwa/backend/venv/bin/python:*)", + "Bash(./venv/bin/pip install:*)", + "Bash(brew install:*)", + "Bash(brew services start:*)", + "Bash(ollama list:*)", + "Bash(ollama pull:*)", + "Bash(export LLM_GATEWAY_ENABLED=true)", + "Bash(export LLM_GATEWAY_DEBUG=true)", + "Bash(export LLM_API_KEYS=test-key-123)", + "Bash(export ANTHROPIC_API_KEY=\"$ANTHROPIC_API_KEY\")", + "Bash(source:*)", + "Bash(pytest:*)", + "Bash(./venv/bin/pytest:*)", + "Bash(python3 -m pytest:*)", + "Bash(export TAVILY_API_KEY=\"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\")", + "Bash(python3:*)", + "Bash(curl:*)", + "Bash(pip3 install:*)", + "WebSearch", + "Bash(export ALERTS_AGENT_ENABLED=true)", + "Bash(export LLM_API_KEYS=test-key)", + "WebFetch(domain:docs.vast.ai)", + "Bash(docker compose:*)", + "Bash(docker ps:*)", + "Bash(docker inspect:*)", + "Bash(docker logs:*)", + "Bash(ls:*)", + "Bash(docker exec:*)", + "WebFetch(domain:www.librechat.ai)", + "Bash(export TAVILY_API_KEY=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)", + "Bash(/Users/benjaminadmin/Projekte/breakpilot-pwa/backend/venv/bin/pip install:*)", + "Bash(/Users/benjaminadmin/Projekte/breakpilot-pwa/backend/venv/bin/pytest -v tests/test_integration/test_librechat_tavily.py -x)", + "WebFetch(domain:vast.ai)", + "Bash(/Users/benjaminadmin/Projekte/breakpilot-pwa/backend/venv/bin/pytest tests/test_infra/test_vast_client.py tests/test_infra/test_vast_power.py -v --tb=short)", + "Bash(go build:*)", + "Bash(go test:*)", + "Bash(npm install)", + "Bash(/usr/local/bin/node:*)", + "Bash(/opt/homebrew/bin/node --version)", + "Bash(docker --version:*)", + "Bash(docker build:*)", + "Bash(docker images:*)", + "Bash(/Users/benjaminadmin/Projekte/breakpilot-pwa/backend/venv/bin/pytest:*)", + "Bash(npm test:*)", + "Bash(/opt/homebrew/bin/node /opt/homebrew/bin/npm test -- --passWithNoTests)", + "Bash(/usr/libexec/java_home:*)", + "Bash(/opt/homebrew/bin/node:*)", + "Bash(docker restart:*)", + "Bash(tree:*)", + "Bash(go mod tidy:*)", + "Bash(go mod vendor:*)", + "Bash(python -m pytest:*)", + "Bash(lsof:*)", + "Bash(python scripts/load_initial_seeds.py:*)", + "Bash(python:*)", + "Bash(docker cp:*)", + "Bash(node --check:*)", + "Bash(cat:*)", + "Bash(DATABASE_URL='postgresql://breakpilot:breakpilot123@localhost:5432/breakpilot_db' python3:*)", + "Bash(docker volume:*)", + "Bash(docker stop:*)", + "Bash(docker rm:*)", + "Bash(docker run:*)", + "Bash(docker network:*)", + "Bash(breakpilot-edu-search:latest)", + "Bash(jq:*)", + "Bash(docker port:*)", + "Bash(/dev/null curl -X POST http://localhost:8086/v1/crawl/queue -H 'Authorization: Bearer dev-key' -H 'Content-Type: application/json' -d '{\"\"\"\"university_id\"\"\"\": \"\"\"\"783333a1-91a3-4015-9299-45d10537dae4\"\"\"\", \"\"\"\"priority\"\"\"\": 10}')", + "Bash(1)", + "WebFetch(domain:uol.de)", + "Bash(xargs:*)" + ] + } +} diff --git a/.docker/build-ci-images.sh b/.docker/build-ci-images.sh new file mode 100755 index 0000000..1f55393 --- /dev/null +++ b/.docker/build-ci-images.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Build CI Docker Images for BreakPilot +# Run this script on the Mac Mini to build the custom CI images + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +echo "=== Building BreakPilot CI Images ===" +echo "Project directory: $PROJECT_DIR" + +cd "$PROJECT_DIR" + +# Build Python CI image with WeasyPrint +echo "" +echo "Building breakpilot/python-ci:3.12 ..." +docker build \ + -t breakpilot/python-ci:3.12 \ + -t breakpilot/python-ci:latest \ + -f .docker/python-ci.Dockerfile \ + . + +echo "" +echo "=== Build complete ===" +echo "" +echo "Images built:" +docker images | grep breakpilot/python-ci + +echo "" +echo "To use in Woodpecker CI, the image is already configured in .woodpecker/main.yml" diff --git a/.docker/python-ci.Dockerfile b/.docker/python-ci.Dockerfile new file mode 100644 index 0000000..ff8cd4a --- /dev/null +++ b/.docker/python-ci.Dockerfile @@ -0,0 +1,51 @@ +# Custom Python CI Image with WeasyPrint Dependencies +# Build: docker build -t breakpilot/python-ci:3.12 -f .docker/python-ci.Dockerfile . +# +# This image includes all system libraries needed for: +# - WeasyPrint (PDF generation) +# - psycopg2 (PostgreSQL) +# - General Python testing + +FROM python:3.12-slim + +LABEL maintainer="BreakPilot Team" +LABEL description="Python 3.12 with WeasyPrint and test dependencies for CI" + +# Install system dependencies in a single layer +RUN apt-get update && apt-get install -y --no-install-recommends \ + # WeasyPrint dependencies + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + libpangoft2-1.0-0 \ + libgdk-pixbuf-2.0-0 \ + libffi-dev \ + libcairo2 \ + libcairo2-dev \ + libgirepository1.0-dev \ + gir1.2-pango-1.0 \ + # PostgreSQL client (for psycopg2) + libpq-dev \ + # Build tools (for some pip packages) + gcc \ + g++ \ + # Useful utilities + curl \ + git \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean + +# Pre-install commonly used Python packages for faster CI +RUN pip install --no-cache-dir \ + pytest \ + pytest-cov \ + pytest-asyncio \ + pytest-json-report \ + psycopg2-binary \ + weasyprint \ + httpx + +# Set working directory +WORKDIR /app + +# Default command +CMD ["python", "--version"] diff --git a/.env.dev b/.env.dev new file mode 100644 index 0000000..079df42 --- /dev/null +++ b/.env.dev @@ -0,0 +1,115 @@ +# ============================================ +# BreakPilot PWA - DEVELOPMENT Environment +# ============================================ +# Usage: cp .env.dev .env +# Or: ./scripts/env-switch.sh dev +# ============================================ + +# ============================================ +# Environment Identifier +# ============================================ +ENVIRONMENT=development +COMPOSE_PROJECT_NAME=breakpilot-dev + +# ============================================ +# HashiCorp Vault (Secrets Management) +# ============================================ +# In development, use the local Vault instance with dev token +VAULT_ADDR=http://localhost:8200 +VAULT_DEV_TOKEN=breakpilot-dev-token + +# ============================================ +# Database +# ============================================ +POSTGRES_USER=breakpilot +POSTGRES_PASSWORD=breakpilot_dev_123 +POSTGRES_DB=breakpilot_dev +DATABASE_URL=postgres://breakpilot:breakpilot_dev_123@postgres:5432/breakpilot_dev?sslmode=disable + +# Synapse DB (Matrix) +SYNAPSE_DB_PASSWORD=synapse_dev_123 + +# ============================================ +# Authentication +# ============================================ +# Development only - NOT for production! +JWT_SECRET=dev-jwt-secret-not-for-production-32chars +JWT_REFRESH_SECRET=dev-refresh-secret-32chars-change-me + +# ============================================ +# Service URLs (Development) +# ============================================ +FRONTEND_URL=http://localhost:8000 +BACKEND_URL=http://localhost:8000 +CONSENT_SERVICE_URL=http://localhost:8081 +BILLING_SERVICE_URL=http://localhost:8083 +SCHOOL_SERVICE_URL=http://localhost:8084 +KLAUSUR_SERVICE_URL=http://localhost:8086 +WEBSITE_URL=http://localhost:3000 + +# ============================================ +# E-Mail (Mailpit for Development) +# ============================================ +# Mailpit catches all emails - view at http://localhost:8025 +SMTP_HOST=mailpit +SMTP_PORT=1025 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_FROM_NAME=BreakPilot Dev +SMTP_FROM_ADDR=dev@breakpilot.local + +# ============================================ +# MinIO (Object Storage) +# ============================================ +MINIO_ROOT_USER=breakpilot_dev +MINIO_ROOT_PASSWORD=breakpilot_dev_123 +MINIO_ENDPOINT=localhost:9000 + +# ============================================ +# Qdrant (Vector DB) +# ============================================ +QDRANT_URL=http://localhost:6333 + +# ============================================ +# API Keys (Optional for Dev) +# ============================================ +# Leave empty for offline development +# Or add your test keys here +ANTHROPIC_API_KEY= +ANTHROPIC_DEFAULT_MODEL=claude-sonnet-4-20250514 +ANTHROPIC_ENABLED=false + +VAST_API_KEY= +VAST_INSTANCE_ID= +CONTROL_API_KEY= +VAST_AUTO_SHUTDOWN=true +VAST_AUTO_SHUTDOWN_MINUTES=30 + +VLLM_BASE_URL= +VLLM_ENABLED=false + +# ============================================ +# Embedding Configuration +# ============================================ +# "local" = sentence-transformers (no API key needed) +# "openai" = OpenAI API (requires OPENAI_API_KEY) +EMBEDDING_BACKEND=local + +# ============================================ +# Stripe (Billing - Test Mode) +# ============================================ +STRIPE_SECRET_KEY= +STRIPE_PUBLISHABLE_KEY= +STRIPE_WEBHOOK_SECRET= + +# ============================================ +# Debug Settings +# ============================================ +DEBUG=true +GIN_MODE=debug +LOG_LEVEL=debug + +# ============================================ +# Jitsi (Video Conferencing) +# ============================================ +JITSI_PUBLIC_URL=http://localhost:8443 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..355c1da --- /dev/null +++ b/.env.example @@ -0,0 +1,124 @@ +# BreakPilot PWA - Environment Configuration +# Kopieren Sie diese Datei nach .env und passen Sie die Werte an + +# ================================================ +# Allgemein +# ================================================ +ENVIRONMENT=development +# ENVIRONMENT=production + +# ================================================ +# Sicherheit +# ================================================ +# WICHTIG: In Produktion sichere Schluessel verwenden! +# Generieren mit: openssl rand -hex 32 +JWT_SECRET=CHANGE_ME_RUN_openssl_rand_hex_32 +JWT_REFRESH_SECRET=CHANGE_ME_RUN_openssl_rand_hex_32 + +# ================================================ +# Keycloak (Optional - fuer Produktion empfohlen) +# ================================================ +# Wenn Keycloak konfiguriert ist, wird es fuer Authentifizierung verwendet. +# Ohne Keycloak wird lokales JWT verwendet (gut fuer Entwicklung). +# +# KEYCLOAK_SERVER_URL=https://keycloak.breakpilot.app +# KEYCLOAK_REALM=breakpilot +# KEYCLOAK_CLIENT_ID=breakpilot-backend +# KEYCLOAK_CLIENT_SECRET=your-client-secret +# KEYCLOAK_VERIFY_SSL=true + +# ================================================ +# E-Mail Konfiguration +# ================================================ + +# === ENTWICKLUNG (Mailpit - Standardwerte) === +# Mailpit fängt alle E-Mails ab und zeigt sie unter http://localhost:8025 +SMTP_HOST=mailpit +SMTP_PORT=1025 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_FROM_NAME=BreakPilot +SMTP_FROM_ADDR=noreply@breakpilot.app +FRONTEND_URL=http://localhost:8000 + +# === PRODUKTION (Beispiel für verschiedene Provider) === + +# --- Option 1: Eigener Mailserver --- +# SMTP_HOST=mail.ihredomain.de +# SMTP_PORT=587 +# SMTP_USERNAME=noreply@ihredomain.de +# SMTP_PASSWORD=ihr-sicheres-passwort +# SMTP_FROM_NAME=BreakPilot +# SMTP_FROM_ADDR=noreply@ihredomain.de +# FRONTEND_URL=https://app.ihredomain.de + +# --- Option 2: SendGrid --- +# SMTP_HOST=smtp.sendgrid.net +# SMTP_PORT=587 +# SMTP_USERNAME=apikey +# SMTP_PASSWORD=SG.xxxxxxxxxxxxxxxxxxxxx +# SMTP_FROM_NAME=BreakPilot +# SMTP_FROM_ADDR=noreply@ihredomain.de + +# --- Option 3: Mailgun --- +# SMTP_HOST=smtp.mailgun.org +# SMTP_PORT=587 +# SMTP_USERNAME=postmaster@mg.ihredomain.de +# SMTP_PASSWORD=ihr-mailgun-passwort +# SMTP_FROM_NAME=BreakPilot +# SMTP_FROM_ADDR=noreply@mg.ihredomain.de + +# --- Option 4: Amazon SES --- +# SMTP_HOST=email-smtp.eu-central-1.amazonaws.com +# SMTP_PORT=587 +# SMTP_USERNAME=AKIAXXXXXXXXXXXXXXXX +# SMTP_PASSWORD=ihr-ses-secret +# SMTP_FROM_NAME=BreakPilot +# SMTP_FROM_ADDR=noreply@ihredomain.de + +# ================================================ +# Datenbank +# ================================================ +POSTGRES_USER=breakpilot +POSTGRES_PASSWORD=breakpilot123 +POSTGRES_DB=breakpilot_db +DATABASE_URL=postgres://breakpilot:breakpilot123@localhost:5432/breakpilot_db?sslmode=disable + +# ================================================ +# Optional: AI Integration +# ================================================ +# ANTHROPIC_API_KEY=your-anthropic-api-key-here + +# ================================================ +# Breakpilot Drive - Lernspiel +# ================================================ +# Aktiviert Datenbank-Speicherung fuer Spielsessions +GAME_USE_DATABASE=true + +# LLM fuer Quiz-Fragen-Generierung (optional) +# Wenn nicht gesetzt, werden statische Fragen verwendet +GAME_LLM_MODEL=llama-3.1-8b +GAME_LLM_FALLBACK_MODEL=claude-3-haiku + +# Feature Flags +GAME_REQUIRE_AUTH=false +GAME_REQUIRE_BILLING=false +GAME_ENABLE_LEADERBOARDS=true + +# Task-Kosten fuer Billing (wenn aktiviert) +GAME_SESSION_TASK_COST=1.0 +GAME_QUICK_SESSION_TASK_COST=0.5 + +# ================================================ +# Woodpecker CI/CD +# ================================================ +# URL zum Woodpecker Server +WOODPECKER_URL=http://woodpecker-server:8000 +# API Token für Dashboard-Integration (Pipeline-Start) +# Erstellen unter: http://macmini:8090 → User Settings → Personal Access Tokens +WOODPECKER_TOKEN= + +# ================================================ +# Debug +# ================================================ +DEBUG=false diff --git a/.env.staging b/.env.staging new file mode 100644 index 0000000..5d79c5c --- /dev/null +++ b/.env.staging @@ -0,0 +1,113 @@ +# ============================================ +# BreakPilot PWA - STAGING Environment +# ============================================ +# Usage: cp .env.staging .env +# Or: ./scripts/env-switch.sh staging +# ============================================ + +# ============================================ +# Environment Identifier +# ============================================ +ENVIRONMENT=staging +COMPOSE_PROJECT_NAME=breakpilot-staging + +# ============================================ +# HashiCorp Vault (Secrets Management) +# ============================================ +# In staging, still use dev token but with staging secrets path +VAULT_ADDR=http://localhost:8200 +VAULT_DEV_TOKEN=breakpilot-staging-token + +# ============================================ +# Database (Separate from Dev!) +# ============================================ +POSTGRES_USER=breakpilot +POSTGRES_PASSWORD=staging_secure_password_change_this +POSTGRES_DB=breakpilot_staging +DATABASE_URL=postgres://breakpilot:staging_secure_password_change_this@postgres:5432/breakpilot_staging?sslmode=disable + +# Synapse DB (Matrix) +SYNAPSE_DB_PASSWORD=synapse_staging_secure_123 + +# ============================================ +# Authentication +# ============================================ +# Staging secrets - more secure than dev, but not production +JWT_SECRET=staging-jwt-secret-32chars-change-me-now +JWT_REFRESH_SECRET=staging-refresh-secret-32chars-secure + +# ============================================ +# Service URLs (Staging - Different Ports) +# ============================================ +FRONTEND_URL=http://localhost:8001 +BACKEND_URL=http://localhost:8001 +CONSENT_SERVICE_URL=http://localhost:8091 +BILLING_SERVICE_URL=http://localhost:8093 +SCHOOL_SERVICE_URL=http://localhost:8094 +KLAUSUR_SERVICE_URL=http://localhost:8096 +WEBSITE_URL=http://localhost:3001 + +# ============================================ +# E-Mail (Still Mailpit for Safety) +# ============================================ +# Mailpit catches all emails - no accidental sends to real users +SMTP_HOST=mailpit +SMTP_PORT=1025 +SMTP_USERNAME= +SMTP_PASSWORD= +SMTP_FROM_NAME=BreakPilot Staging +SMTP_FROM_ADDR=staging@breakpilot.local + +# ============================================ +# MinIO (Object Storage) +# ============================================ +MINIO_ROOT_USER=breakpilot_staging +MINIO_ROOT_PASSWORD=staging_minio_secure_123 +MINIO_ENDPOINT=localhost:9002 + +# ============================================ +# Qdrant (Vector DB) +# ============================================ +QDRANT_URL=http://localhost:6335 + +# ============================================ +# API Keys (Test Keys for Staging) +# ============================================ +# Use test/sandbox API keys here +ANTHROPIC_API_KEY= +ANTHROPIC_DEFAULT_MODEL=claude-sonnet-4-20250514 +ANTHROPIC_ENABLED=false + +VAST_API_KEY= +VAST_INSTANCE_ID= +CONTROL_API_KEY= +VAST_AUTO_SHUTDOWN=true +VAST_AUTO_SHUTDOWN_MINUTES=30 + +VLLM_BASE_URL= +VLLM_ENABLED=false + +# ============================================ +# Embedding Configuration +# ============================================ +EMBEDDING_BACKEND=local + +# ============================================ +# Stripe (Billing - Test Mode) +# ============================================ +# Use Stripe TEST keys (sk_test_...) +STRIPE_SECRET_KEY= +STRIPE_PUBLISHABLE_KEY= +STRIPE_WEBHOOK_SECRET= + +# ============================================ +# Debug Settings (Reduced in Staging) +# ============================================ +DEBUG=false +GIN_MODE=release +LOG_LEVEL=info + +# ============================================ +# Jitsi (Video Conferencing) +# ============================================ +JITSI_PUBLIC_URL=http://localhost:8444 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f318042 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,132 @@ +# Dependabot Configuration for BreakPilot PWA +# This file configures Dependabot to automatically check for outdated dependencies +# and create pull requests to update them + +version: 2 +updates: + # Go dependencies (consent-service) + - package-ecosystem: "gomod" + directory: "/consent-service" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Europe/Berlin" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "go" + - "security" + commit-message: + prefix: "deps(go):" + groups: + go-minor: + patterns: + - "*" + update-types: + - "minor" + - "patch" + + # Python dependencies (backend) + - package-ecosystem: "pip" + directory: "/backend" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Europe/Berlin" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "python" + - "security" + commit-message: + prefix: "deps(python):" + groups: + python-minor: + patterns: + - "*" + update-types: + - "minor" + - "patch" + + # Node.js dependencies (website) + - package-ecosystem: "npm" + directory: "/website" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Europe/Berlin" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "javascript" + - "security" + commit-message: + prefix: "deps(npm):" + groups: + npm-minor: + patterns: + - "*" + update-types: + - "minor" + - "patch" + + # GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Europe/Berlin" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "github-actions" + commit-message: + prefix: "deps(actions):" + + # Docker base images + - package-ecosystem: "docker" + directory: "/consent-service" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Europe/Berlin" + labels: + - "dependencies" + - "docker" + - "security" + commit-message: + prefix: "deps(docker):" + + - package-ecosystem: "docker" + directory: "/backend" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Europe/Berlin" + labels: + - "dependencies" + - "docker" + - "security" + commit-message: + prefix: "deps(docker):" + + - package-ecosystem: "docker" + directory: "/website" + schedule: + interval: "weekly" + day: "monday" + time: "06:00" + timezone: "Europe/Berlin" + labels: + - "dependencies" + - "docker" + - "security" + commit-message: + prefix: "deps(docker):" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a863ca2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,503 @@ +name: CI/CD Pipeline + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +env: + GO_VERSION: '1.21' + PYTHON_VERSION: '3.11' + NODE_VERSION: '20' + POSTGRES_USER: breakpilot + POSTGRES_PASSWORD: breakpilot123 + POSTGRES_DB: breakpilot_test + REGISTRY: ghcr.io + IMAGE_PREFIX: ${{ github.repository_owner }}/breakpilot + +jobs: + # ========================================== + # Go Consent Service Tests + # ========================================== + go-tests: + name: Go Tests + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: ${{ env.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ env.POSTGRES_PASSWORD }} + POSTGRES_DB: ${{ env.POSTGRES_DB }} + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + cache-dependency-path: consent-service/go.sum + + - name: Download dependencies + working-directory: ./consent-service + run: go mod download + + - name: Run Go Vet + working-directory: ./consent-service + run: go vet ./... + + - name: Run Unit Tests + working-directory: ./consent-service + run: go test -v -race -coverprofile=coverage.out ./... + env: + DATABASE_URL: postgres://${{ env.POSTGRES_USER }}:${{ env.POSTGRES_PASSWORD }}@localhost:5432/${{ env.POSTGRES_DB }}?sslmode=disable + JWT_SECRET: test-jwt-secret-for-ci + JWT_REFRESH_SECRET: test-refresh-secret-for-ci + + - name: Check Coverage + working-directory: ./consent-service + run: | + go tool cover -func=coverage.out + COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//') + echo "Total coverage: ${COVERAGE}%" + if (( $(echo "$COVERAGE < 50" | bc -l) )); then + echo "::warning::Coverage is below 50%" + fi + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: ./consent-service/coverage.out + flags: go + name: go-coverage + continue-on-error: true + + # ========================================== + # Python Backend Tests + # ========================================== + python-tests: + name: Python Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: 'pip' + cache-dependency-path: backend/requirements.txt + + - name: Install dependencies + working-directory: ./backend + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-cov pytest-asyncio httpx + + - name: Run Python Tests + working-directory: ./backend + run: pytest -v --cov=. --cov-report=xml --cov-report=term-missing + continue-on-error: true + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: ./backend/coverage.xml + flags: python + name: python-coverage + continue-on-error: true + + # ========================================== + # Node.js Website Tests + # ========================================== + website-tests: + name: Website Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + cache-dependency-path: website/package-lock.json + + - name: Install dependencies + working-directory: ./website + run: npm ci + + - name: Run TypeScript check + working-directory: ./website + run: npx tsc --noEmit + continue-on-error: true + + - name: Run ESLint + working-directory: ./website + run: npm run lint + continue-on-error: true + + - name: Build website + working-directory: ./website + run: npm run build + env: + NEXT_PUBLIC_BILLING_API_URL: http://localhost:8083 + NEXT_PUBLIC_APP_URL: http://localhost:3000 + + # ========================================== + # Linting + # ========================================== + lint: + name: Linting + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ env.GO_VERSION }} + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v4 + with: + version: latest + working-directory: ./consent-service + args: --timeout=5m + continue-on-error: true + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install Python linters + run: pip install flake8 black isort + + - name: Run flake8 + working-directory: ./backend + run: flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics + continue-on-error: true + + - name: Check Black formatting + working-directory: ./backend + run: black --check --diff . + continue-on-error: true + + # ========================================== + # Security Scan + # ========================================== + security: + name: Security Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run Trivy vulnerability scanner + uses: aquasecurity/trivy-action@master + with: + scan-type: 'fs' + scan-ref: '.' + severity: 'CRITICAL,HIGH' + exit-code: '0' + continue-on-error: true + + - name: Run Go security check + uses: securego/gosec@master + with: + args: '-no-fail -fmt sarif -out results.sarif ./consent-service/...' + continue-on-error: true + + # ========================================== + # Docker Build & Push + # ========================================== + docker-build: + name: Docker Build & Push + runs-on: ubuntu-latest + needs: [go-tests, python-tests, website-tests] + permissions: + contents: read + packages: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata for consent-service + id: meta-consent + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-consent-service + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha,prefix= + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + + - name: Build and push consent-service + uses: docker/build-push-action@v5 + with: + context: ./consent-service + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta-consent.outputs.tags }} + labels: ${{ steps.meta-consent.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Extract metadata for backend + id: meta-backend + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha,prefix= + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + + - name: Build and push backend + uses: docker/build-push-action@v5 + with: + context: ./backend + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta-backend.outputs.tags }} + labels: ${{ steps.meta-backend.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Extract metadata for website + id: meta-website + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-website + tags: | + type=ref,event=branch + type=ref,event=pr + type=sha,prefix= + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + + - name: Build and push website + uses: docker/build-push-action@v5 + with: + context: ./website + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta-website.outputs.tags }} + labels: ${{ steps.meta-website.outputs.labels }} + build-args: | + NEXT_PUBLIC_BILLING_API_URL=${{ vars.NEXT_PUBLIC_BILLING_API_URL || 'http://localhost:8083' }} + NEXT_PUBLIC_APP_URL=${{ vars.NEXT_PUBLIC_APP_URL || 'http://localhost:3000' }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # ========================================== + # Integration Tests + # ========================================== + integration-tests: + name: Integration Tests + runs-on: ubuntu-latest + needs: [docker-build] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Start services with Docker Compose + run: | + docker compose up -d postgres mailpit + sleep 10 + + - name: Run consent-service + working-directory: ./consent-service + run: | + go build -o consent-service ./cmd/server + ./consent-service & + sleep 5 + env: + DATABASE_URL: postgres://breakpilot:breakpilot123@localhost:5432/breakpilot_db?sslmode=disable + JWT_SECRET: test-jwt-secret + JWT_REFRESH_SECRET: test-refresh-secret + SMTP_HOST: localhost + SMTP_PORT: 1025 + + - name: Health Check + run: | + curl -f http://localhost:8081/health || exit 1 + + - name: Run Integration Tests + run: | + # Test Auth endpoints + curl -s http://localhost:8081/api/v1/auth/health + + # Test Document endpoints + curl -s http://localhost:8081/api/v1/documents + continue-on-error: true + + - name: Stop services + if: always() + run: docker compose down + + # ========================================== + # Deploy to Staging + # ========================================== + deploy-staging: + name: Deploy to Staging + runs-on: ubuntu-latest + needs: [docker-build, integration-tests] + if: github.ref == 'refs/heads/develop' && github.event_name == 'push' + environment: + name: staging + url: https://staging.breakpilot.app + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Deploy to staging server + env: + STAGING_HOST: ${{ secrets.STAGING_HOST }} + STAGING_USER: ${{ secrets.STAGING_USER }} + STAGING_SSH_KEY: ${{ secrets.STAGING_SSH_KEY }} + run: | + # This is a placeholder for actual deployment + # Configure based on your staging infrastructure + echo "Deploying to staging environment..." + echo "Images to deploy:" + echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-consent-service:develop" + echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend:develop" + echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-website:develop" + + # Example: SSH deployment (uncomment when configured) + # mkdir -p ~/.ssh + # echo "$STAGING_SSH_KEY" > ~/.ssh/id_rsa + # chmod 600 ~/.ssh/id_rsa + # ssh -o StrictHostKeyChecking=no $STAGING_USER@$STAGING_HOST "cd /opt/breakpilot && docker compose pull && docker compose up -d" + + - name: Notify deployment + run: | + echo "## Staging Deployment" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Successfully deployed to staging environment" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Deployed images:**" >> $GITHUB_STEP_SUMMARY + echo "- consent-service: \`develop\`" >> $GITHUB_STEP_SUMMARY + echo "- backend: \`develop\`" >> $GITHUB_STEP_SUMMARY + echo "- website: \`develop\`" >> $GITHUB_STEP_SUMMARY + + # ========================================== + # Deploy to Production + # ========================================== + deploy-production: + name: Deploy to Production + runs-on: ubuntu-latest + needs: [docker-build, integration-tests] + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + environment: + name: production + url: https://breakpilot.app + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Deploy to production server + env: + PROD_HOST: ${{ secrets.PROD_HOST }} + PROD_USER: ${{ secrets.PROD_USER }} + PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }} + run: | + # This is a placeholder for actual deployment + # Configure based on your production infrastructure + echo "Deploying to production environment..." + echo "Images to deploy:" + echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-consent-service:latest" + echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-backend:latest" + echo " - ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-website:latest" + + # Example: SSH deployment (uncomment when configured) + # mkdir -p ~/.ssh + # echo "$PROD_SSH_KEY" > ~/.ssh/id_rsa + # chmod 600 ~/.ssh/id_rsa + # ssh -o StrictHostKeyChecking=no $PROD_USER@$PROD_HOST "cd /opt/breakpilot && docker compose pull && docker compose up -d" + + - name: Notify deployment + run: | + echo "## Production Deployment" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Successfully deployed to production environment" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Deployed images:**" >> $GITHUB_STEP_SUMMARY + echo "- consent-service: \`latest\`" >> $GITHUB_STEP_SUMMARY + echo "- backend: \`latest\`" >> $GITHUB_STEP_SUMMARY + echo "- website: \`latest\`" >> $GITHUB_STEP_SUMMARY + + # ========================================== + # Summary + # ========================================== + summary: + name: CI Summary + runs-on: ubuntu-latest + needs: [go-tests, python-tests, website-tests, lint, security, docker-build, integration-tests] + if: always() + + steps: + - name: Check job results + run: | + echo "## CI/CD Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY + echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Go Tests | ${{ needs.go-tests.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Python Tests | ${{ needs.python-tests.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Website Tests | ${{ needs.website-tests.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Linting | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Docker Build | ${{ needs.docker-build.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Integration Tests | ${{ needs.integration-tests.result }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Docker Images" >> $GITHUB_STEP_SUMMARY + echo "Images are pushed to: \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-*\`" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 0000000..5ced30c --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,222 @@ +name: Security Scanning + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + schedule: + # Run security scans weekly on Sundays at midnight + - cron: '0 0 * * 0' + +jobs: + # ========================================== + # Secret Scanning + # ========================================== + secret-scan: + name: Secret Scanning + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: TruffleHog Secret Scan + uses: trufflesecurity/trufflehog@main + with: + extra_args: --only-verified + + - name: GitLeaks Secret Scan + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + continue-on-error: true + + # ========================================== + # Dependency Vulnerability Scanning + # ========================================== + dependency-scan: + name: Dependency Vulnerability Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run Trivy vulnerability scanner (filesystem) + uses: aquasecurity/trivy-action@master + with: + scan-type: 'fs' + scan-ref: '.' + severity: 'CRITICAL,HIGH' + format: 'sarif' + output: 'trivy-fs-results.sarif' + continue-on-error: true + + - name: Upload Trivy scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: 'trivy-fs-results.sarif' + continue-on-error: true + + # ========================================== + # Go Security Scan + # ========================================== + go-security: + name: Go Security Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Run Gosec Security Scanner + uses: securego/gosec@master + with: + args: '-no-fail -fmt sarif -out gosec-results.sarif ./consent-service/...' + continue-on-error: true + + - name: Upload Gosec results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: 'gosec-results.sarif' + continue-on-error: true + + - name: Run govulncheck + working-directory: ./consent-service + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + govulncheck ./... || true + + # ========================================== + # Python Security Scan + # ========================================== + python-security: + name: Python Security Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install safety + run: pip install safety bandit + + - name: Run Safety (dependency check) + working-directory: ./backend + run: safety check -r requirements.txt --full-report || true + + - name: Run Bandit (code security scan) + working-directory: ./backend + run: bandit -r . -f sarif -o bandit-results.sarif --exit-zero + + - name: Upload Bandit results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: './backend/bandit-results.sarif' + continue-on-error: true + + # ========================================== + # Node.js Security Scan + # ========================================== + node-security: + name: Node.js Security Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + working-directory: ./website + run: npm ci + + - name: Run npm audit + working-directory: ./website + run: npm audit --audit-level=high || true + + # ========================================== + # Docker Image Scanning + # ========================================== + docker-security: + name: Docker Image Security + runs-on: ubuntu-latest + needs: [go-security, python-security, node-security] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Build consent-service image + run: docker build -t breakpilot/consent-service:scan ./consent-service + + - name: Run Trivy on consent-service + uses: aquasecurity/trivy-action@master + with: + image-ref: 'breakpilot/consent-service:scan' + severity: 'CRITICAL,HIGH' + format: 'sarif' + output: 'trivy-consent-results.sarif' + continue-on-error: true + + - name: Build backend image + run: docker build -t breakpilot/backend:scan ./backend + + - name: Run Trivy on backend + uses: aquasecurity/trivy-action@master + with: + image-ref: 'breakpilot/backend:scan' + severity: 'CRITICAL,HIGH' + format: 'sarif' + output: 'trivy-backend-results.sarif' + continue-on-error: true + + - name: Upload Trivy results + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: 'trivy-consent-results.sarif' + continue-on-error: true + + # ========================================== + # Security Summary + # ========================================== + security-summary: + name: Security Summary + runs-on: ubuntu-latest + needs: [secret-scan, dependency-scan, go-security, python-security, node-security, docker-security] + if: always() + + steps: + - name: Create security summary + run: | + echo "## Security Scan Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "| Scan Type | Status |" >> $GITHUB_STEP_SUMMARY + echo "|-----------|--------|" >> $GITHUB_STEP_SUMMARY + echo "| Secret Scanning | ${{ needs.secret-scan.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Dependency Scanning | ${{ needs.dependency-scan.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Go Security | ${{ needs.go-security.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Python Security | ${{ needs.python-security.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Node.js Security | ${{ needs.node-security.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| Docker Security | ${{ needs.docker-security.result }} |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Notes" >> $GITHUB_STEP_SUMMARY + echo "- Results are uploaded to the GitHub Security tab" >> $GITHUB_STEP_SUMMARY + echo "- Weekly scheduled scans run on Sundays" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..dd65cf3 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,244 @@ +name: Tests + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + go-tests: + name: Go Tests + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: breakpilot + POSTGRES_PASSWORD: breakpilot123 + POSTGRES_DB: breakpilot_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + cache: true + cache-dependency-path: consent-service/go.sum + + - name: Install Dependencies + working-directory: ./consent-service + run: go mod download + + - name: Run Tests + working-directory: ./consent-service + env: + DATABASE_URL: postgres://breakpilot:breakpilot123@localhost:5432/breakpilot_test?sslmode=disable + JWT_SECRET: test-secret-key-for-ci + JWT_REFRESH_SECRET: test-refresh-secret-for-ci + run: | + go test -v -race -coverprofile=coverage.out ./... + go tool cover -func=coverage.out + + - name: Check Coverage Threshold + working-directory: ./consent-service + run: | + COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//') + echo "Total Coverage: $COVERAGE%" + if (( $(echo "$COVERAGE < 70.0" | bc -l) )); then + echo "Coverage $COVERAGE% is below threshold 70%" + exit 1 + fi + + - name: Upload Coverage to Codecov + uses: codecov/codecov-action@v3 + with: + files: ./consent-service/coverage.out + flags: go + name: go-coverage + + python-tests: + name: Python Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: 'pip' + cache-dependency-path: backend/requirements.txt + + - name: Install Dependencies + working-directory: ./backend + run: | + pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-cov pytest-asyncio + + - name: Run Tests + working-directory: ./backend + env: + CONSENT_SERVICE_URL: http://localhost:8081 + JWT_SECRET: test-secret-key-for-ci + run: | + pytest -v --cov=. --cov-report=xml --cov-report=term + + - name: Check Coverage Threshold + working-directory: ./backend + run: | + COVERAGE=$(python -c "import xml.etree.ElementTree as ET; tree = ET.parse('coverage.xml'); print(tree.getroot().attrib['line-rate'])") + COVERAGE_PCT=$(echo "$COVERAGE * 100" | bc) + echo "Total Coverage: ${COVERAGE_PCT}%" + if (( $(echo "$COVERAGE_PCT < 60.0" | bc -l) )); then + echo "Coverage ${COVERAGE_PCT}% is below threshold 60%" + exit 1 + fi + + - name: Upload Coverage to Codecov + uses: codecov/codecov-action@v3 + with: + files: ./backend/coverage.xml + flags: python + name: python-coverage + + integration-tests: + name: Integration Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Start Services + run: | + docker-compose up -d + docker-compose ps + + - name: Wait for Postgres + run: | + timeout 60 bash -c 'until docker-compose exec -T postgres pg_isready -U breakpilot; do sleep 2; done' + + - name: Wait for Consent Service + run: | + timeout 60 bash -c 'until curl -f http://localhost:8081/health; do sleep 2; done' + + - name: Wait for Backend + run: | + timeout 60 bash -c 'until curl -f http://localhost:8000/health; do sleep 2; done' + + - name: Wait for Mailpit + run: | + timeout 60 bash -c 'until curl -f http://localhost:8025/api/v1/info; do sleep 2; done' + + - name: Run Integration Tests + run: | + chmod +x ./scripts/integration-tests.sh + ./scripts/integration-tests.sh + + - name: Show Service Logs on Failure + if: failure() + run: | + echo "=== Consent Service Logs ===" + docker-compose logs consent-service + echo "=== Backend Logs ===" + docker-compose logs backend + echo "=== Postgres Logs ===" + docker-compose logs postgres + + - name: Cleanup + if: always() + run: docker-compose down -v + + lint-go: + name: Go Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.21' + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@v3 + with: + version: latest + working-directory: consent-service + args: --timeout=5m + + lint-python: + name: Python Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install Dependencies + run: | + pip install flake8 black mypy + + - name: Run Black + working-directory: ./backend + run: black --check . + + - name: Run Flake8 + working-directory: ./backend + run: flake8 . --max-line-length=120 --exclude=venv + + security-scan: + name: Security Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Run Trivy Security Scan + uses: aquasecurity/trivy-action@master + with: + scan-type: 'fs' + scan-ref: '.' + format: 'sarif' + output: 'trivy-results.sarif' + + - name: Upload Trivy Results to GitHub Security + uses: github/codeql-action/upload-sarif@v2 + if: always() + with: + sarif_file: 'trivy-results.sarif' + + all-checks: + name: All Checks Passed + runs-on: ubuntu-latest + needs: [go-tests, python-tests, integration-tests, lint-go, lint-python, security-scan] + + steps: + - name: All Tests Passed + run: echo "All tests and checks passed successfully!" diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..fbe6794 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,77 @@ +# Gitleaks Configuration for BreakPilot +# https://github.com/gitleaks/gitleaks +# +# Run locally: gitleaks detect --source . -v +# Pre-commit: gitleaks protect --staged -v + +title = "BreakPilot Gitleaks Configuration" + +# Use the default rules plus custom rules +[extend] +useDefault = true + +# Custom rules for BreakPilot-specific patterns +[[rules]] +id = "anthropic-api-key" +description = "Anthropic API Key" +regex = '''sk-ant-api[0-9a-zA-Z-_]{20,}''' +tags = ["api", "anthropic"] +keywords = ["sk-ant-api"] + +[[rules]] +id = "vast-api-key" +description = "vast.ai API Key" +regex = '''(?i)(vast[_-]?api[_-]?key|vast[_-]?key)\s*[=:]\s*['"]?([a-zA-Z0-9-_]{20,})['"]?''' +tags = ["api", "vast"] +keywords = ["vast"] + +[[rules]] +id = "stripe-secret-key" +description = "Stripe Secret Key" +regex = '''sk_live_[0-9a-zA-Z]{24,}''' +tags = ["api", "stripe"] +keywords = ["sk_live"] + +[[rules]] +id = "stripe-restricted-key" +description = "Stripe Restricted Key" +regex = '''rk_live_[0-9a-zA-Z]{24,}''' +tags = ["api", "stripe"] +keywords = ["rk_live"] + +[[rules]] +id = "jwt-secret-hardcoded" +description = "Hardcoded JWT Secret" +regex = '''(?i)(jwt[_-]?secret|jwt[_-]?key)\s*[=:]\s*['"]([^'"]{32,})['"]''' +tags = ["secret", "jwt"] +keywords = ["jwt"] + +# Allowlist for false positives +[allowlist] +description = "Global allowlist" +paths = [ + '''\.env\.example$''', + '''\.env\.template$''', + '''docs/.*\.md$''', + '''SBOM\.md$''', + '''.*_test\.py$''', + '''.*_test\.go$''', + '''test_.*\.py$''', + '''.*\.bak$''', + '''node_modules/.*''', + '''venv/.*''', + '''\.git/.*''', +] + +# Specific commit allowlist (for already-rotated secrets) +commits = [] + +# Regex patterns to ignore +regexes = [ + '''REPLACE_WITH_REAL_.*''', + '''your-.*-key-change-in-production''', + '''breakpilot-dev-.*''', + '''DEVELOPMENT-ONLY-.*''', + '''placeholder.*''', + '''example.*key''', +] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..b5f5cd2 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,152 @@ +# Pre-commit Hooks für BreakPilot +# Installation: pip install pre-commit && pre-commit install +# Aktivierung: pre-commit install + +repos: + # Go Hooks + - repo: local + hooks: + - id: go-test + name: Go Tests + entry: bash -c 'cd consent-service && go test -short ./...' + language: system + pass_filenames: false + files: \.go$ + stages: [commit] + + - id: go-fmt + name: Go Format + entry: bash -c 'cd consent-service && gofmt -l -w .' + language: system + pass_filenames: false + files: \.go$ + stages: [commit] + + - id: go-vet + name: Go Vet + entry: bash -c 'cd consent-service && go vet ./...' + language: system + pass_filenames: false + files: \.go$ + stages: [commit] + + - id: golangci-lint + name: Go Lint (golangci-lint) + entry: bash -c 'cd consent-service && golangci-lint run --timeout=5m' + language: system + pass_filenames: false + files: \.go$ + stages: [commit] + + # Python Hooks + - repo: local + hooks: + - id: pytest + name: Python Tests + entry: bash -c 'cd backend && pytest -x' + language: system + pass_filenames: false + files: \.py$ + stages: [commit] + + - id: black + name: Black Format + entry: black + language: python + types: [python] + args: [--line-length=120] + stages: [commit] + + - id: flake8 + name: Flake8 Lint + entry: flake8 + language: python + types: [python] + args: [--max-line-length=120, --exclude=venv] + stages: [commit] + + # General Hooks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + name: Trim Trailing Whitespace + - id: end-of-file-fixer + name: Fix End of Files + - id: check-yaml + name: Check YAML + args: [--allow-multiple-documents] + - id: check-json + name: Check JSON + - id: check-added-large-files + name: Check Large Files + args: [--maxkb=500] + - id: detect-private-key + name: Detect Private Keys + - id: mixed-line-ending + name: Fix Mixed Line Endings + + # Security Checks + - repo: https://github.com/Yelp/detect-secrets + rev: v1.4.0 + hooks: + - id: detect-secrets + name: Detect Secrets + args: ['--baseline', '.secrets.baseline'] + exclude: | + (?x)^( + .*\.lock| + .*\.sum| + package-lock\.json + )$ + + # ============================================= + # DevSecOps: Gitleaks (Secrets Detection) + # ============================================= + - repo: https://github.com/gitleaks/gitleaks + rev: v8.18.1 + hooks: + - id: gitleaks + name: Gitleaks (secrets detection) + entry: gitleaks protect --staged -v --config .gitleaks.toml + language: golang + pass_filenames: false + + # ============================================= + # DevSecOps: Semgrep (SAST) + # ============================================= + - repo: https://github.com/returntocorp/semgrep + rev: v1.52.0 + hooks: + - id: semgrep + name: Semgrep (SAST) + args: + - --config=auto + - --config=.semgrep.yml + - --severity=ERROR + types_or: [python, javascript, typescript, go] + stages: [commit] + + # ============================================= + # DevSecOps: Bandit (Python Security) + # ============================================= + - repo: https://github.com/PyCQA/bandit + rev: 1.7.6 + hooks: + - id: bandit + name: Bandit (Python security) + args: ["-r", "backend/", "-ll", "-x", "backend/tests/*"] + files: ^backend/.*\.py$ + stages: [commit] + + # Branch Protection + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: no-commit-to-branch + name: Protect main/develop branches + args: ['--branch', 'main', '--branch', 'develop'] + +# Configuration +default_stages: [commit] +fail_fast: false diff --git a/.semgrep.yml b/.semgrep.yml new file mode 100644 index 0000000..da09c10 --- /dev/null +++ b/.semgrep.yml @@ -0,0 +1,147 @@ +# Semgrep Configuration for BreakPilot +# https://semgrep.dev/ +# +# Run locally: semgrep scan --config auto +# Run with this config: semgrep scan --config .semgrep.yml + +rules: + # ============================================= + # Python/FastAPI Security Rules + # ============================================= + + - id: hardcoded-secret-in-string + patterns: + - pattern-either: + - pattern: | + $VAR = "...$SECRET..." + - pattern: | + $VAR = '...$SECRET...' + message: "Potential hardcoded secret detected. Use environment variables or Vault." + languages: [python] + severity: WARNING + metadata: + category: security + cwe: "CWE-798: Use of Hard-coded Credentials" + + - id: sql-injection-fastapi + patterns: + - pattern-either: + - pattern: | + $CURSOR.execute(f"...{$USER_INPUT}...") + - pattern: | + $CURSOR.execute("..." + $USER_INPUT + "...") + - pattern: | + $CURSOR.execute("..." % $USER_INPUT) + message: "Potential SQL injection. Use parameterized queries." + languages: [python] + severity: ERROR + metadata: + category: security + cwe: "CWE-89: SQL Injection" + owasp: "A03:2021 - Injection" + + - id: command-injection + patterns: + - pattern-either: + - pattern: os.system($USER_INPUT) + - pattern: subprocess.call($USER_INPUT, shell=True) + - pattern: subprocess.run($USER_INPUT, shell=True) + - pattern: subprocess.Popen($USER_INPUT, shell=True) + message: "Potential command injection. Avoid shell=True with user input." + languages: [python] + severity: ERROR + metadata: + category: security + cwe: "CWE-78: OS Command Injection" + owasp: "A03:2021 - Injection" + + - id: insecure-jwt-algorithm + patterns: + - pattern: jwt.decode(..., algorithms=["none"], ...) + - pattern: jwt.decode(..., algorithms=["HS256"], verify=False, ...) + message: "Insecure JWT algorithm or verification disabled." + languages: [python] + severity: ERROR + metadata: + category: security + cwe: "CWE-347: Improper Verification of Cryptographic Signature" + + - id: path-traversal + patterns: + - pattern: open(... + $USER_INPUT + ...) + - pattern: open(f"...{$USER_INPUT}...") + - pattern: Path(...) / $USER_INPUT + message: "Potential path traversal. Validate and sanitize file paths." + languages: [python] + severity: WARNING + metadata: + category: security + cwe: "CWE-22: Path Traversal" + + - id: insecure-pickle + patterns: + - pattern: pickle.loads($DATA) + - pattern: pickle.load($FILE) + message: "Pickle deserialization is insecure. Use JSON or other safe formats." + languages: [python] + severity: WARNING + metadata: + category: security + cwe: "CWE-502: Deserialization of Untrusted Data" + + # ============================================= + # Go Security Rules + # ============================================= + + - id: go-sql-injection + patterns: + - pattern: | + $DB.Query(fmt.Sprintf("...", $USER_INPUT)) + - pattern: | + $DB.Exec(fmt.Sprintf("...", $USER_INPUT)) + message: "Potential SQL injection in Go. Use parameterized queries." + languages: [go] + severity: ERROR + metadata: + category: security + cwe: "CWE-89: SQL Injection" + + - id: go-hardcoded-credentials + patterns: + - pattern: | + $VAR := "..." + - metavariable-regex: + metavariable: $VAR + regex: (password|secret|apiKey|api_key|token) + message: "Potential hardcoded credential. Use environment variables." + languages: [go] + severity: WARNING + metadata: + category: security + cwe: "CWE-798: Use of Hard-coded Credentials" + + # ============================================= + # JavaScript/TypeScript Security Rules + # ============================================= + + - id: js-xss-innerhtml + patterns: + - pattern: $EL.innerHTML = $USER_INPUT + message: "Potential XSS via innerHTML. Use textContent or sanitize input." + languages: [javascript, typescript] + severity: WARNING + metadata: + category: security + cwe: "CWE-79: Cross-site Scripting" + owasp: "A03:2021 - Injection" + + - id: js-eval + patterns: + - pattern: eval($CODE) + - pattern: new Function($CODE) + message: "Avoid eval() and new Function() with dynamic input." + languages: [javascript, typescript] + severity: ERROR + metadata: + category: security + cwe: "CWE-95: Improper Neutralization of Directives in Dynamically Evaluated Code" diff --git a/.trivy.yaml b/.trivy.yaml new file mode 100644 index 0000000..7557037 --- /dev/null +++ b/.trivy.yaml @@ -0,0 +1,66 @@ +# Trivy Configuration for BreakPilot +# https://trivy.dev/ +# +# Run: trivy image breakpilot-pwa-backend:latest +# Run filesystem: trivy fs . +# Run config: trivy config . + +# Scan settings +scan: + # Security checks to perform + security-checks: + - vuln # Vulnerabilities + - config # Misconfigurations + - secret # Secrets in files + +# Vulnerability settings +vulnerability: + # Vulnerability types to scan for + type: + - os # OS packages + - library # Application dependencies + + # Ignore unfixed vulnerabilities + ignore-unfixed: false + +# Severity settings +severity: + - CRITICAL + - HIGH + - MEDIUM + # - LOW # Uncomment to include low severity + +# Output format +format: table + +# Exit code on findings +exit-code: 1 + +# Timeout +timeout: 10m + +# Cache directory +cache-dir: /tmp/trivy-cache + +# Skip files/directories +skip-dirs: + - node_modules + - venv + - .venv + - __pycache__ + - .git + - .idea + - .vscode + +skip-files: + - "*.md" + - "*.txt" + - "*.log" + +# Ignore specific vulnerabilities (add after review) +ignorefile: .trivyignore + +# SBOM generation +sbom: + format: cyclonedx + output: sbom.json diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 0000000..17b0d74 --- /dev/null +++ b/.trivyignore @@ -0,0 +1,9 @@ +# Trivy Ignore File for BreakPilot +# Add vulnerability IDs to ignore after security review +# Format: CVE-XXXX-XXXXX or GHSA-xxxx-xxxx-xxxx + +# Example (remove after adding real ignores): +# CVE-2021-12345 # Reason: Not exploitable in our context + +# Reviewed and accepted risks: +# (Add vulnerabilities here after security team review) diff --git a/.woodpecker/auto-fix.yml b/.woodpecker/auto-fix.yml new file mode 100644 index 0000000..c014928 --- /dev/null +++ b/.woodpecker/auto-fix.yml @@ -0,0 +1,132 @@ +# Woodpecker CI Auto-Fix Pipeline +# Automatische Reparatur fehlgeschlagener Tests +# +# Laeuft taeglich um 2:00 Uhr nachts +# Analysiert offene Backlog-Items und versucht automatische Fixes + +when: + - event: cron + cron: "0 2 * * *" # Taeglich um 2:00 Uhr + +clone: + git: + image: woodpeckerci/plugin-git + settings: + depth: 1 + extra_hosts: + - macmini:192.168.178.100 + +steps: + # ======================================== + # 1. Fetch Failed Tests from Backlog + # ======================================== + + fetch-backlog: + image: curlimages/curl:latest + commands: + - | + curl -s "http://backend:8000/api/tests/backlog?status=open&priority=critical" \ + -o backlog-critical.json + curl -s "http://backend:8000/api/tests/backlog?status=open&priority=high" \ + -o backlog-high.json + - echo "=== Kritische Tests ===" + - cat backlog-critical.json | head -50 + - echo "=== Hohe Prioritaet ===" + - cat backlog-high.json | head -50 + + # ======================================== + # 2. Analyze and Classify Errors + # ======================================== + + analyze-errors: + image: python:3.12-slim + commands: + - pip install --quiet jq-py + - | + python3 << 'EOF' + import json + import os + + def classify_error(error_type, error_msg): + """Klassifiziert Fehler nach Auto-Fix-Potential""" + auto_fixable = { + 'nil_pointer': 'high', + 'import_error': 'high', + 'undefined_variable': 'medium', + 'type_error': 'medium', + 'assertion': 'low', + 'timeout': 'low', + 'logic_error': 'manual' + } + return auto_fixable.get(error_type, 'manual') + + # Lade Backlog + try: + with open('backlog-critical.json') as f: + critical = json.load(f) + with open('backlog-high.json') as f: + high = json.load(f) + except: + print("Keine Backlog-Daten gefunden") + exit(0) + + all_items = critical.get('items', []) + high.get('items', []) + + auto_fix_candidates = [] + for item in all_items: + fix_potential = classify_error( + item.get('error_type', 'unknown'), + item.get('error_message', '') + ) + if fix_potential in ['high', 'medium']: + auto_fix_candidates.append({ + 'id': item.get('id'), + 'test_name': item.get('test_name'), + 'error_type': item.get('error_type'), + 'fix_potential': fix_potential + }) + + print(f"Auto-Fix Kandidaten: {len(auto_fix_candidates)}") + with open('auto-fix-candidates.json', 'w') as f: + json.dump(auto_fix_candidates, f, indent=2) + EOF + depends_on: + - fetch-backlog + + # ======================================== + # 3. Generate Fix Suggestions (Placeholder) + # ======================================== + + generate-fixes: + image: python:3.12-slim + commands: + - | + echo "Auto-Fix Generation ist in Phase 4 geplant" + echo "Aktuell werden nur Vorschlaege generiert" + + # Hier wuerde Claude API oder anderer LLM aufgerufen werden + # python3 scripts/auto-fix-agent.py auto-fix-candidates.json + + echo "Fix-Vorschlaege wuerden hier generiert werden" + depends_on: + - analyze-errors + + # ======================================== + # 4. Report Results + # ======================================== + + report-results: + image: curlimages/curl:latest + commands: + - | + curl -X POST "http://backend:8000/api/tests/auto-fix/report" \ + -H "Content-Type: application/json" \ + -d "{ + \"run_date\": \"$(date -Iseconds)\", + \"candidates_found\": $(cat auto-fix-candidates.json | wc -l), + \"fixes_attempted\": 0, + \"fixes_successful\": 0, + \"status\": \"analysis_only\" + }" || true + when: + status: [success, failure] diff --git a/.woodpecker/build-ci-image.yml b/.woodpecker/build-ci-image.yml new file mode 100644 index 0000000..09c3172 --- /dev/null +++ b/.woodpecker/build-ci-image.yml @@ -0,0 +1,37 @@ +# One-time pipeline to build the custom Python CI image +# Trigger manually, then delete this file +# +# This builds the breakpilot/python-ci:3.12 image on the CI runner + +when: + - event: manual + +clone: + git: + image: woodpeckerci/plugin-git + settings: + depth: 1 + extra_hosts: + - macmini:192.168.178.100 + +steps: + build-python-ci-image: + image: docker:27-cli + volumes: + - /var/run/docker.sock:/var/run/docker.sock + commands: + - | + echo "=== Building breakpilot/python-ci:3.12 ===" + + docker build \ + -t breakpilot/python-ci:3.12 \ + -t breakpilot/python-ci:latest \ + -f .docker/python-ci.Dockerfile \ + . + + echo "" + echo "=== Build complete ===" + docker images | grep breakpilot/python-ci + + echo "" + echo "Image is now available for CI pipelines!" diff --git a/.woodpecker/integration.yml b/.woodpecker/integration.yml new file mode 100644 index 0000000..703989b --- /dev/null +++ b/.woodpecker/integration.yml @@ -0,0 +1,161 @@ +# Integration Tests Pipeline +# Separate Datei weil Services auf Pipeline-Ebene definiert werden muessen +# +# Diese Pipeline laeuft parallel zur main.yml und testet: +# - Database Connectivity (PostgreSQL) +# - Cache Connectivity (Valkey/Redis) +# - Service-to-Service Kommunikation +# +# Dokumentation: docs/testing/integration-test-environment.md + +when: + - event: [push, pull_request] + branch: [main, develop] + +clone: + git: + image: woodpeckerci/plugin-git + settings: + depth: 1 + extra_hosts: + - macmini:192.168.178.100 + +# Services auf Pipeline-Ebene (NICHT Step-Ebene!) +# Diese Services sind fuer ALLE Steps verfuegbar +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: breakpilot + POSTGRES_PASSWORD: breakpilot_test + POSTGRES_DB: breakpilot_test + + valkey: + image: valkey/valkey:8-alpine + +steps: + wait-for-services: + image: postgres:16-alpine + commands: + - | + echo "=== Waiting for PostgreSQL ===" + for i in $(seq 1 30); do + if pg_isready -h postgres -U breakpilot; then + echo "PostgreSQL ready after $i attempts!" + break + fi + echo "Attempt $i/30: PostgreSQL not ready, waiting..." + sleep 2 + done + # Final check + if ! pg_isready -h postgres -U breakpilot; then + echo "ERROR: PostgreSQL not ready after 30 attempts" + exit 1 + fi + - | + echo "=== Waiting for Valkey ===" + # Install redis-cli in postgres alpine image + apk add --no-cache redis > /dev/null 2>&1 || true + for i in $(seq 1 30); do + if redis-cli -h valkey ping 2>/dev/null | grep -q PONG; then + echo "Valkey ready after $i attempts!" + break + fi + echo "Attempt $i/30: Valkey not ready, waiting..." + sleep 2 + done + # Final check + if ! redis-cli -h valkey ping 2>/dev/null | grep -q PONG; then + echo "ERROR: Valkey not ready after 30 attempts" + exit 1 + fi + - echo "=== All services ready ===" + + integration-tests: + image: breakpilot/python-ci:3.12 + environment: + CI: "true" + DATABASE_URL: postgresql://breakpilot:breakpilot_test@postgres:5432/breakpilot_test + VALKEY_URL: redis://valkey:6379 + REDIS_URL: redis://valkey:6379 + SKIP_INTEGRATION_TESTS: "false" + SKIP_DB_TESTS: "false" + SKIP_WEASYPRINT_TESTS: "false" + # Test-spezifische Umgebungsvariablen + ENVIRONMENT: "testing" + JWT_SECRET: "test-secret-key-for-integration-tests" + TEACHER_REQUIRE_AUTH: "false" + GAME_USE_DATABASE: "false" + commands: + - | + set -uo pipefail + mkdir -p .ci-results + cd backend + + # PYTHONPATH setzen damit lokale Module gefunden werden + export PYTHONPATH="$(pwd):${PYTHONPATH:-}" + + echo "=== Installing dependencies ===" + pip install --quiet --no-cache-dir -r requirements.txt + + echo "=== Running Integration Tests ===" + set +e + python -m pytest tests/test_integration/ -v \ + --tb=short \ + --json-report \ + --json-report-file=../.ci-results/test-integration.json + TEST_EXIT=$? + set -e + + # Ergebnisse auswerten + if [ -f ../.ci-results/test-integration.json ]; then + TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-integration.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0") + PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-integration.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0") + FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-integration.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0") + SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-integration.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0") + else + echo "WARNUNG: Keine JSON-Ergebnisse gefunden" + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + echo "{\"service\":\"integration-tests\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-integration.json + cat ../.ci-results/results-integration.json + + echo "" + echo "=== Integration Test Summary ===" + echo "Total: $TOTAL | Passed: $PASSED | Failed: $FAILED | Skipped: $SKIPPED" + + if [ "$TEST_EXIT" -ne "0" ]; then + echo "Integration tests failed with exit code $TEST_EXIT" + exit 1 + fi + depends_on: + - wait-for-services + + report-integration-results: + image: curlimages/curl:8.10.1 + commands: + - | + set -uo pipefail + echo "=== Sende Integration Test-Ergebnisse an Dashboard ===" + + if [ -f .ci-results/results-integration.json ]; then + echo "Sending integration test results..." + curl -f -sS -X POST "http://backend:8000/api/tests/ci-result" \ + -H "Content-Type: application/json" \ + -d "{ + \"pipeline_id\": \"${CI_PIPELINE_NUMBER}\", + \"commit\": \"${CI_COMMIT_SHA}\", + \"branch\": \"${CI_COMMIT_BRANCH}\", + \"status\": \"${CI_PIPELINE_STATUS:-unknown}\", + \"test_results\": $(cat .ci-results/results-integration.json) + }" || echo "WARNUNG: Konnte Ergebnisse nicht an Dashboard senden" + else + echo "Keine Integration-Ergebnisse zum Senden gefunden" + fi + + echo "=== Integration Test-Ergebnisse gesendet ===" + when: + status: [success, failure] + depends_on: + - integration-tests diff --git a/.woodpecker/main.yml b/.woodpecker/main.yml new file mode 100644 index 0000000..d358bb0 --- /dev/null +++ b/.woodpecker/main.yml @@ -0,0 +1,669 @@ +# Woodpecker CI Main Pipeline +# BreakPilot PWA - CI/CD Pipeline +# +# Plattform: ARM64 (Apple Silicon Mac Mini) +# +# Strategie: +# - Tests laufen bei JEDEM Push/PR +# - Test-Ergebnisse werden an Dashboard gesendet +# - Builds/Scans laufen nur bei Tags oder manuell +# - Deployment nur manuell (Sicherheit) + +when: + - event: [push, pull_request, manual, tag] + branch: [main, develop] + +clone: + git: + image: woodpeckerci/plugin-git + settings: + depth: 1 + extra_hosts: + - macmini:192.168.178.100 + +variables: + - &golang_image golang:1.23-alpine + - &python_image python:3.12-slim + - &python_ci_image breakpilot/python-ci:3.12 # Custom image with WeasyPrint + - &nodejs_image node:20-alpine + - &docker_image docker:27-cli + +steps: + # ======================================== + # STAGE 1: Lint (nur bei PRs) + # ======================================== + + go-lint: + image: golangci/golangci-lint:v1.55-alpine + commands: + - cd consent-service && golangci-lint run --timeout 5m ./... + - cd ../billing-service && golangci-lint run --timeout 5m ./... + - cd ../school-service && golangci-lint run --timeout 5m ./... + when: + event: pull_request + + python-lint: + image: *python_image + commands: + - pip install --quiet ruff black + - ruff check backend/ --output-format=github || true + - black --check backend/ || true + when: + event: pull_request + + # ======================================== + # STAGE 2: Unit Tests mit JSON-Ausgabe + # Ergebnisse werden im Workspace gespeichert (.ci-results/) + # ======================================== + + test-go-consent: + image: *golang_image + environment: + CGO_ENABLED: "0" + commands: + - | + set -euo pipefail + apk add --no-cache jq bash + mkdir -p .ci-results + + if [ ! -d "consent-service" ]; then + echo '{"service":"consent-service","framework":"go","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-consent.json + echo "WARNUNG: consent-service Verzeichnis nicht gefunden" + exit 0 + fi + + cd consent-service + set +e + go test -v -json -coverprofile=coverage.out ./... 2>&1 | tee ../.ci-results/test-consent.json + TEST_EXIT=$? + set -e + + # JSON-Zeilen extrahieren und mit jq zählen + JSON_FILE="../.ci-results/test-consent.json" + if grep -q '^{' "$JSON_FILE" 2>/dev/null; then + TOTAL=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="run" and .Test != null)] | length') + PASSED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="pass" and .Test != null)] | length') + FAILED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="fail" and .Test != null)] | length') + SKIPPED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="skip" and .Test != null)] | length') + else + echo "WARNUNG: Keine JSON-Zeilen in $JSON_FILE gefunden (Build-Fehler?)" + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' | tr -d '%' || echo "0") + [ -z "$COVERAGE" ] && COVERAGE=0 + + echo "{\"service\":\"consent-service\",\"framework\":\"go\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":$COVERAGE}" > ../.ci-results/results-consent.json + cat ../.ci-results/results-consent.json + + # Backlog-Strategie: Fehler werden gemeldet aber Pipeline laeuft weiter + if [ "$FAILED" -gt "0" ]; then + echo "WARNUNG: $FAILED Tests fehlgeschlagen - werden ins Backlog geschrieben" + fi + + test-go-billing: + image: *golang_image + environment: + CGO_ENABLED: "0" + commands: + - | + set -euo pipefail + apk add --no-cache jq bash + mkdir -p .ci-results + + if [ ! -d "billing-service" ]; then + echo '{"service":"billing-service","framework":"go","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-billing.json + echo "WARNUNG: billing-service Verzeichnis nicht gefunden" + exit 0 + fi + + cd billing-service + set +e + go test -v -json -coverprofile=coverage.out ./... 2>&1 | tee ../.ci-results/test-billing.json + TEST_EXIT=$? + set -e + + # JSON-Zeilen extrahieren und mit jq zählen + JSON_FILE="../.ci-results/test-billing.json" + if grep -q '^{' "$JSON_FILE" 2>/dev/null; then + TOTAL=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="run" and .Test != null)] | length') + PASSED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="pass" and .Test != null)] | length') + FAILED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="fail" and .Test != null)] | length') + SKIPPED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="skip" and .Test != null)] | length') + else + echo "WARNUNG: Keine JSON-Zeilen in $JSON_FILE gefunden (Build-Fehler?)" + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' | tr -d '%' || echo "0") + [ -z "$COVERAGE" ] && COVERAGE=0 + + echo "{\"service\":\"billing-service\",\"framework\":\"go\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":$COVERAGE}" > ../.ci-results/results-billing.json + cat ../.ci-results/results-billing.json + + # Backlog-Strategie: Fehler werden gemeldet aber Pipeline laeuft weiter + if [ "$FAILED" -gt "0" ]; then + echo "WARNUNG: $FAILED Tests fehlgeschlagen - werden ins Backlog geschrieben" + fi + + test-go-school: + image: *golang_image + environment: + CGO_ENABLED: "0" + commands: + - | + set -euo pipefail + apk add --no-cache jq bash + mkdir -p .ci-results + + if [ ! -d "school-service" ]; then + echo '{"service":"school-service","framework":"go","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-school.json + echo "WARNUNG: school-service Verzeichnis nicht gefunden" + exit 0 + fi + + cd school-service + set +e + go test -v -json -coverprofile=coverage.out ./... 2>&1 | tee ../.ci-results/test-school.json + TEST_EXIT=$? + set -e + + # JSON-Zeilen extrahieren und mit jq zählen + JSON_FILE="../.ci-results/test-school.json" + if grep -q '^{' "$JSON_FILE" 2>/dev/null; then + TOTAL=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="run" and .Test != null)] | length') + PASSED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="pass" and .Test != null)] | length') + FAILED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="fail" and .Test != null)] | length') + SKIPPED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="skip" and .Test != null)] | length') + else + echo "WARNUNG: Keine JSON-Zeilen in $JSON_FILE gefunden (Build-Fehler?)" + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' | tr -d '%' || echo "0") + [ -z "$COVERAGE" ] && COVERAGE=0 + + echo "{\"service\":\"school-service\",\"framework\":\"go\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":$COVERAGE}" > ../.ci-results/results-school.json + cat ../.ci-results/results-school.json + + # Backlog-Strategie: Fehler werden gemeldet aber Pipeline laeuft weiter + if [ "$FAILED" -gt "0" ]; then + echo "WARNUNG: $FAILED Tests fehlgeschlagen - werden ins Backlog geschrieben" + fi + + test-go-edu-search: + image: *golang_image + environment: + CGO_ENABLED: "0" + commands: + - | + set -euo pipefail + apk add --no-cache jq bash + mkdir -p .ci-results + + if [ ! -d "edu-search-service" ]; then + echo '{"service":"edu-search-service","framework":"go","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-edu-search.json + echo "WARNUNG: edu-search-service Verzeichnis nicht gefunden" + exit 0 + fi + + cd edu-search-service + set +e + go test -v -json -coverprofile=coverage.out ./internal/... 2>&1 | tee ../.ci-results/test-edu-search.json + TEST_EXIT=$? + set -e + + # JSON-Zeilen extrahieren und mit jq zählen + JSON_FILE="../.ci-results/test-edu-search.json" + if grep -q '^{' "$JSON_FILE" 2>/dev/null; then + TOTAL=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="run" and .Test != null)] | length') + PASSED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="pass" and .Test != null)] | length') + FAILED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="fail" and .Test != null)] | length') + SKIPPED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="skip" and .Test != null)] | length') + else + echo "WARNUNG: Keine JSON-Zeilen in $JSON_FILE gefunden (Build-Fehler?)" + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' | tr -d '%' || echo "0") + [ -z "$COVERAGE" ] && COVERAGE=0 + + echo "{\"service\":\"edu-search-service\",\"framework\":\"go\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":$COVERAGE}" > ../.ci-results/results-edu-search.json + cat ../.ci-results/results-edu-search.json + + # Backlog-Strategie: Fehler werden gemeldet aber Pipeline laeuft weiter + if [ "$FAILED" -gt "0" ]; then + echo "WARNUNG: $FAILED Tests fehlgeschlagen - werden ins Backlog geschrieben" + fi + + test-go-ai-compliance: + image: *golang_image + environment: + CGO_ENABLED: "0" + commands: + - | + set -euo pipefail + apk add --no-cache jq bash + mkdir -p .ci-results + + if [ ! -d "ai-compliance-sdk" ]; then + echo '{"service":"ai-compliance-sdk","framework":"go","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-ai-compliance.json + echo "WARNUNG: ai-compliance-sdk Verzeichnis nicht gefunden" + exit 0 + fi + + cd ai-compliance-sdk + set +e + go test -v -json -coverprofile=coverage.out ./... 2>&1 | tee ../.ci-results/test-ai-compliance.json + TEST_EXIT=$? + set -e + + # JSON-Zeilen extrahieren und mit jq zählen + JSON_FILE="../.ci-results/test-ai-compliance.json" + if grep -q '^{' "$JSON_FILE" 2>/dev/null; then + TOTAL=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="run" and .Test != null)] | length') + PASSED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="pass" and .Test != null)] | length') + FAILED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="fail" and .Test != null)] | length') + SKIPPED=$(grep '^{' "$JSON_FILE" | jq -s '[.[] | select(.Action=="skip" and .Test != null)] | length') + else + echo "WARNUNG: Keine JSON-Zeilen in $JSON_FILE gefunden (Build-Fehler?)" + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' | tr -d '%' || echo "0") + [ -z "$COVERAGE" ] && COVERAGE=0 + + echo "{\"service\":\"ai-compliance-sdk\",\"framework\":\"go\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":$COVERAGE}" > ../.ci-results/results-ai-compliance.json + cat ../.ci-results/results-ai-compliance.json + + # Backlog-Strategie: Fehler werden gemeldet aber Pipeline laeuft weiter + if [ "$FAILED" -gt "0" ]; then + echo "WARNUNG: $FAILED Tests fehlgeschlagen - werden ins Backlog geschrieben" + fi + + test-python-backend: + image: *python_ci_image + environment: + CI: "true" + DATABASE_URL: "postgresql://test:test@localhost:5432/test_db" + SKIP_DB_TESTS: "true" + SKIP_WEASYPRINT_TESTS: "false" + SKIP_INTEGRATION_TESTS: "true" + commands: + - | + set -uo pipefail + mkdir -p .ci-results + + if [ ! -d "backend" ]; then + echo '{"service":"backend","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-backend.json + echo "WARNUNG: backend Verzeichnis nicht gefunden" + exit 0 + fi + + cd backend + # Set PYTHONPATH to current directory (backend) so local packages like classroom_engine, alerts_agent are found + # IMPORTANT: Use absolute path and export before pip install to ensure modules are available + export PYTHONPATH="$(pwd):${PYTHONPATH:-}" + + # Test tools are pre-installed in breakpilot/python-ci image + # Only install project-specific dependencies + pip install --quiet --no-cache-dir -r requirements.txt + + # NOTE: PostgreSQL service removed - tests that require DB are skipped via SKIP_DB_TESTS=true + # For full integration tests, use: docker compose -f docker-compose.test.yml up -d + + set +e + # Use python -m pytest to ensure PYTHONPATH is properly applied before pytest starts + python -m pytest tests/ -v --tb=short --cov=. --cov-report=term-missing --json-report --json-report-file=../.ci-results/test-backend.json + TEST_EXIT=$? + set -e + + if [ -f ../.ci-results/test-backend.json ]; then + TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-backend.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0") + PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-backend.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0") + FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-backend.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0") + SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-backend.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0") + else + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + echo "{\"service\":\"backend\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-backend.json + cat ../.ci-results/results-backend.json + + if [ "$TEST_EXIT" -ne "0" ]; then exit 1; fi + + test-python-voice: + image: *python_image + environment: + CI: "true" + commands: + - | + set -uo pipefail + mkdir -p .ci-results + + if [ ! -d "voice-service" ]; then + echo '{"service":"voice-service","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-voice.json + echo "WARNUNG: voice-service Verzeichnis nicht gefunden" + exit 0 + fi + + cd voice-service + export PYTHONPATH="$(pwd):${PYTHONPATH:-}" + pip install --quiet --no-cache-dir -r requirements.txt + pip install --quiet --no-cache-dir pytest-json-report + + set +e + python -m pytest tests/ -v --tb=short --json-report --json-report-file=../.ci-results/test-voice.json + TEST_EXIT=$? + set -e + + if [ -f ../.ci-results/test-voice.json ]; then + TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0") + PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0") + FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0") + SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-voice.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0") + else + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + echo "{\"service\":\"voice-service\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-voice.json + cat ../.ci-results/results-voice.json + + if [ "$TEST_EXIT" -ne "0" ]; then exit 1; fi + + test-bqas-golden: + image: *python_image + commands: + - | + set -uo pipefail + mkdir -p .ci-results + + if [ ! -d "voice-service/tests/bqas" ]; then + echo '{"service":"bqas-golden","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-bqas-golden.json + echo "WARNUNG: voice-service/tests/bqas Verzeichnis nicht gefunden" + exit 0 + fi + + cd voice-service + export PYTHONPATH="$(pwd):${PYTHONPATH:-}" + pip install --quiet --no-cache-dir -r requirements.txt + pip install --quiet --no-cache-dir pytest-json-report pytest-asyncio + + set +e + python -m pytest tests/bqas/test_golden.py tests/bqas/test_regression.py tests/bqas/test_synthetic.py -v --tb=short --json-report --json-report-file=../.ci-results/test-bqas-golden.json + TEST_EXIT=$? + set -e + + if [ -f ../.ci-results/test-bqas-golden.json ]; then + TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0") + PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0") + FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0") + SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-golden.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0") + else + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + echo "{\"service\":\"bqas-golden\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-bqas-golden.json + cat ../.ci-results/results-bqas-golden.json + + # BQAS tests may skip if Ollama not available - don't fail pipeline + if [ "$FAILED" -gt "0" ]; then exit 1; fi + + test-bqas-rag: + image: *python_image + commands: + - | + set -uo pipefail + mkdir -p .ci-results + + if [ ! -d "voice-service/tests/bqas" ]; then + echo '{"service":"bqas-rag","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-bqas-rag.json + echo "WARNUNG: voice-service/tests/bqas Verzeichnis nicht gefunden" + exit 0 + fi + + cd voice-service + export PYTHONPATH="$(pwd):${PYTHONPATH:-}" + pip install --quiet --no-cache-dir -r requirements.txt + pip install --quiet --no-cache-dir pytest-json-report pytest-asyncio + + set +e + python -m pytest tests/bqas/test_rag.py tests/bqas/test_notifier.py -v --tb=short --json-report --json-report-file=../.ci-results/test-bqas-rag.json + TEST_EXIT=$? + set -e + + if [ -f ../.ci-results/test-bqas-rag.json ]; then + TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0") + PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0") + FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0") + SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-bqas-rag.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0") + else + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + echo "{\"service\":\"bqas-rag\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-bqas-rag.json + cat ../.ci-results/results-bqas-rag.json + + # BQAS tests may skip if Ollama not available - don't fail pipeline + if [ "$FAILED" -gt "0" ]; then exit 1; fi + + test-python-klausur: + image: *python_image + environment: + CI: "true" + commands: + - | + set -uo pipefail + mkdir -p .ci-results + + if [ ! -d "klausur-service/backend" ]; then + echo '{"service":"klausur-service","framework":"pytest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-klausur.json + echo "WARNUNG: klausur-service/backend Verzeichnis nicht gefunden" + exit 0 + fi + + cd klausur-service/backend + # Set PYTHONPATH to current directory so local modules like hyde, hybrid_search, etc. are found + export PYTHONPATH="$(pwd):${PYTHONPATH:-}" + + pip install --quiet --no-cache-dir -r requirements.txt 2>/dev/null || pip install --quiet --no-cache-dir fastapi uvicorn pytest pytest-asyncio pytest-json-report + pip install --quiet --no-cache-dir pytest-json-report + + set +e + python -m pytest tests/ -v --tb=short --json-report --json-report-file=../../.ci-results/test-klausur.json + TEST_EXIT=$? + set -e + + if [ -f ../../.ci-results/test-klausur.json ]; then + TOTAL=$(python3 -c "import json; d=json.load(open('../../.ci-results/test-klausur.json')); print(d.get('summary',{}).get('total',0))" 2>/dev/null || echo "0") + PASSED=$(python3 -c "import json; d=json.load(open('../../.ci-results/test-klausur.json')); print(d.get('summary',{}).get('passed',0))" 2>/dev/null || echo "0") + FAILED=$(python3 -c "import json; d=json.load(open('../../.ci-results/test-klausur.json')); print(d.get('summary',{}).get('failed',0))" 2>/dev/null || echo "0") + SKIPPED=$(python3 -c "import json; d=json.load(open('../../.ci-results/test-klausur.json')); print(d.get('summary',{}).get('skipped',0))" 2>/dev/null || echo "0") + else + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + echo "{\"service\":\"klausur-service\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../../.ci-results/results-klausur.json + cat ../../.ci-results/results-klausur.json + + if [ "$TEST_EXIT" -ne "0" ]; then exit 1; fi + + test-nodejs-h5p: + image: *nodejs_image + commands: + - | + set -uo pipefail + mkdir -p .ci-results + + if [ ! -d "h5p-service" ]; then + echo '{"service":"h5p-service","framework":"jest","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-h5p.json + echo "WARNUNG: h5p-service Verzeichnis nicht gefunden" + exit 0 + fi + + cd h5p-service + npm ci --silent 2>/dev/null || npm install --silent + + set +e + npm run test:ci -- --json --outputFile=../.ci-results/test-h5p.json 2>&1 + TEST_EXIT=$? + set -e + + if [ -f ../.ci-results/test-h5p.json ]; then + TOTAL=$(node -e "const d=require('../.ci-results/test-h5p.json'); console.log(d.numTotalTests || 0)" 2>/dev/null || echo "0") + PASSED=$(node -e "const d=require('../.ci-results/test-h5p.json'); console.log(d.numPassedTests || 0)" 2>/dev/null || echo "0") + FAILED=$(node -e "const d=require('../.ci-results/test-h5p.json'); console.log(d.numFailedTests || 0)" 2>/dev/null || echo "0") + SKIPPED=$(node -e "const d=require('../.ci-results/test-h5p.json'); console.log(d.numPendingTests || 0)" 2>/dev/null || echo "0") + else + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + [ -z "$TOTAL" ] && TOTAL=0 + [ -z "$PASSED" ] && PASSED=0 + [ -z "$FAILED" ] && FAILED=0 + [ -z "$SKIPPED" ] && SKIPPED=0 + + echo "{\"service\":\"h5p-service\",\"framework\":\"jest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" > ../.ci-results/results-h5p.json + cat ../.ci-results/results-h5p.json + + if [ "$TEST_EXIT" -ne "0" ]; then exit 1; fi + + # ======================================== + # STAGE 2.5: Integration Tests + # ======================================== + # Integration Tests laufen in separater Pipeline: + # .woodpecker/integration.yml + # (benötigt Pipeline-Level Services für PostgreSQL und Valkey) + + # ======================================== + # STAGE 3: Test-Ergebnisse an Dashboard senden + # ======================================== + + report-test-results: + image: curlimages/curl:8.10.1 + commands: + - | + set -uo pipefail + echo "=== Sende Test-Ergebnisse an Dashboard ===" + echo "Pipeline Status: ${CI_PIPELINE_STATUS:-unknown}" + ls -la .ci-results/ || echo "Verzeichnis nicht gefunden" + + PIPELINE_STATUS="${CI_PIPELINE_STATUS:-unknown}" + + for f in .ci-results/results-*.json; do + [ -f "$f" ] || continue + echo "Sending: $f" + curl -f -sS -X POST "http://backend:8000/api/tests/ci-result" \ + -H "Content-Type: application/json" \ + -d "{ + \"pipeline_id\": \"${CI_PIPELINE_NUMBER}\", + \"commit\": \"${CI_COMMIT_SHA}\", + \"branch\": \"${CI_COMMIT_BRANCH}\", + \"status\": \"${PIPELINE_STATUS}\", + \"test_results\": $(cat "$f") + }" || echo "WARNUNG: Konnte $f nicht senden" + done + + echo "=== Test-Ergebnisse gesendet ===" + when: + status: [success, failure] + depends_on: + - test-go-consent + - test-go-billing + - test-go-school + - test-go-edu-search + - test-go-ai-compliance + - test-python-backend + - test-python-voice + - test-bqas-golden + - test-bqas-rag + - test-python-klausur + - test-nodejs-h5p + + # ======================================== + # STAGE 4: Build & Security (nur Tags/manuell) + # ======================================== + + build-consent-service: + image: *docker_image + commands: + - docker build -t breakpilot/consent-service:${CI_COMMIT_SHA:0:8} ./consent-service + - docker tag breakpilot/consent-service:${CI_COMMIT_SHA:0:8} breakpilot/consent-service:latest + - echo "Built breakpilot/consent-service:${CI_COMMIT_SHA:0:8}" + when: + - event: tag + - event: manual + + build-backend: + image: *docker_image + commands: + - docker build -t breakpilot/backend:${CI_COMMIT_SHA:0:8} ./backend + - docker tag breakpilot/backend:${CI_COMMIT_SHA:0:8} breakpilot/backend:latest + - echo "Built breakpilot/backend:${CI_COMMIT_SHA:0:8}" + when: + - event: tag + - event: manual + + build-voice-service: + image: *docker_image + commands: + - | + if [ -d ./voice-service ]; then + docker build -t breakpilot/voice-service:${CI_COMMIT_SHA:0:8} ./voice-service + docker tag breakpilot/voice-service:${CI_COMMIT_SHA:0:8} breakpilot/voice-service:latest + echo "Built breakpilot/voice-service:${CI_COMMIT_SHA:0:8}" + else + echo "voice-service Verzeichnis nicht gefunden - ueberspringe" + fi + when: + - event: tag + - event: manual + + generate-sbom: + image: *golang_image + commands: + - | + echo "Installing syft for ARM64..." + wget -qO- https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin + syft dir:./consent-service -o cyclonedx-json > sbom-consent.json + syft dir:./backend -o cyclonedx-json > sbom-backend.json + if [ -d ./voice-service ]; then + syft dir:./voice-service -o cyclonedx-json > sbom-voice.json + fi + echo "SBOMs generated successfully" + when: + - event: tag + - event: manual + + vulnerability-scan: + image: *golang_image + commands: + - | + echo "Installing grype for ARM64..." + wget -qO- https://raw.githubusercontent.com/anchore/grype/main/install.sh | sh -s -- -b /usr/local/bin + grype sbom:sbom-consent.json -o table --fail-on critical || true + grype sbom:sbom-backend.json -o table --fail-on critical || true + if [ -f sbom-voice.json ]; then + grype sbom:sbom-voice.json -o table --fail-on critical || true + fi + when: + - event: tag + - event: manual + depends_on: + - generate-sbom + + # ======================================== + # STAGE 5: Deploy (nur manuell) + # ======================================== + + deploy-production: + image: *docker_image + commands: + - echo "Deploying to production..." + - docker compose -f docker-compose.yml pull || true + - docker compose -f docker-compose.yml up -d --remove-orphans || true + when: + event: manual + depends_on: + - build-consent-service + - build-backend diff --git a/.woodpecker/security.yml b/.woodpecker/security.yml new file mode 100644 index 0000000..8f7892d --- /dev/null +++ b/.woodpecker/security.yml @@ -0,0 +1,314 @@ +# Woodpecker CI Security Pipeline +# Dedizierte Security-Scans fuer DevSecOps +# +# Laeuft taeglich via Cron und bei jedem PR + +when: + - event: cron + cron: "0 3 * * *" # Taeglich um 3:00 Uhr + - event: pull_request + +clone: + git: + image: woodpeckerci/plugin-git + settings: + depth: 1 + extra_hosts: + - macmini:192.168.178.100 + +steps: + # ======================================== + # Static Analysis + # ======================================== + + semgrep-scan: + image: returntocorp/semgrep:latest + commands: + - semgrep scan --config auto --json -o semgrep-results.json . || true + - | + if [ -f semgrep-results.json ]; then + echo "=== Semgrep Findings ===" + cat semgrep-results.json | head -100 + fi + when: + event: [pull_request, cron] + + bandit-python: + image: python:3.12-slim + commands: + - pip install --quiet bandit + - bandit -r backend/ -f json -o bandit-results.json || true + - | + if [ -f bandit-results.json ]; then + echo "=== Bandit Findings ===" + cat bandit-results.json | head -50 + fi + when: + event: [pull_request, cron] + + gosec-go: + image: securego/gosec:latest + commands: + - gosec -fmt json -out gosec-consent.json ./consent-service/... || true + - gosec -fmt json -out gosec-billing.json ./billing-service/... || true + - echo "Go Security Scan abgeschlossen" + when: + event: [pull_request, cron] + + # ======================================== + # Secrets Detection + # ======================================== + + gitleaks-scan: + image: zricethezav/gitleaks:latest + commands: + - gitleaks detect --source . --report-format json --report-path gitleaks-report.json || true + - | + if [ -s gitleaks-report.json ]; then + echo "=== WARNUNG: Potentielle Secrets gefunden ===" + cat gitleaks-report.json + else + echo "Keine Secrets gefunden" + fi + + trufflehog-scan: + image: trufflesecurity/trufflehog:latest + commands: + - trufflehog filesystem . --json > trufflehog-results.json 2>&1 || true + - echo "TruffleHog Scan abgeschlossen" + + # ======================================== + # Dependency Vulnerabilities + # ======================================== + + npm-audit: + image: node:20-alpine + commands: + - cd website && npm audit --json > ../npm-audit-website.json || true + - cd ../studio-v2 && npm audit --json > ../npm-audit-studio.json || true + - cd ../admin-v2 && npm audit --json > ../npm-audit-admin.json || true + - echo "NPM Audit abgeschlossen" + when: + event: [pull_request, cron] + + pip-audit: + image: python:3.12-slim + commands: + - pip install --quiet pip-audit + - pip-audit -r backend/requirements.txt --format json -o pip-audit-backend.json || true + - pip-audit -r voice-service/requirements.txt --format json -o pip-audit-voice.json || true + - echo "Pip Audit abgeschlossen" + when: + event: [pull_request, cron] + + go-vulncheck: + image: golang:1.21-alpine + commands: + - go install golang.org/x/vuln/cmd/govulncheck@latest + - cd consent-service && govulncheck ./... || true + - cd ../billing-service && govulncheck ./... || true + - echo "Go Vulncheck abgeschlossen" + when: + event: [pull_request, cron] + + # ======================================== + # Container Security + # ======================================== + + trivy-filesystem: + image: aquasec/trivy:latest + commands: + - trivy fs --severity HIGH,CRITICAL --format json -o trivy-fs.json . || true + - echo "Trivy Filesystem Scan abgeschlossen" + when: + event: cron + + # ======================================== + # SBOM Generation (taeglich) + # ======================================== + + daily-sbom: + image: anchore/syft:latest + commands: + - mkdir -p sbom-reports + - syft dir:. -o cyclonedx-json > sbom-reports/sbom-full-$(date +%Y%m%d).json + - echo "SBOM generiert" + when: + event: cron + + # ======================================== + # AUTO-FIX: Dependency Vulnerabilities + # Laeuft nur bei Cron (nightly), nicht bei PRs + # ======================================== + + auto-fix-npm: + image: node:20-alpine + commands: + - apk add --no-cache git + - | + echo "=== Auto-Fix: NPM Dependencies ===" + FIXES_APPLIED=0 + + for dir in website studio-v2 admin-v2 h5p-service; do + if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then + echo "Pruefe $dir..." + cd $dir + + # Speichere Hash vor Fix + BEFORE=$(md5sum package-lock.json 2>/dev/null || echo "none") + + # npm audit fix (ohne --force fuer sichere Updates) + npm audit fix --package-lock-only 2>/dev/null || true + + # Pruefe ob Aenderungen + AFTER=$(md5sum package-lock.json 2>/dev/null || echo "none") + if [ "$BEFORE" != "$AFTER" ]; then + echo " -> Fixes angewendet in $dir" + FIXES_APPLIED=$((FIXES_APPLIED + 1)) + fi + + cd .. + fi + done + + echo "NPM Auto-Fix abgeschlossen: $FIXES_APPLIED Projekte aktualisiert" + echo "NPM_FIXES=$FIXES_APPLIED" >> /tmp/autofix-results.env + when: + event: cron + + auto-fix-python: + image: python:3.12-slim + commands: + - apt-get update && apt-get install -y git + - pip install --quiet pip-audit + - | + echo "=== Auto-Fix: Python Dependencies ===" + FIXES_APPLIED=0 + + for reqfile in backend/requirements.txt voice-service/requirements.txt klausur-service/backend/requirements.txt; do + if [ -f "$reqfile" ]; then + echo "Pruefe $reqfile..." + DIR=$(dirname $reqfile) + + # pip-audit mit --fix (aktualisiert requirements.txt) + pip-audit -r $reqfile --fix 2>/dev/null || true + + # Pruefe ob requirements.txt geaendert wurde + if git diff --quiet $reqfile 2>/dev/null; then + echo " -> Keine Aenderungen in $reqfile" + else + echo " -> Fixes angewendet in $reqfile" + FIXES_APPLIED=$((FIXES_APPLIED + 1)) + fi + fi + done + + echo "Python Auto-Fix abgeschlossen: $FIXES_APPLIED Dateien aktualisiert" + echo "PYTHON_FIXES=$FIXES_APPLIED" >> /tmp/autofix-results.env + when: + event: cron + + auto-fix-go: + image: golang:1.21-alpine + commands: + - apk add --no-cache git + - | + echo "=== Auto-Fix: Go Dependencies ===" + FIXES_APPLIED=0 + + for dir in consent-service billing-service school-service edu-search ai-compliance-sdk; do + if [ -d "$dir" ] && [ -f "$dir/go.mod" ]; then + echo "Pruefe $dir..." + cd $dir + + # Go mod tidy und update + go get -u ./... 2>/dev/null || true + go mod tidy 2>/dev/null || true + + # Pruefe ob go.mod/go.sum geaendert wurden + if git diff --quiet go.mod go.sum 2>/dev/null; then + echo " -> Keine Aenderungen in $dir" + else + echo " -> Updates angewendet in $dir" + FIXES_APPLIED=$((FIXES_APPLIED + 1)) + fi + + cd .. + fi + done + + echo "Go Auto-Fix abgeschlossen: $FIXES_APPLIED Module aktualisiert" + echo "GO_FIXES=$FIXES_APPLIED" >> /tmp/autofix-results.env + when: + event: cron + + # ======================================== + # Commit & Push Auto-Fixes + # ======================================== + + commit-security-fixes: + image: alpine/git:latest + commands: + - | + echo "=== Commit Security Fixes ===" + + # Git konfigurieren + git config --global user.email "security-bot@breakpilot.de" + git config --global user.name "Security Bot" + git config --global --add safe.directory /woodpecker/src + + # Pruefe ob es Aenderungen gibt + if git diff --quiet && git diff --cached --quiet; then + echo "Keine Security-Fixes zum Committen" + exit 0 + fi + + # Zeige was geaendert wurde + echo "Geaenderte Dateien:" + git status --short + + # Stage alle relevanten Dateien + git add -A \ + */package-lock.json \ + */requirements.txt \ + */go.mod \ + */go.sum \ + 2>/dev/null || true + + # Commit erstellen + TIMESTAMP=$(date +%Y-%m-%d) + git commit -m "fix(security): auto-fix vulnerable dependencies [$TIMESTAMP] + + Automatische Sicherheitsupdates durch CI/CD Pipeline: + - npm audit fix fuer Node.js Projekte + - pip-audit --fix fuer Python Projekte + - go get -u fuer Go Module + + Co-Authored-By: Security Bot " || echo "Nichts zu committen" + + # Push zum Repository + git push origin HEAD:main || echo "Push fehlgeschlagen - manueller Review erforderlich" + + echo "Security-Fixes committed und gepusht" + when: + event: cron + status: success + + # ======================================== + # Report to Dashboard + # ======================================== + + update-security-dashboard: + image: curlimages/curl:latest + commands: + - | + curl -X POST "http://backend:8000/api/security/scan-results" \ + -H "Content-Type: application/json" \ + -d "{ + \"scan_type\": \"daily\", + \"timestamp\": \"$(date -Iseconds)\", + \"tools\": [\"semgrep\", \"bandit\", \"gosec\", \"gitleaks\", \"trivy\"] + }" || true + when: + status: [success, failure] + event: cron diff --git a/AI_COMPLIANCE_SDK_IMPLEMENTATION_PLAN.md b/AI_COMPLIANCE_SDK_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..ee4c37a --- /dev/null +++ b/AI_COMPLIANCE_SDK_IMPLEMENTATION_PLAN.md @@ -0,0 +1,2029 @@ +# AI Compliance SDK - Vollständige Implementierungsspezifikation + +> **Version:** 1.0.0 +> **Erstellt:** 2026-02-03 +> **Status:** In Planung +> **Projekt:** breakpilot-pwa + +--- + +## Inhaltsverzeichnis + +1. [Executive Summary](#1-executive-summary) +2. [SDK-Architektur Übersicht](#2-sdk-architektur-übersicht) +3. [Logische Navigationsstruktur](#3-logische-navigationsstruktur) +4. [Datenfluss & Abhängigkeiten](#4-datenfluss--abhängigkeiten) +5. [Checkpoint-System](#5-checkpoint-system) +6. [Unified Command Bar](#6-unified-command-bar) +7. [State Management](#7-state-management) +8. [API-Struktur](#8-api-struktur) +9. [UI-Komponenten](#9-ui-komponenten) +10. [TypeScript Interfaces](#10-typescript-interfaces) +11. [Implementierungs-Roadmap](#11-implementierungs-roadmap) +12. [Testplan](#12-testplan) +13. [Dokumentation](#13-dokumentation) +14. [Offene Fragen & Klärungsbedarf](#14-offene-fragen--klärungsbedarf) +15. [Akzeptanzkriterien](#15-akzeptanzkriterien) + +--- + +## 1. Executive Summary + +Der AI Compliance SDK ist ein **B2B SaaS-Produkt** für Compliance-Management von KI-Anwendungsfällen. Er besteht aus zwei Hauptmodulen: + +| Modul | Beschreibung | Hauptfunktionen | +|-------|--------------|-----------------| +| **Modul 1** | Automatisches Compliance Assessment | Use Case → Risiko → Controls | +| **Modul 2** | Dokumentengenerierung | DSFA, TOMs, Verträge, Cookie Banner | + +### Zielgruppen + +- **Datenschutzbeauftragte (DSB)**: Compliance-Überwachung, DSFA-Erstellung +- **IT-Sicherheitsbeauftragte**: Security Screening, TOMs +- **Entwickler**: Use Case Workshop, Technical Controls +- **Management**: Risk Matrix, Audit Reports +- **Auditoren**: Evidence, Checklists, Compliance Reports + +### Technologie-Stack + +| Komponente | Technologie | Version | +|------------|-------------|---------| +| Frontend | Next.js (App Router) | 15.1 | +| UI Framework | React + TypeScript | 18.3 / 5.7 | +| Styling | Tailwind CSS | 3.4.16 | +| Backend | Go + Gin | 1.24.0 / 1.10.1 | +| Datenbank | PostgreSQL | 15+ | +| Cache | Valkey/Redis | - | +| Vector DB | Qdrant | - | + +--- + +## 2. SDK-Architektur Übersicht + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ AI COMPLIANCE SDK │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────────────────────────────────────────────────────────────┐ │ +│ │ UNIFIED COMMAND BAR │ │ +│ │ [🔍 Prompt: "Erstelle DSFA für Marketing-KI..." ] [⌘K] │ │ +│ └────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────┐ ┌─────────────────────────────────────────────────────┐ │ +│ │ │ │ │ │ +│ │ SIDEBAR │ │ MAIN CONTENT │ │ +│ │ (Guided │ │ │ │ +│ │ Flow) │ │ [Progress Bar: Phase 1 ████████░░ Phase 2] │ │ +│ │ │ │ │ │ +│ │ ┌─────────┐ │ │ ┌─────────────────────────────────────────────┐ │ │ +│ │ │Phase 1 │ │ │ │ │ │ │ +│ │ │━━━━━━━━━│ │ │ │ CURRENT STEP CONTENT │ │ │ +│ │ │1.Use Case│ │ │ │ │ │ │ +│ │ │2.Screening│ │ │ │ │ │ │ +│ │ │3.Compliance│ │ │ └─────────────────────────────────────────────┘ │ │ +│ │ │4.Controls│ │ │ │ │ +│ │ │5.Evidence│ │ │ ┌─────────────────────────────────────────────┐ │ │ +│ │ │6.Checklist│ │ │ │ CHECKPOINT: Qualitätsprüfung │ │ │ +│ │ │7.Risk │ │ │ │ ☑ Alle Pflichtfelder ausgefüllt │ │ │ +│ │ │━━━━━━━━━│ │ │ │ ☑ Keine kritischen Lücken │ │ │ +│ │ │Phase 2 │ │ │ │ ☐ DSB-Review erforderlich │ │ │ +│ │ │━━━━━━━━━│ │ │ └─────────────────────────────────────────────┘ │ │ +│ │ │8.AI Act │ │ │ │ │ +│ │ │9.DSFA │ │ │ [← Zurück] [Weiter →] [Überspringen] │ │ +│ │ │10.TOMs │ │ │ │ │ +│ │ │... │ │ │ │ │ +│ │ └─────────┘ │ │ │ │ +│ │ │ │ │ │ +│ └──────────────┘ └─────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### Komponenten-Hierarchie + +``` +SDKLayout +├── CommandBar (global, ⌘K aktiviert) +├── SDKSidebar +│ ├── PhaseIndicator +│ ├── StepList (Phase 1) +│ │ └── StepItem[] mit CheckpointBadge +│ └── StepList (Phase 2) +│ └── StepItem[] mit CheckpointBadge +├── MainContent +│ ├── ProgressBar +│ ├── PageContent (dynamisch je nach Route) +│ └── CheckpointCard +└── NavigationFooter + ├── BackButton + ├── NextButton + └── SkipButton +``` + +--- + +## 3. Logische Navigationsstruktur + +### Phase 1: Automatisches Compliance Assessment + +| # | Schritt | URL | Funktion | Voraussetzung | Checkpoint | +|---|---------|-----|----------|---------------|------------| +| 1.1 | **Use Case Workshop** | `/sdk/advisory-board` | 5-Schritte-Wizard für Use Case Erfassung | - | CP-UC | +| 1.2 | **System Screening** | `/sdk/screening` | SBOM + Security Check | Use Case erstellt | CP-SCAN | +| 1.3 | **Compliance Modules** | `/sdk/modules` | Abgleich welche Regulierungen gelten | Screening abgeschlossen | CP-MOD | +| 1.4 | **Requirements** | `/sdk/requirements` | Prüfaspekte aus Regulierungen ableiten | Module zugewiesen | CP-REQ | +| 1.5 | **Controls** | `/sdk/controls` | Erforderliche Maßnahmen ermitteln | Requirements definiert | CP-CTRL | +| 1.6 | **Evidence** | `/sdk/evidence` | Nachweise dokumentieren | Controls definiert | CP-EVI | +| 1.7 | **Audit Checklist** | `/sdk/audit-checklist` | Prüfliste generieren | Evidence vorhanden | CP-CHK | +| 1.8 | **Risk Matrix** | `/sdk/risks` | Risikobewertung & Residual Risk | Checklist abgeschlossen | CP-RISK | + +### Phase 2: Dokumentengenerierung + +| # | Schritt | URL | Funktion | Voraussetzung | Checkpoint | +|---|---------|-----|----------|---------------|------------| +| 2.1 | **AI Act Klassifizierung** | `/sdk/ai-act` | Risikostufe nach EU AI Act | Phase 1 abgeschlossen | CP-AI | +| 2.2 | **Pflichtenübersicht** | `/sdk/obligations` | NIS2, DSGVO, AI Act Pflichten | AI Act Klassifizierung | CP-OBL | +| 2.3 | **DSFA** | `/sdk/dsfa` | Datenschutz-Folgenabschätzung | Bei DSFA-Empfehlung | CP-DSFA | +| 2.4 | **TOMs** | `/sdk/tom` | Technische & Org. Maßnahmen | DSFA oder Controls | CP-TOM | +| 2.5 | **Löschfristen** | `/sdk/loeschfristen` | Aufbewahrungsrichtlinien | TOMs definiert | CP-RET | +| 2.6 | **Verarbeitungsverzeichnis** | `/sdk/vvt` | Art. 30 DSGVO Dokumentation | Löschfristen definiert | CP-VVT | +| 2.7 | **Rechtliche Vorlagen** | `/sdk/consent` | AGB, Datenschutz, Nutzungsbedingungen | VVT erstellt | CP-DOC | +| 2.8 | **Cookie Banner** | `/sdk/cookie-banner` | Cookie-Consent Generator | Rechtl. Vorlagen | CP-COOK | +| 2.9 | **Einwilligungen** | `/sdk/einwilligungen` | Consent-Tracking Setup | Cookie Banner | CP-CONS | +| 2.10 | **DSR Portal** | `/sdk/dsr` | Betroffenenrechte-Portal | Einwilligungen | CP-DSR | +| 2.11 | **Escalations** | `/sdk/escalations` | Management-Workflows | DSR Portal | CP-ESC | + +### Zusatzmodule (Nicht im Hauptflow) + +| Modul | URL | Funktion | Zugang | +|-------|-----|----------|--------| +| **Legal RAG** | `/sdk/rag` | Rechtliche Suche | Jederzeit | +| **AI Quality** | `/sdk/quality` | Qualitätsprüfung | Nach Phase 1 | +| **Security Backlog** | `/sdk/security-backlog` | Aus Screening generiert | Nach Screening | + +--- + +## 4. Datenfluss & Abhängigkeiten + +``` +┌─────────────────┐ +│ Use Case │ +│ Workshop │ +│ (Advisory) │ +└────────┬────────┘ + │ UseCaseIntake + AssessmentResult + ▼ +┌─────────────────┐ ┌─────────────────┐ +│ System │────▶│ Security │ +│ Screening │ │ Backlog │ +│ (SBOM+Sec) │ │ (Issues) │ +└────────┬────────┘ └─────────────────┘ + │ ServiceModules[] + Vulnerabilities[] + ▼ +┌─────────────────┐ +│ Compliance │ +│ Modules │ +│ (Regulations) │ +└────────┬────────┘ + │ ApplicableRegulations[] + ▼ +┌─────────────────┐ +│ Requirements │ +│ (558+ Rules) │ +└────────┬────────┘ + │ Requirements[] + Gaps[] + ▼ +┌─────────────────┐ +│ Controls │ +│ (44+ TOM) │ +└────────┬────────┘ + │ Controls[] + ImplementationStatus + ▼ +┌─────────────────┐ +│ Evidence │ +│ Management │ +└────────┬────────┘ + │ Evidence[] + Validity + ▼ +┌─────────────────┐ +│ Audit │ +│ Checklist │ +└────────┬────────┘ + │ ChecklistItems[] + ComplianceScore + ▼ +┌─────────────────┐ +│ Risk Matrix │──────────────────────────────────┐ +│ (5x5) │ │ +└────────┬────────┘ │ + │ Risks[] + ResidualRisks[] │ + ▼ │ +═════════════════════════════════════════════════════│═══════ + │ PHASE 2 START │ + ▼ │ +┌─────────────────┐ │ +│ AI Act │◀─────────────────────────────────┘ +│ Klassifizierung│ (Risikodaten aus Phase 1) +└────────┬────────┘ + │ RiskLevel + Obligations + ▼ +┌─────────────────┐ +│ Pflichten- │ +│ übersicht │ +└────────┬────────┘ + │ AllObligations[] grouped by Regulation + ▼ +┌─────────────────┐ +│ DSFA │ (Nur wenn dsfa_recommended=true) +│ Generator │ +└────────┬────────┘ + │ DSFA Document + Mitigations + ▼ +┌─────────────────┐ +│ TOMs │ +│ Katalog │ +└────────┬────────┘ + │ TOM[] + ImplementationPlan + ▼ +┌─────────────────┐ +│ Löschfristen │ +│ Definieren │ +└────────┬────────┘ + │ RetentionPolicies[] + ▼ +┌─────────────────┐ +│ Verarbeitungs- │ +│ verzeichnis │ +└────────┬────────┘ + │ ProcessingActivities[] (Art. 30) + ▼ +┌─────────────────┐ +│ Rechtliche │ +│ Vorlagen │ +└────────┬────────┘ + │ Documents[] (AGB, Privacy, Terms) + ▼ +┌─────────────────┐ +│ Cookie Banner │ +│ Generator │ +└────────┬────────┘ + │ CookieBannerConfig + Scripts + ▼ +┌─────────────────┐ +│ Einwilligungen │ +│ Tracking │ +└────────┬────────┘ + │ ConsentRecords[] + AuditTrail + ▼ +┌─────────────────┐ +│ DSR Portal │ +│ Setup │ +└────────┬────────┘ + │ DSRWorkflow + Templates + ▼ +┌─────────────────┐ +│ Escalations │ +│ Workflows │ +└─────────────────┘ +``` + +--- + +## 5. Checkpoint-System + +### 5.1 Checkpoint-Typen + +```typescript +interface Checkpoint { + id: string // z.B. "CP-UC" + step: string // z.B. "use-case-workshop" + name: string // z.B. "Use Case Checkpoint" + type: 'REQUIRED' | 'RECOMMENDED' | 'OPTIONAL' + validation: ValidationRule[] + blocksProgress: boolean + requiresReview: 'NONE' | 'TEAM_LEAD' | 'DSB' | 'LEGAL' + autoValidate: boolean // Automatische Validierung bei Änderungen +} + +interface ValidationRule { + id: string + field: string // Pfad zum Feld im State + condition: 'NOT_EMPTY' | 'MIN_COUNT' | 'MIN_VALUE' | 'CUSTOM' | 'REGEX' + value?: number | string | RegExp // Vergleichswert + customValidator?: (state: SDKState) => boolean + message: string // Fehlermeldung + severity: 'ERROR' | 'WARNING' | 'INFO' +} + +interface CheckpointStatus { + checkpointId: string + passed: boolean + validatedAt: Date | null + validatedBy: string | null // User ID oder "SYSTEM" + errors: ValidationError[] + warnings: ValidationError[] + overrideReason?: string // Falls manuell überschrieben + overriddenBy?: string + overriddenAt?: Date +} +``` + +### 5.2 Checkpoint-Matrix + +| Checkpoint | Schritt | Validierung | Blockiert | Review | +|------------|---------|-------------|-----------|--------| +| CP-UC | 1.1 Use Case | Min. 1 Use Case mit allen 5 Schritten | ✅ Ja | NONE | +| CP-SCAN | 1.2 Screening | SBOM generiert, Security Scan abgeschlossen | ✅ Ja | NONE | +| CP-MOD | 1.3 Modules | Min. 1 Regulierung zugewiesen | ✅ Ja | NONE | +| CP-REQ | 1.4 Requirements | Alle kritischen Requirements adressiert | ✅ Ja | NONE | +| CP-CTRL | 1.5 Controls | Alle BLOCK-Controls definiert | ✅ Ja | DSB | +| CP-EVI | 1.6 Evidence | Evidence für alle kritischen Controls | ❌ Nein | NONE | +| CP-CHK | 1.7 Checklist | Checklist generiert | ❌ Nein | NONE | +| CP-RISK | 1.8 Risk Matrix | Alle HIGH/CRITICAL Risiken mit Mitigation | ✅ Ja | DSB | +| CP-AI | 2.1 AI Act | Risikostufe bestätigt | ✅ Ja | LEGAL | +| CP-OBL | 2.2 Pflichten | Pflichten zugewiesen | ❌ Nein | NONE | +| CP-DSFA | 2.3 DSFA | DSFA abgeschlossen (wenn erforderlich) | ✅ Ja* | DSB | +| CP-TOM | 2.4 TOMs | Alle erforderlichen TOMs definiert | ✅ Ja | NONE | +| CP-RET | 2.5 Löschfristen | Fristen für alle Datenkategorien | ✅ Ja | NONE | +| CP-VVT | 2.6 VVT | Verzeichnis vollständig | ✅ Ja | DSB | +| CP-DOC | 2.7 Vorlagen | Dokumente erstellt | ❌ Nein | LEGAL | +| CP-COOK | 2.8 Cookie | Banner konfiguriert | ❌ Nein | NONE | +| CP-CONS | 2.9 Einwilligungen | Tracking aktiviert | ❌ Nein | NONE | +| CP-DSR | 2.10 DSR | Portal konfiguriert | ❌ Nein | NONE | +| CP-ESC | 2.11 Escalations | Workflows definiert | ❌ Nein | NONE | + +*Nur wenn DSFA empfohlen wurde + +### 5.3 Validierungsregeln (Beispiele) + +```typescript +const checkpointValidations: Record = { + 'CP-UC': [ + { + id: 'uc-min-count', + field: 'useCases', + condition: 'MIN_COUNT', + value: 1, + message: 'Mindestens ein Use Case muss erstellt werden', + severity: 'ERROR' + }, + { + id: 'uc-steps-complete', + field: 'useCases', + condition: 'CUSTOM', + customValidator: (state) => state.useCases.every(uc => uc.stepsCompleted === 5), + message: 'Alle Use Cases müssen alle 5 Schritte abgeschlossen haben', + severity: 'ERROR' + } + ], + 'CP-SCAN': [ + { + id: 'sbom-exists', + field: 'sbom', + condition: 'NOT_EMPTY', + message: 'SBOM muss generiert werden', + severity: 'ERROR' + }, + { + id: 'scan-complete', + field: 'screening.status', + condition: 'CUSTOM', + customValidator: (state) => state.screening?.status === 'completed', + message: 'Security Scan muss abgeschlossen sein', + severity: 'ERROR' + } + ], + 'CP-RISK': [ + { + id: 'critical-risks-mitigated', + field: 'risks', + condition: 'CUSTOM', + customValidator: (state) => { + const criticalRisks = state.risks.filter(r => + r.severity === 'CRITICAL' || r.severity === 'HIGH' + ); + return criticalRisks.every(r => r.mitigation && r.mitigation.length > 0); + }, + message: 'Alle kritischen und hohen Risiken müssen Mitigationsmaßnahmen haben', + severity: 'ERROR' + } + ] +}; +``` + +--- + +## 6. Unified Command Bar + +### 6.1 Architektur + +```typescript +interface CommandBarState { + isOpen: boolean + query: string + context: SDKContext + suggestions: CommandSuggestion[] + history: CommandHistory[] + isLoading: boolean + error: string | null +} + +interface SDKContext { + currentPhase: 1 | 2 + currentStep: string + completedSteps: string[] + activeCheckpoint: Checkpoint | null + useCases: UseCaseAssessment[] + pendingActions: Action[] +} + +interface CommandSuggestion { + id: string + type: 'ACTION' | 'NAVIGATION' | 'SEARCH' | 'GENERATE' | 'HELP' + label: string + description: string + shortcut?: string + icon?: string + action: () => void | Promise + relevanceScore: number +} + +interface CommandHistory { + id: string + query: string + type: CommandSuggestion['type'] + timestamp: Date + success: boolean +} +``` + +### 6.2 Unterstützte Befehle + +| Kategorie | Befehl | Aktion | +|-----------|--------|--------| +| **Navigation** | "Gehe zu DSFA" | navigiert zu `/sdk/dsfa` | +| | "Öffne Risk Matrix" | navigiert zu `/sdk/risks` | +| | "Zurück" | vorheriger Schritt | +| | "Phase 2" | springt zu Phase 2 Start | +| **Aktionen** | "Erstelle neuen Use Case" | startet Wizard | +| | "Exportiere als PDF" | generiert Export | +| | "Starte Security Scan" | triggert Screening | +| | "Validiere Checkpoint" | führt Validierung durch | +| **Generierung** | "Erstelle DSFA für Marketing-KI" | RAG-basierte Generierung | +| | "Welche TOMs brauche ich für Gesundheitsdaten?" | Empfehlungen | +| | "Erkläre Art. 9 DSGVO" | Rechtliche Erklärung | +| **Suche** | "Suche DSGVO Artikel 17" | Rechtliche Suche | +| | "Finde Controls für Verschlüsselung" | Control-Suche | +| **Hilfe** | "Hilfe" | zeigt verfügbare Befehle | +| | "Was muss ich als nächstes tun?" | Kontextbezogene Hilfe | + +### 6.3 Keyboard Shortcuts + +| Shortcut | Aktion | +|----------|--------| +| `⌘K` / `Ctrl+K` | Command Bar öffnen | +| `Escape` | Command Bar schließen | +| `↑` / `↓` | Navigation in Suggestions | +| `Enter` | Suggestion ausführen | +| `⌘→` / `Ctrl+→` | Nächster Schritt | +| `⌘←` / `Ctrl+←` | Vorheriger Schritt | +| `⌘S` / `Ctrl+S` | State speichern | +| `⌘E` / `Ctrl+E` | Export-Dialog | + +--- + +## 7. State Management + +### 7.1 Globaler SDK-State + +```typescript +interface SDKState { + // Metadata + version: string // Schema-Version + lastModified: Date + + // Tenant & User + tenantId: string + userId: string + subscription: SubscriptionTier + + // Progress + currentPhase: 1 | 2 + currentStep: string + completedSteps: string[] + checkpoints: Record + + // Phase 1 Data + useCases: UseCaseAssessment[] + activeUseCase: string | null + screening: ScreeningResult | null + modules: ServiceModule[] + requirements: Requirement[] + controls: Control[] + evidence: Evidence[] + checklist: ChecklistItem[] + risks: Risk[] + + // Phase 2 Data + aiActClassification: AIActResult | null + obligations: Obligation[] + dsfa: DSFA | null + toms: TOM[] + retentionPolicies: RetentionPolicy[] + vvt: ProcessingActivity[] + documents: LegalDocument[] + cookieBanner: CookieBannerConfig | null + consents: ConsentRecord[] + dsrConfig: DSRConfig | null + escalationWorkflows: EscalationWorkflow[] + + // Security + sbom: SBOM | null + securityIssues: SecurityIssue[] + securityBacklog: BacklogItem[] + + // UI State + commandBarHistory: CommandHistory[] + recentSearches: string[] + preferences: UserPreferences +} + +type SubscriptionTier = 'FREE' | 'STARTER' | 'PROFESSIONAL' | 'ENTERPRISE'; + +interface UserPreferences { + language: 'de' | 'en' + theme: 'light' | 'dark' | 'system' + compactMode: boolean + showHints: boolean + autoSave: boolean + autoValidate: boolean +} +``` + +### 7.2 Persistierung + +```typescript +// Auto-Save bei jeder Änderung +const autoSave = debounce(async (state: SDKState) => { + await api.post('/sdk/v1/state/save', { + tenantId: state.tenantId, + state: serializeState(state), + checksum: calculateChecksum(state) + }); +}, 2000); + +// Load bei App-Start +const loadState = async (tenantId: string): Promise => { + const saved = await api.get(`/sdk/v1/state/${tenantId}`); + return deserializeState(saved); +}; + +// Conflict Resolution +const mergeStates = (local: SDKState, remote: SDKState): SDKState => { + if (local.lastModified > remote.lastModified) { + return local; + } + return remote; +}; +``` + +### 7.3 React Context + +```typescript +interface SDKContextValue { + state: SDKState + dispatch: React.Dispatch + + // Navigation + goToStep: (step: string) => void + goToNextStep: () => void + goToPreviousStep: () => void + + // Checkpoints + validateCheckpoint: (checkpointId: string) => Promise + overrideCheckpoint: (checkpointId: string, reason: string) => Promise + + // State Updates + updateUseCase: (id: string, data: Partial) => void + addRisk: (risk: Risk) => void + updateControl: (id: string, data: Partial) => void + + // Export + exportState: (format: 'json' | 'pdf' | 'zip') => Promise +} + +const SDKContext = React.createContext(null); + +export const useSDK = () => { + const context = useContext(SDKContext); + if (!context) { + throw new Error('useSDK must be used within SDKProvider'); + } + return context; +}; +``` + +--- + +## 8. API-Struktur + +### 8.1 SDK-spezifische Endpoints + +``` +# State Management +GET /sdk/v1/state/{tenantId} → Kompletten State laden +POST /sdk/v1/state/save → State speichern +POST /sdk/v1/state/reset → State zurücksetzen +GET /sdk/v1/state/history → State-Historie (für Undo) + +# Screening +POST /sdk/v1/screening/start → SBOM + Security Scan starten +GET /sdk/v1/screening/status → Scan-Status abrufen +GET /sdk/v1/screening/sbom → SBOM abrufen +GET /sdk/v1/screening/security → Security Issues abrufen +POST /sdk/v1/screening/backlog → Backlog aus Issues generieren + +# Checkpoints +GET /sdk/v1/checkpoints → Alle Checkpoint-Status +GET /sdk/v1/checkpoints/{id} → Einzelner Checkpoint +POST /sdk/v1/checkpoints/{id}/validate → Checkpoint validieren +POST /sdk/v1/checkpoints/{id}/override → Checkpoint überschreiben (mit Review) +GET /sdk/v1/checkpoints/{id}/history → Validierungs-Historie + +# Wizard Flow +GET /sdk/v1/flow/current → Aktueller Schritt + Kontext +POST /sdk/v1/flow/next → Zum nächsten Schritt +POST /sdk/v1/flow/previous → Zum vorherigen Schritt +GET /sdk/v1/flow/suggestions → Kontextbezogene Vorschläge +GET /sdk/v1/flow/progress → Gesamtfortschritt + +# Document Generation +POST /sdk/v1/generate/dsfa → DSFA generieren +POST /sdk/v1/generate/tom → TOMs generieren +POST /sdk/v1/generate/vvt → VVT generieren +POST /sdk/v1/generate/documents → Rechtliche Vorlagen +POST /sdk/v1/generate/cookie-banner → Cookie Banner Code +POST /sdk/v1/generate/dsr-portal → DSR Portal Config + +# Export +GET /sdk/v1/export/full → Kompletter Export (ZIP) +GET /sdk/v1/export/phase1 → Phase 1 Export +GET /sdk/v1/export/phase2 → Phase 2 Export +GET /sdk/v1/export/{document} → Einzelnes Dokument +GET /sdk/v1/export/audit-report → Audit-Report (PDF) + +# Command Bar / RAG +POST /sdk/v1/command/execute → Befehl ausführen +POST /sdk/v1/command/search → Suche durchführen +POST /sdk/v1/command/generate → Content generieren (RAG) +GET /sdk/v1/command/suggestions → Vorschläge basierend auf Kontext +``` + +### 8.2 Request/Response Beispiele + +```typescript +// POST /sdk/v1/checkpoints/{id}/validate +// Request +{ + "checkpointId": "CP-RISK", + "context": { + "userId": "user-123", + "timestamp": "2026-02-03T10:30:00Z" + } +} + +// Response +{ + "checkpointId": "CP-RISK", + "passed": false, + "validatedAt": "2026-02-03T10:30:01Z", + "validatedBy": "SYSTEM", + "errors": [ + { + "ruleId": "critical-risks-mitigated", + "field": "risks[2]", + "message": "Risiko 'Datenverlust' hat keine Mitigationsmaßnahme", + "severity": "ERROR" + } + ], + "warnings": [ + { + "ruleId": "risk-owner-assigned", + "field": "risks[5]", + "message": "Risiko 'Performance-Degradation' hat keinen Owner", + "severity": "WARNING" + } + ], + "nextActions": [ + { + "type": "FIX_ERROR", + "targetField": "risks[2].mitigation", + "suggestion": "Fügen Sie eine Mitigationsmaßnahme hinzu" + } + ] +} +``` + +--- + +## 9. UI-Komponenten + +### 9.1 Komponentenstruktur + +``` +admin-v2/components/sdk/ +├── CommandBar/ +│ ├── CommandBar.tsx # Hauptkomponente (⌘K Modal) +│ ├── CommandInput.tsx # Eingabefeld mit Autocomplete +│ ├── SuggestionList.tsx # Vorschlagsliste +│ ├── SuggestionItem.tsx # Einzelner Vorschlag +│ ├── CommandHistory.tsx # Historie-Anzeige +│ ├── useCommandBar.ts # Hook für State & Logic +│ └── index.ts +│ +├── Sidebar/ +│ ├── SDKSidebar.tsx # Hauptnavigation +│ ├── PhaseIndicator.tsx # Phase 1/2 Anzeige mit Progress +│ ├── StepList.tsx # Liste der Schritte +│ ├── StepItem.tsx # Einzelner Schritt +│ ├── CheckpointBadge.tsx # Checkpoint-Status Badge +│ ├── CollapsibleSection.tsx # Einklappbare Sektion +│ └── index.ts +│ +├── Progress/ +│ ├── ProgressBar.tsx # Gesamtfortschritt +│ ├── StepProgress.tsx # Schritt-Fortschritt (circular) +│ ├── PhaseTransition.tsx # Übergang Phase 1→2 Animation +│ ├── CompletionCard.tsx # Abschluss-Karte +│ └── index.ts +│ +├── Checkpoint/ +│ ├── CheckpointCard.tsx # Checkpoint-Anzeige +│ ├── ValidationList.tsx # Validierungsfehler/-warnungen +│ ├── ValidationItem.tsx # Einzelner Validierungsfehler +│ ├── ReviewRequest.tsx # Review anfordern +│ ├── CheckpointOverride.tsx # Override mit Begründung +│ ├── CheckpointHistory.tsx # Validierungs-Historie +│ └── index.ts +│ +├── Wizard/ +│ ├── WizardContainer.tsx # Wizard-Rahmen +│ ├── WizardStep.tsx # Einzelner Schritt +│ ├── WizardNavigation.tsx # Vor/Zurück/Überspringen +│ ├── WizardSummary.tsx # Zusammenfassung +│ ├── WizardProgress.tsx # Progress Indicator +│ └── index.ts +│ +├── Screening/ +│ ├── ScreeningDashboard.tsx # Übersicht +│ ├── SBOMViewer.tsx # SBOM Anzeige (Tabelle) +│ ├── SBOMGraph.tsx # SBOM als Dependency Graph +│ ├── SecurityIssues.tsx # Issues Liste +│ ├── SecurityIssueCard.tsx # Einzelne Issue +│ ├── BacklogGenerator.tsx # Backlog erstellen +│ ├── ScanProgress.tsx # Scan-Fortschritt +│ └── index.ts +│ +├── Generation/ +│ ├── DocumentGenerator.tsx # Dokument-Generierung +│ ├── GenerationProgress.tsx # Fortschritt (Streaming) +│ ├── DocumentPreview.tsx # Vorschau (Markdown/PDF) +│ ├── DocumentEditor.tsx # Inline-Editor +│ ├── ExportDialog.tsx # Export-Optionen +│ └── index.ts +│ +├── Risk/ +│ ├── RiskMatrix.tsx # 5x5 Risk Matrix +│ ├── RiskCard.tsx # Einzelnes Risiko +│ ├── RiskForm.tsx # Risiko hinzufügen/bearbeiten +│ ├── MitigationForm.tsx # Mitigation hinzufügen +│ └── index.ts +│ +├── Layout/ +│ ├── SDKLayout.tsx # SDK-spezifisches Layout +│ ├── SDKHeader.tsx # Header mit Aktionen +│ ├── NavigationFooter.tsx # Vor/Zurück Footer +│ └── index.ts +│ +└── common/ + ├── StatusBadge.tsx # Status-Anzeige + ├── ActionButton.tsx # Primäre Aktionen + ├── InfoTooltip.tsx # Hilfe-Tooltips + ├── EmptyState.tsx # Leerer Zustand + ├── LoadingState.tsx # Ladezustand + ├── ErrorBoundary.tsx # Fehlerbehandlung + └── index.ts +``` + +### 9.2 Beispiel-Komponenten + +```typescript +// SDKSidebar.tsx +interface SDKSidebarProps { + currentStep: string + completedSteps: string[] + checkpoints: Record + onStepClick: (step: string) => void +} + +// CheckpointCard.tsx +interface CheckpointCardProps { + checkpoint: Checkpoint + status: CheckpointStatus + onValidate: () => Promise + onOverride: (reason: string) => Promise + onRequestReview: (reviewerType: string) => Promise +} + +// CommandBar.tsx +interface CommandBarProps { + isOpen: boolean + onClose: () => void + context: SDKContext + onExecute: (command: CommandSuggestion) => Promise +} +``` + +--- + +## 10. TypeScript Interfaces + +### 10.1 Core Models + +```typescript +// Use Case Assessment +interface UseCaseAssessment { + id: string + name: string + description: string + category: string + stepsCompleted: number + steps: UseCaseStep[] + assessmentResult: AssessmentResult | null + createdAt: Date + updatedAt: Date +} + +interface UseCaseStep { + id: string + name: string + completed: boolean + data: Record +} + +interface AssessmentResult { + riskLevel: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' + applicableRegulations: string[] + recommendedControls: string[] + dsfaRequired: boolean + aiActClassification: string +} + +// Screening +interface ScreeningResult { + id: string + status: 'pending' | 'running' | 'completed' | 'failed' + startedAt: Date + completedAt: Date | null + sbom: SBOM | null + securityScan: SecurityScanResult | null + error: string | null +} + +interface SBOM { + format: 'CycloneDX' | 'SPDX' + version: string + components: SBOMComponent[] + dependencies: SBOMDependency[] + generatedAt: Date +} + +interface SBOMComponent { + name: string + version: string + type: 'library' | 'framework' | 'application' | 'container' + purl: string + licenses: string[] + vulnerabilities: Vulnerability[] +} + +interface SecurityScanResult { + totalIssues: number + critical: number + high: number + medium: number + low: number + issues: SecurityIssue[] +} + +interface SecurityIssue { + id: string + severity: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW' + title: string + description: string + cve: string | null + cvss: number | null + affectedComponent: string + remediation: string + status: 'OPEN' | 'IN_PROGRESS' | 'RESOLVED' | 'ACCEPTED' +} + +// Compliance +interface ServiceModule { + id: string + name: string + description: string + regulations: string[] + criticality: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' + processesPersonalData: boolean + hasAIComponents: boolean +} + +interface Requirement { + id: string + regulation: string + article: string + title: string + description: string + criticality: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' + applicableModules: string[] + status: 'NOT_STARTED' | 'IN_PROGRESS' | 'IMPLEMENTED' | 'VERIFIED' + controls: string[] +} + +interface Control { + id: string + name: string + description: string + type: 'TECHNICAL' | 'ORGANIZATIONAL' | 'PHYSICAL' + category: string + implementationStatus: 'NOT_IMPLEMENTED' | 'PARTIAL' | 'IMPLEMENTED' + effectiveness: 'LOW' | 'MEDIUM' | 'HIGH' + evidence: string[] + owner: string | null + dueDate: Date | null +} + +interface Evidence { + id: string + controlId: string + type: 'DOCUMENT' | 'SCREENSHOT' | 'LOG' | 'CERTIFICATE' | 'AUDIT_REPORT' + name: string + description: string + fileUrl: string | null + validFrom: Date + validUntil: Date | null + uploadedBy: string + uploadedAt: Date +} + +// Risk +interface Risk { + id: string + title: string + description: string + category: string + likelihood: 1 | 2 | 3 | 4 | 5 + impact: 1 | 2 | 3 | 4 | 5 + severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' + inherentRiskScore: number + residualRiskScore: number + status: 'IDENTIFIED' | 'ASSESSED' | 'MITIGATED' | 'ACCEPTED' | 'CLOSED' + mitigation: RiskMitigation[] + owner: string | null + relatedControls: string[] + relatedRequirements: string[] +} + +interface RiskMitigation { + id: string + description: string + type: 'AVOID' | 'TRANSFER' | 'MITIGATE' | 'ACCEPT' + status: 'PLANNED' | 'IN_PROGRESS' | 'COMPLETED' + effectiveness: number // 0-100 + controlId: string | null +} + +// Phase 2 Models +interface AIActResult { + riskCategory: 'MINIMAL' | 'LIMITED' | 'HIGH' | 'UNACCEPTABLE' + systemType: string + obligations: AIActObligation[] + assessmentDate: Date + assessedBy: string + justification: string +} + +interface DSFA { + id: string + status: 'DRAFT' | 'IN_REVIEW' | 'APPROVED' | 'REJECTED' + version: number + sections: DSFASection[] + approvals: DSFAApproval[] + createdAt: Date + updatedAt: Date +} + +interface TOM { + id: string + category: string + name: string + description: string + type: 'TECHNICAL' | 'ORGANIZATIONAL' + implementationStatus: 'NOT_IMPLEMENTED' | 'PARTIAL' | 'IMPLEMENTED' + priority: 'LOW' | 'MEDIUM' | 'HIGH' + responsiblePerson: string | null + implementationDate: Date | null + reviewDate: Date | null + evidence: string[] +} + +interface RetentionPolicy { + id: string + dataCategory: string + description: string + legalBasis: string + retentionPeriod: string // ISO 8601 Duration + deletionMethod: string + exceptions: string[] +} + +interface ProcessingActivity { + id: string + name: string + purpose: string + legalBasis: string + dataCategories: string[] + dataSubjects: string[] + recipients: string[] + thirdCountryTransfers: boolean + retentionPeriod: string + technicalMeasures: string[] + organizationalMeasures: string[] +} + +interface CookieBannerConfig { + id: string + style: 'BANNER' | 'MODAL' | 'FLOATING' + position: 'TOP' | 'BOTTOM' | 'CENTER' + theme: 'LIGHT' | 'DARK' | 'CUSTOM' + texts: { + title: string + description: string + acceptAll: string + rejectAll: string + settings: string + save: string + } + categories: CookieCategory[] + generatedCode: { + html: string + css: string + js: string + } +} + +interface CookieCategory { + id: string + name: string + description: string + required: boolean + cookies: Cookie[] +} +``` + +--- + +## 11. Implementierungs-Roadmap + +### Sprint 1: Foundation (2 Wochen) + +| Task | Beschreibung | Priorität | Abhängigkeiten | +|------|--------------|-----------|----------------| +| SDK-Routing | `/sdk/*` Routen einrichten | KRITISCH | - | +| SDKProvider | State Management Context | KRITISCH | - | +| SDKLayout | Layout mit Sidebar | KRITISCH | SDKProvider | +| SDKSidebar | Navigation mit Phasen | HOCH | SDKLayout | +| Checkpoint-Types | TypeScript Interfaces | HOCH | - | +| Checkpoint-Service | Validierungs-Logik | HOCH | Checkpoint-Types | +| API: State | GET/POST /sdk/v1/state/* | HOCH | - | +| API: Checkpoints | GET/POST /sdk/v1/checkpoints/* | HOCH | Checkpoint-Service | + +**Deliverables Sprint 1:** +- Funktionierendes SDK-Routing +- State-Persistierung +- Basis-Navigation zwischen Schritten + +### Sprint 2: Phase 1 Flow (3 Wochen) + +| Task | Beschreibung | Priorität | Abhängigkeiten | +|------|--------------|-----------|----------------| +| Use Case Workshop | Integration bestehender Advisory Board | KRITISCH | Sprint 1 | +| Screening-UI | Dashboard + SBOM Viewer | KRITISCH | Sprint 1 | +| Screening-Backend | SBOM Generation + Security Scan | KRITISCH | - | +| SecurityBacklog | Backlog aus Issues generieren | HOCH | Screening | +| Modules-Integration | Compliance Module Verknüpfung | HOCH | Screening | +| Requirements-Flow | Requirements aus Modulen ableiten | HOCH | Modules | +| Controls-Flow | Controls aus Requirements | HOCH | Requirements | +| Evidence-Management | Evidence Upload + Validierung | MITTEL | Controls | +| Audit-Checklist | Checklist generieren | MITTEL | Evidence | +| Risk-Matrix | 5x5 Matrix + Mitigation | KRITISCH | Checklist | + +**Deliverables Sprint 2:** +- Vollständiger Phase 1 Flow +- SBOM-Generierung +- Security Scanning +- Risk Assessment + +### Sprint 3: Phase 2 Flow (3 Wochen) + +| Task | Beschreibung | Priorität | Abhängigkeiten | +|------|--------------|-----------|----------------| +| AI Act Klassifizierung | Wizard + Empfehlungen | KRITISCH | Phase 1 | +| Pflichtenübersicht | Gruppierte Pflichten-Ansicht | HOCH | AI Act | +| DSFA Generator | RAG-basierte Generierung | KRITISCH | Pflichtenübersicht | +| TOMs Katalog | TOM-Auswahl + Priorisierung | HOCH | DSFA | +| Löschfristen | Fristen-Management | MITTEL | TOMs | +| VVT Generator | Art. 30 Export | HOCH | Löschfristen | +| Rechtliche Vorlagen | Template-System | MITTEL | VVT | + +**Deliverables Sprint 3:** +- AI Act Compliance +- DSFA-Generierung +- TOM-Management +- VVT-Export + +### Sprint 4: Consent & DSR (2 Wochen) + +| Task | Beschreibung | Priorität | Abhängigkeiten | +|------|--------------|-----------|----------------| +| Cookie Banner Generator | Konfigurator + Code-Export | HOCH | Sprint 3 | +| Cookie Kategorien | Kategorie-Management | HOCH | Cookie Banner | +| Einwilligungen Tracking | Consent Records | MITTEL | Cookie Banner | +| DSR Portal Config | Portal-Einrichtung | MITTEL | Einwilligungen | +| DSR Workflows | Bearbeitungs-Workflows | MITTEL | DSR Portal | +| Escalations | Management-Workflows | NIEDRIG | DSR | + +**Deliverables Sprint 4:** +- Cookie Consent System +- DSR Management +- Escalation Workflows + +### Sprint 5: Command Bar & Polish (2 Wochen) + +| Task | Beschreibung | Priorität | Abhängigkeiten | +|------|--------------|-----------|----------------| +| CommandBar UI | Modal + Input | HOCH | Sprint 4 | +| Command Parser | Befehlserkennung | HOCH | CommandBar UI | +| RAG Integration | Suche + Generierung | HOCH | Command Parser | +| Suggestion Engine | Kontextbezogene Vorschläge | MITTEL | RAG | +| Keyboard Shortcuts | Global Shortcuts | MITTEL | CommandBar | +| Export-Funktionen | PDF/ZIP/JSON Export | HOCH | - | +| Progress Animations | Übergangs-Animationen | NIEDRIG | - | +| Responsive Design | Mobile Optimierung | MITTEL | - | + +**Deliverables Sprint 5:** +- Unified Command Bar +- Export-System +- Polish & UX + +### Sprint 6: Testing & QA (1 Woche) + +| Task | Beschreibung | Priorität | Abhängigkeiten | +|------|--------------|-----------|----------------| +| Unit Tests | Komponenten-Tests | KRITISCH | Sprint 5 | +| Integration Tests | API-Tests | KRITISCH | Sprint 5 | +| E2E Tests | Flow-Tests | KRITISCH | Sprint 5 | +| Performance Tests | Load Testing | HOCH | Sprint 5 | +| Security Audit | Sicherheitsprüfung | KRITISCH | Sprint 5 | +| Dokumentation | User Guide + API Docs | HOCH | Sprint 5 | +| Bug Fixes | Fehlerbehebung | KRITISCH | Tests | + +**Deliverables Sprint 6:** +- Vollständige Testabdeckung +- Dokumentation +- Production-Ready Release + +--- + +## 12. Testplan + +### 12.1 Unit Tests + +```typescript +// Beispiel: Checkpoint Validation Tests +describe('CheckpointService', () => { + describe('validateCheckpoint', () => { + it('should return passed=true when all validations pass', async () => { + const state = createMockState({ + useCases: [{ id: '1', stepsCompleted: 5 }] + }); + + const result = await checkpointService.validate('CP-UC', state); + + expect(result.passed).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should return errors when use case steps incomplete', async () => { + const state = createMockState({ + useCases: [{ id: '1', stepsCompleted: 3 }] + }); + + const result = await checkpointService.validate('CP-UC', state); + + expect(result.passed).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ ruleId: 'uc-steps-complete' }) + ); + }); + + it('should allow override with valid reason', async () => { + const result = await checkpointService.override( + 'CP-UC', + 'Genehmigt durch DSB am 2026-02-03', + 'user-123' + ); + + expect(result.overrideReason).toBeTruthy(); + expect(result.passed).toBe(true); + }); + }); +}); + +// Beispiel: Risk Matrix Tests +describe('RiskMatrix', () => { + describe('calculateRiskScore', () => { + it('should calculate correct risk score', () => { + const risk: Risk = { + likelihood: 4, + impact: 5 + }; + + const score = calculateRiskScore(risk); + + expect(score).toBe(20); + expect(getRiskSeverity(score)).toBe('CRITICAL'); + }); + + it('should calculate residual risk after mitigation', () => { + const risk: Risk = { + likelihood: 4, + impact: 5, + mitigation: [{ effectiveness: 60 }] + }; + + const residualScore = calculateResidualRisk(risk); + + expect(residualScore).toBe(8); // 20 * 0.4 + }); + }); +}); + +// Beispiel: Command Bar Tests +describe('CommandParser', () => { + it('should parse navigation commands', () => { + const result = parseCommand('Gehe zu DSFA'); + + expect(result.type).toBe('NAVIGATION'); + expect(result.target).toBe('/sdk/dsfa'); + }); + + it('should parse generation commands', () => { + const result = parseCommand('Erstelle DSFA für Marketing-KI'); + + expect(result.type).toBe('GENERATE'); + expect(result.documentType).toBe('dsfa'); + expect(result.context).toContain('Marketing-KI'); + }); + + it('should return suggestions for partial input', () => { + const suggestions = getSuggestions('Erst', mockContext); + + expect(suggestions).toContainEqual( + expect.objectContaining({ label: 'Erstelle neuen Use Case' }) + ); + }); +}); +``` + +### 12.2 Integration Tests + +```typescript +// API Integration Tests +describe('SDK API', () => { + describe('POST /sdk/v1/state/save', () => { + it('should save state successfully', async () => { + const state = createMockState(); + + const response = await request(app) + .post('/sdk/v1/state/save') + .send({ tenantId: 'tenant-1', state }) + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.savedAt).toBeTruthy(); + }); + + it('should reject invalid state schema', async () => { + const invalidState = { invalid: 'data' }; + + await request(app) + .post('/sdk/v1/state/save') + .send({ tenantId: 'tenant-1', state: invalidState }) + .expect(400); + }); + }); + + describe('POST /sdk/v1/screening/start', () => { + it('should start SBOM generation', async () => { + const response = await request(app) + .post('/sdk/v1/screening/start') + .send({ + repositoryUrl: 'https://github.com/example/repo', + branch: 'main' + }) + .expect(202); + + expect(response.body.scanId).toBeTruthy(); + expect(response.body.status).toBe('pending'); + }); + }); + + describe('POST /sdk/v1/generate/dsfa', () => { + it('should generate DSFA document', async () => { + const response = await request(app) + .post('/sdk/v1/generate/dsfa') + .send({ + useCaseId: 'uc-1', + includeRiskAssessment: true + }) + .expect(200); + + expect(response.body.dsfa).toBeTruthy(); + expect(response.body.dsfa.sections).toHaveLength(8); + }); + }); +}); +``` + +### 12.3 E2E Tests + +```typescript +// Playwright E2E Tests +import { test, expect } from '@playwright/test'; + +test.describe('SDK Complete Flow', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/sdk'); + await page.waitForSelector('[data-testid="sdk-sidebar"]'); + }); + + test('should complete Phase 1 workflow', async ({ page }) => { + // Step 1.1: Use Case Workshop + await page.click('[data-testid="step-use-case"]'); + await page.fill('[data-testid="use-case-name"]', 'Marketing AI'); + await page.click('[data-testid="wizard-next"]'); + // ... complete all 5 steps + await expect(page.locator('[data-testid="checkpoint-CP-UC"]')).toHaveAttribute( + 'data-passed', + 'true' + ); + + // Step 1.2: Screening + await page.click('[data-testid="step-screening"]'); + await page.fill('[data-testid="repository-url"]', 'https://github.com/example/repo'); + await page.click('[data-testid="start-scan"]'); + await page.waitForSelector('[data-testid="scan-complete"]', { timeout: 60000 }); + + // ... continue through Phase 1 + }); + + test('should block progress when checkpoint fails', async ({ page }) => { + await page.click('[data-testid="step-requirements"]'); + await page.click('[data-testid="nav-next"]'); + + // Should show checkpoint error + await expect(page.locator('[data-testid="checkpoint-error"]')).toBeVisible(); + await expect(page.locator('[data-testid="checkpoint-error"]')).toContainText( + 'Vorherige Schritte nicht abgeschlossen' + ); + }); + + test('should open command bar with Cmd+K', async ({ page }) => { + await page.keyboard.press('Meta+k'); + + await expect(page.locator('[data-testid="command-bar"]')).toBeVisible(); + + await page.fill('[data-testid="command-input"]', 'Gehe zu DSFA'); + await page.keyboard.press('Enter'); + + await expect(page).toHaveURL('/sdk/dsfa'); + }); + + test('should export complete documentation', async ({ page }) => { + // Navigate to completed state + await page.goto('/sdk/escalations'); + + // Open command bar and export + await page.keyboard.press('Meta+k'); + await page.fill('[data-testid="command-input"]', 'Exportiere als PDF'); + await page.keyboard.press('Enter'); + + // Wait for download + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.click('[data-testid="confirm-export"]') + ]); + + expect(download.suggestedFilename()).toMatch(/compliance-report.*\.pdf/); + }); +}); +``` + +### 12.4 Performance Tests + +```typescript +// k6 Load Tests +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +export const options = { + stages: [ + { duration: '30s', target: 20 }, // Ramp up + { duration: '1m', target: 20 }, // Stay at 20 users + { duration: '30s', target: 50 }, // Ramp up more + { duration: '1m', target: 50 }, // Stay at 50 users + { duration: '30s', target: 0 }, // Ramp down + ], + thresholds: { + http_req_duration: ['p(95)<500'], // 95% of requests < 500ms + http_req_failed: ['rate<0.01'], // Error rate < 1% + }, +}; + +export default function () { + // State Load Test + const stateRes = http.get('http://localhost:3002/api/sdk/v1/state/tenant-1'); + check(stateRes, { + 'state load status 200': (r) => r.status === 200, + 'state load time < 200ms': (r) => r.timings.duration < 200, + }); + + // Checkpoint Validation Test + const checkpointRes = http.post( + 'http://localhost:3002/api/sdk/v1/checkpoints/CP-UC/validate', + JSON.stringify({ context: { userId: 'user-1' } }), + { headers: { 'Content-Type': 'application/json' } } + ); + check(checkpointRes, { + 'checkpoint validation status 200': (r) => r.status === 200, + 'checkpoint validation time < 300ms': (r) => r.timings.duration < 300, + }); + + sleep(1); +} +``` + +### 12.5 Test Coverage Requirements + +| Bereich | Minimum Coverage | Ziel Coverage | +|---------|------------------|---------------| +| Komponenten | 80% | 90% | +| Hooks | 85% | 95% | +| Services | 90% | 95% | +| API Endpoints | 90% | 95% | +| State Management | 85% | 95% | +| Checkpoint Logic | 95% | 100% | +| **Gesamt** | **85%** | **92%** | + +--- + +## 13. Dokumentation + +### 13.1 Benutzerhandbuch + +#### Inhaltsverzeichnis + +1. **Erste Schritte** + - SDK aktivieren + - Dashboard Übersicht + - Navigation verstehen + +2. **Phase 1: Compliance Assessment** + - 1.1 Use Case Workshop durchführen + - 1.2 System Screening starten + - 1.3 Compliance Module zuweisen + - 1.4 Requirements prüfen + - 1.5 Controls definieren + - 1.6 Evidence hochladen + - 1.7 Audit Checklist erstellen + - 1.8 Risk Matrix ausfüllen + +3. **Phase 2: Dokumentengenerierung** + - 2.1 AI Act Klassifizierung + - 2.2 Pflichtenübersicht verstehen + - 2.3 DSFA erstellen + - 2.4 TOMs auswählen + - 2.5 Löschfristen festlegen + - 2.6 Verarbeitungsverzeichnis pflegen + - 2.7 Rechtliche Vorlagen nutzen + - 2.8 Cookie Banner konfigurieren + - 2.9 Einwilligungen tracken + - 2.10 DSR Portal einrichten + - 2.11 Escalations konfigurieren + +4. **Command Bar** + - Befehle verwenden + - Tastaturkürzel + - Suche und RAG + +5. **Export & Berichte** + - PDF Export + - ZIP Export + - Audit-Berichte + +6. **FAQ & Troubleshooting** + +### 13.2 API-Dokumentation + +#### OpenAPI Specification (Auszug) + +```yaml +openapi: 3.1.0 +info: + title: AI Compliance SDK API + version: 1.0.0 + description: API für das AI Compliance SDK + +servers: + - url: /api/sdk/v1 + description: SDK API v1 + +paths: + /state/{tenantId}: + get: + summary: Kompletten State laden + tags: [State Management] + parameters: + - name: tenantId + in: path + required: true + schema: + type: string + responses: + '200': + description: State erfolgreich geladen + content: + application/json: + schema: + $ref: '#/components/schemas/SDKState' + '404': + description: Tenant nicht gefunden + + /state/save: + post: + summary: State speichern + tags: [State Management] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + tenantId: + type: string + state: + $ref: '#/components/schemas/SDKState' + responses: + '200': + description: State erfolgreich gespeichert + + /checkpoints/{id}/validate: + post: + summary: Checkpoint validieren + tags: [Checkpoints] + parameters: + - name: id + in: path + required: true + schema: + type: string + enum: [CP-UC, CP-SCAN, CP-MOD, CP-REQ, CP-CTRL, CP-EVI, CP-CHK, CP-RISK, CP-AI, CP-OBL, CP-DSFA, CP-TOM, CP-RET, CP-VVT, CP-DOC, CP-COOK, CP-CONS, CP-DSR, CP-ESC] + responses: + '200': + description: Validierungsergebnis + content: + application/json: + schema: + $ref: '#/components/schemas/CheckpointStatus' + + /screening/start: + post: + summary: SBOM + Security Scan starten + tags: [Screening] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + repositoryUrl: + type: string + format: uri + branch: + type: string + default: main + scanTypes: + type: array + items: + type: string + enum: [sbom, security, license] + responses: + '202': + description: Scan gestartet + content: + application/json: + schema: + type: object + properties: + scanId: + type: string + status: + type: string + enum: [pending, running] + + /generate/dsfa: + post: + summary: DSFA generieren + tags: [Document Generation] + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + useCaseId: + type: string + includeRiskAssessment: + type: boolean + language: + type: string + enum: [de, en] + default: de + responses: + '200': + description: DSFA erfolgreich generiert + content: + application/json: + schema: + $ref: '#/components/schemas/DSFA' + +components: + schemas: + SDKState: + type: object + properties: + version: + type: string + tenantId: + type: string + currentPhase: + type: integer + enum: [1, 2] + currentStep: + type: string + completedSteps: + type: array + items: + type: string + checkpoints: + type: object + additionalProperties: + $ref: '#/components/schemas/CheckpointStatus' + useCases: + type: array + items: + $ref: '#/components/schemas/UseCaseAssessment' + # ... weitere Felder + + CheckpointStatus: + type: object + properties: + checkpointId: + type: string + passed: + type: boolean + validatedAt: + type: string + format: date-time + errors: + type: array + items: + $ref: '#/components/schemas/ValidationError' + warnings: + type: array + items: + $ref: '#/components/schemas/ValidationError' + + ValidationError: + type: object + properties: + ruleId: + type: string + field: + type: string + message: + type: string + severity: + type: string + enum: [ERROR, WARNING, INFO] +``` + +### 13.3 Entwickler-Dokumentation + +#### Architektur-Übersicht + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ FRONTEND (Next.js) │ +├─────────────────────────────────────────────────────────────────┤ +│ Pages (/sdk/*) │ Components │ Hooks │ State (Context) │ +└────────┬────────────────────────────────────────────────────────┘ + │ + │ HTTP/REST + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ API Layer (Next.js Route Handlers) │ +├─────────────────────────────────────────────────────────────────┤ +│ /api/sdk/v1/* │ Authentication │ Rate Limiting │ CORS │ +└────────┬────────────────────────────────────────────────────────┘ + │ + │ Internal HTTP + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ BACKEND (Go/Gin) │ +├─────────────────────────────────────────────────────────────────┤ +│ Handlers │ Services │ UCCA Framework │ LLM Integration │ +└────────┬────────────────────────────────────────────────────────┘ + │ + │ SQL/Cache + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ DATA LAYER │ +├─────────────────────────────────────────────────────────────────┤ +│ PostgreSQL │ Valkey/Redis │ MinIO │ Qdrant (Vector DB) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +#### Neue Komponente erstellen + +```typescript +// 1. Erstelle die Komponente in components/sdk/ +// components/sdk/MyComponent/MyComponent.tsx + +import { useSDK } from '@/lib/sdk/context'; + +interface MyComponentProps { + title: string; + onAction: () => void; +} + +export function MyComponent({ title, onAction }: MyComponentProps) { + const { state, dispatch } = useSDK(); + + return ( +
+

{title}

+

Current Step: {state.currentStep}

+ +
+ ); +} + +// 2. Exportiere in index.ts +// components/sdk/MyComponent/index.ts +export { MyComponent } from './MyComponent'; + +// 3. Verwende in einer Page +// app/(admin)/sdk/my-page/page.tsx +import { MyComponent } from '@/components/sdk/MyComponent'; + +export default function MyPage() { + return ( + console.log('clicked')} + /> + ); +} +``` + +#### Neuen API-Endpoint hinzufügen + +```typescript +// app/api/sdk/v1/my-endpoint/route.ts +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(request: NextRequest) { + try { + const tenantId = request.headers.get('x-tenant-id'); + + // Backend aufrufen + const backendUrl = process.env.SDK_BACKEND_URL; + const response = await fetch(`${backendUrl}/my-endpoint`, { + headers: { + 'X-Tenant-ID': tenantId, + }, + }); + + const data = await response.json(); + + return NextResponse.json(data); + } catch (error) { + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // Validierung + if (!body.requiredField) { + return NextResponse.json( + { error: 'requiredField is required' }, + { status: 400 } + ); + } + + // Backend aufrufen + const backendUrl = process.env.SDK_BACKEND_URL; + const response = await fetch(`${backendUrl}/my-endpoint`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + return NextResponse.json(data); + } catch (error) { + return NextResponse.json( + { error: 'Internal Server Error' }, + { status: 500 } + ); + } +} +``` + +--- + +## 14. Offene Fragen & Klärungsbedarf + +### Fragen die VOR Sprint 1 geklärt werden müssen + +| # | Frage | Bereich | Auswirkung | Entscheidungsträger | +|---|-------|---------|------------|---------------------| +| 1 | **Backend-Verbindung für Screening**: Wie verbindet sich der SDK mit dem Kunden-Backend? Welche Authentifizierung wird verwendet? | Architektur | Blockiert Screening-Implementierung | Tech Lead + Security | +| 2 | **Welche Systeme sollen gescannt werden können?** (Git Repos, Container Registries, Cloud APIs) | Screening | Bestimmt SBOM-Generierungs-Strategie | Product Owner | +| 3 | **Multi-Use-Case Handling**: Sollen alle Use Cases denselben Compliance-Flow durchlaufen? Oder gibt es einen übergeordneten "Projekt"-Kontext? | Datenmodell | Beeinflusst State-Struktur | Product Owner | + +### Fragen die VOR Sprint 4 geklärt werden müssen + +| # | Frage | Bereich | Auswirkung | Entscheidungsträger | +|---|-------|---------|------------|---------------------| +| 4 | **Cookie Banner Integration**: Welche Plattformen sollen unterstützt werden? (Web, iOS, Android) | Cookie Banner | Bestimmt Umfang der Implementierung | Product Owner | +| 5 | **Soll ein fertiger Code-Snippet generiert werden?** Oder nur Konfiguration? | Cookie Banner | Frontend-Aufwand | Tech Lead | + +### Fragen die VOR Go-Live geklärt werden müssen + +| # | Frage | Bereich | Auswirkung | Entscheidungsträger | +|---|-------|---------|------------|---------------------| +| 6 | **Subscription Tiers**: Welche Features sind in welchem Tier verfügbar? | Business | Feature Flags Implementierung | Product Owner + Business | +| 7 | **Gibt es Nutzungslimits?** (z.B. max. Use Cases, Scans pro Monat) | Business | Rate Limiting Implementierung | Product Owner + Business | + +### Zusätzliche technische Fragen + +| # | Frage | Bereich | Auswirkung | Entscheidungsträger | +|---|-------|---------|------------|---------------------| +| 8 | **LLM Provider für RAG/Generierung**: Ollama, Anthropic, oder OpenAI als Standard? | Infrastruktur | Kosten, Performance, Datenschutz | Tech Lead + DSB | +| 9 | **Datenhoheit**: Wo werden generierte Dokumente gespeichert? On-Premise Option? | Infrastruktur | Speicher-Architektur | Tech Lead + Security | +| 10 | **Audit Trail**: Wie granular soll die Änderungsverfolgung sein? | Compliance | Datenbankschema | DSB + Tech Lead | + +### Empfohlene Klärungsreihenfolge + +``` +Woche 0 (vor Projektstart): +├── Frage 1-3 klären (Architektur-Entscheidungen) +├── Frage 8 klären (LLM Provider) +└── Frage 9 klären (Datenhoheit) + +Sprint 1-2: +├── Frage 10 klären (Audit Trail) +└── Dokumentation der Entscheidungen + +Sprint 3 (vor Phase 2): +├── Frage 4-5 klären (Cookie Banner) +└── Feature-Scope finalisieren + +Sprint 5 (vor Go-Live): +├── Frage 6-7 klären (Subscription Tiers) +└── Pricing-Modell integrieren +``` + +--- + +## 15. Akzeptanzkriterien + +### Funktionale Anforderungen + +| # | Kriterium | Testmethode | Status | +|---|-----------|-------------|--------| +| F1 | Nutzer kann kompletten Flow in einer Session durchlaufen | E2E Test | ⬜ | +| F2 | Checkpoints blockieren korrekt bei fehlenden Daten | Integration Test | ⬜ | +| F3 | Command Bar funktioniert in jedem Schritt | E2E Test | ⬜ | +| F4 | Alle 11 Dokument-Typen werden korrekt generiert | Unit + Integration | ⬜ | +| F5 | Export enthält alle relevanten Daten (PDF, ZIP, JSON) | Integration Test | ⬜ | +| F6 | SBOM-Generierung funktioniert für Git Repositories | Integration Test | ⬜ | +| F7 | Security Scan identifiziert bekannte CVEs | Integration Test | ⬜ | +| F8 | Risk Matrix berechnet korrekte Scores | Unit Test | ⬜ | +| F9 | Cookie Banner generiert funktionierenden Code | Manual + E2E | ⬜ | +| F10 | DSR Portal kann Anfragen entgegennehmen | E2E Test | ⬜ | + +### Nicht-funktionale Anforderungen + +| # | Kriterium | Zielwert | Testmethode | Status | +|---|-----------|----------|-------------|--------| +| NF1 | Page Load Time | < 2s | Lighthouse | ⬜ | +| NF2 | State Save Latency | < 500ms | Performance Test | ⬜ | +| NF3 | Checkpoint Validation | < 300ms | Performance Test | ⬜ | +| NF4 | Document Generation | < 30s | Performance Test | ⬜ | +| NF5 | Concurrent Users | 50+ | Load Test | ⬜ | +| NF6 | Error Rate | < 1% | Monitoring | ⬜ | +| NF7 | Test Coverage | > 85% | Jest/Vitest | ⬜ | +| NF8 | Accessibility | WCAG 2.1 AA | axe-core | ⬜ | +| NF9 | Mobile Responsive | iOS/Android | Manual Test | ⬜ | +| NF10 | Browser Support | Chrome, Firefox, Safari, Edge | E2E Test | ⬜ | + +### Sicherheitsanforderungen + +| # | Kriterium | Standard | Status | +|---|-----------|----------|--------| +| S1 | Authentifizierung | OAuth 2.0 / OIDC | ⬜ | +| S2 | Autorisierung | RBAC mit 4 Rollen | ⬜ | +| S3 | Datenverschlüsselung at Rest | AES-256 | ⬜ | +| S4 | Datenverschlüsselung in Transit | TLS 1.3 | ⬜ | +| S5 | Input Validation | OWASP Guidelines | ⬜ | +| S6 | Audit Logging | Alle Schreiboperationen | ⬜ | +| S7 | Rate Limiting | 100 req/min pro User | ⬜ | +| S8 | CSRF Protection | Token-basiert | ⬜ | +| S9 | XSS Prevention | CSP Headers | ⬜ | +| S10 | SQL Injection Prevention | Parameterized Queries | ⬜ | + +--- + +## Anhang + +### A. Glossar + +| Begriff | Definition | +|---------|------------| +| **DSFA** | Datenschutz-Folgenabschätzung (Art. 35 DSGVO) | +| **TOM** | Technische und Organisatorische Maßnahmen | +| **VVT** | Verarbeitungsverzeichnis (Art. 30 DSGVO) | +| **DSR** | Data Subject Request (Betroffenenrechte) | +| **SBOM** | Software Bill of Materials | +| **UCCA** | Unified Compliance Control Architecture | +| **RAG** | Retrieval-Augmented Generation | +| **CVE** | Common Vulnerabilities and Exposures | +| **CVSS** | Common Vulnerability Scoring System | + +### B. Referenzen + +- [EU AI Act](https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:52021PC0206) +- [DSGVO](https://eur-lex.europa.eu/eli/reg/2016/679/oj) +- [NIS2 Directive](https://eur-lex.europa.eu/eli/dir/2022/2555) +- [CycloneDX SBOM Standard](https://cyclonedx.org/) +- [SPDX Standard](https://spdx.dev/) + +### C. Änderungshistorie + +| Version | Datum | Autor | Änderungen | +|---------|-------|-------|------------| +| 1.0.0 | 2026-02-03 | AI Compliance Team | Initiale Version | + +--- + +*Dieses Dokument wurde erstellt für das AI Compliance SDK Projekt.* diff --git a/BREAKPILOT_CONSENT_MANAGEMENT_PLAN.md b/BREAKPILOT_CONSENT_MANAGEMENT_PLAN.md new file mode 100644 index 0000000..7cbd71f --- /dev/null +++ b/BREAKPILOT_CONSENT_MANAGEMENT_PLAN.md @@ -0,0 +1,566 @@ +# BreakPilot Consent Management System - Projektplan + +## Executive Summary + +Dieses Dokument beschreibt den Plan zur Entwicklung eines vollständigen Consent Management Systems (CMS) für BreakPilot. Das System wird komplett neu entwickelt und ersetzt das bestehende Policy Vault System, das Bugs enthält und nicht optimal funktioniert. + +--- + +## Technologie-Entscheidung: Warum welche Sprache? + +### Backend-Optionen im Vergleich + +| Kriterium | Rust | Go | Python (FastAPI) | TypeScript (NestJS) | +|-----------|------|-----|------------------|---------------------| +| **Performance** | Exzellent | Sehr gut | Gut | Gut | +| **Memory Safety** | Garantiert | GC | GC | GC | +| **Entwicklungsgeschwindigkeit** | Langsam | Mittel | Schnell | Schnell | +| **Lernkurve** | Steil | Flach | Flach | Mittel | +| **Ecosystem für Web** | Wachsend | Sehr gut | Exzellent | Exzellent | +| **Integration mit BreakPilot** | Neu | Neu | Bereits vorhanden | Möglich | +| **Team-Erfahrung** | ? | ? | Vorhanden | Möglich | + +### Empfehlung: **Python (FastAPI)** oder **Go** + +#### Option A: Python mit FastAPI (Empfohlen für schnelle Integration) +**Vorteile:** +- Bereits im BreakPilot-Projekt verwendet +- Schnelle Entwicklung +- Exzellente Dokumentation (automatisch generiert) +- Einfache Integration mit bestehendem Code +- Type Hints für bessere Code-Qualität +- Async/Await Support + +**Nachteile:** +- Langsamer als Rust/Go bei hoher Last +- GIL-Einschränkungen bei CPU-intensiven Tasks + +#### Option B: Go (Empfohlen für Microservice-Architektur) +**Vorteile:** +- Extrem schnell und effizient +- Exzellent für Microservices +- Einfache Deployment (Single Binary) +- Gute Concurrency +- Statische Typisierung + +**Nachteile:** +- Neuer Tech-Stack im Projekt +- Getrennte Codebasis von BreakPilot + +#### Option C: Rust (Für maximale Performance & Sicherheit) +**Vorteile:** +- Höchste Performance +- Memory Safety ohne GC +- Exzellente Sicherheit +- WebAssembly-Support + +**Nachteile:** +- Sehr steile Lernkurve +- Längere Entwicklungszeit (2-3x) +- Kleineres Web-Ecosystem +- Komplexere Fehlerbehandlung + +### Finale Empfehlung + +**Für BreakPilot empfehle ich: Go (Golang)** + +Begründung: +1. **Unabhängiger Microservice** - Das CMS sollte als eigenständiger Service laufen +2. **Performance** - Consent-Checks müssen schnell sein (bei jedem API-Call) +3. **Einfaches Deployment** - Single Binary, ideal für Container +4. **Gute Balance** - Schneller als Python, einfacher als Rust +5. **Zukunftssicher** - Moderne Sprache mit wachsendem Ecosystem + +--- + +## Systemarchitektur + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ BreakPilot Ecosystem │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ BreakPilot │ │ Consent Admin │ │ BreakPilot │ │ +│ │ Studio (Web) │ │ Dashboard │ │ Mobile Apps │ │ +│ │ (Python/HTML) │ │ (Vue.js/React) │ │ (iOS/Android) │ │ +│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ +│ │ │ │ │ +│ └──────────────────────┼──────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────┐ │ +│ │ API Gateway / Proxy │ │ +│ └────────────┬────────────┘ │ +│ │ │ +│ ┌─────────────────────┼─────────────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ BreakPilot API │ │ Consent Service │ │ Auth Service │ │ +│ │ (Python/FastAPI)│ │ (Go) │ │ (Go) │ │ +│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │ +│ │ │ │ │ +│ └────────────────────┼────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────┐ │ +│ │ PostgreSQL │ │ +│ │ (Shared Database) │ │ +│ └─────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Projektphasen + +### Phase 1: Grundlagen & Datenbank (Woche 1-2) +**Ziel:** Datenbank-Schema und Basis-Services + +#### 1.1 Datenbank-Design +- [ ] Users-Tabelle (Integration mit BreakPilot Auth) +- [ ] Legal Documents (AGB, Datenschutz, Community Guidelines, etc.) +- [ ] Document Versions (Versionierung mit Freigabe-Workflow) +- [ ] User Consents (Welcher User hat wann was zugestimmt) +- [ ] Cookie Categories (Notwendig, Funktional, Marketing, Analytics) +- [ ] Cookie Consents (Granulare Cookie-Zustimmungen) +- [ ] Audit Log (DSGVO-konforme Protokollierung) + +#### 1.2 Go Backend Setup +- [ ] Projekt-Struktur mit Clean Architecture +- [ ] Database Layer (sqlx oder GORM) +- [ ] Migration System +- [ ] Config Management +- [ ] Logging & Error Handling + +### Phase 2: Core Consent Service (Woche 3-4) +**Ziel:** Kern-Funktionalität für Consent-Management + +#### 2.1 Document Management API +- [ ] CRUD für Legal Documents +- [ ] Versionierung mit Diff-Tracking +- [ ] Draft/Published/Archived Status +- [ ] Mehrsprachigkeit (DE, EN, etc.) + +#### 2.2 Consent Tracking API +- [ ] User Consent erstellen/abrufen +- [ ] Consent History pro User +- [ ] Bulk-Consent für mehrere Dokumente +- [ ] Consent Withdrawal (Widerruf) + +#### 2.3 Cookie Consent API +- [ ] Cookie-Kategorien verwalten +- [ ] Granulare Cookie-Einstellungen +- [ ] Consent-Banner Konfiguration + +### Phase 3: Admin Dashboard (Woche 5-6) +**Ziel:** Web-Interface für Administratoren + +#### 3.1 Admin Frontend (Vue.js oder React) +- [ ] Login/Auth (Integration mit BreakPilot) +- [ ] Dashboard mit Statistiken +- [ ] Document Editor (Rich Text) +- [ ] Version Management UI +- [ ] User Consent Übersicht +- [ ] Cookie Management UI + +#### 3.2 Freigabe-Workflow +- [ ] Draft → Review → Approved → Published +- [ ] Benachrichtigungen bei neuen Versionen +- [ ] Rollback-Funktion + +### Phase 4: BreakPilot Integration (Woche 7-8) +**Ziel:** Integration in BreakPilot Studio + +#### 4.1 User-facing Features +- [ ] "Legal" Button in Einstellungen +- [ ] Consent-Historie anzeigen +- [ ] Cookie-Präferenzen ändern +- [ ] Datenauskunft anfordern (DSGVO Art. 15) + +#### 4.2 Cookie Banner +- [ ] Cookie-Consent-Modal beim ersten Besuch +- [ ] Granulare Auswahl der Kategorien +- [ ] "Alle akzeptieren" / "Nur notwendige" +- [ ] Persistente Speicherung + +#### 4.3 Consent-Check Middleware +- [ ] Automatische Prüfung bei API-Calls +- [ ] Blocking bei fehlender Zustimmung +- [ ] Marketing-Opt-out respektieren + +### Phase 5: Data Subject Rights (Woche 9-10) +**Ziel:** DSGVO-Compliance Features + +#### 5.1 Datenauskunft (Art. 15 DSGVO) +- [ ] API für "Welche Daten haben wir?" +- [ ] Export als JSON/PDF +- [ ] Automatisierte Bereitstellung + +#### 5.2 Datenlöschung (Art. 17 DSGVO) +- [ ] "Recht auf Vergessenwerden" +- [ ] Anonymisierung statt Löschung (wo nötig) +- [ ] Audit Trail für Löschungen + +#### 5.3 Datenportabilität (Art. 20 DSGVO) +- [ ] Export in maschinenlesbarem Format +- [ ] Download-Funktion im Frontend + +### Phase 6: Testing & Security (Woche 11-12) +**Ziel:** Absicherung und Qualität + +#### 6.1 Testing +- [ ] Unit Tests (>80% Coverage) +- [ ] Integration Tests +- [ ] E2E Tests für kritische Flows +- [ ] Performance Tests + +#### 6.2 Security +- [ ] Security Audit +- [ ] Penetration Testing +- [ ] Rate Limiting +- [ ] Input Validation +- [ ] SQL Injection Prevention +- [ ] XSS Protection + +--- + +## Datenbank-Schema (Entwurf) + +```sql +-- Benutzer (Integration mit BreakPilot) +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + external_id VARCHAR(255) UNIQUE, -- BreakPilot User ID + email VARCHAR(255) UNIQUE NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Rechtliche Dokumente +CREATE TABLE legal_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + type VARCHAR(50) NOT NULL, -- 'terms', 'privacy', 'cookies', 'community' + name VARCHAR(255) NOT NULL, + description TEXT, + is_mandatory BOOLEAN DEFAULT true, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Dokumentversionen +CREATE TABLE document_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + document_id UUID REFERENCES legal_documents(id) ON DELETE CASCADE, + version VARCHAR(20) NOT NULL, -- Semver: 1.0.0, 1.1.0, etc. + language VARCHAR(5) DEFAULT 'de', -- ISO 639-1 + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, -- HTML oder Markdown + summary TEXT, -- Kurze Zusammenfassung der Änderungen + status VARCHAR(20) DEFAULT 'draft', -- draft, review, approved, published, archived + published_at TIMESTAMPTZ, + created_by UUID REFERENCES users(id), + approved_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(document_id, version, language) +); + +-- Benutzer-Zustimmungen +CREATE TABLE user_consents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + document_version_id UUID REFERENCES document_versions(id), + consented BOOLEAN NOT NULL, + ip_address INET, + user_agent TEXT, + consented_at TIMESTAMPTZ DEFAULT NOW(), + withdrawn_at TIMESTAMPTZ, + UNIQUE(user_id, document_version_id) +); + +-- Cookie-Kategorien +CREATE TABLE cookie_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL, -- 'necessary', 'functional', 'analytics', 'marketing' + display_name_de VARCHAR(255) NOT NULL, + display_name_en VARCHAR(255), + description_de TEXT, + description_en TEXT, + is_mandatory BOOLEAN DEFAULT false, + sort_order INT DEFAULT 0, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Cookie-Zustimmungen +CREATE TABLE cookie_consents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + category_id UUID REFERENCES cookie_categories(id), + consented BOOLEAN NOT NULL, + consented_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(user_id, category_id) +); + +-- Audit Log (DSGVO-konform) +CREATE TABLE consent_audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID, + action VARCHAR(50) NOT NULL, -- 'consent_given', 'consent_withdrawn', 'data_export', 'data_delete' + entity_type VARCHAR(50), -- 'document', 'cookie_category' + entity_id UUID, + details JSONB, + ip_address INET, + user_agent TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Indizes für Performance +CREATE INDEX idx_user_consents_user ON user_consents(user_id); +CREATE INDEX idx_user_consents_version ON user_consents(document_version_id); +CREATE INDEX idx_cookie_consents_user ON cookie_consents(user_id); +CREATE INDEX idx_audit_log_user ON consent_audit_log(user_id); +CREATE INDEX idx_audit_log_created ON consent_audit_log(created_at); +``` + +--- + +## API-Endpoints (Entwurf) + +### Public API (für BreakPilot Frontend) + +``` +# Dokumente abrufen +GET /api/v1/documents # Alle aktiven Dokumente +GET /api/v1/documents/:type # Dokument nach Typ (terms, privacy) +GET /api/v1/documents/:type/latest # Neueste publizierte Version + +# Consent Management +POST /api/v1/consent # Zustimmung erteilen +GET /api/v1/consent/my # Meine Zustimmungen +GET /api/v1/consent/check/:documentType # Prüfen ob zugestimmt +DELETE /api/v1/consent/:id # Zustimmung widerrufen + +# Cookie Consent +GET /api/v1/cookies/categories # Cookie-Kategorien +POST /api/v1/cookies/consent # Cookie-Präferenzen setzen +GET /api/v1/cookies/consent/my # Meine Cookie-Einstellungen + +# Data Subject Rights (DSGVO) +GET /api/v1/privacy/my-data # Alle meine Daten abrufen +POST /api/v1/privacy/export # Datenexport anfordern +POST /api/v1/privacy/delete # Löschung anfordern +``` + +### Admin API (für Admin Dashboard) + +``` +# Document Management +GET /api/v1/admin/documents # Alle Dokumente (mit Drafts) +POST /api/v1/admin/documents # Neues Dokument +PUT /api/v1/admin/documents/:id # Dokument bearbeiten +DELETE /api/v1/admin/documents/:id # Dokument löschen + +# Version Management +GET /api/v1/admin/versions/:docId # Alle Versionen eines Dokuments +POST /api/v1/admin/versions # Neue Version erstellen +PUT /api/v1/admin/versions/:id # Version bearbeiten +POST /api/v1/admin/versions/:id/publish # Version veröffentlichen +POST /api/v1/admin/versions/:id/archive # Version archivieren + +# Cookie Categories +GET /api/v1/admin/cookies/categories # Alle Kategorien +POST /api/v1/admin/cookies/categories # Neue Kategorie +PUT /api/v1/admin/cookies/categories/:id +DELETE /api/v1/admin/cookies/categories/:id + +# Statistics & Reports +GET /api/v1/admin/stats/consents # Consent-Statistiken +GET /api/v1/admin/stats/cookies # Cookie-Statistiken +GET /api/v1/admin/audit-log # Audit Log (mit Filter) +``` + +--- + +## Consent-Check Middleware (Konzept) + +```go +// middleware/consent_check.go + +func ConsentCheckMiddleware(requiredConsent string) gin.HandlerFunc { + return func(c *gin.Context) { + userID := c.GetString("user_id") + + // Prüfe ob User zugestimmt hat + hasConsent, err := consentService.CheckConsent(userID, requiredConsent) + if err != nil { + c.AbortWithStatusJSON(500, gin.H{"error": "Consent check failed"}) + return + } + + if !hasConsent { + c.AbortWithStatusJSON(403, gin.H{ + "error": "consent_required", + "document_type": requiredConsent, + "message": "Sie müssen den Nutzungsbedingungen zustimmen", + }) + return + } + + c.Next() + } +} + +// Verwendung in BreakPilot +router.POST("/api/worksheets", + authMiddleware, + ConsentCheckMiddleware("terms"), + worksheetHandler.Create, +) +``` + +--- + +## Cookie-Banner Flow + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Erster Besuch │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 1. User öffnet BreakPilot │ +│ │ │ +│ ▼ │ +│ 2. Check: Hat User Cookie-Consent gegeben? │ +│ │ │ +│ ┌─────────┴─────────┐ │ +│ │ Nein │ Ja │ +│ ▼ ▼ │ +│ 3. Zeige Cookie Lade gespeicherte │ +│ Banner Präferenzen │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────┐ │ +│ │ Cookie Consent Banner │ │ +│ ├─────────────────────────────────────────┤ │ +│ │ Wir verwenden Cookies, um Ihnen die │ │ +│ │ beste Erfahrung zu bieten. │ │ +│ │ │ │ +│ │ ☑ Notwendig (immer aktiv) │ │ +│ │ ☐ Funktional │ │ +│ │ ☐ Analytics │ │ +│ │ ☐ Marketing │ │ +│ │ │ │ +│ │ [Alle akzeptieren] [Auswahl speichern] │ │ +│ │ [Nur notwendige] [Mehr erfahren] │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Legal-Bereich im BreakPilot Frontend (Mockup) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Einstellungen > Legal │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Meine Zustimmungen │ │ +│ ├─────────────────────────────────────────────────────┤ │ +│ │ │ │ +│ │ ✓ Allgemeine Geschäftsbedingungen │ │ +│ │ Version 2.1 · Zugestimmt am 15.11.2024 │ │ +│ │ [Ansehen] [Widerrufen] │ │ +│ │ │ │ +│ │ ✓ Datenschutzerklärung │ │ +│ │ Version 3.0 · Zugestimmt am 15.11.2024 │ │ +│ │ [Ansehen] [Widerrufen] │ │ +│ │ │ │ +│ │ ✓ Community Guidelines │ │ +│ │ Version 1.2 · Zugestimmt am 15.11.2024 │ │ +│ │ [Ansehen] [Widerrufen] │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Cookie-Einstellungen │ │ +│ ├─────────────────────────────────────────────────────┤ │ +│ │ │ │ +│ │ ☑ Notwendige Cookies (erforderlich) │ │ +│ │ ☑ Funktionale Cookies │ │ +│ │ ☐ Analytics Cookies │ │ +│ │ ☐ Marketing Cookies │ │ +│ │ │ │ +│ │ [Einstellungen speichern] │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Meine Daten (DSGVO) │ │ +│ ├─────────────────────────────────────────────────────┤ │ +│ │ │ │ +│ │ [Meine Daten exportieren] │ │ +│ │ Erhalten Sie eine Kopie aller Ihrer gespeicherten │ │ +│ │ Daten als JSON-Datei. │ │ +│ │ │ │ +│ │ [Account löschen] │ │ +│ │ Alle Ihre Daten werden unwiderruflich gelöscht. │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Nächste Schritte + +### Sofort (diese Woche) +1. **Entscheidung:** Go oder Python für Backend? +2. **Projekt-Setup:** Repository anlegen +3. **Datenbank:** Schema finalisieren und migrieren + +### Kurzfristig (nächste 2 Wochen) +1. Core API implementieren +2. Basis-Integration in BreakPilot + +### Mittelfristig (nächste 4-6 Wochen) +1. Admin Dashboard +2. Cookie Banner +3. DSGVO-Features + +--- + +## Offene Fragen + +1. **Sprache:** Go oder Python für das Backend? +2. **Admin Dashboard:** Eigenes Frontend oder in BreakPilot integriert? +3. **Hosting:** Gleicher Server wie BreakPilot oder separater Service? +4. **Auth:** Shared Authentication mit BreakPilot oder eigenes System? +5. **Datenbank:** Shared PostgreSQL oder eigene Instanz? + +--- + +## Ressourcen-Schätzung + +| Phase | Aufwand (Tage) | Beschreibung | +|-------|---------------|--------------| +| Phase 1 | 5-7 | Datenbank & Setup | +| Phase 2 | 8-10 | Core Consent Service | +| Phase 3 | 10-12 | Admin Dashboard | +| Phase 4 | 8-10 | BreakPilot Integration | +| Phase 5 | 5-7 | DSGVO Features | +| Phase 6 | 5-7 | Testing & Security | +| **Gesamt** | **41-53** | ~8-10 Wochen | + +--- + +*Dokument erstellt am: 12. Dezember 2024* +*Version: 1.0* diff --git a/CONTENT_SERVICE_SETUP.md b/CONTENT_SERVICE_SETUP.md new file mode 100644 index 0000000..65a2aba --- /dev/null +++ b/CONTENT_SERVICE_SETUP.md @@ -0,0 +1,473 @@ +# BreakPilot Content Service - Setup & Deployment Guide + +## 🎯 Übersicht + +Der BreakPilot Content Service ist eine vollständige Educational Content Management Plattform mit: + +- ✅ **Content Service API** (FastAPI) - Educational Content Management +- ✅ **MinIO S3 Storage** - File Storage für Videos, PDFs, Bilder +- ✅ **H5P Service** - Interactive Educational Content (Quizzes, etc.) +- ✅ **Matrix Feed Integration** - Content Publishing zu Matrix Spaces +- ✅ **PostgreSQL** - Content Metadata Storage +- ✅ **Creative Commons Licensing** - CC-BY, CC-BY-SA, etc. +- ✅ **Rating & Download Tracking** - Analytics & Impact Scoring + +## 🚀 Quick Start + +### 1. Alle Services starten + +```bash +# Haupt-Services + Content Services starten +docker-compose \ + -f docker-compose.yml \ + -f docker-compose.content.yml \ + up -d + +# Logs verfolgen +docker-compose -f docker-compose.yml -f docker-compose.content.yml logs -f +``` + +### 2. Verfügbare Services + +| Service | URL | Beschreibung | +|---------|-----|--------------| +| Content Service API | http://localhost:8002 | REST API für Content Management | +| MinIO Console | http://localhost:9001 | Storage Dashboard (User: minioadmin, Pass: minioadmin123) | +| H5P Service | http://localhost:8003 | Interactive Content Editor | +| Content DB | localhost:5433 | PostgreSQL Database | + +### 3. API Dokumentation + +Content Service API Docs: +``` +http://localhost:8002/docs +``` + +## 📦 Installation (Development) + +### Content Service (Backend) + +```bash +cd backend/content_service + +# Virtual Environment erstellen +python3 -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate + +# Dependencies installieren +pip install -r requirements.txt + +# Environment Variables +cp .env.example .env + +# Database Migrations +alembic upgrade head + +# Service starten +uvicorn main:app --reload --port 8002 +``` + +### H5P Service + +```bash +cd h5p-service + +# Dependencies installieren +npm install + +# Service starten +npm start +``` + +### Creator Dashboard (Frontend) + +```bash +cd frontend/creator-studio + +# Dependencies installieren +npm install + +# Development Server +npm run dev +``` + +## 🔧 Konfiguration + +### Environment Variables + +Erstelle `.env` im Projekt-Root: + +```env +# Content Service +CONTENT_DB_URL=postgresql://breakpilot:breakpilot123@localhost:5433/breakpilot_content +MINIO_ENDPOINT=localhost:9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin123 +MINIO_BUCKET=breakpilot-content + +# Matrix Integration +MATRIX_HOMESERVER=http://localhost:8008 +MATRIX_ACCESS_TOKEN=your-matrix-token-here +MATRIX_BOT_USER=@breakpilot-bot:localhost +MATRIX_FEED_ROOM=!breakpilot-feed:localhost + +# OAuth2 (consent-service) +CONSENT_SERVICE_URL=http://localhost:8081 +JWT_SECRET=your-jwt-secret-here + +# H5P Service +H5P_BASE_URL=http://localhost:8003 +H5P_STORAGE_PATH=/app/h5p-content +``` + +## 📝 Content Service API Endpoints + +### Content Management + +```bash +# Create Content +POST /api/v1/content +{ + "title": "5-Minuten Yoga für Grundschule", + "description": "Bewegungspause mit einfachen Yoga-Übungen", + "content_type": "video", + "category": "movement", + "license": "CC-BY-SA-4.0", + "age_min": 6, + "age_max": 10, + "tags": ["yoga", "bewegung", "pause"] +} + +# Upload File +POST /api/v1/upload +Content-Type: multipart/form-data +file: + +# Add Files to Content +POST /api/v1/content/{content_id}/files +{ + "file_urls": ["http://minio:9000/breakpilot-content/..."] +} + +# Publish Content (→ Matrix Feed) +POST /api/v1/content/{content_id}/publish + +# List Content (with filters) +GET /api/v1/content?category=movement&age_min=6&age_max=10 + +# Get Content Details +GET /api/v1/content/{content_id} + +# Rate Content +POST /api/v1/content/{content_id}/rate +{ + "stars": 5, + "comment": "Sehr hilfreich für meine Klasse!" +} +``` + +### H5P Interactive Content + +```bash +# Get H5P Editor +GET http://localhost:8003/h5p/editor/new + +# Save H5P Content +POST http://localhost:8003/h5p/editor +{ + "library": "H5P.InteractiveVideo 1.22", + "params": { ... } +} + +# Play H5P Content +GET http://localhost:8003/h5p/play/{contentId} + +# Export as .h5p File +GET http://localhost:8003/h5p/export/{contentId} +``` + +## 🎨 Creator Workflow + +### 1. Content erstellen + +```javascript +// Creator Dashboard +const content = await createContent({ + title: "Mathe-Quiz: Einmaleins", + description: "Interaktives Quiz zum Üben des Einmaleins", + content_type: "h5p", + category: "math", + license: "CC-BY-SA-4.0", + age_min: 7, + age_max: 9 +}); +``` + +### 2. Files hochladen + +```javascript +// Upload Video/PDF/Images +const file = document.querySelector('#fileInput').files[0]; +const formData = new FormData(); +formData.append('file', file); + +const response = await fetch('/api/v1/upload', { + method: 'POST', + body: formData +}); + +const { file_url } = await response.json(); +``` + +### 3. Publish to Matrix Feed + +```javascript +// Publish → Matrix Spaces +await publishContent(content.id); +// → Content erscheint in #movement, #math, etc. +``` + +## 📊 Matrix Feed Integration + +### Matrix Spaces Struktur + +``` +#breakpilot (Root Space) +├── #feed (Chronologischer Content Feed) +├── #bewegung (Kategorie: Movement) +├── #mathe (Kategorie: Math) +├── #steam (Kategorie: STEAM) +└── #sprache (Kategorie: Language) +``` + +### Content Message Format + +Wenn Content published wird, erscheint in Matrix: + +``` +📹 5-Minuten Yoga für Grundschule + +Bewegungspause mit einfachen Yoga-Übungen für den Unterricht + +📝 Von: Max Mustermann +🏃 Kategorie: movement +👥 Alter: 6-10 Jahre +⚖️ Lizenz: CC-BY-SA-4.0 +🏷️ Tags: yoga, bewegung, pause + +[📥 Inhalt ansehen/herunterladen] +``` + +## 🔐 Creative Commons Lizenzen + +Verfügbare Lizenzen: + +- `CC-BY-4.0` - Attribution (Namensnennung) +- `CC-BY-SA-4.0` - Attribution + ShareAlike (empfohlen) +- `CC-BY-NC-4.0` - Attribution + NonCommercial +- `CC-BY-NC-SA-4.0` - Attribution + NonCommercial + ShareAlike +- `CC0-1.0` - Public Domain + +### Lizenz-Workflow + +```python +# Bei Content-Erstellung: Creator wählt Lizenz +content.license = "CC-BY-SA-4.0" + +# System validiert: +✅ Nur erlaubte Lizenzen +✅ Lizenz-Badge wird angezeigt +✅ Lizenz-Link zu Creative Commons +``` + +## 📈 Analytics & Impact Scoring + +### Download Tracking + +```python +# Automatisch getrackt bei Download +POST /api/v1/content/{content_id}/download + +# → Zähler erhöht +# → Download-Event gespeichert +# → Für Impact-Score verwendet +``` + +### Creator Statistics + +```bash +# Get Creator Stats +GET /api/v1/stats/creator/{creator_id} + +{ + "total_contents": 12, + "total_downloads": 347, + "total_views": 1203, + "avg_rating": 4.7, + "impact_score": 892.5, + "content_breakdown": { + "movement": 5, + "math": 4, + "steam": 3 + } +} +``` + +## 🧪 Testing + +### API Tests + +```bash +# Pytest +cd backend/content_service +pytest tests/ + +# Mit Coverage +pytest --cov=. --cov-report=html +``` + +### Integration Tests + +```bash +# Test Content Upload Flow +curl -X POST http://localhost:8002/api/v1/content \ + -H "Content-Type: application/json" \ + -d '{ + "title": "Test Content", + "content_type": "pdf", + "category": "math", + "license": "CC-BY-SA-4.0" + }' +``` + +## 🐳 Docker Commands + +```bash +# Build einzelnen Service +docker-compose -f docker-compose.content.yml build content-service + +# Nur Content Services starten +docker-compose -f docker-compose.content.yml up -d + +# Logs einzelner Service +docker-compose logs -f content-service + +# Service neu starten +docker-compose restart content-service + +# Alle stoppen +docker-compose -f docker-compose.yml -f docker-compose.content.yml down + +# Mit Volumes löschen (Achtung: Datenverlust!) +docker-compose -f docker-compose.yml -f docker-compose.content.yml down -v +``` + +## 🗄️ Database Migrations + +```bash +cd backend/content_service + +# Neue Migration erstellen +alembic revision --autogenerate -m "Add new field" + +# Migration anwenden +alembic upgrade head + +# Zurückrollen +alembic downgrade -1 +``` + +## 📱 Frontend Development + +### Creator Studio + +```bash +cd frontend/creator-studio + +# Install dependencies +npm install + +# Development +npm run dev # → http://localhost:3000 + +# Build +npm run build + +# Preview Production Build +npm run preview +``` + +## 🔒 DSGVO Compliance + +### Datenminimierung + +- ✅ Nur notwendige Metadaten gespeichert +- ✅ Keine Schülerdaten +- ✅ IP-Adressen anonymisiert nach 7 Tagen +- ✅ User kann Content/Account löschen + +### Datenexport + +```bash +# User Data Export +GET /api/v1/user/export +→ JSON mit allen Daten des Users +``` + +## 🚨 Troubleshooting + +### MinIO Connection Failed + +```bash +# Check MinIO status +docker-compose logs minio + +# Test connection +curl http://localhost:9000/minio/health/live +``` + +### Content Service Database Connection + +```bash +# Check PostgreSQL +docker-compose logs content-db + +# Connect manually +docker exec -it breakpilot-pwa-content-db psql -U breakpilot -d breakpilot_content +``` + +### H5P Service Not Starting + +```bash +# Check logs +docker-compose logs h5p-service + +# Rebuild +docker-compose build h5p-service +docker-compose up -d h5p-service +``` + +## 📚 Weitere Dokumentation + +- [Architekturempfehlung](./backend/docs/Architekturempfehlung%20für%20Breakpilot%20–%20Offene,%20modulare%20Bildungsplattform%20im%20DACH-Raum.pdf) +- [Content Service API](./backend/content_service/README.md) +- [H5P Integration](./h5p-service/README.md) +- [Matrix Feed Setup](./docs/matrix-feed-setup.md) + +## 🎉 Next Steps + +1. ✅ Services starten (siehe Quick Start) +2. ✅ Creator Account erstellen +3. ✅ Ersten Content hochladen +4. ✅ H5P Interactive Content erstellen +5. ✅ Content publishen → Matrix Feed +6. ✅ Teacher Discovery UI testen +7. 🔜 OAuth2 SSO mit consent-service integrieren +8. 🔜 Production Deployment vorbereiten + +## 💡 Support + +Bei Fragen oder Problemen: +- GitHub Issues: https://github.com/breakpilot/breakpilot-pwa/issues +- Matrix Chat: #breakpilot-dev:matrix.org +- Email: dev@breakpilot.app diff --git a/IMPLEMENTATION_SUMMARY.md b/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..00d0361 --- /dev/null +++ b/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,427 @@ +# 🎓 BreakPilot Content Service - Implementierungs-Zusammenfassung + +## ✅ Vollständig implementierte Sprints + +### **Sprint 1-2: Content Service Foundation** ✅ + +**Backend (FastAPI):** +- ✅ Complete Database Schema (PostgreSQL) + - `Content` Model mit allen Metadaten + - `Rating` Model für Teacher Reviews + - `Tag` System für Content Organization + - `Download` Tracking für Impact Scoring +- ✅ Pydantic Schemas für API Validation +- ✅ Full CRUD API für Content Management +- ✅ Upload API für Files (Video, PDF, Images, Audio) +- ✅ Search & Filter Endpoints +- ✅ Analytics & Statistics Endpoints + +**Storage:** +- ✅ MinIO S3-kompatible Object Storage +- ✅ Automatic Bucket Creation +- ✅ Public Read Policy für Content +- ✅ File Upload Integration +- ✅ Presigned URLs für private Files + +**Files Created:** +``` +backend/content_service/ +├── models.py # Database Models +├── schemas.py # Pydantic Schemas +├── database.py # DB Configuration +├── main.py # FastAPI Application +├── storage.py # MinIO Integration +├── requirements.txt # Python Dependencies +└── Dockerfile # Container Definition +``` + +--- + +### **Sprint 3-4: Matrix Feed Integration** ✅ + +**Matrix Client:** +- ✅ Matrix SDK Integration (matrix-nio) +- ✅ Content Publishing to Matrix Spaces +- ✅ Formatted Messages (Plain Text + HTML) +- ✅ Category-based Room Routing +- ✅ Rich Metadata for Content +- ✅ Reactions & Threading Support + +**Matrix Spaces Struktur:** +``` +#breakpilot:server.de (Root Space) +├── #feed (Chronologischer Content Feed) +├── #bewegung (Movement Category) +├── #mathe (Math Category) +├── #steam (STEAM Category) +└── #sprache (Language Category) +``` + +**Files Created:** +``` +backend/content_service/ +└── matrix_client.py # Matrix Integration +``` + +**Features:** +- ✅ Auto-publish on Content.status = PUBLISHED +- ✅ Rich HTML Formatting mit Thumbnails +- ✅ CC License Badges in Messages +- ✅ Direct Links zu Content +- ✅ Category-specific Posting + +--- + +### **Sprint 5-6: Rating & Download Tracking** ✅ + +**Rating System:** +- ✅ 5-Star Rating System +- ✅ Text Comments +- ✅ Average Rating Calculation +- ✅ Rating Count Tracking +- ✅ One Rating per User (Update möglich) + +**Download Tracking:** +- ✅ Event-based Download Logging +- ✅ User-specific Tracking +- ✅ IP Anonymization (nach 7 Tagen) +- ✅ Download Counter +- ✅ Impact Score Foundation + +**Analytics:** +- ✅ Platform-wide Statistics +- ✅ Creator Statistics +- ✅ Content Breakdown by Category +- ✅ Downloads, Views, Ratings + +--- + +### **Sprint 7-8: H5P Interactive Content** ✅ + +**H5P Service (Node.js):** +- ✅ Self-hosted H5P Server +- ✅ H5P Editor Integration +- ✅ H5P Player +- ✅ File-based Content Storage +- ✅ Library Management +- ✅ Export as .h5p Files +- ✅ Import .h5p Files + +**Supported H5P Content Types:** +- ✅ Interactive Video +- ✅ Course Presentation +- ✅ Quiz (Multiple Choice) +- ✅ Drag & Drop +- ✅ Timeline +- ✅ Memory Game +- ✅ Fill in the Blanks +- ✅ 50+ weitere Content Types + +**Files Created:** +``` +h5p-service/ +├── server.js # H5P Express Server +├── package.json # Node Dependencies +└── Dockerfile # Container Definition +``` + +**Integration:** +- ✅ Content Service → H5P Service API +- ✅ H5P Content ID in Content Model +- ✅ Automatic Publishing to Matrix + +--- + +### **Sprint 7-8: Creative Commons Licensing** ✅ + +**Lizenz-System:** +- ✅ CC-BY-4.0 +- ✅ CC-BY-SA-4.0 (Recommended) +- ✅ CC-BY-NC-4.0 +- ✅ CC-BY-NC-SA-4.0 +- ✅ CC0-1.0 (Public Domain) + +**Features:** +- ✅ License Validation bei Upload +- ✅ License Selector in Creator Studio +- ✅ License Badges in UI +- ✅ Direct Links zu Creative Commons +- ✅ Matrix Messages mit License Info + +--- + +### **Sprint 7-8: DSGVO Compliance** ✅ + +**Privacy by Design:** +- ✅ Datenminimierung (nur notwendige Daten) +- ✅ EU Server Hosting +- ✅ IP Anonymization +- ✅ User Data Export API +- ✅ Account Deletion +- ✅ No Schülerdaten + +**Transparency:** +- ✅ Clear License Information +- ✅ Open Source Code +- ✅ Transparent Analytics + +--- + +## 🐳 Docker Infrastructure + +**docker-compose.content.yml:** +```yaml +Services: + - minio (Object Storage) + - content-db (PostgreSQL) + - content-service (FastAPI) + - h5p-service (Node.js H5P) + +Volumes: + - minio_data + - content_db_data + - h5p_content + +Networks: + - breakpilot-pwa-network (external) +``` + +--- + +## 📊 Architektur-Übersicht + +``` +┌─────────────────────────────────────────────────────────┐ +│ BREAKPILOT CONTENT PLATFORM │ +├─────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌───────────┐ │ +│ │ Creator │───▶│ Content │───▶│ Matrix │ │ +│ │ Studio │ │ Service │ │ Feed │ │ +│ │ (Vue.js) │ │ (FastAPI) │ │ (Synapse) │ │ +│ └──────────────┘ └──────┬───────┘ └───────────┘ │ +│ │ │ +│ ┌────────┴────────┐ │ +│ │ │ │ +│ ┌──────▼─────┐ ┌─────▼─────┐ │ +│ │ MinIO │ │ H5P │ │ +│ │ Storage │ │ Service │ │ +│ └────────────┘ └───────────┘ │ +│ │ │ │ +│ ┌──────▼─────────────────▼─────┐ │ +│ │ PostgreSQL Database │ │ +│ └──────────────────────────────┘ │ +│ │ +│ ┌──────────────┐ ┌───────────┐ │ +│ │ Teacher │────────────────────────▶│ Content │ │ +│ │ Discovery │ Search & Download │ Player │ │ +│ │ UI │ │ │ │ +│ └──────────────┘ └───────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +--- + +## 🚀 Deployment + +### Quick Start + +```bash +# 1. Startup Script ausführbar machen +chmod +x scripts/start-content-services.sh + +# 2. Alle Services starten +./scripts/start-content-services.sh + +# ODER manuell: +docker-compose \ + -f docker-compose.yml \ + -f docker-compose.content.yml \ + up -d +``` + +### URLs nach Start + +| Service | URL | Credentials | +|---------|-----|-------------| +| Content Service API | http://localhost:8002/docs | - | +| MinIO Console | http://localhost:9001 | minioadmin / minioadmin123 | +| H5P Editor | http://localhost:8003/h5p/editor/new | - | +| Content Database | localhost:5433 | breakpilot / breakpilot123 | + +--- + +## 📝 Content Creation Workflow + +### 1. Creator erstellt Content + +```javascript +// POST /api/v1/content +{ + "title": "5-Minuten Yoga", + "description": "Bewegungspause für Grundschüler", + "content_type": "video", + "category": "movement", + "license": "CC-BY-SA-4.0", + "age_min": 6, + "age_max": 10, + "tags": ["yoga", "bewegung"] +} +``` + +### 2. Upload Media Files + +```javascript +// POST /api/v1/upload +FormData { + file: +} +→ Returns: { file_url: "http://minio:9000/..." } +``` + +### 3. Attach Files to Content + +```javascript +// POST /api/v1/content/{id}/files +{ + "file_urls": ["http://minio:9000/..."] +} +``` + +### 4. Publish to Matrix + +```javascript +// POST /api/v1/content/{id}/publish +→ Status: PUBLISHED +→ Matrix Message in #movement Space +→ Discoverable by Teachers +``` + +--- + +## 🎨 Frontend Components (Creator Studio) + +### Struktur (Vorbereitet) + +``` +frontend/creator-studio/ +├── src/ +│ ├── components/ +│ │ ├── ContentUpload.vue +│ │ ├── ContentList.vue +│ │ ├── ContentEditor.vue +│ │ ├── H5PEditor.vue +│ │ └── Analytics.vue +│ ├── views/ +│ │ ├── Dashboard.vue +│ │ ├── CreateContent.vue +│ │ └── MyContent.vue +│ ├── api/ +│ │ └── content.js +│ └── router/ +│ └── index.js +├── package.json +└── vite.config.js +``` + +**Status:** Framework vorbereitet, vollständige UI-Implementation ausstehend (Sprint 1-2 Frontend) + +--- + +## ⏭️ Nächste Schritte (Optional/Future) + +### **Ausstehend:** + +1. **OAuth2 SSO Integration** (Sprint 3-4) + - consent-service → Matrix SSO + - JWT Validation in Content Service + - User Roles & Permissions + +2. **Teacher Discovery UI** (Sprint 5-6) + - Vue.js Frontend komplett + - Search & Filter UI + - Content Preview & Download + - Rating Interface + +3. **Production Deployment** + - Environment Configuration + - SSL/TLS Certificates + - Backup Strategy + - Monitoring (Prometheus/Grafana) + +--- + +## 📈 Impact Scoring (Fundament gelegt) + +**Vorbereitet für zukünftige Implementierung:** + +```python +# Impact Score Calculation (Beispiel) +impact_score = ( + downloads * 10 + + rating_count * 5 + + avg_rating * 20 + + matrix_engagement * 2 +) +``` + +**Bereits getrackt:** +- ✅ Downloads +- ✅ Views +- ✅ Ratings (Stars + Comments) +- ✅ Matrix Event IDs + +--- + +## 🎯 Erreichte Features (Zusammenfassung) + +| Feature | Status | Sprint | +|---------|--------|--------| +| Content CRUD API | ✅ | 1-2 | +| File Upload (MinIO) | ✅ | 1-2 | +| PostgreSQL Schema | ✅ | 1-2 | +| Matrix Feed Publishing | ✅ | 3-4 | +| Rating System | ✅ | 5-6 | +| Download Tracking | ✅ | 5-6 | +| H5P Integration | ✅ | 7-8 | +| CC Licensing | ✅ | 7-8 | +| DSGVO Compliance | ✅ | 7-8 | +| Docker Setup | ✅ | 7-8 | +| Deployment Guide | ✅ | 7-8 | +| Creator Studio (Backend) | ✅ | 1-2 | +| Creator Studio (Frontend) | 🔜 | Pending | +| Teacher Discovery UI | 🔜 | Pending | +| OAuth2 SSO | 🔜 | Pending | + +--- + +## 📚 Dokumentation + +- ✅ **CONTENT_SERVICE_SETUP.md** - Vollständiger Setup Guide +- ✅ **IMPLEMENTATION_SUMMARY.md** - Diese Datei +- ✅ **API Dokumentation** - Auto-generiert via FastAPI (/docs) +- ✅ **Architekturempfehlung PDF** - Strategische Planung + +--- + +## 🎉 Fazit + +**Implementiert:** 8+ Wochen Entwicklung in Sprints 1-8 + +**Kernfunktionen:** +- ✅ Vollständiger Content Service (Backend) +- ✅ MinIO S3 Storage +- ✅ H5P Interactive Content +- ✅ Matrix Feed Integration +- ✅ Creative Commons Licensing +- ✅ Rating & Analytics +- ✅ DSGVO Compliance +- ✅ Docker Deployment Ready + +**Ready to Use:** Alle Backend-Services produktionsbereit + +**Next:** Frontend UI vervollständigen & Production Deploy + +--- + +**🚀 Die BreakPilot Content Platform ist LIVE!** diff --git a/LICENSES/THIRD_PARTY_LICENSES.md b/LICENSES/THIRD_PARTY_LICENSES.md new file mode 100644 index 0000000..9aae934 --- /dev/null +++ b/LICENSES/THIRD_PARTY_LICENSES.md @@ -0,0 +1,371 @@ +# Third-Party Licenses +## BreakPilot PWA + +Dieses Dokument enthält die vollständigen Lizenztexte aller Open-Source-Komponenten, die in BreakPilot verwendet werden. + +--- + +## 1. LibreChat + +``` +MIT License + +Copyright (c) 2025 LibreChat + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +**Repository:** https://github.com/danny-avila/LibreChat + +--- + +## 2. FastAPI + +``` +MIT License + +Copyright (c) 2018 Sebastián Ramírez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +**Repository:** https://github.com/tiangolo/fastapi + +--- + +## 3. Meilisearch + +``` +MIT License + +Copyright (c) 2019-2024 Meili SAS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +**Repository:** https://github.com/meilisearch/meilisearch + +--- + +## 4. PostgreSQL + +``` +PostgreSQL License + +PostgreSQL is released under the PostgreSQL License, a liberal Open Source +license, similar to the BSD or MIT licenses. + +PostgreSQL Database Management System +(formerly known as Postgres, then as Postgres95) + +Portions Copyright (c) 1996-2024, PostgreSQL Global Development Group +Portions Copyright (c) 1994, The Regents of the University of California + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose, without fee, and without a written agreement +is hereby granted, provided that the above copyright notice and this +paragraph and the following two paragraphs appear in all copies. + +IN NO EVENT SHALL THE UNIVERSITY OF CALIFORNIA BE LIABLE TO ANY PARTY FOR +DIRECT, INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, INCLUDING +LOST PROFITS, ARISING OUT OF THE USE OF THIS SOFTWARE AND ITS DOCUMENTATION, +EVEN IF THE UNIVERSITY OF CALIFORNIA HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGE. + +THE UNIVERSITY OF CALIFORNIA SPECIFICALLY DISCLAIMS ANY WARRANTIES, +INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS FOR A PARTICULAR PURPOSE. THE SOFTWARE PROVIDED HEREUNDER IS +ON AN "AS IS" BASIS, AND THE UNIVERSITY OF CALIFORNIA HAS NO OBLIGATIONS +TO PROVIDE MAINTENANCE, SUPPORT, UPDATES, ENHANCEMENTS, OR MODIFICATIONS. +``` + +**Repository:** https://www.postgresql.org/ + +--- + +## 5. pgvector + +``` +PostgreSQL License + +Copyright (c) 2021-2024 Andrew Kane + +Permission to use, copy, modify, and distribute this software and its +documentation for any purpose, without fee, and without a written agreement +is hereby granted, provided that the above copyright notice and this +paragraph appear in all copies. +``` + +**Repository:** https://github.com/pgvector/pgvector + +--- + +## 6. Gorilla Mux (Go Router) + +``` +BSD 3-Clause License + +Copyright (c) 2012-2023 The Gorilla Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. +``` + +**Repository:** https://github.com/gorilla/mux + +--- + +## 7. golang-jwt/jwt + +``` +MIT License + +Copyright (c) 2012 Dave Grijalva +Copyright (c) 2021 golang-jwt maintainers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +**Repository:** https://github.com/golang-jwt/jwt + +--- + +## 8. Uvicorn + +``` +BSD 3-Clause License + +Copyright (c) 2017-present, Encode OSS Ltd. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` + +**Repository:** https://github.com/encode/uvicorn + +--- + +## 9. Pydantic + +``` +MIT License + +Copyright (c) 2017 to present Pydantic Services Inc. and individual contributors. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +**Repository:** https://github.com/pydantic/pydantic + +--- + +## 10. Jinja2 + +``` +BSD 3-Clause License + +Copyright 2007 Pallets + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` + +**Repository:** https://github.com/pallets/jinja + +--- + +## 11. WeasyPrint + +``` +BSD 3-Clause License + +Copyright (c) 2011-2024, Kozea + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` + +**Repository:** https://github.com/Kozea/WeasyPrint + +--- + +## MongoDB (SSPL Hinweis) + +MongoDB verwendet die Server Side Public License (SSPL). Diese Lizenz erlaubt die kommerzielle Nutzung von MongoDB, **solange MongoDB nicht als Database-as-a-Service angeboten wird**. + +BreakPilot nutzt MongoDB ausschließlich intern für LibreChat und bietet MongoDB nicht als externen Service an. Damit ist die kommerzielle Nutzung vollständig konform. + +Weitere Informationen: https://www.mongodb.com/licensing/server-side-public-license + +--- + +*Letzte Aktualisierung: 2025-12-14* diff --git a/MAC_MINI_SETUP.md b/MAC_MINI_SETUP.md new file mode 100644 index 0000000..faab752 --- /dev/null +++ b/MAC_MINI_SETUP.md @@ -0,0 +1,95 @@ +# Mac Mini Headless Setup - Vollständig Automatisch + +## Verbindungsdaten +- **IP (LAN):** 192.168.178.100 +- **IP (WiFi):** 192.168.178.163 (nicht mehr aktiv) +- **User:** benjaminadmin +- **SSH:** `ssh benjaminadmin@192.168.178.100` + +## Nach Neustart - Alles startet automatisch! + +| Service | Auto-Start | Port | +|---------|------------|------| +| ✅ SSH | Ja | 22 | +| ✅ Docker Desktop | Ja | - | +| ✅ Docker Container | Ja (nach ~2 Min) | 8000, 8081, etc. | +| ✅ Ollama Server | Ja | 11434 | +| ✅ Unity Hub | Ja | - | +| ✅ VS Code | Ja | - | + +**Keine Aktion nötig nach Neustart!** Einfach 2-3 Minuten warten. + +## Status prüfen +```bash +./scripts/mac-mini/status.sh +``` + +## Services & Ports +| Service | Port | URL | +|---------|------|-----| +| Backend API | 8000 | http://192.168.178.100:8000/admin | +| Consent Service | 8081 | - | +| PostgreSQL | 5432 | - | +| Valkey/Redis | 6379 | - | +| MinIO | 9000/9001 | http://192.168.178.100:9001 | +| Mailpit | 8025 | http://192.168.178.100:8025 | +| Ollama | 11434 | http://192.168.178.100:11434/api/tags | + +## LLM Modelle +- **Qwen 2.5 14B** (14.8 Milliarden Parameter) + +## Scripts (auf MacBook) +```bash +./scripts/mac-mini/status.sh # Status prüfen +./scripts/mac-mini/sync.sh # Code synchronisieren +./scripts/mac-mini/docker.sh # Docker-Befehle +./scripts/mac-mini/backup.sh # Backup erstellen +``` + +## Docker-Befehle +```bash +./scripts/mac-mini/docker.sh ps # Container anzeigen +./scripts/mac-mini/docker.sh logs backend # Logs +./scripts/mac-mini/docker.sh restart # Neustart +./scripts/mac-mini/docker.sh build # Image bauen +``` + +## LaunchAgents (Auto-Start) +Pfad auf Mac Mini: `~/Library/LaunchAgents/` + +| Agent | Funktion | +|-------|----------| +| `com.docker.desktop.plist` | Docker Desktop | +| `com.breakpilot.docker-containers.plist` | Container Auto-Start | +| `com.ollama.serve.plist` | Ollama Server | +| `com.unity.hub.plist` | Unity Hub | +| `com.microsoft.vscode.plist` | VS Code | + +## Projekt-Pfade +- **MacBook:** `/Users/benjaminadmin/Projekte/breakpilot-pwa/` +- **Mac Mini:** `/Users/benjaminadmin/Projekte/breakpilot-pwa/` + +## Troubleshooting + +### Docker Onboarding erscheint wieder +Docker-Einstellungen sind gesichert in `~/docker-settings-backup/` +```bash +# Wiederherstellen: +cp -r ~/docker-settings-backup/* ~/Library/Group\ Containers/group.com.docker/ +``` + +### Container starten nicht automatisch +Log prüfen: +```bash +ssh benjaminadmin@192.168.178.163 "cat /tmp/docker-autostart.log" +``` + +Manuell starten: +```bash +./scripts/mac-mini/docker.sh up +``` + +### SSH nicht erreichbar +- Prüfe ob Mac Mini an ist (Ping: `ping 192.168.178.163`) +- Warte 1-2 Minuten nach Boot +- Prüfe Netzwerkverbindung diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2c95009 --- /dev/null +++ b/Makefile @@ -0,0 +1,80 @@ +# BreakPilot PWA - Makefile fuer lokale CI-Simulation +# +# Verwendung: +# make ci - Alle Tests lokal ausfuehren +# make test-go - Nur Go-Tests +# make test-python - Nur Python-Tests +# make logs-agent - Woodpecker Agent Logs +# make logs-backend - Backend Logs (ci-result) + +.PHONY: ci test-go test-python test-node logs-agent logs-backend clean help + +# Verzeichnis fuer Test-Ergebnisse +CI_RESULTS_DIR := .ci-results + +help: + @echo "BreakPilot CI - Verfuegbare Befehle:" + @echo "" + @echo " make ci - Alle Tests lokal ausfuehren" + @echo " make test-go - Go Service Tests" + @echo " make test-python - Python Service Tests" + @echo " make test-node - Node.js Service Tests" + @echo " make logs-agent - Woodpecker Agent Logs anzeigen" + @echo " make logs-backend - Backend Logs (ci-result) anzeigen" + @echo " make clean - Test-Ergebnisse loeschen" + +ci: test-go test-python test-node + @echo "=========================================" + @echo "Local CI complete. Results in $(CI_RESULTS_DIR)/" + @echo "=========================================" + @ls -la $(CI_RESULTS_DIR)/ + +test-go: $(CI_RESULTS_DIR) + @echo "=== Go Tests ===" + @if [ -d "consent-service" ]; then \ + cd consent-service && go test -v -json ./... > ../$(CI_RESULTS_DIR)/test-consent.json 2>&1 || true; \ + echo "consent-service: done"; \ + fi + @if [ -d "billing-service" ]; then \ + cd billing-service && go test -v -json ./... > ../$(CI_RESULTS_DIR)/test-billing.json 2>&1 || true; \ + echo "billing-service: done"; \ + fi + @if [ -d "school-service" ]; then \ + cd school-service && go test -v -json ./... > ../$(CI_RESULTS_DIR)/test-school.json 2>&1 || true; \ + echo "school-service: done"; \ + fi + +test-python: $(CI_RESULTS_DIR) + @echo "=== Python Tests ===" + @if [ -d "backend" ]; then \ + cd backend && python -m pytest tests/ -v --tb=short 2>&1 || true; \ + echo "backend: done"; \ + fi + @if [ -d "voice-service" ]; then \ + cd voice-service && python -m pytest tests/ -v --tb=short 2>&1 || true; \ + echo "voice-service: done"; \ + fi + @if [ -d "klausur-service/backend" ]; then \ + cd klausur-service/backend && python -m pytest tests/ -v --tb=short 2>&1 || true; \ + echo "klausur-service: done"; \ + fi + +test-node: $(CI_RESULTS_DIR) + @echo "=== Node.js Tests ===" + @if [ -d "h5p-service" ]; then \ + cd h5p-service && npm test 2>&1 || true; \ + echo "h5p-service: done"; \ + fi + +$(CI_RESULTS_DIR): + @mkdir -p $(CI_RESULTS_DIR) + +logs-agent: + docker logs breakpilot-pwa-woodpecker-agent --tail=200 + +logs-backend: + docker compose logs backend --tail=200 | grep -E "(ci-result|error|ERROR)" + +clean: + rm -rf $(CI_RESULTS_DIR) + @echo "Test-Ergebnisse geloescht" diff --git a/POLICY_VAULT_OVERVIEW.md b/POLICY_VAULT_OVERVIEW.md new file mode 100644 index 0000000..c7c3580 --- /dev/null +++ b/POLICY_VAULT_OVERVIEW.md @@ -0,0 +1,794 @@ +# Policy Vault - Projekt-Dokumentation + +## Projektübersicht + +**Policy Vault** ist eine vollständige Web-Anwendung zur Verwaltung von Datenschutzrichtlinien, Cookie-Einwilligungen und Nutzerzustimmungen für verschiedene Projekte und Plattformen. Das System ermöglicht es Administratoren, Datenschutzdokumente zu erstellen, zu verwalten und zu versionieren, sowie Nutzereinwilligungen zu verfolgen und Cookie-Präferenzen zu speichern. + +## Zweck und Anwendungsbereich + +Das Policy Vault System dient als zentrale Plattform für: +- **Verwaltung von Datenschutzrichtlinien** (Privacy Policies, Terms of Service, etc.) +- **Cookie-Consent-Management** mit Kategorisierung und Vendor-Verwaltung +- **Versionskontrolle** für Richtliniendokumente +- **Multi-Projekt-Verwaltung** mit rollenbasiertem Zugriff +- **Nutzereinwilligungs-Tracking** über verschiedene Plattformen hinweg +- **Mehrsprachige Unterstützung** für globale Anwendungen + +--- + +## Technologie-Stack + +### Backend +- **Framework**: NestJS (Node.js/TypeScript) +- **Datenbank**: PostgreSQL +- **ORM**: Drizzle ORM +- **Authentifizierung**: JWT (JSON Web Tokens) mit Access/Refresh Token +- **API-Dokumentation**: Swagger/OpenAPI +- **Validierung**: class-validator, class-transformer +- **Security**: + - Encryption-based authentication + - Rate limiting (Throttler) + - Role-based access control (RBAC) + - bcrypt für Password-Hashing +- **Logging**: Winston mit Daily Rotate File +- **Job Scheduling**: NestJS Schedule +- **E-Mail**: Nodemailer +- **OTP-Generierung**: otp-generator + +### Frontend +- **Framework**: Angular 18 +- **UI**: + - TailwindCSS + - Custom SCSS +- **Rich Text Editor**: CKEditor 5 + - Alignment, Block Quote, Code Block + - Font styling, Image support + - List und Table support +- **State Management**: RxJS +- **Security**: DOMPurify für HTML-Sanitization +- **Multi-Select**: ng-multiselect-dropdown +- **Process Manager**: PM2 + +--- + +## Hauptfunktionen und Features + +### 1. Administratoren-Verwaltung +- **Super Admin und Admin Rollen** + - Super Admin (Role 1): Vollzugriff auf alle Funktionen + - Admin (Role 2): Eingeschränkter Zugriff auf zugewiesene Projekte +- **Authentifizierung** + - Login mit E-Mail und Passwort + - JWT-basierte Sessions (Access + Refresh Token) + - OTP-basierte Passwort-Wiederherstellung + - Account-Lock-Mechanismus bei mehrfachen Fehlversuchen +- **Benutzerverwaltung** + - Admin-Erstellung durch Super Admin + - Projekt-Zuweisungen für Admins + - Rollen-Modifikation (Promote/Demote) + - Soft-Delete (isDeleted Flag) + +### 2. Projekt-Management +- **Projektverwaltung** + - Erstellung und Verwaltung von Projekten + - Projekt-spezifische Konfiguration (Theme-Farben, Icons, Logos) + - Mehrsprachige Unterstützung (Language Configuration) + - Projekt-Keys für sichere API-Zugriffe + - Soft-Delete und Blocking von Projekten +- **Projekt-Zugriffskontrolle** + - Zuweisung von Admins zu spezifischen Projekten + - Project-Admin-Beziehungen + +### 3. Policy Document Management +- **Dokumentenverwaltung** + - Erstellung von Datenschutzdokumenten (Privacy Policies, ToS, etc.) + - Projekt-spezifische Dokumente + - Beschreibung und Metadaten +- **Versionierung** + - Multiple Versionen pro Dokument + - Version-Metadaten mit Inhalt + - Publish/Draft-Status + - Versionsnummern-Tracking + +### 4. Cookie-Consent-Management +- **Cookie-Kategorien** + - Kategorien-Metadaten (z.B. Notwendig, Marketing, Analytics) + - Plattform-spezifische Kategorien (Web, Mobile, etc.) + - Versionierung der Kategorien + - Pflicht- und optionale Kategorien + - Mehrsprachige Kategorie-Beschreibungen +- **Vendor-Management** + - Verwaltung von Drittanbieter-Services + - Vendor-Metadaten und -Beschreibungen + - Zuordnung zu Kategorien + - Sub-Services für Vendors + - Mehrsprachige Vendor-Informationen +- **Globale Cookie-Einstellungen** + - Projekt-weite Cookie-Texte und -Beschreibungen + - Mehrsprachige globale Inhalte + - Datei-Upload-Unterstützung + +### 5. User Consent Tracking +- **Policy Document Consent** + - Tracking von Nutzereinwilligungen für Richtlinien-Versionen + - Username-basiertes Tracking + - Status (Akzeptiert/Abgelehnt) + - Timestamp-Tracking +- **Cookie Consent** + - Granulare Cookie-Einwilligungen pro Kategorie + - Vendor-spezifische Einwilligungen + - Versions-Tracking + - Username und Projekt-basiert +- **Verschlüsselte API-Zugriffe** + - Token-basierte Authentifizierung für Mobile/Web + - Encryption-based authentication für externe Zugriffe + +### 6. Mehrsprachige Unterstützung +- **Language Management** + - Dynamische Sprachen-Konfiguration pro Projekt + - Mehrsprachige Inhalte für: + - Kategorien-Beschreibungen + - Vendor-Informationen + - Globale Cookie-Texte + - Sub-Service-Beschreibungen + +--- + +## API-Struktur und Endpoints + +### Admin-Endpoints (`/admins`) +``` +POST /admins/create-admin - Admin erstellen (Super Admin only) +POST /admins/create-super-admin - Super Admin erstellen (Super Admin only) +POST /admins/create-root-user-super-admin - Root Super Admin erstellen (Secret-based) +POST /admins/login - Admin Login +GET /admins/get-access-token - Neuen Access Token abrufen +POST /admins/generate-otp - OTP für Passwort-Reset generieren +POST /admins/validate-otp - OTP validieren +POST /admins/change-password - Passwort ändern (mit OTP) +PUT /admins/update-password - Passwort aktualisieren (eingeloggt) +PUT /admins/forgot-password - Passwort vergessen +PUT /admins/make-super-admin - Admin zu Super Admin befördern +PUT /admins/remove-super-admin - Super Admin zu Admin zurückstufen +PUT /admins/make-project-admin - Projekt-Zugriff gewähren +DELETE /admins/remove-project-admin - Projekt-Zugriff entfernen +GET /admins/findAll?role= - Alle Admins abrufen (gefiltert nach Rolle) +GET /admins/findAll-super-admins - Alle Super Admins abrufen +GET /admins/findOne?id= - Einzelnen Admin abrufen +PUT /admins/update - Admin-Details aktualisieren +DELETE /admins/delete-admin?id= - Admin löschen (Soft-Delete) +``` + +### Project-Endpoints (`/project`) +``` +POST /project/create - Projekt erstellen (Super Admin only) +PUT /project/v2/updateProjectKeys - Projekt-Keys aktualisieren +GET /project/findAll - Alle Projekte abrufen (mit Pagination) +GET /project/findAllByUser - Projekte eines bestimmten Users +GET /project/findOne?id= - Einzelnes Projekt abrufen +PUT /project/update - Projekt aktualisieren +DELETE /project/delete?id= - Projekt löschen +``` + +### Policy Document-Endpoints (`/policydocument`) +``` +POST /policydocument/create - Policy Document erstellen +GET /policydocument/findAll - Alle Policy Documents abrufen +GET /policydocument/findOne?id= - Einzelnes Policy Document +GET /policydocument/findPolicyDocs?projectId= - Documents für ein Projekt +PUT /policydocument/update - Policy Document aktualisieren +DELETE /policydocument/delete?id= - Policy Document löschen +``` + +### Version-Endpoints (`/version`) +``` +POST /version/create - Version erstellen +GET /version/findAll - Alle Versionen abrufen +GET /version/findOne?id= - Einzelne Version abrufen +GET /version/findVersions?policyDocId= - Versionen für ein Policy Document +PUT /version/update - Version aktualisieren +DELETE /version/delete?id= - Version löschen +``` + +### User Consent-Endpoints (`/consent`) +``` +POST /consent/v2/create - User Consent erstellen (Encrypted) +GET /consent/v2/GetConsent - Consent abrufen (Encrypted) +GET /consent/v2/GetConsentFileContent - Consent mit Dateiinhalt (Encrypted) +GET /consent/v2/latestAcceptedConsent - Letzte akzeptierte Consent +DELETE /consent/v2/delete - Consent löschen (Encrypted) +``` + +### Cookie Consent-Endpoints (`/cookieconsent`) +``` +POST /cookieconsent/v2/create - Cookie Consent erstellen (Encrypted) +GET /cookieconsent/v2/get - Cookie Kategorien abrufen (Encrypted) +GET /cookieconsent/v2/getFileContent - Cookie Daten mit Dateiinhalt (Encrypted) +DELETE /cookieconsent/v2/delete - Cookie Consent löschen (Encrypted) +``` + +### Cookie-Endpoints (`/cookies`) +``` +POST /cookies/createCategory - Cookie-Kategorie erstellen +POST /cookies/createVendor - Vendor erstellen +POST /cookies/createGlobalCookie - Globale Cookie-Einstellung erstellen +GET /cookies/getCategories?projectId= - Kategorien für Projekt abrufen +GET /cookies/getVendors?projectId= - Vendors für Projekt abrufen +GET /cookies/getGlobalCookie?projectId= - Globale Cookie-Settings +PUT /cookies/updateCategory - Kategorie aktualisieren +PUT /cookies/updateVendor - Vendor aktualisieren +PUT /cookies/updateGlobalCookie - Globale Settings aktualisieren +DELETE /cookies/deleteCategory?id= - Kategorie löschen +DELETE /cookies/deleteVendor?id= - Vendor löschen +DELETE /cookies/deleteGlobalCookie?id= - Globale Settings löschen +``` + +### Health Check-Endpoint (`/db-health-check`) +``` +GET /db-health-check - Datenbank-Status prüfen +``` + +--- + +## Datenmodelle + +### Admin +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + employeeCode: string (nullable) + firstName: string (max 60) + lastName: string (max 50) + officialMail: string (unique, max 100) + role: number (1 = Super Admin, 2 = Admin) + passwordHash: string + salt: string (nullable) + accessToken: text (nullable) + refreshToken: text (nullable) + accLockCount: number (default 0) + accLockTime: number (default 0) + isBlocked: boolean (default false) + isDeleted: boolean (default false) + otp: string (nullable) +} +``` + +### Project +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + name: string (unique) + description: string + imageURL: text (nullable) + iconURL: text (nullable) + isBlocked: boolean (default false) + isDeleted: boolean (default false) + themeColor: string + textColor: string + languages: json (nullable) // Array von Sprach-Codes +} +``` + +### Policy Document +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + name: string + description: string (nullable) + projectId: number (FK -> project.id, CASCADE) +} +``` + +### Version (Policy Document Meta & Version Meta) +```typescript +// Policy Document Meta +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + policyDocumentId: number (FK) + version: string + isPublish: boolean +} + +// Version Meta (Sprachspezifischer Inhalt) +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + policyDocMetaId: number (FK) + language: string + content: text + file: text (nullable) +} +``` + +### User Consent +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + username: string + status: boolean + projectId: number (FK -> project.id, CASCADE) + versionMetaId: number (FK -> versionMeta.id, CASCADE) +} +``` + +### Cookie Consent +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + username: string + categoryId: number[] (Array) + vendors: number[] (Array) + projectId: number (FK -> project.id, CASCADE) + version: string +} +``` + +### Categories Metadata +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + projectId: number (FK -> project.id, CASCADE) + platform: string + version: string + isPublish: boolean (default false) + metaName: string + isMandatory: boolean (default false) +} +``` + +### Categories Language Data +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + categoryMetaId: number (FK -> categoriesMetadata.id, CASCADE) + language: string + title: string + description: text +} +``` + +### Vendor Meta +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + categoryId: number (FK -> categoriesMetadata.id, CASCADE) + vendorName: string +} +``` + +### Vendor Language +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + vendorMetaId: number (FK -> vendorMeta.id, CASCADE) + language: string + description: text +} +``` + +### Sub Service +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + vendorMetaId: number (FK -> vendorMeta.id, CASCADE) + serviceName: string +} +``` + +### Global Cookie Metadata +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + projectId: number (FK -> project.id, CASCADE) + version: string + isPublish: boolean (default false) +} +``` + +### Global Cookie Language Data +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + globalCookieMetaId: number (FK -> globalCookieMetadata.id, CASCADE) + language: string + title: string + description: text + file: text (nullable) +} +``` + +### Project Keys +```typescript +{ + id: number (PK) + createdAt: timestamp + updatedAt: timestamp + projectId: number (FK -> project.id, CASCADE) + publicKey: text + privateKey: text +} +``` + +### Admin Projects (Junction Table) +```typescript +{ + id: number (PK) + adminId: number (FK -> admin.id, CASCADE) + projectId: number (FK -> project.id, CASCADE) +} +``` + +--- + +## Architektur-Übersicht + +### Backend-Architektur + +``` +┌─────────────────────────────────────────────────────────────┐ +│ NestJS Backend │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Guards │ │ Middlewares │ │ Interceptors │ │ +│ ├──────────────┤ ├──────────────┤ ├──────────────┤ │ +│ │ - AuthGuard │ │ - Token │ │ - Serialize │ │ +│ │ - RolesGuard │ │ - Decrypt │ │ - Logging │ │ +│ │ - Throttler │ │ - Headers │ │ │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────┐ │ +│ │ API Modules │ │ +│ ├───────────────────────────────────────────────────┤ │ +│ │ - Admins (Authentication, Authorization) │ │ +│ │ - Projects (Multi-tenant Management) │ │ +│ │ - Policy Documents (Document Management) │ │ +│ │ - Versions (Versioning System) │ │ +│ │ - User Consent (Consent Tracking) │ │ +│ │ - Cookies (Cookie Categories & Vendors) │ │ +│ │ - Cookie Consent (Cookie Consent Tracking) │ │ +│ │ - DB Health Check (System Monitoring) │ │ +│ └───────────────────────────────────────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────┐ │ +│ │ Drizzle ORM Layer │ │ +│ ├───────────────────────────────────────────────────┤ │ +│ │ - Schema Definitions │ │ +│ │ - Relations │ │ +│ │ - Database Connection Pool │ │ +│ └───────────────────────────────────────────────────┘ │ +│ │ │ +└──────────────────────────┼────────────────────────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ PostgreSQL │ + │ Database │ + └─────────────────┘ +``` + +### Frontend-Architektur + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Angular Frontend │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Guards │ │ Interceptors │ │ Services │ │ +│ ├──────────────┤ ├──────────────┤ ├──────────────┤ │ +│ │ - AuthGuard │ │ - HTTP │ │ - Auth │ │ +│ │ │ │ - Error │ │ - REST API │ │ +│ │ │ │ │ │ - Session │ │ +│ │ │ │ │ │ - Security │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────┐ │ +│ │ Feature Modules │ │ +│ ├───────────────────────────────────────────────────┤ │ +│ │ ┌─────────────────────────────────────────┐ │ │ +│ │ │ Auth Module │ │ │ +│ │ │ - Login Component │ │ │ +│ │ └─────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────┐ │ │ +│ │ │ Project Dashboard │ │ │ +│ │ │ - Project List │ │ │ +│ │ │ - Project Creation │ │ │ +│ │ │ - Project Settings │ │ │ +│ │ └─────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────┐ │ │ +│ │ │ Individual Project Dashboard │ │ │ +│ │ │ - Agreements (Policy Documents) │ │ │ +│ │ │ - Cookie Consent Management │ │ │ +│ │ │ - FAQ Management │ │ │ +│ │ │ - Licenses Management │ │ │ +│ │ │ - User Management │ │ │ +│ │ │ - Project Settings │ │ │ +│ │ └─────────────────────────────────────────┘ │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────┐ │ │ +│ │ │ Shared Components │ │ │ +│ │ │ - Settings │ │ │ +│ │ │ - Common UI Elements │ │ │ +│ │ └─────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ + │ + │ HTTPS/REST API + ▼ + ┌─────────────────┐ + │ NestJS Backend │ + └─────────────────┘ +``` + +### Datenbankbeziehungen + +``` +┌──────────┐ ┌─────────────────┐ ┌─────────────┐ +│ Admin │◄───────►│ AdminProjects │◄───────►│ Project │ +└──────────┘ └─────────────────┘ └─────────────┘ + │ + │ 1:N + ┌────────────────────────────────────┤ + │ │ + ▼ ▼ + ┌──────────────────────┐ ┌──────────────────────────┐ + │ Policy Document │ │ Categories Metadata │ + └──────────────────────┘ └──────────────────────────┘ + │ │ + │ 1:N │ 1:N + ▼ ▼ + ┌──────────────────────┐ ┌──────────────────────────┐ + │ Policy Document Meta │ │ Categories Language Data │ + └──────────────────────┘ └──────────────────────────┘ + │ │ + │ 1:N │ 1:N + ▼ ▼ + ┌──────────────────────┐ ┌──────────────────────────┐ + │ Version Meta │ │ Vendor Meta │ + └──────────────────────┘ └──────────────────────────┘ + │ │ + │ 1:N │ 1:N + ▼ ├──────────┐ + ┌──────────────────────┐ ▼ ▼ + │ User Consent │ ┌─────────────────┐ ┌────────────┐ + └──────────────────────┘ │ Vendor Language │ │Sub Service │ + └─────────────────┘ └────────────┘ +┌──────────────────────┐ +│ Cookie Consent │◄─── Project +└──────────────────────┘ + +┌─────────────────────────┐ +│ Global Cookie Metadata │◄─── Project +└─────────────────────────┘ + │ + │ 1:N + ▼ +┌─────────────────────────────┐ +│ Global Cookie Language Data │ +└─────────────────────────────────┘ + +┌──────────────────┐ +│ Project Keys │◄─── Project +└──────────────────┘ +``` + +### Sicherheitsarchitektur + +#### Authentifizierung & Autorisierung +1. **JWT-basierte Authentifizierung** + - Access Token (kurzlebig) + - Refresh Token (langlebig) + - Token-Refresh-Mechanismus + +2. **Rollenbasierte Zugriffskontrolle (RBAC)** + - Super Admin (Role 1): Vollzugriff + - Admin (Role 2): Projektbezogener Zugriff + - Guard-basierte Absicherung auf Controller-Ebene + +3. **Encryption-based Authentication** + - Für externe/mobile Zugriffe + - Token-basierte Verschlüsselung + - User + Project ID Validierung + +#### Security Features +- **Rate Limiting**: Throttler mit konfigurierbaren Limits +- **Password Security**: bcrypt Hashing mit Salt +- **Account Lock**: Nach mehrfachen Fehlversuchen +- **OTP-basierte Passwort-Wiederherstellung** +- **Input Validation**: class-validator auf allen DTOs +- **HTML Sanitization**: DOMPurify im Frontend +- **CORS Configuration**: Custom Headers Middleware +- **Soft Delete**: Keine permanente Löschung von Daten + +--- + +## Deployment und Konfiguration + +### Backend Environment Variables +```env +DATABASE_URL=postgresql://username:password@host:port/database +NODE_ENV=development|test|production|local|demo +PORT=3000 +JWT_SECRET=your_jwt_secret +JWT_REFRESH_SECRET=your_refresh_secret +ROOT_SECRET=your_root_secret +ENCRYPTION_KEY=your_encryption_key +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=your_email +SMTP_PASSWORD=your_password +``` + +### Frontend Environment +```typescript +{ + production: false, + BASE_URL: "https://api.example.com/api/", + TITLE: "Policy Vault - Environment" +} +``` + +### Datenbank-Setup +```bash +# Migrationen ausführen +npm run migration:up + +# Migrationen zurückrollen +npm run migration:down + +# Schema generieren +npx drizzle-kit push +``` + +--- + +## API-Sicherheit + +### Token-basierte Authentifizierung +- Alle geschützten Endpoints erfordern einen gültigen JWT-Token im Authorization-Header +- Format: `Authorization: Bearer ` + +### Encryption-based Endpoints +Für mobile/externe Zugriffe (Consent Tracking): +- Header: `secret` oder `mobiletoken` +- Format: Verschlüsselter String mit `userId_projectId` +- Automatische Validierung durch DecryptMiddleware + +### Rate Limiting +- Standard: 10 Requests pro Minute +- OTP/Login: 3 Requests pro Minute +- Konfigurierbar über ThrottlerModule + +--- + +## Besondere Features + +### 1. Versionierung +- Komplettes Versions-Management für Policy Documents +- Mehrsprachige Versionen mit separaten Inhalten +- Publish/Draft Status +- Historische Versionsverfolgung + +### 2. Mehrsprachigkeit +- Zentrale Sprach-Konfiguration pro Projekt +- Separate Language-Data Tabellen für alle Inhaltstypen +- Support für unbegrenzte Sprachen + +### 3. Cookie-Consent-System +- Granulare Kontrolle über Cookie-Kategorien +- Vendor-Management mit Sub-Services +- Plattform-spezifische Kategorien (Web, Mobile, etc.) +- Versions-Tracking für Compliance + +### 4. Rich Content Editing +- CKEditor 5 Integration +- Support für komplexe Formatierungen +- Bild-Upload und -Verwaltung +- Code-Block-Unterstützung + +### 5. Logging & Monitoring +- Winston-basiertes Logging +- Daily Rotate Files +- Structured Logging +- Fehler-Tracking +- Datenbank-Health-Checks + +### 6. Soft Delete Pattern +- Keine permanente Datenlöschung +- `isDeleted` Flags auf allen Haupt-Entitäten +- Möglichkeit zur Wiederherstellung +- Audit Trail Erhaltung + +--- + +## Entwicklung + +### Backend starten +```bash +# Development +npm run start:dev + +# Local (mit Watch) +npm run start:local + +# Production +npm run start:prod +``` + +### Frontend starten +```bash +# Development Server +npm run start +# oder +ng serve + +# Build +npm run build + +# Mit PM2 +npm run start:pm2 +``` + +### Tests +```bash +# Backend Tests +npm run test +npm run test:e2e +npm run test:cov + +# Frontend Tests +npm run test +``` + +--- + +## Zusammenfassung + +Policy Vault ist eine umfassende Enterprise-Lösung für die Verwaltung von Datenschutzrichtlinien und Cookie-Einwilligungen. Das System bietet: + +- **Multi-Tenant-Architektur** mit Projekt-basierter Trennung +- **Robuste Authentifizierung** mit JWT und rollenbasierter Zugriffskontrolle +- **Vollständiges Versions-Management** für Compliance-Tracking +- **Granulare Cookie-Consent-Verwaltung** mit Vendor-Support +- **Mehrsprachige Unterstützung** für globale Anwendungen +- **Moderne Tech-Stack** mit NestJS, Angular und PostgreSQL +- **Enterprise-Grade Security** mit Encryption, Rate Limiting und Audit Trails +- **Skalierbare Architektur** mit klarer Trennung von Concerns + +Das System eignet sich ideal für Unternehmen, die: +- Multiple Projekte/Produkte mit unterschiedlichen Datenschutzrichtlinien verwalten +- GDPR/DSGVO-Compliance sicherstellen müssen +- Granulare Cookie-Einwilligungen tracken wollen +- Mehrsprachige Anwendungen betreiben +- Eine zentrale Policy-Management-Plattform benötigen diff --git a/SOURCE_POLICY_IMPLEMENTATION_PLAN.md b/SOURCE_POLICY_IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..6bd6632 --- /dev/null +++ b/SOURCE_POLICY_IMPLEMENTATION_PLAN.md @@ -0,0 +1,530 @@ +# Source-Policy System - Implementierungsplan + +## Zusammenfassung + +Whitelist-basiertes Datenquellen-Management fuer das edu-search-service unter `/compliance/source-policy`. Fuer Auditoren pruefbar mit vollstaendigem Audit-Trail. + +**Kernprinzipien:** +- Nur offizielle Open-Data-Portale und amtliche Quellen (§5 UrhG) +- Training mit externen Daten: **VERBOTEN** +- Alle Aenderungen protokolliert (Audit-Trail) +- PII-Blocklist mit Hard-Block + +--- + +## 1. Architektur + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ admin-v2 (Next.js) │ +│ /app/(admin)/compliance/source-policy/ │ +│ ├── page.tsx (Dashboard + Tabs) │ +│ └── components/ │ +│ ├── SourcesTab.tsx (Whitelist-Verwaltung) │ +│ ├── OperationsMatrixTab.tsx (Lookup/RAG/Training/Export) │ +│ ├── PIIRulesTab.tsx (PII-Blocklist) │ +│ └── AuditTab.tsx (Aenderungshistorie + Export) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ edu-search-service (Go) │ +│ NEW: internal/policy/ │ +│ ├── models.go (Datenstrukturen) │ +│ ├── store.go (PostgreSQL CRUD) │ +│ ├── enforcer.go (Policy-Enforcement) │ +│ ├── pii_detector.go (PII-Erkennung) │ +│ └── audit.go (Audit-Logging) │ +│ │ +│ MODIFIED: │ +│ ├── crawler/crawler.go (Whitelist-Check vor Fetch) │ +│ ├── pipeline/pipeline.go (PII-Filter nach Extract) │ +│ └── api/handlers/policy_handlers.go (Admin-API) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ PostgreSQL │ +│ NEW TABLES: │ +│ - source_policies (versionierte Policies) │ +│ - allowed_sources (Whitelist pro Bundesland) │ +│ - operation_permissions (Lookup/RAG/Training/Export Matrix) │ +│ - pii_rules (Regex/Keyword Blocklist) │ +│ - policy_audit_log (unveraenderlich) │ +│ - blocked_content_log (blockierte URLs fuer Audit) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. Datenmodell + +### 2.1 PostgreSQL Schema + +```sql +-- Policies (versioniert) +CREATE TABLE source_policies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + version INTEGER NOT NULL DEFAULT 1, + name VARCHAR(255) NOT NULL, + bundesland VARCHAR(2), -- NULL = Bundesebene/KMK + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW(), + approved_by UUID, + approved_at TIMESTAMP +); + +-- Whitelist +CREATE TABLE allowed_sources ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + policy_id UUID REFERENCES source_policies(id), + domain VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + license VARCHAR(50) NOT NULL, -- DL-DE-BY-2.0, CC-BY, §5 UrhG + legal_basis VARCHAR(100), + citation_template TEXT, + trust_boost DECIMAL(3,2) DEFAULT 0.50, + is_active BOOLEAN DEFAULT true +); + +-- Operations Matrix +CREATE TABLE operation_permissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + source_id UUID REFERENCES allowed_sources(id), + operation VARCHAR(50) NOT NULL, -- lookup, rag, training, export + is_allowed BOOLEAN NOT NULL, + requires_citation BOOLEAN DEFAULT false, + notes TEXT +); + +-- PII Blocklist +CREATE TABLE pii_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + rule_type VARCHAR(50) NOT NULL, -- regex, keyword + pattern TEXT NOT NULL, + severity VARCHAR(20) DEFAULT 'block', -- block, warn, redact + is_active BOOLEAN DEFAULT true +); + +-- Audit Log (immutable) +CREATE TABLE policy_audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + action VARCHAR(50) NOT NULL, + entity_type VARCHAR(50) NOT NULL, + entity_id UUID, + old_value JSONB, + new_value JSONB, + user_email VARCHAR(255), + created_at TIMESTAMP DEFAULT NOW() +); + +-- Blocked Content Log +CREATE TABLE blocked_content_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + url VARCHAR(2048) NOT NULL, + domain VARCHAR(255) NOT NULL, + block_reason VARCHAR(100) NOT NULL, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 2.2 Initial-Daten + +Datei: `edu-search-service/policies/bundeslaender.yaml` + +```yaml +federal: + name: "KMK & Bundesebene" + sources: + - domain: "kmk.org" + name: "Kultusministerkonferenz" + license: "§5 UrhG" + legal_basis: "Amtliche Werke (§5 UrhG)" + citation_template: "Quelle: KMK, {title}, {date}" + - domain: "bildungsserver.de" + name: "Deutscher Bildungsserver" + license: "DL-DE-BY-2.0" + +NI: + name: "Niedersachsen" + sources: + - domain: "nibis.de" + name: "NiBiS Bildungsserver" + license: "DL-DE-BY-2.0" + - domain: "mk.niedersachsen.de" + name: "Kultusministerium Niedersachsen" + license: "§5 UrhG" + - domain: "cuvo.nibis.de" + name: "Kerncurricula Niedersachsen" + license: "DL-DE-BY-2.0" + +BY: + name: "Bayern" + sources: + - domain: "km.bayern.de" + name: "Bayerisches Kultusministerium" + license: "§5 UrhG" + - domain: "isb.bayern.de" + name: "ISB Bayern" + license: "DL-DE-BY-2.0" + - domain: "lehrplanplus.bayern.de" + name: "LehrplanPLUS" + license: "DL-DE-BY-2.0" + +# Default Operations Matrix +default_operations: + lookup: + allowed: true + requires_citation: true + rag: + allowed: true + requires_citation: true + training: + allowed: false # VERBOTEN + export: + allowed: true + requires_citation: true + +# Default PII Rules +pii_rules: + - name: "Email Addresses" + type: "regex" + pattern: "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}" + severity: "block" + - name: "German Phone Numbers" + type: "regex" + pattern: "(?:\\+49|0)[\\s.-]?\\d{2,4}[\\s.-]?\\d{3,}[\\s.-]?\\d{2,}" + severity: "block" + - name: "IBAN" + type: "regex" + pattern: "DE\\d{2}\\s?\\d{4}\\s?\\d{4}\\s?\\d{4}\\s?\\d{4}\\s?\\d{2}" + severity: "block" +``` + +--- + +## 3. Backend Implementation + +### 3.1 Neue Dateien + +| Datei | Beschreibung | +|-------|--------------| +| `internal/policy/models.go` | Go Structs (SourcePolicy, AllowedSource, PIIRule, etc.) | +| `internal/policy/store.go` | PostgreSQL CRUD mit pgx | +| `internal/policy/enforcer.go` | `CheckSource()`, `CheckOperation()`, `DetectPII()` | +| `internal/policy/audit.go` | `LogChange()`, `LogBlocked()` | +| `internal/policy/pii_detector.go` | Regex-basierte PII-Erkennung | +| `internal/api/handlers/policy_handlers.go` | Admin-Endpoints | +| `migrations/005_source_policies.sql` | DB-Schema | +| `policies/bundeslaender.yaml` | Initial-Daten | + +### 3.2 API Endpoints + +``` +# Policies +GET /v1/admin/policies +POST /v1/admin/policies +PUT /v1/admin/policies/:id + +# Sources (Whitelist) +GET /v1/admin/sources +POST /v1/admin/sources +PUT /v1/admin/sources/:id +DELETE /v1/admin/sources/:id + +# Operations Matrix +GET /v1/admin/operations-matrix +PUT /v1/admin/operations/:id + +# PII Rules +GET /v1/admin/pii-rules +POST /v1/admin/pii-rules +PUT /v1/admin/pii-rules/:id +DELETE /v1/admin/pii-rules/:id +POST /v1/admin/pii-rules/test # Test gegen Sample-Text + +# Audit +GET /v1/admin/policy-audit?from=&to= +GET /v1/admin/blocked-content?from=&to= +GET /v1/admin/compliance-report # PDF/JSON Export + +# Live-Check +POST /v1/admin/check-compliance + Body: { "url": "...", "operation": "lookup" } +``` + +### 3.3 Crawler-Integration + +In `crawler/crawler.go`: +```go +func (c *Crawler) FetchWithPolicy(ctx context.Context, url string) (*FetchResult, error) { + // 1. Whitelist-Check + source, err := c.enforcer.CheckSource(ctx, url) + if err != nil || source == nil { + c.enforcer.LogBlocked(ctx, url, "not_whitelisted") + return nil, ErrNotWhitelisted + } + + // ... existing fetch ... + + // 2. PII-Check nach Fetch + piiMatches := c.enforcer.DetectPII(content) + if hasSeverity(piiMatches, "block") { + c.enforcer.LogBlocked(ctx, url, "pii_detected") + return nil, ErrPIIDetected + } + + return result, nil +} +``` + +--- + +## 4. Frontend Implementation + +### 4.1 Navigation Update + +In `lib/navigation.ts` unter `compliance` Kategorie hinzufuegen: + +```typescript +{ + id: 'source-policy', + name: 'Quellen-Policy', + href: '/compliance/source-policy', + description: 'Datenquellen & Compliance', + purpose: 'Whitelist zugelassener Datenquellen mit Operations-Matrix und PII-Blocklist.', + audience: ['DSB', 'Compliance Officer', 'Auditor'], + gdprArticles: ['Art. 5 (Rechtmaessigkeit)', 'Art. 6 (Rechtsgrundlage)'], +} +``` + +### 4.2 Seiten-Struktur + +``` +/app/(admin)/compliance/source-policy/ +├── page.tsx # Haupt-Dashboard mit Tabs +└── components/ + ├── SourcesTab.tsx # Whitelist-Tabelle mit CRUD + ├── OperationsMatrixTab.tsx # 4x4 Matrix + ├── PIIRulesTab.tsx # PII-Regeln mit Test-Funktion + └── AuditTab.tsx # Aenderungshistorie + Export +``` + +### 4.3 UI-Layout + +**Stats Cards (oben):** +- Aktive Policies +- Zugelassene Quellen +- Blockiert (heute) +- Compliance Score + +**Tabs:** +1. **Dashboard** - Uebersicht mit Quick-Stats +2. **Quellen** - Whitelist-Tabelle (Domain, Name, Lizenz, Status) +3. **Operations** - Matrix mit Lookup/RAG/Training/Export +4. **PII-Regeln** - Blocklist mit Test-Funktion +5. **Audit** - Aenderungshistorie mit PDF/JSON-Export + +**Pattern (aus audit-report/page.tsx):** +- Tab-Navigation: `bg-purple-600 text-white` fuer aktiv +- Status-Badges: `bg-green-100 text-green-700` fuer aktiv +- Tabellen: `hover:bg-slate-50` +- Info-Boxen: `bg-blue-50 border-blue-200` + +--- + +## 5. Betroffene Dateien + +### Neue Dateien erstellen: + +**Backend (edu-search-service):** +``` +internal/policy/models.go +internal/policy/store.go +internal/policy/enforcer.go +internal/policy/audit.go +internal/policy/pii_detector.go +internal/api/handlers/policy_handlers.go +migrations/005_source_policies.sql +policies/bundeslaender.yaml +``` + +**Frontend (admin-v2):** +``` +app/(admin)/compliance/source-policy/page.tsx +app/(admin)/compliance/source-policy/components/SourcesTab.tsx +app/(admin)/compliance/source-policy/components/OperationsMatrixTab.tsx +app/(admin)/compliance/source-policy/components/PIIRulesTab.tsx +app/(admin)/compliance/source-policy/components/AuditTab.tsx +``` + +### Bestehende Dateien aendern: + +``` +edu-search-service/cmd/server/main.go # Policy-Endpoints registrieren +edu-search-service/internal/crawler/crawler.go # Policy-Check hinzufuegen +edu-search-service/internal/pipeline/pipeline.go # PII-Filter +edu-search-service/internal/database/database.go # Migrations +admin-v2/lib/navigation.ts # source-policy Modul +``` + +--- + +## 6. Implementierungs-Reihenfolge + +### Phase 1: Datenbank & Models +1. Migration `005_source_policies.sql` erstellen +2. Go Models in `internal/policy/models.go` +3. Store-Layer in `internal/policy/store.go` +4. YAML-Loader fuer Initial-Daten + +### Phase 2: Policy Enforcer +1. `internal/policy/enforcer.go` - CheckSource, CheckOperation +2. `internal/policy/pii_detector.go` - Regex-basierte Erkennung +3. `internal/policy/audit.go` - Logging +4. Integration in Crawler + +### Phase 3: Admin API +1. `internal/api/handlers/policy_handlers.go` +2. Routen in main.go registrieren +3. API testen + +### Phase 4: Frontend +1. Hauptseite mit PagePurpose +2. SourcesTab mit Whitelist-CRUD +3. OperationsMatrixTab +4. PIIRulesTab mit Test-Funktion +5. AuditTab mit Export + +### Phase 5: Testing & Deployment +1. Unit Tests fuer Enforcer +2. Integration Tests fuer API +3. E2E Test fuer Frontend +4. Deployment auf Mac Mini + +--- + +## 7. Verifikation + +### Nach Backend (Phase 1-3): +```bash +# Migration ausfuehren +ssh macmini "cd /path/to/edu-search-service && go run ./cmd/migrate" + +# API testen +curl -X GET http://macmini:8088/v1/admin/policies +curl -X POST http://macmini:8088/v1/admin/check-compliance \ + -d '{"url":"https://nibis.de/test","operation":"lookup"}' +``` + +### Nach Frontend (Phase 4): +```bash +# Build & Deploy +rsync -avz admin-v2/ macmini:/path/to/admin-v2/ +ssh macmini "docker compose build admin-v2 && docker compose up -d admin-v2" + +# Testen +open https://macmini:3002/compliance/source-policy +``` + +### Auditor-Checkliste: +- [ ] Alle Quellen in Whitelist dokumentiert +- [ ] Operations-Matrix zeigt Training = VERBOTEN +- [ ] PII-Regeln aktiv und testbar +- [ ] Audit-Log zeigt alle Aenderungen +- [ ] Blocked-Content-Log zeigt blockierte URLs +- [ ] PDF/JSON-Export funktioniert + +--- + +## 8. KMK-Spezifika (§5 UrhG) + +**Rechtsgrundlage:** +- KMK-Beschluesse, Vereinbarungen, EPA sind amtliche Werke nach §5 UrhG +- Frei nutzbar, Attribution erforderlich + +**Zitierformat:** +``` +Quelle: KMK, [Titel des Beschlusses], [Datum] +Beispiel: Quelle: KMK, Bildungsstandards im Fach Deutsch, 2003 +``` + +**Zugelassene Dokumenttypen:** +- Beschluesse (Resolutions) +- Vereinbarungen (Agreements) +- EPA (Einheitliche Pruefungsanforderungen) +- Empfehlungen (Recommendations) + +**In Operations-Matrix:** +| Operation | Erlaubt | Hinweis | +|-----------|---------|---------| +| Lookup | Ja | Quelle anzeigen | +| RAG | Ja | Zitation im Output | +| Training | **NEIN** | VERBOTEN | +| Export | Ja | Attribution | + +--- + +## 9. Lizenzen + +| Lizenz | Name | Attribution | +|--------|------|-------------| +| DL-DE-BY-2.0 | Datenlizenz Deutschland | Ja | +| CC-BY | Creative Commons Attribution | Ja | +| CC-BY-SA | CC Attribution-ShareAlike | Ja + ShareAlike | +| CC0 | Public Domain | Nein | +| §5 UrhG | Amtliche Werke | Ja (Quelle) | + +--- + +## 10. Aktueller Stand + +**Phase 1: Datenbank & Models - ABGESCHLOSSEN** +- [x] Codebase-Exploration edu-search-service +- [x] Codebase-Exploration admin-v2 +- [x] Plan dokumentiert +- [x] Migration 005_source_policies.sql erstellen +- [x] Go Models implementieren (internal/policy/models.go) +- [x] Store-Layer implementieren (internal/policy/store.go) +- [x] Policy Enforcer implementieren (internal/policy/enforcer.go) +- [x] PII Detector implementieren (internal/policy/pii_detector.go) +- [x] Audit Logging implementieren (internal/policy/audit.go) +- [x] YAML Loader implementieren (internal/policy/loader.go) +- [x] Initial-Daten YAML erstellen (policies/bundeslaender.yaml) +- [x] Unit Tests schreiben (internal/policy/policy_test.go) +- [x] README aktualisieren + +**Phase 2: Admin API - AUSSTEHEND** +- [ ] API Handlers implementieren (policy_handlers.go) +- [ ] main.go aktualisieren +- [ ] API testen + +**Phase 3: Integration - AUSSTEHEND** +- [ ] Crawler-Integration +- [ ] Pipeline-Integration + +**Phase 4: Frontend - AUSSTEHEND** +- [ ] Frontend page.tsx erstellen +- [ ] SourcesTab Component +- [ ] OperationsMatrixTab Component +- [ ] PIIRulesTab Component +- [ ] AuditTab Component +- [ ] Navigation aktualisieren + +**Erstellte Dateien:** +``` +edu-search-service/ +├── migrations/ +│ └── 005_source_policies.sql # DB Schema (6 Tabellen) +├── internal/policy/ +│ ├── models.go # Datenstrukturen & Enums +│ ├── store.go # PostgreSQL CRUD +│ ├── enforcer.go # Policy-Enforcement +│ ├── pii_detector.go # PII-Erkennung +│ ├── audit.go # Audit-Logging +│ ├── loader.go # YAML-Loader +│ └── policy_test.go # Unit Tests +└── policies/ + └── bundeslaender.yaml # Initial-Daten (8 Bundeslaender) +``` diff --git a/admin-v2/Dockerfile b/admin-v2/Dockerfile index ce55b41..6fb0577 100644 --- a/admin-v2/Dockerfile +++ b/admin-v2/Dockerfile @@ -16,11 +16,13 @@ COPY . . ARG NEXT_PUBLIC_API_URL ARG NEXT_PUBLIC_OLD_ADMIN_URL ARG NEXT_PUBLIC_SDK_URL +ARG NEXT_PUBLIC_KLAUSUR_SERVICE_URL # Set environment variables for build ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL ENV NEXT_PUBLIC_OLD_ADMIN_URL=$NEXT_PUBLIC_OLD_ADMIN_URL ENV NEXT_PUBLIC_SDK_URL=$NEXT_PUBLIC_SDK_URL +ENV NEXT_PUBLIC_KLAUSUR_SERVICE_URL=$NEXT_PUBLIC_KLAUSUR_SERVICE_URL # Build the application RUN npm run build diff --git a/admin-v2/ai-compliance-sdk/Dockerfile b/admin-v2/ai-compliance-sdk/Dockerfile new file mode 100644 index 0000000..cc3c2d7 --- /dev/null +++ b/admin-v2/ai-compliance-sdk/Dockerfile @@ -0,0 +1,45 @@ +# Build stage +FROM golang:1.21-alpine AS builder + +WORKDIR /app + +# Install dependencies +RUN apk add --no-cache git ca-certificates + +# Copy go mod files +COPY go.mod go.sum* ./ + +# Download dependencies +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o sdk-backend ./cmd/server + +# Runtime stage +FROM alpine:3.19 + +WORKDIR /app + +# Install ca-certificates for HTTPS +RUN apk add --no-cache ca-certificates tzdata + +# Copy binary from builder +COPY --from=builder /app/sdk-backend . +COPY --from=builder /app/configs ./configs + +# Create non-root user +RUN adduser -D -g '' appuser +USER appuser + +# Expose port +EXPOSE 8085 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8085/health || exit 1 + +# Run the application +CMD ["./sdk-backend"] diff --git a/admin-v2/ai-compliance-sdk/cmd/server/main.go b/admin-v2/ai-compliance-sdk/cmd/server/main.go new file mode 100644 index 0000000..15f3e1a --- /dev/null +++ b/admin-v2/ai-compliance-sdk/cmd/server/main.go @@ -0,0 +1,160 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/breakpilot/ai-compliance-sdk/internal/api" + "github.com/breakpilot/ai-compliance-sdk/internal/db" + "github.com/breakpilot/ai-compliance-sdk/internal/llm" + "github.com/breakpilot/ai-compliance-sdk/internal/rag" + "github.com/gin-gonic/gin" + "github.com/joho/godotenv" +) + +func main() { + // Load environment variables + if err := godotenv.Load(); err != nil { + log.Println("No .env file found, using environment variables") + } + + // Get configuration from environment + port := getEnv("PORT", "8085") + dbURL := getEnv("DATABASE_URL", "postgres://localhost:5432/sdk_states?sslmode=disable") + qdrantURL := getEnv("QDRANT_URL", "http://localhost:6333") + anthropicKey := getEnv("ANTHROPIC_API_KEY", "") + + // Initialize database connection + dbPool, err := db.NewPostgresPool(dbURL) + if err != nil { + log.Printf("Warning: Database connection failed: %v", err) + // Continue without database - use in-memory fallback + } + + // Initialize RAG service + ragService, err := rag.NewService(qdrantURL) + if err != nil { + log.Printf("Warning: RAG service initialization failed: %v", err) + // Continue without RAG - will return empty results + } + + // Initialize LLM service + llmService := llm.NewService(anthropicKey) + + // Create Gin router + gin.SetMode(gin.ReleaseMode) + if os.Getenv("GIN_MODE") == "debug" { + gin.SetMode(gin.DebugMode) + } + + router := gin.Default() + + // CORS middleware + router.Use(corsMiddleware()) + + // Health check + router.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "healthy", + "timestamp": time.Now().UTC().Format(time.RFC3339), + "services": gin.H{ + "database": dbPool != nil, + "rag": ragService != nil, + "llm": anthropicKey != "", + }, + }) + }) + + // API routes + v1 := router.Group("/sdk/v1") + { + // State Management + stateHandler := api.NewStateHandler(dbPool) + v1.GET("/state/:tenantId", stateHandler.GetState) + v1.POST("/state", stateHandler.SaveState) + v1.DELETE("/state/:tenantId", stateHandler.DeleteState) + + // RAG Search + ragHandler := api.NewRAGHandler(ragService) + v1.GET("/rag/search", ragHandler.Search) + v1.GET("/rag/status", ragHandler.GetCorpusStatus) + v1.POST("/rag/index", ragHandler.IndexDocument) + + // Document Generation + generateHandler := api.NewGenerateHandler(llmService, ragService) + v1.POST("/generate/dsfa", generateHandler.GenerateDSFA) + v1.POST("/generate/tom", generateHandler.GenerateTOM) + v1.POST("/generate/vvt", generateHandler.GenerateVVT) + v1.POST("/generate/gutachten", generateHandler.GenerateGutachten) + + // Checkpoint Validation + checkpointHandler := api.NewCheckpointHandler() + v1.GET("/checkpoints", checkpointHandler.GetAll) + v1.POST("/checkpoints/validate", checkpointHandler.Validate) + } + + // Create server + srv := &http.Server{ + Addr: ":" + port, + Handler: router, + } + + // Graceful shutdown + go func() { + log.Printf("SDK Backend starting on port %s", port) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Failed to start server: %v", err) + } + }() + + // Wait for interrupt signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("Shutting down server...") + + // Give outstanding requests 5 seconds to complete + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + log.Fatal("Server forced to shutdown:", err) + } + + // Close database connection + if dbPool != nil { + dbPool.Close() + } + + log.Println("Server exited") +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +func corsMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With, If-Match, If-None-Match") + c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE") + c.Writer.Header().Set("Access-Control-Expose-Headers", "ETag, Last-Modified") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + + c.Next() + } +} diff --git a/admin-v2/ai-compliance-sdk/configs/config.yaml b/admin-v2/ai-compliance-sdk/configs/config.yaml new file mode 100644 index 0000000..182fe2c --- /dev/null +++ b/admin-v2/ai-compliance-sdk/configs/config.yaml @@ -0,0 +1,42 @@ +server: + port: 8085 + mode: release # debug, release, test + +database: + url: postgres://localhost:5432/sdk_states?sslmode=disable + max_connections: 10 + min_connections: 2 + +rag: + qdrant_url: http://localhost:6333 + collection: legal_corpus + embedding_model: BGE-M3 + top_k: 5 + +llm: + provider: anthropic # anthropic, openai + model: claude-3-5-sonnet-20241022 + max_tokens: 4096 + temperature: 0.3 + +cors: + allowed_origins: + - http://localhost:3000 + - http://localhost:3002 + - http://macmini:3000 + - http://macmini:3002 + allowed_methods: + - GET + - POST + - PUT + - DELETE + - OPTIONS + allowed_headers: + - Content-Type + - Authorization + - If-Match + - If-None-Match + +logging: + level: info # debug, info, warn, error + format: json diff --git a/admin-v2/ai-compliance-sdk/go.mod b/admin-v2/ai-compliance-sdk/go.mod new file mode 100644 index 0000000..8a833e4 --- /dev/null +++ b/admin-v2/ai-compliance-sdk/go.mod @@ -0,0 +1,11 @@ +module github.com/breakpilot/ai-compliance-sdk + +go 1.21 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/jackc/pgx/v5 v5.5.1 + github.com/joho/godotenv v1.5.1 + github.com/qdrant/go-client v1.7.0 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/admin-v2/ai-compliance-sdk/internal/api/checkpoint.go b/admin-v2/ai-compliance-sdk/internal/api/checkpoint.go new file mode 100644 index 0000000..4652754 --- /dev/null +++ b/admin-v2/ai-compliance-sdk/internal/api/checkpoint.go @@ -0,0 +1,327 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// Checkpoint represents a checkpoint definition +type Checkpoint struct { + ID string `json:"id"` + Step string `json:"step"` + Name string `json:"name"` + Type string `json:"type"` + BlocksProgress bool `json:"blocksProgress"` + RequiresReview string `json:"requiresReview"` + AutoValidate bool `json:"autoValidate"` + Description string `json:"description"` +} + +// CheckpointHandler handles checkpoint-related requests +type CheckpointHandler struct { + checkpoints map[string]Checkpoint +} + +// NewCheckpointHandler creates a new checkpoint handler +func NewCheckpointHandler() *CheckpointHandler { + return &CheckpointHandler{ + checkpoints: initCheckpoints(), + } +} + +func initCheckpoints() map[string]Checkpoint { + return map[string]Checkpoint{ + "CP-UC": { + ID: "CP-UC", + Step: "use-case-workshop", + Name: "Use Case Erfassung", + Type: "REQUIRED", + BlocksProgress: true, + RequiresReview: "NONE", + AutoValidate: true, + Description: "Mindestens ein Use Case muss erfasst sein", + }, + "CP-SCAN": { + ID: "CP-SCAN", + Step: "screening", + Name: "System Screening", + Type: "REQUIRED", + BlocksProgress: true, + RequiresReview: "NONE", + AutoValidate: true, + Description: "SBOM und Security Scan müssen abgeschlossen sein", + }, + "CP-MOD": { + ID: "CP-MOD", + Step: "modules", + Name: "Modul-Zuweisung", + Type: "REQUIRED", + BlocksProgress: true, + RequiresReview: "NONE", + AutoValidate: true, + Description: "Mindestens ein Compliance-Modul muss zugewiesen sein", + }, + "CP-REQ": { + ID: "CP-REQ", + Step: "requirements", + Name: "Anforderungen", + Type: "REQUIRED", + BlocksProgress: true, + RequiresReview: "NONE", + AutoValidate: true, + Description: "Anforderungen müssen aus Regulierungen abgeleitet sein", + }, + "CP-CTRL": { + ID: "CP-CTRL", + Step: "controls", + Name: "Controls", + Type: "REQUIRED", + BlocksProgress: true, + RequiresReview: "NONE", + AutoValidate: true, + Description: "Controls müssen den Anforderungen zugeordnet sein", + }, + "CP-EVI": { + ID: "CP-EVI", + Step: "evidence", + Name: "Nachweise", + Type: "REQUIRED", + BlocksProgress: true, + RequiresReview: "NONE", + AutoValidate: true, + Description: "Nachweise für Controls müssen dokumentiert sein", + }, + "CP-CHK": { + ID: "CP-CHK", + Step: "audit-checklist", + Name: "Audit Checklist", + Type: "REQUIRED", + BlocksProgress: true, + RequiresReview: "NONE", + AutoValidate: true, + Description: "Prüfliste muss generiert und überprüft sein", + }, + "CP-RISK": { + ID: "CP-RISK", + Step: "risks", + Name: "Risikobewertung", + Type: "REQUIRED", + BlocksProgress: true, + RequiresReview: "NONE", + AutoValidate: true, + Description: "Kritische Risiken müssen Mitigationsmaßnahmen haben", + }, + "CP-AI": { + ID: "CP-AI", + Step: "ai-act", + Name: "AI Act Klassifizierung", + Type: "REQUIRED", + BlocksProgress: true, + RequiresReview: "LEGAL", + AutoValidate: false, + Description: "KI-System muss klassifiziert sein", + }, + "CP-OBL": { + ID: "CP-OBL", + Step: "obligations", + Name: "Pflichtenübersicht", + Type: "REQUIRED", + BlocksProgress: true, + RequiresReview: "NONE", + AutoValidate: true, + Description: "Rechtliche Pflichten müssen identifiziert sein", + }, + "CP-DSFA": { + ID: "CP-DSFA", + Step: "dsfa", + Name: "DSFA", + Type: "RECOMMENDED", + BlocksProgress: false, + RequiresReview: "DSB", + AutoValidate: false, + Description: "Datenschutz-Folgenabschätzung muss erstellt und genehmigt sein", + }, + "CP-TOM": { + ID: "CP-TOM", + Step: "tom", + Name: "TOMs", + Type: "REQUIRED", + BlocksProgress: true, + RequiresReview: "NONE", + AutoValidate: true, + Description: "Technische und organisatorische Maßnahmen müssen definiert sein", + }, + "CP-VVT": { + ID: "CP-VVT", + Step: "vvt", + Name: "Verarbeitungsverzeichnis", + Type: "REQUIRED", + BlocksProgress: true, + RequiresReview: "DSB", + AutoValidate: false, + Description: "Verarbeitungsverzeichnis muss vollständig sein", + }, + } +} + +// GetAll returns all checkpoint definitions +func (h *CheckpointHandler) GetAll(c *gin.Context) { + tenantID := c.Query("tenantId") + + checkpointList := make([]Checkpoint, 0, len(h.checkpoints)) + for _, cp := range h.checkpoints { + checkpointList = append(checkpointList, cp) + } + + SuccessResponse(c, gin.H{ + "tenantId": tenantID, + "checkpoints": checkpointList, + }) +} + +// Validate validates a specific checkpoint +func (h *CheckpointHandler) Validate(c *gin.Context) { + var req struct { + TenantID string `json:"tenantId" binding:"required"` + CheckpointID string `json:"checkpointId" binding:"required"` + Data map[string]interface{} `json:"data"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST") + return + } + + checkpoint, ok := h.checkpoints[req.CheckpointID] + if !ok { + ErrorResponse(c, http.StatusNotFound, "Checkpoint not found", "CHECKPOINT_NOT_FOUND") + return + } + + // Perform validation based on checkpoint ID + result := h.validateCheckpoint(checkpoint, req.Data) + + SuccessResponse(c, result) +} + +func (h *CheckpointHandler) validateCheckpoint(checkpoint Checkpoint, data map[string]interface{}) CheckpointResult { + result := CheckpointResult{ + CheckpointID: checkpoint.ID, + Passed: true, + ValidatedAt: now(), + ValidatedBy: "SYSTEM", + Errors: []ValidationError{}, + Warnings: []ValidationError{}, + } + + // Validation logic based on checkpoint + switch checkpoint.ID { + case "CP-UC": + useCases, _ := data["useCases"].([]interface{}) + if len(useCases) == 0 { + result.Passed = false + result.Errors = append(result.Errors, ValidationError{ + RuleID: "uc-min-count", + Field: "useCases", + Message: "Mindestens ein Use Case muss erstellt werden", + Severity: "ERROR", + }) + } + + case "CP-SCAN": + screening, _ := data["screening"].(map[string]interface{}) + if screening == nil || screening["status"] != "COMPLETED" { + result.Passed = false + result.Errors = append(result.Errors, ValidationError{ + RuleID: "scan-complete", + Field: "screening", + Message: "Security Scan muss abgeschlossen sein", + Severity: "ERROR", + }) + } + + case "CP-MOD": + modules, _ := data["modules"].([]interface{}) + if len(modules) == 0 { + result.Passed = false + result.Errors = append(result.Errors, ValidationError{ + RuleID: "mod-min-count", + Field: "modules", + Message: "Mindestens ein Modul muss zugewiesen werden", + Severity: "ERROR", + }) + } + + case "CP-RISK": + risks, _ := data["risks"].([]interface{}) + criticalUnmitigated := 0 + for _, r := range risks { + risk, ok := r.(map[string]interface{}) + if !ok { + continue + } + severity, _ := risk["severity"].(string) + if severity == "CRITICAL" || severity == "HIGH" { + mitigations, _ := risk["mitigation"].([]interface{}) + if len(mitigations) == 0 { + criticalUnmitigated++ + } + } + } + if criticalUnmitigated > 0 { + result.Passed = false + result.Errors = append(result.Errors, ValidationError{ + RuleID: "critical-risks-mitigated", + Field: "risks", + Message: "Kritische Risiken ohne Mitigationsmaßnahmen gefunden", + Severity: "ERROR", + }) + } + + case "CP-DSFA": + dsfa, _ := data["dsfa"].(map[string]interface{}) + if dsfa == nil { + result.Passed = false + result.Errors = append(result.Errors, ValidationError{ + RuleID: "dsfa-exists", + Field: "dsfa", + Message: "DSFA muss erstellt werden", + Severity: "ERROR", + }) + } else if dsfa["status"] != "APPROVED" { + result.Warnings = append(result.Warnings, ValidationError{ + RuleID: "dsfa-approved", + Field: "dsfa", + Message: "DSFA sollte vom DSB genehmigt werden", + Severity: "WARNING", + }) + } + + case "CP-TOM": + toms, _ := data["toms"].([]interface{}) + if len(toms) == 0 { + result.Passed = false + result.Errors = append(result.Errors, ValidationError{ + RuleID: "tom-min-count", + Field: "toms", + Message: "Mindestens eine TOM muss definiert werden", + Severity: "ERROR", + }) + } + + case "CP-VVT": + vvt, _ := data["vvt"].([]interface{}) + if len(vvt) == 0 { + result.Passed = false + result.Errors = append(result.Errors, ValidationError{ + RuleID: "vvt-min-count", + Field: "vvt", + Message: "Mindestens eine Verarbeitungstätigkeit muss dokumentiert werden", + Severity: "ERROR", + }) + } + } + + return result +} diff --git a/admin-v2/ai-compliance-sdk/internal/api/generate.go b/admin-v2/ai-compliance-sdk/internal/api/generate.go new file mode 100644 index 0000000..7f7d8c9 --- /dev/null +++ b/admin-v2/ai-compliance-sdk/internal/api/generate.go @@ -0,0 +1,365 @@ +package api + +import ( + "net/http" + + "github.com/breakpilot/ai-compliance-sdk/internal/llm" + "github.com/breakpilot/ai-compliance-sdk/internal/rag" + "github.com/gin-gonic/gin" +) + +// GenerateHandler handles document generation requests +type GenerateHandler struct { + llmService *llm.Service + ragService *rag.Service +} + +// NewGenerateHandler creates a new generate handler +func NewGenerateHandler(llmService *llm.Service, ragService *rag.Service) *GenerateHandler { + return &GenerateHandler{ + llmService: llmService, + ragService: ragService, + } +} + +// GenerateDSFA generates a Data Protection Impact Assessment +func (h *GenerateHandler) GenerateDSFA(c *gin.Context) { + var req GenerateRequest + if err := c.ShouldBindJSON(&req); err != nil { + ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST") + return + } + + // Get RAG context if requested + var ragSources []SearchResult + if req.UseRAG && h.ragService != nil { + query := req.RAGQuery + if query == "" { + query = "DSFA Datenschutz-Folgenabschätzung Anforderungen" + } + results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "regulation:DSGVO") + for _, r := range results { + ragSources = append(ragSources, SearchResult{ + ID: r.ID, + Content: r.Content, + Source: r.Source, + Score: r.Score, + Metadata: r.Metadata, + }) + } + } + + // Generate DSFA content + content, tokensUsed, err := h.llmService.GenerateDSFA(c.Request.Context(), req.Context, ragSources) + if err != nil { + // Return mock content if LLM fails + content = h.getMockDSFA(req.Context) + tokensUsed = 0 + } + + SuccessResponse(c, GenerateResponse{ + Content: content, + GeneratedAt: now(), + Model: h.llmService.GetModel(), + TokensUsed: tokensUsed, + RAGSources: ragSources, + Confidence: 0.85, + }) +} + +// GenerateTOM generates Technical and Organizational Measures +func (h *GenerateHandler) GenerateTOM(c *gin.Context) { + var req GenerateRequest + if err := c.ShouldBindJSON(&req); err != nil { + ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST") + return + } + + // Get RAG context if requested + var ragSources []SearchResult + if req.UseRAG && h.ragService != nil { + query := req.RAGQuery + if query == "" { + query = "technische organisatorische Maßnahmen TOM Datenschutz" + } + results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "") + for _, r := range results { + ragSources = append(ragSources, SearchResult{ + ID: r.ID, + Content: r.Content, + Source: r.Source, + Score: r.Score, + Metadata: r.Metadata, + }) + } + } + + // Generate TOM content + content, tokensUsed, err := h.llmService.GenerateTOM(c.Request.Context(), req.Context, ragSources) + if err != nil { + content = h.getMockTOM(req.Context) + tokensUsed = 0 + } + + SuccessResponse(c, GenerateResponse{ + Content: content, + GeneratedAt: now(), + Model: h.llmService.GetModel(), + TokensUsed: tokensUsed, + RAGSources: ragSources, + Confidence: 0.82, + }) +} + +// GenerateVVT generates Processing Activity Register +func (h *GenerateHandler) GenerateVVT(c *gin.Context) { + var req GenerateRequest + if err := c.ShouldBindJSON(&req); err != nil { + ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST") + return + } + + // Get RAG context if requested + var ragSources []SearchResult + if req.UseRAG && h.ragService != nil { + query := req.RAGQuery + if query == "" { + query = "Verarbeitungsverzeichnis Art. 30 DSGVO" + } + results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "regulation:DSGVO") + for _, r := range results { + ragSources = append(ragSources, SearchResult{ + ID: r.ID, + Content: r.Content, + Source: r.Source, + Score: r.Score, + Metadata: r.Metadata, + }) + } + } + + // Generate VVT content + content, tokensUsed, err := h.llmService.GenerateVVT(c.Request.Context(), req.Context, ragSources) + if err != nil { + content = h.getMockVVT(req.Context) + tokensUsed = 0 + } + + SuccessResponse(c, GenerateResponse{ + Content: content, + GeneratedAt: now(), + Model: h.llmService.GetModel(), + TokensUsed: tokensUsed, + RAGSources: ragSources, + Confidence: 0.88, + }) +} + +// GenerateGutachten generates an expert opinion/assessment +func (h *GenerateHandler) GenerateGutachten(c *gin.Context) { + var req GenerateRequest + if err := c.ShouldBindJSON(&req); err != nil { + ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST") + return + } + + // Get RAG context if requested + var ragSources []SearchResult + if req.UseRAG && h.ragService != nil { + query := req.RAGQuery + if query == "" { + query = "Compliance Bewertung Gutachten" + } + results, _ := h.ragService.Search(c.Request.Context(), query, 5, "legal_corpus", "") + for _, r := range results { + ragSources = append(ragSources, SearchResult{ + ID: r.ID, + Content: r.Content, + Source: r.Source, + Score: r.Score, + Metadata: r.Metadata, + }) + } + } + + // Generate Gutachten content + content, tokensUsed, err := h.llmService.GenerateGutachten(c.Request.Context(), req.Context, ragSources) + if err != nil { + content = h.getMockGutachten(req.Context) + tokensUsed = 0 + } + + SuccessResponse(c, GenerateResponse{ + Content: content, + GeneratedAt: now(), + Model: h.llmService.GetModel(), + TokensUsed: tokensUsed, + RAGSources: ragSources, + Confidence: 0.80, + }) +} + +// Mock content generators for when LLM is not available +func (h *GenerateHandler) getMockDSFA(context map[string]interface{}) string { + return `# Datenschutz-Folgenabschätzung (DSFA) + +## 1. Systematische Beschreibung der Verarbeitungsvorgänge + +Die geplante Verarbeitung umfasst die Analyse von Kundendaten mittels KI-gestützter Systeme zur Verbesserung der Servicequalität und Personalisierung von Angeboten. + +### Verarbeitungszwecke: +- Kundensegmentierung und Analyse des Nutzerverhaltens +- Personalisierte Empfehlungen +- Optimierung von Geschäftsprozessen + +### Rechtsgrundlage: +- Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse) +- Alternativ: Art. 6 Abs. 1 lit. a DSGVO (Einwilligung) + +## 2. Bewertung der Notwendigkeit und Verhältnismäßigkeit + +Die Verarbeitung ist für die genannten Zwecke erforderlich und verhältnismäßig. Alternative Maßnahmen wurden geprüft, jedoch sind diese weniger effektiv. + +## 3. Risikobewertung + +### Identifizierte Risiken: +| Risiko | Eintrittswahrscheinlichkeit | Schwere | Maßnahmen | +|--------|---------------------------|---------|-----------| +| Unbefugter Zugriff | Mittel | Hoch | Verschlüsselung, Zugangskontrolle | +| Profilbildung | Hoch | Mittel | Anonymisierung, Einwilligung | +| Datenverlust | Niedrig | Hoch | Backup, Redundanz | + +## 4. Maßnahmen zur Risikominderung + +- Implementierung von Verschlüsselung (AES-256) +- Strenge Zugriffskontrollen nach dem Least-Privilege-Prinzip +- Regelmäßige Datenschutz-Schulungen +- Audit-Logging aller Zugriffe + +## 5. Stellungnahme des Datenschutzbeauftragten + +[Hier Stellungnahme einfügen] + +## 6. Dokumentation der Konsultation + +Erstellt am: ${new Date().toISOString()} +Status: ENTWURF +` +} + +func (h *GenerateHandler) getMockTOM(context map[string]interface{}) string { + return `# Technische und Organisatorische Maßnahmen (TOMs) + +## 1. Vertraulichkeit (Art. 32 Abs. 1 lit. b DSGVO) + +### 1.1 Zutrittskontrolle +- Alarmanlage +- Chipkarten-/Transponder-System +- Videoüberwachung der Eingänge +- Besuchererfassung und -begleitung + +### 1.2 Zugangskontrolle +- Passwort-Richtlinie (min. 12 Zeichen, Komplexitätsanforderungen) +- Multi-Faktor-Authentifizierung +- Automatische Bildschirmsperre +- VPN für Remote-Zugriffe + +### 1.3 Zugriffskontrolle +- Rollenbasiertes Berechtigungskonzept +- Need-to-know-Prinzip +- Regelmäßige Überprüfung der Zugriffsrechte +- Protokollierung aller Zugriffe + +## 2. Integrität (Art. 32 Abs. 1 lit. b DSGVO) + +### 2.1 Weitergabekontrolle +- Transportverschlüsselung (TLS 1.3) +- Ende-zu-Ende-Verschlüsselung für sensible Daten +- Sichere E-Mail-Kommunikation (S/MIME) + +### 2.2 Eingabekontrolle +- Protokollierung aller Datenänderungen +- Benutzeridentifikation bei Änderungen +- Audit-Trail für alle Transaktionen + +## 3. Verfügbarkeit (Art. 32 Abs. 1 lit. c DSGVO) + +### 3.1 Verfügbarkeitskontrolle +- Tägliche Backups +- Georedundante Datenspeicherung +- USV-Anlage +- Notfallplan + +### 3.2 Wiederherstellung +- Dokumentierte Wiederherstellungsverfahren +- Regelmäßige Backup-Tests +- Maximale Wiederherstellungszeit: 4 Stunden + +## 4. Belastbarkeit (Art. 32 Abs. 1 lit. b DSGVO) + +- Lastverteilung +- DDoS-Schutz +- Skalierbare Infrastruktur +` +} + +func (h *GenerateHandler) getMockVVT(context map[string]interface{}) string { + return `# Verzeichnis der Verarbeitungstätigkeiten (Art. 30 DSGVO) + +## Verarbeitungstätigkeit: Kundenanalyse und Personalisierung + +### Angaben nach Art. 30 Abs. 1 DSGVO: + +| Feld | Inhalt | +|------|--------| +| **Name des Verantwortlichen** | [Unternehmensname] | +| **Kontaktdaten** | [Adresse, E-Mail, Telefon] | +| **Datenschutzbeauftragter** | [Name, Kontakt] | +| **Zweck der Verarbeitung** | Kundensegmentierung, Personalisierung, Serviceoptimierung | +| **Kategorien betroffener Personen** | Kunden, Interessenten | +| **Kategorien personenbezogener Daten** | Kontaktdaten, Nutzungsdaten, Transaktionsdaten | +| **Kategorien von Empfängern** | Interne Abteilungen, IT-Dienstleister | +| **Drittlandtransfer** | Nein / Ja (mit Angabe der Garantien) | +| **Löschfristen** | 3 Jahre nach letzter Aktivität | +| **TOM-Referenz** | Siehe TOM-Dokument v1.0 | + +### Rechtsgrundlage: +Art. 6 Abs. 1 lit. f DSGVO - Berechtigtes Interesse + +### Dokumentation: +- Erstellt: ${new Date().toISOString()} +- Letzte Aktualisierung: ${new Date().toISOString()} +- Version: 1.0 +` +} + +func (h *GenerateHandler) getMockGutachten(context map[string]interface{}) string { + return `# Compliance-Gutachten + +## Zusammenfassung + +Das geprüfte KI-System erfüllt die wesentlichen Anforderungen der DSGVO und des AI Acts. Es wurden jedoch Optimierungspotenziale identifiziert. + +## Prüfungsumfang + +- DSGVO-Konformität +- AI Act Compliance +- NIS2-Anforderungen + +## Bewertungsergebnis + +| Bereich | Bewertung | Handlungsbedarf | +|---------|-----------|-----------------| +| Datenschutz | Gut | Gering | +| KI-Risikoeinstufung | Erfüllt | Keiner | +| Cybersicherheit | Befriedigend | Mittel | + +## Empfehlungen + +1. Verstärkung der Dokumentation +2. Regelmäßige Audits einplanen +3. Schulungsmaßnahmen erweitern + +Erstellt am: ${new Date().toISOString()} +` +} diff --git a/admin-v2/ai-compliance-sdk/internal/api/rag.go b/admin-v2/ai-compliance-sdk/internal/api/rag.go new file mode 100644 index 0000000..286a888 --- /dev/null +++ b/admin-v2/ai-compliance-sdk/internal/api/rag.go @@ -0,0 +1,182 @@ +package api + +import ( + "net/http" + "strconv" + + "github.com/breakpilot/ai-compliance-sdk/internal/rag" + "github.com/gin-gonic/gin" +) + +// RAGHandler handles RAG search requests +type RAGHandler struct { + ragService *rag.Service +} + +// NewRAGHandler creates a new RAG handler +func NewRAGHandler(ragService *rag.Service) *RAGHandler { + return &RAGHandler{ + ragService: ragService, + } +} + +// Search performs semantic search on the legal corpus +func (h *RAGHandler) Search(c *gin.Context) { + query := c.Query("q") + if query == "" { + ErrorResponse(c, http.StatusBadRequest, "Query parameter 'q' is required", "MISSING_QUERY") + return + } + + topK := 5 + if topKStr := c.Query("top_k"); topKStr != "" { + if parsed, err := strconv.Atoi(topKStr); err == nil && parsed > 0 { + topK = parsed + } + } + + collection := c.DefaultQuery("collection", "legal_corpus") + filter := c.Query("filter") // e.g., "regulation:DSGVO" or "category:ai_act" + + // Check if RAG service is available + if h.ragService == nil { + // Return mock data when RAG is not available + SuccessResponse(c, gin.H{ + "query": query, + "topK": topK, + "results": h.getMockResults(query), + "source": "mock", + }) + return + } + + results, err := h.ragService.Search(c.Request.Context(), query, topK, collection, filter) + if err != nil { + ErrorResponse(c, http.StatusInternalServerError, "Search failed: "+err.Error(), "SEARCH_FAILED") + return + } + + SuccessResponse(c, gin.H{ + "query": query, + "topK": topK, + "results": results, + "source": "qdrant", + }) +} + +// GetCorpusStatus returns the status of the legal corpus +func (h *RAGHandler) GetCorpusStatus(c *gin.Context) { + if h.ragService == nil { + SuccessResponse(c, gin.H{ + "status": "unavailable", + "collections": []string{}, + "documents": 0, + }) + return + } + + status, err := h.ragService.GetCorpusStatus(c.Request.Context()) + if err != nil { + ErrorResponse(c, http.StatusInternalServerError, "Failed to get corpus status", "STATUS_FAILED") + return + } + + SuccessResponse(c, status) +} + +// IndexDocument indexes a new document into the corpus +func (h *RAGHandler) IndexDocument(c *gin.Context) { + var req struct { + Collection string `json:"collection" binding:"required"` + ID string `json:"id" binding:"required"` + Content string `json:"content" binding:"required"` + Metadata map[string]string `json:"metadata"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST") + return + } + + if h.ragService == nil { + ErrorResponse(c, http.StatusServiceUnavailable, "RAG service not available", "SERVICE_UNAVAILABLE") + return + } + + err := h.ragService.IndexDocument(c.Request.Context(), req.Collection, req.ID, req.Content, req.Metadata) + if err != nil { + ErrorResponse(c, http.StatusInternalServerError, "Failed to index document: "+err.Error(), "INDEX_FAILED") + return + } + + SuccessResponse(c, gin.H{ + "indexed": true, + "id": req.ID, + "collection": req.Collection, + "indexedAt": now(), + }) +} + +// getMockResults returns mock search results for development +func (h *RAGHandler) getMockResults(query string) []SearchResult { + // Simplified mock results based on common compliance queries + results := []SearchResult{ + { + ID: "dsgvo-art-5", + Content: "Art. 5 DSGVO - Grundsätze für die Verarbeitung personenbezogener Daten: Personenbezogene Daten müssen auf rechtmäßige Weise, nach Treu und Glauben und in einer für die betroffene Person nachvollziehbaren Weise verarbeitet werden.", + Source: "DSGVO", + Score: 0.95, + Metadata: map[string]string{ + "article": "5", + "regulation": "DSGVO", + "category": "grundsaetze", + }, + }, + { + ID: "dsgvo-art-6", + Content: "Art. 6 DSGVO - Rechtmäßigkeit der Verarbeitung: Die Verarbeitung ist nur rechtmäßig, wenn mindestens eine der folgenden Bedingungen erfüllt ist: Einwilligung, Vertragserfüllung, rechtliche Verpflichtung, lebenswichtige Interessen, öffentliche Aufgabe, berechtigtes Interesse.", + Source: "DSGVO", + Score: 0.89, + Metadata: map[string]string{ + "article": "6", + "regulation": "DSGVO", + "category": "rechtsgrundlage", + }, + }, + { + ID: "ai-act-art-6", + Content: "Art. 6 AI Act - Klassifizierungsregeln für Hochrisiko-KI-Systeme: Ein KI-System gilt als Hochrisiko-System, wenn es als Sicherheitskomponente eines Produkts verwendet wird oder selbst ein Produkt ist, das unter die in Anhang II aufgeführten Harmonisierungsrechtsvorschriften fällt.", + Source: "AI Act", + Score: 0.85, + Metadata: map[string]string{ + "article": "6", + "regulation": "AI_ACT", + "category": "hochrisiko", + }, + }, + { + ID: "nis2-art-21", + Content: "Art. 21 NIS2 - Risikomanagementmaßnahmen: Wesentliche und wichtige Einrichtungen müssen geeignete und verhältnismäßige technische, operative und organisatorische Maßnahmen ergreifen, um die Risiken für die Sicherheit der Netz- und Informationssysteme zu beherrschen.", + Source: "NIS2", + Score: 0.78, + Metadata: map[string]string{ + "article": "21", + "regulation": "NIS2", + "category": "risikomanagement", + }, + }, + { + ID: "dsgvo-art-35", + Content: "Art. 35 DSGVO - Datenschutz-Folgenabschätzung: Hat eine Form der Verarbeitung, insbesondere bei Verwendung neuer Technologien, aufgrund der Art, des Umfangs, der Umstände und der Zwecke der Verarbeitung voraussichtlich ein hohes Risiko für die Rechte und Freiheiten natürlicher Personen zur Folge, so führt der Verantwortliche vorab eine Abschätzung der Folgen der vorgesehenen Verarbeitungsvorgänge für den Schutz personenbezogener Daten durch.", + Source: "DSGVO", + Score: 0.75, + Metadata: map[string]string{ + "article": "35", + "regulation": "DSGVO", + "category": "dsfa", + }, + }, + } + + return results +} diff --git a/admin-v2/ai-compliance-sdk/internal/api/router.go b/admin-v2/ai-compliance-sdk/internal/api/router.go new file mode 100644 index 0000000..eb2db0e --- /dev/null +++ b/admin-v2/ai-compliance-sdk/internal/api/router.go @@ -0,0 +1,96 @@ +package api + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +// Response represents a standard API response +type Response struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Error string `json:"error,omitempty"` + Code string `json:"code,omitempty"` +} + +// SuccessResponse creates a success response +func SuccessResponse(c *gin.Context, data interface{}) { + c.JSON(http.StatusOK, Response{ + Success: true, + Data: data, + }) +} + +// ErrorResponse creates an error response +func ErrorResponse(c *gin.Context, status int, err string, code string) { + c.JSON(status, Response{ + Success: false, + Error: err, + Code: code, + }) +} + +// StateData represents state response data +type StateData struct { + TenantID string `json:"tenantId"` + State interface{} `json:"state"` + Version int `json:"version"` + LastModified string `json:"lastModified"` +} + +// ValidationError represents a validation error +type ValidationError struct { + RuleID string `json:"ruleId"` + Field string `json:"field"` + Message string `json:"message"` + Severity string `json:"severity"` +} + +// CheckpointResult represents checkpoint validation result +type CheckpointResult struct { + CheckpointID string `json:"checkpointId"` + Passed bool `json:"passed"` + ValidatedAt string `json:"validatedAt"` + ValidatedBy string `json:"validatedBy"` + Errors []ValidationError `json:"errors"` + Warnings []ValidationError `json:"warnings"` +} + +// SearchResult represents a RAG search result +type SearchResult struct { + ID string `json:"id"` + Content string `json:"content"` + Source string `json:"source"` + Score float64 `json:"score"` + Metadata map[string]string `json:"metadata,omitempty"` + Highlights []string `json:"highlights,omitempty"` +} + +// GenerateRequest represents a document generation request +type GenerateRequest struct { + TenantID string `json:"tenantId" binding:"required"` + Context map[string]interface{} `json:"context"` + Template string `json:"template,omitempty"` + Language string `json:"language,omitempty"` + UseRAG bool `json:"useRag"` + RAGQuery string `json:"ragQuery,omitempty"` + MaxTokens int `json:"maxTokens,omitempty"` + Temperature float64 `json:"temperature,omitempty"` +} + +// GenerateResponse represents a document generation response +type GenerateResponse struct { + Content string `json:"content"` + GeneratedAt string `json:"generatedAt"` + Model string `json:"model"` + TokensUsed int `json:"tokensUsed"` + RAGSources []SearchResult `json:"ragSources,omitempty"` + Confidence float64 `json:"confidence,omitempty"` +} + +// Timestamps helper +func now() string { + return time.Now().UTC().Format(time.RFC3339) +} diff --git a/admin-v2/ai-compliance-sdk/internal/api/state.go b/admin-v2/ai-compliance-sdk/internal/api/state.go new file mode 100644 index 0000000..2980d98 --- /dev/null +++ b/admin-v2/ai-compliance-sdk/internal/api/state.go @@ -0,0 +1,171 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/breakpilot/ai-compliance-sdk/internal/db" + "github.com/gin-gonic/gin" +) + +// StateHandler handles state management requests +type StateHandler struct { + dbPool *db.Pool + memStore *db.InMemoryStore +} + +// NewStateHandler creates a new state handler +func NewStateHandler(dbPool *db.Pool) *StateHandler { + return &StateHandler{ + dbPool: dbPool, + memStore: db.NewInMemoryStore(), + } +} + +// GetState retrieves state for a tenant +func (h *StateHandler) GetState(c *gin.Context) { + tenantID := c.Param("tenantId") + if tenantID == "" { + ErrorResponse(c, http.StatusBadRequest, "tenantId is required", "MISSING_TENANT_ID") + return + } + + var state *db.SDKState + var err error + + // Try database first, fall back to in-memory + if h.dbPool != nil { + state, err = h.dbPool.GetState(c.Request.Context(), tenantID) + } else { + state, err = h.memStore.GetState(tenantID) + } + + if err != nil { + ErrorResponse(c, http.StatusNotFound, "State not found", "STATE_NOT_FOUND") + return + } + + // Generate ETag + etag := generateETag(state.Version, state.UpdatedAt.String()) + + // Check If-None-Match header + if c.GetHeader("If-None-Match") == etag { + c.Status(http.StatusNotModified) + return + } + + // Parse state JSON + var stateData interface{} + if err := json.Unmarshal(state.State, &stateData); err != nil { + stateData = state.State + } + + c.Header("ETag", etag) + c.Header("Last-Modified", state.UpdatedAt.Format("Mon, 02 Jan 2006 15:04:05 GMT")) + c.Header("Cache-Control", "private, no-cache") + + SuccessResponse(c, StateData{ + TenantID: state.TenantID, + State: stateData, + Version: state.Version, + LastModified: state.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + }) +} + +// SaveState saves state for a tenant +func (h *StateHandler) SaveState(c *gin.Context) { + var req struct { + TenantID string `json:"tenantId" binding:"required"` + UserID string `json:"userId"` + State json.RawMessage `json:"state" binding:"required"` + Version *int `json:"version"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + ErrorResponse(c, http.StatusBadRequest, err.Error(), "INVALID_REQUEST") + return + } + + // Check If-Match header for optimistic locking + var expectedVersion *int + if ifMatch := c.GetHeader("If-Match"); ifMatch != "" { + v, err := strconv.Atoi(ifMatch) + if err == nil { + expectedVersion = &v + } + } else if req.Version != nil { + expectedVersion = req.Version + } + + var state *db.SDKState + var err error + + // Try database first, fall back to in-memory + if h.dbPool != nil { + state, err = h.dbPool.SaveState(c.Request.Context(), req.TenantID, req.UserID, req.State, expectedVersion) + } else { + state, err = h.memStore.SaveState(req.TenantID, req.UserID, req.State, expectedVersion) + } + + if err != nil { + if err.Error() == "version conflict" { + ErrorResponse(c, http.StatusConflict, "Version conflict. State was modified by another request.", "VERSION_CONFLICT") + return + } + ErrorResponse(c, http.StatusInternalServerError, "Failed to save state", "SAVE_FAILED") + return + } + + // Generate ETag + etag := generateETag(state.Version, state.UpdatedAt.String()) + + // Parse state JSON + var stateData interface{} + if err := json.Unmarshal(state.State, &stateData); err != nil { + stateData = state.State + } + + c.Header("ETag", etag) + c.Header("Last-Modified", state.UpdatedAt.Format("Mon, 02 Jan 2006 15:04:05 GMT")) + + SuccessResponse(c, StateData{ + TenantID: state.TenantID, + State: stateData, + Version: state.Version, + LastModified: state.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"), + }) +} + +// DeleteState deletes state for a tenant +func (h *StateHandler) DeleteState(c *gin.Context) { + tenantID := c.Param("tenantId") + if tenantID == "" { + ErrorResponse(c, http.StatusBadRequest, "tenantId is required", "MISSING_TENANT_ID") + return + } + + var err error + + // Try database first, fall back to in-memory + if h.dbPool != nil { + err = h.dbPool.DeleteState(c.Request.Context(), tenantID) + } else { + err = h.memStore.DeleteState(tenantID) + } + + if err != nil { + ErrorResponse(c, http.StatusInternalServerError, "Failed to delete state", "DELETE_FAILED") + return + } + + SuccessResponse(c, gin.H{ + "tenantId": tenantID, + "deletedAt": now(), + }) +} + +// generateETag creates an ETag from version and timestamp +func generateETag(version int, timestamp string) string { + return "\"" + strconv.Itoa(version) + "-" + timestamp[:8] + "\"" +} diff --git a/admin-v2/ai-compliance-sdk/internal/db/postgres.go b/admin-v2/ai-compliance-sdk/internal/db/postgres.go new file mode 100644 index 0000000..168d5cc --- /dev/null +++ b/admin-v2/ai-compliance-sdk/internal/db/postgres.go @@ -0,0 +1,173 @@ +package db + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// Pool wraps a pgxpool.Pool with SDK-specific methods +type Pool struct { + *pgxpool.Pool +} + +// SDKState represents the state stored in the database +type SDKState struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + UserID string `json:"user_id,omitempty"` + State json.RawMessage `json:"state"` + Version int `json:"version"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// NewPostgresPool creates a new database connection pool +func NewPostgresPool(connectionString string) (*Pool, error) { + config, err := pgxpool.ParseConfig(connectionString) + if err != nil { + return nil, fmt.Errorf("failed to parse connection string: %w", err) + } + + config.MaxConns = 10 + config.MinConns = 2 + config.MaxConnLifetime = 1 * time.Hour + config.MaxConnIdleTime = 30 * time.Minute + + pool, err := pgxpool.NewWithConfig(context.Background(), config) + if err != nil { + return nil, fmt.Errorf("failed to create connection pool: %w", err) + } + + // Test connection + if err := pool.Ping(context.Background()); err != nil { + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + return &Pool{Pool: pool}, nil +} + +// GetState retrieves state for a tenant +func (p *Pool) GetState(ctx context.Context, tenantID string) (*SDKState, error) { + query := ` + SELECT id, tenant_id, user_id, state, version, created_at, updated_at + FROM sdk_states + WHERE tenant_id = $1 + ` + + var state SDKState + err := p.QueryRow(ctx, query, tenantID).Scan( + &state.ID, + &state.TenantID, + &state.UserID, + &state.State, + &state.Version, + &state.CreatedAt, + &state.UpdatedAt, + ) + if err != nil { + return nil, err + } + + return &state, nil +} + +// SaveState saves or updates state for a tenant with optimistic locking +func (p *Pool) SaveState(ctx context.Context, tenantID string, userID string, state json.RawMessage, expectedVersion *int) (*SDKState, error) { + query := ` + INSERT INTO sdk_states (tenant_id, user_id, state, version) + VALUES ($1, $2, $3, 1) + ON CONFLICT (tenant_id) DO UPDATE SET + state = $3, + user_id = COALESCE($2, sdk_states.user_id), + version = sdk_states.version + 1, + updated_at = NOW() + WHERE ($4::int IS NULL OR sdk_states.version = $4) + RETURNING id, tenant_id, user_id, state, version, created_at, updated_at + ` + + var result SDKState + err := p.QueryRow(ctx, query, tenantID, userID, state, expectedVersion).Scan( + &result.ID, + &result.TenantID, + &result.UserID, + &result.State, + &result.Version, + &result.CreatedAt, + &result.UpdatedAt, + ) + if err != nil { + return nil, err + } + + return &result, nil +} + +// DeleteState deletes state for a tenant +func (p *Pool) DeleteState(ctx context.Context, tenantID string) error { + query := `DELETE FROM sdk_states WHERE tenant_id = $1` + _, err := p.Exec(ctx, query, tenantID) + return err +} + +// InMemoryStore provides an in-memory fallback when database is not available +type InMemoryStore struct { + states map[string]*SDKState +} + +// NewInMemoryStore creates a new in-memory store +func NewInMemoryStore() *InMemoryStore { + return &InMemoryStore{ + states: make(map[string]*SDKState), + } +} + +// GetState retrieves state from memory +func (s *InMemoryStore) GetState(tenantID string) (*SDKState, error) { + state, ok := s.states[tenantID] + if !ok { + return nil, fmt.Errorf("state not found") + } + return state, nil +} + +// SaveState saves state to memory +func (s *InMemoryStore) SaveState(tenantID string, userID string, state json.RawMessage, expectedVersion *int) (*SDKState, error) { + existing, exists := s.states[tenantID] + + // Optimistic locking check + if expectedVersion != nil && exists && existing.Version != *expectedVersion { + return nil, fmt.Errorf("version conflict") + } + + now := time.Now() + version := 1 + createdAt := now + + if exists { + version = existing.Version + 1 + createdAt = existing.CreatedAt + } + + newState := &SDKState{ + ID: fmt.Sprintf("%s-%d", tenantID, time.Now().UnixNano()), + TenantID: tenantID, + UserID: userID, + State: state, + Version: version, + CreatedAt: createdAt, + UpdatedAt: now, + } + + s.states[tenantID] = newState + return newState, nil +} + +// DeleteState deletes state from memory +func (s *InMemoryStore) DeleteState(tenantID string) error { + delete(s.states, tenantID) + return nil +} diff --git a/admin-v2/ai-compliance-sdk/internal/llm/service.go b/admin-v2/ai-compliance-sdk/internal/llm/service.go new file mode 100644 index 0000000..61a78e0 --- /dev/null +++ b/admin-v2/ai-compliance-sdk/internal/llm/service.go @@ -0,0 +1,384 @@ +package llm + +import ( + "context" + "fmt" + "strings" +) + +// SearchResult matches the RAG service result structure +type SearchResult struct { + ID string `json:"id"` + Content string `json:"content"` + Source string `json:"source"` + Score float64 `json:"score"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// Service provides LLM functionality for document generation +type Service struct { + apiKey string + model string +} + +// NewService creates a new LLM service +func NewService(apiKey string) *Service { + model := "claude-3-5-sonnet-20241022" + if apiKey == "" { + model = "mock" + } + return &Service{ + apiKey: apiKey, + model: model, + } +} + +// GetModel returns the current model name +func (s *Service) GetModel() string { + return s.model +} + +// GenerateDSFA generates a Data Protection Impact Assessment +func (s *Service) GenerateDSFA(ctx context.Context, context map[string]interface{}, ragSources []SearchResult) (string, int, error) { + if s.apiKey == "" { + return "", 0, fmt.Errorf("LLM not configured") + } + + // Build prompt with context and RAG sources + prompt := s.buildDSFAPrompt(context, ragSources) + + // In production, this would call the Anthropic API + // response, err := s.callAnthropicAPI(ctx, prompt) + // if err != nil { + // return "", 0, err + // } + + // For now, simulate a response + content := s.generateDSFAContent(context, ragSources) + tokensUsed := len(strings.Split(content, " ")) * 2 // Rough estimate + + return content, tokensUsed, nil +} + +// GenerateTOM generates Technical and Organizational Measures +func (s *Service) GenerateTOM(ctx context.Context, context map[string]interface{}, ragSources []SearchResult) (string, int, error) { + if s.apiKey == "" { + return "", 0, fmt.Errorf("LLM not configured") + } + + content := s.generateTOMContent(context, ragSources) + tokensUsed := len(strings.Split(content, " ")) * 2 + + return content, tokensUsed, nil +} + +// GenerateVVT generates a Processing Activity Register +func (s *Service) GenerateVVT(ctx context.Context, context map[string]interface{}, ragSources []SearchResult) (string, int, error) { + if s.apiKey == "" { + return "", 0, fmt.Errorf("LLM not configured") + } + + content := s.generateVVTContent(context, ragSources) + tokensUsed := len(strings.Split(content, " ")) * 2 + + return content, tokensUsed, nil +} + +// GenerateGutachten generates an expert opinion/assessment +func (s *Service) GenerateGutachten(ctx context.Context, context map[string]interface{}, ragSources []SearchResult) (string, int, error) { + if s.apiKey == "" { + return "", 0, fmt.Errorf("LLM not configured") + } + + content := s.generateGutachtenContent(context, ragSources) + tokensUsed := len(strings.Split(content, " ")) * 2 + + return content, tokensUsed, nil +} + +// buildDSFAPrompt builds the prompt for DSFA generation +func (s *Service) buildDSFAPrompt(context map[string]interface{}, ragSources []SearchResult) string { + var sb strings.Builder + + sb.WriteString("Du bist ein Datenschutz-Experte und erstellst eine Datenschutz-Folgenabschätzung (DSFA) gemäß Art. 35 DSGVO.\n\n") + + // Add context + if useCaseName, ok := context["useCaseName"].(string); ok { + sb.WriteString(fmt.Sprintf("Use Case: %s\n", useCaseName)) + } + if description, ok := context["description"].(string); ok { + sb.WriteString(fmt.Sprintf("Beschreibung: %s\n", description)) + } + + // Add RAG context + if len(ragSources) > 0 { + sb.WriteString("\nRelevante rechtliche Grundlagen:\n") + for _, source := range ragSources { + sb.WriteString(fmt.Sprintf("- %s (%s)\n", source.Content[:min(200, len(source.Content))], source.Source)) + } + } + + sb.WriteString("\nErstelle eine vollständige DSFA mit allen erforderlichen Abschnitten.") + + return sb.String() +} + +// Content generation functions (would be replaced by actual LLM calls in production) +func (s *Service) generateDSFAContent(context map[string]interface{}, ragSources []SearchResult) string { + useCaseName := "KI-gestützte Datenverarbeitung" + if name, ok := context["useCaseName"].(string); ok { + useCaseName = name + } + + return fmt.Sprintf(`# Datenschutz-Folgenabschätzung (DSFA) + +## Use Case: %s + +## 1. Systematische Beschreibung der Verarbeitungsvorgänge + +Die geplante Verarbeitung umfasst die Analyse von Daten mittels KI-gestützter Systeme. + +### 1.1 Verarbeitungszwecke +- Automatisierte Analyse und Verarbeitung +- Optimierung von Geschäftsprozessen +- Qualitätssicherung + +### 1.2 Rechtsgrundlage +Gemäß Art. 6 Abs. 1 lit. f DSGVO basiert die Verarbeitung auf dem berechtigten Interesse des Verantwortlichen. + +### 1.3 Kategorien verarbeiteter Daten +- Nutzungsdaten +- Metadaten +- Aggregierte Analysedaten + +## 2. Bewertung der Notwendigkeit und Verhältnismäßigkeit + +### 2.1 Notwendigkeit +Die Verarbeitung ist erforderlich, um die definierten Geschäftsziele zu erreichen. + +### 2.2 Verhältnismäßigkeit +Alternative Methoden wurden geprüft. Die gewählte Verarbeitungsmethode stellt den geringsten Eingriff bei gleichem Nutzen dar. + +## 3. Risikobewertung + +### 3.1 Identifizierte Risiken + +| Risiko | Wahrscheinlichkeit | Schwere | Gesamtbewertung | +|--------|-------------------|---------|-----------------| +| Unbefugter Zugriff | Mittel | Hoch | HOCH | +| Datenverlust | Niedrig | Hoch | MITTEL | +| Fehlinterpretation | Mittel | Mittel | MITTEL | + +### 3.2 Maßnahmen zur Risikominderung + +1. **Technische Maßnahmen** + - Verschlüsselung (AES-256) + - Zugriffskontrollen + - Audit-Logging + +2. **Organisatorische Maßnahmen** + - Schulungen + - Dokumentation + - Regelmäßige Überprüfungen + +## 4. Genehmigungsstatus + +| Rolle | Status | Datum | +|-------|--------|-------| +| Projektleiter | AUSSTEHEND | - | +| DSB | AUSSTEHEND | - | +| Geschäftsführung | AUSSTEHEND | - | + +--- +*Generiert mit KI-Unterstützung. Manuelle Überprüfung erforderlich.* +`, useCaseName) +} + +func (s *Service) generateTOMContent(context map[string]interface{}, ragSources []SearchResult) string { + return `# Technische und Organisatorische Maßnahmen (TOMs) + +## 1. Vertraulichkeit (Art. 32 Abs. 1 lit. b DSGVO) + +### 1.1 Zutrittskontrolle +- [ ] Alarmanlage installiert +- [ ] Chipkarten-System aktiv +- [ ] Besucherprotokoll geführt + +### 1.2 Zugangskontrolle +- [ ] Starke Passwort-Policy (12+ Zeichen) +- [ ] MFA aktiviert +- [ ] Automatische Bildschirmsperre + +### 1.3 Zugriffskontrolle +- [ ] Rollenbasierte Berechtigungen +- [ ] Need-to-know Prinzip +- [ ] Quartalsweise Berechtigungsüberprüfung + +## 2. Integrität (Art. 32 Abs. 1 lit. b DSGVO) + +### 2.1 Weitergabekontrolle +- [ ] TLS 1.3 für alle Übertragungen +- [ ] E-Mail-Verschlüsselung +- [ ] Sichere File-Transfer-Protokolle + +### 2.2 Eingabekontrolle +- [ ] Vollständiges Audit-Logging +- [ ] Benutzeridentifikation bei Änderungen +- [ ] Unveränderliche Protokolle + +## 3. Verfügbarkeit (Art. 32 Abs. 1 lit. c DSGVO) + +### 3.1 Verfügbarkeitskontrolle +- [ ] Tägliche Backups +- [ ] Georedundante Speicherung +- [ ] USV-System +- [ ] Dokumentierter Notfallplan + +### 3.2 Wiederherstellung +- [ ] RPO: 1 Stunde +- [ ] RTO: 4 Stunden +- [ ] Jährliche Wiederherstellungstests + +## 4. Belastbarkeit + +- [ ] DDoS-Schutz implementiert +- [ ] Lastverteilung aktiv +- [ ] Skalierbare Infrastruktur + +--- +*Generiert mit KI-Unterstützung. Manuelle Überprüfung erforderlich.* +` +} + +func (s *Service) generateVVTContent(context map[string]interface{}, ragSources []SearchResult) string { + return `# Verzeichnis der Verarbeitungstätigkeiten (Art. 30 DSGVO) + +## Verarbeitungstätigkeit Nr. 1 + +### Stammdaten + +| Feld | Wert | +|------|------| +| **Bezeichnung** | KI-gestützte Datenanalyse | +| **Verantwortlicher** | [Unternehmen] | +| **DSB** | [Name, Kontakt] | +| **Abteilung** | IT / Data Science | + +### Verarbeitungsdetails + +| Feld | Wert | +|------|------| +| **Zweck** | Optimierung von Geschäftsprozessen durch KI-Analyse | +| **Rechtsgrundlage** | Art. 6 Abs. 1 lit. f DSGVO | +| **Betroffene Kategorien** | Kunden, Mitarbeiter, Geschäftspartner | +| **Datenkategorien** | Nutzungsdaten, Metadaten, Analyseergebnisse | + +### Empfänger + +| Kategorie | Beispiele | +|-----------|-----------| +| Intern | IT-Abteilung, Management | +| Auftragsverarbeiter | Cloud-Provider (mit AVV) | +| Dritte | Keine | + +### Drittlandtransfer + +| Frage | Antwort | +|-------|---------| +| Übermittlung in Drittländer? | Nein / Ja | +| Falls ja, Garantien | [Standardvertragsklauseln / Angemessenheitsbeschluss] | + +### Löschfristen + +| Datenkategorie | Frist | Grundlage | +|----------------|-------|-----------| +| Nutzungsdaten | 12 Monate | Betriebliche Notwendigkeit | +| Analyseergebnisse | 36 Monate | Geschäftszweck | +| Audit-Logs | 10 Jahre | Handelsrechtlich | + +### Technisch-Organisatorische Maßnahmen + +Verweis auf TOM-Dokument Version 1.0 + +--- +*Generiert mit KI-Unterstützung. Manuelle Überprüfung erforderlich.* +` +} + +func (s *Service) generateGutachtenContent(context map[string]interface{}, ragSources []SearchResult) string { + return `# Compliance-Gutachten + +## Management Summary + +Das geprüfte System erfüllt die wesentlichen Anforderungen der anwendbaren Regulierungen. Es bestehen Optimierungspotenziale, die priorisiert adressiert werden sollten. + +## 1. Prüfungsumfang + +### 1.1 Geprüfte Regulierungen +- DSGVO (EU 2016/679) +- AI Act (EU 2024/...) +- NIS2 (EU 2022/2555) + +### 1.2 Prüfungsmethodik +- Dokumentenprüfung +- Technische Analyse +- Interviews mit Stakeholdern + +## 2. Ergebnisse + +### 2.1 DSGVO-Konformität + +| Bereich | Bewertung | Handlungsbedarf | +|---------|-----------|-----------------| +| Rechtmäßigkeit | ✓ Erfüllt | Gering | +| Transparenz | ◐ Teilweise | Mittel | +| Datensicherheit | ✓ Erfüllt | Gering | +| Betroffenenrechte | ◐ Teilweise | Mittel | + +### 2.2 AI Act-Konformität + +| Bereich | Bewertung | Handlungsbedarf | +|---------|-----------|-----------------| +| Risikoklassifizierung | ✓ Erfüllt | Keiner | +| Dokumentation | ◐ Teilweise | Mittel | +| Human Oversight | ✓ Erfüllt | Gering | + +### 2.3 NIS2-Konformität + +| Bereich | Bewertung | Handlungsbedarf | +|---------|-----------|-----------------| +| Risikomanagement | ✓ Erfüllt | Gering | +| Incident Reporting | ◐ Teilweise | Hoch | +| Supply Chain | ○ Nicht erfüllt | Kritisch | + +## 3. Empfehlungen + +### Kritisch (sofort) +1. Supply-Chain-Risikomanagement implementieren +2. Incident-Reporting-Prozess etablieren + +### Hoch (< 3 Monate) +3. Transparenzdokumentation vervollständigen +4. Betroffenenrechte-Portal optimieren + +### Mittel (< 6 Monate) +5. AI Act Dokumentation erweitern +6. Schulungsmaßnahmen durchführen + +## 4. Fazit + +Das System zeigt einen guten Compliance-Stand mit klar definierten Verbesserungsbereichen. Bei Umsetzung der Empfehlungen ist eine vollständige Konformität erreichbar. + +--- +*Erstellt: [Datum]* +*Gutachter: [Name]* +*Version: 1.0* +` +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/admin-v2/ai-compliance-sdk/internal/rag/service.go b/admin-v2/ai-compliance-sdk/internal/rag/service.go new file mode 100644 index 0000000..1366094 --- /dev/null +++ b/admin-v2/ai-compliance-sdk/internal/rag/service.go @@ -0,0 +1,208 @@ +package rag + +import ( + "context" + "fmt" +) + +// SearchResult represents a search result from the RAG system +type SearchResult struct { + ID string `json:"id"` + Content string `json:"content"` + Source string `json:"source"` + Score float64 `json:"score"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// CorpusStatus represents the status of the legal corpus +type CorpusStatus struct { + Status string `json:"status"` + Collections []string `json:"collections"` + Documents int `json:"documents"` + LastUpdated string `json:"lastUpdated,omitempty"` +} + +// Service provides RAG functionality +type Service struct { + qdrantURL string + // client *qdrant.Client // Would be actual Qdrant client in production +} + +// NewService creates a new RAG service +func NewService(qdrantURL string) (*Service, error) { + if qdrantURL == "" { + return nil, fmt.Errorf("qdrant URL is required") + } + + // In production, this would initialize the Qdrant client + // client, err := qdrant.NewClient(qdrantURL) + // if err != nil { + // return nil, err + // } + + return &Service{ + qdrantURL: qdrantURL, + }, nil +} + +// Search performs semantic search on the legal corpus +func (s *Service) Search(ctx context.Context, query string, topK int, collection string, filter string) ([]SearchResult, error) { + // In production, this would: + // 1. Generate embedding for the query using an embedding model (e.g., BGE-M3) + // 2. Search Qdrant for similar vectors + // 3. Return the results + + // For now, return mock results that simulate a real RAG response + results := s.getMockSearchResults(query, topK) + return results, nil +} + +// GetCorpusStatus returns the status of the legal corpus +func (s *Service) GetCorpusStatus(ctx context.Context) (*CorpusStatus, error) { + // In production, this would query Qdrant for collection info + return &CorpusStatus{ + Status: "ready", + Collections: []string{ + "legal_corpus", + "dsgvo_articles", + "ai_act_articles", + "nis2_articles", + }, + Documents: 1500, + LastUpdated: "2026-02-01T00:00:00Z", + }, nil +} + +// IndexDocument indexes a new document into the corpus +func (s *Service) IndexDocument(ctx context.Context, collection string, id string, content string, metadata map[string]string) error { + // In production, this would: + // 1. Generate embedding for the content + // 2. Store in Qdrant with the embedding and metadata + return nil +} + +// getMockSearchResults returns mock search results for development +func (s *Service) getMockSearchResults(query string, topK int) []SearchResult { + // Comprehensive mock data for legal searches + allResults := []SearchResult{ + // DSGVO Articles + { + ID: "dsgvo-art-5", + Content: "Art. 5 DSGVO - Grundsätze für die Verarbeitung personenbezogener Daten\n\n(1) Personenbezogene Daten müssen:\na) auf rechtmäßige Weise, nach Treu und Glauben und in einer für die betroffene Person nachvollziehbaren Weise verarbeitet werden („Rechtmäßigkeit, Verarbeitung nach Treu und Glauben, Transparenz");\nb) für festgelegte, eindeutige und legitime Zwecke erhoben werden und dürfen nicht in einer mit diesen Zwecken nicht zu vereinbarenden Weise weiterverarbeitet werden („Zweckbindung");\nc) dem Zweck angemessen und erheblich sowie auf das für die Zwecke der Verarbeitung notwendige Maß beschränkt sein („Datenminimierung");", + Source: "DSGVO", + Score: 0.95, + Metadata: map[string]string{ + "article": "5", + "regulation": "DSGVO", + "category": "grundsaetze", + }, + }, + { + ID: "dsgvo-art-6", + Content: "Art. 6 DSGVO - Rechtmäßigkeit der Verarbeitung\n\n(1) Die Verarbeitung ist nur rechtmäßig, wenn mindestens eine der nachstehenden Bedingungen erfüllt ist:\na) Die betroffene Person hat ihre Einwilligung zu der Verarbeitung der sie betreffenden personenbezogenen Daten für einen oder mehrere bestimmte Zwecke gegeben;\nb) die Verarbeitung ist für die Erfüllung eines Vertrags erforderlich;\nc) die Verarbeitung ist zur Erfüllung einer rechtlichen Verpflichtung erforderlich;", + Source: "DSGVO", + Score: 0.92, + Metadata: map[string]string{ + "article": "6", + "regulation": "DSGVO", + "category": "rechtsgrundlage", + }, + }, + { + ID: "dsgvo-art-30", + Content: "Art. 30 DSGVO - Verzeichnis von Verarbeitungstätigkeiten\n\n(1) Jeder Verantwortliche und gegebenenfalls sein Vertreter führen ein Verzeichnis aller Verarbeitungstätigkeiten, die ihrer Zuständigkeit unterliegen. Dieses Verzeichnis enthält sämtliche folgenden Angaben:\na) den Namen und die Kontaktdaten des Verantwortlichen;\nb) die Zwecke der Verarbeitung;\nc) eine Beschreibung der Kategorien betroffener Personen und der Kategorien personenbezogener Daten;", + Source: "DSGVO", + Score: 0.89, + Metadata: map[string]string{ + "article": "30", + "regulation": "DSGVO", + "category": "dokumentation", + }, + }, + { + ID: "dsgvo-art-32", + Content: "Art. 32 DSGVO - Sicherheit der Verarbeitung\n\n(1) Unter Berücksichtigung des Stands der Technik, der Implementierungskosten und der Art, des Umfangs, der Umstände und der Zwecke der Verarbeitung sowie der unterschiedlichen Eintrittswahrscheinlichkeit und Schwere des Risikos für die Rechte und Freiheiten natürlicher Personen treffen der Verantwortliche und der Auftragsverarbeiter geeignete technische und organisatorische Maßnahmen, um ein dem Risiko angemessenes Schutzniveau zu gewährleisten.", + Source: "DSGVO", + Score: 0.88, + Metadata: map[string]string{ + "article": "32", + "regulation": "DSGVO", + "category": "sicherheit", + }, + }, + { + ID: "dsgvo-art-35", + Content: "Art. 35 DSGVO - Datenschutz-Folgenabschätzung\n\n(1) Hat eine Form der Verarbeitung, insbesondere bei Verwendung neuer Technologien, aufgrund der Art, des Umfangs, der Umstände und der Zwecke der Verarbeitung voraussichtlich ein hohes Risiko für die Rechte und Freiheiten natürlicher Personen zur Folge, so führt der Verantwortliche vorab eine Abschätzung der Folgen der vorgesehenen Verarbeitungsvorgänge für den Schutz personenbezogener Daten durch.", + Source: "DSGVO", + Score: 0.87, + Metadata: map[string]string{ + "article": "35", + "regulation": "DSGVO", + "category": "dsfa", + }, + }, + // AI Act Articles + { + ID: "ai-act-art-6", + Content: "Art. 6 AI Act - Klassifizierungsregeln für Hochrisiko-KI-Systeme\n\n(1) Unbeschadet des Absatzes 2 gilt ein KI-System als Hochrisiko-KI-System, wenn es beide der folgenden Bedingungen erfüllt:\na) das KI-System soll als Sicherheitskomponente eines unter die in Anhang II aufgeführten Harmonisierungsrechtsvorschriften der Union fallenden Produkts verwendet werden oder ist selbst ein solches Produkt;\nb) das Produkt, dessen Sicherheitskomponente das KI-System ist, oder das KI-System selbst muss einer Konformitätsbewertung durch Dritte unterzogen werden.", + Source: "AI Act", + Score: 0.91, + Metadata: map[string]string{ + "article": "6", + "regulation": "AI_ACT", + "category": "klassifizierung", + }, + }, + { + ID: "ai-act-art-9", + Content: "Art. 9 AI Act - Risikomanagement\n\n(1) Für Hochrisiko-KI-Systeme wird ein Risikomanagementsystem eingerichtet, umgesetzt, dokumentiert und aufrechterhalten. Das Risikomanagementsystem ist ein kontinuierlicher iterativer Prozess, der während des gesamten Lebenszyklus eines Hochrisiko-KI-Systems geplant und durchgeführt wird und einer regelmäßigen systematischen Aktualisierung bedarf.", + Source: "AI Act", + Score: 0.85, + Metadata: map[string]string{ + "article": "9", + "regulation": "AI_ACT", + "category": "risikomanagement", + }, + }, + { + ID: "ai-act-art-52", + Content: "Art. 52 AI Act - Transparenzpflichten für bestimmte KI-Systeme\n\n(1) Die Anbieter stellen sicher, dass KI-Systeme, die für die Interaktion mit natürlichen Personen bestimmt sind, so konzipiert und entwickelt werden, dass die betreffenden natürlichen Personen darüber informiert werden, dass sie mit einem KI-System interagieren, es sei denn, dies ist aus den Umständen und dem Nutzungskontext offensichtlich.", + Source: "AI Act", + Score: 0.83, + Metadata: map[string]string{ + "article": "52", + "regulation": "AI_ACT", + "category": "transparenz", + }, + }, + // NIS2 Articles + { + ID: "nis2-art-21", + Content: "Art. 21 NIS2 - Risikomanagementmaßnahmen im Bereich der Cybersicherheit\n\n(1) Die Mitgliedstaaten stellen sicher, dass wesentliche und wichtige Einrichtungen geeignete und verhältnismäßige technische, operative und organisatorische Maßnahmen ergreifen, um die Risiken für die Sicherheit der Netz- und Informationssysteme, die diese Einrichtungen für ihren Betrieb oder die Erbringung ihrer Dienste nutzen, zu beherrschen und die Auswirkungen von Sicherheitsvorfällen auf die Empfänger ihrer Dienste und auf andere Dienste zu verhindern oder möglichst gering zu halten.", + Source: "NIS2", + Score: 0.86, + Metadata: map[string]string{ + "article": "21", + "regulation": "NIS2", + "category": "risikomanagement", + }, + }, + { + ID: "nis2-art-23", + Content: "Art. 23 NIS2 - Meldepflichten\n\n(1) Jeder Mitgliedstaat stellt sicher, dass wesentliche und wichtige Einrichtungen jeden Sicherheitsvorfall, der erhebliche Auswirkungen auf die Erbringung ihrer Dienste hat, unverzüglich dem zuständigen CSIRT oder gegebenenfalls der zuständigen Behörde melden.", + Source: "NIS2", + Score: 0.81, + Metadata: map[string]string{ + "article": "23", + "regulation": "NIS2", + "category": "meldepflicht", + }, + }, + } + + // Return top K results + if topK > len(allResults) { + topK = len(allResults) + } + return allResults[:topK] +} diff --git a/admin-v2/app/(admin)/ai/gpu/page.tsx b/admin-v2/app/(admin)/ai/gpu/page.tsx new file mode 100644 index 0000000..d77f507 --- /dev/null +++ b/admin-v2/app/(admin)/ai/gpu/page.tsx @@ -0,0 +1,396 @@ +'use client' + +/** + * GPU Infrastructure Admin Page + * + * vast.ai GPU Management for LLM Processing + * Part of KI-Werkzeuge + */ + +import { useEffect, useState, useCallback } from 'react' +import { PagePurpose } from '@/components/common/PagePurpose' +import { AIToolsSidebarResponsive } from '@/components/ai/AIToolsSidebar' + +interface VastStatus { + instance_id: number | null + status: string + gpu_name: string | null + dph_total: number | null + endpoint_base_url: string | null + last_activity: string | null + auto_shutdown_in_minutes: number | null + total_runtime_hours: number | null + total_cost_usd: number | null + account_credit: number | null + account_total_spend: number | null + session_runtime_minutes: number | null + session_cost_usd: number | null + message: string | null + error?: string +} + +export default function GPUInfrastructurePage() { + const [status, setStatus] = useState(null) + const [loading, setLoading] = useState(true) + const [actionLoading, setActionLoading] = useState(null) + const [error, setError] = useState(null) + const [message, setMessage] = useState(null) + + const API_PROXY = '/api/admin/gpu' + + const fetchStatus = useCallback(async () => { + setLoading(true) + setError(null) + + try { + const response = await fetch(API_PROXY) + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || `HTTP ${response.status}`) + } + + setStatus(data) + } catch (err) { + setError(err instanceof Error ? err.message : 'Verbindungsfehler') + setStatus({ + instance_id: null, + status: 'error', + gpu_name: null, + dph_total: null, + endpoint_base_url: null, + last_activity: null, + auto_shutdown_in_minutes: null, + total_runtime_hours: null, + total_cost_usd: null, + account_credit: null, + account_total_spend: null, + session_runtime_minutes: null, + session_cost_usd: null, + message: 'Verbindung fehlgeschlagen' + }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchStatus() + }, [fetchStatus]) + + useEffect(() => { + const interval = setInterval(fetchStatus, 30000) + return () => clearInterval(interval) + }, [fetchStatus]) + + const powerOn = async () => { + setActionLoading('on') + setError(null) + setMessage(null) + + try { + const response = await fetch(API_PROXY, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'on' }), + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || data.detail || 'Aktion fehlgeschlagen') + } + + setMessage('Start angefordert') + setTimeout(fetchStatus, 3000) + setTimeout(fetchStatus, 10000) + } catch (err) { + setError(err instanceof Error ? err.message : 'Fehler beim Starten') + fetchStatus() + } finally { + setActionLoading(null) + } + } + + const powerOff = async () => { + setActionLoading('off') + setError(null) + setMessage(null) + + try { + const response = await fetch(API_PROXY, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'off' }), + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.error || data.detail || 'Aktion fehlgeschlagen') + } + + setMessage('Stop angefordert') + setTimeout(fetchStatus, 3000) + setTimeout(fetchStatus, 10000) + } catch (err) { + setError(err instanceof Error ? err.message : 'Fehler beim Stoppen') + fetchStatus() + } finally { + setActionLoading(null) + } + } + + const getStatusBadge = (s: string) => { + const baseClasses = 'px-3 py-1 rounded-full text-sm font-semibold uppercase' + switch (s) { + case 'running': + return `${baseClasses} bg-green-100 text-green-800` + case 'stopped': + case 'exited': + return `${baseClasses} bg-red-100 text-red-800` + case 'loading': + case 'scheduling': + case 'creating': + case 'starting...': + case 'stopping...': + return `${baseClasses} bg-yellow-100 text-yellow-800` + default: + return `${baseClasses} bg-slate-100 text-slate-600` + } + } + + const getCreditColor = (credit: number | null) => { + if (credit === null) return 'text-slate-500' + if (credit < 5) return 'text-red-600' + if (credit < 15) return 'text-yellow-600' + return 'text-green-600' + } + + return ( +
+ {/* Page Purpose */} + + + {/* KI-Werkzeuge Sidebar */} + + + {/* Status Cards */} +
+
+
+
Status
+ {loading ? ( + + Laden... + + ) : ( + + {actionLoading === 'on' ? 'starting...' : + actionLoading === 'off' ? 'stopping...' : + status?.status || 'unbekannt'} + + )} +
+ +
+
GPU
+
+ {status?.gpu_name || '-'} +
+
+ +
+
Kosten/h
+
+ {status?.dph_total ? `$${status.dph_total.toFixed(3)}` : '-'} +
+
+ +
+
Auto-Stop
+
+ {status && status.auto_shutdown_in_minutes !== null + ? `${status.auto_shutdown_in_minutes} min` + : '-'} +
+
+ +
+
Budget
+
+ {status && status.account_credit !== null + ? `$${status.account_credit.toFixed(2)}` + : '-'} +
+
+ +
+
Session
+
+ {status && status.session_runtime_minutes !== null && status.session_cost_usd !== null + ? `${Math.round(status.session_runtime_minutes)} min / $${status.session_cost_usd.toFixed(3)}` + : '-'} +
+
+
+ + {/* Buttons */} +
+ + + + + {message && ( + {message} + )} + {error && ( + {error} + )} +
+
+ + {/* Extended Stats */} +
+
+

Kosten-Uebersicht

+
+
+ Session Laufzeit + + {status && status.session_runtime_minutes !== null + ? `${Math.round(status.session_runtime_minutes)} Minuten` + : '-'} + +
+
+ Session Kosten + + {status && status.session_cost_usd !== null + ? `$${status.session_cost_usd.toFixed(4)}` + : '-'} + +
+
+ Gesamtlaufzeit + + {status && status.total_runtime_hours !== null + ? `${status.total_runtime_hours.toFixed(1)} Stunden` + : '-'} + +
+
+ Gesamtkosten + + {status && status.total_cost_usd !== null + ? `$${status.total_cost_usd.toFixed(2)}` + : '-'} + +
+
+ vast.ai Ausgaben + + {status && status.account_total_spend !== null + ? `$${status.account_total_spend.toFixed(2)}` + : '-'} + +
+
+
+ +
+

Instanz-Details

+
+
+ Instanz ID + + {status?.instance_id || '-'} + +
+
+ GPU + + {status?.gpu_name || '-'} + +
+
+ Stundensatz + + {status?.dph_total ? `$${status.dph_total.toFixed(4)}/h` : '-'} + +
+
+ Letzte Aktivitaet + + {status?.last_activity + ? new Date(status.last_activity).toLocaleString('de-DE') + : '-'} + +
+ {status?.endpoint_base_url && status.status === 'running' && ( +
+
Endpoint
+ + {status.endpoint_base_url} + +
+ )} +
+
+
+ + {/* Info */} +
+
+ + + +
+

Auto-Shutdown

+

+ Die GPU-Instanz wird automatisch gestoppt, wenn sie laengere Zeit inaktiv ist. + Der Status wird alle 30 Sekunden automatisch aktualisiert. +

+
+
+
+
+ ) +} diff --git a/admin-v2/app/(admin)/ai/llm-compare/page.tsx b/admin-v2/app/(admin)/ai/llm-compare/page.tsx index d87e64c..162fb53 100644 --- a/admin-v2/app/(admin)/ai/llm-compare/page.tsx +++ b/admin-v2/app/(admin)/ai/llm-compare/page.tsx @@ -12,6 +12,7 @@ import { useState, useEffect, useCallback } from 'react' import { PagePurpose } from '@/components/common/PagePurpose' +import { AIToolsSidebarResponsive } from '@/components/ai/AIToolsSidebar' interface LLMResponse { provider: string @@ -210,21 +211,24 @@ export default function LLMComparePage() { {/* Page Purpose */} + {/* KI-Werkzeuge Sidebar */} + +
{/* Left Column: Input & Settings */}
diff --git a/admin-v2/app/(admin)/ai/magic-help/page.tsx b/admin-v2/app/(admin)/ai/magic-help/page.tsx new file mode 100644 index 0000000..95951c0 --- /dev/null +++ b/admin-v2/app/(admin)/ai/magic-help/page.tsx @@ -0,0 +1,1604 @@ +'use client' + +/** + * Magic Help Admin Page - Admin v2 Migration + * + * Comprehensive admin interface for TrOCR Handwriting Recognition and Exam Correction. + * Features: + * - Model status monitoring + * - OCR testing with image upload + * - Training data management + * - Fine-tuning controls + * - Architecture documentation + * - Configuration settings + * + * Phase 1 Enhancements: + * - Clipboard Paste (Ctrl+V) support + * - Global Drag & Drop anywhere on window + * - Skeleton loading states + * - Live OCR preview with debounce + * - Keyboard shortcuts + * + * Phase 2-4 Enhancements: + * - Batch processing with SSE progress + * - Confidence heatmap visualization + * - Training metrics dashboard + * - Model export functionality + */ + +import { useState, useEffect, useCallback, useRef } from 'react' +import Link from 'next/link' +import { SkeletonOCRResult, SkeletonText, SkeletonDots } from '@/components/common/SkeletonText' +import { ConfidenceHeatmap, ConfidenceStats } from '@/components/ai/ConfidenceHeatmap' +import { TrainingMetrics } from '@/components/ai/TrainingMetrics' +import { BatchUploader } from '@/components/ai/BatchUploader' +import { AIModuleSidebarResponsive } from '@/components/ai/AIModuleSidebar' +import { PagePurpose } from '@/components/common/PagePurpose' + +type TabId = 'overview' | 'test' | 'batch' | 'training' | 'architecture' | 'settings' + +interface TrOCRStatus { + status: 'available' | 'not_installed' | 'error' + model_name?: string + model_id?: string + device?: string + is_loaded?: boolean + has_lora_adapter?: boolean + training_examples_count?: number + error?: string + install_command?: string +} + +interface OCRResult { + text: string + confidence: number + processing_time_ms: number + model: string + has_lora_adapter: boolean + char_confidences?: number[] + word_boxes?: Array<{ text: string; confidence: number; bbox: number[] }> +} + +interface TrainingExample { + image_path: string + ground_truth: string + teacher_id: string + created_at: string +} + +interface MagicSettings { + autoDetectLines: boolean + confidenceThreshold: number + maxImageSize: number + loraRank: number + loraAlpha: number + learningRate: number + epochs: number + batchSize: number + enableCache: boolean + cacheMaxAge: number + livePreview: boolean + soundFeedback: boolean +} + +const DEFAULT_SETTINGS: MagicSettings = { + autoDetectLines: true, + confidenceThreshold: 0.7, + maxImageSize: 4096, + loraRank: 8, + loraAlpha: 32, + learningRate: 0.00005, + epochs: 3, + batchSize: 4, + enableCache: true, + cacheMaxAge: 3600, + livePreview: true, + soundFeedback: false, +} + +export default function MagicHelpPage() { + const [activeTab, setActiveTab] = useState('overview') + const [status, setStatus] = useState(null) + const [loading, setLoading] = useState(true) + const [ocrResult, setOcrResult] = useState(null) + const [ocrLoading, setOcrLoading] = useState(false) + const [examples, setExamples] = useState([]) + const [trainingImage, setTrainingImage] = useState(null) + const [trainingText, setTrainingText] = useState('') + const [fineTuning, setFineTuning] = useState(false) + const [settings, setSettings] = useState(DEFAULT_SETTINGS) + const [settingsSaved, setSettingsSaved] = useState(false) + + // Phase 1: New state for enhanced features + const [globalDragActive, setGlobalDragActive] = useState(false) + const [uploadedImage, setUploadedImage] = useState(null) + const [imagePreview, setImagePreview] = useState(null) + const [showShortcutHint, setShowShortcutHint] = useState(false) + const debounceTimer = useRef(null) + const dragCounter = useRef(0) + + // Use same-origin nginx proxy to avoid CORS issues + const API_BASE = '/klausur-api' + + const fetchStatus = useCallback(async () => { + try { + const res = await fetch(`${API_BASE}/api/klausur/trocr/status`) + const data = await res.json() + setStatus(data) + } catch { + setStatus({ status: 'error', error: 'Failed to fetch status' }) + } finally { + setLoading(false) + } + }, [API_BASE]) + + const fetchExamples = useCallback(async () => { + try { + const res = await fetch(`${API_BASE}/api/klausur/trocr/training/examples`) + const data = await res.json() + setExamples(data.examples || []) + } catch (error) { + console.error('Failed to fetch examples:', error) + } + }, [API_BASE]) + + // Phase 1: Live OCR with debounce + const triggerOCR = useCallback(async (file: File) => { + setOcrLoading(true) + setOcrResult(null) + + const formData = new FormData() + formData.append('file', file) + + try { + const res = await fetch(`${API_BASE}/api/klausur/trocr/extract?detect_lines=${settings.autoDetectLines}`, { + method: 'POST', + body: formData, + }) + const data = await res.json() + if (data.text !== undefined) { + setOcrResult(data) + // Play sound feedback if enabled + if (settings.soundFeedback && data.confidence > 0.7) { + playSuccessSound() + } + } else { + setOcrResult({ text: `Error: ${data.detail || 'Unknown error'}`, confidence: 0, processing_time_ms: 0, model: '', has_lora_adapter: false }) + } + } catch (error) { + setOcrResult({ text: `Error: ${error}`, confidence: 0, processing_time_ms: 0, model: '', has_lora_adapter: false }) + } finally { + setOcrLoading(false) + } + }, [API_BASE, settings.autoDetectLines, settings.soundFeedback]) + + // Play subtle success sound + const playSuccessSound = () => { + try { + const audioContext = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)() + const oscillator = audioContext.createOscillator() + const gainNode = audioContext.createGain() + + oscillator.connect(gainNode) + gainNode.connect(audioContext.destination) + + oscillator.frequency.value = 800 + oscillator.type = 'sine' + gainNode.gain.setValueAtTime(0.1, audioContext.currentTime) + gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.2) + + oscillator.start(audioContext.currentTime) + oscillator.stop(audioContext.currentTime + 0.2) + } catch { + // Audio not supported, ignore + } + } + + // Handle file upload with live preview + const handleFileUpload = useCallback((file: File) => { + if (!file.type.startsWith('image/')) return + + setUploadedImage(file) + + // Create preview URL + const previewUrl = URL.createObjectURL(file) + setImagePreview(previewUrl) + + // Auto-switch to test tab if not there + setActiveTab('test') + + // Live preview: trigger OCR with debounce + if (settings.livePreview) { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current) + } + debounceTimer.current = setTimeout(() => { + triggerOCR(file) + }, 500) + } + }, [settings.livePreview, triggerOCR]) + + // Manual OCR trigger + const handleManualOCR = () => { + if (uploadedImage) { + triggerOCR(uploadedImage) + } + } + + // Phase 1: Global Drag & Drop handler + useEffect(() => { + const handleDragEnter = (e: DragEvent) => { + e.preventDefault() + e.stopPropagation() + dragCounter.current++ + if (e.dataTransfer?.types.includes('Files')) { + setGlobalDragActive(true) + } + } + + const handleDragLeave = (e: DragEvent) => { + e.preventDefault() + e.stopPropagation() + dragCounter.current-- + if (dragCounter.current === 0) { + setGlobalDragActive(false) + } + } + + const handleDragOver = (e: DragEvent) => { + e.preventDefault() + e.stopPropagation() + } + + const handleDrop = (e: DragEvent) => { + e.preventDefault() + e.stopPropagation() + dragCounter.current = 0 + setGlobalDragActive(false) + + const file = e.dataTransfer?.files[0] + if (file?.type.startsWith('image/')) { + handleFileUpload(file) + } + } + + document.addEventListener('dragenter', handleDragEnter) + document.addEventListener('dragleave', handleDragLeave) + document.addEventListener('dragover', handleDragOver) + document.addEventListener('drop', handleDrop) + + return () => { + document.removeEventListener('dragenter', handleDragEnter) + document.removeEventListener('dragleave', handleDragLeave) + document.removeEventListener('dragover', handleDragOver) + document.removeEventListener('drop', handleDrop) + } + }, [handleFileUpload]) + + // Phase 1: Clipboard paste handler (Ctrl+V) + useEffect(() => { + const handlePaste = async (e: ClipboardEvent) => { + const items = e.clipboardData?.items + if (!items) return + + for (const item of items) { + if (item.type.startsWith('image/')) { + e.preventDefault() + const file = item.getAsFile() + if (file) { + handleFileUpload(file) + } + break + } + } + } + + document.addEventListener('paste', handlePaste) + return () => document.removeEventListener('paste', handlePaste) + }, [handleFileUpload]) + + // Phase 1: Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ctrl+Enter: Start OCR + if (e.ctrlKey && e.key === 'Enter' && uploadedImage) { + e.preventDefault() + handleManualOCR() + } + + // Tab: Switch tabs (with numbers 1-6) + if (e.key >= '1' && e.key <= '6' && e.altKey) { + e.preventDefault() + const tabIndex = parseInt(e.key) - 1 + const tabIds: TabId[] = ['overview', 'test', 'batch', 'training', 'architecture', 'settings'] + if (tabIds[tabIndex]) { + setActiveTab(tabIds[tabIndex]) + } + } + + // Escape: Clear uploaded image + if (e.key === 'Escape' && uploadedImage) { + setUploadedImage(null) + setImagePreview(null) + setOcrResult(null) + } + + // ? : Show shortcuts + if (e.key === '?') { + setShowShortcutHint(prev => !prev) + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [uploadedImage]) + + useEffect(() => { + fetchStatus() + fetchExamples() + // Load settings from localStorage + const saved = localStorage.getItem('magic-help-settings') + if (saved) { + try { + setSettings({ ...DEFAULT_SETTINGS, ...JSON.parse(saved) }) + } catch { + // ignore parse errors + } + } + }, [fetchStatus, fetchExamples]) + + // Cleanup preview URL + useEffect(() => { + return () => { + if (imagePreview) { + URL.revokeObjectURL(imagePreview) + } + } + }, [imagePreview]) + + const handleAddTrainingExample = async () => { + if (!trainingImage || !trainingText.trim()) { + alert('Please provide both an image and the correct text') + return + } + + const formData = new FormData() + formData.append('file', trainingImage) + + try { + const res = await fetch(`${API_BASE}/api/klausur/trocr/training/add?ground_truth=${encodeURIComponent(trainingText)}`, { + method: 'POST', + body: formData, + }) + const data = await res.json() + if (data.example_id) { + alert(`Training example added! Total: ${data.total_examples}`) + setTrainingImage(null) + setTrainingText('') + fetchStatus() + fetchExamples() + } else { + alert(`Error: ${data.detail || 'Unknown error'}`) + } + } catch (error) { + alert(`Error: ${error}`) + } + } + + const handleFineTune = async () => { + if (!confirm('Start fine-tuning? This may take several minutes.')) return + + setFineTuning(true) + try { + const res = await fetch(`${API_BASE}/api/klausur/trocr/training/fine-tune`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + epochs: settings.epochs, + learning_rate: settings.learningRate, + lora_rank: settings.loraRank, + lora_alpha: settings.loraAlpha, + }), + }) + const data = await res.json() + if (data.status === 'success') { + alert(`Fine-tuning successful!\nExamples used: ${data.examples_used}\nEpochs: ${data.epochs}`) + fetchStatus() + } else { + alert(`Fine-tuning failed: ${data.message}`) + } + } catch (error) { + alert(`Error: ${error}`) + } finally { + setFineTuning(false) + } + } + + const saveSettings = () => { + localStorage.setItem('magic-help-settings', JSON.stringify(settings)) + setSettingsSaved(true) + setTimeout(() => setSettingsSaved(false), 2000) + } + + const getStatusBadge = () => { + if (!status) return null + switch (status.status) { + case 'available': + return Verfuegbar + case 'not_installed': + return Nicht installiert + case 'error': + return Fehler + } + } + + // Get confidence color for visualization + const getConfidenceColor = (confidence: number) => { + if (confidence >= 0.9) return 'bg-green-500' + if (confidence >= 0.7) return 'bg-yellow-500' + return 'bg-red-500' + } + + // State for new features + const [showHeatmap, setShowHeatmap] = useState(false) + const [showTrainingDashboard, setShowTrainingDashboard] = useState(false) + + const tabs = [ + { id: 'overview' as TabId, label: 'Uebersicht', icon: '📊', shortcut: 'Alt+1' }, + { id: 'test' as TabId, label: 'OCR Test', icon: '🔍', shortcut: 'Alt+2' }, + { id: 'batch' as TabId, label: 'Batch OCR', icon: '📁', shortcut: 'Alt+3' }, + { id: 'training' as TabId, label: 'Training', icon: '🎯', shortcut: 'Alt+4' }, + { id: 'architecture' as TabId, label: 'Architektur', icon: '🏗️', shortcut: 'Alt+5' }, + { id: 'settings' as TabId, label: 'Einstellungen', icon: '⚙️', shortcut: 'Alt+6' }, + ] + + return ( +
+ {/* Global Drag Overlay */} + {globalDragActive && ( +
+
+
📄
+
Bild hier ablegen
+
PNG, JPG - Handgeschriebener Text
+
+
+ )} + + {/* Keyboard Shortcuts Modal */} + {showShortcutHint && ( +
setShowShortcutHint(false)}> +
e.stopPropagation()}> +

Tastenkuerzel

+
+
+ Bild einfuegen + Ctrl+V +
+
+ OCR starten + Ctrl+Enter +
+
+ Tab wechseln + Alt+1-6 +
+
+ Bild entfernen + Escape +
+
+ Shortcuts anzeigen + ? +
+
+ +
+
+ )} + + {/* Header */} +
+
+

+ + Magic Help - Handschrifterkennung +

+

+ KI-gestuetzte Klausurkorrektur mit TrOCR und Privacy-by-Design +

+
+
+ + {getStatusBadge()} +
+
+ + {/* Page Purpose with Related Pages */} + + + {/* AI Module Sidebar - Desktop: Fixed, Mobile: FAB + Drawer */} + + + {/* Quick paste hint */} +
+ 💡 + + Tipp: Druecke Ctrl+V um ein Bild aus der Zwischenablage einzufuegen, oder ziehe es einfach irgendwo ins Fenster. + +
+ + {/* Tabs */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Tab Content */} + {activeTab === 'overview' && ( +
+ {/* Status Card */} +
+
+

Systemstatus

+ +
+ + {loading ? ( +
+ {[1, 2, 3, 4].map((i) => ( +
+ +
+
+ ))} +
+ ) : status?.status === 'available' ? ( +
+
+
{status.model_name || 'trocr-base'}
+
Modell
+
+
+
{status.device || 'CPU'}
+
Geraet
+
+
+
{status.training_examples_count || 0}
+
Trainingsbeispiele
+
+
+
{status.has_lora_adapter ? 'Aktiv' : 'Keiner'}
+
LoRA Adapter
+
+
+ ) : status?.status === 'not_installed' ? ( +
+

TrOCR ist nicht installiert. Fuehre aus:

+ {status.install_command} +
+ ) : ( +
{status?.error || 'Unbekannter Fehler'}
+ )} +
+ + {/* Quick Overview Cards */} +
+
+
🎯
+

Handschrifterkennung

+

+ TrOCR erkennt automatisch handgeschriebenen Text in Klausuren. + Das Modell wurde speziell fuer deutsche Handschriften optimiert. +

+
+
+
🔒
+

Privacy by Design

+

+ Alle Daten werden lokal verarbeitet. Schuelernamen werden durch + QR-Codes pseudonymisiert - DSGVO-konform. +

+
+
+
📈
+

Kontinuierliches Lernen

+

+ Mit LoRA Fine-Tuning passt sich das Modell an individuelle + Handschriften an - ohne das Basismodell zu veraendern. +

+
+
+ + {/* Workflow Overview */} +
+

Magic Onboarding Workflow

+
+
+ 📄 +
+
1. Upload
+
25 Klausuren hochladen
+
+
+
+
+ 🔍 +
+
2. Analyse
+
Lokale OCR in 5-10 Sek
+
+
+
+
+ +
+
3. Bestaetigung
+
Klasse, Schueler, Fach
+
+
+
+
+ 🤖 +
+
4. KI-Korrektur
+
Cloud mit Pseudonymisierung
+
+
+
+
+ 📊 +
+
5. Integration
+
Notenbuch, Zeugnisse
+
+
+
+
+
+ )} + + {activeTab === 'test' && ( +
+ {/* OCR Test */} +
+

OCR Test

+

+ Teste die Handschrifterkennung mit einem eigenen Bild. Das Ergebnis zeigt + den erkannten Text, Konfidenz und Verarbeitungszeit. + {settings.livePreview && ( + (Live-Vorschau aktiv) + )} +

+ +
+ {/* Upload Area */} +
+
document.getElementById('ocr-file-input')?.click()} + onDragOver={(e) => { e.preventDefault(); e.currentTarget.classList.add('border-purple-500', 'bg-purple-50') }} + onDragLeave={(e) => { e.currentTarget.classList.remove('border-purple-500', 'bg-purple-50') }} + onDrop={(e) => { + e.preventDefault() + e.stopPropagation() + e.currentTarget.classList.remove('border-purple-500', 'bg-purple-50') + const file = e.dataTransfer.files[0] + if (file?.type.startsWith('image/')) handleFileUpload(file) + }} + > + {imagePreview ? ( +
+ Hochgeladenes Bild + +
+ ) : ( + <> +
📄
+
Bild hierher ziehen oder klicken zum Hochladen
+
PNG, JPG - Handgeschriebener Text
+
+ oder Ctrl+V zum Einfuegen +
+ + )} +
+ { + const file = e.target.files?.[0] + if (file) handleFileUpload(file) + }} + /> + + {/* Manual trigger button if live preview is off */} + {uploadedImage && !settings.livePreview && ( + + )} +
+ + {/* Results Area */} +
+ {ocrLoading ? ( + + ) : ocrResult ? ( +
+
+

Erkannter Text:

+
= 0.9 ? 'bg-green-100 text-green-700' : + ocrResult.confidence >= 0.7 ? 'bg-yellow-100 text-yellow-700' : + 'bg-red-100 text-red-700' + }`}> + {(ocrResult.confidence * 100).toFixed(0)}% Konfidenz +
+
+
+                      {ocrResult.text || '(Kein Text erkannt)'}
+                    
+ + {/* Confidence bar visualization */} +
+
+
+
+
+ +
+
+
Konfidenz
+
{(ocrResult.confidence * 100).toFixed(1)}%
+
+
+
Verarbeitungszeit
+
{ocrResult.processing_time_ms}ms
+
+
+
Modell
+
{ocrResult.model || 'TrOCR'}
+
+
+
LoRA Adapter
+
{ocrResult.has_lora_adapter ? 'Ja' : 'Nein'}
+
+
+ + {/* Quick training action */} + {ocrResult.confidence < 0.9 && ( +
+

+ Die Erkennung koennte verbessert werden! Moechtest du dieses Beispiel zum Training hinzufuegen? +

+ +
+ )} +
+ ) : ( +
+
🔍
+
Lade ein Bild hoch um die Erkennung zu testen
+
+ )} +
+
+
+ + {/* Confidence Heatmap (when image and result available) */} + {imagePreview && ocrResult && ocrResult.confidence > 0 && ( +
+
+

Konfidenz-Visualisierung

+ +
+ {showHeatmap && ( + ({ + text: w.text, + confidence: w.confidence, + bbox: w.bbox as [number, number, number, number] + })) || []} + charConfidences={ocrResult.char_confidences || []} + showLegend={true} + toggleable={true} + /> + )} +
+ )} + + {/* Confidence Interpretation */} +
+

Konfidenz-Interpretation

+
+
+
90-100%
+
Sehr hohe Sicherheit - Text kann direkt uebernommen werden
+
+
+
70-90%
+
Gute Sicherheit - manuelle Ueberpruefung empfohlen
+
+
+
< 70%
+
Niedrige Sicherheit - manuelle Eingabe erforderlich
+
+
+
+
+ )} + + {activeTab === 'batch' && ( +
+ {/* Batch OCR Processing */} +
+

Batch-Verarbeitung

+

+ Verarbeite mehrere Bilder gleichzeitig mit Echtzeit-Fortschrittsanzeige. + Die Ergebnisse werden per Server-Sent Events gestreamt. +

+ + { + console.log('Batch complete:', results) + }} + /> +
+ + {/* Batch Processing Info */} +
+
+
🚀
+

Parallele Verarbeitung

+

+ Mehrere Bilder werden parallel verarbeitet fuer maximale Geschwindigkeit. +

+
+
+
💾
+

Smart Caching

+

+ Identische Bilder werden automatisch aus dem Cache geladen (unter 50ms). +

+
+
+
📊
+

Live-Fortschritt

+

+ Echtzeit-Updates via Server-Sent Events zeigen den Verarbeitungsfortschritt. +

+
+
+
+ )} + + {activeTab === 'training' && ( +
+ {/* Training Overview */} +
+

Training mit LoRA

+

+ LoRA (Low-Rank Adaptation) ermoeglicht effizientes Fine-Tuning ohne das Basismodell zu veraendern. + Das Training erfolgt lokal auf Ihrem System. +

+ +
+
+
{status?.training_examples_count || 0}
+
Trainingsbeispiele
+
+
+
10
+
Minimum benoetigt
+
+
+
{settings.loraRank}
+
LoRA Rank
+
+
+
{status?.has_lora_adapter ? '✓' : '✗'}
+
Adapter aktiv
+
+
+ + {/* Progress Bar */} +
+
+ Fortschritt zum Fine-Tuning + {Math.min(100, ((status?.training_examples_count || 0) / 10) * 100).toFixed(0)}% +
+
+
+
+
+
+ +
+ {/* Add Training Example */} +
+

Trainingsbeispiel hinzufuegen

+

+ Lade ein Bild mit handgeschriebenem Text hoch und gib die korrekte Transkription ein. +

+ +
+
+ + setTrainingImage(e.target.files?.[0] || null)} + /> + {trainingImage && ( +
+ Bild ausgewaehlt: {trainingImage.name} +
+ )} +
+
+ + +
+
+
+ + + +
+
+ + +
+
Wählen Sie eine E-Mail-Vorlage aus, um deren Versionen anzuzeigen.
+
+ + + + + + +
+ + +
+
+
+ + + +
+ +
+ + +
+
Lade Statistiken...
+
+ + + + + +
+
Lade Betroffenenanfragen...
+
+ + + +
+ + +
+
+
+ Dezentrales Speichersystem (IPFS) +
+
+ + +
+
+ + +
+
Lade DSMS Status...
+
+ + +
+ + + +
+ + +
+
+
+ +
+ +
+ + + + +
+
Lade archivierte Dokumente...
+
+
+ + + + + + +
+
+
+
+ + + + + + + + + + + +
+
+
+

🔐 Anmeldung

+ +
+
+ + +
+
+ +
+
+
+
+
+ + +
+
+ + +
+ +
+ +
+ + +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ + +
+
+
+

+ Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen einen Link zum Zurücksetzen Ihres Passworts. +

+
+
+ + +
+ +
+ +
+ + +
+
+
+
+
+ + +
+
+ + +
+ + +
+
+ + +
+
+
+
+
+ E-Mail wird verifiziert... +
+
+
+
+
+
+ + +
+
+
+

Benachrichtigungseinstellungen

+ +
+
+
+
+
E-Mail-Benachrichtigungen
+
Wichtige Updates per E-Mail erhalten
+
+
+
+
+
+
+
+
In-App-Benachrichtigungen
+
Benachrichtigungen in der App anzeigen
+
+
+
+
+
+
+
+
Push-Benachrichtigungen
+
Browser-Push-Benachrichtigungen aktivieren
+
+
+
+
+
+
+ +
+
+
+
+ + +
+
+
🚫
+
Account vorübergehend gesperrt
+
+ Ihr Account wurde gesperrt, da ausstehende Zustimmungen nicht innerhalb der Frist erteilt wurden. + Bitte bestätigen Sie die folgenden Dokumente, um Ihren Account wiederherzustellen. +
+
+
Ausstehende Dokumente:
+
+ +
+
+ +
+
+
+ + +
+ + + + + \ No newline at end of file diff --git a/backend/frontend/archive/studio.js.full.bak b/backend/frontend/archive/studio.js.full.bak new file mode 100644 index 0000000..54c91fe --- /dev/null +++ b/backend/frontend/archive/studio.js.full.bak @@ -0,0 +1,7767 @@ +console.log('studio.js loading...'); + +// ========================================== + // THEME TOGGLE (Dark/Light Mode) + // ========================================== + (function() { + const savedTheme = localStorage.getItem('bp-theme') || 'dark'; + if (savedTheme === 'light') { + document.documentElement.setAttribute('data-theme', 'light'); + } + })(); + + function initThemeToggle() { + const toggle = document.getElementById('theme-toggle'); + const icon = document.getElementById('theme-icon'); + const label = document.getElementById('theme-label'); + + function updateToggleUI(theme) { + if (theme === 'light') { + icon.textContent = '☀️'; + label.textContent = 'Light'; + } else { + icon.textContent = '🌙'; + label.textContent = 'Dark'; + } + } + + // Initialize UI based on current theme + const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark'; + updateToggleUI(currentTheme); + + toggle.addEventListener('click', function() { + const current = document.documentElement.getAttribute('data-theme') || 'dark'; + const newTheme = current === 'dark' ? 'light' : 'dark'; + + if (newTheme === 'light') { + document.documentElement.setAttribute('data-theme', 'light'); + } else { + document.documentElement.removeAttribute('data-theme'); + } + + localStorage.setItem('bp-theme', newTheme); + updateToggleUI(newTheme); + }); + } + + // ========================================== + // INTERNATIONALISIERUNG (i18n) + // ========================================== + const translations = { + de: { + // Navigation & Header + brand_sub: "Studio", + nav_compare: "Arbeitsblätter", + nav_tiles: "Lern-Kacheln", + login: "Login / Anmeldung", + mvp_local: "MVP · Lokal auf deinem Mac", + + // Sidebar + sidebar_areas: "Bereiche", + sidebar_studio: "Arbeitsblatt Studio", + sidebar_active: "aktiv", + sidebar_parents: "Eltern-Kanal", + sidebar_soon: "demnächst", + sidebar_correction: "Korrektur / Noten", + sidebar_units: "Lerneinheiten (lokal)", + input_student: "Schüler/in", + input_subject: "Fach", + input_grade: "Klasse (z.B. 7a)", + input_unit_title: "Lerneinheit / Thema", + btn_create: "Anlegen", + btn_add_current: "Aktuelles Arbeitsblatt hinzufügen", + btn_filter_unit: "Nur Lerneinheit", + btn_filter_all: "Alle Dateien", + + // Screen 1 - Compare + uploaded_worksheets: "Hochgeladene Arbeitsblätter", + files: "Dateien", + btn_upload: "Hochladen", + btn_delete: "Löschen", + original_scan: "Original-Scan", + cleaned_version: "Bereinigt (Handschrift entfernt)", + no_cleaned: "Noch keine bereinigte Version vorhanden.", + process_hint: "Klicke auf 'Verarbeiten', um das Arbeitsblatt zu analysieren und zu bereinigen.", + worksheet_print: "Drucken", + worksheet_no_data: "Keine Arbeitsblatt-Daten vorhanden.", + btn_full_process: "Verarbeiten (Analyse + Bereinigung + HTML)", + btn_original_generate: "Nur Original-HTML generieren", + + // Screen 2 - Tiles + learning_unit: "Lerneinheit", + no_unit_selected: "Keine Lerneinheit ausgewählt", + + // MC Tile + mc_title: "Multiple Choice Test", + mc_ready: "Bereit", + mc_generating: "Generiert...", + mc_done: "Fertig", + mc_error: "Fehler", + mc_desc: "Erzeugt passende MC-Aufgaben zur ursprünglichen Schwierigkeit (z. B. Klasse 7), ohne das Niveau zu verändern.", + mc_generate: "MC generieren", + mc_show: "Fragen anzeigen", + mc_quiz_title: "Multiple Choice Quiz", + mc_evaluate: "Auswerten", + mc_correct: "Richtig!", + mc_incorrect: "Leider falsch.", + mc_not_answered: "Nicht beantwortet. Richtig wäre:", + mc_result: "von", + mc_result_correct: "richtig", + mc_percent: "korrekt", + mc_no_questions: "Noch keine MC-Fragen für dieses Arbeitsblatt generiert.", + mc_print: "Drucken", + mc_print_with_answers: "Mit Lösungen drucken?", + + // Cloze Tile + cloze_title: "Lückentext", + cloze_desc: "Erzeugt Lückentexte mit mehreren sinnvollen Lücken pro Satz. Inkl. Übersetzung für Eltern.", + cloze_translation: "Übersetzung:", + cloze_generate: "Lückentext generieren", + cloze_start: "Übung starten", + cloze_exercise_title: "Lückentext-Übung", + cloze_instruction: "Fülle die Lücken aus und klicke auf 'Prüfen'.", + cloze_check: "Prüfen", + cloze_show_answers: "Lösungen zeigen", + cloze_no_texts: "Noch keine Lückentexte für dieses Arbeitsblatt generiert.", + cloze_sentences: "Sätze", + cloze_gaps: "Lücken", + cloze_gaps_total: "Lücken gesamt", + cloze_with_gaps: "(mit Lücken)", + cloze_print: "Drucken", + cloze_print_with_answers: "Mit Lösungen drucken?", + + // QA Tile + qa_title: "Frage-Antwort-Blatt", + qa_desc: "Frage-Antwort-Paare mit Leitner-Box System. Wiederholung nach Schwierigkeitsgrad.", + qa_generate: "Q&A generieren", + qa_learn: "Lernen starten", + qa_print: "Drucken", + qa_no_questions: "Noch keine Q&A für dieses Arbeitsblatt generiert.", + qa_box_new: "Neu", + qa_box_learning: "Gelernt", + qa_box_mastered: "Gefestigt", + qa_show_answer: "Antwort zeigen", + qa_your_answer: "Deine Antwort", + qa_type_answer: "Schreibe deine Antwort hier...", + qa_check_answer: "Antwort prüfen", + qa_correct_answer: "Richtige Antwort", + qa_self_evaluate: "War deine Antwort richtig?", + qa_no_answer: "(keine Antwort eingegeben)", + qa_correct: "Richtig", + qa_incorrect: "Falsch", + qa_key_terms: "Schlüsselbegriffe", + qa_session_correct: "Richtig", + qa_session_incorrect: "Falsch", + qa_session_complete: "Lernrunde abgeschlossen!", + qa_result_correct: "richtig", + qa_restart: "Nochmal lernen", + qa_print_with_answers: "Mit Lösungen drucken?", + question: "Frage", + answer: "Antwort", + status_generating_qa: "Generiere Q&A …", + status_qa_generated: "Q&A generiert", + + // Common + close: "Schließen", + subject: "Fach", + grade: "Stufe", + questions: "Fragen", + worksheet: "Arbeitsblatt", + loading: "Lädt...", + error: "Fehler", + success: "Erfolgreich", + + // Footer + imprint: "Impressum", + privacy: "Datenschutz", + contact: "Kontakt", + + // Status messages + status_ready: "Bereit", + status_processing: "Verarbeitet...", + status_generating_mc: "Generiere MC-Fragen …", + status_generating_cloze: "Generiere Lückentexte …", + status_please_wait: "Bitte warten, KI arbeitet.", + status_mc_generated: "MC-Fragen generiert", + status_cloze_generated: "Lückentexte generiert", + status_files_created: "Dateien erstellt", + + // Mindmap Tile + mindmap_title: "Mindmap Lernposter", + mindmap_desc: "Erstellt eine kindgerechte Mindmap mit dem Hauptthema in der Mitte und allen Fachbegriffen in farbigen Kategorien.", + mindmap_generate: "Mindmap erstellen", + mindmap_show: "Ansehen", + mindmap_print_a3: "A3 Drucken", + generating_mindmap: "Erstelle Mindmap...", + mindmap_generated: "Mindmap erstellt!", + no_analysis: "Keine Analyse", + analyze_first: "Bitte zuerst analysieren (Verarbeiten starten)", + categories: "Kategorien", + terms: "Begriffe", + }, + + tr: { + brand_sub: "Stüdyo", + nav_compare: "Çalışma Sayfaları", + nav_tiles: "Öğrenme Kartları", + login: "Giriş / Kayıt", + mvp_local: "MVP · Mac'inizde Yerel", + + sidebar_areas: "Alanlar", + sidebar_studio: "Çalışma Sayfası Stüdyosu", + sidebar_active: "aktif", + sidebar_parents: "Ebeveyn Kanalı", + sidebar_soon: "yakında", + sidebar_correction: "Düzeltme / Notlar", + sidebar_units: "Öğrenme Birimleri (yerel)", + input_student: "Öğrenci", + input_subject: "Ders", + input_grade: "Sınıf (örn. 7a)", + input_unit_title: "Öğrenme Birimi / Konu", + btn_create: "Oluştur", + btn_add_current: "Mevcut çalışma sayfasını ekle", + btn_filter_unit: "Sadece Birim", + btn_filter_all: "Tüm Dosyalar", + + uploaded_worksheets: "Yüklenen Çalışma Sayfaları", + files: "Dosya", + btn_upload: "Yükle", + btn_delete: "Sil", + original_scan: "Orijinal Tarama", + cleaned_version: "Temizlenmiş (El yazısı kaldırıldı)", + no_cleaned: "Henüz temizlenmiş sürüm yok.", + process_hint: "Çalışma sayfasını analiz etmek ve temizlemek için 'İşle'ye tıklayın.", + worksheet_print: "Yazdır", + worksheet_no_data: "Çalışma sayfası verisi yok.", + btn_full_process: "İşle (Analiz + Temizleme + HTML)", + btn_original_generate: "Sadece Orijinal HTML Oluştur", + + learning_unit: "Öğrenme Birimi", + no_unit_selected: "Öğrenme birimi seçilmedi", + + mc_title: "Çoktan Seçmeli Test", + mc_ready: "Hazır", + mc_generating: "Oluşturuluyor...", + mc_done: "Tamamlandı", + mc_error: "Hata", + mc_desc: "Orijinal zorluğa uygun (örn. 7. sınıf) çoktan seçmeli sorular oluşturur.", + mc_generate: "ÇS Oluştur", + mc_show: "Soruları Göster", + mc_quiz_title: "Çoktan Seçmeli Quiz", + mc_evaluate: "Değerlendir", + mc_correct: "Doğru!", + mc_incorrect: "Maalesef yanlış.", + mc_not_answered: "Cevaplanmadı. Doğru cevap:", + mc_result: "/", + mc_result_correct: "doğru", + mc_percent: "doğru", + mc_no_questions: "Bu çalışma sayfası için henüz ÇS sorusu oluşturulmadı.", + mc_print: "Yazdır", + mc_print_with_answers: "Cevaplarla yazdır?", + + cloze_title: "Boşluk Doldurma", + cloze_desc: "Her cümlede birden fazla anlamlı boşluk içeren metinler oluşturur. Ebeveynler için çeviri dahil.", + cloze_translation: "Çeviri:", + cloze_generate: "Boşluk Metni Oluştur", + cloze_start: "Alıştırmayı Başlat", + cloze_exercise_title: "Boşluk Doldurma Alıştırması", + cloze_instruction: "Boşlukları doldurun ve 'Kontrol Et'e tıklayın.", + cloze_check: "Kontrol Et", + cloze_show_answers: "Cevapları Göster", + cloze_no_texts: "Bu çalışma sayfası için henüz boşluk metni oluşturulmadı.", + cloze_sentences: "Cümle", + cloze_gaps: "Boşluk", + cloze_gaps_total: "Toplam boşluk", + cloze_with_gaps: "(boşluklu)", + cloze_print: "Yazdır", + cloze_print_with_answers: "Cevaplarla yazdır?", + + qa_title: "Soru-Cevap Sayfası", + qa_desc: "Leitner kutu sistemiyle soru-cevap çiftleri. Zorluk derecesine göre tekrar.", + qa_generate: "S&C Oluştur", + qa_learn: "Öğrenmeye Başla", + qa_print: "Yazdır", + qa_no_questions: "Bu çalışma sayfası için henüz S&C oluşturulmadı.", + qa_box_new: "Yeni", + qa_box_learning: "Öğreniliyor", + qa_box_mastered: "Pekiştirildi", + qa_show_answer: "Cevabı Göster", + qa_your_answer: "Senin Cevabın", + qa_type_answer: "Cevabını buraya yaz...", + qa_check_answer: "Cevabı Kontrol Et", + qa_correct_answer: "Doğru Cevap", + qa_self_evaluate: "Cevabın doğru muydu?", + qa_no_answer: "(cevap girilmedi)", + qa_correct: "Doğru", + qa_incorrect: "Yanlış", + qa_key_terms: "Anahtar Kavramlar", + qa_session_correct: "Doğru", + qa_session_incorrect: "Yanlış", + qa_session_complete: "Öğrenme turu tamamlandı!", + qa_result_correct: "doğru", + qa_restart: "Tekrar Öğren", + qa_print_with_answers: "Cevaplarla yazdır?", + question: "Soru", + answer: "Cevap", + status_generating_qa: "S&C oluşturuluyor…", + status_qa_generated: "S&C oluşturuldu", + + close: "Kapat", + subject: "Ders", + grade: "Seviye", + questions: "Soru", + worksheet: "Çalışma Sayfası", + loading: "Yükleniyor...", + error: "Hata", + success: "Başarılı", + + imprint: "Künye", + privacy: "Gizlilik", + contact: "İletişim", + + status_ready: "Hazır", + status_processing: "İşleniyor...", + status_generating_mc: "ÇS soruları oluşturuluyor…", + status_generating_cloze: "Boşluk metinleri oluşturuluyor…", + status_please_wait: "Lütfen bekleyin, yapay zeka çalışıyor.", + status_mc_generated: "ÇS soruları oluşturuldu", + status_cloze_generated: "Boşluk metinleri oluşturuldu", + status_files_created: "dosya oluşturuldu", + + // Mindmap Tile + mindmap_title: "Zihin Haritası Poster", + mindmap_desc: "Ana konuyu ortada ve tüm terimleri renkli kategorilerde gösteren çocuk dostu bir zihin haritası oluşturur.", + mindmap_generate: "Zihin Haritası Oluştur", + mindmap_show: "Görüntüle", + mindmap_print_a3: "A3 Yazdır", + generating_mindmap: "Zihin haritası oluşturuluyor...", + mindmap_generated: "Zihin haritası oluşturuldu!", + no_analysis: "Analiz yok", + analyze_first: "Lütfen önce analiz edin (İşle'ye tıklayın)", + categories: "Kategoriler", + terms: "Terimler", + }, + + ar: { + brand_sub: "ستوديو", + nav_compare: "أوراق العمل", + nav_tiles: "بطاقات التعلم", + login: "تسجيل الدخول / التسجيل", + mvp_local: "MVP · محلي على جهازك", + + sidebar_areas: "الأقسام", + sidebar_studio: "استوديو أوراق العمل", + sidebar_active: "نشط", + sidebar_parents: "قناة الوالدين", + sidebar_soon: "قريباً", + sidebar_correction: "التصحيح / الدرجات", + sidebar_units: "وحدات التعلم (محلية)", + input_student: "الطالب/ة", + input_subject: "المادة", + input_grade: "الصف (مثل 7أ)", + input_unit_title: "وحدة التعلم / الموضوع", + btn_create: "إنشاء", + btn_add_current: "إضافة ورقة العمل الحالية", + btn_filter_unit: "الوحدة فقط", + btn_filter_all: "جميع الملفات", + + uploaded_worksheets: "أوراق العمل المحملة", + files: "ملفات", + btn_upload: "تحميل", + btn_delete: "حذف", + original_scan: "المسح الأصلي", + cleaned_version: "منظف (تم إزالة الكتابة اليدوية)", + no_cleaned: "لا توجد نسخة منظفة بعد.", + process_hint: "انقر على 'معالجة' لتحليل وتنظيف ورقة العمل.", + worksheet_print: "طباعة", + worksheet_no_data: "لا توجد بيانات ورقة العمل.", + btn_full_process: "معالجة (تحليل + تنظيف + HTML)", + btn_original_generate: "إنشاء HTML الأصلي فقط", + + learning_unit: "وحدة التعلم", + no_unit_selected: "لم يتم اختيار وحدة تعلم", + + mc_title: "اختبار متعدد الخيارات", + mc_ready: "جاهز", + mc_generating: "جاري الإنشاء...", + mc_done: "تم", + mc_error: "خطأ", + mc_desc: "ينشئ أسئلة اختيار من متعدد تناسب مستوى الصعوبة الأصلي (مثل الصف 7).", + mc_generate: "إنشاء أسئلة", + mc_show: "عرض الأسئلة", + mc_quiz_title: "اختبار متعدد الخيارات", + mc_evaluate: "تقييم", + mc_correct: "صحيح!", + mc_incorrect: "للأسف خطأ.", + mc_not_answered: "لم تتم الإجابة. الإجابة الصحيحة:", + mc_result: "من", + mc_result_correct: "صحيح", + mc_percent: "صحيح", + mc_no_questions: "لم يتم إنشاء أسئلة بعد لورقة العمل هذه.", + mc_print: "طباعة", + mc_print_with_answers: "طباعة مع الإجابات؟", + + cloze_title: "ملء الفراغات", + cloze_desc: "ينشئ نصوصاً بفراغات متعددة في كل جملة. يشمل الترجمة للوالدين.", + cloze_translation: "الترجمة:", + cloze_generate: "إنشاء نص الفراغات", + cloze_start: "بدء التمرين", + cloze_exercise_title: "تمرين ملء الفراغات", + cloze_instruction: "املأ الفراغات وانقر على 'تحقق'.", + cloze_check: "تحقق", + cloze_show_answers: "عرض الإجابات", + cloze_no_texts: "لم يتم إنشاء نصوص فراغات بعد لورقة العمل هذه.", + cloze_sentences: "جمل", + cloze_gaps: "فراغات", + cloze_gaps_total: "إجمالي الفراغات", + cloze_with_gaps: "(مع فراغات)", + cloze_print: "طباعة", + cloze_print_with_answers: "طباعة مع الإجابات؟", + + qa_title: "ورقة الأسئلة والأجوبة", + qa_desc: "أزواج أسئلة وأجوبة مع نظام صندوق لايتنر. التكرار حسب الصعوبة.", + qa_generate: "إنشاء س&ج", + qa_learn: "بدء التعلم", + qa_print: "طباعة", + qa_no_questions: "لم يتم إنشاء س&ج بعد لورقة العمل هذه.", + qa_box_new: "جديد", + qa_box_learning: "قيد التعلم", + qa_box_mastered: "متقن", + qa_show_answer: "عرض الإجابة", + qa_your_answer: "إجابتك", + qa_type_answer: "اكتب إجابتك هنا...", + qa_check_answer: "تحقق من الإجابة", + qa_correct_answer: "الإجابة الصحيحة", + qa_self_evaluate: "هل كانت إجابتك صحيحة؟", + qa_no_answer: "(لم يتم إدخال إجابة)", + qa_correct: "صحيح", + qa_incorrect: "خطأ", + qa_key_terms: "المصطلحات الرئيسية", + qa_session_correct: "صحيح", + qa_session_incorrect: "خطأ", + qa_session_complete: "اكتملت جولة التعلم!", + qa_result_correct: "صحيح", + qa_restart: "تعلم مرة أخرى", + qa_print_with_answers: "طباعة مع الإجابات؟", + question: "سؤال", + answer: "إجابة", + status_generating_qa: "جاري إنشاء س&ج…", + status_qa_generated: "تم إنشاء س&ج", + + close: "إغلاق", + subject: "المادة", + grade: "المستوى", + questions: "أسئلة", + worksheet: "ورقة العمل", + loading: "جاري التحميل...", + error: "خطأ", + success: "نجاح", + + imprint: "البصمة", + privacy: "الخصوصية", + contact: "اتصل بنا", + + status_ready: "جاهز", + status_processing: "جاري المعالجة...", + status_generating_mc: "جاري إنشاء الأسئلة…", + status_generating_cloze: "جاري إنشاء نصوص الفراغات…", + status_please_wait: "يرجى الانتظار، الذكاء الاصطناعي يعمل.", + status_mc_generated: "تم إنشاء الأسئلة", + status_cloze_generated: "تم إنشاء نصوص الفراغات", + status_files_created: "ملفات تم إنشاؤها", + + // Mindmap Tile + mindmap_title: "ملصق خريطة ذهنية", + mindmap_desc: "ينشئ خريطة ذهنية مناسبة للأطفال مع الموضوع الرئيسي في المنتصف وجميع المصطلحات في فئات ملونة.", + mindmap_generate: "إنشاء خريطة ذهنية", + mindmap_show: "عرض", + mindmap_print_a3: "طباعة A3", + generating_mindmap: "جاري إنشاء الخريطة الذهنية...", + mindmap_generated: "تم إنشاء الخريطة الذهنية!", + no_analysis: "لا يوجد تحليل", + analyze_first: "يرجى التحليل أولاً (انقر على معالجة)", + categories: "الفئات", + terms: "المصطلحات", + }, + + ru: { + brand_sub: "Студия", + nav_compare: "Рабочие листы", + nav_tiles: "Учебные карточки", + login: "Вход / Регистрация", + mvp_local: "MVP · Локально на вашем Mac", + + sidebar_areas: "Разделы", + sidebar_studio: "Студия рабочих листов", + sidebar_active: "активно", + sidebar_parents: "Канал для родителей", + sidebar_soon: "скоро", + sidebar_correction: "Проверка / Оценки", + sidebar_units: "Учебные блоки (локально)", + input_student: "Ученик", + input_subject: "Предмет", + input_grade: "Класс (напр. 7а)", + input_unit_title: "Учебный блок / Тема", + btn_create: "Создать", + btn_add_current: "Добавить текущий лист", + btn_filter_unit: "Только блок", + btn_filter_all: "Все файлы", + + uploaded_worksheets: "Загруженные рабочие листы", + files: "файлов", + btn_upload: "Загрузить", + btn_delete: "Удалить", + original_scan: "Оригинальный скан", + cleaned_version: "Очищено (рукопись удалена)", + no_cleaned: "Очищенная версия пока недоступна.", + process_hint: "Нажмите 'Обработать' для анализа и очистки листа.", + worksheet_print: "Печать", + worksheet_no_data: "Нет данных рабочего листа.", + btn_full_process: "Обработать (Анализ + Очистка + HTML)", + btn_original_generate: "Только оригинальный HTML", + + learning_unit: "Учебный блок", + no_unit_selected: "Блок не выбран", + + mc_title: "Тест с выбором ответа", + mc_ready: "Готово", + mc_generating: "Создается...", + mc_done: "Готово", + mc_error: "Ошибка", + mc_desc: "Создает вопросы с выбором ответа соответствующей сложности (напр. 7 класс).", + mc_generate: "Создать тест", + mc_show: "Показать вопросы", + mc_quiz_title: "Тест с выбором ответа", + mc_evaluate: "Оценить", + mc_correct: "Правильно!", + mc_incorrect: "К сожалению, неверно.", + mc_not_answered: "Нет ответа. Правильный ответ:", + mc_result: "из", + mc_result_correct: "правильно", + mc_percent: "верно", + mc_no_questions: "Вопросы для этого листа еще не созданы.", + mc_print: "Печать", + mc_print_with_answers: "Печатать с ответами?", + + cloze_title: "Текст с пропусками", + cloze_desc: "Создает тексты с несколькими пропусками в каждом предложении. Включая перевод для родителей.", + cloze_translation: "Перевод:", + cloze_generate: "Создать текст", + cloze_start: "Начать упражнение", + cloze_exercise_title: "Упражнение с пропусками", + cloze_instruction: "Заполните пропуски и нажмите 'Проверить'.", + cloze_check: "Проверить", + cloze_show_answers: "Показать ответы", + cloze_no_texts: "Тексты для этого листа еще не созданы.", + cloze_sentences: "предложений", + cloze_gaps: "пропусков", + cloze_gaps_total: "Всего пропусков", + cloze_with_gaps: "(с пропусками)", + cloze_print: "Печать", + cloze_print_with_answers: "Печатать с ответами?", + + qa_title: "Лист вопросов и ответов", + qa_desc: "Пары вопрос-ответ с системой Лейтнера. Повторение по уровню сложности.", + qa_generate: "Создать В&О", + qa_learn: "Начать обучение", + qa_print: "Печать", + qa_no_questions: "В&О для этого листа еще не созданы.", + qa_box_new: "Новый", + qa_box_learning: "Изучается", + qa_box_mastered: "Освоено", + qa_show_answer: "Показать ответ", + qa_your_answer: "Твой ответ", + qa_type_answer: "Напиши свой ответ здесь...", + qa_check_answer: "Проверить ответ", + qa_correct_answer: "Правильный ответ", + qa_self_evaluate: "Твой ответ был правильным?", + qa_no_answer: "(ответ не введён)", + qa_correct: "Правильно", + qa_incorrect: "Неправильно", + qa_key_terms: "Ключевые термины", + qa_session_correct: "Правильно", + qa_session_incorrect: "Неправильно", + qa_session_complete: "Раунд обучения завершен!", + qa_result_correct: "правильно", + qa_restart: "Учить снова", + qa_print_with_answers: "Печатать с ответами?", + question: "Вопрос", + answer: "Ответ", + status_generating_qa: "Создание В&О…", + status_qa_generated: "В&О созданы", + + close: "Закрыть", + subject: "Предмет", + grade: "Уровень", + questions: "вопросов", + worksheet: "Рабочий лист", + loading: "Загрузка...", + error: "Ошибка", + success: "Успешно", + + imprint: "Импрессум", + privacy: "Конфиденциальность", + contact: "Контакт", + + status_ready: "Готово", + status_processing: "Обработка...", + status_generating_mc: "Создание вопросов…", + status_generating_cloze: "Создание текстов…", + status_please_wait: "Пожалуйста, подождите, ИИ работает.", + status_mc_generated: "Вопросы созданы", + status_cloze_generated: "Тексты созданы", + status_files_created: "файлов создано", + + // Mindmap Tile + mindmap_title: "Плакат Майнд-карта", + mindmap_desc: "Создает детскую ментальную карту с главной темой в центре и всеми терминами в цветных категориях.", + mindmap_generate: "Создать карту", + mindmap_show: "Просмотр", + mindmap_print_a3: "Печать A3", + generating_mindmap: "Создание карты...", + mindmap_generated: "Карта создана!", + no_analysis: "Нет анализа", + analyze_first: "Сначала выполните анализ (нажмите Обработать)", + categories: "Категории", + terms: "Термины", + }, + + uk: { + brand_sub: "Студія", + nav_compare: "Робочі аркуші", + nav_tiles: "Навчальні картки", + login: "Вхід / Реєстрація", + mvp_local: "MVP · Локально на вашому Mac", + + sidebar_areas: "Розділи", + sidebar_studio: "Студія робочих аркушів", + sidebar_active: "активно", + sidebar_parents: "Канал для батьків", + sidebar_soon: "незабаром", + sidebar_correction: "Перевірка / Оцінки", + sidebar_units: "Навчальні блоки (локально)", + input_student: "Учень", + input_subject: "Предмет", + input_grade: "Клас (напр. 7а)", + input_unit_title: "Навчальний блок / Тема", + btn_create: "Створити", + btn_add_current: "Додати поточний аркуш", + btn_filter_unit: "Лише блок", + btn_filter_all: "Усі файли", + + uploaded_worksheets: "Завантажені робочі аркуші", + files: "файлів", + btn_upload: "Завантажити", + btn_delete: "Видалити", + original_scan: "Оригінальний скан", + cleaned_version: "Очищено (рукопис видалено)", + no_cleaned: "Очищена версія ще недоступна.", + process_hint: "Натисніть 'Обробити' для аналізу та очищення аркуша.", + worksheet_print: "Друк", + worksheet_no_data: "Немає даних робочого аркуша.", + btn_full_process: "Обробити (Аналіз + Очищення + HTML)", + btn_original_generate: "Лише оригінальний HTML", + + learning_unit: "Навчальний блок", + no_unit_selected: "Блок не вибрано", + + mc_title: "Тест з вибором відповіді", + mc_ready: "Готово", + mc_generating: "Створюється...", + mc_done: "Готово", + mc_error: "Помилка", + mc_desc: "Створює питання з вибором відповіді відповідної складності (напр. 7 клас).", + mc_generate: "Створити тест", + mc_show: "Показати питання", + mc_quiz_title: "Тест з вибором відповіді", + mc_evaluate: "Оцінити", + mc_correct: "Правильно!", + mc_incorrect: "На жаль, неправильно.", + mc_not_answered: "Немає відповіді. Правильна відповідь:", + mc_result: "з", + mc_result_correct: "правильно", + mc_percent: "вірно", + mc_no_questions: "Питання для цього аркуша ще не створені.", + mc_print: "Друк", + mc_print_with_answers: "Друкувати з відповідями?", + + cloze_title: "Текст з пропусками", + cloze_desc: "Створює тексти з кількома пропусками в кожному реченні. Включаючи переклад для батьків.", + cloze_translation: "Переклад:", + cloze_generate: "Створити текст", + cloze_start: "Почати вправу", + cloze_exercise_title: "Вправа з пропусками", + cloze_instruction: "Заповніть пропуски та натисніть 'Перевірити'.", + cloze_check: "Перевірити", + cloze_show_answers: "Показати відповіді", + cloze_no_texts: "Тексти для цього аркуша ще не створені.", + cloze_sentences: "речень", + cloze_gaps: "пропусків", + cloze_gaps_total: "Всього пропусків", + cloze_with_gaps: "(з пропусками)", + cloze_print: "Друк", + cloze_print_with_answers: "Друкувати з відповідями?", + + qa_title: "Аркуш питань і відповідей", + qa_desc: "Пари питання-відповідь з системою Лейтнера. Повторення за рівнем складності.", + qa_generate: "Створити П&В", + qa_learn: "Почати навчання", + qa_print: "Друк", + qa_no_questions: "П&В для цього аркуша ще не створені.", + qa_box_new: "Новий", + qa_box_learning: "Вивчається", + qa_box_mastered: "Засвоєно", + qa_show_answer: "Показати відповідь", + qa_your_answer: "Твоя відповідь", + qa_type_answer: "Напиши свою відповідь тут...", + qa_check_answer: "Перевірити відповідь", + qa_correct_answer: "Правильна відповідь", + qa_self_evaluate: "Твоя відповідь була правильною?", + qa_no_answer: "(відповідь не введена)", + qa_correct: "Правильно", + qa_incorrect: "Неправильно", + qa_key_terms: "Ключові терміни", + qa_session_correct: "Правильно", + qa_session_incorrect: "Неправильно", + qa_session_complete: "Раунд навчання завершено!", + qa_result_correct: "правильно", + qa_restart: "Вчити знову", + qa_print_with_answers: "Друкувати з відповідями?", + question: "Питання", + answer: "Відповідь", + status_generating_qa: "Створення П&В…", + status_qa_generated: "П&В створені", + + close: "Закрити", + subject: "Предмет", + grade: "Рівень", + questions: "питань", + worksheet: "Робочий аркуш", + loading: "Завантаження...", + error: "Помилка", + success: "Успішно", + + imprint: "Імпресум", + privacy: "Конфіденційність", + contact: "Контакт", + + status_ready: "Готово", + status_processing: "Обробка...", + status_generating_mc: "Створення питань…", + status_generating_cloze: "Створення текстів…", + status_please_wait: "Будь ласка, зачекайте, ШІ працює.", + status_mc_generated: "Питання створені", + status_cloze_generated: "Тексти створені", + status_files_created: "файлів створено", + + // Mindmap Tile + mindmap_title: "Плакат Інтелект-карта", + mindmap_desc: "Створює дитячу інтелект-карту з головною темою в центрі та всіма термінами в кольорових категоріях.", + mindmap_generate: "Створити карту", + mindmap_show: "Переглянути", + mindmap_print_a3: "Друк A3", + generating_mindmap: "Створення карти...", + mindmap_generated: "Карту створено!", + no_analysis: "Немає аналізу", + analyze_first: "Спочатку виконайте аналіз (натисніть Обробити)", + categories: "Категорії", + terms: "Терміни", + }, + + pl: { + brand_sub: "Studio", + nav_compare: "Karty pracy", + nav_tiles: "Karty nauki", + login: "Logowanie / Rejestracja", + mvp_local: "MVP · Lokalnie na Twoim Mac", + + sidebar_areas: "Sekcje", + sidebar_studio: "Studio kart pracy", + sidebar_active: "aktywne", + sidebar_parents: "Kanał dla rodziców", + sidebar_soon: "wkrótce", + sidebar_correction: "Korekta / Oceny", + sidebar_units: "Jednostki nauki (lokalnie)", + input_student: "Uczeń", + input_subject: "Przedmiot", + input_grade: "Klasa (np. 7a)", + input_unit_title: "Jednostka nauki / Temat", + btn_create: "Utwórz", + btn_add_current: "Dodaj bieżącą kartę", + btn_filter_unit: "Tylko jednostka", + btn_filter_all: "Wszystkie pliki", + + uploaded_worksheets: "Przesłane karty pracy", + files: "plików", + btn_upload: "Prześlij", + btn_delete: "Usuń", + original_scan: "Oryginalny skan", + cleaned_version: "Oczyszczone (pismo ręczne usunięte)", + no_cleaned: "Oczyszczona wersja jeszcze niedostępna.", + process_hint: "Kliknij 'Przetwórz', aby przeanalizować i oczyścić kartę.", + worksheet_print: "Drukuj", + worksheet_no_data: "Brak danych arkusza.", + btn_full_process: "Przetwórz (Analiza + Czyszczenie + HTML)", + btn_original_generate: "Tylko oryginalny HTML", + + learning_unit: "Jednostka nauki", + no_unit_selected: "Nie wybrano jednostki", + + mc_title: "Test wielokrotnego wyboru", + mc_ready: "Gotowe", + mc_generating: "Tworzenie...", + mc_done: "Gotowe", + mc_error: "Błąd", + mc_desc: "Tworzy pytania wielokrotnego wyboru o odpowiednim poziomie trudności (np. klasa 7).", + mc_generate: "Utwórz test", + mc_show: "Pokaż pytania", + mc_quiz_title: "Test wielokrotnego wyboru", + mc_evaluate: "Oceń", + mc_correct: "Dobrze!", + mc_incorrect: "Niestety źle.", + mc_not_answered: "Brak odpowiedzi. Poprawna odpowiedź:", + mc_result: "z", + mc_result_correct: "poprawnie", + mc_percent: "poprawnie", + mc_no_questions: "Pytania dla tej karty jeszcze nie zostały utworzone.", + mc_print: "Drukuj", + mc_print_with_answers: "Drukować z odpowiedziami?", + + cloze_title: "Tekst z lukami", + cloze_desc: "Tworzy teksty z wieloma lukami w każdym zdaniu. W tym tłumaczenie dla rodziców.", + cloze_translation: "Tłumaczenie:", + cloze_generate: "Utwórz tekst", + cloze_start: "Rozpocznij ćwiczenie", + cloze_exercise_title: "Ćwiczenie z lukami", + cloze_instruction: "Wypełnij luki i kliknij 'Sprawdź'.", + cloze_check: "Sprawdź", + cloze_show_answers: "Pokaż odpowiedzi", + cloze_no_texts: "Teksty dla tej karty jeszcze nie zostały utworzone.", + cloze_sentences: "zdań", + cloze_gaps: "luk", + cloze_gaps_total: "Łącznie luk", + cloze_with_gaps: "(z lukami)", + cloze_print: "Drukuj", + cloze_print_with_answers: "Drukować z odpowiedziami?", + + qa_title: "Arkusz pytań i odpowiedzi", + qa_desc: "Pary pytanie-odpowiedź z systemem Leitnera. Powtórki według poziomu trudności.", + qa_generate: "Utwórz P&O", + qa_learn: "Rozpocznij naukę", + qa_print: "Drukuj", + qa_no_questions: "P&O dla tej karty jeszcze nie zostały utworzone.", + qa_box_new: "Nowy", + qa_box_learning: "W nauce", + qa_box_mastered: "Opanowane", + qa_show_answer: "Pokaż odpowiedź", + qa_your_answer: "Twoja odpowiedź", + qa_type_answer: "Napisz swoją odpowiedź tutaj...", + qa_check_answer: "Sprawdź odpowiedź", + qa_correct_answer: "Prawidłowa odpowiedź", + qa_self_evaluate: "Czy twoja odpowiedź była poprawna?", + qa_no_answer: "(nie wprowadzono odpowiedzi)", + qa_correct: "Dobrze", + qa_incorrect: "Źle", + qa_key_terms: "Kluczowe pojęcia", + qa_session_correct: "Dobrze", + qa_session_incorrect: "Źle", + qa_session_complete: "Runda nauki zakończona!", + qa_result_correct: "poprawnie", + qa_restart: "Ucz się ponownie", + qa_print_with_answers: "Drukować z odpowiedziami?", + question: "Pytanie", + answer: "Odpowiedź", + status_generating_qa: "Tworzenie P&O…", + status_qa_generated: "P&O utworzone", + + close: "Zamknij", + subject: "Przedmiot", + grade: "Poziom", + questions: "pytań", + worksheet: "Karta pracy", + loading: "Ładowanie...", + error: "Błąd", + success: "Sukces", + + imprint: "Impressum", + privacy: "Prywatność", + contact: "Kontakt", + + status_ready: "Gotowe", + status_processing: "Przetwarzanie...", + status_generating_mc: "Tworzenie pytań…", + status_generating_cloze: "Tworzenie tekstów…", + status_please_wait: "Proszę czekać, AI pracuje.", + status_mc_generated: "Pytania utworzone", + status_cloze_generated: "Teksty utworzone", + status_files_created: "plików utworzono", + + // Mindmap Tile + mindmap_title: "Plakat Mapa myśli", + mindmap_desc: "Tworzy przyjazną dla dzieci mapę myśli z głównym tematem w centrum i wszystkimi terminami w kolorowych kategoriach.", + mindmap_generate: "Utwórz mapę", + mindmap_show: "Podgląd", + mindmap_print_a3: "Drukuj A3", + generating_mindmap: "Tworzenie mapy...", + mindmap_generated: "Mapa utworzona!", + no_analysis: "Brak analizy", + analyze_first: "Najpierw wykonaj analizę (kliknij Przetwórz)", + categories: "Kategorie", + terms: "Terminy", + }, + + en: { + brand_sub: "Studio", + nav_compare: "Worksheets", + nav_tiles: "Learning Tiles", + login: "Login / Sign Up", + mvp_local: "MVP · Local on your Mac", + + sidebar_areas: "Areas", + sidebar_studio: "Worksheet Studio", + sidebar_active: "active", + sidebar_parents: "Parents Channel", + sidebar_soon: "coming soon", + sidebar_correction: "Correction / Grades", + sidebar_units: "Learning Units (local)", + input_student: "Student", + input_subject: "Subject", + input_grade: "Grade (e.g. 7a)", + input_unit_title: "Learning Unit / Topic", + btn_create: "Create", + btn_add_current: "Add current worksheet", + btn_filter_unit: "Unit only", + btn_filter_all: "All files", + + uploaded_worksheets: "Uploaded Worksheets", + files: "files", + btn_upload: "Upload", + btn_delete: "Delete", + original_scan: "Original Scan", + cleaned_version: "Cleaned (handwriting removed)", + no_cleaned: "No cleaned version available yet.", + process_hint: "Click 'Process' to analyze and clean the worksheet.", + worksheet_print: "Print", + worksheet_no_data: "No worksheet data available.", + btn_full_process: "Process (Analysis + Cleaning + HTML)", + btn_original_generate: "Generate Original HTML Only", + + learning_unit: "Learning Unit", + no_unit_selected: "No unit selected", + + mc_title: "Multiple Choice Test", + mc_ready: "Ready", + mc_generating: "Generating...", + mc_done: "Done", + mc_error: "Error", + mc_desc: "Creates multiple choice questions matching the original difficulty level (e.g. Grade 7).", + mc_generate: "Generate MC", + mc_show: "Show Questions", + mc_quiz_title: "Multiple Choice Quiz", + mc_evaluate: "Evaluate", + mc_correct: "Correct!", + mc_incorrect: "Unfortunately wrong.", + mc_not_answered: "Not answered. Correct answer:", + mc_result: "of", + mc_result_correct: "correct", + mc_percent: "correct", + mc_no_questions: "No MC questions generated yet for this worksheet.", + mc_print: "Print", + mc_print_with_answers: "Print with answers?", + + cloze_title: "Fill in the Blanks", + cloze_desc: "Creates texts with multiple meaningful gaps per sentence. Including translation for parents.", + cloze_translation: "Translation:", + cloze_generate: "Generate Cloze Text", + cloze_start: "Start Exercise", + cloze_exercise_title: "Fill in the Blanks Exercise", + cloze_instruction: "Fill in the blanks and click 'Check'.", + cloze_check: "Check", + cloze_show_answers: "Show Answers", + cloze_no_texts: "No cloze texts generated yet for this worksheet.", + cloze_sentences: "sentences", + cloze_gaps: "gaps", + cloze_gaps_total: "Total gaps", + cloze_with_gaps: "(with gaps)", + cloze_print: "Print", + cloze_print_with_answers: "Print with answers?", + + qa_title: "Question & Answer Sheet", + qa_desc: "Q&A pairs with Leitner box system. Spaced repetition by difficulty level.", + qa_generate: "Generate Q&A", + qa_learn: "Start Learning", + qa_print: "Print", + qa_no_questions: "No Q&A generated yet for this worksheet.", + qa_box_new: "New", + qa_box_learning: "Learning", + qa_box_mastered: "Mastered", + qa_show_answer: "Show Answer", + qa_your_answer: "Your Answer", + qa_type_answer: "Write your answer here...", + qa_check_answer: "Check Answer", + qa_correct_answer: "Correct Answer", + qa_self_evaluate: "Was your answer correct?", + qa_no_answer: "(no answer entered)", + qa_correct: "Correct", + qa_incorrect: "Incorrect", + qa_key_terms: "Key Terms", + qa_session_correct: "Correct", + qa_session_incorrect: "Incorrect", + qa_session_complete: "Learning session complete!", + qa_result_correct: "correct", + qa_restart: "Learn Again", + qa_print_with_answers: "Print with answers?", + question: "Question", + answer: "Answer", + status_generating_qa: "Generating Q&A…", + status_qa_generated: "Q&A generated", + + close: "Close", + subject: "Subject", + grade: "Level", + questions: "questions", + worksheet: "Worksheet", + loading: "Loading...", + error: "Error", + success: "Success", + + imprint: "Imprint", + privacy: "Privacy", + contact: "Contact", + + status_ready: "Ready", + status_processing: "Processing...", + status_generating_mc: "Generating MC questions…", + status_generating_cloze: "Generating cloze texts…", + status_please_wait: "Please wait, AI is working.", + status_mc_generated: "MC questions generated", + status_cloze_generated: "Cloze texts generated", + status_files_created: "files created", + + // Mindmap Tile + mindmap_title: "Mindmap Learning Poster", + mindmap_desc: "Creates a child-friendly mindmap with the main topic in the center and all terms in colorful categories.", + mindmap_generate: "Create Mindmap", + mindmap_show: "View", + mindmap_print_a3: "Print A3", + generating_mindmap: "Creating mindmap...", + mindmap_generated: "Mindmap created!", + no_analysis: "No analysis", + analyze_first: "Please analyze first (click Process)", + categories: "Categories", + terms: "Terms", + } + }; + + // Aktuelle Sprache (aus localStorage oder Browser-Sprache) + let currentLang = localStorage.getItem('bp_language') || 'de'; + + // RTL-Sprachen + const rtlLanguages = ['ar']; + + // Übersetzungsfunktion + function t(key) { + const lang = translations[currentLang] || translations['de']; + return lang[key] || translations['de'][key] || key; + } + + // Sprache anwenden + function applyLanguage(lang) { + currentLang = lang; + localStorage.setItem('bp_language', lang); + + // RTL für Arabisch + if (rtlLanguages.includes(lang)) { + document.documentElement.setAttribute('dir', 'rtl'); + } else { + document.documentElement.setAttribute('dir', 'ltr'); + } + + // Alle Elemente mit data-i18n aktualisieren + document.querySelectorAll('[data-i18n]').forEach(el => { + const key = el.getAttribute('data-i18n'); + if (el.tagName === 'INPUT' && el.hasAttribute('placeholder')) { + el.placeholder = t(key); + } else { + el.textContent = t(key); + } + }); + + // Spezielle Elemente manuell aktualisieren + updateUITexts(); + } + + // UI-Texte aktualisieren + function updateUITexts() { + // Header + const brandSub = document.querySelector('.brand-text-sub'); + if (brandSub) brandSub.textContent = t('brand_sub'); + + // Navigation (Text entfernt - wird nicht mehr angezeigt) + // const navItems = document.querySelectorAll('.top-nav-item'); + // if (navItems[0]) navItems[0].textContent = t('nav_compare'); + // if (navItems[1]) navItems[1].textContent = t('nav_tiles'); + + // Sidebar Bereiche + const sidebarTitles = document.querySelectorAll('.sidebar-section-title'); + if (sidebarTitles[0]) sidebarTitles[0].textContent = t('sidebar_areas'); + if (sidebarTitles[1]) sidebarTitles[1].textContent = t('sidebar_units'); + + const sidebarLabels = document.querySelectorAll('.sidebar-item-label span'); + if (sidebarLabels[0]) sidebarLabels[0].textContent = t('sidebar_studio'); + if (sidebarLabels[1]) sidebarLabels[1].textContent = t('sidebar_parents'); + if (sidebarLabels[2]) sidebarLabels[2].textContent = t('sidebar_correction'); + + const sidebarBadges = document.querySelectorAll('.sidebar-item-badge'); + if (sidebarBadges[0]) sidebarBadges[0].textContent = t('sidebar_active'); + if (sidebarBadges[1]) sidebarBadges[1].textContent = t('sidebar_soon'); + if (sidebarBadges[2]) sidebarBadges[2].textContent = t('sidebar_soon'); + + // Input Placeholders + if (unitStudentInput) unitStudentInput.placeholder = t('input_student'); + if (unitSubjectInput) unitSubjectInput.placeholder = t('input_subject'); + if (unitGradeInput) unitGradeInput.placeholder = t('input_grade'); + if (unitTitleInput) unitTitleInput.placeholder = t('input_unit_title'); + + // Buttons + if (btnAddUnit) btnAddUnit.textContent = t('btn_create'); + if (btnAttachCurrentToLu) btnAttachCurrentToLu.textContent = t('btn_add_current'); + if (btnToggleFilter) { + btnToggleFilter.textContent = showOnlyUnitFiles ? t('btn_filter_unit') : t('btn_filter_all'); + } + if (btnFullProcess) btnFullProcess.textContent = t('btn_full_process'); + if (btnOriginalGenerate) btnOriginalGenerate.textContent = t('btn_original_generate'); + + // Titles + const uploadedTitle = document.querySelector('.panel-left > div:first-child'); + if (uploadedTitle) { + uploadedTitle.innerHTML = '' + t('uploaded_worksheets') + ' 0 ' + t('files') + ''; + } + + // MC Tile + const mcTitle = document.querySelector('[data-tile="mc"] .card-title'); + if (mcTitle) mcTitle.textContent = t('mc_title'); + const mcDesc = document.querySelector('[data-tile="mc"] .card-body > div:first-child'); + if (mcDesc) mcDesc.textContent = t('mc_desc'); + if (btnMcGenerate) btnMcGenerate.textContent = t('mc_generate'); + if (btnMcShow) btnMcShow.textContent = t('mc_show'); + + // Cloze Tile + const clozeTitle = document.querySelector('[data-tile="cloze"] .card-title'); + if (clozeTitle) clozeTitle.textContent = t('cloze_title'); + const clozeDesc = document.querySelector('[data-tile="cloze"] .card-body > div:first-child'); + if (clozeDesc) clozeDesc.textContent = t('cloze_desc'); + const clozeLabel = document.querySelector('.cloze-language-select label'); + if (clozeLabel) clozeLabel.textContent = t('cloze_translation'); + if (btnClozeGenerate) btnClozeGenerate.textContent = t('cloze_generate'); + if (btnClozeShow) btnClozeShow.textContent = t('cloze_start'); + + // QA Tile + const qaTitle = document.querySelector('[data-tile="qa"] .card-title'); + if (qaTitle) qaTitle.textContent = t('qa_title'); + const qaDesc = document.querySelector('[data-tile="qa"] .card-body > div:first-child'); + if (qaDesc) qaDesc.textContent = t('qa_desc'); + const qaBadge = document.querySelector('[data-tile="qa"] .card-badge'); + if (qaBadge) qaBadge.textContent = t('qa_soon'); + + // Modal Titles + const mcModalTitle = document.querySelector('#mc-modal .mc-modal-title'); + if (mcModalTitle) mcModalTitle.textContent = t('mc_quiz_title'); + const clozeModalTitle = document.querySelector('#cloze-modal .mc-modal-title'); + if (clozeModalTitle) clozeModalTitle.textContent = t('cloze_exercise_title'); + + // Close Buttons + document.querySelectorAll('.mc-modal-close').forEach(btn => { + btn.textContent = t('close') + ' ✕'; + }); + if (lightboxClose) lightboxClose.textContent = t('close') + ' ✕'; + + // Footer + const footerLinks = document.querySelectorAll('.footer a'); + if (footerLinks[0]) footerLinks[0].textContent = t('imprint'); + if (footerLinks[1]) footerLinks[1].textContent = t('privacy'); + if (footerLinks[2]) footerLinks[2].textContent = t('contact'); + } + + const eingangListEl = document.getElementById('eingang-list'); + const eingangCountEl = document.getElementById('eingang-count'); + const previewContainer = document.getElementById('preview-container'); + const fileInput = document.getElementById('file-input'); + const btnUploadInline = document.getElementById('btn-upload-inline'); + const btnFullProcess = document.getElementById('btn-full-process'); + const btnOriginalGenerate = document.getElementById('btn-original-generate'); + const statusBar = document.getElementById('status-bar'); + const statusDot = document.getElementById('status-dot'); + const statusMain = document.getElementById('status-main'); + const statusSub = document.getElementById('status-sub'); + + const panelCompare = document.getElementById('panel-compare'); + const panelTiles = document.getElementById('panel-tiles'); + const pagerPrev = document.getElementById('pager-prev'); + const pagerNext = document.getElementById('pager-next'); + const pagerLabel = document.getElementById('pager-label'); + + const topNavItems = document.querySelectorAll('.top-nav-item'); + + const lightboxEl = document.getElementById('lightbox'); + const lightboxImg = document.getElementById('lightbox-img'); + const lightboxCaption = document.getElementById('lightbox-caption'); + const lightboxClose = document.getElementById('lightbox-close'); + + const unitStudentInput = document.getElementById('unit-student'); + const unitSubjectInput = document.getElementById('unit-subject'); + const unitGradeInput = document.getElementById('unit-grade'); + const unitTitleInput = document.getElementById('unit-title'); + const unitListEl = document.getElementById('unit-list'); + const btnAddUnit = document.getElementById('btn-add-unit'); + const btnAttachCurrentToLu = document.getElementById('btn-attach-current-to-lu'); + const unitHeading1 = document.getElementById('unit-heading-screen1'); + const unitHeading2 = document.getElementById('unit-heading-screen2'); + const btnToggleFilter = document.getElementById('btn-toggle-filter'); + + let currentSelectedFile = null; + let allEingangFiles = []; // Master-Liste aller Dateien + let eingangFiles = []; // aktuell gefilterte Ansicht + let currentIndex = 0; + let showOnlyUnitFiles = true; // Filter-Modus: true = nur Lerneinheit (Standard), false = alle + + let allWorksheetPairs = {}; // Master-Mapping original -> { clean_html, clean_image } + let worksheetPairs = {}; // aktuell gefiltertes Mapping + + let tileState = { + mindmap: true, + qa: true, + mc: true, + cloze: true, + }; + let currentScreen = 1; + + // Lerneinheiten aus dem Backend + let units = []; + let currentUnitId = null; + + // --- Lightbox / Vollbild --- + function openLightbox(src, caption) { + if (!src) return; + lightboxImg.src = src; + lightboxCaption.textContent = caption || ''; + lightboxEl.classList.remove('hidden'); + } + + function closeLightbox() { + lightboxEl.classList.add('hidden'); + lightboxImg.src = ''; + lightboxCaption.textContent = ''; + } + + lightboxClose.addEventListener('click', closeLightbox); + lightboxEl.addEventListener('click', (ev) => { + if (ev.target === lightboxEl) { + closeLightbox(); + } + }); + document.addEventListener('keydown', (ev) => { + if (ev.key === 'Escape') { + closeLightbox(); + // Close compare view if open + const compareView = document.getElementById('version-compare-view'); + if (compareView && compareView.classList.contains('active')) { + hideCompareView(); + } + } + }); + + // --- Status-Balken --- + function setStatus(main, sub = '', state = 'idle') { + statusMain.textContent = main; + statusSub.textContent = sub; + statusDot.classList.remove('busy', 'error'); + if (state === 'busy') { + statusDot.classList.add('busy'); + } else if (state === 'error') { + statusDot.classList.add('error'); + } + } + + setStatus('Bereit', 'Lade Arbeitsblätter hoch und starte den Neuaufbau.'); + + // --- API-Helfer --- + async function apiFetch(url, options = {}) { + const resp = await fetch(url, options); + if (!resp.ok) { + throw new Error('HTTP ' + resp.status); + } + return resp.json(); + } + + // --- Dateien laden & rendern --- + async function loadEingangFiles() { + try { + const data = await apiFetch('/api/eingang-dateien'); + allEingangFiles = data.eingang || []; + eingangFiles = allEingangFiles.slice(); + currentIndex = 0; + renderEingangList(); + } catch (e) { + console.error(e); + setStatus('Fehler beim Laden der Dateien', String(e), 'error'); + } + } + + function renderEingangList() { + eingangListEl.innerHTML = ''; + + if (!eingangFiles.length) { + const li = document.createElement('li'); + li.className = 'file-empty'; + li.textContent = 'Noch keine Dateien vorhanden.'; + eingangListEl.appendChild(li); + eingangCountEl.textContent = '0 Dateien'; + return; + } + + eingangFiles.forEach((filename, idx) => { + const li = document.createElement('li'); + li.className = 'file-item'; + if (idx === currentIndex) { + li.classList.add('active'); + } + + const nameSpan = document.createElement('span'); + nameSpan.className = 'file-item-name'; + nameSpan.textContent = filename; + + const actionsSpan = document.createElement('span'); + actionsSpan.style.display = 'flex'; + actionsSpan.style.gap = '6px'; + + // Button: Aus Lerneinheit entfernen + const removeFromUnitBtn = document.createElement('span'); + removeFromUnitBtn.className = 'file-item-delete'; + removeFromUnitBtn.textContent = '✕'; + removeFromUnitBtn.title = 'Aus Lerneinheit entfernen'; + removeFromUnitBtn.addEventListener('click', (ev) => { + ev.stopPropagation(); + + if (!currentUnitId) { + alert('Zum Entfernen bitte zuerst eine Lerneinheit auswählen.'); + return; + } + + const ok = confirm('Dieses Arbeitsblatt aus der aktuellen Lerneinheit entfernen? Die Datei selbst bleibt erhalten.'); + if (!ok) return; + + removeWorksheetFromCurrentUnit(eingangFiles[idx]); + }); + + // Button: Datei komplett löschen + const deleteFileBtn = document.createElement('span'); + deleteFileBtn.className = 'file-item-delete'; + deleteFileBtn.textContent = '🗑️'; + deleteFileBtn.title = 'Datei komplett löschen'; + deleteFileBtn.style.color = '#ef4444'; + deleteFileBtn.addEventListener('click', async (ev) => { + ev.stopPropagation(); + + const ok = confirm(`Datei "${eingangFiles[idx]}" wirklich komplett löschen? Diese Aktion kann nicht rückgängig gemacht werden.`); + if (!ok) return; + + await deleteFileCompletely(eingangFiles[idx]); + }); + + actionsSpan.appendChild(removeFromUnitBtn); + actionsSpan.appendChild(deleteFileBtn); + + li.appendChild(nameSpan); + li.appendChild(actionsSpan); + + li.addEventListener('click', () => { + currentIndex = idx; + currentSelectedFile = filename; + renderEingangList(); + renderPreviewForCurrent(); + }); + + eingangListEl.appendChild(li); + }); + + eingangCountEl.textContent = eingangFiles.length + (eingangFiles.length === 1 ? ' Datei' : ' Dateien'); + } + + async function loadWorksheetPairs() { + try { + const data = await apiFetch('/api/worksheet-pairs'); + allWorksheetPairs = {}; + (data.pairs || []).forEach((p) => { + allWorksheetPairs[p.original] = { clean_html: p.clean_html, clean_image: p.clean_image }; + }); + worksheetPairs = { ...allWorksheetPairs }; + renderPreviewForCurrent(); + } catch (e) { + console.error(e); + setStatus('Fehler beim Laden der Neuaufbau-Daten', String(e), 'error'); + } + } + + function renderPreviewForCurrent() { + if (!eingangFiles.length) { + const message = showOnlyUnitFiles && currentUnitId + ? 'Dieser Lerneinheit sind noch keine Arbeitsblätter zugeordnet.' + : 'Keine Dateien vorhanden.'; + previewContainer.innerHTML = `
${message}
`; + return; + } + if (currentIndex < 0) currentIndex = 0; + if (currentIndex >= eingangFiles.length) currentIndex = eingangFiles.length - 1; + + const filename = eingangFiles[currentIndex]; + const entry = worksheetPairs[filename] || { clean_html: null, clean_image: null }; + + renderPreview(entry, currentIndex); + } + + function renderThumbnailsInColumn(container) { + container.innerHTML = ''; + + if (eingangFiles.length <= 1) { + return; // Keine Thumbnails nötig wenn nur 1 oder 0 Dateien + } + + // Zeige bis zu 5 Thumbnails (die nächsten Dateien nach dem aktuellen) + const maxThumbs = 5; + let thumbCount = 0; + + for (let i = 0; i < eingangFiles.length && thumbCount < maxThumbs; i++) { + if (i === currentIndex) continue; // Aktuelles Dokument überspringen + + const filename = eingangFiles[i]; + const thumb = document.createElement('div'); + thumb.className = 'preview-thumb'; + + const img = document.createElement('img'); + img.src = '/preview-file/' + encodeURIComponent(filename); + img.alt = filename; + + const label = document.createElement('div'); + label.className = 'preview-thumb-label'; + label.textContent = `${i + 1}`; + + thumb.appendChild(img); + thumb.appendChild(label); + + thumb.addEventListener('click', () => { + currentIndex = i; + renderEingangList(); + renderPreviewForCurrent(); + }); + + container.appendChild(thumb); + thumbCount++; + } + } + + function renderPreview(entry, index) { + previewContainer.innerHTML = ''; + + const wrapper = document.createElement('div'); + wrapper.className = 'compare-wrapper'; + + // Original + const originalSection = document.createElement('div'); + originalSection.className = 'compare-section'; + const origHeader = document.createElement('div'); + origHeader.className = 'compare-header'; + origHeader.innerHTML = 'Original-ScanAlt (links)'; + const origBody = document.createElement('div'); + origBody.className = 'compare-body'; + const origInner = document.createElement('div'); + origInner.className = 'compare-body-inner'; + + const img = document.createElement('img'); + img.className = 'preview-img'; + const imgSrc = '/preview-file/' + encodeURIComponent(eingangFiles[index]); + img.src = imgSrc; + img.alt = 'Original ' + eingangFiles[index]; + img.addEventListener('dblclick', () => openLightbox(imgSrc, eingangFiles[index])); + + origInner.appendChild(img); + origBody.appendChild(origInner); + originalSection.appendChild(origHeader); + originalSection.appendChild(origBody); + + // Neu aufgebaut + const cleanSection = document.createElement('div'); + cleanSection.className = 'compare-section'; + const cleanHeader = document.createElement('div'); + cleanHeader.className = 'compare-header'; + cleanHeader.innerHTML = 'Neu aufgebautes ArbeitsblattNeu (rechts)'; + const cleanBody = document.createElement('div'); + cleanBody.className = 'compare-body'; + const cleanInner = document.createElement('div'); + cleanInner.className = 'compare-body-inner'; + + // Bevorzuge bereinigtes Bild über HTML (für pixel-genaue Darstellung) + if (entry.clean_image) { + const imgClean = document.createElement('img'); + imgClean.className = 'preview-img'; + const cleanSrc = '/preview-clean-file/' + encodeURIComponent(entry.clean_image); + imgClean.src = cleanSrc; + imgClean.alt = 'Neu aufgebaut ' + eingangFiles[index]; + imgClean.addEventListener('dblclick', () => openLightbox(cleanSrc, eingangFiles[index] + ' (neu)')); + cleanInner.appendChild(imgClean); + } else if (entry.clean_html) { + const frame = document.createElement('iframe'); + frame.className = 'clean-frame'; + frame.src = '/api/clean-html/' + encodeURIComponent(entry.clean_html); + frame.title = 'Neu aufgebautes Arbeitsblatt'; + frame.addEventListener('dblclick', () => { + window.open('/api/clean-html/' + encodeURIComponent(entry.clean_html), '_blank'); + }); + cleanInner.appendChild(frame); + } else { + cleanInner.innerHTML = '
Noch keine Neuaufbau-Daten vorhanden.
'; + } + + cleanBody.appendChild(cleanInner); + cleanSection.appendChild(cleanHeader); + cleanSection.appendChild(cleanBody); + + // Print-Button Event-Listener + const printWorksheetBtn = cleanHeader.querySelector('#btn-print-worksheet'); + if (printWorksheetBtn) { + printWorksheetBtn.addEventListener('click', () => { + const currentFile = eingangFiles[currentIndex]; + if (!currentFile) { + alert(t('worksheet_no_data') || 'Keine Arbeitsblatt-Daten vorhanden.'); + return; + } + window.open('/api/print-worksheet/' + encodeURIComponent(currentFile), '_blank'); + }); + } + + // Thumbnails in der Mitte + const thumbsColumn = document.createElement('div'); + thumbsColumn.className = 'preview-thumbnails'; + thumbsColumn.id = 'preview-thumbnails-middle'; + renderThumbnailsInColumn(thumbsColumn); + + wrapper.appendChild(originalSection); + wrapper.appendChild(thumbsColumn); + wrapper.appendChild(cleanSection); + + // Navigation-Buttons hinzufügen + const navDiv = document.createElement('div'); + navDiv.className = 'preview-nav'; + + const prevBtn = document.createElement('button'); + prevBtn.type = 'button'; + prevBtn.textContent = '‹'; + prevBtn.disabled = currentIndex === 0; + prevBtn.addEventListener('click', () => { + if (currentIndex > 0) { + currentIndex--; + renderEingangList(); + renderPreviewForCurrent(); + } + }); + + const nextBtn = document.createElement('button'); + nextBtn.type = 'button'; + nextBtn.textContent = '›'; + nextBtn.disabled = currentIndex >= eingangFiles.length - 1; + nextBtn.addEventListener('click', () => { + if (currentIndex < eingangFiles.length - 1) { + currentIndex++; + renderEingangList(); + renderPreviewForCurrent(); + } + }); + + const positionSpan = document.createElement('span'); + positionSpan.textContent = `${currentIndex + 1} von ${eingangFiles.length}`; + + navDiv.appendChild(prevBtn); + navDiv.appendChild(positionSpan); + navDiv.appendChild(nextBtn); + + wrapper.appendChild(navDiv); + + previewContainer.appendChild(wrapper); + } + + // --- Upload --- + btnUploadInline.addEventListener('click', async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + + const files = fileInput.files; + if (!files || !files.length) { + alert('Bitte erst Dateien auswählen.'); + return; + } + + const formData = new FormData(); + for (const file of files) { + formData.append('files', file); + } + + try { + setStatus('Upload läuft …', 'Dateien werden in den Ordner „Eingang“ geschrieben.', 'busy'); + + const resp = await fetch('/api/upload-multi', { + method: 'POST', + body: formData, + }); + + if (!resp.ok) { + console.error('Upload-Fehler: HTTP', resp.status); + setStatus('Fehler beim Upload', 'Serverantwort: HTTP ' + resp.status, 'error'); + return; + } + + setStatus('Upload abgeschlossen', 'Dateien wurden gespeichert.'); + fileInput.value = ''; + + // Liste neu laden + await loadEingangFiles(); + await loadWorksheetPairs(); + } catch (e) { + console.error('Netzwerkfehler beim Upload', e); + setStatus('Netzwerkfehler beim Upload', String(e), 'error'); + } + }); + + // --- Vollpipeline --- + async function runFullPipeline() { + try { + setStatus('Entferne Handschrift …', 'Bilder werden aufbereitet.', 'busy'); + await apiFetch('/api/remove-handwriting-all', { method: 'POST' }); + + setStatus('Analysiere Arbeitsblätter …', 'Struktur wird erkannt.', 'busy'); + await apiFetch('/api/analyze-all', { method: 'POST' }); + + setStatus('Erzeuge HTML-Arbeitsblätter …', 'Neuaufbau läuft.', 'busy'); + await apiFetch('/api/generate-clean', { method: 'POST' }); + + setStatus('Fertig', 'Alt & Neu können jetzt verglichen werden.'); + await loadWorksheetPairs(); + renderPreviewForCurrent(); + } catch (e) { + console.error(e); + setStatus('Fehler in der Verarbeitung', String(e), 'error'); + } + } + + if (btnFullProcess) btnFullProcess.addEventListener('click', runFullPipeline); + if (btnOriginalGenerate) btnOriginalGenerate.addEventListener('click', runFullPipeline); + + // --- Screen-Navigation (oben + Pager unten) --- + function updateScreen() { + if (currentScreen === 1) { + panelCompare.style.display = 'flex'; + panelTiles.style.display = 'none'; + pagerLabel.textContent = '1 von 2'; + } else { + panelCompare.style.display = 'none'; + panelTiles.style.display = 'flex'; + pagerLabel.textContent = '2 von 2'; + } + + topNavItems.forEach((item) => { + const screen = Number(item.getAttribute('data-screen')); + if (screen === currentScreen) { + item.classList.add('active'); + } else { + item.classList.remove('active'); + } + }); + } + + topNavItems.forEach((item) => { + item.addEventListener('click', () => { + const screen = Number(item.getAttribute('data-screen')); + currentScreen = screen; + updateScreen(); + }); + }); + + pagerPrev.addEventListener('click', () => { + if (currentScreen > 1) { + currentScreen -= 1; + updateScreen(); + } + }); + + pagerNext.addEventListener('click', () => { + if (currentScreen < 2) { + currentScreen += 1; + updateScreen(); + } + }); + + // --- Toggle-Kacheln --- + const tileToggles = document.querySelectorAll('.toggle-pill'); + const cards = document.querySelectorAll('.card'); + + function updateTiles() { + let activeTiles = Object.keys(tileState).filter((k) => tileState[k]); + cards.forEach((card) => { + const key = card.getAttribute('data-tile'); + if (!tileState[key]) { + card.classList.add('card-hidden'); + } else { + card.classList.remove('card-hidden'); + } + card.classList.remove('card-full'); + }); + + if (activeTiles.length === 1) { + const only = activeTiles[0]; + cards.forEach((card) => { + if (card.getAttribute('data-tile') === only) { + card.classList.add('card-full'); + } + }); + } + } + + tileToggles.forEach((btn) => { + btn.addEventListener('click', () => { + const key = btn.getAttribute('data-tile'); + tileState[key] = !tileState[key]; + btn.classList.toggle('active', tileState[key]); + updateTiles(); + }); + }); + + // --- Lerneinheiten-Logik (Backend) --- + function updateUnitHeading(unit = null) { + if (!unit && currentUnitId && units && units.length) { + unit = units.find((u) => u.id === currentUnitId) || null; + } + + let text = 'Keine Lerneinheit ausgewählt'; + if (unit) { + const name = unit.label || unit.title || 'Lerneinheit'; + text = 'Lerneinheit: ' + name; + } + + if (unitHeading1) unitHeading1.textContent = text; + if (unitHeading2) unitHeading2.textContent = text; + } + + function applyUnitFilter() { + let unit = null; + if (currentUnitId && units && units.length) { + unit = units.find((u) => u.id === currentUnitId) || null; + } + + // Wenn Filter deaktiviert ODER keine Lerneinheit ausgewählt -> alle Dateien anzeigen + if (!showOnlyUnitFiles || !unit || !Array.isArray(unit.worksheet_files) || unit.worksheet_files.length === 0) { + eingangFiles = allEingangFiles.slice(); + worksheetPairs = { ...allWorksheetPairs }; + currentIndex = 0; + renderEingangList(); + renderPreviewForCurrent(); + updateUnitHeading(unit); + return; + } + + // Filter aktiv: nur Dateien der aktuellen Lerneinheit anzeigen + const allowed = new Set(unit.worksheet_files || []); + eingangFiles = allEingangFiles.filter((f) => allowed.has(f)); + + const filteredPairs = {}; + Object.keys(allWorksheetPairs).forEach((key) => { + if (allowed.has(key)) { + filteredPairs[key] = allWorksheetPairs[key]; + } + }); + worksheetPairs = filteredPairs; + + currentIndex = 0; + renderEingangList(); + renderPreviewForCurrent(); + updateUnitHeading(unit); + } + + async function loadLearningUnits() { + try { + const resp = await fetch('/api/learning-units/'); + if (!resp.ok) { + console.error('Fehler beim Laden der Lerneinheiten', resp.status); + return; + } + units = await resp.json(); + if (units.length && !currentUnitId) { + currentUnitId = units[0].id; + } + renderUnits(); + applyUnitFilter(); + } catch (e) { + console.error('Netzwerkfehler beim Laden der Lerneinheiten', e); + } + } + + function renderUnits() { + unitListEl.innerHTML = ''; + + if (!units.length) { + const li = document.createElement('li'); + li.className = 'unit-item'; + li.textContent = 'Noch keine Lerneinheiten angelegt.'; + unitListEl.appendChild(li); + updateUnitHeading(null); + return; + } + + units.forEach((u) => { + const li = document.createElement('li'); + li.className = 'unit-item'; + if (u.id === currentUnitId) { + li.classList.add('active'); + } + + const contentDiv = document.createElement('div'); + contentDiv.style.flex = '1'; + contentDiv.style.minWidth = '0'; + + const titleEl = document.createElement('div'); + titleEl.textContent = u.label || u.title || 'Lerneinheit'; + + const metaEl = document.createElement('div'); + metaEl.className = 'unit-item-meta'; + + const metaParts = []; + if (u.meta) { + metaParts.push(u.meta); + } + if (Array.isArray(u.worksheet_files)) { + metaParts.push('Blätter: ' + u.worksheet_files.length); + } + + metaEl.textContent = metaParts.join(' · '); + + contentDiv.appendChild(titleEl); + contentDiv.appendChild(metaEl); + + // Delete-Button + const deleteBtn = document.createElement('span'); + deleteBtn.textContent = '🗑️'; + deleteBtn.style.cursor = 'pointer'; + deleteBtn.style.fontSize = '12px'; + deleteBtn.style.color = '#ef4444'; + deleteBtn.title = 'Lerneinheit löschen'; + deleteBtn.addEventListener('click', async (ev) => { + ev.stopPropagation(); + const ok = confirm(`Lerneinheit "${u.label || u.title}" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.`); + if (!ok) return; + await deleteLearningUnit(u.id); + }); + + li.appendChild(contentDiv); + li.appendChild(deleteBtn); + + li.addEventListener('click', () => { + currentUnitId = u.id; + renderUnits(); + applyUnitFilter(); + }); + + unitListEl.appendChild(li); + }); + } + + async function addUnitFromForm() { + const student = (unitStudentInput.value || '').trim(); + const subject = (unitSubjectInput.value || '').trim(); + const grade = (unitGradeInput && unitGradeInput.value || '').trim(); + const title = (unitTitleInput.value || '').trim(); + + if (!student && !subject && !title) { + alert('Bitte mindestens einen Wert (Schüler/in, Fach oder Thema) eintragen.'); + return; + } + + const payload = { + student, + subject, + title, + grade, + }; + + try { + const resp = await fetch('/api/learning-units/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + console.error('Fehler beim Anlegen der Lerneinheit', resp.status); + alert('Lerneinheit konnte nicht angelegt werden.'); + return; + } + + const created = await resp.json(); + units.push(created); + currentUnitId = created.id; + + unitStudentInput.value = ''; + unitSubjectInput.value = ''; + unitTitleInput.value = ''; + if (unitGradeInput) unitGradeInput.value = ''; + + renderUnits(); + applyUnitFilter(); + } catch (e) { + console.error('Netzwerkfehler beim Anlegen der Lerneinheit', e); + alert('Netzwerkfehler beim Anlegen der Lerneinheit.'); + } + } + + function getCurrentWorksheetBasename() { + if (!eingangFiles.length) return null; + if (currentIndex < 0 || currentIndex >= eingangFiles.length) return null; + return eingangFiles[currentIndex]; + } + + async function attachCurrentWorksheetToUnit() { + if (!currentUnitId) { + alert('Bitte zuerst eine Lerneinheit auswählen oder anlegen.'); + return; + } + const basename = getCurrentWorksheetBasename(); + if (!basename) { + alert('Bitte zuerst ein Arbeitsblatt im linken Bereich auswählen.'); + return; + } + + const payload = { worksheet_files: [basename] }; + + try { + const resp = await fetch(`/api/learning-units/${currentUnitId}/attach-worksheets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + console.error('Fehler beim Zuordnen des Arbeitsblatts', resp.status); + alert('Arbeitsblatt konnte nicht zugeordnet werden.'); + return; + } + + const updated = await resp.json(); + const idx = units.findIndex((u) => u.id === updated.id); + if (idx !== -1) { + units[idx] = updated; + } + renderUnits(); + applyUnitFilter(); + } catch (e) { + console.error('Netzwerkfehler beim Zuordnen des Arbeitsblatts', e); + alert('Netzwerkfehler beim Zuordnen des Arbeitsblatts.'); + } + } + async function removeWorksheetFromCurrentUnit(filename) { + if (!currentUnitId) { + alert('Bitte zuerst eine Lerneinheit auswählen.'); + return; + } + if (!filename) { + alert('Fehler: kein Dateiname übergeben.'); + return; + } + + const payload = { worksheet_file: filename }; + + try { + const resp = await fetch(`/api/learning-units/${currentUnitId}/remove-worksheet`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + console.error('Fehler beim Entfernen des Arbeitsblatts', resp.status); + alert('Arbeitsblatt konnte nicht aus der Lerneinheit entfernt werden.'); + return; + } + + const updated = await resp.json(); + const idx = units.findIndex((u) => u.id === updated.id); + if (idx !== -1) { + units[idx] = updated; + } + renderUnits(); + applyUnitFilter(); + } catch (e) { + console.error('Netzwerkfehler beim Entfernen des Arbeitsblatts', e); + alert('Netzwerkfehler beim Entfernen des Arbeitsblatts.'); + } + } + + async function deleteFileCompletely(filename) { + if (!filename) { + alert('Fehler: kein Dateiname übergeben.'); + return; + } + + try { + setStatus('Lösche Datei …', filename, 'busy'); + + const resp = await fetch(`/api/eingang-dateien/${encodeURIComponent(filename)}`, { + method: 'DELETE', + }); + + if (!resp.ok) { + console.error('Fehler beim Löschen der Datei', resp.status); + setStatus('Fehler beim Löschen', filename, 'error'); + alert('Datei konnte nicht gelöscht werden.'); + return; + } + + const result = await resp.json(); + if (result.status === 'OK') { + setStatus('Datei gelöscht', filename); + // Dateien neu laden + await loadEingangFiles(); + await loadWorksheetPairs(); + await loadLearningUnits(); + } else { + setStatus('Fehler', result.message, 'error'); + alert(result.message); + } + } catch (e) { + console.error('Netzwerkfehler beim Löschen der Datei', e); + setStatus('Netzwerkfehler', String(e), 'error'); + alert('Netzwerkfehler beim Löschen der Datei.'); + } + } + + async function deleteLearningUnit(unitId) { + if (!unitId) { + alert('Fehler: keine Lerneinheit-ID übergeben.'); + return; + } + + try { + setStatus('Lösche Lerneinheit …', '', 'busy'); + + const resp = await fetch(`/api/learning-units/${unitId}`, { + method: 'DELETE', + }); + + if (!resp.ok) { + console.error('Fehler beim Löschen der Lerneinheit', resp.status); + setStatus('Fehler beim Löschen', '', 'error'); + alert('Lerneinheit konnte nicht gelöscht werden.'); + return; + } + + const result = await resp.json(); + if (result.status === 'deleted') { + setStatus('Lerneinheit gelöscht', ''); + + // Lerneinheit aus der lokalen Liste entfernen + units = units.filter((u) => u.id !== unitId); + + // Wenn die gelöschte Einheit ausgewählt war, Auswahl zurücksetzen + if (currentUnitId === unitId) { + currentUnitId = units.length > 0 ? units[0].id : null; + } + + renderUnits(); + applyUnitFilter(); + } else { + setStatus('Fehler', 'Unbekannter Fehler', 'error'); + alert('Fehler beim Löschen der Lerneinheit.'); + } + } catch (e) { + console.error('Netzwerkfehler beim Löschen der Lerneinheit', e); + setStatus('Netzwerkfehler', String(e), 'error'); + alert('Netzwerkfehler beim Löschen der Lerneinheit.'); + } + } + + if (btnAddUnit) { + btnAddUnit.addEventListener('click', (ev) => { + ev.preventDefault(); + addUnitFromForm(); + }); + } + + if (btnAttachCurrentToLu) { + btnAttachCurrentToLu.addEventListener('click', (ev) => { + ev.preventDefault(); + attachCurrentWorksheetToUnit(); + }); + } + + // --- Filter-Toggle --- + if (btnToggleFilter) { + btnToggleFilter.addEventListener('click', () => { + showOnlyUnitFiles = !showOnlyUnitFiles; + if (showOnlyUnitFiles) { + btnToggleFilter.textContent = 'Nur Lerneinheit'; + btnToggleFilter.classList.add('btn-primary'); + } else { + btnToggleFilter.textContent = 'Alle Dateien'; + btnToggleFilter.classList.remove('btn-primary'); + } + applyUnitFilter(); + }); + } + + // --- Multiple Choice Logik --- + const btnMcGenerate = document.getElementById('btn-mc-generate'); + const btnMcShow = document.getElementById('btn-mc-show'); + const btnMcPrint = document.getElementById('btn-mc-print'); + const mcPreview = document.getElementById('mc-preview'); + const mcBadge = document.getElementById('mc-badge'); + const mcModal = document.getElementById('mc-modal'); + const mcModalBody = document.getElementById('mc-modal-body'); + const mcModalClose = document.getElementById('mc-modal-close'); + + let currentMcData = null; + let mcAnswers = {}; // Speichert Nutzerantworten + + async function generateMcQuestions() { + try { + setStatus('Generiere MC-Fragen …', 'Bitte warten, KI arbeitet.', 'busy'); + if (mcBadge) mcBadge.textContent = 'Generiert...'; + + const resp = await fetch('/api/generate-mc', { method: 'POST' }); + if (!resp.ok) { + throw new Error('HTTP ' + resp.status); + } + + const result = await resp.json(); + if (result.status === 'OK' && result.generated.length > 0) { + setStatus('MC-Fragen generiert', result.generated.length + ' Dateien erstellt.'); + if (mcBadge) mcBadge.textContent = 'Fertig'; + if (btnMcShow) btnMcShow.style.display = 'inline-block'; + if (btnMcPrint) btnMcPrint.style.display = 'inline-block'; + + // Lade die erste MC-Datei für Vorschau + await loadMcPreviewForCurrent(); + } else if (result.errors && result.errors.length > 0) { + setStatus('Fehler bei MC-Generierung', result.errors[0].error, 'error'); + if (mcBadge) mcBadge.textContent = 'Fehler'; + } else { + setStatus('Keine MC-Fragen generiert', 'Möglicherweise fehlen Analyse-Daten.', 'error'); + if (mcBadge) mcBadge.textContent = 'Bereit'; + } + } catch (e) { + console.error('MC-Generierung fehlgeschlagen:', e); + setStatus('Fehler bei MC-Generierung', String(e), 'error'); + if (mcBadge) mcBadge.textContent = 'Fehler'; + } + } + + async function loadMcPreviewForCurrent() { + if (!eingangFiles.length) { + if (mcPreview) mcPreview.innerHTML = '
Keine Arbeitsblätter vorhanden.
'; + return; + } + + const currentFile = eingangFiles[currentIndex]; + if (!currentFile) return; + + try { + const resp = await fetch('/api/mc-data/' + encodeURIComponent(currentFile)); + const result = await resp.json(); + + if (result.status === 'OK' && result.data) { + currentMcData = result.data; + renderMcPreview(result.data); + if (btnMcShow) btnMcShow.style.display = 'inline-block'; + if (btnMcPrint) btnMcPrint.style.display = 'inline-block'; + } else { + if (mcPreview) mcPreview.innerHTML = '
Noch keine MC-Fragen für dieses Arbeitsblatt generiert.
'; + currentMcData = null; + if (btnMcPrint) btnMcPrint.style.display = 'none'; + } + } catch (e) { + console.error('Fehler beim Laden der MC-Daten:', e); + if (mcPreview) mcPreview.innerHTML = ''; + } + } + + function renderMcPreview(mcData) { + if (!mcPreview) return; + if (!mcData || !mcData.questions || mcData.questions.length === 0) { + mcPreview.innerHTML = '
Keine Fragen vorhanden.
'; + return; + } + + const questions = mcData.questions; + const metadata = mcData.metadata || {}; + + let html = ''; + + // Zeige Metadaten + if (metadata.grade_level || metadata.subject) { + html += '
'; + if (metadata.subject) { + html += '
Fach: ' + metadata.subject + '
'; + } + if (metadata.grade_level) { + html += '
Stufe: ' + metadata.grade_level + '
'; + } + html += '
Fragen: ' + questions.length + '
'; + html += '
'; + } + + // Zeige erste 2 Fragen als Vorschau + const previewQuestions = questions.slice(0, 2); + previewQuestions.forEach((q, idx) => { + html += '
'; + html += '
' + (idx + 1) + '. ' + q.question + '
'; + html += '
'; + q.options.forEach(opt => { + html += '
'; + html += '' + opt.id + ') ' + opt.text; + html += '
'; + }); + html += '
'; + html += '
'; + }); + + if (questions.length > 2) { + html += '
+ ' + (questions.length - 2) + ' weitere Fragen
'; + } + + mcPreview.innerHTML = html; + + // Event-Listener für Antwort-Auswahl + mcPreview.querySelectorAll('.mc-option').forEach(optEl => { + optEl.addEventListener('click', () => handleMcOptionClick(optEl)); + }); + } + + function handleMcOptionClick(optEl) { + const qid = optEl.getAttribute('data-qid'); + const optId = optEl.getAttribute('data-opt'); + + if (!currentMcData) return; + + // Finde die Frage + const question = currentMcData.questions.find(q => q.id === qid); + if (!question) return; + + // Markiere alle Optionen dieser Frage + const questionEl = optEl.closest('.mc-question'); + const allOptions = questionEl.querySelectorAll('.mc-option'); + + allOptions.forEach(opt => { + opt.classList.remove('selected', 'correct', 'incorrect'); + const thisOptId = opt.getAttribute('data-opt'); + if (thisOptId === question.correct_answer) { + opt.classList.add('correct'); + } else if (thisOptId === optId) { + opt.classList.add('incorrect'); + } + }); + + // Speichere Antwort + mcAnswers[qid] = optId; + + // Zeige Feedback wenn gewünscht + const isCorrect = optId === question.correct_answer; + let feedbackEl = questionEl.querySelector('.mc-feedback'); + if (!feedbackEl) { + feedbackEl = document.createElement('div'); + feedbackEl.className = 'mc-feedback'; + questionEl.appendChild(feedbackEl); + } + + if (isCorrect) { + feedbackEl.style.background = 'rgba(34,197,94,0.1)'; + feedbackEl.style.borderColor = 'rgba(34,197,94,0.3)'; + feedbackEl.style.color = 'var(--bp-accent)'; + feedbackEl.textContent = 'Richtig! ' + (question.explanation || ''); + } else { + feedbackEl.style.background = 'rgba(239,68,68,0.1)'; + feedbackEl.style.borderColor = 'rgba(239,68,68,0.3)'; + feedbackEl.style.color = '#ef4444'; + feedbackEl.textContent = 'Leider falsch. ' + (question.explanation || ''); + } + } + + function openMcModal() { + if (!currentMcData || !currentMcData.questions) { + alert('Keine MC-Fragen vorhanden. Bitte zuerst generieren.'); + return; + } + + mcAnswers = {}; // Reset Antworten + renderMcModal(currentMcData); + mcModal.classList.remove('hidden'); + } + + function closeMcModal() { + mcModal.classList.add('hidden'); + } + + function renderMcModal(mcData) { + const questions = mcData.questions; + const metadata = mcData.metadata || {}; + + let html = ''; + + // Header mit Metadaten + html += '
'; + if (metadata.source_title) { + html += '
Arbeitsblatt: ' + metadata.source_title + '
'; + } + if (metadata.subject) { + html += '
Fach: ' + metadata.subject + '
'; + } + if (metadata.grade_level) { + html += '
Stufe: ' + metadata.grade_level + '
'; + } + html += '
'; + + // Alle Fragen + questions.forEach((q, idx) => { + html += '
'; + html += '
' + (idx + 1) + '. ' + q.question + '
'; + html += '
'; + q.options.forEach(opt => { + html += '
'; + html += '' + opt.id + ') ' + opt.text; + html += '
'; + }); + html += '
'; + html += '
'; + }); + + // Auswertungs-Button + html += '
'; + html += ''; + html += '
'; + + mcModalBody.innerHTML = html; + + // Event-Listener + mcModalBody.querySelectorAll('.mc-option').forEach(optEl => { + optEl.addEventListener('click', () => { + const qid = optEl.getAttribute('data-qid'); + const optId = optEl.getAttribute('data-opt'); + + // Deselektiere andere Optionen der gleichen Frage + const questionEl = optEl.closest('.mc-question'); + questionEl.querySelectorAll('.mc-option').forEach(o => o.classList.remove('selected')); + optEl.classList.add('selected'); + + mcAnswers[qid] = optId; + }); + }); + + const btnEvaluate = document.getElementById('btn-mc-evaluate'); + if (btnEvaluate) { + btnEvaluate.addEventListener('click', evaluateMcQuiz); + } + } + + function evaluateMcQuiz() { + if (!currentMcData) return; + + let correct = 0; + let total = currentMcData.questions.length; + + currentMcData.questions.forEach(q => { + const questionEl = mcModalBody.querySelector('.mc-question[data-qid="' + q.id + '"]'); + if (!questionEl) return; + + const userAnswer = mcAnswers[q.id]; + const allOptions = questionEl.querySelectorAll('.mc-option'); + + allOptions.forEach(opt => { + opt.classList.remove('correct', 'incorrect'); + const optId = opt.getAttribute('data-opt'); + if (optId === q.correct_answer) { + opt.classList.add('correct'); + } else if (optId === userAnswer && userAnswer !== q.correct_answer) { + opt.classList.add('incorrect'); + } + }); + + // Zeige Erklärung + let feedbackEl = questionEl.querySelector('.mc-feedback'); + if (!feedbackEl) { + feedbackEl = document.createElement('div'); + feedbackEl.className = 'mc-feedback'; + questionEl.appendChild(feedbackEl); + } + + if (userAnswer === q.correct_answer) { + correct++; + feedbackEl.style.background = 'rgba(34,197,94,0.1)'; + feedbackEl.style.borderColor = 'rgba(34,197,94,0.3)'; + feedbackEl.style.color = 'var(--bp-accent)'; + feedbackEl.textContent = 'Richtig! ' + (q.explanation || ''); + } else if (userAnswer) { + feedbackEl.style.background = 'rgba(239,68,68,0.1)'; + feedbackEl.style.borderColor = 'rgba(239,68,68,0.3)'; + feedbackEl.style.color = '#ef4444'; + feedbackEl.textContent = 'Falsch. ' + (q.explanation || ''); + } else { + feedbackEl.style.background = 'rgba(148,163,184,0.1)'; + feedbackEl.style.borderColor = 'rgba(148,163,184,0.3)'; + feedbackEl.style.color = 'var(--bp-text-muted)'; + feedbackEl.textContent = 'Nicht beantwortet. Richtig wäre: ' + q.correct_answer.toUpperCase(); + } + }); + + // Zeige Gesamtergebnis + const resultHtml = '
' + + '
' + correct + ' von ' + total + ' richtig
' + + '
' + Math.round(correct / total * 100) + '% korrekt
' + + '
'; + + const existingResult = mcModalBody.querySelector('.mc-result'); + if (existingResult) { + existingResult.remove(); + } + + const resultDiv = document.createElement('div'); + resultDiv.className = 'mc-result'; + resultDiv.innerHTML = resultHtml; + mcModalBody.appendChild(resultDiv); + } + + function openMcPrintDialog() { + if (!currentMcData) { + alert(t('mc_no_questions') || 'Keine MC-Fragen vorhanden.'); + return; + } + const currentFile = eingangFiles[currentIndex]; + const choice = confirm((t('mc_print_with_answers') || 'Mit Lösungen drucken?') + '\\n\\nOK = Lösungsblatt mit markierten Antworten\\nAbbrechen = Übungsblatt ohne Lösungen'); + const url = '/api/print-mc/' + encodeURIComponent(currentFile) + '?show_answers=' + choice; + window.open(url, '_blank'); + } + + // Event Listener für MC-Buttons + if (btnMcGenerate) { + btnMcGenerate.addEventListener('click', generateMcQuestions); + } + + if (btnMcShow) { + btnMcShow.addEventListener('click', openMcModal); + } + + if (btnMcPrint) { + btnMcPrint.addEventListener('click', openMcPrintDialog); + } + + if (mcModalClose) { + mcModalClose.addEventListener('click', closeMcModal); + } + + if (mcModal) { + mcModal.addEventListener('click', (ev) => { + if (ev.target === mcModal) { + closeMcModal(); + } + }); + } + + // --- Lückentext (Cloze) Logik --- + const btnClozeGenerate = document.getElementById('btn-cloze-generate'); + const btnClozeShow = document.getElementById('btn-cloze-show'); + const btnClozePrint = document.getElementById('btn-cloze-print'); + const clozePreview = document.getElementById('cloze-preview'); + const clozeBadge = document.getElementById('cloze-badge'); + const clozeLanguageSelect = document.getElementById('cloze-language'); + const clozeModal = document.getElementById('cloze-modal'); + const clozeModalBody = document.getElementById('cloze-modal-body'); + const clozeModalClose = document.getElementById('cloze-modal-close'); + + let currentClozeData = null; + let clozeAnswers = {}; // Speichert Nutzerantworten + + async function generateClozeTexts() { + const targetLang = clozeLanguageSelect ? clozeLanguageSelect.value : 'tr'; + + try { + setStatus('Generiere Lückentexte …', 'Bitte warten, KI arbeitet.', 'busy'); + if (clozeBadge) clozeBadge.textContent = 'Generiert...'; + + const resp = await fetch('/api/generate-cloze?target_language=' + targetLang, { method: 'POST' }); + if (!resp.ok) { + throw new Error('HTTP ' + resp.status); + } + + const result = await resp.json(); + if (result.status === 'OK' && result.generated.length > 0) { + setStatus('Lückentexte generiert', result.generated.length + ' Dateien erstellt.'); + if (clozeBadge) clozeBadge.textContent = 'Fertig'; + if (btnClozeShow) btnClozeShow.style.display = 'inline-block'; + if (btnClozePrint) btnClozePrint.style.display = 'inline-block'; + + // Lade Vorschau für aktuelle Datei + await loadClozePreviewForCurrent(); + } else if (result.errors && result.errors.length > 0) { + setStatus('Fehler bei Lückentext-Generierung', result.errors[0].error, 'error'); + if (clozeBadge) clozeBadge.textContent = 'Fehler'; + } else { + setStatus('Keine Lückentexte generiert', 'Möglicherweise fehlen Analyse-Daten.', 'error'); + if (clozeBadge) clozeBadge.textContent = 'Bereit'; + } + } catch (e) { + console.error('Lückentext-Generierung fehlgeschlagen:', e); + setStatus('Fehler bei Lückentext-Generierung', String(e), 'error'); + if (clozeBadge) clozeBadge.textContent = 'Fehler'; + } + } + + async function loadClozePreviewForCurrent() { + if (!eingangFiles.length) { + if (clozePreview) clozePreview.innerHTML = '
Keine Arbeitsblätter vorhanden.
'; + return; + } + + const currentFile = eingangFiles[currentIndex]; + if (!currentFile) return; + + try { + const resp = await fetch('/api/cloze-data/' + encodeURIComponent(currentFile)); + const result = await resp.json(); + + if (result.status === 'OK' && result.data) { + currentClozeData = result.data; + renderClozePreview(result.data); + if (btnClozeShow) btnClozeShow.style.display = 'inline-block'; + if (btnClozePrint) btnClozePrint.style.display = 'inline-block'; + } else { + if (clozePreview) clozePreview.innerHTML = '
Noch keine Lückentexte für dieses Arbeitsblatt generiert.
'; + currentClozeData = null; + if (btnClozeShow) btnClozeShow.style.display = 'none'; + if (btnClozePrint) btnClozePrint.style.display = 'none'; + } + } catch (e) { + console.error('Fehler beim Laden der Lückentext-Daten:', e); + if (clozePreview) clozePreview.innerHTML = ''; + } + } + + function renderClozePreview(clozeData) { + if (!clozePreview) return; + if (!clozeData || !clozeData.cloze_items || clozeData.cloze_items.length === 0) { + clozePreview.innerHTML = '
Keine Lückentexte vorhanden.
'; + return; + } + + const items = clozeData.cloze_items; + const metadata = clozeData.metadata || {}; + + let html = ''; + + // Statistiken + html += '
'; + if (metadata.subject) { + html += '
Fach: ' + metadata.subject + '
'; + } + if (metadata.grade_level) { + html += '
Stufe: ' + metadata.grade_level + '
'; + } + html += '
Sätze: ' + items.length + '
'; + if (metadata.total_gaps) { + html += '
Lücken: ' + metadata.total_gaps + '
'; + } + html += '
'; + + // Zeige erste 2 Sätze als Vorschau + const previewItems = items.slice(0, 2); + previewItems.forEach((item, idx) => { + html += '
'; + html += '
' + (idx + 1) + '. ' + item.sentence_with_gaps.replace(/___/g, '___') + '
'; + + // Übersetzung anzeigen + if (item.translation && item.translation.full_sentence) { + html += '
'; + html += '
' + (item.translation.language_name || 'Übersetzung') + ':
'; + html += item.translation.full_sentence; + html += '
'; + } + html += '
'; + }); + + if (items.length > 2) { + html += '
+ ' + (items.length - 2) + ' weitere Sätze
'; + } + + clozePreview.innerHTML = html; + } + + function openClozeModal() { + if (!currentClozeData || !currentClozeData.cloze_items) { + alert('Keine Lückentexte vorhanden. Bitte zuerst generieren.'); + return; + } + + clozeAnswers = {}; // Reset Antworten + renderClozeModal(currentClozeData); + clozeModal.classList.remove('hidden'); + } + + function closeClozeModal() { + clozeModal.classList.add('hidden'); + } + + function renderClozeModal(clozeData) { + const items = clozeData.cloze_items; + const metadata = clozeData.metadata || {}; + + let html = ''; + + // Header + html += '
'; + if (metadata.source_title) { + html += '
Arbeitsblatt: ' + metadata.source_title + '
'; + } + if (metadata.total_gaps) { + html += '
Lücken gesamt: ' + metadata.total_gaps + '
'; + } + html += '
'; + + html += '
Fülle die Lücken aus und klicke auf "Prüfen".
'; + + // Alle Sätze mit Eingabefeldern + items.forEach((item, idx) => { + html += '
'; + + // Satz mit Eingabefeldern statt ___ + let sentenceHtml = item.sentence_with_gaps; + const gaps = item.gaps || []; + + // Ersetze ___ durch Eingabefelder + let gapIndex = 0; + sentenceHtml = sentenceHtml.replace(/___/g, () => { + const gap = gaps[gapIndex] || { id: 'g' + gapIndex, word: '' }; + const inputId = item.id + '_' + gap.id; + gapIndex++; + return ''; + }); + + html += '
' + (idx + 1) + '. ' + sentenceHtml + '
'; + + // Übersetzung als Hilfe + if (item.translation && item.translation.sentence_with_gaps) { + html += '
'; + html += '
' + (item.translation.language_name || 'Übersetzung') + ' (mit Lücken):
'; + html += item.translation.sentence_with_gaps; + html += '
'; + } + + html += '
'; + }); + + // Buttons + html += '
'; + html += ''; + html += ''; + html += '
'; + + clozeModalBody.innerHTML = html; + + // Event-Listener für Prüfen-Button + const btnCheck = document.getElementById('btn-cloze-check'); + if (btnCheck) { + btnCheck.addEventListener('click', checkClozeAnswers); + } + + // Event-Listener für Lösungen zeigen + const btnShowAnswers = document.getElementById('btn-cloze-show-answers'); + if (btnShowAnswers) { + btnShowAnswers.addEventListener('click', showClozeAnswers); + } + + // Enter-Taste zum Prüfen + clozeModalBody.querySelectorAll('.cloze-gap-input').forEach(input => { + input.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + checkClozeAnswers(); + } + }); + }); + } + + function checkClozeAnswers() { + let correct = 0; + let total = 0; + + clozeModalBody.querySelectorAll('.cloze-gap-input').forEach(input => { + const userAnswer = input.value.trim().toLowerCase(); + const correctAnswer = input.getAttribute('data-answer').toLowerCase(); + total++; + + // Entferne vorherige Klassen + input.classList.remove('correct', 'incorrect'); + + if (userAnswer === correctAnswer) { + input.classList.add('correct'); + correct++; + } else if (userAnswer !== '') { + input.classList.add('incorrect'); + } + }); + + // Zeige Ergebnis + let existingResult = clozeModalBody.querySelector('.cloze-result'); + if (existingResult) existingResult.remove(); + + const resultHtml = '
' + + '
' + correct + ' von ' + total + ' richtig
' + + '
' + Math.round(correct / total * 100) + '% korrekt
' + + '
'; + + const resultDiv = document.createElement('div'); + resultDiv.innerHTML = resultHtml; + clozeModalBody.appendChild(resultDiv.firstChild); + } + + function showClozeAnswers() { + clozeModalBody.querySelectorAll('.cloze-gap-input').forEach(input => { + const correctAnswer = input.getAttribute('data-answer'); + input.value = correctAnswer; + input.classList.remove('incorrect'); + input.classList.add('correct'); + }); + } + + function openClozePrintDialog() { + if (!currentClozeData) { + alert(t('cloze_no_texts') || 'Keine Lückentexte vorhanden.'); + return; + } + + const currentFile = eingangFiles[currentIndex]; + + // Öffne Druck-Optionen + const choice = confirm((t('cloze_print_with_answers') || 'Mit Lösungen drucken?') + '\\n\\nOK = Mit ausgefüllten Lücken\\nAbbrechen = Übungsblatt mit Wortbank'); + + const url = '/api/print-cloze/' + encodeURIComponent(currentFile) + '?show_answers=' + choice; + window.open(url, '_blank'); + } + + // Event Listener für Cloze-Buttons + if (btnClozeGenerate) { + btnClozeGenerate.addEventListener('click', generateClozeTexts); + } + + if (btnClozeShow) { + btnClozeShow.addEventListener('click', openClozeModal); + } + + if (btnClozePrint) { + btnClozePrint.addEventListener('click', openClozePrintDialog); + } + + if (clozeModalClose) { + clozeModalClose.addEventListener('click', closeClozeModal); + } + + if (clozeModal) { + clozeModal.addEventListener('click', (ev) => { + if (ev.target === clozeModal) { + closeClozeModal(); + } + }); + } + + // --- Mindmap Lernposter Logik --- + const btnMindmapGenerate = document.getElementById('btn-mindmap-generate'); + const btnMindmapShow = document.getElementById('btn-mindmap-show'); + const btnMindmapPrint = document.getElementById('btn-mindmap-print'); + const mindmapPreview = document.getElementById('mindmap-preview'); + const mindmapBadge = document.getElementById('mindmap-badge'); + + let currentMindmapData = null; + + async function generateMindmap() { + const currentFile = eingangFiles[currentIndex]; + if (!currentFile) { + setStatus('error', t('select_file_first') || 'Bitte zuerst eine Datei auswählen'); + return; + } + + if (mindmapBadge) { + mindmapBadge.textContent = t('generating') || 'Generiere...'; + mindmapBadge.className = 'card-badge badge-working'; + } + setStatus('working', t('generating_mindmap') || 'Erstelle Mindmap...'); + + try { + const resp = await fetch('/api/generate-mindmap/' + encodeURIComponent(currentFile), { + method: 'POST' + }); + const data = await resp.json(); + + if (data.status === 'OK') { + if (mindmapBadge) { + mindmapBadge.textContent = t('ready') || 'Fertig'; + mindmapBadge.className = 'card-badge badge-success'; + } + setStatus('ok', t('mindmap_generated') || 'Mindmap erstellt!'); + + // Lade Mindmap-Daten + await loadMindmapData(); + } else if (data.status === 'NOT_FOUND') { + if (mindmapBadge) { + mindmapBadge.textContent = t('no_analysis') || 'Keine Analyse'; + mindmapBadge.className = 'card-badge badge-error'; + } + setStatus('error', t('analyze_first') || 'Bitte zuerst analysieren (Neuaufbau starten)'); + } else { + throw new Error(data.message || 'Fehler bei der Mindmap-Generierung'); + } + } catch (err) { + console.error('Mindmap error:', err); + if (mindmapBadge) { + mindmapBadge.textContent = t('error') || 'Fehler'; + mindmapBadge.className = 'card-badge badge-error'; + } + setStatus('error', err.message); + } + } + + async function loadMindmapData() { + if (!eingangFiles.length) { + if (mindmapPreview) mindmapPreview.innerHTML = ''; + return; + } + + const currentFile = eingangFiles[currentIndex]; + if (!currentFile) return; + + try { + const resp = await fetch('/api/mindmap-data/' + encodeURIComponent(currentFile)); + const data = await resp.json(); + + if (data.status === 'OK' && data.data) { + currentMindmapData = data.data; + renderMindmapPreview(); + if (btnMindmapShow) btnMindmapShow.style.display = 'inline-block'; + if (btnMindmapPrint) btnMindmapPrint.style.display = 'inline-block'; + if (mindmapBadge) { + mindmapBadge.textContent = t('ready') || 'Fertig'; + mindmapBadge.className = 'card-badge badge-success'; + } + } else { + currentMindmapData = null; + if (mindmapPreview) mindmapPreview.innerHTML = ''; + if (btnMindmapShow) btnMindmapShow.style.display = 'none'; + if (btnMindmapPrint) btnMindmapPrint.style.display = 'none'; + if (mindmapBadge) { + mindmapBadge.textContent = t('ready') || 'Bereit'; + mindmapBadge.className = 'card-badge'; + } + } + } catch (err) { + console.error('Error loading mindmap:', err); + } + } + + function renderMindmapPreview() { + if (!mindmapPreview) return; + + if (!currentMindmapData) { + mindmapPreview.innerHTML = ''; + return; + } + + const topic = currentMindmapData.topic || 'Thema'; + const categories = currentMindmapData.categories || []; + const categoryCount = categories.length; + const termCount = categories.reduce((sum, cat) => sum + (cat.terms ? cat.terms.length : 0), 0); + + mindmapPreview.innerHTML = '
' + + '
' + topic + '
' + + '
' + categoryCount + ' ' + (t('categories') || 'Kategorien') + ' | ' + termCount + ' ' + (t('terms') || 'Begriffe') + '
' + + '
'; + } + + function openMindmapView() { + const currentFile = eingangFiles[currentIndex]; + if (!currentFile) return; + window.open('/api/mindmap-html/' + encodeURIComponent(currentFile) + '?format=a4', '_blank'); + } + + function openMindmapPrint() { + const currentFile = eingangFiles[currentIndex]; + if (!currentFile) return; + window.open('/api/mindmap-html/' + encodeURIComponent(currentFile) + '?format=a3', '_blank'); + } + + if (btnMindmapGenerate) { + btnMindmapGenerate.addEventListener('click', generateMindmap); + } + + if (btnMindmapShow) { + btnMindmapShow.addEventListener('click', openMindmapView); + } + + if (btnMindmapPrint) { + btnMindmapPrint.addEventListener('click', openMindmapPrint); + } + + // --- Frage-Antwort (Q&A) mit Leitner-System --- + const btnQaGenerate = document.getElementById('btn-qa-generate'); + const btnQaLearn = document.getElementById('btn-qa-learn'); + const btnQaPrint = document.getElementById('btn-qa-print'); + const qaPreview = document.getElementById('qa-preview'); + const qaBadge = document.getElementById('qa-badge'); + const qaModal = document.getElementById('qa-modal'); + const qaModalBody = document.getElementById('qa-modal-body'); + const qaModalClose = document.getElementById('qa-modal-close'); + + let currentQaData = null; + let currentQaIndex = 0; + let qaSessionStats = { correct: 0, incorrect: 0, total: 0 }; + + async function generateQaQuestions() { + try { + setStatus(t('status_generating_qa') || 'Generiere Q&A …', t('status_please_wait'), 'busy'); + if (qaBadge) qaBadge.textContent = t('mc_generating'); + + const resp = await fetch('/api/generate-qa', { method: 'POST' }); + if (!resp.ok) { + throw new Error('HTTP ' + resp.status); + } + + const result = await resp.json(); + if (result.status === 'OK' && result.generated.length > 0) { + setStatus(t('status_qa_generated') || 'Q&A generiert', result.generated.length + ' ' + t('status_files_created')); + if (qaBadge) qaBadge.textContent = t('mc_done'); + if (btnQaLearn) btnQaLearn.style.display = 'inline-block'; + if (btnQaPrint) btnQaPrint.style.display = 'inline-block'; + + await loadQaPreviewForCurrent(); + } else if (result.errors && result.errors.length > 0) { + setStatus(t('error'), result.errors[0].error, 'error'); + if (qaBadge) qaBadge.textContent = t('mc_error'); + } else { + setStatus(t('error'), 'Keine Q&A generiert.', 'error'); + if (qaBadge) qaBadge.textContent = t('mc_ready'); + } + } catch (e) { + console.error('Q&A-Generierung fehlgeschlagen:', e); + setStatus(t('error'), String(e), 'error'); + if (qaBadge) qaBadge.textContent = t('mc_error'); + } + } + + async function loadQaPreviewForCurrent() { + if (!eingangFiles.length) { + if (qaPreview) qaPreview.innerHTML = '
' + t('qa_no_questions') + '
'; + return; + } + + const currentFile = eingangFiles[currentIndex]; + if (!currentFile) return; + + try { + const resp = await fetch('/api/qa-data/' + encodeURIComponent(currentFile)); + const result = await resp.json(); + + if (result.status === 'OK' && result.data) { + currentQaData = result.data; + renderQaPreview(result.data); + if (btnQaLearn) btnQaLearn.style.display = 'inline-block'; + if (btnQaPrint) btnQaPrint.style.display = 'inline-block'; + } else { + if (qaPreview) qaPreview.innerHTML = '
' + (t('qa_no_questions') || 'Noch keine Q&A für dieses Arbeitsblatt generiert.') + '
'; + currentQaData = null; + if (btnQaLearn) btnQaLearn.style.display = 'none'; + if (btnQaPrint) btnQaPrint.style.display = 'none'; + } + } catch (e) { + console.error('Fehler beim Laden der Q&A-Daten:', e); + if (qaPreview) qaPreview.innerHTML = ''; + } + } + + function renderQaPreview(qaData) { + if (!qaPreview) return; + if (!qaData || !qaData.qa_items || qaData.qa_items.length === 0) { + qaPreview.innerHTML = '
' + t('qa_no_questions') + '
'; + return; + } + + const items = qaData.qa_items; + const metadata = qaData.metadata || {}; + + let html = ''; + + // Leitner-Box Statistiken + html += '
'; + + // Zähle Fragen nach Box + let box0 = 0, box1 = 0, box2 = 0; + items.forEach(item => { + const box = item.leitner ? item.leitner.box : 0; + if (box === 0) box0++; + else if (box === 1) box1++; + else box2++; + }); + + html += '
'; + html += '
' + (t('qa_box_new') || 'Neu') + ': ' + box0 + '
'; + html += '
' + (t('qa_box_learning') || 'Lernt') + ': ' + box1 + '
'; + html += '
' + (t('qa_box_mastered') || 'Gefestigt') + ': ' + box2 + '
'; + html += '
'; + html += '
'; + + // Zeige erste 2 Fragen als Vorschau + const previewItems = items.slice(0, 2); + previewItems.forEach((item, idx) => { + html += '
'; + html += '
' + (idx + 1) + '. ' + item.question + '
'; + html += '
→ ' + item.answer.substring(0, 60) + (item.answer.length > 60 ? '...' : '') + '
'; + html += '
'; + }); + + if (items.length > 2) { + html += '
+ ' + (items.length - 2) + ' ' + (t('questions') || 'weitere Fragen') + '
'; + } + + qaPreview.innerHTML = html; + } + + function openQaModal() { + if (!currentQaData || !currentQaData.qa_items) { + alert(t('qa_no_questions') || 'Keine Q&A vorhanden. Bitte zuerst generieren.'); + return; + } + + currentQaIndex = 0; + qaSessionStats = { correct: 0, incorrect: 0, total: 0 }; + renderQaLearningCard(); + qaModal.classList.remove('hidden'); + } + + function closeQaModal() { + qaModal.classList.add('hidden'); + } + + function renderQaLearningCard() { + const items = currentQaData.qa_items; + + if (currentQaIndex >= items.length) { + // Alle Fragen durch - Zeige Zusammenfassung + renderQaSessionSummary(); + return; + } + + const item = items[currentQaIndex]; + const leitner = item.leitner || { box: 0 }; + const boxNames = [t('qa_box_new') || 'Neu', t('qa_box_learning') || 'Gelernt', t('qa_box_mastered') || 'Gefestigt']; + const boxColors = ['#ef4444', '#f59e0b', '#22c55e']; + + let html = ''; + + // Fortschrittsanzeige + html += '
'; + html += '
' + (t('question') || 'Frage') + ' ' + (currentQaIndex + 1) + ' / ' + items.length + '
'; + html += '
'; + html += '' + boxNames[leitner.box] + ''; + html += '
'; + html += '
'; + + // Frage + html += '
'; + html += '
' + (t('question') || 'Frage') + ':
'; + html += '
' + item.question + '
'; + html += '
'; + + // Eingabefeld für eigene Antwort + html += '
'; + html += '
' + (t('qa_your_answer') || 'Deine Antwort') + ':
'; + html += ''; + html += '
'; + + // Prüfen-Button + html += '
'; + html += ''; + html += '
'; + + // Vergleichs-Container (versteckt) + html += ''; // Ende qa-comparison-container + + // Session-Statistik + html += '
'; + html += '
' + (t('qa_session_correct') || 'Richtig') + ': ' + qaSessionStats.correct + '
'; + html += '
' + (t('qa_session_incorrect') || 'Falsch') + ': ' + qaSessionStats.incorrect + '
'; + html += '
'; + + qaModalBody.innerHTML = html; + + // Event Listener für Prüfen-Button + document.getElementById('btn-qa-check-answer').addEventListener('click', () => { + const userAnswer = document.getElementById('qa-user-answer').value.trim(); + + // Zeige die eigene Antwort im Vergleich + document.getElementById('qa-user-answer-text').textContent = userAnswer || (t('qa_no_answer') || '(keine Antwort eingegeben)'); + + // Verstecke Eingabe, zeige Vergleich + document.getElementById('qa-input-container').style.display = 'none'; + document.getElementById('qa-check-btn-container').style.display = 'none'; + document.getElementById('qa-comparison-container').style.display = 'block'; + }); + + // Enter-Taste im Textfeld löst Prüfen aus + document.getElementById('qa-user-answer').addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + document.getElementById('btn-qa-check-answer').click(); + } + }); + + // Fokus auf Textfeld setzen + setTimeout(() => { + document.getElementById('qa-user-answer').focus(); + }, 100); + + document.getElementById('btn-qa-correct').addEventListener('click', () => handleQaAnswer(true)); + document.getElementById('btn-qa-incorrect').addEventListener('click', () => handleQaAnswer(false)); + } + + async function handleQaAnswer(correct) { + const item = currentQaData.qa_items[currentQaIndex]; + + // Update Session-Statistik + qaSessionStats.total++; + if (correct) qaSessionStats.correct++; + else qaSessionStats.incorrect++; + + // Update Leitner-Fortschritt auf dem Server + try { + const currentFile = eingangFiles[currentIndex]; + await fetch('/api/qa-progress?filename=' + encodeURIComponent(currentFile) + '&item_id=' + encodeURIComponent(item.id) + '&correct=' + correct, { + method: 'POST' + }); + } catch (e) { + console.error('Fehler beim Speichern des Fortschritts:', e); + } + + // Nächste Frage + currentQaIndex++; + renderQaLearningCard(); + } + + function renderQaSessionSummary() { + const percent = qaSessionStats.total > 0 ? Math.round(qaSessionStats.correct / qaSessionStats.total * 100) : 0; + + let html = ''; + html += '
'; + html += '
' + (percent >= 80 ? '🎉' : percent >= 50 ? '👍' : '💪') + '
'; + html += '
' + (t('qa_session_complete') || 'Lernrunde abgeschlossen!') + '
'; + html += '
' + qaSessionStats.correct + ' / ' + qaSessionStats.total + ' ' + (t('qa_result_correct') || 'richtig') + ' (' + percent + '%)
'; + + // Statistik + html += '
'; + html += '
' + qaSessionStats.correct + '
' + (t('qa_correct') || 'Richtig') + '
'; + html += '
' + qaSessionStats.incorrect + '
' + (t('qa_incorrect') || 'Falsch') + '
'; + html += '
'; + + html += '
'; + html += ''; + html += ''; + html += '
'; + html += '
'; + + qaModalBody.innerHTML = html; + + document.getElementById('btn-qa-restart').addEventListener('click', () => { + currentQaIndex = 0; + qaSessionStats = { correct: 0, incorrect: 0, total: 0 }; + renderQaLearningCard(); + }); + + document.getElementById('btn-qa-close-summary').addEventListener('click', closeQaModal); + + // Aktualisiere Preview nach Session + loadQaPreviewForCurrent(); + } + + function openQaPrintDialog() { + if (!currentQaData) { + alert(t('qa_no_questions') || 'Keine Q&A vorhanden.'); + return; + } + + const currentFile = eingangFiles[currentIndex]; + const baseName = currentFile.split('.')[0]; + + // Öffne Druck-Optionen + const choice = confirm((t('qa_print_with_answers') || 'Mit Lösungen drucken?') + '\\n\\nOK = Mit Lösungen\\nAbbrechen = Nur Fragen'); + + const url = '/api/print-qa/' + encodeURIComponent(currentFile) + '?show_answers=' + choice; + window.open(url, '_blank'); + } + + // Event Listener für Q&A-Buttons + if (btnQaGenerate) { + btnQaGenerate.addEventListener('click', generateQaQuestions); + } + + if (btnQaLearn) { + btnQaLearn.addEventListener('click', openQaModal); + } + + if (btnQaPrint) { + btnQaPrint.addEventListener('click', openQaPrintDialog); + } + + if (qaModalClose) { + qaModalClose.addEventListener('click', closeQaModal); + } + + if (qaModal) { + qaModal.addEventListener('click', (ev) => { + if (ev.target === qaModal) { + closeQaModal(); + } + }); + } + + // --- Sprachauswahl Event Listener --- + const languageSelect = document.getElementById('language-select'); + if (languageSelect) { + // Setze initiale Auswahl basierend auf gespeicherter Sprache + languageSelect.value = currentLang; + + languageSelect.addEventListener('change', (e) => { + applyLanguage(e.target.value); + }); + } + + // --- Initial --- + async function init() { + // Theme Toggle initialisieren + initThemeToggle(); + + // Sprache anwenden (aus localStorage oder default) + applyLanguage(currentLang); + + updateScreen(); + updateTiles(); + await loadEingangFiles(); + await loadWorksheetPairs(); + await loadLearningUnits(); + // Lade MC-Vorschau für aktuelle Datei + await loadMcPreviewForCurrent(); + // Lade Lückentext-Vorschau + await loadClozePreviewForCurrent(); + // Lade Q&A-Vorschau + await loadQaPreviewForCurrent(); + // Lade Mindmap-Vorschau + await loadMindmapData(); + // Initialisiere vast.ai Control + initVastControl(); + } + + // ============================================ + // vast.ai GPU Control + // ============================================ + const VAST_API_KEY = '88573e05868f29958022a78652e63b934812a01021e5580ed4fea35dc39b5e9c'; + let vastRefreshInterval = null; + + function initVastControl() { + const btnStart = document.getElementById('btn-vast-start'); + const btnStop = document.getElementById('btn-vast-stop'); + + if (btnStart) { + btnStart.addEventListener('click', () => vastAction('start')); + } + if (btnStop) { + btnStop.addEventListener('click', () => vastAction('stop')); + } + + // Initial load + refreshVastStatus(); + // Auto-refresh every 30 seconds + vastRefreshInterval = setInterval(refreshVastStatus, 30000); + } + + async function refreshVastStatus() { + const statusBadge = document.getElementById('vast-status-badge'); + const gpuName = document.getElementById('vast-gpu-name'); + const costHour = document.getElementById('vast-cost-hour'); + const autoShutdown = document.getElementById('vast-auto-shutdown'); + const creditEl = document.getElementById('vast-credit'); + const sessionCostEl = document.getElementById('vast-session-cost'); + const btnStart = document.getElementById('btn-vast-start'); + const btnStop = document.getElementById('btn-vast-stop'); + const msgEl = document.getElementById('vast-message'); + + try { + const resp = await fetch('/infra/vast/status', { + headers: { 'X-API-Key': VAST_API_KEY } + }); + + if (!resp.ok) { + throw new Error('API nicht erreichbar'); + } + + const data = await resp.json(); + + // Update status badge + statusBadge.textContent = data.status || 'unbekannt'; + statusBadge.className = 'vast-badge vast-badge-' + (data.status || 'unknown'); + + // Update GPU info + gpuName.textContent = data.gpu_name || '-'; + costHour.textContent = data.dph_total ? ('$' + data.dph_total.toFixed(3)) : '-'; + + // Update auto-shutdown + if (data.auto_shutdown_in_minutes !== null) { + autoShutdown.textContent = data.auto_shutdown_in_minutes + ' min'; + } else { + autoShutdown.textContent = '-'; + } + + // Update Budget/Credit + if (data.account_credit !== null && data.account_credit !== undefined) { + creditEl.textContent = '$' + data.account_credit.toFixed(2); + // Color based on remaining credit + if (data.account_credit < 5) { + creditEl.style.color = 'var(--bp-danger)'; + } else if (data.account_credit < 15) { + creditEl.style.color = 'var(--bp-warning)'; + } else { + creditEl.style.color = 'var(--bp-success)'; + } + } else { + creditEl.textContent = '-'; + } + + // Update Session cost + if (data.session_runtime_minutes !== null && data.session_cost_usd !== null) { + const mins = Math.round(data.session_runtime_minutes); + const cost = data.session_cost_usd.toFixed(3); + sessionCostEl.textContent = mins + ' min / $' + cost; + } else { + sessionCostEl.textContent = '-'; + } + + // Enable/disable buttons based on status + if (data.status === 'running') { + btnStart.disabled = true; + btnStop.disabled = false; + } else if (data.status === 'stopped' || data.status === 'exited') { + btnStart.disabled = false; + btnStop.disabled = true; + } else { + // loading, scheduling, creating + btnStart.disabled = true; + btnStop.disabled = true; + } + + msgEl.textContent = ''; + msgEl.className = 'vast-message'; + + } catch (err) { + statusBadge.textContent = 'Fehler'; + statusBadge.className = 'vast-badge vast-badge-unknown'; + gpuName.textContent = '-'; + costHour.textContent = '-'; + autoShutdown.textContent = '-'; + creditEl.textContent = '-'; + sessionCostEl.textContent = '-'; + btnStart.disabled = true; + btnStop.disabled = true; + msgEl.textContent = err.message; + msgEl.className = 'vast-message error'; + } + } + + async function vastAction(action) { + const btnStart = document.getElementById('btn-vast-start'); + const btnStop = document.getElementById('btn-vast-stop'); + const msgEl = document.getElementById('vast-message'); + const statusBadge = document.getElementById('vast-status-badge'); + + btnStart.disabled = true; + btnStop.disabled = true; + statusBadge.textContent = action === 'start' ? 'starting...' : 'stopping...'; + statusBadge.className = 'vast-badge vast-badge-loading'; + msgEl.textContent = ''; + + try { + const endpoint = action === 'start' ? '/infra/vast/power/on' : '/infra/vast/power/off'; + const resp = await fetch(endpoint, { + method: 'POST', + headers: { + 'X-API-Key': VAST_API_KEY, + 'Content-Type': 'application/json' + }, + body: '{}' + }); + + const data = await resp.json(); + + if (!resp.ok) { + throw new Error(data.detail || 'Aktion fehlgeschlagen'); + } + + msgEl.textContent = action === 'start' ? 'Start angefordert' : 'Stop angefordert'; + msgEl.className = 'vast-message success'; + + // Refresh status after short delay + setTimeout(refreshVastStatus, 3000); + setTimeout(refreshVastStatus, 10000); + + } catch (err) { + msgEl.textContent = err.message; + msgEl.className = 'vast-message error'; + refreshVastStatus(); + } + } + + init(); + +// === SCRIPT BLOCK SEPARATOR === + +// GDPR Actions + async function saveCookiePreferences() { + const functional = document.getElementById('cookie-functional')?.checked || false; + const analytics = document.getElementById('cookie-analytics')?.checked || false; + + // Save to localStorage for now + localStorage.setItem('bp_cookies', JSON.stringify({functional, analytics})); + alert('Cookie-Einstellungen gespeichert!'); + } + + async function requestDataExport() { + alert('Ihre Datenanfrage wurde erstellt. Sie erhalten eine E-Mail, sobald Ihre Daten bereit sind.'); + } + + async function requestDataDownload() { + alert('Ihr Datenexport wurde gestartet. Sie erhalten eine E-Mail mit dem Download-Link.'); + } + + async function requestDataDeletion() { + if (confirm('Sind Sie sicher, dass Sie alle Ihre Daten löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.')) { + alert('Ihre Löschanfrage wurde erstellt. Wir werden sie innerhalb von 30 Tagen bearbeiten.'); + } + } + + // Load saved cookie preferences + const savedCookies = localStorage.getItem('bp_cookies'); + if (savedCookies) { + const prefs = JSON.parse(savedCookies); + if (document.getElementById('cookie-functional')) { + document.getElementById('cookie-functional').checked = prefs.functional; + } + if (document.getElementById('cookie-analytics')) { + document.getElementById('cookie-analytics').checked = prefs.analytics; + } + } + + // ========================================== + // LEGAL MODAL (now after modal HTML exists) + // ========================================== + const legalModal = document.getElementById('legal-modal'); + const legalModalClose = document.getElementById('legal-modal-close'); + const legalTabs = document.querySelectorAll('.legal-tab'); + const legalContents = document.querySelectorAll('.legal-content'); + const btnLegal = document.getElementById('btn-legal'); + + // Imprint Modal + const imprintModal = document.getElementById('imprint-modal'); + const imprintModalClose = document.getElementById('imprint-modal-close'); + + // Open legal modal from footer + function openLegalModal(tab = 'terms') { + legalModal.classList.add('active'); + // Switch to specified tab + if (tab) { + legalTabs.forEach(t => t.classList.remove('active')); + legalContents.forEach(c => c.classList.remove('active')); + const targetTab = document.querySelector(`.legal-tab[data-tab="${tab}"]`); + if (targetTab) targetTab.classList.add('active'); + document.getElementById(`legal-${tab}`)?.classList.add('active'); + } + loadLegalDocuments(); + } + + // Open imprint modal from footer + function openImprintModal() { + imprintModal.classList.add('active'); + loadImprintContent(); + } + + // Open legal modal + btnLegal?.addEventListener('click', async () => { + openLegalModal(); + }); + + // Close legal modal + legalModalClose?.addEventListener('click', () => { + legalModal.classList.remove('active'); + }); + + // Close imprint modal + imprintModalClose?.addEventListener('click', () => { + imprintModal.classList.remove('active'); + }); + + // Close on background click + legalModal?.addEventListener('click', (e) => { + if (e.target === legalModal) { + legalModal.classList.remove('active'); + } + }); + + imprintModal?.addEventListener('click', (e) => { + if (e.target === imprintModal) { + imprintModal.classList.remove('active'); + } + }); + + // Tab switching + legalTabs.forEach(tab => { + tab.addEventListener('click', () => { + const tabId = tab.dataset.tab; + legalTabs.forEach(t => t.classList.remove('active')); + legalContents.forEach(c => c.classList.remove('active')); + tab.classList.add('active'); + document.getElementById(`legal-${tabId}`)?.classList.add('active'); + + // Load cookie categories when switching to cookies tab + if (tabId === 'cookies') { + loadCookieCategories(); + } + }); + }); + + // Load legal documents from consent service + async function loadLegalDocuments() { + const lang = document.getElementById('language-select')?.value || 'de'; + + // Load all documents in parallel + await Promise.all([ + loadDocumentContent('terms', 'legal-terms-content', getDefaultTerms, lang), + loadDocumentContent('privacy', 'legal-privacy-content', getDefaultPrivacy, lang), + loadDocumentContent('community_guidelines', 'legal-community-content', getDefaultCommunityGuidelines, lang) + ]); + } + + // Load imprint content + async function loadImprintContent() { + const lang = document.getElementById('language-select')?.value || 'de'; + await loadDocumentContent('imprint', 'imprint-content', getDefaultImprint, lang); + } + + // Generic function to load document content + async function loadDocumentContent(docType, containerId, defaultFn, lang) { + const container = document.getElementById(containerId); + if (!container) return; + + try { + const res = await fetch(`/api/consent/documents/${docType}/latest?language=${lang}`); + if (res.ok) { + const data = await res.json(); + if (data.content) { + container.innerHTML = data.content; + return; + } + } + } catch(e) { + console.log(`Could not load ${docType}:`, e); + } + + // Fallback to default + container.innerHTML = defaultFn(lang); + } + + // Load cookie categories for the cookie settings tab + async function loadCookieCategories() { + const container = document.getElementById('cookie-categories-container'); + if (!container) return; + + try { + const res = await fetch('/api/consent/cookies/categories'); + if (res.ok) { + const data = await res.json(); + const categories = data.categories || []; + + if (categories.length === 0) { + container.innerHTML = getDefaultCookieCategories(); + return; + } + + // Get current preferences from localStorage + const savedPrefs = JSON.parse(localStorage.getItem('bp_cookie_consent') || '{}'); + + container.innerHTML = categories.map(cat => ` + + `).join(''); + } else { + container.innerHTML = getDefaultCookieCategories(); + } + } catch(e) { + container.innerHTML = getDefaultCookieCategories(); + } + } + + function getDefaultCookieCategories() { + return ` + + + + `; + } + + function getDefaultTerms(lang) { + const terms = { + de: '

Allgemeine Geschäftsbedingungen

Die BreakPilot-Plattform wird von der BreakPilot UG bereitgestellt.

Nutzung: Die Plattform dient zur Erstellung und Verwaltung von Lernmaterialien für Bildungszwecke.

Haftung: Die Nutzung erfolgt auf eigene Verantwortung.

Änderungen: Wir behalten uns vor, diese AGB jederzeit zu ändern.

', + en: '

Terms of Service

The BreakPilot platform is provided by BreakPilot UG.

Usage: The platform is designed for creating and managing learning materials for educational purposes.

Liability: Use at your own risk.

Changes: We reserve the right to modify these terms at any time.

' + }; + return terms[lang] || terms.de; + } + + function getDefaultPrivacy(lang) { + const privacy = { + de: '

Datenschutzerklärung

Verantwortlicher: BreakPilot UG

Erhobene Daten: Bei der Nutzung werden technische Daten (IP-Adresse, Browser-Typ) sowie von Ihnen eingegebene Inhalte verarbeitet.

Zweck: Die Daten werden zur Bereitstellung der Plattform und zur Verbesserung unserer Dienste genutzt.

Ihre Rechte (DSGVO):

  • Auskunftsrecht (Art. 15)
  • Recht auf Berichtigung (Art. 16)
  • Recht auf Löschung (Art. 17)
  • Recht auf Datenübertragbarkeit (Art. 20)

Kontakt: datenschutz@breakpilot.app

', + en: '

Privacy Policy

Controller: BreakPilot UG

Data Collected: Technical data (IP address, browser type) and content you provide are processed.

Purpose: Data is used to provide the platform and improve our services.

Your Rights (GDPR):

  • Right of access (Art. 15)
  • Right to rectification (Art. 16)
  • Right to erasure (Art. 17)
  • Right to data portability (Art. 20)

Contact: privacy@breakpilot.app

' + }; + return privacy[lang] || privacy.de; + } + + function getDefaultCommunityGuidelines(lang) { + const guidelines = { + de: '

Community Guidelines

Willkommen bei BreakPilot! Um eine positive und respektvolle Umgebung zu gewährleisten, bitten wir alle Nutzer, diese Richtlinien zu befolgen.

Respektvoller Umgang: Behandeln Sie andere Nutzer mit Respekt und Höflichkeit.

Keine illegalen Inhalte: Das Erstellen oder Teilen von illegalen Inhalten ist streng untersagt.

Urheberrecht: Respektieren Sie das geistige Eigentum anderer. Verwenden Sie nur Inhalte, für die Sie die Rechte besitzen.

Datenschutz: Teilen Sie keine persönlichen Daten anderer ohne deren ausdrückliche Zustimmung.

Qualität: Bemühen Sie sich um qualitativ hochwertige Lerninhalte.

Verstöße gegen diese Richtlinien können zur Sperrung des Accounts führen.

', + en: '

Community Guidelines

Welcome to BreakPilot! To ensure a positive and respectful environment, we ask all users to follow these guidelines.

Respectful Behavior: Treat other users with respect and courtesy.

No Illegal Content: Creating or sharing illegal content is strictly prohibited.

Copyright: Respect the intellectual property of others. Only use content you have rights to.

Privacy: Do not share personal data of others without their explicit consent.

Quality: Strive for high-quality learning content.

Violations of these guidelines may result in account suspension.

' + }; + return guidelines[lang] || guidelines.de; + } + + function getDefaultImprint(lang) { + const imprint = { + de: '

Impressum

Angaben gemäß § 5 TMG:

BreakPilot UG (haftungsbeschränkt)
Musterstraße 1
12345 Musterstadt
Deutschland

Vertreten durch:
Geschäftsführer: Max Mustermann

Kontakt:
Telefon: +49 (0) 123 456789
E-Mail: info@breakpilot.app

Registereintrag:
Eintragung im Handelsregister
Registergericht: Amtsgericht Musterstadt
Registernummer: HRB 12345

Umsatzsteuer-ID:
Umsatzsteuer-Identifikationsnummer gemäß § 27 a UStG: DE123456789

Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV:
Max Mustermann
Musterstraße 1
12345 Musterstadt

', + en: '

Legal Notice

Information according to § 5 TMG:

BreakPilot UG (limited liability)
Musterstraße 1
12345 Musterstadt
Germany

Represented by:
Managing Director: Max Mustermann

Contact:
Phone: +49 (0) 123 456789
Email: info@breakpilot.app

Register entry:
Entry in the commercial register
Register court: Amtsgericht Musterstadt
Register number: HRB 12345

VAT ID:
VAT identification number according to § 27 a UStG: DE123456789

Responsible for content according to § 55 Abs. 2 RStV:
Max Mustermann
Musterstraße 1
12345 Musterstadt

' + }; + return imprint[lang] || imprint.de; + } + + // Save cookie preferences + function saveCookiePreferences() { + const prefs = {}; + const checkboxes = document.querySelectorAll('#cookie-categories-container input[type="checkbox"]'); + checkboxes.forEach(cb => { + const name = cb.id.replace('cookie-', ''); + if (name && !cb.disabled) { + prefs[name] = cb.checked; + } + }); + localStorage.setItem('bp_cookie_consent', JSON.stringify(prefs)); + localStorage.setItem('bp_cookie_consent_date', new Date().toISOString()); + + // TODO: Send to consent service if user is logged in + alert('Cookie-Einstellungen gespeichert!'); + } + + // ========================================== + // AUTH MODAL + // ========================================== + const authModal = document.getElementById('auth-modal'); + const authModalClose = document.getElementById('auth-modal-close'); + const authTabs = document.querySelectorAll('.auth-tab'); + const authContents = document.querySelectorAll('.auth-content'); + const btnLogin = document.getElementById('btn-login'); + + // Auth state + let currentUser = null; + let accessToken = localStorage.getItem('bp_access_token'); + let refreshToken = localStorage.getItem('bp_refresh_token'); + + // Update UI based on auth state + function updateAuthUI() { + const loginBtn = document.getElementById('btn-login'); + const userDropdown = document.querySelector('.auth-user-dropdown'); + const notificationBell = document.getElementById('notification-bell'); + + if (currentUser && accessToken) { + // User is logged in - hide login button + if (loginBtn) loginBtn.style.display = 'none'; + + // Show notification bell + if (notificationBell) { + notificationBell.classList.add('active'); + loadNotifications(); // Load notifications on login + startNotificationPolling(); // Start polling for new notifications + checkSuspensionStatus(); // Check if account is suspended + } + + // Show user dropdown if it exists + if (userDropdown) { + userDropdown.classList.add('active'); + const avatar = userDropdown.querySelector('.auth-user-avatar'); + const menuName = userDropdown.querySelector('.auth-user-menu-name'); + const menuEmail = userDropdown.querySelector('.auth-user-menu-email'); + + if (avatar) { + const initials = currentUser.name + ? currentUser.name.substring(0, 2).toUpperCase() + : currentUser.email.substring(0, 2).toUpperCase(); + avatar.textContent = initials; + } + if (menuName) menuName.textContent = currentUser.name || 'Benutzer'; + if (menuEmail) menuEmail.textContent = currentUser.email; + } + } else { + // User is logged out - show login button + if (loginBtn) loginBtn.style.display = 'block'; + if (userDropdown) userDropdown.classList.remove('active'); + if (notificationBell) notificationBell.classList.remove('active'); + stopNotificationPolling(); + } + } + + // Check if user is already logged in + async function checkAuthStatus() { + if (!accessToken) return; + + try { + const response = await fetch('/api/auth/profile', { + headers: { 'Authorization': `Bearer ${accessToken}` } + }); + + if (response.ok) { + currentUser = await response.json(); + updateAuthUI(); + } else if (response.status === 401 && refreshToken) { + // Try to refresh the token + await refreshAccessToken(); + } else { + // Clear invalid tokens + logout(false); + } + } catch (e) { + console.error('Auth check failed:', e); + } + } + + // Refresh access token + async function refreshAccessToken() { + if (!refreshToken) return false; + + try { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }) + }); + + if (response.ok) { + const data = await response.json(); + accessToken = data.access_token; + refreshToken = data.refresh_token; + currentUser = data.user; + + localStorage.setItem('bp_access_token', accessToken); + localStorage.setItem('bp_refresh_token', refreshToken); + updateAuthUI(); + return true; + } else { + logout(false); + return false; + } + } catch (e) { + console.error('Token refresh failed:', e); + return false; + } + } + + // Logout + function logout(showMessage = true) { + if (refreshToken) { + fetch('/api/auth/logout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }) + }).catch(() => {}); + } + + currentUser = null; + accessToken = null; + refreshToken = null; + localStorage.removeItem('bp_access_token'); + localStorage.removeItem('bp_refresh_token'); + updateAuthUI(); + + if (showMessage) { + alert('Sie wurden erfolgreich abgemeldet.'); + } + } + + // Open auth modal + btnLogin?.addEventListener('click', () => { + authModal.classList.add('active'); + showAuthTab('login'); + clearAuthErrors(); + }); + + // Close auth modal + authModalClose?.addEventListener('click', () => { + authModal.classList.remove('active'); + clearAuthErrors(); + }); + + // Close on background click + authModal?.addEventListener('click', (e) => { + if (e.target === authModal) { + authModal.classList.remove('active'); + clearAuthErrors(); + } + }); + + // Tab switching + authTabs.forEach(tab => { + tab.addEventListener('click', () => { + const tabId = tab.dataset.tab; + showAuthTab(tabId); + }); + }); + + function showAuthTab(tabId) { + authTabs.forEach(t => t.classList.remove('active')); + authContents.forEach(c => c.classList.remove('active')); + + const activeTab = document.querySelector(`.auth-tab[data-tab="${tabId}"]`); + if (activeTab) activeTab.classList.add('active'); + + document.getElementById(`auth-${tabId}`)?.classList.add('active'); + clearAuthErrors(); + } + + function clearAuthErrors() { + document.querySelectorAll('.auth-error, .auth-success').forEach(el => { + el.classList.remove('active'); + el.textContent = ''; + }); + } + + function showAuthError(elementId, message) { + const el = document.getElementById(elementId); + if (el) { + el.textContent = message; + el.classList.add('active'); + } + } + + function showAuthSuccess(elementId, message) { + const el = document.getElementById(elementId); + if (el) { + el.textContent = message; + el.classList.add('active'); + } + } + + // Login form + document.getElementById('auth-login-form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + clearAuthErrors(); + + const email = document.getElementById('login-email').value; + const password = document.getElementById('login-password').value; + const btn = document.getElementById('login-btn'); + + btn.disabled = true; + btn.textContent = 'Anmelden...'; + + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }) + }); + + const data = await response.json(); + + if (response.ok) { + accessToken = data.access_token; + refreshToken = data.refresh_token; + currentUser = data.user; + + localStorage.setItem('bp_access_token', accessToken); + localStorage.setItem('bp_refresh_token', refreshToken); + + updateAuthUI(); + authModal.classList.remove('active'); + + // Clear form + document.getElementById('login-email').value = ''; + document.getElementById('login-password').value = ''; + } else { + showAuthError('auth-login-error', data.detail || data.error || 'Anmeldung fehlgeschlagen'); + } + } catch (e) { + showAuthError('auth-login-error', 'Verbindungsfehler. Bitte versuchen Sie es erneut.'); + } + + btn.disabled = false; + btn.textContent = 'Anmelden'; + }); + + // Register form + document.getElementById('auth-register-form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + clearAuthErrors(); + + const name = document.getElementById('register-name').value; + const email = document.getElementById('register-email').value; + const password = document.getElementById('register-password').value; + const passwordConfirm = document.getElementById('register-password-confirm').value; + + if (password !== passwordConfirm) { + showAuthError('auth-register-error', 'Passwörter stimmen nicht überein'); + return; + } + + if (password.length < 8) { + showAuthError('auth-register-error', 'Passwort muss mindestens 8 Zeichen lang sein'); + return; + } + + const btn = document.getElementById('register-btn'); + btn.disabled = true; + btn.textContent = 'Registrieren...'; + + try { + const response = await fetch('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, name: name || undefined }) + }); + + const data = await response.json(); + + if (response.ok) { + showAuthSuccess('auth-register-success', + 'Registrierung erfolgreich! Bitte überprüfen Sie Ihre E-Mails zur Bestätigung.'); + + // Clear form + document.getElementById('register-name').value = ''; + document.getElementById('register-email').value = ''; + document.getElementById('register-password').value = ''; + document.getElementById('register-password-confirm').value = ''; + } else { + showAuthError('auth-register-error', data.detail || data.error || 'Registrierung fehlgeschlagen'); + } + } catch (e) { + showAuthError('auth-register-error', 'Verbindungsfehler. Bitte versuchen Sie es erneut.'); + } + + btn.disabled = false; + btn.textContent = 'Registrieren'; + }); + + // Forgot password form + document.getElementById('auth-forgot-form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + clearAuthErrors(); + + const email = document.getElementById('forgot-email').value; + const btn = document.getElementById('forgot-btn'); + + btn.disabled = true; + btn.textContent = 'Senden...'; + + try { + const response = await fetch('/api/auth/forgot-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }) + }); + + // Always show success to prevent email enumeration + showAuthSuccess('auth-forgot-success', + 'Falls ein Konto mit dieser E-Mail existiert, wurde ein Link zum Zurücksetzen gesendet.'); + + document.getElementById('forgot-email').value = ''; + } catch (e) { + showAuthError('auth-forgot-error', 'Verbindungsfehler. Bitte versuchen Sie es erneut.'); + } + + btn.disabled = false; + btn.textContent = 'Link senden'; + }); + + // Reset password form + document.getElementById('auth-reset-form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + clearAuthErrors(); + + const password = document.getElementById('reset-password').value; + const passwordConfirm = document.getElementById('reset-password-confirm').value; + const token = document.getElementById('reset-token').value; + + if (password !== passwordConfirm) { + showAuthError('auth-reset-error', 'Passwörter stimmen nicht überein'); + return; + } + + if (password.length < 8) { + showAuthError('auth-reset-error', 'Passwort muss mindestens 8 Zeichen lang sein'); + return; + } + + const btn = document.getElementById('reset-btn'); + btn.disabled = true; + btn.textContent = 'Ändern...'; + + try { + const response = await fetch('/api/auth/reset-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, new_password: password }) + }); + + const data = await response.json(); + + if (response.ok) { + showAuthSuccess('auth-reset-success', + 'Passwort erfolgreich geändert! Sie können sich jetzt anmelden.'); + + // Clear URL params + window.history.replaceState({}, document.title, window.location.pathname); + + // Switch to login after 2 seconds + setTimeout(() => showAuthTab('login'), 2000); + } else { + showAuthError('auth-reset-error', data.detail || data.error || 'Passwort zurücksetzen fehlgeschlagen'); + } + } catch (e) { + showAuthError('auth-reset-error', 'Verbindungsfehler. Bitte versuchen Sie es erneut.'); + } + + btn.disabled = false; + btn.textContent = 'Passwort ändern'; + }); + + // Navigation links + document.getElementById('auth-forgot-password')?.addEventListener('click', (e) => { + e.preventDefault(); + showAuthTab('forgot'); + // Hide tabs for forgot password + document.querySelector('.auth-tabs').style.display = 'none'; + }); + + document.getElementById('auth-back-to-login')?.addEventListener('click', (e) => { + e.preventDefault(); + showAuthTab('login'); + document.querySelector('.auth-tabs').style.display = 'flex'; + }); + + document.getElementById('auth-goto-login')?.addEventListener('click', (e) => { + e.preventDefault(); + showAuthTab('login'); + }); + + // Check for URL parameters (email verification, password reset) + function checkAuthUrlParams() { + const urlParams = new URLSearchParams(window.location.search); + const verifyToken = urlParams.get('verify'); + const resetToken = urlParams.get('reset'); + + if (verifyToken) { + authModal.classList.add('active'); + document.querySelector('.auth-tabs').style.display = 'none'; + showAuthTab('verify'); + verifyEmail(verifyToken); + } else if (resetToken) { + authModal.classList.add('active'); + document.querySelector('.auth-tabs').style.display = 'none'; + showAuthTab('reset'); + document.getElementById('reset-token').value = resetToken; + } + } + + async function verifyEmail(token) { + try { + const response = await fetch('/api/auth/verify-email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }) + }); + + const data = await response.json(); + const loadingEl = document.getElementById('auth-verify-loading'); + + if (response.ok) { + if (loadingEl) loadingEl.style.display = 'none'; + showAuthSuccess('auth-verify-success', 'E-Mail erfolgreich verifiziert! Sie können sich jetzt anmelden.'); + + // Clear URL params + window.history.replaceState({}, document.title, window.location.pathname); + + // Switch to login after 2 seconds + setTimeout(() => { + showAuthTab('login'); + document.querySelector('.auth-tabs').style.display = 'flex'; + }, 2000); + } else { + if (loadingEl) loadingEl.style.display = 'none'; + showAuthError('auth-verify-error', data.detail || data.error || 'Verifizierung fehlgeschlagen. Der Link ist möglicherweise abgelaufen.'); + } + } catch (e) { + document.getElementById('auth-verify-loading').style.display = 'none'; + showAuthError('auth-verify-error', 'Verbindungsfehler. Bitte versuchen Sie es erneut.'); + } + } + + // User dropdown toggle + const authUserBtn = document.getElementById('auth-user-btn'); + const authUserMenu = document.getElementById('auth-user-menu'); + + authUserBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + authUserMenu.classList.toggle('active'); + }); + + // Close dropdown when clicking outside + document.addEventListener('click', () => { + authUserMenu?.classList.remove('active'); + }); + + // Placeholder functions for profile/sessions + function showProfileModal() { + alert('Profil-Einstellungen kommen bald!'); + } + + function showSessionsModal() { + alert('Sitzungsverwaltung kommt bald!'); + } + + // ========================================== + // NOTIFICATION FUNCTIONS + // ========================================== + let notificationPollingInterval = null; + let notificationOffset = 0; + let notificationPrefs = { + email_enabled: true, + push_enabled: false, + in_app_enabled: true + }; + + // Toggle notification panel + document.getElementById('notification-bell-btn')?.addEventListener('click', (e) => { + e.stopPropagation(); + const panel = document.getElementById('notification-panel'); + panel.classList.toggle('active'); + + // Close user menu if open + const userMenu = document.getElementById('auth-user-menu'); + userMenu?.classList.remove('active'); + }); + + // Close notification panel when clicking outside + document.addEventListener('click', (e) => { + const bell = document.getElementById('notification-bell'); + const panel = document.getElementById('notification-panel'); + if (bell && panel && !bell.contains(e.target)) { + panel.classList.remove('active'); + } + }); + + // Load notifications from API + async function loadNotifications(append = false) { + if (!accessToken) return; + + try { + const limit = 10; + const offset = append ? notificationOffset : 0; + + const response = await fetch(`/api/v1/notifications?limit=${limit}&offset=${offset}`, { + headers: { 'Authorization': `Bearer ${accessToken}` } + }); + + if (!response.ok) return; + + const data = await response.json(); + + if (!append) { + notificationOffset = 0; + } + notificationOffset += data.notifications?.length || 0; + + renderNotifications(data.notifications || [], data.total || 0, append); + updateNotificationBadge(); + } catch (e) { + console.error('Failed to load notifications:', e); + } + } + + // Render notifications in the panel + function renderNotifications(notifications, total, append = false) { + const list = document.getElementById('notification-list'); + if (!list) return; + + if (!append) { + list.innerHTML = ''; + } + + if (notifications.length === 0 && !append) { + list.innerHTML = ` +
+
🔔
+
Keine Benachrichtigungen
+
+ `; + return; + } + + notifications.forEach(n => { + const item = document.createElement('div'); + item.className = `notification-item ${!n.read_at ? 'unread' : ''}`; + item.onclick = () => markNotificationRead(n.id); + + const icon = getNotificationIcon(n.type); + const timeAgo = formatTimeAgo(new Date(n.created_at)); + + item.innerHTML = ` +
${icon}
+
+
${escapeHtml(n.title)}
+
${escapeHtml(n.body)}
+
${timeAgo}
+
+ `; + list.appendChild(item); + }); + + // Show/hide load more button + const footer = document.querySelector('.notification-footer'); + if (footer) { + footer.style.display = notificationOffset < total ? 'block' : 'none'; + } + } + + // Get icon for notification type + function getNotificationIcon(type) { + const icons = { + 'consent_required': '📋', + 'consent_reminder': '⏰', + 'version_published': '📢', + 'version_approved': '✅', + 'version_rejected': '❌', + 'account_suspended': '🚫', + 'account_restored': '🔓', + 'general': '🔔' + }; + return icons[type] || '🔔'; + } + + // Format time ago + function formatTimeAgo(date) { + const now = new Date(); + const diff = Math.floor((now - date) / 1000); + + if (diff < 60) return 'Gerade eben'; + if (diff < 3600) return `vor ${Math.floor(diff / 60)} Min.`; + if (diff < 86400) return `vor ${Math.floor(diff / 3600)} Std.`; + if (diff < 604800) return `vor ${Math.floor(diff / 86400)} Tagen`; + return date.toLocaleDateString('de-DE'); + } + + // Escape HTML to prevent XSS + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // Update notification badge + async function updateNotificationBadge() { + if (!accessToken) return; + + try { + const response = await fetch('/api/v1/notifications/unread-count', { + headers: { 'Authorization': `Bearer ${accessToken}` } + }); + + if (!response.ok) return; + + const data = await response.json(); + const badge = document.getElementById('notification-badge'); + + if (badge) { + const count = data.unread_count || 0; + badge.textContent = count > 99 ? '99+' : count; + badge.classList.toggle('hidden', count === 0); + } + } catch (e) { + console.error('Failed to update badge:', e); + } + } + + // Mark notification as read + async function markNotificationRead(id) { + if (!accessToken) return; + + try { + await fetch(`/api/v1/notifications/${id}/read`, { + method: 'PUT', + headers: { 'Authorization': `Bearer ${accessToken}` } + }); + + // Update UI + const item = document.querySelector(`.notification-item[onclick*="${id}"]`); + if (item) item.classList.remove('unread'); + updateNotificationBadge(); + } catch (e) { + console.error('Failed to mark notification as read:', e); + } + } + + // Mark all notifications as read + async function markAllNotificationsRead() { + if (!accessToken) return; + + try { + await fetch('/api/v1/notifications/read-all', { + method: 'PUT', + headers: { 'Authorization': `Bearer ${accessToken}` } + }); + + // Update UI + document.querySelectorAll('.notification-item.unread').forEach(item => { + item.classList.remove('unread'); + }); + updateNotificationBadge(); + } catch (e) { + console.error('Failed to mark all as read:', e); + } + } + + // Load more notifications + function loadMoreNotifications() { + loadNotifications(true); + } + + // Start polling for new notifications + function startNotificationPolling() { + stopNotificationPolling(); + notificationPollingInterval = setInterval(() => { + updateNotificationBadge(); + }, 30000); // Poll every 30 seconds + } + + // Stop polling + function stopNotificationPolling() { + if (notificationPollingInterval) { + clearInterval(notificationPollingInterval); + notificationPollingInterval = null; + } + } + + // Show notification preferences modal + function showNotificationPreferences() { + document.getElementById('notification-panel')?.classList.remove('active'); + document.getElementById('notification-prefs-modal')?.classList.add('active'); + loadNotificationPreferences(); + } + + // Close notification preferences modal + function closeNotificationPreferences() { + document.getElementById('notification-prefs-modal')?.classList.remove('active'); + } + + // Load notification preferences + async function loadNotificationPreferences() { + if (!accessToken) return; + + try { + const response = await fetch('/api/v1/notifications/preferences', { + headers: { 'Authorization': `Bearer ${accessToken}` } + }); + + if (!response.ok) return; + + const prefs = await response.json(); + notificationPrefs = prefs; + + // Update UI toggles + updateToggle('pref-email-toggle', prefs.email_enabled); + updateToggle('pref-inapp-toggle', prefs.in_app_enabled); + updateToggle('pref-push-toggle', prefs.push_enabled); + } catch (e) { + console.error('Failed to load preferences:', e); + } + } + + // Update toggle UI + function updateToggle(id, active) { + const toggle = document.getElementById(id); + if (toggle) { + toggle.classList.toggle('active', active); + } + } + + // Toggle notification preference + function toggleNotificationPref(type) { + const toggleMap = { + 'email': 'pref-email-toggle', + 'inapp': 'pref-inapp-toggle', + 'push': 'pref-push-toggle' + }; + const prefMap = { + 'email': 'email_enabled', + 'inapp': 'in_app_enabled', + 'push': 'push_enabled' + }; + + const toggleId = toggleMap[type]; + const prefKey = prefMap[type]; + const toggle = document.getElementById(toggleId); + + if (toggle) { + const isActive = toggle.classList.toggle('active'); + notificationPrefs[prefKey] = isActive; + } + } + + // Save notification preferences + async function saveNotificationPreferences() { + if (!accessToken) return; + + try { + const response = await fetch('/api/v1/notifications/preferences', { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(notificationPrefs) + }); + + if (response.ok) { + closeNotificationPreferences(); + alert('Einstellungen gespeichert!'); + } else { + alert('Fehler beim Speichern der Einstellungen'); + } + } catch (e) { + console.error('Failed to save preferences:', e); + alert('Fehler beim Speichern der Einstellungen'); + } + } + + // ========================================== + // SUSPENSION CHECK FUNCTIONS + // ========================================== + let isSuspended = false; + + // Check suspension status after login + async function checkSuspensionStatus() { + if (!accessToken) return; + + try { + const response = await fetch('/api/v1/account/suspension-status', { + headers: { 'Authorization': `Bearer ${accessToken}` } + }); + + if (!response.ok) return; + + const data = await response.json(); + + if (data.suspended) { + isSuspended = true; + showSuspensionOverlay(data); + } else { + isSuspended = false; + hideSuspensionOverlay(); + } + } catch (e) { + console.error('Failed to check suspension status:', e); + } + } + + // Show suspension overlay + function showSuspensionOverlay(data) { + const overlay = document.getElementById('suspension-overlay'); + const docList = document.getElementById('suspension-doc-list'); + + if (!overlay || !docList) return; + + // Populate document list + if (data.pending_deadlines && data.pending_deadlines.length > 0) { + docList.innerHTML = data.pending_deadlines.map(d => { + const deadline = new Date(d.deadline_at); + const isOverdue = deadline < new Date(); + return ` +
+ ${escapeHtml(d.document_name)} + ${isOverdue ? 'Überfällig' : deadline.toLocaleDateString('de-DE')} +
+ `; + }).join(''); + } else if (data.details && data.details.documents) { + docList.innerHTML = data.details.documents.map(doc => ` +
+ ${escapeHtml(doc)} + Bestätigung erforderlich +
+ `).join(''); + } + + overlay.classList.add('active'); + } + + // Hide suspension overlay + function hideSuspensionOverlay() { + const overlay = document.getElementById('suspension-overlay'); + if (overlay) { + overlay.classList.remove('active'); + } + } + + // Show consent modal from suspension overlay + function showConsentModal() { + hideSuspensionOverlay(); + // Open legal modal to consent tab + document.getElementById('legal-modal')?.classList.add('active'); + // Switch to appropriate tab + } + + // Initialize auth on page load + checkAuthStatus(); + checkAuthUrlParams(); + + // ========================================== + // RICH TEXT EDITOR FUNCTIONS + // ========================================== + const versionEditor = document.getElementById('admin-version-editor'); + const versionContentHidden = document.getElementById('admin-version-content'); + const editorCharCount = document.getElementById('editor-char-count'); + + // Update hidden field and char count when editor content changes + versionEditor?.addEventListener('input', () => { + versionContentHidden.value = versionEditor.innerHTML; + const textLength = versionEditor.textContent.length; + editorCharCount.textContent = `${textLength} Zeichen`; + }); + + // Format document with execCommand + function formatDoc(cmd, value = null) { + versionEditor.focus(); + document.execCommand(cmd, false, value); + } + + // Format block element + function formatBlock(tag) { + versionEditor.focus(); + document.execCommand('formatBlock', false, `<${tag}>`); + } + + // Insert link + function insertLink() { + const url = prompt('Link-URL eingeben:', 'https://'); + if (url) { + versionEditor.focus(); + document.execCommand('createLink', false, url); + } + } + + // Handle Word document upload + async function handleWordUpload(event) { + const file = event.target.files[0]; + if (!file) return; + + // Show loading indicator + const editor = document.getElementById('admin-version-editor'); + const originalContent = editor.innerHTML; + editor.innerHTML = '

Word-Dokument wird verarbeitet...

'; + + const formData = new FormData(); + formData.append('file', file); + + try { + const response = await fetch('/api/consent/admin/versions/upload-word', { + method: 'POST', + body: formData + }); + + if (response.ok) { + const data = await response.json(); + editor.innerHTML = data.html || '

Konvertierung fehlgeschlagen

'; + versionContentHidden.value = editor.innerHTML; + + // Update char count + const textLength = editor.textContent.length; + editorCharCount.textContent = `${textLength} Zeichen`; + } else { + const error = await response.json(); + editor.innerHTML = originalContent; + alert('Fehler beim Importieren: ' + (error.detail || 'Unbekannter Fehler')); + } + } catch (e) { + editor.innerHTML = originalContent; + alert('Fehler beim Hochladen: ' + e.message); + } + + // Reset file input + event.target.value = ''; + } + + // Handle paste from Word - clean up the HTML + versionEditor?.addEventListener('paste', (e) => { + // Get pasted data via clipboard API + const clipboardData = e.clipboardData || window.clipboardData; + const pastedData = clipboardData.getData('text/html') || clipboardData.getData('text/plain'); + + if (pastedData && clipboardData.getData('text/html')) { + e.preventDefault(); + + // Clean the HTML + const cleanHtml = cleanWordHtml(pastedData); + document.execCommand('insertHTML', false, cleanHtml); + + // Update hidden field + versionContentHidden.value = versionEditor.innerHTML; + } + }); + + // Clean Word-specific HTML + function cleanWordHtml(html) { + // Create a temporary container + const temp = document.createElement('div'); + temp.innerHTML = html; + + // Remove Word-specific elements and attributes + const elementsToRemove = temp.querySelectorAll('style, script, meta, link, xml'); + elementsToRemove.forEach(el => el.remove()); + + // Get text content from Word spans with specific styling + let cleanedHtml = temp.innerHTML; + + // Remove mso-* styles and other Office-specific CSS + cleanedHtml = cleanedHtml.replace(/\s*mso-[^:]+:[^;]+;?/gi, ''); + cleanedHtml = cleanedHtml.replace(/\s*style="[^"]*"/gi, ''); + cleanedHtml = cleanedHtml.replace(/\s*class="[^"]*"/gi, ''); + cleanedHtml = cleanedHtml.replace(/<\/o:p>/gi, ''); + cleanedHtml = cleanedHtml.replace(/<\/?o:[^>]*>/gi, ''); + cleanedHtml = cleanedHtml.replace(/<\/?w:[^>]*>/gi, ''); + cleanedHtml = cleanedHtml.replace(/<\/?m:[^>]*>/gi, ''); + + // Clean up empty spans + cleanedHtml = cleanedHtml.replace(/]*>\s*<\/span>/gi, ''); + + // Convert Word list markers to proper lists + cleanedHtml = cleanedHtml.replace(/]*>\s*[•·]\s*/gi, '
  • '); + + return cleanedHtml; + } + + // ========================================== + // ADMIN PANEL + // ========================================== + const adminModal = document.getElementById('admin-modal'); + const adminModalClose = document.getElementById('admin-modal-close'); + const adminTabs = document.querySelectorAll('.admin-tab'); + const adminContents = document.querySelectorAll('.admin-content'); + const btnAdmin = document.getElementById('btn-admin'); + + // Admin data cache + let adminDocuments = []; + let adminCookieCategories = []; + + // Open admin modal + btnAdmin?.addEventListener('click', async () => { + adminModal.classList.add('active'); + await loadAdminDocuments(); + await loadAdminCookieCategories(); + populateDocumentSelect(); + }); + + // Close admin modal + adminModalClose?.addEventListener('click', () => { + adminModal.classList.remove('active'); + }); + + // Close on background click + adminModal?.addEventListener('click', (e) => { + if (e.target === adminModal) { + adminModal.classList.remove('active'); + } + }); + + // Admin tab switching + adminTabs.forEach(tab => { + tab.addEventListener('click', () => { + const tabId = tab.dataset.tab; + adminTabs.forEach(t => t.classList.remove('active')); + adminContents.forEach(c => c.classList.remove('active')); + tab.classList.add('active'); + document.getElementById(`admin-${tabId}`)?.classList.add('active'); + + // Load stats when stats tab is clicked + if (tabId === 'stats') { + loadAdminStats(); + } + }); + }); + + // ========================================== + // DOCUMENTS MANAGEMENT + // ========================================== + async function loadAdminDocuments() { + const container = document.getElementById('admin-doc-table-container'); + container.innerHTML = '
    Lade Dokumente...
    '; + + try { + const res = await fetch('/api/consent/admin/documents'); + if (!res.ok) throw new Error('Failed to load'); + const data = await res.json(); + adminDocuments = data.documents || []; + renderDocumentsTable(); + } catch(e) { + container.innerHTML = '
    Fehler beim Laden der Dokumente.
    '; + } + } + + function renderDocumentsTable() { + const container = document.getElementById('admin-doc-table-container'); + + // Alle Dokumente anzeigen + const allDocs = adminDocuments; + + if (allDocs.length === 0) { + container.innerHTML = '
    Keine Dokumente vorhanden. Klicken Sie auf "+ Neues Dokument" um ein Dokument zu erstellen.
    '; + return; + } + + const typeLabels = { + 'terms': 'AGB', + 'privacy': 'Datenschutz', + 'cookies': 'Cookies', + 'community': 'Community', + 'imprint': 'Impressum' + }; + + const html = ` + + + + + + + + + + + + ${allDocs.map(doc => ` + + + + + + + + `).join('')} + +
    TypNameBeschreibungStatusAktionen
    ${typeLabels[doc.type] || doc.type}${doc.name}${doc.description || '-'} + ${doc.is_active ? 'Aktiv' : 'Inaktiv'} + ${doc.is_mandatory ? 'Pflicht' : ''} + + + +
    + `; + container.innerHTML = html; + } + + function goToVersions(docId) { + // Wechsle zum Versionen-Tab und wähle das Dokument aus + const versionsTab = document.querySelector('.admin-tab[data-tab="versions"]'); + if (versionsTab) { + versionsTab.click(); + setTimeout(() => { + const select = document.getElementById('admin-version-doc-select'); + if (select) { + select.value = docId; + loadVersionsForDocument(); + } + }, 100); + } + } + + function showDocumentForm(doc = null) { + const form = document.getElementById('admin-document-form'); + const title = document.getElementById('admin-document-form-title'); + + if (doc) { + title.textContent = 'Dokument bearbeiten'; + document.getElementById('admin-document-id').value = doc.id; + document.getElementById('admin-document-type').value = doc.type; + document.getElementById('admin-document-name').value = doc.name; + document.getElementById('admin-document-description').value = doc.description || ''; + document.getElementById('admin-document-mandatory').checked = doc.is_mandatory; + } else { + title.textContent = 'Neues Dokument erstellen'; + document.getElementById('admin-document-id').value = ''; + document.getElementById('admin-document-type').value = ''; + document.getElementById('admin-document-name').value = ''; + document.getElementById('admin-document-description').value = ''; + document.getElementById('admin-document-mandatory').checked = true; + } + + form.style.display = 'block'; + } + + function hideDocumentForm() { + document.getElementById('admin-document-form').style.display = 'none'; + } + + function editDocument(docId) { + const doc = adminDocuments.find(d => d.id === docId); + if (doc) showDocumentForm(doc); + } + + async function saveDocument() { + const docId = document.getElementById('admin-document-id').value; + const docType = document.getElementById('admin-document-type').value; + const docName = document.getElementById('admin-document-name').value; + + if (!docType || !docName) { + alert('Bitte füllen Sie alle Pflichtfelder aus (Typ und Name).'); + return; + } + + const data = { + type: docType, + name: docName, + description: document.getElementById('admin-document-description').value || null, + is_mandatory: document.getElementById('admin-document-mandatory').checked + }; + + try { + const url = docId ? `/api/consent/admin/documents/${docId}` : '/api/consent/admin/documents'; + const method = docId ? 'PUT' : 'POST'; + + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + if (!res.ok) throw new Error('Failed to save'); + + hideDocumentForm(); + await loadAdminDocuments(); + populateDocumentSelect(); + alert('Dokument gespeichert!'); + } catch(e) { + alert('Fehler beim Speichern: ' + e.message); + } + } + + async function deleteDocument(docId) { + if (!confirm('Dokument wirklich deaktivieren?')) return; + + try { + const res = await fetch(`/api/consent/admin/documents/${docId}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Failed to delete'); + + await loadAdminDocuments(); + populateDocumentSelect(); + alert('Dokument deaktiviert!'); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + // ========================================== + // VERSIONS MANAGEMENT + // ========================================== + function populateDocumentSelect() { + const select = document.getElementById('admin-version-doc-select'); + const uniqueDocs = [...new Map(adminDocuments.map(d => [d.type, d])).values()]; + + select.innerHTML = '' + + adminDocuments.filter(d => d.is_active).map(doc => + `` + ).join(''); + } + + async function loadVersionsForDocument() { + const docId = document.getElementById('admin-version-doc-select').value; + const container = document.getElementById('admin-version-table-container'); + const btnNew = document.getElementById('btn-new-version'); + + if (!docId) { + container.innerHTML = '
    Wählen Sie ein Dokument aus.
    '; + btnNew.disabled = true; + return; + } + + btnNew.disabled = false; + container.innerHTML = '
    Lade Versionen...
    '; + + try { + const res = await fetch(`/api/consent/admin/documents/${docId}/versions`); + if (!res.ok) throw new Error('Failed to load'); + const data = await res.json(); + renderVersionsTable(data.versions || []); + } catch(e) { + container.innerHTML = '
    Fehler beim Laden der Versionen.
    '; + } + } + + function renderVersionsTable(versions) { + const container = document.getElementById('admin-version-table-container'); + if (versions.length === 0) { + container.innerHTML = '
    Keine Versionen vorhanden.
    '; + return; + } + + const getStatusBadge = (status) => { + const statusLabels = { + 'draft': 'Entwurf', + 'review': 'In Prüfung', + 'approved': 'Genehmigt', + 'rejected': 'Abgelehnt', + 'scheduled': 'Geplant', + 'published': 'Veröffentlicht', + 'archived': 'Archiviert' + }; + return statusLabels[status] || status; + }; + + const formatScheduledDate = (isoDate) => { + if (!isoDate) return ''; + const date = new Date(isoDate); + return date.toLocaleString('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit' + }); + }; + + const html = ` + + + + + + + + + + + + ${versions.map(v => ` + + + + + + + + `).join('')} + +
    VersionSpracheTitelStatusAktionen
    ${v.version}${v.language.toUpperCase()}${v.title} + ${getStatusBadge(v.status)} + ${v.scheduled_publish_at ? `
    Geplant: ${formatScheduledDate(v.scheduled_publish_at)}` : ''} +
    + ${v.status === 'draft' ? ` + + + + ` : ''} + ${v.status === 'review' ? ` + + + + ` : ''} + ${v.status === 'rejected' ? ` + + + + ` : ''} + ${v.status === 'scheduled' ? ` + + Wartet auf Veröffentlichung + ` : ''} + ${v.status === 'approved' ? ` + + + + ` : ''} + ${v.status === 'published' ? ` + + ` : ''} + +
    + `; + container.innerHTML = html; + } + + function showVersionForm() { + const form = document.getElementById('admin-version-form'); + document.getElementById('admin-version-id').value = ''; + document.getElementById('admin-version-number').value = ''; + document.getElementById('admin-version-lang').value = 'de'; + document.getElementById('admin-version-title').value = ''; + document.getElementById('admin-version-summary').value = ''; + document.getElementById('admin-version-content').value = ''; + // Clear rich text editor + const editor = document.getElementById('admin-version-editor'); + if (editor) { + editor.innerHTML = ''; + document.getElementById('editor-char-count').textContent = '0 Zeichen'; + } + form.classList.add('active'); + } + + function hideVersionForm() { + document.getElementById('admin-version-form').classList.remove('active'); + } + + async function editVersion(versionId) { + // Lade die Version und fülle das Formular + const docId = document.getElementById('admin-version-doc-select').value; + + try { + const res = await fetch(`/api/consent/admin/documents/${docId}/versions`); + if (!res.ok) throw new Error('Failed to load versions'); + const data = await res.json(); + + const version = (data.versions || []).find(v => v.id === versionId); + if (!version) { + alert('Version nicht gefunden'); + return; + } + + // Formular öffnen und Daten einfügen + const form = document.getElementById('admin-version-form'); + document.getElementById('admin-version-id').value = version.id; + document.getElementById('admin-version-number').value = version.version; + document.getElementById('admin-version-lang').value = version.language; + document.getElementById('admin-version-title').value = version.title; + document.getElementById('admin-version-summary').value = version.summary || ''; + + // Rich-Text-Editor mit Inhalt füllen + const editor = document.getElementById('admin-version-editor'); + if (editor) { + editor.innerHTML = version.content || ''; + const charCount = editor.textContent.length; + document.getElementById('editor-char-count').textContent = charCount + ' Zeichen'; + } + document.getElementById('admin-version-content').value = version.content || ''; + + form.classList.add('active'); + } catch(e) { + alert('Fehler beim Laden der Version: ' + e.message); + } + } + + async function saveVersion() { + const docId = document.getElementById('admin-version-doc-select').value; + const versionId = document.getElementById('admin-version-id').value; + + // Get content from rich text editor + const editor = document.getElementById('admin-version-editor'); + const content = editor ? editor.innerHTML : document.getElementById('admin-version-content').value; + + const data = { + document_id: docId, + version: document.getElementById('admin-version-number').value, + language: document.getElementById('admin-version-lang').value, + title: document.getElementById('admin-version-title').value, + summary: document.getElementById('admin-version-summary').value, + content: content + }; + + try { + const url = versionId ? `/api/consent/admin/versions/${versionId}` : '/api/consent/admin/versions'; + const method = versionId ? 'PUT' : 'POST'; + + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + if (!res.ok) throw new Error('Failed to save'); + + hideVersionForm(); + await loadVersionsForDocument(); + alert('Version gespeichert!'); + } catch(e) { + alert('Fehler beim Speichern: ' + e.message); + } + } + + async function publishVersion(versionId) { + if (!confirm('Version wirklich veröffentlichen?')) return; + + try { + const res = await fetch(`/api/consent/admin/versions/${versionId}/publish`, { method: 'POST' }); + if (!res.ok) throw new Error('Failed to publish'); + + await loadVersionsForDocument(); + alert('Version veröffentlicht!'); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + async function archiveVersion(versionId) { + if (!confirm('Version wirklich archivieren?')) return; + + try { + const res = await fetch(`/api/consent/admin/versions/${versionId}/archive`, { method: 'POST' }); + if (!res.ok) throw new Error('Failed to archive'); + + await loadVersionsForDocument(); + alert('Version archiviert!'); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + async function deleteVersion(versionId) { + if (!confirm('Version wirklich dauerhaft löschen?\\n\\nDie Versionsnummer wird wieder frei und kann erneut verwendet werden.\\n\\nDiese Aktion kann nicht rückgängig gemacht werden!')) return; + + try { + const res = await fetch(`/api/consent/admin/versions/${versionId}`, { method: 'DELETE' }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail?.message || err.error || 'Löschen fehlgeschlagen'); + } + + await loadVersionsForDocument(); + alert('Version wurde dauerhaft gelöscht.'); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + // ========================================== + // DSB APPROVAL WORKFLOW + // ========================================== + + async function submitForReview(versionId) { + if (!confirm('Version zur DSB-Prüfung einreichen?')) return; + + try { + const res = await fetch(`/api/consent/admin/versions/${versionId}/submit-review`, { method: 'POST' }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.detail?.error || 'Einreichung fehlgeschlagen'); + } + + await loadVersionsForDocument(); + alert('Version wurde zur Prüfung eingereicht!'); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + // Dialog für Genehmigung mit Veröffentlichungszeitpunkt + let approvalVersionId = null; + + function showApprovalDialog(versionId) { + approvalVersionId = versionId; + const dialog = document.getElementById('approval-dialog'); + + // Setze Minimum-Datum auf morgen + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + document.getElementById('approval-date').min = tomorrow.toISOString().split('T')[0]; + document.getElementById('approval-date').value = ''; + document.getElementById('approval-time').value = '00:00'; + document.getElementById('approval-comment').value = ''; + + dialog.classList.add('active'); + } + + function hideApprovalDialog() { + document.getElementById('approval-dialog').classList.remove('active'); + approvalVersionId = null; + } + + async function submitApproval() { + if (!approvalVersionId) return; + + const dateInput = document.getElementById('approval-date').value; + const timeInput = document.getElementById('approval-time').value; + const comment = document.getElementById('approval-comment').value; + + let scheduledPublishAt = null; + if (dateInput) { + // Kombiniere Datum und Zeit zu ISO 8601 + const datetime = new Date(dateInput + 'T' + (timeInput || '00:00') + ':00'); + scheduledPublishAt = datetime.toISOString(); + } + + try { + const body = { comment: comment || '' }; + if (scheduledPublishAt) { + body.scheduled_publish_at = scheduledPublishAt; + } + + const res = await fetch(`/api/consent/admin/versions/${approvalVersionId}/approve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.detail?.error || data.detail || 'Genehmigung fehlgeschlagen'); + } + + hideApprovalDialog(); + await loadVersionsForDocument(); + + if (scheduledPublishAt) { + const date = new Date(scheduledPublishAt); + alert('Version genehmigt! Geplante Veröffentlichung: ' + date.toLocaleString('de-DE')); + } else { + alert('Version genehmigt! Sie kann jetzt manuell veröffentlicht werden.'); + } + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + // Alte Funktion für Rückwärtskompatibilität + async function approveVersion(versionId) { + showApprovalDialog(versionId); + } + + async function rejectVersion(versionId) { + const comment = prompt('Begründung für Ablehnung (erforderlich):'); + if (!comment) { + alert('Eine Begründung ist erforderlich.'); + return; + } + + try { + const res = await fetch(`/api/consent/admin/versions/${versionId}/reject`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ comment: comment }) + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.detail?.error || data.detail || 'Ablehnung fehlgeschlagen'); + } + + await loadVersionsForDocument(); + alert('Version abgelehnt und zurück in Entwurf-Status versetzt.'); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + // Store current compare version for actions + let currentCompareVersionId = null; + let currentCompareVersionStatus = null; + let currentCompareDocId = null; + + async function showCompareView(versionId) { + try { + const res = await fetch(`/api/consent/admin/versions/${versionId}/compare`); + if (!res.ok) throw new Error('Vergleich konnte nicht geladen werden'); + const data = await res.json(); + + const currentVersion = data.current_version; + const publishedVersion = data.published_version; + const history = data.approval_history || []; + + // Store version info for actions + currentCompareVersionId = versionId; + currentCompareVersionStatus = currentVersion.status; + currentCompareDocId = currentVersion.document_id; + + // Update header info + document.getElementById('compare-published-info').textContent = + publishedVersion ? `${publishedVersion.title} (v${publishedVersion.version})` : 'Keine Version'; + document.getElementById('compare-draft-info').textContent = + `${currentVersion.title} (v${currentVersion.version})`; + document.getElementById('compare-published-version').textContent = + publishedVersion ? `v${publishedVersion.version}` : ''; + document.getElementById('compare-draft-version').textContent = + `v${currentVersion.version} - ${currentVersion.status}`; + + // Populate content panels + const leftPanel = document.getElementById('compare-content-left'); + const rightPanel = document.getElementById('compare-content-right'); + + leftPanel.innerHTML = publishedVersion + ? publishedVersion.content + : '
    Keine veröffentlichte Version vorhanden
    '; + rightPanel.innerHTML = currentVersion.content || '
    Kein Inhalt
    '; + + // Populate history + const historyContainer = document.getElementById('compare-history-container'); + if (history.length > 0) { + historyContainer.innerHTML = ` +
    Genehmigungsverlauf
    +
    + ${history.map(h => ` + + ${h.action} von ${h.approver || 'System'} + (${new Date(h.created_at).toLocaleString('de-DE')}) + ${h.comment ? ': ' + h.comment : ''} + + `).join(' | ')} +
    + `; + } else { + historyContainer.innerHTML = ''; + } + + // Render action buttons based on status + renderCompareActions(currentVersion.status, versionId); + + // Setup synchronized scrolling + setupSyncScroll(leftPanel, rightPanel); + + // Show the overlay + document.getElementById('version-compare-view').classList.add('active'); + document.body.style.overflow = 'hidden'; + } catch(e) { + alert('Fehler beim Laden des Vergleichs: ' + e.message); + } + } + + function renderCompareActions(status, versionId) { + const actionsContainer = document.getElementById('compare-actions-container'); + + let buttons = ''; + + // Edit button - available for draft, review, and rejected + if (status === 'draft' || status === 'review' || status === 'rejected') { + buttons += ``; + } + + // Status-specific actions + if (status === 'draft') { + buttons += ``; + } + + if (status === 'review') { + buttons += ``; + buttons += ``; + } + + if (status === 'approved') { + buttons += ``; + } + + // Delete button for draft/rejected + if (status === 'draft' || status === 'rejected') { + buttons += ``; + } + + actionsContainer.innerHTML = buttons; + } + + async function editVersionFromCompare(versionId) { + // Store the doc ID before closing compare view + const docId = currentCompareDocId; + + // Close compare view + hideCompareView(); + + // Switch to versions tab + const versionsTab = document.querySelector('.admin-tab[data-tab="versions"]'); + if (versionsTab) { + versionsTab.click(); + } + + // Wait a moment for the tab to become active + await new Promise(resolve => setTimeout(resolve, 150)); + + // Ensure document select is populated + populateDocumentSelect(); + + // Set the document select if we have the doc ID + if (docId) { + const select = document.getElementById('admin-version-doc-select'); + if (select) { + select.value = docId; + // Load versions for this document + await loadVersionsForDocument(); + } + } + + // Now load the version data directly and open the form + try { + const res = await fetch(`/api/consent/admin/documents/${docId}/versions`); + if (!res.ok) throw new Error('Failed to load versions'); + const data = await res.json(); + + const version = (data.versions || []).find(v => v.id === versionId); + if (!version) { + alert('Version nicht gefunden'); + return; + } + + // Open the form and fill with version data + const form = document.getElementById('admin-version-form'); + document.getElementById('admin-version-id').value = version.id; + document.getElementById('admin-version-number').value = version.version; + document.getElementById('admin-version-lang').value = version.language; + document.getElementById('admin-version-title').value = version.title; + document.getElementById('admin-version-summary').value = version.summary || ''; + + // Fill rich text editor with content + const editor = document.getElementById('admin-version-editor'); + if (editor) { + editor.innerHTML = version.content || ''; + const charCount = editor.textContent.length; + document.getElementById('editor-char-count').textContent = charCount + ' Zeichen'; + } + document.getElementById('admin-version-content').value = version.content || ''; + + form.classList.add('active'); + } catch(e) { + alert('Fehler beim Laden der Version: ' + e.message); + } + } + + async function submitForReviewFromCompare(versionId) { + await submitForReview(versionId); + hideCompareView(); + await loadVersionsForDocument(); + } + + async function approveVersionFromCompare(versionId) { + const comment = prompt('Kommentar zur Genehmigung (optional):'); + try { + const res = await fetch(`/api/consent/admin/versions/${versionId}/approve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ comment: comment || '' }) + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail?.error || err.error || 'Genehmigung fehlgeschlagen'); + } + alert('Version genehmigt!'); + hideCompareView(); + await loadVersionsForDocument(); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + async function rejectVersionFromCompare(versionId) { + const comment = prompt('Begründung für die Ablehnung (erforderlich):'); + if (!comment) { + alert('Eine Begründung ist erforderlich.'); + return; + } + try { + const res = await fetch(`/api/consent/admin/versions/${versionId}/reject`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ comment: comment }) + }); + if (!res.ok) throw new Error('Ablehnung fehlgeschlagen'); + alert('Version abgelehnt. Der Autor kann sie überarbeiten.'); + hideCompareView(); + await loadVersionsForDocument(); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + async function publishVersionFromCompare(versionId) { + if (!confirm('Version wirklich veröffentlichen?')) return; + try { + const res = await fetch(`/api/consent/admin/versions/${versionId}/publish`, { method: 'POST' }); + if (!res.ok) throw new Error('Veröffentlichung fehlgeschlagen'); + alert('Version veröffentlicht!'); + hideCompareView(); + await loadVersionsForDocument(); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + async function deleteVersionFromCompare(versionId) { + if (!confirm('Version wirklich dauerhaft löschen?\\n\\nDie Versionsnummer wird wieder frei.')) return; + try { + const res = await fetch(`/api/consent/admin/versions/${versionId}`, { method: 'DELETE' }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail?.message || err.error || 'Löschen fehlgeschlagen'); + } + alert('Version gelöscht!'); + hideCompareView(); + await loadVersionsForDocument(); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + function hideCompareView() { + document.getElementById('version-compare-view').classList.remove('active'); + document.body.style.overflow = ''; + // Remove scroll listeners + const leftPanel = document.getElementById('compare-content-left'); + const rightPanel = document.getElementById('compare-content-right'); + if (leftPanel) leftPanel.onscroll = null; + if (rightPanel) rightPanel.onscroll = null; + } + + // Synchronized scrolling between two panels + let syncScrollActive = false; + function setupSyncScroll(leftPanel, rightPanel) { + // Remove any existing listeners first + leftPanel.onscroll = null; + rightPanel.onscroll = null; + + // Flag to prevent infinite scroll loops + let isScrolling = false; + + rightPanel.onscroll = function() { + if (isScrolling) return; + isScrolling = true; + + // Calculate scroll percentage + const rightScrollPercent = rightPanel.scrollTop / (rightPanel.scrollHeight - rightPanel.clientHeight); + + // Apply same percentage to left panel + const leftMaxScroll = leftPanel.scrollHeight - leftPanel.clientHeight; + leftPanel.scrollTop = rightScrollPercent * leftMaxScroll; + + setTimeout(() => { isScrolling = false; }, 10); + }; + + leftPanel.onscroll = function() { + if (isScrolling) return; + isScrolling = true; + + // Calculate scroll percentage + const leftScrollPercent = leftPanel.scrollTop / (leftPanel.scrollHeight - leftPanel.clientHeight); + + // Apply same percentage to right panel + const rightMaxScroll = rightPanel.scrollHeight - rightPanel.clientHeight; + rightPanel.scrollTop = leftScrollPercent * rightMaxScroll; + + setTimeout(() => { isScrolling = false; }, 10); + }; + } + + async function showApprovalHistory(versionId) { + try { + const res = await fetch(`/api/consent/admin/versions/${versionId}/approval-history`); + if (!res.ok) throw new Error('Historie konnte nicht geladen werden'); + const data = await res.json(); + const history = data.approval_history || []; + + const content = history.length === 0 + ? '

    Keine Genehmigungshistorie vorhanden.

    ' + : ` + + + + + + + + + + + ${history.map(h => ` + + + + + + + `).join('')} + +
    AktionBenutzerKommentarDatum
    ${h.action}${h.approver || h.name || '-'}${h.comment || '-'}${new Date(h.created_at).toLocaleString('de-DE')}
    + `; + + showCustomModal('Genehmigungsverlauf', content, [ + { text: 'Schließen', onClick: () => hideCustomModal() } + ]); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + // Custom Modal Functions + function showCustomModal(title, content, buttons = []) { + let modal = document.getElementById('custom-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'custom-modal'; + modal.className = 'modal-overlay'; + document.body.appendChild(modal); + } + + modal.innerHTML = ` + + `; + modal.classList.add('active'); + } + + function hideCustomModal() { + const modal = document.getElementById('custom-modal'); + if (modal) modal.classList.remove('active'); + } + + // ========================================== + // COOKIE CATEGORIES MANAGEMENT + // ========================================== + async function loadAdminCookieCategories() { + const container = document.getElementById('admin-cookie-table-container'); + container.innerHTML = '
    Lade Cookie-Kategorien...
    '; + + try { + const res = await fetch('/api/consent/admin/cookies/categories'); + if (!res.ok) throw new Error('Failed to load'); + const data = await res.json(); + adminCookieCategories = data.categories || []; + renderCookieCategoriesTable(); + } catch(e) { + container.innerHTML = '
    Fehler beim Laden der Kategorien.
    '; + } + } + + function renderCookieCategoriesTable() { + const container = document.getElementById('admin-cookie-table-container'); + if (adminCookieCategories.length === 0) { + container.innerHTML = '
    Keine Cookie-Kategorien vorhanden.
    '; + return; + } + + const html = ` + + + + + + + + + + + ${adminCookieCategories.map(cat => ` + + + + + + + `).join('')} + +
    NameAnzeigename (DE)TypAktionen
    ${cat.name}${cat.display_name_de} + ${cat.is_mandatory ? 'Notwendig' : 'Optional'} + + + ${!cat.is_mandatory ? `` : ''} +
    + `; + container.innerHTML = html; + } + + function showCookieForm(cat = null) { + const form = document.getElementById('admin-cookie-form'); + + if (cat) { + document.getElementById('admin-cookie-id').value = cat.id; + document.getElementById('admin-cookie-name').value = cat.name; + document.getElementById('admin-cookie-display-de').value = cat.display_name_de; + document.getElementById('admin-cookie-display-en').value = cat.display_name_en || ''; + document.getElementById('admin-cookie-desc-de').value = cat.description_de || ''; + document.getElementById('admin-cookie-mandatory').checked = cat.is_mandatory; + } else { + document.getElementById('admin-cookie-id').value = ''; + document.getElementById('admin-cookie-name').value = ''; + document.getElementById('admin-cookie-display-de').value = ''; + document.getElementById('admin-cookie-display-en').value = ''; + document.getElementById('admin-cookie-desc-de').value = ''; + document.getElementById('admin-cookie-mandatory').checked = false; + } + + form.classList.add('active'); + } + + function hideCookieForm() { + document.getElementById('admin-cookie-form').classList.remove('active'); + } + + function editCookieCategory(catId) { + const cat = adminCookieCategories.find(c => c.id === catId); + if (cat) showCookieForm(cat); + } + + async function saveCookieCategory() { + const catId = document.getElementById('admin-cookie-id').value; + const data = { + name: document.getElementById('admin-cookie-name').value, + display_name_de: document.getElementById('admin-cookie-display-de').value, + display_name_en: document.getElementById('admin-cookie-display-en').value, + description_de: document.getElementById('admin-cookie-desc-de').value, + is_mandatory: document.getElementById('admin-cookie-mandatory').checked + }; + + try { + const url = catId ? `/api/consent/admin/cookies/categories/${catId}` : '/api/consent/admin/cookies/categories'; + const method = catId ? 'PUT' : 'POST'; + + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + if (!res.ok) throw new Error('Failed to save'); + + hideCookieForm(); + await loadAdminCookieCategories(); + alert('Kategorie gespeichert!'); + } catch(e) { + alert('Fehler beim Speichern: ' + e.message); + } + } + + async function deleteCookieCategory(catId) { + if (!confirm('Kategorie wirklich löschen?')) return; + + try { + const res = await fetch(`/api/consent/admin/cookies/categories/${catId}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Failed to delete'); + + await loadAdminCookieCategories(); + alert('Kategorie gelöscht!'); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + // ========================================== + // STATISTICS & GDPR EXPORT + // ========================================== + let dataCategories = []; + + async function loadAdminStats() { + const container = document.getElementById('admin-stats-container'); + container.innerHTML = '
    Lade Statistiken & DSGVO-Informationen...
    '; + + try { + // Lade Datenkategorien + const catRes = await fetch('/api/consent/privacy/data-categories'); + if (catRes.ok) { + const catData = await catRes.json(); + dataCategories = catData.categories || []; + } + + renderStatsPanel(); + } catch(e) { + container.innerHTML = '
    Fehler beim Laden: ' + e.message + '
    '; + } + } + + function renderStatsPanel() { + const container = document.getElementById('admin-stats-container'); + + // Kategorisiere Daten + const essential = dataCategories.filter(c => c.is_essential); + const optional = dataCategories.filter(c => !c.is_essential); + + const html = ` +
    + +
    +

    + 📋 DSGVO-Datenauskunft (Art. 15) +

    +

    + Exportieren Sie alle personenbezogenen Daten eines Nutzers als PDF-Dokument. + Dies erfüllt die Anforderungen der DSGVO Art. 15 (Auskunftsrecht). +

    + +
    + + + +
    + +
    +
    + + +
    +

    + 🗄️ Datenkategorien & Löschfristen +

    + +
    +

    + Essentielle Daten (Pflicht für Betrieb) +

    + + + + + + + + + + + ${essential.map(cat => ` + + + + + + + `).join('')} + +
    KategorieBeschreibungLöschfristRechtsgrundlage
    ${cat.name_de}${cat.description_de}${cat.retention_period}${cat.legal_basis}
    +
    + +
    +

    + Optionale Daten (nur bei Einwilligung) +

    + + + + + + + + + + + ${optional.map(cat => ` + + + + + + + `).join('')} + +
    KategorieBeschreibungCookie-KategorieLöschfrist
    ${cat.name_de}${cat.description_de}${cat.cookie_category || '-'}${cat.retention_period}
    +
    +
    + + +
    +
    +
    ${dataCategories.length}
    +
    Datenkategorien
    +
    +
    +
    ${essential.length}
    +
    Essentiell
    +
    +
    +
    ${optional.length}
    +
    Optional (Opt-in)
    +
    +
    +
    + `; + + container.innerHTML = html; + } + + async function exportUserDataPdf() { + const userIdInput = document.getElementById('gdpr-export-user-id'); + const statusDiv = document.getElementById('gdpr-export-status'); + const userId = userIdInput?.value?.trim(); + + statusDiv.innerHTML = 'Generiere PDF...'; + + try { + let url = '/api/consent/privacy/export-pdf'; + + // Wenn eine User-ID angegeben wurde, verwende den Admin-Endpoint + if (userId) { + url = `/api/consent/admin/privacy/export-pdf/${userId}`; + } + + const res = await fetch(url, { method: 'POST' }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.detail?.message || error.detail || 'Export fehlgeschlagen'); + } + + // PDF herunterladen + const blob = await res.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = downloadUrl; + a.download = userId ? `datenauskunft_${userId.slice(0,8)}.pdf` : 'breakpilot_datenauskunft.pdf'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(downloadUrl); + + statusDiv.innerHTML = '✓ PDF erfolgreich generiert!'; + } catch(e) { + statusDiv.innerHTML = `Fehler: ${e.message}`; + } + } + + async function previewUserDataHtml() { + const statusDiv = document.getElementById('gdpr-export-status'); + statusDiv.innerHTML = 'Lade Vorschau...'; + + try { + const res = await fetch('/api/consent/privacy/export-html'); + + if (!res.ok) { + throw new Error('Vorschau konnte nicht geladen werden'); + } + + const html = await res.text(); + + // In neuem Tab öffnen + const win = window.open('', '_blank'); + win.document.write(html); + win.document.close(); + + statusDiv.innerHTML = '✓ Vorschau in neuem Tab geöffnet'; + } catch(e) { + statusDiv.innerHTML = `Fehler: ${e.message}`; + } + } + + // ========================================== + // DSR (DATA SUBJECT REQUESTS) FUNCTIONS + // ========================================== + let dsrList = []; + let currentDSR = null; + + const DSR_TYPE_LABELS = { + 'access': 'Art. 15 - Auskunft', + 'rectification': 'Art. 16 - Berichtigung', + 'erasure': 'Art. 17 - Löschung', + 'restriction': 'Art. 18 - Einschränkung', + 'portability': 'Art. 20 - Datenübertragbarkeit' + }; + + const DSR_STATUS_LABELS = { + 'intake': 'Eingang', + 'identity_verification': 'Identitätsprüfung', + 'processing': 'In Bearbeitung', + 'completed': 'Abgeschlossen', + 'rejected': 'Abgelehnt', + 'cancelled': 'Storniert' + }; + + const DSR_STATUS_COLORS = { + 'intake': '#6366f1', + 'identity_verification': '#f59e0b', + 'processing': '#3b82f6', + 'completed': '#22c55e', + 'rejected': '#ef4444', + 'cancelled': '#6b7280' + }; + + async function loadDSRStats() { + const container = document.getElementById('dsr-stats-cards'); + try { + const res = await fetch('/api/v1/admin/dsr/stats'); + if (!res.ok) throw new Error('Failed to load stats'); + const stats = await res.json(); + + container.innerHTML = ` +
    +

    Überfällig

    +
    ${stats.overdue_requests || 0}
    +
    +
    +

    In Bearbeitung

    +
    ${stats.pending_requests || 0}
    +
    +
    +

    Diesen Monat abgeschlossen

    +
    ${stats.completed_this_month || 0}
    +
    +
    +

    Gesamt

    +
    ${stats.total_requests || 0}
    +
    +
    +

    Ø Bearbeitungszeit

    +
    ${(stats.average_processing_days || 0).toFixed(1)} Tage
    +
    + `; + } catch(e) { + container.innerHTML = `
    Fehler beim Laden der Statistiken: ${e.message}
    `; + } + } + + async function loadDSRList() { + const container = document.getElementById('dsr-table-container'); + const status = document.getElementById('dsr-filter-status').value; + const requestType = document.getElementById('dsr-filter-type').value; + const overdueOnly = document.getElementById('dsr-filter-overdue').checked; + + container.innerHTML = '
    Lade Betroffenenanfragen...
    '; + + try { + let url = '/api/v1/admin/dsr?limit=50'; + if (status) url += `&status=${status}`; + if (requestType) url += `&request_type=${requestType}`; + if (overdueOnly) url += `&overdue_only=true`; + + const res = await fetch(url); + if (!res.ok) throw new Error('Failed to load DSRs'); + const data = await res.json(); + dsrList = data.requests || []; + + if (dsrList.length === 0) { + container.innerHTML = ` +
    +

    📋

    +

    Keine Betroffenenanfragen gefunden.

    +
    + `; + return; + } + + container.innerHTML = ` + + + + + + + + + + + + + + + ${dsrList.map(dsr => { + const isOverdue = new Date(dsr.deadline_at) < new Date() && !['completed', 'rejected', 'cancelled'].includes(dsr.status); + const deadlineDate = new Date(dsr.deadline_at).toLocaleDateString('de-DE'); + return ` + + + + + + + + + + + `; + }).join('')} + +
    Nr.TypAntragstellerStatusPrioritätFristErstellt
    ${dsr.request_number}${DSR_TYPE_LABELS[dsr.request_type] || dsr.request_type} +
    ${dsr.requester_email}
    + ${dsr.requester_name ? `
    ${dsr.requester_name}
    ` : ''} +
    + + ${DSR_STATUS_LABELS[dsr.status] || dsr.status} + + + + ${dsr.priority === 'expedited' ? '🔴' : dsr.priority === 'high' ? '🟡' : ''} + ${dsr.priority === 'expedited' ? 'Beschleunigt' : dsr.priority === 'high' ? 'Hoch' : 'Normal'} + + ${deadlineDate}${isOverdue ? ' ⚠️' : ''}${new Date(dsr.created_at).toLocaleDateString('de-DE')} + +
    +
    + ${data.total || dsrList.length} Anfragen gefunden +
    + `; + } catch(e) { + container.innerHTML = `
    Fehler: ${e.message}
    `; + } + } + + function showDSRCreateForm() { + document.getElementById('dsr-create-form').style.display = 'block'; + document.getElementById('dsr-create-type').value = ''; + document.getElementById('dsr-create-priority').value = 'normal'; + document.getElementById('dsr-create-email').value = ''; + document.getElementById('dsr-create-name').value = ''; + document.getElementById('dsr-create-phone').value = ''; + } + + function hideDSRCreateForm() { + document.getElementById('dsr-create-form').style.display = 'none'; + } + + async function createDSR() { + const type = document.getElementById('dsr-create-type').value; + const priority = document.getElementById('dsr-create-priority').value; + const email = document.getElementById('dsr-create-email').value; + const name = document.getElementById('dsr-create-name').value; + const phone = document.getElementById('dsr-create-phone').value; + + if (!type || !email) { + alert('Bitte füllen Sie alle Pflichtfelder aus.'); + return; + } + + try { + const res = await fetch('/api/v1/admin/dsr', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + request_type: type, + priority: priority, + requester_email: email, + requester_name: name || undefined, + requester_phone: phone || undefined, + source: 'admin_panel' + }) + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail?.error || err.detail || 'Fehler beim Erstellen'); + } + + const data = await res.json(); + alert(`Anfrage ${data.request_number} wurde erstellt.`); + hideDSRCreateForm(); + loadDSRStats(); + loadDSRList(); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + async function showDSRDetail(dsrId) { + try { + const res = await fetch(`/api/v1/admin/dsr/${dsrId}`); + if (!res.ok) throw new Error('Failed to load DSR'); + currentDSR = await res.json(); + + // Load history + const historyRes = await fetch(`/api/v1/admin/dsr/${dsrId}/history`); + const historyData = historyRes.ok ? await historyRes.json() : { history: [] }; + + document.getElementById('dsr-table-container').style.display = 'none'; + document.getElementById('dsr-create-form').style.display = 'none'; + document.getElementById('dsr-detail-view').style.display = 'block'; + + const isOverdue = new Date(currentDSR.deadline_at) < new Date() && !['completed', 'rejected', 'cancelled'].includes(currentDSR.status); + + document.getElementById('dsr-detail-content').innerHTML = ` +
    +
    +
    +

    + ${currentDSR.request_number} + + ${DSR_STATUS_LABELS[currentDSR.status] || currentDSR.status} + +

    +
    +
    +
    Anfragetyp
    +
    ${DSR_TYPE_LABELS[currentDSR.request_type] || currentDSR.request_type}
    +
    +
    +
    Priorität
    +
    ${currentDSR.priority === 'expedited' ? '🔴 Beschleunigt' : currentDSR.priority === 'high' ? '🟡 Hoch' : 'Normal'}
    +
    +
    +
    Frist
    +
    ${new Date(currentDSR.deadline_at).toLocaleDateString('de-DE')} ${isOverdue ? '⚠️ ÜBERFÄLLIG' : ''}
    +
    +
    +
    Gesetzliche Frist
    +
    ${currentDSR.legal_deadline_days} Tage
    +
    +
    +
    Identität verifiziert
    +
    ${currentDSR.identity_verified ? '✅ Ja' : '❌ Nein'}
    +
    +
    +
    Quelle
    +
    ${currentDSR.source === 'api' ? 'API' : currentDSR.source === 'admin_panel' ? 'Admin Panel' : currentDSR.source}
    +
    +
    +
    + +
    +

    Antragsteller

    +
    +
    +
    E-Mail
    +
    ${currentDSR.requester_email}
    +
    +
    +
    Name
    +
    ${currentDSR.requester_name || '-'}
    +
    +
    +
    Telefon
    +
    ${currentDSR.requester_phone || '-'}
    +
    +
    +
    + + ${currentDSR.processing_notes ? ` +
    +

    Bearbeitungsnotizen

    +
    ${currentDSR.processing_notes}
    +
    + ` : ''} + + ${currentDSR.result_summary ? ` +
    +

    Ergebnis

    +
    ${currentDSR.result_summary}
    +
    + ` : ''} + + ${currentDSR.rejection_reason ? ` +
    +

    Ablehnung

    +
    Rechtsgrundlage: ${currentDSR.rejection_legal_basis}
    +
    ${currentDSR.rejection_reason}
    +
    + ` : ''} +
    + +
    +
    +

    Verlauf

    +
    + ${(historyData.history || []).map(h => ` +
    +
    ${new Date(h.created_at).toLocaleString('de-DE')}
    +
    + ${h.from_status ? `${DSR_STATUS_LABELS[h.from_status] || h.from_status} → ` : ''} + ${DSR_STATUS_LABELS[h.to_status] || h.to_status} +
    + ${h.comment ? `
    ${h.comment}
    ` : ''} +
    + `).join('') || '
    Kein Verlauf vorhanden
    '} +
    +
    +
    +
    + `; + + // Update button visibility based on status + const canVerify = !currentDSR.identity_verified && ['intake', 'identity_verification'].includes(currentDSR.status); + const canComplete = ['processing'].includes(currentDSR.status); + const canReject = ['intake', 'identity_verification', 'processing'].includes(currentDSR.status); + const canExtend = !['completed', 'rejected', 'cancelled'].includes(currentDSR.status); + + document.getElementById('dsr-btn-verify').style.display = canVerify ? 'inline-flex' : 'none'; + document.getElementById('dsr-btn-complete').style.display = canComplete ? 'inline-flex' : 'none'; + document.getElementById('dsr-btn-reject').style.display = canReject ? 'inline-flex' : 'none'; + document.getElementById('dsr-btn-extend').style.display = canExtend ? 'inline-flex' : 'none'; + + } catch(e) { + alert('Fehler beim Laden: ' + e.message); + } + } + + function hideDSRDetail() { + document.getElementById('dsr-detail-view').style.display = 'none'; + document.getElementById('dsr-table-container').style.display = 'block'; + currentDSR = null; + } + + async function verifyDSRIdentity() { + if (!currentDSR) return; + const method = prompt('Verifizierungsmethode (z.B. id_card, passport, video_call, email):', 'email'); + if (!method) return; + + try { + const res = await fetch(`/api/v1/admin/dsr/${currentDSR.id}/verify-identity`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: method }) + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail?.error || 'Fehler'); + } + + alert('Identität wurde verifiziert.'); + showDSRDetail(currentDSR.id); + loadDSRStats(); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + async function showDSRExtendDialog() { + if (!currentDSR) return; + const reason = prompt('Begründung für die Fristverlängerung:'); + if (!reason) return; + + try { + const res = await fetch(`/api/v1/admin/dsr/${currentDSR.id}/extend`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason: reason, days: 60 }) + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail?.error || 'Fehler'); + } + + alert('Frist wurde um 60 Tage verlängert.'); + showDSRDetail(currentDSR.id); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + async function showDSRCompleteDialog() { + if (!currentDSR) return; + const summary = prompt('Zusammenfassung des Ergebnisses:'); + if (!summary) return; + + try { + const res = await fetch(`/api/v1/admin/dsr/${currentDSR.id}/complete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ summary: summary }) + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail?.error || 'Fehler'); + } + + alert('Anfrage wurde abgeschlossen.'); + showDSRDetail(currentDSR.id); + loadDSRStats(); + loadDSRList(); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + async function showDSRRejectDialog() { + if (!currentDSR) return; + const legalBasis = prompt('Rechtsgrundlage für die Ablehnung (z.B. Art. 17(3)a, Art. 12(5)):'); + if (!legalBasis) return; + const reason = prompt('Begründung der Ablehnung:'); + if (!reason) return; + + try { + const res = await fetch(`/api/v1/admin/dsr/${currentDSR.id}/reject`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason: reason, legal_basis: legalBasis }) + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail?.error || 'Fehler'); + } + + alert('Anfrage wurde abgelehnt.'); + showDSRDetail(currentDSR.id); + loadDSRStats(); + loadDSRList(); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + function showDSRAssignDialog() { + // TODO: Implement user selection dialog + alert('Zuweisung noch nicht implementiert. Verwenden Sie die API direkt.'); + } + + function loadDSRData() { + loadDSRStats(); + loadDSRList(); + } + + // Load DSR data when tab is clicked + document.querySelector('.admin-tab[data-tab="dsr"]')?.addEventListener('click', loadDSRData); + + // ========================================== + // DSMS FUNCTIONS + // ========================================== + const DSMS_GATEWAY_URL = 'http://localhost:8082'; + let dsmsArchives = []; + + function switchDsmsTab(tabName) { + document.querySelectorAll('.dsms-subtab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.dsms-content').forEach(c => c.classList.remove('active')); + + document.querySelector(`.dsms-subtab[data-dsms-tab="${tabName}"]`)?.classList.add('active'); + document.getElementById(`dsms-${tabName}`)?.classList.add('active'); + + // Load data for specific tabs + if (tabName === 'settings') { + loadDsmsNodeInfo(); + } + } + + async function loadDsmsData() { + await Promise.all([ + loadDsmsStatus(), + loadDsmsArchives(), + loadDsmsDocumentSelect() + ]); + } + + async function loadDsmsStatus() { + const container = document.getElementById('dsms-status-cards'); + container.innerHTML = '
    Lade DSMS Status...
    '; + + try { + const [healthRes, nodeRes] = await Promise.all([ + fetch(`${DSMS_GATEWAY_URL}/health`).catch(() => null), + fetch(`${DSMS_GATEWAY_URL}/api/v1/node/info`).catch(() => null) + ]); + + const health = healthRes?.ok ? await healthRes.json() : null; + const nodeInfo = nodeRes?.ok ? await nodeRes.json() : null; + + const isOnline = health?.ipfs_connected === true; + const repoSize = nodeInfo?.repo_size ? formatBytes(nodeInfo.repo_size) : '-'; + const storageMax = nodeInfo?.storage_max ? formatBytes(nodeInfo.storage_max) : '-'; + const numObjects = nodeInfo?.num_objects ?? '-'; + + container.innerHTML = ` +
    +

    Status

    +
    ${isOnline ? 'Online' : 'Offline'}
    +
    +
    +

    Speicher verwendet

    +
    ${repoSize}
    +
    +
    +

    Max. Speicher

    +
    ${storageMax}
    +
    +
    +

    Objekte

    +
    ${numObjects}
    +
    + `; + } catch(e) { + container.innerHTML = ` +
    +

    Status

    +
    Nicht erreichbar
    +

    + DSMS Gateway ist nicht verfügbar. Stellen Sie sicher, dass die Container laufen. +

    +
    + `; + } + } + + async function loadDsmsArchives() { + const container = document.getElementById('dsms-archives-table'); + container.innerHTML = '
    Lade archivierte Dokumente...
    '; + + try { + const token = localStorage.getItem('bp_token') || ''; + const res = await fetch(`${DSMS_GATEWAY_URL}/api/v1/documents`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (!res.ok) { + throw new Error('Fehler beim Laden'); + } + + const data = await res.json(); + dsmsArchives = data.documents || []; + + if (dsmsArchives.length === 0) { + container.innerHTML = ` +
    +

    Keine archivierten Dokumente vorhanden.

    +

    + Klicken Sie auf "+ Dokument archivieren" um ein Legal Document im DSMS zu speichern. +

    +
    + `; + return; + } + + container.innerHTML = ` + + + + + + + + + + + + ${dsmsArchives.map(doc => ` + + + + + + + + `).join('')} + +
    CIDDokumentVersionArchiviert amAktionen
    + + ${doc.cid.substring(0, 12)}... + + ${doc.metadata?.document_id || doc.filename || '-'}${doc.metadata?.version || '-'}${doc.metadata?.created_at ? new Date(doc.metadata.created_at).toLocaleString('de-DE') : '-'} + + + + ↗ + +
    + `; + } catch(e) { + container.innerHTML = `
    Fehler: ${e.message}
    `; + } + } + + async function loadDsmsDocumentSelect() { + const select = document.getElementById('dsms-archive-doc-select'); + if (!select) return; + + try { + const res = await fetch('/api/consent/admin/documents'); + if (!res.ok) return; + + const data = await res.json(); + const docs = data.documents || []; + + select.innerHTML = '' + + docs.map(d => ``).join(''); + } catch(e) { + console.error('Error loading documents:', e); + } + } + + async function loadDsmsVersionSelect() { + const docSelect = document.getElementById('dsms-archive-doc-select'); + const versionSelect = document.getElementById('dsms-archive-version-select'); + const docId = docSelect?.value; + + if (!docId) { + versionSelect.innerHTML = ''; + versionSelect.disabled = true; + return; + } + + versionSelect.disabled = false; + versionSelect.innerHTML = ''; + + try { + const res = await fetch(`/api/consent/admin/documents/${docId}/versions`); + if (!res.ok) throw new Error('Fehler'); + + const data = await res.json(); + const versions = data.versions || []; + + if (versions.length === 0) { + versionSelect.innerHTML = ''; + return; + } + + versionSelect.innerHTML = '' + + versions.map(v => ``).join(''); + } catch(e) { + versionSelect.innerHTML = ''; + } + } + + // Attach event listener for doc select change + document.getElementById('dsms-archive-doc-select')?.addEventListener('change', loadDsmsVersionSelect); + + function showArchiveForm() { + document.getElementById('dsms-archive-form').style.display = 'block'; + loadDsmsDocumentSelect(); + } + + function hideArchiveForm() { + document.getElementById('dsms-archive-form').style.display = 'none'; + } + + async function archiveDocumentToDsms() { + const docSelect = document.getElementById('dsms-archive-doc-select'); + const versionSelect = document.getElementById('dsms-archive-version-select'); + const selectedOption = versionSelect.options[versionSelect.selectedIndex]; + + if (!docSelect.value || !versionSelect.value) { + alert('Bitte Dokument und Version auswählen'); + return; + } + + const content = decodeURIComponent(selectedOption.dataset.content || ''); + const version = selectedOption.dataset.version; + const docId = docSelect.value; + + if (!content) { + alert('Die ausgewählte Version hat keinen Inhalt'); + return; + } + + try { + const token = localStorage.getItem('bp_token') || ''; + const params = new URLSearchParams({ + document_id: docId, + version: version, + content: content, + language: 'de' + }); + + const res = await fetch(`${DSMS_GATEWAY_URL}/api/v1/legal-documents/archive?${params}`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail || 'Archivierung fehlgeschlagen'); + } + + const result = await res.json(); + alert(`Dokument erfolgreich archiviert!\\n\\nCID: ${result.cid}\\nChecksum: ${result.checksum}`); + hideArchiveForm(); + loadDsmsArchives(); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + async function verifyDsmsDocument() { + const cidInput = document.getElementById('dsms-verify-cid'); + const resultDiv = document.getElementById('dsms-verify-result'); + const cid = cidInput?.value?.trim(); + + if (!cid) { + alert('Bitte CID eingeben'); + return; + } + + await verifyDsmsDocumentByCid(cid); + } + + async function verifyDsmsDocumentByCid(cid) { + const resultDiv = document.getElementById('dsms-verify-result'); + resultDiv.style.display = 'block'; + resultDiv.innerHTML = '
    Verifiziere...
    '; + + // Switch to verify tab + switchDsmsTab('verify'); + document.getElementById('dsms-verify-cid').value = cid; + + try { + const res = await fetch(`${DSMS_GATEWAY_URL}/api/v1/verify/${cid}`); + const data = await res.json(); + + if (data.exists && data.integrity_valid) { + resultDiv.innerHTML = ` +
    +

    ✓ Dokument verifiziert

    +
    +
    CID: ${cid}
    +
    Integrität: Gültig
    +
    Typ: ${data.metadata?.document_type || '-'}
    +
    Dokument-ID: ${data.metadata?.document_id || '-'}
    +
    Version: ${data.metadata?.version || '-'}
    +
    Erstellt: ${data.metadata?.created_at ? new Date(data.metadata.created_at).toLocaleString('de-DE') : '-'}
    +
    Checksum: ${data.stored_checksum || '-'}
    +
    +
    + `; + } else if (data.exists && !data.integrity_valid) { + resultDiv.innerHTML = ` +
    +

    ⚠ Integritätsfehler

    +

    Das Dokument existiert, aber die Prüfsumme stimmt nicht überein.

    +

    Gespeichert: ${data.stored_checksum}

    +

    Berechnet: ${data.calculated_checksum}

    +
    + `; + } else { + resultDiv.innerHTML = ` +
    +

    ✗ Nicht gefunden

    +

    Kein Dokument mit diesem CID gefunden.

    + ${data.error ? `

    ${data.error}

    ` : ''} +
    + `; + } + } catch(e) { + resultDiv.innerHTML = ` +
    +

    Fehler

    +

    ${e.message}

    +
    + `; + } + } + + async function loadDsmsNodeInfo() { + const container = document.getElementById('dsms-node-info'); + container.innerHTML = '
    Lade Node-Info...
    '; + + try { + const res = await fetch(`${DSMS_GATEWAY_URL}/api/v1/node/info`); + if (!res.ok) throw new Error('Nicht erreichbar'); + + const info = await res.json(); + + container.innerHTML = ` +
    +
    Node ID: ${info.node_id || '-'}
    +
    Agent: ${info.agent_version || '-'}
    +
    Repo-Größe: ${info.repo_size ? formatBytes(info.repo_size) : '-'}
    +
    Max. Speicher: ${info.storage_max ? formatBytes(info.storage_max) : '-'}
    +
    Objekte: ${info.num_objects ?? '-'}
    +
    + Adressen: +
      + ${(info.addresses || []).map(a => `
    • ${a}
    • `).join('')} +
    +
    +
    + `; + } catch(e) { + container.innerHTML = `
    DSMS nicht erreichbar: ${e.message}
    `; + } + } + + function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + function copyToClipboard(text) { + navigator.clipboard.writeText(text).then(() => { + // Optional: Show toast + }).catch(err => { + console.error('Copy failed:', err); + }); + } + + // ========================================== + // DSMS WEBUI FUNCTIONS + // ========================================== + function openDsmsWebUI() { + document.getElementById('dsms-webui-modal').style.display = 'flex'; + loadDsmsWebUIData(); + } + + function closeDsmsWebUI() { + document.getElementById('dsms-webui-modal').style.display = 'none'; + } + + function switchDsmsWebUISection(section) { + // Update nav buttons + document.querySelectorAll('.dsms-webui-nav').forEach(btn => { + btn.classList.toggle('active', btn.dataset.section === section); + }); + // Update sections + document.querySelectorAll('.dsms-webui-section').forEach(sec => { + sec.classList.remove('active'); + sec.style.display = 'none'; + }); + const activeSection = document.getElementById('dsms-webui-' + section); + if (activeSection) { + activeSection.classList.add('active'); + activeSection.style.display = 'block'; + } + // Load section-specific data + if (section === 'peers') loadDsmsPeers(); + } + + async function loadDsmsWebUIData() { + try { + // Load node info + const infoRes = await fetch(DSMS_GATEWAY_URL + '/api/v1/node/info'); + const info = await infoRes.json(); + + document.getElementById('webui-status').innerHTML = 'Online'; + document.getElementById('webui-node-id').textContent = info.node_id || '--'; + document.getElementById('webui-protocol').textContent = info.protocol_version || '--'; + document.getElementById('webui-agent').textContent = info.agent_version || '--'; + document.getElementById('webui-repo-size').textContent = formatBytes(info.repo_size || 0); + document.getElementById('webui-storage-info').textContent = 'Max: ' + formatBytes(info.storage_max || 0); + document.getElementById('webui-num-objects').textContent = (info.num_objects || 0).toLocaleString(); + + // Addresses + const addresses = info.addresses || []; + document.getElementById('webui-addresses').innerHTML = addresses.length > 0 + ? addresses.map(a => '
    ' + a + '
    ').join('') + : 'Keine Adressen verfügbar'; + + // Load pinned count + const token = localStorage.getItem('bp_token') || ''; + const docsRes = await fetch(DSMS_GATEWAY_URL + '/api/v1/documents', { + headers: { 'Authorization': 'Bearer ' + token } + }); + if (docsRes.ok) { + const docs = await docsRes.json(); + document.getElementById('webui-pinned-count').textContent = docs.total || 0; + } + } catch (e) { + console.error('Failed to load WebUI data:', e); + document.getElementById('webui-status').innerHTML = 'Offline'; + } + } + + async function loadDsmsPeers() { + const container = document.getElementById('webui-peers-list'); + try { + // IPFS peers endpoint via proxy would need direct IPFS API access + // For now, show info that private network has no peers + container.innerHTML = ` +
    +
    🔒
    +

    Privates Netzwerk

    +

    + DSMS läuft als isolierter Node. Keine externen Peers verbunden. +

    +
    + `; + } catch (e) { + container.innerHTML = '
    Fehler beim Laden der Peers
    '; + } + } + + // File upload handlers + function handleDsmsDragOver(e) { + e.preventDefault(); + e.stopPropagation(); + document.getElementById('dsms-upload-zone').classList.add('dragover'); + } + + function handleDsmsDragLeave(e) { + e.preventDefault(); + e.stopPropagation(); + document.getElementById('dsms-upload-zone').classList.remove('dragover'); + } + + async function handleDsmsFileDrop(e) { + e.preventDefault(); + e.stopPropagation(); + document.getElementById('dsms-upload-zone').classList.remove('dragover'); + const files = e.dataTransfer.files; + if (files.length > 0) { + await uploadDsmsFiles(files); + } + } + + async function handleDsmsFileSelect(e) { + const files = e.target.files; + if (files.length > 0) { + await uploadDsmsFiles(files); + } + } + + async function uploadDsmsFiles(files) { + const token = localStorage.getItem('bp_token') || ''; + const progressDiv = document.getElementById('dsms-upload-progress'); + const statusDiv = document.getElementById('dsms-upload-status'); + const barDiv = document.getElementById('dsms-upload-bar'); + const resultsDiv = document.getElementById('dsms-upload-results'); + + progressDiv.style.display = 'block'; + resultsDiv.innerHTML = ''; + + const results = []; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + statusDiv.textContent = 'Lade hoch: ' + file.name + ' (' + (i+1) + '/' + files.length + ')'; + barDiv.style.width = ((i / files.length) * 100) + '%'; + + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('document_type', 'legal_document'); + + const res = await fetch(DSMS_GATEWAY_URL + '/api/v1/documents', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + token }, + body: formData + }); + + if (res.ok) { + const data = await res.json(); + results.push({ file: file.name, cid: data.cid, success: true }); + } else { + results.push({ file: file.name, error: 'Upload fehlgeschlagen', success: false }); + } + } catch (e) { + results.push({ file: file.name, error: e.message, success: false }); + } + } + + barDiv.style.width = '100%'; + statusDiv.textContent = 'Upload abgeschlossen!'; + + // Show results + resultsDiv.innerHTML = '

    Ergebnisse

    ' + + results.map(r => ` +
    +
    +
    ${r.file}
    + ${r.success + ? '
    CID: ' + r.cid + '
    ' + : '
    ' + r.error + '
    ' + } +
    + ${r.success ? `` : ''} +
    + `).join(''); + + setTimeout(() => { + progressDiv.style.display = 'none'; + barDiv.style.width = '0%'; + }, 2000); + } + + async function exploreDsmsCid() { + const cid = document.getElementById('webui-explore-cid').value.trim(); + if (!cid) return; + + const resultDiv = document.getElementById('dsms-explore-result'); + const contentDiv = document.getElementById('dsms-explore-content'); + + resultDiv.style.display = 'block'; + contentDiv.innerHTML = '
    Lade...
    '; + + try { + const res = await fetch(DSMS_GATEWAY_URL + '/api/v1/verify/' + cid); + const data = await res.json(); + + if (data.exists) { + contentDiv.innerHTML = ` +
    + + ${data.integrity_valid ? '✓' : '✗'} + + + ${data.integrity_valid ? 'Dokument verifiziert' : 'Integritätsfehler'} + +
    + + + + + + + + + + + + + + + + + +
    CID${cid}
    Typ${data.metadata?.document_type || '--'}
    Erstellt${data.metadata?.created_at ? new Date(data.metadata.created_at).toLocaleString('de-DE') : '--'}
    Checksum${data.stored_checksum || '--'}
    + + `; + } else { + contentDiv.innerHTML = ` +
    + Nicht gefunden
    + CID existiert nicht im DSMS: ${cid} +
    + `; + } + } catch (e) { + contentDiv.innerHTML = ` +
    + Fehler
    + ${e.message} +
    + `; + } + } + + async function runDsmsGarbageCollection() { + if (!confirm('Garbage Collection ausführen? Dies entfernt nicht gepinnte Objekte.')) return; + + try { + // Note: Direct GC requires IPFS API access - show info for now + alert('Garbage Collection wird im Hintergrund ausgeführt. Dies kann einige Minuten dauern.'); + } catch (e) { + alert('Fehler: ' + e.message); + } + } + + // Close modal on escape key + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeDsmsWebUI(); + } + }); + + // Close modal on backdrop click + document.getElementById('dsms-webui-modal')?.addEventListener('click', (e) => { + if (e.target.id === 'dsms-webui-modal') { + closeDsmsWebUI(); + } + }); + + // Load DSMS data when tab is clicked + document.querySelector('.admin-tab[data-tab="dsms"]')?.addEventListener('click', loadDsmsData); + + // ========================================== + // E-MAIL TEMPLATE MANAGEMENT + // ========================================== + + let emailTemplates = []; + let emailTemplateVersions = []; + let currentEmailTemplateId = null; + let currentEmailVersionId = null; + + // E-Mail-Template-Typen mit deutschen Namen + const emailTypeNames = { + 'welcome': 'Willkommens-E-Mail', + 'email_verification': 'E-Mail-Verifizierung', + 'password_reset': 'Passwort zurücksetzen', + 'password_changed': 'Passwort geändert', + '2fa_enabled': '2FA aktiviert', + '2fa_disabled': '2FA deaktiviert', + 'new_device_login': 'Neues Gerät Login', + 'suspicious_activity': 'Verdächtige Aktivität', + 'account_locked': 'Account gesperrt', + 'account_unlocked': 'Account entsperrt', + 'deletion_requested': 'Löschung angefordert', + 'deletion_confirmed': 'Löschung bestätigt', + 'data_export_ready': 'Datenexport bereit', + 'email_changed': 'E-Mail geändert', + 'new_version_published': 'Neue Version veröffentlicht', + 'consent_reminder': 'Consent Erinnerung', + 'consent_deadline_warning': 'Consent Frist Warnung', + 'account_suspended': 'Account suspendiert' + }; + + // Load E-Mail Templates when tab is clicked + document.querySelector('.admin-tab[data-tab="emails"]')?.addEventListener('click', loadEmailTemplates); + + async function loadEmailTemplates() { + try { + const res = await fetch('/api/consent/admin/email-templates'); + if (!res.ok) throw new Error('Fehler beim Laden der Templates'); + const data = await res.json(); + emailTemplates = data.templates || []; + populateEmailTemplateSelect(); + } catch (e) { + console.error('Error loading email templates:', e); + showToast('Fehler beim Laden der E-Mail-Templates', 'error'); + } + } + + function populateEmailTemplateSelect() { + const select = document.getElementById('email-template-select'); + select.innerHTML = ''; + + emailTemplates.forEach(item => { + const template = item.template; // API liefert verschachtelte Struktur + const opt = document.createElement('option'); + opt.value = template.id; + opt.textContent = emailTypeNames[template.type] || template.name; + select.appendChild(opt); + }); + } + + async function loadEmailTemplateVersions() { + const select = document.getElementById('email-template-select'); + const templateId = select.value; + const newVersionBtn = document.getElementById('btn-new-email-version'); + const infoCard = document.getElementById('email-template-info'); + const container = document.getElementById('email-version-table-container'); + + if (!templateId) { + newVersionBtn.disabled = true; + infoCard.style.display = 'none'; + container.innerHTML = '
    Wählen Sie eine E-Mail-Vorlage aus, um deren Versionen anzuzeigen.
    '; + currentEmailTemplateId = null; + return; + } + + currentEmailTemplateId = templateId; + newVersionBtn.disabled = false; + + // Finde das Template (API liefert verschachtelte Struktur) + const templateItem = emailTemplates.find(t => t.template.id === templateId); + const template = templateItem?.template; + if (template) { + infoCard.style.display = 'block'; + document.getElementById('email-template-name').textContent = emailTypeNames[template.type] || template.name; + document.getElementById('email-template-description').textContent = template.description || 'Keine Beschreibung'; + document.getElementById('email-template-type-badge').textContent = template.type; + + // Variablen anzeigen (wird aus dem Default-Inhalt ermittelt) + try { + const defaultRes = await fetch(`/api/consent/admin/email-templates/default/${template.type}`); + if (defaultRes.ok) { + const defaultData = await defaultRes.json(); + const variables = extractVariables(defaultData.body_html || ''); + document.getElementById('email-template-variables').textContent = variables.join(', ') || 'Keine'; + } + } catch (e) { + document.getElementById('email-template-variables').textContent = '-'; + } + } + + // Lade Versionen + container.innerHTML = '
    Lade Versionen...
    '; + try { + const res = await fetch(`/api/consent/admin/email-templates/${templateId}/versions`); + if (!res.ok) throw new Error('Fehler beim Laden'); + const data = await res.json(); + emailTemplateVersions = data.versions || []; + renderEmailVersionsTable(); + } catch (e) { + container.innerHTML = '
    Fehler beim Laden der Versionen.
    '; + } + } + + function extractVariables(content) { + const matches = content.match(/\\{\\{([^}]+)\\}\\}/g) || []; + return [...new Set(matches.map(m => m.replace(/[{}]/g, '')))]; + } + + function renderEmailVersionsTable() { + const container = document.getElementById('email-version-table-container'); + + if (emailTemplateVersions.length === 0) { + container.innerHTML = '
    Keine Versionen vorhanden. Erstellen Sie eine neue Version.
    '; + return; + } + + const statusColors = { + 'draft': 'draft', + 'review': 'review', + 'approved': 'approved', + 'published': 'published', + 'archived': 'archived' + }; + + const statusNames = { + 'draft': 'Entwurf', + 'review': 'In Prüfung', + 'approved': 'Genehmigt', + 'published': 'Veröffentlicht', + 'archived': 'Archiviert' + }; + + container.innerHTML = ` + + + + + + + + + + + + + ${emailTemplateVersions.map(v => ` + + + + + + + + + `).join('')} + +
    VersionSpracheBetreffStatusAktualisiertAktionen
    ${v.version}${v.language === 'de' ? '🇩🇪 DE' : '🇬🇧 EN'}${v.subject}${statusNames[v.status] || v.status}${new Date(v.updated_at).toLocaleDateString('de-DE')} + + ${v.status === 'draft' ? ` + + + + ` : ''} + ${v.status === 'review' ? ` + + + ` : ''} + ${v.status === 'approved' ? ` + + ` : ''} +
    + `; + } + + function showEmailVersionForm() { + document.getElementById('email-version-form').style.display = 'block'; + document.getElementById('email-version-form-title').textContent = 'Neue E-Mail-Version erstellen'; + document.getElementById('email-version-id').value = ''; + document.getElementById('email-version-number').value = ''; + document.getElementById('email-version-subject').value = ''; + document.getElementById('email-version-editor').innerHTML = ''; + document.getElementById('email-version-text').value = ''; + + // Lade Default-Inhalt (API liefert verschachtelte Struktur) + const templateItem = emailTemplates.find(t => t.template.id === currentEmailTemplateId); + if (templateItem?.template) { + loadDefaultEmailContent(templateItem.template.type); + } + } + + async function loadDefaultEmailContent(templateType) { + try { + const res = await fetch(`/api/consent/admin/email-templates/default/${templateType}`); + if (res.ok) { + const data = await res.json(); + document.getElementById('email-version-subject').value = data.subject || ''; + document.getElementById('email-version-editor').innerHTML = data.body_html || ''; + document.getElementById('email-version-text').value = data.body_text || ''; + } + } catch (e) { + console.error('Error loading default content:', e); + } + } + + function hideEmailVersionForm() { + document.getElementById('email-version-form').style.display = 'none'; + } + + async function saveEmailVersion() { + const versionId = document.getElementById('email-version-id').value; + const templateId = currentEmailTemplateId; + const version = document.getElementById('email-version-number').value.trim(); + const language = document.getElementById('email-version-lang').value; + const subject = document.getElementById('email-version-subject').value.trim(); + const bodyHtml = document.getElementById('email-version-editor').innerHTML; + const bodyText = document.getElementById('email-version-text').value.trim(); + + if (!version || !subject || !bodyHtml) { + showToast('Bitte füllen Sie alle Pflichtfelder aus', 'error'); + return; + } + + const data = { + template_id: templateId, + version: version, + language: language, + subject: subject, + body_html: bodyHtml, + body_text: bodyText || stripHtml(bodyHtml) + }; + + try { + let res; + if (versionId) { + // Update existing version + res = await fetch(`/api/consent/admin/email-template-versions/${versionId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + } else { + // Create new version + res = await fetch('/api/consent/admin/email-template-versions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + } + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.detail || 'Fehler beim Speichern'); + } + + showToast('E-Mail-Version gespeichert!', 'success'); + hideEmailVersionForm(); + loadEmailTemplateVersions(); + } catch (e) { + showToast('Fehler: ' + e.message, 'error'); + } + } + + function stripHtml(html) { + const div = document.createElement('div'); + div.innerHTML = html; + return div.textContent || div.innerText || ''; + } + + async function editEmailVersion(versionId) { + try { + const res = await fetch(`/api/consent/admin/email-template-versions/${versionId}`); + if (!res.ok) throw new Error('Version nicht gefunden'); + const version = await res.json(); + + document.getElementById('email-version-form').style.display = 'block'; + document.getElementById('email-version-form-title').textContent = 'E-Mail-Version bearbeiten'; + document.getElementById('email-version-id').value = versionId; + document.getElementById('email-version-number').value = version.version; + document.getElementById('email-version-lang').value = version.language; + document.getElementById('email-version-subject').value = version.subject; + document.getElementById('email-version-editor').innerHTML = version.body_html; + document.getElementById('email-version-text').value = version.body_text || ''; + } catch (e) { + showToast('Fehler beim Laden der Version', 'error'); + } + } + + async function deleteEmailVersion(versionId) { + if (!confirm('Möchten Sie diese Version wirklich löschen?')) return; + + try { + const res = await fetch(`/api/consent/admin/email-template-versions/${versionId}`, { + method: 'DELETE' + }); + if (!res.ok) throw new Error('Fehler beim Löschen'); + showToast('Version gelöscht', 'success'); + loadEmailTemplateVersions(); + } catch (e) { + showToast('Fehler beim Löschen', 'error'); + } + } + + async function submitEmailForReview(versionId) { + try { + const res = await fetch(`/api/consent/admin/email-template-versions/${versionId}/submit`, { + method: 'POST' + }); + if (!res.ok) throw new Error('Fehler'); + showToast('Zur Prüfung eingereicht', 'success'); + loadEmailTemplateVersions(); + } catch (e) { + showToast('Fehler beim Einreichen', 'error'); + } + } + + function showEmailApprovalDialogFor(versionId) { + currentEmailVersionId = versionId; + document.getElementById('email-approval-dialog').style.display = 'flex'; + document.getElementById('email-approval-comment').value = ''; + } + + function hideEmailApprovalDialog() { + document.getElementById('email-approval-dialog').style.display = 'none'; + currentEmailVersionId = null; + } + + async function submitEmailApproval() { + if (!currentEmailVersionId) return; + + const comment = document.getElementById('email-approval-comment').value.trim(); + + try { + const res = await fetch(`/api/consent/admin/email-template-versions/${currentEmailVersionId}/approve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ comment: comment }) + }); + if (!res.ok) throw new Error('Fehler'); + showToast('Version genehmigt', 'success'); + hideEmailApprovalDialog(); + loadEmailTemplateVersions(); + } catch (e) { + showToast('Fehler bei der Genehmigung', 'error'); + } + } + + async function rejectEmailVersion(versionId) { + const reason = prompt('Ablehnungsgrund:'); + if (!reason) return; + + try { + const res = await fetch(`/api/consent/admin/email-template-versions/${versionId}/reject`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason: reason }) + }); + if (!res.ok) throw new Error('Fehler'); + showToast('Version abgelehnt', 'success'); + loadEmailTemplateVersions(); + } catch (e) { + showToast('Fehler bei der Ablehnung', 'error'); + } + } + + async function publishEmailVersion(versionId) { + if (!confirm('Möchten Sie diese Version veröffentlichen? Die vorherige Version wird archiviert.')) return; + + try { + const res = await fetch(`/api/consent/admin/email-template-versions/${versionId}/publish`, { + method: 'POST' + }); + if (!res.ok) throw new Error('Fehler'); + showToast('Version veröffentlicht!', 'success'); + loadEmailTemplateVersions(); + } catch (e) { + showToast('Fehler beim Veröffentlichen', 'error'); + } + } + + async function previewEmailVersionById(versionId) { + try { + const res = await fetch(`/api/consent/admin/email-template-versions/${versionId}/preview`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + if (!res.ok) throw new Error('Fehler'); + const data = await res.json(); + + document.getElementById('email-preview-subject').textContent = data.subject; + document.getElementById('email-preview-content').innerHTML = data.body_html; + document.getElementById('email-preview-dialog').style.display = 'flex'; + currentEmailVersionId = versionId; + } catch (e) { + showToast('Fehler bei der Vorschau', 'error'); + } + } + + function previewEmailVersion() { + const subject = document.getElementById('email-version-subject').value; + const bodyHtml = document.getElementById('email-version-editor').innerHTML; + + document.getElementById('email-preview-subject').textContent = subject; + document.getElementById('email-preview-content').innerHTML = bodyHtml; + document.getElementById('email-preview-dialog').style.display = 'flex'; + } + + function hideEmailPreview() { + document.getElementById('email-preview-dialog').style.display = 'none'; + } + + async function sendTestEmail() { + const email = document.getElementById('email-test-address').value.trim(); + if (!email) { + showToast('Bitte geben Sie eine E-Mail-Adresse ein', 'error'); + return; + } + + if (!currentEmailVersionId) { + showToast('Keine Version ausgewählt', 'error'); + return; + } + + try { + const res = await fetch(`/api/consent/admin/email-template-versions/${currentEmailVersionId}/send-test`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email }) + }); + if (!res.ok) throw new Error('Fehler'); + showToast('Test-E-Mail gesendet!', 'success'); + } catch (e) { + showToast('Fehler beim Senden der Test-E-Mail', 'error'); + } + } + + async function initializeEmailTemplates() { + if (!confirm('Möchten Sie alle Standard-E-Mail-Templates initialisieren?')) return; + + try { + const res = await fetch('/api/consent/admin/email-templates/initialize', { + method: 'POST' + }); + if (!res.ok) throw new Error('Fehler'); + showToast('Templates initialisiert!', 'success'); + loadEmailTemplates(); + } catch (e) { + showToast('Fehler bei der Initialisierung', 'error'); + } + } + + // E-Mail Editor Helpers + function formatEmailDoc(command) { + document.execCommand(command, false, null); + document.getElementById('email-version-editor').focus(); + } + + function formatEmailBlock(tag) { + document.execCommand('formatBlock', false, '<' + tag + '>'); + document.getElementById('email-version-editor').focus(); + } + + function insertEmailVariable() { + const variable = prompt('Variablenname eingeben (z.B. user_name, reset_link):'); + if (variable) { + document.execCommand('insertText', false, '{{' + variable + '}}'); + } + } + + function insertEmailLink() { + const url = prompt('Link-URL:'); + if (url) { + const text = prompt('Link-Text:', url); + document.execCommand('insertHTML', false, `${text}`); + } + } + + function insertEmailButton() { + const url = prompt('Button-Link:'); + if (url) { + const text = prompt('Button-Text:', 'Klicken'); + const buttonHtml = `
    ${text}
    `; + document.execCommand('insertHTML', false, buttonHtml); + } + } + + // ========================================== + // INITIALIZATION - DOMContentLoaded + // ========================================== + document.addEventListener('DOMContentLoaded', function() { + // Theme Toggle + initThemeToggle(); + + // Language initialization + if (typeof initLanguage === 'function') { + initLanguage(); + } + + // Vast Control + if (typeof initVastControl === 'function') { + initVastControl(); + } + + // Legal Modal Close Button + const legalCloseBtn = document.querySelector('.legal-modal-close'); + if (legalCloseBtn) { + legalCloseBtn.addEventListener('click', function() { + document.getElementById('legal-modal').classList.remove('active'); + }); + } + + // Auth Modal Close Button + const authCloseBtn = document.querySelector('.auth-modal-close'); + if (authCloseBtn) { + authCloseBtn.addEventListener('click', function() { + document.getElementById('auth-modal').classList.remove('active'); + }); + } + + // Admin Modal Close Button + const adminCloseBtn = document.querySelector('.admin-modal-close'); + if (adminCloseBtn) { + adminCloseBtn.addEventListener('click', function() { + document.getElementById('admin-modal').classList.remove('active'); + }); + } + + // Legal Button (Footer) + const legalBtn = document.querySelector('[onclick*="showLegalModal"]'); + if (legalBtn) { + legalBtn.removeAttribute('onclick'); + legalBtn.addEventListener('click', function() { + document.getElementById('legal-modal').classList.add('active'); + }); + } + + // Consent Button (Footer) + const consentBtn = document.querySelector('[onclick*="showConsentModal"]'); + if (consentBtn) { + consentBtn.removeAttribute('onclick'); + consentBtn.addEventListener('click', function() { + document.getElementById('legal-modal').classList.add('active'); + // Switch to consent tab + document.querySelectorAll('.legal-tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.legal-content').forEach(c => c.classList.remove('active')); + const consentTab = document.querySelector('.legal-tab[data-tab="consent"]'); + const consentContent = document.getElementById('legal-content-consent'); + if (consentTab) consentTab.classList.add('active'); + if (consentContent) consentContent.classList.add('active'); + }); + } + + // Legal Tabs + document.querySelectorAll('.legal-tab').forEach(tab => { + tab.addEventListener('click', function() { + const tabName = this.dataset.tab; + document.querySelectorAll('.legal-tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.legal-content').forEach(c => c.classList.remove('active')); + this.classList.add('active'); + const content = document.getElementById('legal-content-' + tabName); + if (content) content.classList.add('active'); + }); + }); + + // Auth Tabs + document.querySelectorAll('.auth-tab').forEach(tab => { + tab.addEventListener('click', function() { + const tabName = this.dataset.tab; + document.querySelectorAll('.auth-tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.auth-form').forEach(f => f.classList.remove('active')); + this.classList.add('active'); + const form = document.getElementById('auth-form-' + tabName); + if (form) form.classList.add('active'); + }); + }); + + // Admin Tabs + document.querySelectorAll('.admin-tab').forEach(tab => { + tab.addEventListener('click', function() { + const tabName = this.dataset.tab; + document.querySelectorAll('.admin-tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.admin-content').forEach(c => c.classList.remove('active')); + this.classList.add('active'); + const content = document.getElementById('admin-content-' + tabName); + if (content) content.classList.add('active'); + }); + }); + + // Login Button (TopBar) + const loginBtn = document.getElementById('btn-login'); + if (loginBtn) { + loginBtn.addEventListener('click', function() { + document.getElementById('auth-modal').classList.add('active'); + }); + } + + // Admin Button (TopBar) + const adminBtn = document.getElementById('btn-admin'); + if (adminBtn) { + adminBtn.addEventListener('click', function() { + document.getElementById('admin-modal').classList.add('active'); + }); + } + + // Legal Button (TopBar) + const legalTopBtn = document.getElementById('btn-legal'); + if (legalTopBtn) { + legalTopBtn.addEventListener('click', function() { + document.getElementById('legal-modal').classList.add('active'); + }); + } + + // Modal Close Buttons (by specific IDs) + document.getElementById('legal-modal-close')?.addEventListener('click', function() { + document.getElementById('legal-modal').classList.remove('active'); + }); + document.getElementById('auth-modal-close')?.addEventListener('click', function() { + document.getElementById('auth-modal').classList.remove('active'); + }); + document.getElementById('admin-modal-close')?.addEventListener('click', function() { + document.getElementById('admin-modal').classList.remove('active'); + }); + document.getElementById('imprint-modal-close')?.addEventListener('click', function() { + document.getElementById('imprint-modal').classList.remove('active'); + }); + + // Language selector + const langSelect = document.getElementById('language-select'); + if (langSelect && typeof setLanguage === 'function') { + langSelect.addEventListener('change', function() { + setLanguage(this.value); + }); + } + + console.log('BreakPilot Studio initialized'); + }); \ No newline at end of file diff --git a/backend/frontend/auth.py b/backend/frontend/auth.py new file mode 100644 index 0000000..d5e99e8 --- /dev/null +++ b/backend/frontend/auth.py @@ -0,0 +1,1457 @@ +from fastapi import APIRouter +from fastapi.responses import HTMLResponse + +router = APIRouter() + +# Shared CSS and JS for auth pages +AUTH_STYLES = """ + +""" + + +@router.get("/login", response_class=HTMLResponse) +def login_page(): + return f""" + + + + + Login - BreakPilot + + {AUTH_STYLES} + + +
    +
    +
    + +

    Willkommen zurück

    +

    Melden Sie sich an, um fortzufahren

    +
    + +
    + + +
    +
    + + +
    +
    + + +
    + +
    + + + + + +
    +
    + + + + + """ + + +@router.get("/register", response_class=HTMLResponse) +def register_page(): + return f""" + + + + + Registrieren - BreakPilot + + {AUTH_STYLES} + + +
    +
    + +
    +
    1
    +
    2
    +
    3
    +
    + +
    + + +
    +
    + +

    Konto erstellen

    +

    Geben Sie Ihre Daten ein

    +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    + + +
    + + + + + + +
    +
    + + + + + """ + + +@router.get("/account/security", response_class=HTMLResponse) +def security_settings_page(): + return f""" + + + + + Sicherheitseinstellungen - BreakPilot + + {AUTH_STYLES} + + + +
    + + + Zurück zur App + + +
    +
    +

    Sicherheitseinstellungen

    +

    Verwalten Sie Ihre Zwei-Faktor-Authentifizierung

    +
    + +
    + + +
    +
    + Zwei-Faktor-Authentifizierung + Laden... +
    +

    + Schützen Sie Ihr Konto mit einem zusätzlichen Sicherheitsfaktor. Nutzen Sie Google Authenticator, Microsoft Authenticator oder eine andere TOTP-kompatible App. +

    + + + + +
    + + + + + + + + + + + + +
    +
    + + + + + """ diff --git a/backend/frontend/components/README.md b/backend/frontend/components/README.md new file mode 100644 index 0000000..285063e --- /dev/null +++ b/backend/frontend/components/README.md @@ -0,0 +1,232 @@ +# BreakPilot Studio - Komponenten-Refactoring + +## Überblick + +Die monolithische `studio.py` (11.703 Zeilen) wurde erfolgreich in 7 modulare Komponenten aufgeteilt: + +| Komponente | Zeilen | Beschreibung | +|------------|--------|--------------| +| `base.py` | ~300 | CSS Variables, Base Styles, Theme Toggle | +| `legal_modal.py` | ~1.200 | Legal/Consent Modal (AGB, Datenschutz, Cookies, etc.) | +| `auth_modal.py` | ~1.500 | Auth/Login/Register Modal, 2FA | +| `admin_panel.py` | ~3.000 | Admin Panel (Documents, Versions, Approval) | +| `admin_email.py` | ~1.000 | E-Mail Template Management | +| `admin_dsms.py` | ~1.500 | DSMS/IPFS WebUI, Archive Management | +| `admin_stats.py` | ~700 | Statistics & GDPR Export | + +**Gesamt:** ~9.200 Zeilen in Komponenten (78% der ursprünglichen Datei) + +## Struktur + +``` +backend/frontend/ +├── studio.py (ursprünglich 11.703 Zeilen) +├── studio.py.backup (Backup der Original-Datei) +├── studio_refactored_demo.py (Demo der Integration) +└── components/ + ├── __init__.py + ├── base.py + ├── legal_modal.py + ├── auth_modal.py + ├── admin_panel.py + ├── admin_email.py + ├── admin_dsms.py + └── admin_stats.py +``` + +## Komponenten-API + +Jede Komponente exportiert 3 Funktionen: + +```python +def get_[component]_css() -> str: + """Gibt das CSS für die Komponente zurück""" + +def get_[component]_html() -> str: + """Gibt das HTML für die Komponente zurück""" + +def get_[component]_js() -> str: + """Gibt das JavaScript für die Komponente zurück""" +``` + +## Integration in studio.py + +### 1. Imports hinzufügen + +```python +from fastapi import APIRouter +from fastapi.responses import HTMLResponse + +from .components import ( + base, + legal_modal, + auth_modal, + admin_panel, + admin_email, + admin_dsms, + admin_stats, +) + +router = APIRouter() +``` + +### 2. F-String verwenden + +```python +@router.get("/app", response_class=HTMLResponse) +def app_ui(): + return f""" # F-String statt normaler String + + ... + """ +``` + +### 3. Komponenten-Aufrufe einsetzen + +#### CSS (im ` +``` + +#### HTML (im ``): +```python + + + + {legal_modal.get_legal_modal_html()} + {auth_modal.get_auth_modal_html()} + {admin_panel.get_admin_panel_html()} + {admin_dsms.get_admin_dsms_html()} + +``` + +#### JavaScript (im ` +``` + +## Vorteile des Refactorings + +### Wartbarkeit +- Jede Komponente ist eigenständig und fokussiert +- Änderungen sind isoliert und einfacher zu testen +- Code-Reviews sind übersichtlicher + +### Team-Zusammenarbeit +- Mehrere Entwickler können parallel an verschiedenen Komponenten arbeiten +- Reduzierte Merge-Konflikte +- Klare Zuständigkeiten + +### Performance +- IDE-Performance deutlich verbessert +- Schnelleres Laden in Editoren +- Bessere Syntax-Highlighting + +### Testbarkeit +- Komponenten können einzeln getestet werden +- Mock-Daten für isolierte Tests +- Einfacheres Unit-Testing + +## Nächste Schritte + +### Vollständige Integration (geschätzt 2-3 Stunden) + +1. **Backup sichern** (bereits erledigt ✓) + ```bash + cp studio.py studio.py.backup + ``` + +2. **Neue studio.py erstellen** + - Kopiere Header aus `studio_refactored_demo.py` + - Übernimm Main Content aus `studio.py.backup` + - Ersetze Modal-Bereiche durch Komponenten-Aufrufe + +3. **Testen** + ```bash + cd backend + python -c "from frontend.studio import app_ui; print('OK')" + ``` + +4. **Funktionstest** + - Starte die Anwendung + - Teste alle Modals (Legal, Auth, Admin, DSMS) + - Teste Theme Toggle + - Teste Admin-Funktionen + +5. **Cleanup** (optional) + - Entferne `studio.py.backup` nach erfolgreichen Tests + - Entferne `studio_refactored_demo.py` + - Aktualisiere Dokumentation + +## Fehlerbehandlung + +Falls nach der Integration Probleme auftreten: + +1. **Syntax-Fehler** + - Prüfe F-String-Syntax: `return f"""...` + - Prüfe geschweifte Klammern in CSS/JS (escapen mit `{{` und `}}`) + +2. **Import-Fehler** + - Prüfe `__init__.py` in components/ + - Prüfe relative Imports: `from .components import ...` + +3. **Fehlende Styles/JS** + - Vergleiche mit `studio.py.backup` + - Prüfe, ob gemeinsame Styles übernommen wurden + +4. **Rollback** + ```bash + cp studio.py.backup studio.py + ``` + +## Wartung + +### Neue Komponente hinzufügen + +1. Erstelle `components/new_component.py` +2. Implementiere die 3 Funktionen (css, html, js) +3. Exportiere in `components/__init__.py` +4. Importiere in `studio.py` +5. Rufe in `app_ui()` auf + +### Komponente ändern + +1. Öffne die entsprechende Datei in `components/` +2. Ändere CSS/HTML/JS +3. Speichern - Änderung wird automatisch übernommen + +## Performance-Metriken + +| Metrik | Vorher | Nachher | Verbesserung | +|--------|--------|---------|--------------| +| Dateigröße studio.py | 454 KB | ~50 KB | -89% | +| Zeilen studio.py | 11.703 | ~2.500 | -78% | +| IDE-Ladezeit | ~3s | ~0.5s | -83% | +| Größte Datei | 11.703 Z. | 3.000 Z. | -74% | + +## Support + +Bei Fragen oder Problemen: +- Siehe `docs/architecture/studio-refactoring-proposal.md` +- Backup ist in `studio.py.backup` +- Demo ist in `studio_refactored_demo.py` diff --git a/backend/frontend/components/REFACTORING_STATUS.md b/backend/frontend/components/REFACTORING_STATUS.md new file mode 100644 index 0000000..d31edbb --- /dev/null +++ b/backend/frontend/components/REFACTORING_STATUS.md @@ -0,0 +1,227 @@ +# Studio.py Refactoring - Status Report + +**Datum:** 2025-12-14 +**Status:** Komponenten-Struktur erfolgreich erstellt +**Nächster Schritt:** Manuelle Vervollständigung der HTML-Extraktion + +--- + +## Zusammenfassung + +Das Refactoring der monolithischen `studio.py` (11.703 Zeilen) wurde gemäß dem Plan in `docs/architecture/studio-refactoring-proposal.md` implementiert. Die Komponenten-Struktur (Option A) ist vollständig aufgesetzt. + +## Was wurde erreicht ✓ + +### 1. Verzeichnisstruktur +``` +backend/frontend/ +├── studio.py (Original - unverändert) +├── studio.py.backup (Backup) +├── studio_refactored_demo.py (Demo) +└── components/ + ├── __init__.py + ├── README.md + ├── base.py + ├── legal_modal.py + ├── auth_modal.py + ├── admin_panel.py + ├── admin_email.py + ├── admin_dsms.py + └── admin_stats.py +``` + +### 2. Komponenten erstellt + +| Komponente | Status | CSS | HTML | JS | +|------------|--------|-----|------|-----| +| `base.py` | ✓ Komplett | ✓ | ✓ | ✓ | +| `legal_modal.py` | ⚠ Partial | ✓ | ○ | ✓ | +| `auth_modal.py` | ⚠ Partial | ✓ | ○ | ✓ | +| `admin_panel.py` | ⚠ Partial | ✓ | ○ | ✓ | +| `admin_email.py` | ✓ Komplett | - | - | ✓ | +| `admin_dsms.py` | ⚠ Partial | ✓ | ○ | ✓ | +| `admin_stats.py` | ✓ Komplett | - | - | ✓ | + +**Legende:** +✓ = Vollständig extrahiert +⚠ = CSS und JS extrahiert, HTML teilweise +○ = Nicht/nur teilweise extrahiert +\- = Nicht erforderlich (in anderen Komponenten enthalten) + +### 3. Automatische Extraktion + +Ein automatisches Extraktions-Skript wurde erstellt und ausgeführt: +- **Erfolgreich:** CSS und JavaScript für alle Komponenten +- **Teilweise:** HTML-Extraktion (Regex-Pattern haben nicht alle HTML-Bereiche erfasst) + +### 4. Dokumentation + +- ✓ `components/README.md` - Vollständige Integrations-Anleitung +- ✓ `studio_refactored_demo.py` - Funktionierendes Demo-Beispiel +- ✓ `REFACTORING_STATUS.md` - Dieser Statusbericht + +## Was funktioniert + +### Komponenten-Import +```python +from frontend.components import ( + base, legal_modal, auth_modal, + admin_panel, admin_email, admin_dsms, admin_stats +) +# ✓ Alle Imports funktionieren +``` + +### CSS-Extraktion +```python +base.get_base_css() # ✓ 7.106 Zeichen +legal_modal.get_legal_modal_css() # ✓ Funktioniert +auth_modal.get_auth_modal_css() # ✓ Funktioniert +# ... alle CSS-Funktionen funktionieren +``` + +### JavaScript-Extraktion +```python +base.get_base_js() # ✓ Theme Toggle JS +legal_modal.get_legal_modal_js() # ✓ Legal Modal Functions +# ... alle JS-Funktionen funktionieren +``` + +## Was noch zu tun ist + +### HTML-Extraktion vervollständigen + +Die HTML-Bereiche müssen manuell aus `studio.py.backup` extrahiert werden: + +#### 1. Legal Modal HTML (ca. Zeilen 6800-7000) +```python +# In components/legal_modal.py +def get_legal_modal_html() -> str: + return """ + + """ +``` + +#### 2. Auth Modal HTML (ca. Zeilen 7000-7040) +```python +# In components/auth_modal.py +def get_auth_modal_html() -> str: + return """ +
    + +
    + """ +``` + +#### 3. Admin Panel HTML (ca. Zeilen 7040-7500) +```python +# In components/admin_panel.py +def get_admin_panel_html() -> str: + return """ +
    + +
    + """ +``` + +#### 4. DSMS WebUI HTML (ca. Zeilen 7500-7800) +```python +# In components/admin_dsms.py +def get_admin_dsms_html() -> str: + return """ + +
    + +
    + """ +``` + +### Anleitung zur manuellen Vervollständigung + +```bash +# 1. Öffne studio.py.backup in einem Editor +code /Users/benjaminadmin/Projekte/breakpilot-pwa/backend/frontend/studio.py.backup + +# 2. Suche nach den HTML-Bereichen: +# - Suche: "
  • +
    ${act.icon || '📄'}
    +
    +
    ${act.text}
    +
    ${formatActivityTime(act.time)}
    +
    +
  • + `).join(''); + } catch (e) { + console.error('Error loading activity:', e); + } +} + +function addActivity(icon, text) { + const stored = localStorage.getItem('bp-activity'); + let activities = []; + try { + activities = stored ? JSON.parse(stored) : []; + } catch (e) {} + + activities.unshift({ + icon: icon, + text: text, + time: Date.now() + }); + + // Keep only last 20 + activities = activities.slice(0, 20); + localStorage.setItem('bp-activity', JSON.stringify(activities)); +} + +function formatActivityTime(timestamp) { + const diff = Date.now() - timestamp; + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 1) return 'Gerade eben'; + if (minutes < 60) return `Vor ${minutes} Minute${minutes > 1 ? 'n' : ''}`; + if (hours < 24) return `Vor ${hours} Stunde${hours > 1 ? 'n' : ''}`; + return `Vor ${days} Tag${days > 1 ? 'en' : ''}`; +} + +// Show Dashboard Panel +function showDashboardPanel() { + console.log('showDashboardPanel called'); + hideAllPanels(); + const panel = document.getElementById('panel-dashboard'); + if (panel) { + panel.style.display = 'flex'; + loadDashboardModule(); + console.log('Dashboard panel shown'); + } else { + console.error('panel-dashboard not found'); + } +} + +// Open Klausur-Service (External Microservice) +function openKlausurService() { + // Pass auth token to klausur-service + const token = localStorage.getItem('auth_token'); + const url = window.location.port === '8000' + ? 'http://localhost:8086' + : window.location.origin.replace(':8000', ':8086'); + + // Open in new tab + const klausurWindow = window.open(url, '_blank'); + + // Try to pass token via postMessage after window loads + if (klausurWindow && token) { + setTimeout(() => { + try { + klausurWindow.postMessage({ type: 'AUTH_TOKEN', token: token }, url); + } catch (e) { + console.log('Could not pass token to klausur-service:', e); + } + }, 1000); + } + + addActivity('🎓', 'Klausur-Service geoeffnet'); +} + +// ============================================= +// AI PROMPT FUNCTIONS +// ============================================= + +let aiPromptAbortController = null; + +function handleAiPromptKeydown(event) { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + sendAiPrompt(); + } +} + +function autoResizeTextarea(textarea) { + textarea.style.height = 'auto'; + textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'; +} + +async function sendAiPrompt() { + const input = document.getElementById('ai-prompt-input'); + const sendBtn = document.getElementById('ai-prompt-send'); + const responseDiv = document.getElementById('ai-response'); + const responseText = document.getElementById('ai-response-text'); + const responseModel = document.getElementById('ai-response-model'); + const modelSelect = document.getElementById('ai-model-select'); + + const prompt = input?.value?.trim(); + if (!prompt) return; + + const model = modelSelect?.value || 'llama3.2:latest'; + + // Show loading state + sendBtn.disabled = true; + sendBtn.classList.add('loading'); + sendBtn.textContent = '⏳'; + responseDiv.classList.add('active'); + responseText.textContent = 'Denke nach...'; + responseModel.textContent = model; + + // Cancel previous request if exists + if (aiPromptAbortController) { + aiPromptAbortController.abort(); + } + aiPromptAbortController = new AbortController(); + + try { + // Determine Ollama endpoint based on current host + let ollamaUrl = 'http://localhost:11434/api/generate'; + if (window.location.hostname === 'macmini') { + ollamaUrl = 'http://macmini:11434/api/generate'; + } + + const response = await fetch(ollamaUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: model, + prompt: prompt, + stream: true + }), + signal: aiPromptAbortController.signal + }); + + if (!response.ok) { + throw new Error(`Ollama error: ${response.status}`); + } + + // Stream the response + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let fullResponse = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value); + const lines = chunk.split('\\n').filter(l => l.trim()); + + for (const line of lines) { + try { + const data = JSON.parse(line); + if (data.response) { + fullResponse += data.response; + responseText.textContent = fullResponse; + } + } catch (e) { + // Ignore JSON parse errors for partial chunks + } + } + } + + // Format the response + responseText.innerHTML = formatAiResponse(fullResponse); + + // Log activity + addActivity('🤖', 'KI-Anfrage: ' + prompt.substring(0, 50) + (prompt.length > 50 ? '...' : '')); + + } catch (error) { + if (error.name === 'AbortError') { + responseText.textContent = 'Anfrage abgebrochen.'; + } else { + console.error('AI Prompt error:', error); + responseText.textContent = '❌ Fehler: ' + error.message + '\\n\\nBitte prüfen Sie, ob Ollama läuft (http://localhost:11434)'; + } + } finally { + sendBtn.disabled = false; + sendBtn.classList.remove('loading'); + sendBtn.textContent = '➤'; + aiPromptAbortController = null; + } +} + +function formatAiResponse(text) { + // Basic markdown-like formatting + let formatted = text + // Escape HTML + .replace(/&/g, '&') + .replace(//g, '>') + // Code blocks + .replace(/```(\\w+)?\\n([\\s\\S]*?)```/g, '
    $2
    ') + // Inline code + .replace(/`([^`]+)`/g, '$1') + // Bold + .replace(/\\*\\*([^*]+)\\*\\*/g, '$1') + // Italic + .replace(/\\*([^*]+)\\*/g, '$1') + // Line breaks + .replace(/\\n/g, '
    '); + + return formatted; +} + +// Load available models from Ollama +async function loadOllamaModels() { + const modelSelect = document.getElementById('ai-model-select'); + if (!modelSelect) return; + + try { + let ollamaUrl = 'http://localhost:11434/api/tags'; + if (window.location.hostname === 'macmini') { + ollamaUrl = 'http://macmini:11434/api/tags'; + } + + const response = await fetch(ollamaUrl); + if (!response.ok) return; + + const data = await response.json(); + if (data.models && data.models.length > 0) { + modelSelect.innerHTML = data.models.map(m => + `` + ).join(''); + } + } catch (error) { + console.log('Could not load Ollama models:', error.message); + } +} + +// Initialize AI prompt on dashboard load +const originalLoadDashboardModule = loadDashboardModule; +loadDashboardModule = function() { + originalLoadDashboardModule(); + loadOllamaModels(); +}; +""" diff --git a/backend/frontend/modules/gradebook.py b/backend/frontend/modules/gradebook.py new file mode 100644 index 0000000..4672123 --- /dev/null +++ b/backend/frontend/modules/gradebook.py @@ -0,0 +1,933 @@ +""" +BreakPilot Studio - Notenbuch (Gradebook) Modul + +Funktionen: +- Notenübersicht pro Klasse/Schüler +- Noteneingabe für verschiedene Prüfungsarten +- Durchschnittsberechnung +- Trend-Analyse +- Export nach Excel/PDF +- Integration mit Zeugnissen +""" + + +class GradebookModule: + """Modul fuer digitale Notenverwaltung.""" + + @staticmethod + def get_css() -> str: + """CSS fuer das Gradebook-Modul.""" + return """ +/* ============================================= + GRADEBOOK MODULE - Notenbuch + ============================================= */ + +/* Panel Layout */ +.panel-gradebook { + display: none; + flex-direction: column; + height: 100%; + background: var(--bp-bg); + overflow: hidden; +} + +.panel-gradebook.active { + display: flex; +} + +/* Header */ +.gradebook-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 32px; + border-bottom: 1px solid var(--bp-border); + background: var(--bp-surface); +} + +.gradebook-title-section h1 { + font-size: 24px; + font-weight: 700; + color: var(--bp-text); + margin-bottom: 4px; +} + +.gradebook-subtitle { + font-size: 14px; + color: var(--bp-text-muted); +} + +.gradebook-actions { + display: flex; + gap: 12px; +} + +/* Filter Bar */ +.gradebook-filters { + display: flex; + gap: 16px; + padding: 16px 32px; + background: var(--bp-surface-elevated); + border-bottom: 1px solid var(--bp-border); + flex-wrap: wrap; + align-items: center; +} + +.filter-group { + display: flex; + flex-direction: column; + gap: 4px; +} + +.filter-group label { + font-size: 12px; + color: var(--bp-text-muted); + font-weight: 500; +} + +.filter-group select, +.filter-group input { + padding: 8px 12px; + border: 1px solid var(--bp-border); + border-radius: 8px; + background: var(--bp-surface); + color: var(--bp-text); + font-size: 13px; + min-width: 150px; +} + +.filter-group select:focus, +.filter-group input:focus { + outline: none; + border-color: var(--bp-primary); +} + +/* Content Layout */ +.gradebook-content { + flex: 1; + overflow: auto; + padding: 24px 32px; +} + +/* Grade Table */ +.gradebook-table-container { + background: var(--bp-surface); + border-radius: 12px; + border: 1px solid var(--bp-border); + overflow: hidden; +} + +.gradebook-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.gradebook-table thead { + background: var(--bp-surface-elevated); + position: sticky; + top: 0; + z-index: 10; +} + +.gradebook-table th { + padding: 12px 16px; + text-align: left; + font-weight: 600; + color: var(--bp-text); + border-bottom: 2px solid var(--bp-border); + white-space: nowrap; +} + +.gradebook-table th.grade-column { + text-align: center; + min-width: 80px; +} + +.gradebook-table th.average-column { + text-align: center; + background: var(--bp-primary-soft); +} + +.gradebook-table tbody tr { + border-bottom: 1px solid var(--bp-border); + transition: background 0.2s; +} + +.gradebook-table tbody tr:hover { + background: var(--bp-surface-elevated); +} + +.gradebook-table td { + padding: 12px 16px; + color: var(--bp-text); +} + +.gradebook-table td.grade-cell { + text-align: center; +} + +.gradebook-table td.average-cell { + text-align: center; + font-weight: 600; + background: var(--bp-primary-soft); +} + +/* Student Name Column */ +.student-name { + display: flex; + align-items: center; + gap: 12px; +} + +.student-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--bp-primary); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; +} + +.student-info { + display: flex; + flex-direction: column; +} + +.student-info .name { + font-weight: 500; +} + +.student-info .class { + font-size: 11px; + color: var(--bp-text-muted); +} + +/* Grade Input */ +.grade-input { + width: 50px; + padding: 6px 8px; + border: 1px solid transparent; + border-radius: 6px; + background: var(--bp-surface); + color: var(--bp-text); + text-align: center; + font-size: 14px; + font-weight: 500; + transition: all 0.2s; +} + +.grade-input:hover { + border-color: var(--bp-border); +} + +.grade-input:focus { + outline: none; + border-color: var(--bp-primary); + background: var(--bp-surface-elevated); +} + +/* Grade Colors */ +.grade-1 { color: #22c55e; } +.grade-2 { color: #84cc16; } +.grade-3 { color: #eab308; } +.grade-4 { color: #f97316; } +.grade-5 { color: #ef4444; } +.grade-6 { color: #dc2626; } + +.grade-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 8px; + font-weight: 600; + font-size: 14px; +} + +.grade-badge.grade-1 { background: rgba(34, 197, 94, 0.15); } +.grade-badge.grade-2 { background: rgba(132, 204, 22, 0.15); } +.grade-badge.grade-3 { background: rgba(234, 179, 8, 0.15); } +.grade-badge.grade-4 { background: rgba(249, 115, 22, 0.15); } +.grade-badge.grade-5 { background: rgba(239, 68, 68, 0.15); } +.grade-badge.grade-6 { background: rgba(220, 38, 38, 0.15); } + +/* Trend Indicator */ +.trend-indicator { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + margin-left: 8px; +} + +.trend-up { color: var(--bp-success); } +.trend-down { color: var(--bp-danger); } +.trend-stable { color: var(--bp-text-muted); } + +/* Statistics Panel */ +.gradebook-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.stat-card { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + padding: 20px; +} + +.stat-card .stat-icon { + width: 40px; + height: 40px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + margin-bottom: 12px; +} + +.stat-card .stat-icon.blue { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; +} + +.stat-card .stat-icon.green { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; +} + +.stat-card .stat-icon.yellow { + background: rgba(234, 179, 8, 0.15); + color: #eab308; +} + +.stat-card .stat-icon.red { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; +} + +.stat-card .stat-value { + font-size: 28px; + font-weight: 700; + color: var(--bp-text); + margin-bottom: 4px; +} + +.stat-card .stat-label { + font-size: 13px; + color: var(--bp-text-muted); +} + +/* Add Grade Modal */ +.add-grade-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: none; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.add-grade-modal.active { + display: flex; +} + +.add-grade-content { + background: var(--bp-surface); + border-radius: 16px; + padding: 24px; + width: 100%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; +} + +.add-grade-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.add-grade-header h2 { + font-size: 18px; + font-weight: 600; +} + +.add-grade-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.form-group label { + font-size: 13px; + font-weight: 500; + color: var(--bp-text-muted); +} + +.form-group input, +.form-group select, +.form-group textarea { + padding: 10px 12px; + border: 1px solid var(--bp-border); + border-radius: 8px; + background: var(--bp-surface-elevated); + color: var(--bp-text); + font-size: 14px; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--bp-primary); +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 8px; +} + +/* Exam Types */ +.exam-type-selector { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.exam-type-btn { + padding: 8px 16px; + border: 1px solid var(--bp-border); + border-radius: 20px; + background: var(--bp-surface); + color: var(--bp-text-muted); + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} + +.exam-type-btn:hover { + border-color: var(--bp-primary); + color: var(--bp-text); +} + +.exam-type-btn.active { + background: var(--bp-primary); + border-color: var(--bp-primary); + color: white; +} + +/* Chart Container */ +.gradebook-chart { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + padding: 20px; + margin-bottom: 24px; +} + +.gradebook-chart h3 { + font-size: 16px; + font-weight: 600; + margin-bottom: 16px; +} + +.chart-placeholder { + height: 200px; + background: var(--bp-surface-elevated); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: var(--bp-text-muted); +} + +/* Responsive */ +@media (max-width: 768px) { + .gradebook-filters { + flex-direction: column; + align-items: stretch; + } + + .filter-group { + width: 100%; + } + + .filter-group select { + width: 100%; + } + + .form-row { + grid-template-columns: 1fr; + } + + .gradebook-stats { + grid-template-columns: 1fr; + } +} + +/* Print Styles */ +@media print { + .gradebook-header, + .gradebook-filters, + .gradebook-actions, + .add-grade-modal { + display: none !important; + } + + .gradebook-content { + padding: 0; + } + + .gradebook-table { + font-size: 11px; + } +} +""" + + @staticmethod + def get_html() -> str: + """HTML fuer das Gradebook-Panel.""" + return """ + +
    + +
    +
    +

    Notenbuch

    +
    Notenübersicht und -verwaltung
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    +
    📊
    +
    2.4
    +
    Klassendurchschnitt
    +
    +
    +
    +
    24
    +
    Schüler bestanden
    +
    +
    +
    📝
    +
    8
    +
    Leistungsnachweise
    +
    +
    +
    ⚠️
    +
    3
    +
    Gefährdete Schüler
    +
    +
    + + +
    +

    Notenverteilung

    +
    + Hier wird das Notenverteilungsdiagramm angezeigt +
    +
    + + +
    + + + + + + + + + + + + + + + + +
    Schüler/inKlausur 1Test 1Klausur 2MündlichTest 2SchnittTrend
    +
    +
    +
    + + +
    +
    +
    +

    Note eintragen

    + +
    +
    +
    +
    + + +
    +
    + + +
    +
    + +
    + +
    + + + + + +
    +
    + +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    +
    +
    +
    +""" + + @staticmethod + def get_js() -> str: + """JavaScript fuer das Gradebook-Modul.""" + return """ +// ============================================= +// GRADEBOOK MODULE - JavaScript +// ============================================= + +// Sample data (in production: from API) +const gradebookData = { + students: [ + { id: 1, name: 'Anna Beispiel', class: '5a', grades: { k1: 2, t1: 1, k2: 2, m: 2, t2: 2 }, avg: 1.8 }, + { id: 2, name: 'Ben Schmidt', class: '5a', grades: { k1: 3, t1: 3, k2: 3, m: 2, t2: 3 }, avg: 2.8 }, + { id: 3, name: 'Clara Weber', class: '5a', grades: { k1: 1, t1: 2, k2: 1, m: 1, t2: 1 }, avg: 1.2 }, + { id: 4, name: 'David Müller', class: '5a', grades: { k1: 4, t1: 4, k2: 5, m: 3, t2: 4 }, avg: 4.0 }, + { id: 5, name: 'Emma Fischer', class: '5a', grades: { k1: 2, t1: 2, k2: 2, m: 2, t2: 2 }, avg: 2.0 }, + { id: 6, name: 'Felix Wagner', class: '5a', grades: { k1: 3, t1: 2, k2: 3, m: 3, t2: 2 }, avg: 2.6 }, + { id: 7, name: 'Greta Hoffmann', class: '5a', grades: { k1: 1, t1: 1, k2: 1, m: 2, t2: 1 }, avg: 1.2 }, + { id: 8, name: 'Hans Bauer', class: '5a', grades: { k1: 5, t1: 4, k2: 5, m: 4, t2: 5 }, avg: 4.6 }, + ] +}; + +// Initialize Gradebook +function initGradebook() { + loadGradebook(); + initExamTypeSelector(); + setTodayDate(); + populateStudentSelect(); +} + +// Load Gradebook Data +function loadGradebook() { + const tbody = document.getElementById('gradebook-tbody'); + if (!tbody) return; + + tbody.innerHTML = ''; + + gradebookData.students.forEach(student => { + const row = createStudentRow(student); + tbody.appendChild(row); + }); + + updateStatistics(); +} + +// Create Student Row +function createStudentRow(student) { + const row = document.createElement('tr'); + + // Get initials + const initials = student.name.split(' ').map(n => n[0]).join(''); + + // Calculate trend + const grades = Object.values(student.grades); + const recent = grades.slice(-2); + const trend = recent.length >= 2 + ? (recent[1] < recent[0] ? 'up' : recent[1] > recent[0] ? 'down' : 'stable') + : 'stable'; + + const trendIcon = trend === 'up' ? '↑' : trend === 'down' ? '↓' : '→'; + const trendClass = trend === 'up' ? 'trend-up' : trend === 'down' ? 'trend-down' : 'trend-stable'; + + row.innerHTML = ` + +
    +
    ${initials}
    +
    + ${student.name} + ${student.class} +
    +
    + + + ${student.grades.k1} + + + ${student.grades.t1} + + + ${student.grades.k2} + + + ${student.grades.m} + + + ${student.grades.t2} + + + ${student.avg.toFixed(1)} + + + ${trendIcon} + + `; + + return row; +} + +// Update Statistics +function updateStatistics() { + const students = gradebookData.students; + const averages = students.map(s => s.avg); + const classAvg = (averages.reduce((a, b) => a + b, 0) / averages.length).toFixed(1); + const passed = students.filter(s => s.avg <= 4.0).length; + const warning = students.filter(s => s.avg > 4.0).length; + + document.getElementById('stat-average').textContent = classAvg; + document.getElementById('stat-passed').textContent = passed; + document.getElementById('stat-warning').textContent = warning; +} + +// Exam Type Selector +function initExamTypeSelector() { + const buttons = document.querySelectorAll('.exam-type-btn'); + buttons.forEach(btn => { + btn.addEventListener('click', () => { + buttons.forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + }); + }); +} + +// Set Today's Date +function setTodayDate() { + const dateInput = document.getElementById('grade-date'); + if (dateInput) { + dateInput.value = new Date().toISOString().split('T')[0]; + } +} + +// Populate Student Select +function populateStudentSelect() { + const select = document.getElementById('grade-student'); + if (!select) return; + + select.innerHTML = ''; + gradebookData.students.forEach(student => { + const option = document.createElement('option'); + option.value = student.id; + option.textContent = `${student.name} (${student.class})`; + select.appendChild(option); + }); +} + +// Open Add Grade Modal +function openAddGradeModal() { + const modal = document.getElementById('add-grade-modal'); + if (modal) { + modal.classList.add('active'); + setTodayDate(); + } +} + +// Close Add Grade Modal +function closeAddGradeModal() { + const modal = document.getElementById('add-grade-modal'); + if (modal) { + modal.classList.remove('active'); + } +} + +// Save Grade +function saveGrade(event) { + event.preventDefault(); + + const studentId = document.getElementById('grade-student').value; + const subject = document.getElementById('grade-subject').value; + const grade = document.getElementById('grade-value').value; + const date = document.getElementById('grade-date').value; + const weight = document.getElementById('grade-weight').value; + const comment = document.getElementById('grade-comment').value; + + const examType = document.querySelector('.exam-type-btn.active')?.dataset.type || 'klausur'; + + // In production: API call + console.log('Saving grade:', { + studentId, subject, grade, date, weight, comment, examType + }); + + // Show success message + alert('Note wurde gespeichert!'); + closeAddGradeModal(); + + // Reload table + loadGradebook(); +} + +// Export Gradebook +function exportGradebook() { + const format = prompt('Export-Format wählen (excel/pdf/csv):', 'excel'); + + if (format) { + // In production: API call to generate export + console.log('Exporting gradebook as:', format); + alert(`Export als ${format.toUpperCase()} wird erstellt...`); + } +} + +// Initialize on panel activation +document.addEventListener('DOMContentLoaded', () => { + // Check if gradebook panel is active + const panel = document.getElementById('panel-gradebook'); + if (panel && panel.classList.contains('active')) { + initGradebook(); + } +}); + +// Also initialize when panel becomes active +const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.type === 'attributes' && mutation.attributeName === 'class') { + const panel = document.getElementById('panel-gradebook'); + if (panel && panel.classList.contains('active')) { + initGradebook(); + } + } + }); +}); + +const gradebookPanel = document.getElementById('panel-gradebook'); +if (gradebookPanel) { + observer.observe(gradebookPanel, { attributes: true }); +} +""" diff --git a/backend/frontend/modules/hilfe.py b/backend/frontend/modules/hilfe.py new file mode 100644 index 0000000..6050cf8 --- /dev/null +++ b/backend/frontend/modules/hilfe.py @@ -0,0 +1,740 @@ +""" +BreakPilot Studio - Hilfe & Dokumentation Modul + +Benutzerfreundliche Anleitung fuer Lehrer mit Schritt-fuer-Schritt Erklaerungen. +""" + + +class HilfeModule: + """Hilfe und Dokumentation fuer Lehrer.""" + + @staticmethod + def get_css() -> str: + """CSS fuer das Hilfe-Modul.""" + return """ +/* ============================================= + HILFE & DOKUMENTATION MODULE + ============================================= */ + +/* Container */ +.hilfe-container { + max-width: 100%; + min-height: 100%; + background: var(--bp-bg, #0f172a); + color: var(--bp-text, #e2e8f0); +} + +/* Header */ +.hilfe-header { + background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); + padding: 40px; + text-align: center; + position: relative; +} + +.hilfe-header h1 { + color: white; + font-size: 28px; + font-weight: 700; + margin: 0 0 8px 0; +} + +.hilfe-header p { + color: rgba(255, 255, 255, 0.85); + font-size: 16px; + margin: 0; +} + +/* Navigation Tabs */ +.hilfe-nav { + display: flex; + gap: 4px; + padding: 16px 24px; + background: var(--bp-surface, #1e293b); + border-bottom: 1px solid var(--bp-border, #334155); + flex-wrap: wrap; +} + +.hilfe-nav-tab { + padding: 10px 20px; + background: transparent; + border: 1px solid var(--bp-border, #334155); + border-radius: 8px; + color: var(--bp-text-muted, #94a3b8); + cursor: pointer; + transition: all 0.2s; + font-size: 14px; +} + +.hilfe-nav-tab:hover { + background: var(--bp-surface-elevated, #334155); + color: var(--bp-text, #e2e8f0); +} + +.hilfe-nav-tab.active { + background: #3b82f6; + border-color: #3b82f6; + color: white; +} + +/* Content */ +.hilfe-content { + padding: 32px; + max-width: 900px; + margin: 0 auto; + width: 100%; +} + +.hilfe-section { + display: none; +} + +.hilfe-section.active { + display: block; +} + +/* Cards */ +.hilfe-card { + background: var(--bp-surface, #1e293b); + border: 1px solid var(--bp-border, #334155); + border-radius: 16px; + padding: 24px; + margin-bottom: 24px; +} + +.hilfe-card h2 { + color: var(--bp-text, #e2e8f0); + font-size: 20px; + margin: 0 0 16px 0; + display: flex; + align-items: center; + gap: 12px; +} + +.hilfe-card h3 { + color: var(--bp-text, #e2e8f0); + font-size: 16px; + margin: 24px 0 12px 0; +} + +.hilfe-card p { + color: var(--bp-text-muted, #94a3b8); + font-size: 14px; + line-height: 1.7; + margin: 0 0 16px 0; +} + +/* Step List */ +.hilfe-steps { + list-style: none; + padding: 0; + margin: 0; + counter-reset: step; +} + +.hilfe-step { + position: relative; + padding: 20px 20px 20px 70px; + background: var(--bp-surface-elevated); + border-radius: 12px; + margin-bottom: 12px; + counter-increment: step; +} + +.hilfe-step::before { + content: counter(step); + position: absolute; + left: 20px; + top: 50%; + transform: translateY(-50%); + width: 36px; + height: 36px; + background: linear-gradient(135deg, #3b82f6, #1d4ed8); + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 14px; +} + +.hilfe-step h4 { + color: var(--bp-text); + font-size: 15px; + margin: 0 0 6px 0; +} + +.hilfe-step p { + color: var(--bp-text-muted); + font-size: 13px; + margin: 0; + line-height: 1.5; +} + +/* Info Box */ +.hilfe-info { + background: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: 12px; + padding: 16px 20px; + margin: 16px 0; + display: flex; + gap: 12px; + align-items: flex-start; +} + +.hilfe-info-icon { + font-size: 20px; + flex-shrink: 0; +} + +.hilfe-info-text { + color: var(--bp-text); + font-size: 14px; + line-height: 1.6; +} + +.hilfe-info-text strong { + color: #3b82f6; +} + +/* Warning Box */ +.hilfe-warning { + background: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.3); + border-radius: 12px; + padding: 16px 20px; + margin: 16px 0; + display: flex; + gap: 12px; + align-items: flex-start; +} + +.hilfe-warning-icon { + font-size: 20px; + flex-shrink: 0; +} + +.hilfe-warning-text { + color: var(--bp-text); + font-size: 14px; + line-height: 1.6; +} + +/* Success Box */ +.hilfe-success { + background: rgba(34, 197, 94, 0.1); + border: 1px solid rgba(34, 197, 94, 0.3); + border-radius: 12px; + padding: 16px 20px; + margin: 16px 0; + display: flex; + gap: 12px; + align-items: flex-start; +} + +.hilfe-success-icon { + font-size: 20px; + flex-shrink: 0; +} + +.hilfe-success-text { + color: var(--bp-text); + font-size: 14px; + line-height: 1.6; +} + +/* FAQ */ +.hilfe-faq { + border: 1px solid var(--bp-border); + border-radius: 12px; + overflow: hidden; + margin-bottom: 12px; +} + +.hilfe-faq-question { + padding: 16px 20px; + background: var(--bp-surface-elevated); + color: var(--bp-text); + font-size: 14px; + font-weight: 600; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + transition: background 0.2s; +} + +.hilfe-faq-question:hover { + background: var(--bp-border); +} + +.hilfe-faq-arrow { + transition: transform 0.3s; +} + +.hilfe-faq.open .hilfe-faq-arrow { + transform: rotate(180deg); +} + +.hilfe-faq-answer { + padding: 0 20px; + max-height: 0; + overflow: hidden; + transition: all 0.3s; + background: var(--bp-surface); +} + +.hilfe-faq.open .hilfe-faq-answer { + padding: 16px 20px; + max-height: 500px; +} + +.hilfe-faq-answer p { + margin: 0; +} + +/* Keyboard Shortcuts */ +.hilfe-shortcut { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 0; + border-bottom: 1px solid var(--bp-border); +} + +.hilfe-shortcut:last-child { + border-bottom: none; +} + +.hilfe-shortcut-keys { + display: flex; + gap: 4px; +} + +.hilfe-shortcut-keys kbd { + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border); + border-radius: 6px; + padding: 4px 10px; + font-family: monospace; + font-size: 13px; + color: var(--bp-text); +} + +.hilfe-shortcut-desc { + color: var(--bp-text-muted); + font-size: 14px; +} + +/* Contact Card */ +.hilfe-contact { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + margin-top: 24px; +} + +@media (max-width: 600px) { + .hilfe-contact { + grid-template-columns: 1fr; + } +} + +.hilfe-contact-card { + background: var(--bp-surface-elevated); + border-radius: 12px; + padding: 20px; + text-align: center; +} + +.hilfe-contact-icon { + font-size: 32px; + margin-bottom: 12px; +} + +.hilfe-contact-title { + color: var(--bp-text); + font-size: 16px; + font-weight: 600; + margin-bottom: 4px; +} + +.hilfe-contact-info { + color: var(--bp-text-muted); + font-size: 14px; +} +""" + + @staticmethod + def get_html() -> str: + """HTML fuer das Hilfe-Modul.""" + return """ + + +""" + + @staticmethod + def get_js() -> str: + """JavaScript fuer das Hilfe-Modul.""" + return """ +// ============================================= +// HILFE & DOKUMENTATION MODULE +// ============================================= + +let hilfeInitialized = false; + +function loadHilfeModule() { + if (hilfeInitialized) { + console.log('Hilfe module already initialized'); + return; + } + + console.log('Initializing Hilfe Module...'); + hilfeInitialized = true; + console.log('Hilfe Module initialized'); +} + +function showHilfeTab(tabName) { + // Update tabs + document.querySelectorAll('.hilfe-nav-tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.tab === tabName); + }); + + // Update sections + document.querySelectorAll('.hilfe-section').forEach(section => { + section.classList.remove('active'); + }); + + const targetSection = document.getElementById('hilfe-' + tabName); + if (targetSection) { + targetSection.classList.add('active'); + } +} + +function toggleFaq(element) { + element.classList.toggle('open'); +} + +// Show panel function +function showHilfePanel() { + hideAllPanels(); + const panel = document.getElementById('panel-hilfe'); + if (panel) { + panel.classList.add('active'); + loadHilfeModule(); + } +} +""" diff --git a/backend/frontend/modules/jitsi.py b/backend/frontend/modules/jitsi.py new file mode 100644 index 0000000..2485c2e --- /dev/null +++ b/backend/frontend/modules/jitsi.py @@ -0,0 +1,687 @@ +""" +BreakPilot Studio - Jitsi Videokonferenz Modul + +Funktionen: +- Elterngespraeche (mit Lobby und Passwort) +- Klassenkonferenzen +- Schulungen (mit Aufzeichnung) +- Schnelle Meetings + +Nutzt die Jitsi-API unter /api/meetings/* +""" + + +class JitsiModule: + """Jitsi Videokonferenz Modul fuer BreakPilot Studio.""" + + @staticmethod + def get_css() -> str: + """CSS fuer das Jitsi-Modul.""" + return """ +/* ========================================== + JITSI MODULE STYLES + ========================================== */ + +/* Panel - hidden by default */ +.panel-jitsi { + display: none; + flex-direction: column; + height: 100%; + background: var(--bp-bg); + overflow: hidden; +} + +.panel-jitsi.active { + display: flex; +} + +.jitsi-container { + padding: 24px; + flex: 1; + overflow-y: auto; +} + +.jitsi-header { + margin-bottom: 24px; +} + +.jitsi-title { + font-size: 24px; + font-weight: 700; + margin-bottom: 8px; +} + +.jitsi-subtitle { + color: var(--bp-text-muted); + font-size: 14px; +} + +/* Meeting Type Cards */ +.meeting-types { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; + margin-bottom: 32px; +} + +.meeting-type-card { + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border); + border-radius: 12px; + padding: 24px; + cursor: pointer; + transition: all 0.2s; +} + +.meeting-type-card:hover { + border-color: var(--bp-primary); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.meeting-type-icon { + font-size: 32px; + margin-bottom: 12px; +} + +.meeting-type-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 8px; +} + +.meeting-type-desc { + font-size: 13px; + color: var(--bp-text-muted); + margin-bottom: 16px; +} + +.meeting-type-features { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.meeting-feature { + font-size: 11px; + padding: 4px 8px; + border-radius: 4px; + background: var(--bp-bg); + color: var(--bp-text-muted); +} + +.meeting-feature.highlight { + background: var(--bp-accent-soft); + color: var(--bp-accent); +} + +/* Active Meetings */ +.active-meetings { + margin-bottom: 32px; +} + +.section-title { + font-size: 18px; + font-weight: 600; + margin-bottom: 16px; + display: flex; + align-items: center; + gap: 8px; +} + +.section-badge { + font-size: 12px; + padding: 2px 8px; + border-radius: 999px; + background: var(--bp-accent); + color: white; +} + +.meetings-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.meeting-item { + display: flex; + align-items: center; + justify-content: space-between; + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border); + border-radius: 8px; + padding: 16px; +} + +.meeting-info { + display: flex; + align-items: center; + gap: 16px; +} + +.meeting-status { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--bp-success); + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.meeting-details h4 { + font-size: 14px; + font-weight: 600; + margin-bottom: 4px; +} + +.meeting-meta { + font-size: 12px; + color: var(--bp-text-muted); +} + +.meeting-actions { + display: flex; + gap: 8px; +} + +/* Create Meeting Form */ +.create-meeting-form { + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border); + border-radius: 12px; + padding: 24px; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 16px; +} + +@media (max-width: 600px) { + .form-row { + grid-template-columns: 1fr; + } +} + +/* Jitsi Embed */ +.jitsi-embed-container { + display: none; + position: fixed; + top: 56px; + left: var(--sidebar-width); + right: 0; + bottom: 0; + background: #000; + z-index: 90; +} + +.jitsi-embed-container.active { + display: block; +} + +.jitsi-embed-header { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 50px; + background: var(--bp-surface); + border-bottom: 1px solid var(--bp-border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + z-index: 10; +} + +.jitsi-embed-title { + font-weight: 600; +} + +.jitsi-iframe { + width: 100%; + height: calc(100% - 50px); + margin-top: 50px; + border: none; +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 48px; + color: var(--bp-text-muted); +} + +.empty-state-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.5; +} + +.empty-state-text { + font-size: 14px; +} +""" + + @staticmethod + def get_html() -> str: + """HTML fuer das Jitsi-Modul.""" + return """ + + +""" + + @staticmethod + def get_js() -> str: + """JavaScript fuer das Jitsi-Modul.""" + return """ +// ========================================== +// JITSI MODULE +// ========================================== + +console.log('Jitsi Module loaded'); + +const JITSI_BASE_URL = 'https://meet.jit.si'; // oder eigener Server + +// ========================================== +// MODULE LOADER +// ========================================== + +function loadJitsiModule() { + console.log('Initializing Jitsi Module'); + loadActiveMeetings(); + + // Set default date to today + const dateInput = document.getElementById('pm-date'); + if (dateInput) { + dateInput.valueAsDate = new Date(); + } +} + +// ========================================== +// MEETING FORMS +// ========================================== + +function showParentMeetingForm() { + document.getElementById('parent-meeting-form').classList.remove('hidden'); + document.getElementById('pm-student-name').focus(); +} + +function hideParentMeetingForm() { + document.getElementById('parent-meeting-form').classList.add('hidden'); +} + +function showClassMeetingForm() { + const className = prompt('Klassenname (z.B. 7a):'); + if (!className) return; + + const roomName = 'klasse-' + className.toLowerCase().replace(/[^a-z0-9]/g, '') + '-' + Date.now(); + createAndOpenMeeting(roomName, 'Klassenkonferenz ' + className, { + startWithAudioMuted: true, + startWithVideoMuted: false + }); +} + +function showTrainingForm() { + const topic = prompt('Thema der Schulung:'); + if (!topic) return; + + const roomName = 'schulung-' + topic.toLowerCase().replace(/[^a-z0-9]/g, '-').substring(0, 30) + '-' + Date.now(); + createAndOpenMeeting(roomName, 'Schulung: ' + topic, { + startWithAudioMuted: false, + startWithVideoMuted: false, + enableRecording: true + }); +} + +function startQuickMeeting() { + const roomName = 'bp-quick-' + Date.now(); + createAndOpenMeeting(roomName, 'Schnelles Meeting', { + startWithAudioMuted: false, + startWithVideoMuted: false + }); +} + +// ========================================== +// CREATE MEETINGS +// ========================================== + +function createParentMeeting(event) { + event.preventDefault(); + + const studentName = document.getElementById('pm-student-name').value; + const parentName = document.getElementById('pm-parent-name').value; + const date = document.getElementById('pm-date').value; + const time = document.getElementById('pm-time').value; + const topic = document.getElementById('pm-topic').value; + + // Generate room name + const sanitizedName = studentName.toLowerCase() + .replace(/ae/g, 'ae').replace(/oe/g, 'oe').replace(/ue/g, 'ue') + .replace(/[^a-z0-9]/g, '-'); + const roomName = 'elterngespraech-' + sanitizedName + '-' + date.replace(/-/g, ''); + + // Generate password + const password = Math.random().toString(36).substring(2, 10); + + console.log('Creating parent meeting:', { roomName, studentName, parentName, date, time, topic }); + + // Call API to create meeting + fetch('/api/meetings/parent-teacher', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + (localStorage.getItem('bp-token') || '') + }, + body: JSON.stringify({ + student_name: studentName, + parent_name: parentName, + scheduled_date: date, + scheduled_time: time, + topic: topic, + password: password + }) + }) + .then(response => response.json()) + .then(data => { + if (data.meeting_url || data.room_name) { + hideParentMeetingForm(); + showMeetingCreatedDialog({ + roomName: data.room_name || roomName, + meetingUrl: data.meeting_url || JITSI_BASE_URL + '/' + roomName, + password: data.password || password, + studentName: studentName, + date: date, + time: time + }); + loadActiveMeetings(); + } else { + alert('Fehler beim Erstellen: ' + (data.error || 'Unbekannter Fehler')); + } + }) + .catch(err => { + console.error('Error creating meeting:', err); + // Fallback: Open directly + createAndOpenMeeting(roomName, 'Elterngespraech: ' + studentName, { + startWithAudioMuted: false, + startWithVideoMuted: false, + password: password, + lobbyEnabled: true + }); + }); +} + +function createAndOpenMeeting(roomName, title, options = {}) { + console.log('Creating meeting:', roomName, title, options); + + const meetingUrl = JITSI_BASE_URL + '/' + roomName; + + // Build config params + let configParams = []; + if (options.startWithAudioMuted) configParams.push('config.startWithAudioMuted=true'); + if (options.startWithVideoMuted) configParams.push('config.startWithVideoMuted=true'); + if (options.password) configParams.push('config.prejoinPageEnabled=true'); + + const fullUrl = meetingUrl + (configParams.length ? '#' + configParams.join('&') : ''); + + // Open in embed + openJitsiEmbed(fullUrl, title); +} + +// ========================================== +// JITSI EMBED +// ========================================== + +function openJitsiEmbed(url, title) { + const container = document.getElementById('jitsi-embed'); + const iframe = document.getElementById('jitsi-iframe'); + const titleEl = document.getElementById('jitsi-embed-title'); + + titleEl.textContent = title || 'Meeting'; + iframe.src = url; + container.classList.add('active'); + + console.log('Opened Jitsi embed:', url); +} + +function closeJitsiEmbed() { + const container = document.getElementById('jitsi-embed'); + const iframe = document.getElementById('jitsi-iframe'); + + iframe.src = ''; + container.classList.remove('active'); +} + +// ========================================== +// ACTIVE MEETINGS +// ========================================== + +function loadActiveMeetings() { + fetch('/api/meetings/active', { + headers: { + 'Authorization': 'Bearer ' + (localStorage.getItem('bp-token') || '') + } + }) + .then(response => response.json()) + .then(data => { + const list = document.getElementById('meetings-list'); + const countBadge = document.getElementById('active-meetings-count'); + + if (data.meetings && data.meetings.length > 0) { + countBadge.textContent = data.meetings.length; + list.innerHTML = data.meetings.map(meeting => ` +
    +
    +
    +
    +

    ${escapeHtml(meeting.title || meeting.room_name)}

    +
    ${meeting.participants || 0} Teilnehmer | Gestartet ${formatTime(meeting.started_at)}
    +
    +
    +
    + + +
    +
    + `).join(''); + } else { + countBadge.textContent = '0'; + list.innerHTML = ` +
    +
    📷
    +

    Keine aktiven Meetings. Starten Sie ein neues Meeting oben.

    +
    + `; + } + }) + .catch(err => { + console.log('Could not load active meetings:', err); + }); +} + +function joinMeeting(roomName, title) { + const url = JITSI_BASE_URL + '/' + roomName; + openJitsiEmbed(url, title || roomName); +} + +function copyMeetingLink(roomName) { + const url = JITSI_BASE_URL + '/' + roomName; + navigator.clipboard.writeText(url).then(() => { + alert('Link kopiert: ' + url); + }); +} + +// ========================================== +// DIALOGS +// ========================================== + +function showMeetingCreatedDialog(info) { + const message = ` +Elterngespraech erstellt! + +Schueler: ${info.studentName} +Datum: ${info.date} um ${info.time} +Passwort: ${info.password} + +Link fuer Eltern: +${info.meetingUrl} + +Der Link wurde in die Zwischenablage kopiert. + `; + + navigator.clipboard.writeText(info.meetingUrl + '\\nPasswort: ' + info.password); + alert(message); + + // Ask to open now + if (confirm('Moechten Sie das Meeting jetzt oeffnen?')) { + openJitsiEmbed(info.meetingUrl, 'Elterngespraech: ' + info.studentName); + } +} + +// ========================================== +// HELPERS +// ========================================== + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function formatTime(isoString) { + if (!isoString) return ''; + const date = new Date(isoString); + return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); +} + +// Auto-refresh active meetings every 30 seconds +setInterval(loadActiveMeetings, 30000); +""" + + +def get_jitsi_module() -> dict: + """Gibt das komplette Jitsi-Modul als Dictionary zurueck.""" + module = JitsiModule() + return { + 'css': module.get_css(), + 'html': module.get_html(), + 'js': module.get_js(), + 'init_function': 'loadJitsiModule' + } diff --git a/backend/frontend/modules/klausur_korrektur.py b/backend/frontend/modules/klausur_korrektur.py new file mode 100644 index 0000000..a9f677a --- /dev/null +++ b/backend/frontend/modules/klausur_korrektur.py @@ -0,0 +1,113 @@ +""" +BreakPilot Studio - Klausur-Korrektur Stub + +Das vollstaendige Klausur-Korrektur Modul wurde in einen eigenstaendigen +Microservice (klausur-service) ausgelagert. + +Dieser Stub existiert nur fuer Abwaertskompatibilitaet. +Die Klausur-Korrektur wird ueber das Dashboard (openKlausurService) geoeffnet. +""" + + +class KlausurKorrekturModule: + """Stub - Klausur-Korrektur ist jetzt ein eigenstaendiger Service.""" + + @staticmethod + def get_css() -> str: + """Minimales CSS fuer Redirect-Hinweis.""" + return """ +/* Klausur-Korrektur wurde in eigenstaendigen Service ausgelagert */ +.panel-klausur-korrektur { + display: none; + position: fixed; + top: 56px; + left: 0; + right: 0; + bottom: 0; + background: var(--bp-surface, #1e293b); + z-index: 60; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 24px; + padding: 48px; + text-align: center; +} + +.panel-klausur-korrektur.active { + display: flex; +} + +.panel-klausur-korrektur h2 { + color: var(--bp-text, #e5e7eb); + font-size: 24px; + margin: 0; +} + +.panel-klausur-korrektur p { + color: var(--bp-text-muted, #9ca3af); + font-size: 16px; + max-width: 500px; + line-height: 1.6; +} + +.panel-klausur-korrektur .redirect-btn { + background: var(--bp-primary, #6C1B1B); + color: white; + border: none; + padding: 16px 32px; + border-radius: 8px; + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + gap: 12px; + transition: background 0.2s; +} + +.panel-klausur-korrektur .redirect-btn:hover { + background: var(--bp-primary-hover, #8B2323); +} +""" + + @staticmethod + def get_html() -> str: + """HTML mit Redirect-Hinweis.""" + return """ + +
    +

    Klausur-Korrektur wurde optimiert

    +

    + Das Klausur-Korrektur Modul ist jetzt ein eigenstaendiger Service + fuer bessere Performance und Stabilitaet. +

    + +
    +""" + + @staticmethod + def get_js() -> str: + """Minimales JavaScript - openKlausurService ist im Dashboard definiert.""" + return """ +// Klausur-Korrektur Stub - Service wurde ausgelagert +// Die Funktion openKlausurService() ist in dashboard.py definiert + +function showKlausurKorrekturPanel() { + // Falls jemand direkt zu diesem Panel navigiert, zeige Redirect-Hinweis + hideAllPanels(); + const panel = document.getElementById('panel-klausur-korrektur'); + if (panel) { + panel.style.display = 'flex'; + } + console.log('Klausur-Korrektur ist jetzt ein eigenstaendiger Service auf Port 8086'); +} + +// Legacy-Funktion fuer Abwaertskompatibilitaet +function loadKlausurKorrekturModule() { + console.log('loadKlausurKorrekturModule() - Service ausgelagert, oeffne externen Service'); + openKlausurService(); +} +""" diff --git a/backend/frontend/modules/lehrer_dashboard.py b/backend/frontend/modules/lehrer_dashboard.py new file mode 100644 index 0000000..f0914eb --- /dev/null +++ b/backend/frontend/modules/lehrer_dashboard.py @@ -0,0 +1,889 @@ +""" +Lehrer-Dashboard Modul fuer das BreakPilot Studio. + +Ein frei konfigurierbares Dashboard mit Drag & Drop Widget-System. +Lehrer koennen ihre persoenliche Startseite aus verschiedenen Widgets zusammenstellen. +""" + +from .widgets import ( + TodosWidget, + SchnellzugriffWidget, + NotizenWidget, + StundenplanWidget, + KlassenWidget, + FehlzeitenWidget, + ArbeitenWidget, + NachrichtenWidget, + MatrixWidget, + AlertsWidget, + StatistikWidget, + KalenderWidget, +) + + +class LehrerDashboardModule: + """ + Haupt-Modul fuer das konfigurierbare Lehrer-Dashboard. + """ + + @staticmethod + def get_css() -> str: + # Sammle CSS von allen Widgets + widget_css = "\n".join([ + TodosWidget.get_css(), + SchnellzugriffWidget.get_css(), + NotizenWidget.get_css(), + StundenplanWidget.get_css(), + KlassenWidget.get_css(), + FehlzeitenWidget.get_css(), + ArbeitenWidget.get_css(), + NachrichtenWidget.get_css(), + MatrixWidget.get_css(), + AlertsWidget.get_css(), + StatistikWidget.get_css(), + KalenderWidget.get_css(), + ]) + + return f""" +/* ===== Lehrer-Dashboard Styles ===== */ +.lehrer-dashboard-container {{ + padding: 24px; + max-width: 1400px; + margin: 0 auto; +}} + +/* Dashboard Header */ +.dashboard-header {{ + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; +}} + +.dashboard-greeting {{ + font-size: 24px; + font-weight: 700; + color: var(--bp-text, #e5e7eb); + margin-bottom: 4px; +}} + +.dashboard-date {{ + font-size: 14px; + color: var(--bp-text-muted, #9ca3af); +}} + +.dashboard-actions {{ + display: flex; + gap: 8px; +}} + +.dashboard-edit-btn {{ + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background: var(--bp-surface, #1e293b); + border: 1px solid var(--bp-border, #475569); + border-radius: 8px; + color: var(--bp-text, #e5e7eb); + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +}} + +.dashboard-edit-btn:hover {{ + background: var(--bp-surface-elevated, #334155); + border-color: var(--bp-primary, #6C1B1B); +}} + +.dashboard-edit-btn.active {{ + background: var(--bp-primary, #6C1B1B); + border-color: var(--bp-primary, #6C1B1B); + color: white; +}} + +/* Widget Grid */ +.dashboard-grid {{ + display: flex; + flex-direction: column; + gap: 16px; +}} + +.dashboard-row {{ + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +}} + +.dashboard-row.single {{ + grid-template-columns: 1fr; +}} + +/* Widget Container */ +.dashboard-widget {{ + position: relative; + min-height: 200px; +}} + +.dashboard-widget.full {{ + grid-column: 1 / -1; +}} + +/* Widget Settings Button */ +.widget-settings-btn {{ + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: var(--bp-text-muted, #9ca3af); + cursor: pointer; + border-radius: 6px; + transition: all 0.2s; + font-size: 14px; +}} + +.widget-settings-btn:hover {{ + background: var(--bp-surface-elevated, #334155); + color: var(--bp-text, #e5e7eb); +}} + +/* ===== Edit Mode Styles ===== */ +.dashboard-edit-mode .widget-catalog {{ + display: block !important; +}} + +.dashboard-edit-mode .dashboard-widget {{ + position: relative; +}} + +.dashboard-edit-mode .dashboard-widget::after {{ + content: ''; + position: absolute; + inset: 0; + border: 2px dashed var(--bp-border, #475569); + border-radius: 12px; + pointer-events: none; + opacity: 0; + transition: opacity 0.2s; +}} + +.dashboard-edit-mode .dashboard-widget:hover::after {{ + opacity: 1; +}} + +.dashboard-edit-mode .widget-remove-btn {{ + display: flex !important; +}} + +/* Widget Remove Button */ +.widget-remove-btn {{ + display: none; + position: absolute; + top: 8px; + right: 8px; + width: 24px; + height: 24px; + align-items: center; + justify-content: center; + background: rgba(239, 68, 68, 0.9); + color: white; + border: none; + border-radius: 50%; + cursor: pointer; + font-size: 14px; + z-index: 10; + transition: all 0.2s; +}} + +.widget-remove-btn:hover {{ + background: #ef4444; + transform: scale(1.1); +}} + +/* Widget Catalog */ +.widget-catalog {{ + display: none; + background: var(--bp-surface, #1e293b); + border: 1px solid var(--bp-border, #475569); + border-radius: 12px; + padding: 16px; + margin-bottom: 24px; +}} + +.widget-catalog-title {{ + font-size: 14px; + font-weight: 600; + color: var(--bp-text, #e5e7eb); + margin-bottom: 12px; +}} + +.widget-catalog-grid {{ + display: flex; + flex-wrap: wrap; + gap: 8px; +}} + +.widget-catalog-item {{ + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + background: var(--bp-bg, #0f172a); + border: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.1)); + border-radius: 8px; + cursor: grab; + transition: all 0.2s; + user-select: none; +}} + +.widget-catalog-item:hover {{ + border-color: var(--bp-primary, #6C1B1B); + transform: translateY(-2px); +}} + +.widget-catalog-item:active {{ + cursor: grabbing; +}} + +.widget-catalog-item.dragging {{ + opacity: 0.5; +}} + +.widget-catalog-item.disabled {{ + opacity: 0.4; + cursor: not-allowed; +}} + +.widget-catalog-item-icon {{ + font-size: 16px; +}} + +.widget-catalog-item-name {{ + font-size: 12px; + font-weight: 500; + color: var(--bp-text, #e5e7eb); +}} + +/* Drop Zone */ +.drop-zone {{ + display: none; + min-height: 100px; + border: 2px dashed var(--bp-border, #475569); + border-radius: 12px; + background: var(--bp-bg, #0f172a); + transition: all 0.2s; +}} + +.dashboard-edit-mode .drop-zone {{ + display: flex; + align-items: center; + justify-content: center; + color: var(--bp-text-muted, #9ca3af); + font-size: 13px; +}} + +.drop-zone.drag-over {{ + border-color: var(--bp-accent, #5ABF60); + background: rgba(90, 191, 96, 0.1); + color: var(--bp-accent, #5ABF60); +}} + +/* Add Row Button */ +.add-row-btn {{ + display: none; + width: 100%; + padding: 16px; + background: transparent; + border: 2px dashed var(--bp-border, #475569); + border-radius: 12px; + color: var(--bp-text-muted, #9ca3af); + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +}} + +.dashboard-edit-mode .add-row-btn {{ + display: block; +}} + +.add-row-btn:hover {{ + border-color: var(--bp-accent, #5ABF60); + color: var(--bp-accent, #5ABF60); +}} + +/* Widget Settings Modal */ +.widget-settings-modal {{ + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + z-index: 1000; + align-items: center; + justify-content: center; +}} + +.widget-settings-modal.active {{ + display: flex; +}} + +.widget-settings-content {{ + background: var(--bp-surface, #1e293b); + border: 1px solid var(--bp-border, #475569); + border-radius: 16px; + padding: 24px; + max-width: 400px; + width: 90%; +}} + +.widget-settings-title {{ + font-size: 18px; + font-weight: 600; + color: var(--bp-text, #e5e7eb); + margin-bottom: 16px; +}} + +.widget-settings-close {{ + position: absolute; + top: 16px; + right: 16px; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + color: var(--bp-text-muted, #9ca3af); + cursor: pointer; + border-radius: 8px; + font-size: 18px; +}} + +.widget-settings-close:hover {{ + background: var(--bp-surface-elevated, #334155); +}} + +/* Responsive */ +@media (max-width: 768px) {{ + .lehrer-dashboard-container {{ + padding: 16px; + }} + + .dashboard-header {{ + flex-direction: column; + gap: 16px; + }} + + .dashboard-row {{ + grid-template-columns: 1fr; + }} + + .dashboard-greeting {{ + font-size: 20px; + }} + + .widget-catalog-grid {{ + flex-direction: column; + }} + + .widget-catalog-item {{ + width: 100%; + }} +}} + +/* Widget CSS */ +{widget_css} +""" + + @staticmethod + def get_html() -> str: + return """ + +""" + + @staticmethod + def get_js() -> str: + # Sammle JS von allen Widgets + widget_js = "\n".join([ + TodosWidget.get_js(), + SchnellzugriffWidget.get_js(), + NotizenWidget.get_js(), + StundenplanWidget.get_js(), + KlassenWidget.get_js(), + FehlzeitenWidget.get_js(), + ArbeitenWidget.get_js(), + NachrichtenWidget.get_js(), + MatrixWidget.get_js(), + AlertsWidget.get_js(), + StatistikWidget.get_js(), + KalenderWidget.get_js(), + ]) + + return f""" +// ===== Lehrer-Dashboard JavaScript ===== +const DASHBOARD_LAYOUT_KEY = 'bp-dashboard-layout'; +const LEHRER_PROFIL_KEY = 'bp-lehrer-profil'; +let dashboardEditMode = false; +let lehrerDashboardInitialized = false; + +// Widget Registry +const WidgetRegistry = {{ + stundenplan: {{ + id: 'stundenplan', + name: 'Stundenplan', + icon: '📅', + color: '#3b82f6', + defaultWidth: 'half', + init: initStundenplanWidget, + getHtml: () => `{StundenplanWidget.get_html().replace('`', '\\`').replace('${', '\\${')}` + }}, + klassen: {{ + id: 'klassen', + name: 'Meine Klassen', + icon: '📊', + color: '#8b5cf6', + defaultWidth: 'half', + init: initKlassenWidget, + getHtml: () => `{KlassenWidget.get_html().replace('`', '\\`').replace('${', '\\${')}` + }}, + fehlzeiten: {{ + id: 'fehlzeiten', + name: 'Fehlzeiten', + icon: '⚠', + color: '#ef4444', + defaultWidth: 'half', + init: initFehlzeitenWidget, + getHtml: () => `{FehlzeitenWidget.get_html().replace('`', '\\`').replace('${', '\\${')}` + }}, + arbeiten: {{ + id: 'arbeiten', + name: 'Arbeiten', + icon: '📝', + color: '#f59e0b', + defaultWidth: 'half', + init: initArbeitenWidget, + getHtml: () => `{ArbeitenWidget.get_html().replace('`', '\\`').replace('${', '\\${')}` + }}, + todos: {{ + id: 'todos', + name: 'To-Dos', + icon: '✓', + color: '#10b981', + defaultWidth: 'half', + init: initTodosWidget, + getHtml: () => `{TodosWidget.get_html().replace('`', '\\`').replace('${', '\\${')}` + }}, + nachrichten: {{ + id: 'nachrichten', + name: 'E-Mails', + icon: '📧', + color: '#06b6d4', + defaultWidth: 'half', + init: initNachrichtenWidget, + getHtml: () => `{NachrichtenWidget.get_html().replace('`', '\\`').replace('${', '\\${')}` + }}, + matrix: {{ + id: 'matrix', + name: 'Matrix-Chat', + icon: '💬', + color: '#8b5cf6', + defaultWidth: 'half', + init: initMatrixWidget, + getHtml: () => `{MatrixWidget.get_html().replace('`', '\\`').replace('${', '\\${')}` + }}, + alerts: {{ + id: 'alerts', + name: 'Google Alerts', + icon: '🔔', + color: '#f59e0b', + defaultWidth: 'half', + init: initAlertsWidget, + getHtml: () => `{AlertsWidget.get_html().replace('`', '\\`').replace('${', '\\${')}` + }}, + statistik: {{ + id: 'statistik', + name: 'Statistik', + icon: '📈', + color: '#3b82f6', + defaultWidth: 'full', + init: initStatistikWidget, + getHtml: () => `{StatistikWidget.get_html().replace('`', '\\`').replace('${', '\\${')}` + }}, + schnellzugriff: {{ + id: 'schnellzugriff', + name: 'Schnellzugriff', + icon: '⚡', + color: '#6b7280', + defaultWidth: 'full', + init: initSchnellzugriffWidget, + getHtml: () => `{SchnellzugriffWidget.get_html().replace('`', '\\`').replace('${', '\\${')}` + }}, + notizen: {{ + id: 'notizen', + name: 'Notizen', + icon: '📋', + color: '#fbbf24', + defaultWidth: 'half', + init: initNotizenWidget, + getHtml: () => `{NotizenWidget.get_html().replace('`', '\\`').replace('${', '\\${')}` + }}, + kalender: {{ + id: 'kalender', + name: 'Termine', + icon: '📆', + color: '#ec4899', + defaultWidth: 'half', + init: initKalenderWidget, + getHtml: () => `{KalenderWidget.get_html().replace('`', '\\`').replace('${', '\\${')}` + }} +}}; + +// Default Layout +function getDefaultLayout() {{ + return {{ + version: 1, + rows: [ + {{ + id: 'row-1', + widgets: [ + {{ widgetId: 'stundenplan', width: 'half' }}, + {{ widgetId: 'klassen', width: 'half' }} + ] + }}, + {{ + id: 'row-2', + widgets: [ + {{ widgetId: 'fehlzeiten', width: 'half' }}, + {{ widgetId: 'arbeiten', width: 'half' }} + ] + }}, + {{ + id: 'row-3', + widgets: [ + {{ widgetId: 'todos', width: 'half' }}, + {{ widgetId: 'nachrichten', width: 'half' }} + ] + }}, + {{ + id: 'row-4', + widgets: [ + {{ widgetId: 'schnellzugriff', width: 'full' }} + ] + }} + ] + }}; +}} + +function loadDashboardLayout() {{ + const stored = localStorage.getItem(DASHBOARD_LAYOUT_KEY); + return stored ? JSON.parse(stored) : getDefaultLayout(); +}} + +function saveDashboardLayout(layout) {{ + localStorage.setItem(DASHBOARD_LAYOUT_KEY, JSON.stringify(layout)); +}} + +function getGreeting() {{ + const hour = new Date().getHours(); + if (hour < 12) return 'Guten Morgen'; + if (hour < 18) return 'Guten Tag'; + return 'Guten Abend'; +}} + +function getLehrerName() {{ + const profil = localStorage.getItem(LEHRER_PROFIL_KEY); + if (profil) {{ + try {{ + return JSON.parse(profil).name || 'Lehrer'; + }} catch (e) {{}} + }} + return ''; +}} + +function formatDashboardDate() {{ + return new Date().toLocaleDateString('de-DE', {{ + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + }}); +}} + +function renderDashboardHeader() {{ + const greetingEl = document.getElementById('dashboard-greeting'); + const dateEl = document.getElementById('dashboard-date'); + + if (greetingEl) {{ + const name = getLehrerName(); + greetingEl.textContent = `${{getGreeting()}}${{name ? ', ' + name : ''}}!`; + }} + + if (dateEl) {{ + dateEl.textContent = formatDashboardDate(); + }} +}} + +function renderWidgetCatalog() {{ + const grid = document.getElementById('widget-catalog-grid'); + if (!grid) return; + + const layout = loadDashboardLayout(); + const usedWidgets = new Set(); + + layout.rows.forEach(row => {{ + row.widgets.forEach(w => usedWidgets.add(w.widgetId)); + }}); + + grid.innerHTML = Object.values(WidgetRegistry).map(widget => {{ + const isUsed = usedWidgets.has(widget.id); + return ` +
    + ${{widget.icon}} + ${{widget.name}} +
    + `; + }}).join(''); +}} + +function renderDashboardGrid() {{ + const grid = document.getElementById('dashboard-grid'); + if (!grid) return; + + const layout = loadDashboardLayout(); + + grid.innerHTML = layout.rows.map((row, rowIndex) => {{ + const isSingleFull = row.widgets.length === 1 && row.widgets[0].width === 'full'; + + return ` +
    + ${{row.widgets.map((w, widgetIndex) => {{ + const widget = WidgetRegistry[w.widgetId]; + if (!widget) return ''; + + return ` +
    + + ${{widget.getHtml()}} +
    + `; + }}).join('')}} + ${{dashboardEditMode && row.widgets.length < 2 ? ` +
    + Widget hier ablegen +
    + ` : ''}} +
    + `; + }}).join(''); + + // Initialize all widgets + layout.rows.forEach(row => {{ + row.widgets.forEach(w => {{ + const widget = WidgetRegistry[w.widgetId]; + if (widget && widget.init) {{ + setTimeout(() => widget.init(), 0); + }} + }}); + }}); +}} + +function toggleDashboardEditMode() {{ + dashboardEditMode = !dashboardEditMode; + + const container = document.querySelector('.lehrer-dashboard-container'); + const btn = document.getElementById('dashboard-edit-btn'); + const btnText = document.getElementById('edit-btn-text'); + + if (container) {{ + if (dashboardEditMode) {{ + container.classList.add('dashboard-edit-mode'); + }} else {{ + container.classList.remove('dashboard-edit-mode'); + }} + }} + + if (btn) {{ + btn.classList.toggle('active', dashboardEditMode); + }} + + if (btnText) {{ + btnText.textContent = dashboardEditMode ? 'Fertig' : 'Anpassen'; + }} + + renderWidgetCatalog(); + renderDashboardGrid(); +}} + +function handleWidgetDragStart(event) {{ + const widgetId = event.target.dataset.widgetId; + event.dataTransfer.setData('widget-id', widgetId); + event.target.classList.add('dragging'); +}} + +function handleWidgetDragEnd(event) {{ + event.target.classList.remove('dragging'); +}} + +function handleDragOver(event) {{ + event.preventDefault(); + event.currentTarget.classList.add('drag-over'); +}} + +function handleDragLeave(event) {{ + event.currentTarget.classList.remove('drag-over'); +}} + +function handleDrop(event, rowId) {{ + event.preventDefault(); + event.currentTarget.classList.remove('drag-over'); + + const widgetId = event.dataTransfer.getData('widget-id'); + if (!widgetId || !WidgetRegistry[widgetId]) return; + + const layout = loadDashboardLayout(); + const row = layout.rows.find(r => r.id === rowId); + + if (row && row.widgets.length < 2) {{ + const widget = WidgetRegistry[widgetId]; + row.widgets.push({{ + widgetId: widgetId, + width: widget.defaultWidth === 'full' ? 'full' : 'half' + }}); + + saveDashboardLayout(layout); + renderWidgetCatalog(); + renderDashboardGrid(); + }} +}} + +function removeWidget(rowId, widgetIndex) {{ + const layout = loadDashboardLayout(); + const rowIndex = layout.rows.findIndex(r => r.id === rowId); + + if (rowIndex !== -1) {{ + layout.rows[rowIndex].widgets.splice(widgetIndex, 1); + + // Remove empty rows + if (layout.rows[rowIndex].widgets.length === 0) {{ + layout.rows.splice(rowIndex, 1); + }} + + saveDashboardLayout(layout); + renderWidgetCatalog(); + renderDashboardGrid(); + }} +}} + +function addDashboardRow() {{ + const layout = loadDashboardLayout(); + const newRowId = 'row-' + Date.now(); + + layout.rows.push({{ + id: newRowId, + widgets: [] + }}); + + saveDashboardLayout(layout); + renderDashboardGrid(); +}} + +function openWidgetSettings(widgetId) {{ + const modal = document.getElementById('widget-settings-modal'); + const title = document.getElementById('widget-settings-title'); + const body = document.getElementById('widget-settings-body'); + + if (!modal || !title || !body) return; + + const widget = WidgetRegistry[widgetId]; + if (!widget) return; + + title.textContent = widget.name + ' - Einstellungen'; + body.innerHTML = ` +

    + Widget-Einstellungen werden in einer zukuenftigen Version verfuegbar sein. +

    + `; + + modal.classList.add('active'); +}} + +function closeWidgetSettings() {{ + const modal = document.getElementById('widget-settings-modal'); + if (modal) {{ + modal.classList.remove('active'); + }} +}} + +function loadLehrerDashboardModule() {{ + if (lehrerDashboardInitialized) {{ + console.log('Lehrer-Dashboard already initialized'); + return; + }} + + console.log('Loading Lehrer-Dashboard Module...'); + + renderDashboardHeader(); + renderWidgetCatalog(); + renderDashboardGrid(); + + lehrerDashboardInitialized = true; + console.log('Lehrer-Dashboard Module loaded successfully'); +}} + +// Widget JavaScript +{widget_js} +""" diff --git a/backend/frontend/modules/lehrer_onboarding.py b/backend/frontend/modules/lehrer_onboarding.py new file mode 100644 index 0000000..36a266b --- /dev/null +++ b/backend/frontend/modules/lehrer_onboarding.py @@ -0,0 +1,654 @@ +""" +BreakPilot Studio - Lehrer Onboarding Modul + +Ein intuitives Willkommens-Dashboard fuer Lehrer, die BreakPilot zum ersten Mal nutzen. +Zeigt den Workflow und bietet schnellen Zugang zu allen wichtigen Funktionen. +""" + + +class LehrerOnboardingModule: + """Onboarding-Dashboard fuer neue Lehrer.""" + + @staticmethod + def get_css() -> str: + """CSS fuer das Lehrer-Onboarding Modul.""" + return """ +/* ============================================= + LEHRER ONBOARDING MODULE + ============================================= */ + +/* Container */ +.lehrer-onboarding-container { + max-width: 100%; + min-height: 100%; + background: var(--bp-bg, #0f172a); + color: var(--bp-text, #e2e8f0); +} + +/* Hero Section */ +.lehrer-hero { + background: linear-gradient(135deg, var(--bp-primary, #6C1B1B) 0%, #4a1010 100%); + padding: 48px 40px; + text-align: center; + position: relative; + overflow: hidden; +} + +.lehrer-hero::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.05'%3E%3Ccircle cx='30' cy='30' r='4'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"); + pointer-events: none; +} + +.lehrer-hero-icon { + width: 80px; + height: 80px; + background: rgba(255, 255, 255, 0.15); + border-radius: 20px; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 24px; + font-size: 40px; + backdrop-filter: blur(10px); +} + +.lehrer-hero h1 { + color: white; + font-size: 32px; + font-weight: 700; + margin: 0 0 12px 0; + position: relative; +} + +.lehrer-hero p { + color: rgba(255, 255, 255, 0.85); + font-size: 18px; + margin: 0; + max-width: 600px; + margin: 0 auto; + position: relative; +} + +/* Content Container */ +.lehrer-content { + padding: 40px; + max-width: 1200px; + margin: 0 auto; + width: 100%; +} + +/* Section Titles */ +.lehrer-section-title { + font-size: 14px; + font-weight: 600; + color: var(--bp-text-muted, #94a3b8); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 20px; + display: flex; + align-items: center; + gap: 10px; +} + +.lehrer-section-title::after { + content: ''; + flex: 1; + height: 1px; + background: var(--bp-border, #334155); +} + +/* Workflow Steps */ +.lehrer-workflow { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + margin-bottom: 48px; +} + +@media (max-width: 900px) { + .lehrer-workflow { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 600px) { + .lehrer-workflow { + grid-template-columns: 1fr; + } +} + +.lehrer-step { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 16px; + padding: 24px; + text-align: center; + position: relative; + transition: all 0.3s ease; +} + +.lehrer-step:hover { + transform: translateY(-4px); + box-shadow: 0 12px 24px rgba(0, 0, 0, 0.2); + border-color: var(--bp-primary); +} + +.lehrer-step-number { + position: absolute; + top: -12px; + left: 50%; + transform: translateX(-50%); + width: 28px; + height: 28px; + background: var(--bp-primary); + color: white; + border-radius: 50%; + font-size: 14px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + border: 3px solid var(--bp-bg); +} + +.lehrer-step-icon { + font-size: 36px; + margin-bottom: 12px; +} + +.lehrer-step h3 { + color: var(--bp-text); + font-size: 16px; + font-weight: 600; + margin: 0 0 8px 0; +} + +.lehrer-step p { + color: var(--bp-text-muted); + font-size: 13px; + margin: 0; + line-height: 1.5; +} + +/* Quick Actions Grid */ +.lehrer-actions { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 20px; + margin-bottom: 48px; +} + +@media (max-width: 900px) { + .lehrer-actions { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 600px) { + .lehrer-actions { + grid-template-columns: 1fr; + } +} + +.lehrer-action-card { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 16px; + padding: 24px; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.lehrer-action-card:hover { + transform: translateY(-4px); + box-shadow: 0 12px 24px rgba(0, 0, 0, 0.2); +} + +.lehrer-action-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: var(--bp-primary); + opacity: 0; + transition: opacity 0.3s; +} + +.lehrer-action-card:hover::before { + opacity: 1; +} + +.lehrer-action-icon { + width: 56px; + height: 56px; + border-radius: 14px; + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + margin-bottom: 16px; +} + +/* Icon Colors */ +.lehrer-action-card.worksheets .lehrer-action-icon { + background: rgba(59, 130, 246, 0.15); +} + +.lehrer-action-card.correction .lehrer-action-icon { + background: rgba(16, 185, 129, 0.15); +} + +.lehrer-action-card.letters .lehrer-action-icon { + background: rgba(236, 72, 153, 0.15); +} + +.lehrer-action-card.abitur .lehrer-action-icon { + background: rgba(168, 85, 247, 0.15); +} + +.lehrer-action-card.classes .lehrer-action-icon { + background: rgba(245, 158, 11, 0.15); +} + +.lehrer-action-card.meet .lehrer-action-icon { + background: rgba(139, 92, 246, 0.15); +} + +.lehrer-action-title { + color: var(--bp-text); + font-size: 18px; + font-weight: 600; + margin: 0 0 8px 0; +} + +.lehrer-action-desc { + color: var(--bp-text-muted); + font-size: 14px; + line-height: 1.5; + margin: 0 0 16px 0; +} + +.lehrer-action-cta { + display: flex; + align-items: center; + gap: 6px; + color: var(--bp-primary); + font-size: 14px; + font-weight: 500; +} + +.lehrer-action-cta::after { + content: '→'; + transition: transform 0.2s; +} + +.lehrer-action-card:hover .lehrer-action-cta::after { + transform: translateX(4px); +} + +.lehrer-action-badge { + position: absolute; + top: 16px; + right: 16px; + font-size: 11px; + padding: 4px 10px; + border-radius: 12px; + font-weight: 600; +} + +.lehrer-action-badge.new { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; +} + +.lehrer-action-badge.popular { + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; +} + +.lehrer-action-badge.ai { + background: rgba(168, 85, 247, 0.15); + color: #a855f7; +} + +/* Tips Section */ +.lehrer-tips { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 16px; + padding: 24px; + margin-bottom: 48px; +} + +.lehrer-tips-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; +} + +.lehrer-tips-icon { + width: 40px; + height: 40px; + background: rgba(59, 130, 246, 0.15); + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; +} + +.lehrer-tips-header h3 { + color: var(--bp-text); + font-size: 16px; + font-weight: 600; + margin: 0; +} + +.lehrer-tips-list { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} + +@media (max-width: 700px) { + .lehrer-tips-list { + grid-template-columns: 1fr; + } +} + +.lehrer-tip { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.lehrer-tip-check { + width: 24px; + height: 24px; + background: rgba(34, 197, 94, 0.15); + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: #22c55e; + font-size: 14px; +} + +.lehrer-tip-text { + color: var(--bp-text); + font-size: 14px; + line-height: 1.5; +} + +/* Help Section */ +.lehrer-help { + display: flex; + align-items: center; + justify-content: center; + gap: 24px; + padding: 24px; + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 16px; +} + +.lehrer-help-text { + color: var(--bp-text-muted); + font-size: 14px; +} + +.lehrer-help-link { + color: var(--bp-primary); + font-weight: 500; + cursor: pointer; + transition: color 0.2s; +} + +.lehrer-help-link:hover { + color: var(--bp-primary-hover); + text-decoration: underline; +} + +/* Progress Indicator (optional, fuer zukuenftige Features) */ +.lehrer-progress { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 16px; + padding: 24px; + margin-bottom: 48px; +} + +.lehrer-progress-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.lehrer-progress-header h3 { + color: var(--bp-text); + font-size: 16px; + font-weight: 600; + margin: 0; +} + +.lehrer-progress-value { + color: var(--bp-primary); + font-weight: 600; +} + +.lehrer-progress-bar { + height: 8px; + background: var(--bp-border); + border-radius: 4px; + overflow: hidden; +} + +.lehrer-progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--bp-primary), #a855f7); + border-radius: 4px; + transition: width 0.5s ease; +} +""" + + @staticmethod + def get_html() -> str: + """HTML fuer das Lehrer-Onboarding Modul.""" + return """ + + +""" + + @staticmethod + def get_js() -> str: + """JavaScript fuer das Lehrer-Onboarding Modul.""" + return """ +// ============================================= +// LEHRER ONBOARDING MODULE +// ============================================= + +let lehrerOnboardingInitialized = false; + +function loadLehrerOnboardingModule() { + if (lehrerOnboardingInitialized) { + console.log('Lehrer Onboarding already initialized'); + return; + } + + console.log('Initializing Lehrer Onboarding Module...'); + + // Track that user has seen onboarding + localStorage.setItem('bp-onboarding-seen', 'true'); + + // Add activity + if (typeof addActivity === 'function') { + addActivity('👋', 'Willkommen bei BreakPilot!'); + } + + lehrerOnboardingInitialized = true; + console.log('Lehrer Onboarding Module initialized'); +} + +// Check if user should see onboarding on first visit +function checkFirstVisit() { + const seen = localStorage.getItem('bp-onboarding-seen'); + if (!seen) { + // First visit - show onboarding instead of dashboard + console.log('First visit detected - showing onboarding'); + setTimeout(() => { + if (typeof loadModule === 'function') { + loadModule('lehrer-onboarding'); + } + }, 100); + } +} + +// Listen for module activation +window.addEventListener('show-lehrer-onboarding', loadLehrerOnboardingModule); + +// Check on page load (deferred) +// document.addEventListener('DOMContentLoaded', checkFirstVisit); +""" diff --git a/backend/frontend/modules/letters.py b/backend/frontend/modules/letters.py new file mode 100644 index 0000000..1aa9e6a --- /dev/null +++ b/backend/frontend/modules/letters.py @@ -0,0 +1,1952 @@ +""" +BreakPilot Studio - Elternkommunikation Modul + +Refactored: 2024-12-18 +- Kachel-basierte Startansicht +- Module fuer verschiedene Kommunikationsformen + +Funktionen (als Kacheln): +- Elterngespraech (Notizen, Protokolle) +- Elterngespraech planen (Terminbuchung) +- Elternbriefe mit Legal Assistant (GFK) +""" + + +class LettersModule: + """Elternkommunikation Modul mit Legal Assistant.""" + + @staticmethod + def get_css() -> str: + """CSS fuer das Elternkommunikation-Modul.""" + return """ +/* ========================================== + LETTERS MODULE STYLES - Elternkommunikation + ========================================== */ + +/* Panel Layout */ +.panel-letters { + display: none; + flex-direction: column; + height: 100%; + background: var(--bp-bg); + overflow: hidden; +} + +.panel-letters.active { + display: flex; +} + +/* Letters Header */ +.letters-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 32px; + border-bottom: 1px solid var(--bp-border); + background: var(--bp-surface); +} + +.letters-title-section h1 { + font-size: 24px; + font-weight: 700; + color: var(--bp-text); + margin-bottom: 4px; +} + +.letters-subtitle { + font-size: 14px; + color: var(--bp-text-muted); +} + +/* Letters Content - Kacheln */ +.letters-content { + flex: 1; + overflow-y: auto; + padding: 32px; +} + +/* Tiles Grid */ +.letters-tiles { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 24px; + max-width: 1200px; + margin: 0 auto; +} + +/* Letter Tile */ +.letter-tile { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 16px; + padding: 28px; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.letter-tile:hover { + transform: translateY(-4px); + box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1); + border-color: var(--bp-primary); +} + +.letter-tile::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + opacity: 0; + transition: opacity 0.3s; +} + +.letter-tile:hover::before { + opacity: 1; +} + +.letter-tile.conversation::before { background: linear-gradient(90deg, #3b82f6, #1d4ed8); } +.letter-tile.planning::before { background: linear-gradient(90deg, #10b981, #059669); } +.letter-tile.legal::before { background: linear-gradient(90deg, #8b5cf6, #6d28d9); } + +.tile-icon-wrapper { + width: 64px; + height: 64px; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + font-size: 32px; + margin-bottom: 20px; +} + +.tile-icon-wrapper.conversation { background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); } +.tile-icon-wrapper.planning { background: linear-gradient(135deg, #10b981 0%, #059669 100%); } +.tile-icon-wrapper.legal { background: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%); } + +.tile-heading { + font-size: 18px; + font-weight: 600; + color: var(--bp-text); + margin-bottom: 10px; +} + +.tile-description { + font-size: 14px; + color: var(--bp-text-muted); + line-height: 1.6; + margin-bottom: 20px; +} + +.tile-features-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.tile-feature { + padding: 6px 12px; + background: var(--bp-bg); + border-radius: 16px; + font-size: 12px; + color: var(--bp-text-muted); +} + +.tile-arrow-icon { + position: absolute; + bottom: 24px; + right: 24px; + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--bp-bg); + display: flex; + align-items: center; + justify-content: center; + color: var(--bp-text-muted); + transition: all 0.3s; + font-size: 18px; +} + +.letter-tile:hover .tile-arrow-icon { + background: var(--bp-primary); + color: white; + transform: translateX(4px); +} + +/* Sub-Panel */ +.letters-subpanel { + display: none; + flex-direction: column; + height: 100%; +} + +.letters-subpanel.active { + display: flex; +} + +.subpanel-header { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 24px; + border-bottom: 1px solid var(--bp-border); + background: var(--bp-surface); +} + +.subpanel-back { + width: 36px; + height: 36px; + border-radius: 8px; + border: 1px solid var(--bp-border); + background: var(--bp-bg); + color: var(--bp-text); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + transition: all 0.2s; +} + +.subpanel-back:hover { + background: var(--bp-surface-elevated); +} + +.subpanel-title { + font-size: 18px; + font-weight: 600; +} + +.subpanel-content { + flex: 1; + overflow-y: auto; + padding: 24px; +} + +/* ========================================== + SUB-MODULE: Elterngespraech + ========================================== */ + +.conversation-container { + max-width: 900px; + margin: 0 auto; +} + +.conversation-header-info { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.info-card { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + padding: 16px; +} + +.info-card label { + display: block; + font-size: 12px; + color: var(--bp-text-muted); + margin-bottom: 6px; +} + +.info-card input, +.info-card select { + width: 100%; + padding: 10px; + border: 1px solid var(--bp-border); + border-radius: 8px; + background: var(--bp-bg); + color: var(--bp-text); + font-size: 14px; +} + +.conversation-notes { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + padding: 20px; + margin-bottom: 24px; +} + +.conversation-notes h3 { + font-size: 14px; + color: var(--bp-text-muted); + margin-bottom: 12px; +} + +.conversation-notes textarea { + width: 100%; + min-height: 250px; + padding: 16px; + border: 1px solid var(--bp-border); + border-radius: 8px; + background: var(--bp-bg); + color: var(--bp-text); + font-size: 14px; + line-height: 1.6; + resize: vertical; +} + +.conversation-notes textarea:focus { + outline: none; + border-color: var(--bp-primary); +} + +.conversation-actions { + display: flex; + gap: 12px; + justify-content: flex-end; +} + +/* ========================================== + SUB-MODULE: Terminplanung + ========================================== */ + +.planning-container { + max-width: 800px; + margin: 0 auto; +} + +.planning-calendar { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + padding: 24px; + margin-bottom: 24px; +} + +.calendar-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.calendar-nav { + display: flex; + gap: 8px; +} + +.calendar-nav button { + width: 32px; + height: 32px; + border-radius: 8px; + border: 1px solid var(--bp-border); + background: var(--bp-bg); + color: var(--bp-text); + cursor: pointer; +} + +.calendar-month { + font-size: 18px; + font-weight: 600; +} + +.calendar-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; +} + +.calendar-day-header { + text-align: center; + font-size: 12px; + color: var(--bp-text-muted); + padding: 8px; +} + +.calendar-day { + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + font-size: 14px; +} + +.calendar-day:hover { + background: var(--bp-bg); +} + +.calendar-day.today { + background: var(--bp-primary-soft); + color: var(--bp-primary); + font-weight: 600; +} + +.calendar-day.selected { + background: var(--bp-primary); + color: white; +} + +.calendar-day.has-appointment::after { + content: ''; + position: absolute; + bottom: 4px; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--bp-primary); +} + +.time-slots { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + padding: 20px; +} + +.time-slots h3 { + font-size: 14px; + color: var(--bp-text-muted); + margin-bottom: 16px; +} + +.time-slot-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 8px; +} + +.time-slot { + padding: 10px; + text-align: center; + border: 1px solid var(--bp-border); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + font-size: 13px; +} + +.time-slot:hover { + border-color: var(--bp-primary); +} + +.time-slot.selected { + background: var(--bp-primary); + border-color: var(--bp-primary); + color: white; +} + +.time-slot.booked { + opacity: 0.5; + cursor: not-allowed; + text-decoration: line-through; +} + +/* ========================================== + SUB-MODULE: Legal Assistant / Elternbriefe + ========================================== */ + +.legal-container { + display: grid; + grid-template-columns: 1fr 380px; + gap: 24px; + max-width: 1400px; +} + +@media (max-width: 1100px) { + .legal-container { + grid-template-columns: 1fr; + } +} + +/* Editor Section */ +.legal-editor { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + padding: 24px; +} + +.editor-section { + margin-bottom: 24px; +} + +.editor-section-title { + font-size: 14px; + font-weight: 600; + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; +} + +/* Letter Type Selection */ +.letter-types { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 8px; + margin-bottom: 20px; +} + +.letter-type-btn { + padding: 12px; + border-radius: 8px; + border: 1px solid var(--bp-border); + background: var(--bp-surface); + cursor: pointer; + text-align: center; + transition: all 0.2s; +} + +.letter-type-btn:hover { + border-color: var(--bp-primary); +} + +.letter-type-btn.active { + background: var(--bp-primary-soft); + border-color: var(--bp-primary); + color: var(--bp-primary); +} + +.letter-type-icon { + font-size: 20px; + margin-bottom: 4px; +} + +.letter-type-label { + font-size: 11px; + font-weight: 500; +} + +/* Tone Selection */ +.tone-options { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.tone-btn { + padding: 6px 12px; + border-radius: 20px; + border: 1px solid var(--bp-border); + background: transparent; + font-size: 12px; + cursor: pointer; + transition: all 0.2s; +} + +.tone-btn:hover { + border-color: var(--bp-primary); +} + +.tone-btn.active { + background: var(--bp-primary); + border-color: var(--bp-primary); + color: white; +} + +/* Text Editor */ +.text-editor { + width: 100%; + min-height: 300px; + padding: 16px; + border-radius: 8px; + border: 1px solid var(--bp-border); + background: var(--bp-surface); + color: var(--bp-text); + font-family: inherit; + font-size: 14px; + line-height: 1.6; + resize: vertical; +} + +.text-editor:focus { + outline: none; + border-color: var(--bp-primary); +} + +/* Character Counter */ +.char-counter { + display: flex; + justify-content: flex-end; + font-size: 12px; + color: var(--bp-text-muted); + margin-top: 8px; +} + +/* Legal Assistant Panel */ +.legal-assistant-panel { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + padding: 24px; + height: fit-content; + position: sticky; + top: 24px; +} + +.legal-assistant-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; +} + +.legal-icon-box { + width: 44px; + height: 44px; + background: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%); + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; +} + +.legal-assistant-title { + font-size: 16px; + font-weight: 600; +} + +.legal-assistant-subtitle { + font-size: 12px; + color: var(--bp-text-muted); +} + +/* GFK Score */ +.gfk-score-card { + background: var(--bp-bg); + border-radius: 12px; + padding: 16px; + margin-bottom: 20px; +} + +.gfk-score-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.gfk-score-label { + font-size: 13px; + font-weight: 500; +} + +.gfk-score-value { + font-size: 28px; + font-weight: 700; + color: var(--bp-accent); +} + +.gfk-score-bar { + height: 8px; + background: var(--bp-border); + border-radius: 4px; + overflow: hidden; +} + +.gfk-score-fill { + height: 100%; + background: var(--bp-accent); + border-radius: 4px; + transition: width 0.3s ease; +} + +.gfk-info { + font-size: 11px; + color: var(--bp-text-muted); + margin-top: 8px; +} + +/* Suggestions */ +.suggestions-list { + display: flex; + flex-direction: column; + gap: 10px; + margin-bottom: 20px; +} + +.suggestion-item { + background: var(--bp-bg); + border-radius: 8px; + padding: 12px; + border-left: 3px solid var(--bp-warning); +} + +.suggestion-item.positive { + border-left-color: var(--bp-success); +} + +.suggestion-type { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + color: var(--bp-text-muted); + margin-bottom: 4px; +} + +.suggestion-text { + font-size: 13px; + line-height: 1.4; +} + +/* Templates */ +.templates-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.template-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + background: var(--bp-bg); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; +} + +.template-item:hover { + background: var(--bp-surface-elevated); +} + +.template-name { + font-size: 13px; + font-weight: 500; +} + +.template-use-btn { + font-size: 12px; + color: var(--bp-primary); +} + +/* Legal References */ +.legal-refs-section { + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--bp-border); +} + +.legal-refs-title { + font-size: 12px; + font-weight: 600; + margin-bottom: 12px; + color: var(--bp-text-muted); +} + +.legal-ref-item { + font-size: 12px; + padding: 10px; + background: var(--bp-bg); + border-radius: 8px; + margin-bottom: 8px; +} + +.legal-ref-law { + font-weight: 600; + color: var(--bp-info); +} + +/* Actions */ +.editor-actions { + display: flex; + gap: 12px; + margin-top: 20px; +} + +/* Status Bar */ +.letters-status { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 24px; + border-top: 1px solid var(--bp-border); + background: var(--bp-surface); + font-size: 12px; +} + +.status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background: #10b981; +} + +.status-indicator.busy { + background: #f59e0b; + animation: statusPulse 1.5s infinite; +} + +@keyframes statusPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.status-text { + color: var(--bp-text); +} + +.status-detail { + color: var(--bp-text-muted); +} +""" + + @staticmethod + def get_html() -> str: + """HTML fuer das Elternkommunikation-Modul mit Kachel-basierter Startansicht.""" + return """ + +
    + + +
    + +
    +
    +

    Elternkommunikation

    +

    Professionelle Kommunikation mit Eltern und Erziehungsberechtigten

    +
    +
    + + +
    +
    + + +
    +
    💬
    +
    Elterngespraech
    +
    Fuehre strukturierte Elterngespraeche und dokumentiere wichtige Vereinbarungen. Erstelle Protokolle und Notizen.
    +
    + Protokolle + Notizen + Export +
    +
    +
    + + +
    +
    📅
    +
    Elterngespraech planen
    +
    Plane und verwalte Termine fuer Elterngespraeche. Versende Einladungen und Erinnerungen automatisch.
    +
    + Kalender + Einladungen + Erinnerungen +
    +
    +
    + + + + +
    +
    +
    + + +
    +
    + +
    Elterngespraech dokumentieren
    +
    +
    +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +

    📝 Gespraechsnotizen

    + +
    + +
    + + + +
    +
    +
    +
    + + +
    +
    + +
    Elterngespraech planen
    +
    +
    +
    +
    +
    +
    + + +
    +
    Dezember 2024
    +
    +
    +
    Mo
    +
    Di
    +
    Mi
    +
    Do
    +
    Fr
    +
    Sa
    +
    So
    + +
    +
    + +
    +

    🕑 Verfuegbare Zeitslots

    +
    +
    08:00
    +
    08:30
    +
    09:00
    +
    09:30
    +
    10:00
    +
    10:30
    +
    14:00
    +
    14:30
    +
    15:00
    +
    15:30
    +
    16:00
    +
    16:30
    +
    +
    + +
    + +
    +
    +
    +
    + + + + + +
    + + Bereit + +
    +
    +""" + + @staticmethod + def get_js() -> str: + """JavaScript fuer das Elternkommunikation-Modul.""" + return """ +// ========================================== +// LETTERS MODULE - Elternkommunikation +// ========================================== + +let lettersInitialized = false; +let currentLetterType = 'general'; +let currentTone = 'professional'; +let analysisTimeout = null; + +// Letter Templates +const LETTER_TEMPLATES = { + halbjahr: { + subject: 'Information zum Leistungsstand - Halbjahr', + content: `Sehr geehrte Eltern, + +ich moechte Sie ueber den aktuellen Leistungsstand Ihres Kindes [SCHUELER] in der Klasse [KLASSE] informieren. + +[Beobachtungen einfuegen] + +Ich wuerde mich freuen, wenn wir gemeinsam besprechen koennten, wie wir [SCHUELER] weiter unterstuetzen koennen. + +Mit freundlichen Gruessen` + }, + fehlzeiten: { + subject: 'Mitteilung ueber Fehlzeiten', + content: `Sehr geehrte Eltern, + +mir ist aufgefallen, dass [SCHUELER] in letzter Zeit haeufiger dem Unterricht ferngeblieben ist. + +Ich mache mir Sorgen darueber und moechte gerne verstehen, ob es Gruende gibt, bei denen wir als Schule unterstuetzen koennen. + +Koennten wir einen Termin fuer ein kurzes Gespraech vereinbaren? + +Mit freundlichen Gruessen` + }, + elternabend: { + subject: 'Einladung zum Elternabend', + content: `Sehr geehrte Eltern, + +hiermit lade ich Sie herzlich zum Elternabend der Klasse [KLASSE] ein. + +Datum: [DATUM] +Uhrzeit: [UHRZEIT] +Ort: [ORT] + +Tagesordnung: +1. Begruessung +2. Informationen zum Halbjahr +3. Verschiedenes + +Ich freue mich auf Ihr Kommen und einen konstruktiven Austausch. + +Mit freundlichen Gruessen` + }, + lob: { + subject: 'Positive Rueckmeldung zu [SCHUELER]', + content: `Sehr geehrte Eltern, + +ich freue mich, Ihnen eine positive Rueckmeldung zu [SCHUELER] geben zu koennen. + +[Positive Beobachtungen einfuegen] + +Es ist schoen zu sehen, wie sich [SCHUELER] entwickelt. Bitte geben Sie dieses Lob auch zu Hause weiter. + +Mit freundlichen Gruessen` + } +}; + +// GFK Patterns +const GFK_POSITIVE_PATTERNS = [ + /ich (beobachte|sehe|habe bemerkt)/i, + /ich (fuehle|empfinde)/i, + /ich (brauche|wuensche mir)/i, + /ich (bitte|moechte bitten)/i, + /koennten wir/i, + /gemeinsam/i, + /unterstuetzen/i, + /wertschaetze/i, + /freue mich/i +]; + +const GFK_NEGATIVE_PATTERNS = [ + /muss|muessen/i, + /immer|nie|staendig/i, + /schuld/i, + /versagt/i, + /unfaehig/i, + /sie sollten/i, + /inakzeptabel/i +]; + +function loadLettersModule() { + if (lettersInitialized) { + console.log('Letters module already initialized'); + return; + } + + console.log('Loading Letters Module...'); + + // Set default date + const dateInput = document.getElementById('conv-date'); + if (dateInput) { + dateInput.valueAsDate = new Date(); + } + + // Initialize calendar + initCalendar(); + + lettersInitialized = true; + console.log('Letters Module loaded successfully'); +} + +// ========================================== +// VIEW SWITCHING +// ========================================== + +function openLettersSubpanel(panelId) { + document.getElementById('letters-tiles-view').style.display = 'none'; + + document.querySelectorAll('.letters-subpanel').forEach(p => { + p.classList.remove('active'); + }); + + const panel = document.getElementById('letters-subpanel-' + panelId); + if (panel) { + panel.classList.add('active'); + } +} + +function closeLettersSubpanel() { + document.querySelectorAll('.letters-subpanel').forEach(p => { + p.classList.remove('active'); + }); + document.getElementById('letters-tiles-view').style.display = 'block'; +} + +// ========================================== +// STATUS +// ========================================== + +function setLettersStatus(text, detail = '', state = 'idle') { + const indicator = document.getElementById('letters-status-indicator'); + const textEl = document.getElementById('letters-status-text'); + const detailEl = document.getElementById('letters-status-detail'); + + if (textEl) textEl.textContent = text; + if (detailEl) detailEl.textContent = detail; + if (indicator) { + indicator.classList.remove('busy'); + if (state === 'busy') indicator.classList.add('busy'); + } +} + +// ========================================== +// CONVERSATION (Elterngespraech) +// ========================================== + +function saveConversationDraft() { + const data = { + student: document.getElementById('conv-student')?.value, + class: document.getElementById('conv-class')?.value, + date: document.getElementById('conv-date')?.value, + participants: document.getElementById('conv-participants')?.value, + notes: document.getElementById('conv-notes')?.value + }; + + localStorage.setItem('bp-conversation-draft', JSON.stringify(data)); + setLettersStatus('Entwurf gespeichert', ''); +} + +async function saveConversation() { + const data = { + student: document.getElementById('conv-student')?.value, + class: document.getElementById('conv-class')?.value, + date: document.getElementById('conv-date')?.value, + participants: document.getElementById('conv-participants')?.value, + notes: document.getElementById('conv-notes')?.value + }; + + if (!data.student || !data.notes) { + alert('Bitte fuellen Sie mindestens Schueler/in und Notizen aus.'); + return; + } + + setLettersStatus('Speichere Protokoll...', '', 'busy'); + + try { + // Save as a letter with type 'general' for the conversation record + const resp = await fetch('/api/letters/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recipient_name: 'Gespraechsprotokoll', + recipient_address: '', + student_name: data.student, + student_class: data.class || '', + subject: 'Elterngespraech vom ' + (data.date || new Date().toLocaleDateString('de-DE')), + content: 'Teilnehmer: ' + (data.participants || 'k.A.') + '\\n\\n' + data.notes, + letter_type: 'general', + tone: 'professional', + teacher_name: '', + teacher_title: '' + }) + }); + + if (!resp.ok) { + throw new Error('Speichern fehlgeschlagen'); + } + + const result = await resp.json(); + setLettersStatus('Protokoll gespeichert', 'ID: ' + result.id); + alert('Gespraechsprotokoll wurde gespeichert.'); + closeLettersSubpanel(); + + } catch (e) { + console.error('Save conversation error:', e); + setLettersStatus('Speichern fehlgeschlagen', e.message, 'error'); + // Fallback to local storage + saveConversationDraft(); + alert('Gespraechsprotokoll wurde lokal gespeichert.'); + closeLettersSubpanel(); + } +} + +async function exportConversation(format) { + const data = { + student: document.getElementById('conv-student')?.value || 'Unbekannt', + class: document.getElementById('conv-class')?.value || '', + date: document.getElementById('conv-date')?.value || new Date().toLocaleDateString('de-DE'), + participants: document.getElementById('conv-participants')?.value || '', + notes: document.getElementById('conv-notes')?.value || '' + }; + + if (!data.notes) { + alert('Bitte geben Sie zuerst Notizen ein.'); + return; + } + + setLettersStatus('Erstelle ' + format.toUpperCase() + '...', '', 'busy'); + + if (format === 'pdf') { + try { + const resp = await fetch('/api/letters/export-pdf', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + letter_data: { + recipient_name: 'Gespraechsprotokoll', + recipient_address: '', + student_name: data.student, + student_class: data.class, + subject: 'Elterngespraech vom ' + data.date, + content: 'Teilnehmer: ' + (data.participants || 'k.A.') + '\\n\\n' + data.notes, + letter_type: 'general', + tone: 'professional', + teacher_name: '', + teacher_title: '' + } + }) + }); + + if (!resp.ok) { + throw new Error('PDF-Export fehlgeschlagen'); + } + + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `Gespraechsprotokoll_${data.student.replace(/\\s+/g, '_')}_${data.date.replace(/\\./g, '-')}.pdf`; + a.click(); + URL.revokeObjectURL(url); + + setLettersStatus('PDF erstellt', 'Download gestartet'); + + } catch (e) { + console.error('PDF export error:', e); + setLettersStatus('Export fehlgeschlagen', e.message, 'error'); + alert('PDF-Export fehlgeschlagen: ' + e.message); + } + } else { + // Text export fallback + const textContent = [ + 'GESPRAECHSPROTOKOLL', + '==================', + '', + 'Schueler/in: ' + data.student, + 'Klasse: ' + data.class, + 'Datum: ' + data.date, + 'Teilnehmer: ' + data.participants, + '', + 'NOTIZEN:', + '--------', + data.notes + ].join('\\n'); + + const blob = new Blob([textContent], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `Gespraechsprotokoll_${data.student.replace(/\\s+/g, '_')}.txt`; + a.click(); + URL.revokeObjectURL(url); + + setLettersStatus('Export abgeschlossen', ''); + } +} + +// ========================================== +// PLANNING (Terminplanung) +// ========================================== + +let selectedDate = null; +let selectedTimeSlot = null; +let currentCalendarDate = new Date(); + +function initCalendar() { + renderCalendar(); +} + +function renderCalendar() { + const grid = document.getElementById('calendar-grid'); + const monthLabel = document.getElementById('calendar-month'); + if (!grid || !monthLabel) return; + + const year = currentCalendarDate.getFullYear(); + const month = currentCalendarDate.getMonth(); + + const monthNames = ['Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni', + 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; + monthLabel.textContent = monthNames[month] + ' ' + year; + + // Clear existing days (keep headers) + const headers = Array.from(grid.querySelectorAll('.calendar-day-header')); + grid.innerHTML = ''; + headers.forEach(h => grid.appendChild(h)); + + // First day of month + const firstDay = new Date(year, month, 1); + let startDay = firstDay.getDay(); + if (startDay === 0) startDay = 7; // Monday = 1 + + // Days in month + const daysInMonth = new Date(year, month + 1, 0).getDate(); + + // Today + const today = new Date(); + const isThisMonth = today.getFullYear() === year && today.getMonth() === month; + + // Add empty cells for days before start + for (let i = 1; i < startDay; i++) { + const empty = document.createElement('div'); + empty.className = 'calendar-day'; + grid.appendChild(empty); + } + + // Add days + for (let day = 1; day <= daysInMonth; day++) { + const dayEl = document.createElement('div'); + dayEl.className = 'calendar-day'; + dayEl.textContent = day; + + if (isThisMonth && day === today.getDate()) { + dayEl.classList.add('today'); + } + + dayEl.addEventListener('click', () => { + document.querySelectorAll('.calendar-day.selected').forEach(d => d.classList.remove('selected')); + dayEl.classList.add('selected'); + selectedDate = new Date(year, month, day); + }); + + grid.appendChild(dayEl); + } +} + +function prevMonth() { + currentCalendarDate.setMonth(currentCalendarDate.getMonth() - 1); + renderCalendar(); +} + +function nextMonth() { + currentCalendarDate.setMonth(currentCalendarDate.getMonth() + 1); + renderCalendar(); +} + +function selectTimeSlot(el) { + if (el.classList.contains('booked')) return; + + document.querySelectorAll('.time-slot.selected').forEach(s => s.classList.remove('selected')); + el.classList.add('selected'); + selectedTimeSlot = el.textContent; +} + +function bookAppointment() { + if (!selectedDate || !selectedTimeSlot) { + alert('Bitte waehlen Sie ein Datum und eine Uhrzeit aus.'); + return; + } + + const dateStr = selectedDate.toLocaleDateString('de-DE'); + alert('Termin gebucht: ' + dateStr + ' um ' + selectedTimeSlot + ' Uhr\\n\\nEinladung wird versendet...'); + closeLettersSubpanel(); +} + +// ========================================== +// LEGAL ASSISTANT +// ========================================== + +function selectLetterType(type) { + currentLetterType = type; + + document.querySelectorAll('.letter-type-btn').forEach(btn => { + btn.classList.remove('active'); + }); + const btn = document.querySelector(`.letter-type-btn[data-type="${type}"]`); + if (btn) btn.classList.add('active'); + + updateLegalReferences(type); +} + +function selectTone(tone) { + currentTone = tone; + + document.querySelectorAll('.tone-btn').forEach(btn => { + btn.classList.remove('active'); + }); + const btn = document.querySelector(`.tone-btn[data-tone="${tone}"]`); + if (btn) btn.classList.add('active'); +} + +function analyzeLetterText() { + clearTimeout(analysisTimeout); + + analysisTimeout = setTimeout(() => { + const content = document.getElementById('letter-content')?.value || ''; + const charCount = content.length; + + document.getElementById('letter-char-count').textContent = charCount; + + if (charCount < 20) { + document.getElementById('gfk-score-display').textContent = '--'; + document.getElementById('gfk-score-bar-fill').style.width = '0%'; + return; + } + + // Calculate GFK Score + let score = 50; + const suggestions = []; + + GFK_POSITIVE_PATTERNS.forEach(pattern => { + if (pattern.test(content)) score += 7; + }); + + GFK_NEGATIVE_PATTERNS.forEach(pattern => { + if (pattern.test(content)) { + score -= 10; + const match = content.match(pattern); + if (match) { + suggestions.push({ + type: 'warning', + text: '"' + match[0] + '" koennte wertend wirken.' + }); + } + } + }); + + const iStatements = (content.match(/\\bich\\b/gi) || []).length; + if (iStatements > 2) { + score += 10; + suggestions.push({ type: 'positive', text: 'Gut: Sie verwenden Ich-Botschaften.' }); + } + + if ((content.match(/\\?/g) || []).length > 0) { + score += 5; + suggestions.push({ type: 'positive', text: 'Gut: Offene Fragen foerdern den Dialog.' }); + } + + score = Math.max(0, Math.min(100, score)); + + document.getElementById('gfk-score-display').textContent = score; + document.getElementById('gfk-score-bar-fill').style.width = score + '%'; + + const bar = document.getElementById('gfk-score-bar-fill'); + if (score >= 70) bar.style.background = 'var(--bp-success)'; + else if (score >= 40) bar.style.background = 'var(--bp-warning)'; + else bar.style.background = 'var(--bp-danger)'; + + updateSuggestions(suggestions); + }, 500); +} + +function updateSuggestions(suggestions) { + const list = document.getElementById('suggestions-list'); + if (!list) return; + + if (!suggestions.length) { + list.innerHTML = ` +
    +
    ✅ Tipp
    +
    Verwenden Sie Ich-Botschaften und beschreiben Sie konkrete Beobachtungen.
    +
    + `; + return; + } + + list.innerHTML = suggestions.map(s => ` +
    +
    ${s.type === 'positive' ? '✅ GUT' : '⚠ HINWEIS'}
    +
    ${s.text}
    +
    + `).join(''); +} + +function updateLegalReferences(type) { + const refs = { + general: [{ law: 'SchulG § 42', desc: 'Informationspflicht der Schule' }], + behavior: [ + { law: 'SchulG § 53', desc: 'Erziehungs- und Ordnungsmassnahmen' }, + { law: 'AOGS § 2', desc: 'Verfahren bei Ordnungsmassnahmen' } + ], + academic: [ + { law: 'SchulG § 44', desc: 'Information ueber Leistungsentwicklung' }, + { law: 'APO-SI § 7', desc: 'Versetzung und Foerderung' } + ], + attendance: [ + { law: 'SchulG § 43', desc: 'Schulpflicht und Teilnahmepflicht' }, + { law: 'SchulG § 41', desc: 'Verantwortung der Erziehungsberechtigten' } + ], + meeting: [{ law: 'SchulG § 44', desc: 'Elternberatung und -gespraeche' }], + positive: [{ law: 'SchulG § 2', desc: 'Foerderung individueller Faehigkeiten' }] + }; + + const container = document.getElementById('legal-refs-container'); + if (!container) return; + + const typeRefs = refs[type] || refs.general; + container.innerHTML = typeRefs.map(ref => ` + + `).join(''); +} + +function loadLetterTemplate(templateId) { + const template = LETTER_TEMPLATES[templateId]; + if (!template) return; + + const student = document.getElementById('letter-student')?.value || '[SCHUELER]'; + const classVal = document.getElementById('letter-class')?.value || '[KLASSE]'; + + let content = template.content + .replace(/\\[SCHUELER\\]/g, student) + .replace(/\\[KLASSE\\]/g, classVal); + + document.getElementById('letter-subject').value = template.subject.replace('[SCHUELER]', student); + document.getElementById('letter-content').value = content; + + analyzeLetterText(); +} + +// Improve letter with AI via /api/letters/improve +async function improveWithAI() { + const content = document.getElementById('letter-content')?.value || ''; + if (content.length < 20) { + alert('Bitte geben Sie zuerst einen Text ein.'); + return; + } + + setLettersStatus('Verbessere mit KI...', 'GFK-Analyse', 'busy'); + + try { + const resp = await fetch('/api/letters/improve', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content: content, + communication_type: currentLetterType, + tone: currentTone + }) + }); + + if (!resp.ok) { + throw new Error('API-Fehler: ' + resp.status); + } + + const result = await resp.json(); + + // Update GFK score display + const score = Math.round((result.gfk_score || 0.5) * 100); + document.getElementById('gfk-score-display').textContent = score; + document.getElementById('gfk-score-bar-fill').style.width = score + '%'; + + // Update bar color based on score + const bar = document.getElementById('gfk-score-bar-fill'); + if (score >= 70) bar.style.background = 'var(--bp-success)'; + else if (score >= 40) bar.style.background = 'var(--bp-warning)'; + else bar.style.background = 'var(--bp-danger)'; + + // Show improvements/suggestions + const changes = result.changes || []; + const suggestions = changes.map(change => ({ + type: change.includes('Gut') || change.includes('positiv') ? 'positive' : 'warning', + text: change + })); + updateSuggestions(suggestions); + + // If improved content is different, offer to replace + if (result.improved_content && result.improved_content !== content) { + if (confirm('Der verbesserte Text liegt vor. Moechten Sie ihn uebernehmen?')) { + document.getElementById('letter-content').value = result.improved_content; + analyzeLetterText(); + } + } + + setLettersStatus('GFK-Analyse abgeschlossen', 'Score: ' + score + '%'); + + } catch (e) { + console.error('AI improvement error:', e); + setLettersStatus('Verbesserung fehlgeschlagen', e.message, 'error'); + // Fallback to local analysis + analyzeLetterText(); + } +} + +// Show letter preview in modal +function showLetterPreview() { + const student = document.getElementById('letter-student')?.value || '[Schueler/in]'; + const className = document.getElementById('letter-class')?.value || '[Klasse]'; + const subject = document.getElementById('letter-subject')?.value || '[Betreff]'; + const content = document.getElementById('letter-content')?.value || ''; + + const previewHtml = ` +
    +
    +
    Schule XY
    +
    Musterstrasse 1
    +
    12345 Musterstadt
    +
    ${new Date().toLocaleDateString('de-DE')}
    +
    +
    +
    Familie von ${student}
    +
    Klasse ${className}
    +
    +
    + Betreff: ${subject} +
    +
    + ${content} +
    +
    + `; + + // Create preview modal + const modal = document.createElement('div'); + modal.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); z-index: 10000; display: flex; align-items: center; justify-content: center; padding: 20px;'; + modal.innerHTML = ` +
    + + ${previewHtml} +
    + `; + document.body.appendChild(modal); +} + +// Export letter as PDF via /api/letters/export-pdf +async function exportLetterPDF() { + const student = document.getElementById('letter-student')?.value || 'Unbekannt'; + const className = document.getElementById('letter-class')?.value || ''; + const subject = document.getElementById('letter-subject')?.value || 'Elternbrief'; + const content = document.getElementById('letter-content')?.value || ''; + + if (content.length < 20) { + alert('Bitte geben Sie zuerst einen Text ein.'); + return; + } + + setLettersStatus('Erstelle PDF...', '', 'busy'); + + try { + const resp = await fetch('/api/letters/export-pdf', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + letter_data: { + recipient_name: 'Familie ' + student, + recipient_address: '', + student_name: student, + student_class: className, + subject: subject, + content: content, + letter_type: currentLetterType, + tone: currentTone, + teacher_name: 'Klassenlehrerin', + teacher_title: '' + } + }) + }); + + if (!resp.ok) { + throw new Error('PDF-Export fehlgeschlagen: ' + resp.status); + } + + // Download PDF + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `Elternbrief_${student.replace(/\\s+/g, '_')}_${new Date().toISOString().slice(0,10)}.pdf`; + a.click(); + URL.revokeObjectURL(url); + + setLettersStatus('PDF erstellt', 'Download gestartet'); + + } catch (e) { + console.error('PDF export error:', e); + setLettersStatus('PDF-Export fehlgeschlagen', e.message, 'error'); + alert('PDF-Export fehlgeschlagen: ' + e.message); + } +} + +// Save letter as template via /api/letters +async function saveLetterTemplate() { + const student = document.getElementById('letter-student')?.value || ''; + const className = document.getElementById('letter-class')?.value || ''; + const subject = document.getElementById('letter-subject')?.value || ''; + const content = document.getElementById('letter-content')?.value || ''; + + if (content.length < 20) { + alert('Bitte geben Sie zuerst einen Text ein.'); + return; + } + + const name = prompt('Name fuer die Vorlage:', subject); + if (!name) return; + + setLettersStatus('Speichere Vorlage...', '', 'busy'); + + try { + const resp = await fetch('/api/letters/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recipient_name: 'Familie ' + (student || '[SCHUELER]'), + recipient_address: '', + student_name: student || '[SCHUELER]', + student_class: className || '[KLASSE]', + subject: name, + content: content, + letter_type: currentLetterType, + tone: currentTone, + teacher_name: '[LEHRER]', + teacher_title: '' + }) + }); + + if (!resp.ok) { + throw new Error('Speichern fehlgeschlagen: ' + resp.status); + } + + const result = await resp.json(); + setLettersStatus('Vorlage gespeichert', 'ID: ' + result.id); + alert('Vorlage "' + name + '" wurde gespeichert.'); + + } catch (e) { + console.error('Save template error:', e); + setLettersStatus('Speichern fehlgeschlagen', e.message, 'error'); + alert('Speichern fehlgeschlagen: ' + e.message); + } +} + +// ========================================== +// SHOW PANEL +// ========================================== + +function showLettersPanel() { + console.log('showLettersPanel called'); + hideAllPanels(); + if (typeof hideStudioSubMenu === 'function') hideStudioSubMenu(); + const panel = document.getElementById('panel-letters'); + if (panel) { + panel.style.display = 'flex'; + loadLettersModule(); + console.log('Letters panel shown'); + } +} + +// Escape key handler +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && document.querySelector('.letters-subpanel.active')) { + closeLettersSubpanel(); + } +}); +""" + + +def get_letters_module() -> dict: + """Gibt das komplette Elternkommunikation-Modul als Dictionary zurueck.""" + module = LettersModule() + return { + 'css': module.get_css(), + 'html': module.get_html(), + 'js': module.get_js(), + 'init_function': 'loadLettersModule' + } diff --git a/backend/frontend/modules/mac_mini.py b/backend/frontend/modules/mac_mini.py new file mode 100644 index 0000000..cbeff41 --- /dev/null +++ b/backend/frontend/modules/mac_mini.py @@ -0,0 +1,808 @@ +""" +BreakPilot Studio - Mac Mini Control Module + +Funktionen: +- Fernsteuerung des Mac Mini Servers +- Status-Uebersicht (Ping, SSH, Docker, Ollama) +- Docker Container Management +- Ollama Modell-Downloads mit Fortschrittsanzeige +- Power Management (Wake-on-LAN, Restart, Shutdown) +""" + + +class MacMiniControlModule: + """Modul fuer die Mac Mini Server-Steuerung.""" + + @staticmethod + def get_css() -> str: + """CSS fuer das Mac Mini Control Modul.""" + return """ +/* ============================================= + MAC MINI CONTROL MODULE + ============================================= */ + +.panel-mac-mini { + display: flex; + flex-direction: column; + height: 100%; + background: var(--bp-bg); + overflow-y: auto; +} + +.mac-mini-header { + padding: 32px 40px 24px; + background: var(--bp-surface); + border-bottom: 1px solid var(--bp-border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.mac-mini-header h1 { + font-size: 28px; + font-weight: 700; + color: var(--bp-text); + margin: 0; + display: flex; + align-items: center; + gap: 12px; +} + +.mac-mini-status-badge { + padding: 6px 16px; + border-radius: 20px; + font-size: 14px; + font-weight: 600; +} + +.mac-mini-status-badge.online { + background: rgba(34, 197, 94, 0.2); + color: #22c55e; + border: 1px solid #22c55e; +} + +.mac-mini-status-badge.offline { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; + border: 1px solid #ef4444; +} + +.mac-mini-status-badge.checking { + background: rgba(251, 191, 36, 0.2); + color: #fbbf24; + border: 1px solid #fbbf24; +} + +.mac-mini-content { + padding: 32px 40px; + flex: 1; +} + +.mac-mini-controls { + display: flex; + gap: 12px; + margin-bottom: 24px; + flex-wrap: wrap; +} + +.mac-mini-controls .btn { + padding: 10px 20px; + border-radius: 8px; + font-weight: 600; + cursor: pointer; + border: none; + transition: all 0.2s; +} + +.mac-mini-controls .btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.2); +} + +.mac-mini-controls .btn-wake { + background: #22c55e; + color: white; +} + +.mac-mini-controls .btn-restart { + background: #f59e0b; + color: white; +} + +.mac-mini-controls .btn-shutdown { + background: #ef4444; + color: white; +} + +.mac-mini-controls .btn-refresh { + background: var(--bp-surface-elevated); + color: var(--bp-text); + border: 1px solid var(--bp-border); +} + +.mac-mini-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 20px; + margin-bottom: 24px; +} + +.mac-mini-card { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + padding: 20px; +} + +.mac-mini-card h3 { + color: var(--bp-text); + font-size: 16px; + margin: 0 0 16px 0; + display: flex; + align-items: center; + gap: 8px; +} + +.mac-mini-card-row { + display: flex; + justify-content: space-between; + padding: 10px 0; + border-bottom: 1px solid var(--bp-border); +} + +.mac-mini-card-row:last-child { + border-bottom: none; +} + +.mac-mini-card-label { + color: var(--bp-text-muted); +} + +.mac-mini-card-value { + font-weight: 500; +} + +.mac-mini-card-value.ok { + color: #22c55e; +} + +.mac-mini-card-value.error { + color: #ef4444; +} + +/* Docker Containers */ +.mac-mini-container-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.mac-mini-container-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: var(--bp-surface-elevated); + border-radius: 8px; + border: 1px solid var(--bp-border); +} + +.mac-mini-container-name { + color: var(--bp-text); + font-size: 14px; + font-family: monospace; +} + +.mac-mini-container-status { + padding: 4px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; +} + +.mac-mini-container-status.healthy { + background: rgba(34, 197, 94, 0.2); + color: #22c55e; +} + +.mac-mini-container-status.running { + background: rgba(59, 130, 246, 0.2); + color: #3b82f6; +} + +.mac-mini-container-status.stopped { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; +} + +/* Ollama Models */ +.mac-mini-model-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 20px; +} + +.mac-mini-model-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + background: var(--bp-surface-elevated); + border-radius: 8px; + border: 1px solid var(--bp-border); +} + +.mac-mini-model-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.mac-mini-model-name { + color: var(--bp-text); + font-size: 16px; + font-weight: 600; +} + +.mac-mini-model-details { + color: var(--bp-text-muted); + font-size: 13px; +} + +.mac-mini-model-size { + color: var(--bp-primary); + font-size: 14px; + font-weight: 600; +} + +/* Download Progress */ +.mac-mini-download { + margin-top: 20px; + padding-top: 20px; + border-top: 1px solid var(--bp-border); +} + +.mac-mini-download h4 { + color: var(--bp-text); + margin: 0 0 12px 0; +} + +.mac-mini-download-input { + display: flex; + gap: 12px; + margin-bottom: 16px; +} + +.mac-mini-download-input input { + flex: 1; + padding: 12px 16px; + border-radius: 8px; + border: 1px solid var(--bp-border); + background: var(--bp-surface-elevated); + color: var(--bp-text); + font-size: 14px; +} + +.mac-mini-download-input input:focus { + outline: none; + border-color: var(--bp-primary); +} + +.mac-mini-progress { + display: none; +} + +.mac-mini-progress.active { + display: block; +} + +.mac-mini-progress-header { + display: flex; + justify-content: space-between; + margin-bottom: 8px; +} + +.mac-mini-progress-name { + color: var(--bp-text); + font-weight: 600; +} + +.mac-mini-progress-stats { + color: var(--bp-text-muted); + font-size: 13px; +} + +.mac-mini-progress-bar-container { + height: 24px; + background: var(--bp-surface-elevated); + border-radius: 12px; + overflow: hidden; + position: relative; +} + +.mac-mini-progress-bar { + height: 100%; + background: linear-gradient(90deg, var(--bp-primary), #991b1b); + border-radius: 12px; + transition: width 0.3s ease; + width: 0%; +} + +.mac-mini-progress-text { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: white; + font-size: 12px; + font-weight: 600; + text-shadow: 0 1px 2px rgba(0,0,0,0.5); +} + +.mac-mini-progress-log { + margin-top: 16px; + padding: 16px; + background: #0a0a0a; + border-radius: 8px; + font-family: monospace; + font-size: 12px; + color: #22c55e; + max-height: 200px; + overflow-y: auto; + white-space: pre-wrap; +} + +/* Docker Controls */ +.mac-mini-docker-controls { + margin-top: 16px; + display: flex; + gap: 8px; +} + +.mac-mini-docker-controls .btn { + flex: 1; + padding: 10px 16px; + border-radius: 8px; + font-weight: 500; + cursor: pointer; + background: var(--bp-surface-elevated); + color: var(--bp-text); + border: 1px solid var(--bp-border); + transition: all 0.2s; +} + +.mac-mini-docker-controls .btn:hover { + background: var(--bp-surface); + border-color: var(--bp-primary); +} +""" + + @staticmethod + def get_html() -> str: + """HTML fuer das Mac Mini Control Modul.""" + return """ + + +""" + + @staticmethod + def get_js() -> str: + """JavaScript fuer das Mac Mini Control Modul.""" + return """ +// ============================================= +// MAC MINI CONTROL MODULE +// ============================================= + +let macMiniModuleState = { + ip: '192.168.178.100', + isOnline: false, + downloadInProgress: false, + pollInterval: null +}; + +function loadMacMiniModule() { + console.log('Loading Mac Mini Control Module...'); + macMiniRefresh(); + startMacMiniPolling(); +} + +function unloadMacMiniModule() { + stopMacMiniPolling(); +} + +function startMacMiniPolling() { + stopMacMiniPolling(); + macMiniModuleState.pollInterval = setInterval(macMiniRefresh, 30000); +} + +function stopMacMiniPolling() { + if (macMiniModuleState.pollInterval) { + clearInterval(macMiniModuleState.pollInterval); + macMiniModuleState.pollInterval = null; + } +} + +async function macMiniRefresh() { + const statusBadge = document.getElementById('mac-mini-status-badge'); + if (!statusBadge) return; + + statusBadge.className = 'mac-mini-status-badge checking'; + statusBadge.textContent = 'Prüfe...'; + + try { + const response = await fetch('/api/mac-mini/status'); + const data = await response.json(); + + macMiniModuleState.isOnline = data.online; + macMiniModuleState.ip = data.ip || macMiniModuleState.ip; + + // Update status badge + if (data.online) { + statusBadge.className = 'mac-mini-status-badge online'; + statusBadge.textContent = 'Online'; + } else { + statusBadge.className = 'mac-mini-status-badge offline'; + statusBadge.textContent = 'Offline'; + } + + // Update IP + setMacMiniValue('mac-mini-ip', macMiniModuleState.ip); + + // Update connection + setMacMiniStatus('mac-mini-ssh', data.ssh ? 'Verbunden' : 'Nicht verfügbar', data.ssh); + setMacMiniStatus('mac-mini-ping', data.ping ? 'OK' : 'Timeout', data.ping); + + // Update services + setMacMiniStatus('mac-mini-backend', data.backend ? 'Läuft' : 'Offline', data.backend); + setMacMiniStatus('mac-mini-ollama', data.ollama ? 'Läuft' : 'Offline', data.ollama); + setMacMiniStatus('mac-mini-docker', data.docker ? 'Läuft' : 'Offline', data.docker); + + // Update system + setMacMiniValue('mac-mini-uptime', data.uptime || '--'); + setMacMiniValue('mac-mini-cpu', data.cpu_load || '--'); + setMacMiniValue('mac-mini-memory', data.memory || '--'); + + // Update containers + renderMacMiniContainers(data.containers || []); + + // Update models + renderMacMiniModels(data.models || []); + + // Enable/disable buttons + const btnWake = document.getElementById('btn-mac-mini-wake'); + const btnRestart = document.getElementById('btn-mac-mini-restart'); + const btnShutdown = document.getElementById('btn-mac-mini-shutdown'); + if (btnWake) btnWake.disabled = data.online; + if (btnRestart) btnRestart.disabled = !data.online; + if (btnShutdown) btnShutdown.disabled = !data.online; + + } catch (error) { + console.error('Mac Mini status error:', error); + statusBadge.className = 'mac-mini-status-badge offline'; + statusBadge.textContent = 'Fehler'; + } +} + +function setMacMiniValue(id, value) { + const el = document.getElementById(id); + if (el) el.textContent = value; +} + +function setMacMiniStatus(id, text, isOk) { + const el = document.getElementById(id); + if (el) { + el.textContent = text; + el.className = 'mac-mini-card-value ' + (isOk ? 'ok' : 'error'); + } +} + +function renderMacMiniContainers(containers) { + const list = document.getElementById('mac-mini-containers'); + if (!list) return; + + if (containers.length === 0) { + list.innerHTML = '
    Keine Container gefunden
    '; + return; + } + + list.innerHTML = containers.map(c => { + const isHealthy = c.status.includes('healthy'); + const isRunning = c.status.includes('Up'); + const statusClass = isHealthy ? 'healthy' : (isRunning ? 'running' : 'stopped'); + return ` +
    + ${c.name} + ${c.status} +
    + `; + }).join(''); +} + +function renderMacMiniModels(models) { + const list = document.getElementById('mac-mini-models'); + if (!list) return; + + if (models.length === 0) { + list.innerHTML = '
    Keine Modelle installiert
    '; + return; + } + + list.innerHTML = models.map(m => { + const sizeGB = (m.size / (1024 * 1024 * 1024)).toFixed(1); + const details = m.details || {}; + return ` +
    +
    + ${m.name} + ${details.parameter_size || ''} | ${details.quantization_level || ''} +
    + ${sizeGB} GB +
    + `; + }).join(''); +} + +// Power Controls +async function macMiniWake() { + if (!confirm('Mac Mini per Wake-on-LAN aufwecken?')) return; + try { + const response = await fetch('/api/mac-mini/wake', { method: 'POST' }); + const data = await response.json(); + alert(data.message || 'Wake-on-LAN Paket gesendet'); + setTimeout(macMiniRefresh, 5000); + } catch (error) { + alert('Fehler: ' + error.message); + } +} + +async function macMiniRestart() { + if (!confirm('Mac Mini wirklich neu starten?')) return; + try { + const response = await fetch('/api/mac-mini/restart', { method: 'POST' }); + const data = await response.json(); + alert(data.message || 'Neustart ausgelöst'); + setTimeout(macMiniRefresh, 60000); + } catch (error) { + alert('Fehler: ' + error.message); + } +} + +async function macMiniShutdown() { + if (!confirm('Mac Mini wirklich herunterfahren?')) return; + try { + const response = await fetch('/api/mac-mini/shutdown', { method: 'POST' }); + const data = await response.json(); + alert(data.message || 'Shutdown ausgelöst'); + setTimeout(macMiniRefresh, 10000); + } catch (error) { + alert('Fehler: ' + error.message); + } +} + +// Docker Controls +async function macMiniDockerUp() { + try { + const response = await fetch('/api/mac-mini/docker/up', { method: 'POST' }); + const data = await response.json(); + alert(data.message || 'Container werden gestartet...'); + setTimeout(macMiniRefresh, 10000); + } catch (error) { + alert('Fehler: ' + error.message); + } +} + +async function macMiniDockerDown() { + if (!confirm('Alle Container stoppen?')) return; + try { + const response = await fetch('/api/mac-mini/docker/down', { method: 'POST' }); + const data = await response.json(); + alert(data.message || 'Container werden gestoppt...'); + setTimeout(macMiniRefresh, 5000); + } catch (error) { + alert('Fehler: ' + error.message); + } +} + +// Ollama Model Download +async function macMiniPullModel() { + const input = document.getElementById('mac-mini-model-input'); + const modelName = input ? input.value.trim() : ''; + + if (!modelName) { + alert('Bitte Modellnamen eingeben'); + return; + } + + if (macMiniModuleState.downloadInProgress) { + alert('Download läuft bereits'); + return; + } + + macMiniModuleState.downloadInProgress = true; + const btnPull = document.getElementById('btn-mac-mini-pull'); + if (btnPull) btnPull.disabled = true; + + const progressDiv = document.getElementById('mac-mini-progress'); + const progressBar = document.getElementById('mac-mini-progress-bar'); + const progressText = document.getElementById('mac-mini-progress-text'); + const progressStats = document.getElementById('mac-mini-progress-stats'); + const progressLog = document.getElementById('mac-mini-progress-log'); + const progressName = document.getElementById('mac-mini-progress-name'); + + if (progressDiv) progressDiv.classList.add('active'); + if (progressName) progressName.textContent = modelName; + if (progressLog) progressLog.textContent = 'Starte Download...\\n'; + + try { + const response = await fetch('/api/mac-mini/ollama/pull', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: modelName }) + }); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const text = decoder.decode(value); + const lines = text.split('\\n').filter(l => l.trim()); + + for (const line of lines) { + try { + const data = JSON.parse(line); + + if (data.status && progressLog) { + progressLog.textContent += data.status + '\\n'; + progressLog.scrollTop = progressLog.scrollHeight; + } + + if (data.total && data.completed) { + const percent = Math.round((data.completed / data.total) * 100); + const completedMB = (data.completed / (1024 * 1024)).toFixed(1); + const totalMB = (data.total / (1024 * 1024)).toFixed(1); + + if (progressBar) progressBar.style.width = percent + '%'; + if (progressText) progressText.textContent = percent + '%'; + if (progressStats) progressStats.textContent = completedMB + ' MB / ' + totalMB + ' MB'; + } + + if (data.status === 'success' && progressLog) { + progressLog.textContent += '\\n✅ Download abgeschlossen!\\n'; + if (progressBar) progressBar.style.width = '100%'; + if (progressText) progressText.textContent = '100%'; + } + } catch (e) { + if (progressLog) progressLog.textContent += line + '\\n'; + } + } + } + + setTimeout(macMiniRefresh, 2000); + + } catch (error) { + if (progressLog) progressLog.textContent += '\\n❌ Fehler: ' + error.message + '\\n'; + } finally { + macMiniModuleState.downloadInProgress = false; + if (btnPull) btnPull.disabled = false; + } +} +""" diff --git a/backend/frontend/modules/mac_mini_control.py b/backend/frontend/modules/mac_mini_control.py new file mode 100644 index 0000000..c96372b --- /dev/null +++ b/backend/frontend/modules/mac_mini_control.py @@ -0,0 +1,876 @@ +""" +Mac Mini Remote Control Module for BreakPilot Admin Panel. + +Features: +- Power control (shutdown, restart, wake-on-LAN) +- Service status monitoring +- Docker container management +- Ollama model downloads with progress +""" + + +class MacMiniControlModule: + """Mac Mini Remote Control Panel.""" + + MAC_MINI_IP = "192.168.178.100" + MAC_MINI_USER = "benjaminadmin" + + @staticmethod + def get_css() -> str: + return """ +/* ============================================ + Mac Mini Control Panel + ============================================ */ + +.mac-mini-dashboard { + padding: 24px; + max-width: 1400px; + margin: 0 auto; +} + +.mac-mini-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 24px; +} + +.mac-mini-header h1 { + color: var(--bp-text, #e5e7eb); + font-size: 28px; + margin: 0; + display: flex; + align-items: center; + gap: 12px; +} + +.mac-mini-status-badge { + padding: 6px 16px; + border-radius: 20px; + font-size: 14px; + font-weight: 600; +} + +.mac-mini-status-badge.online { + background: rgba(34, 197, 94, 0.2); + color: #22c55e; + border: 1px solid #22c55e; +} + +.mac-mini-status-badge.offline { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; + border: 1px solid #ef4444; +} + +.mac-mini-status-badge.checking { + background: rgba(251, 191, 36, 0.2); + color: #fbbf24; + border: 1px solid #fbbf24; +} + +/* Power Controls */ +.power-controls { + display: flex; + gap: 12px; + margin-bottom: 24px; +} + +.power-btn { + padding: 12px 24px; + border-radius: 8px; + border: none; + font-size: 14px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s; +} + +.power-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.power-btn.wake { + background: #22c55e; + color: white; +} + +.power-btn.wake:hover:not(:disabled) { + background: #16a34a; +} + +.power-btn.restart { + background: #f59e0b; + color: white; +} + +.power-btn.restart:hover:not(:disabled) { + background: #d97706; +} + +.power-btn.shutdown { + background: #ef4444; + color: white; +} + +.power-btn.shutdown:hover:not(:disabled) { + background: #dc2626; +} + +.power-btn.refresh { + background: var(--bp-surface, #1e293b); + color: var(--bp-text, #e5e7eb); + border: 1px solid var(--bp-border, #334155); +} + +.power-btn.refresh:hover:not(:disabled) { + background: var(--bp-surface-elevated, #334155); +} + +/* Status Grid */ +.status-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.status-card { + background: var(--bp-surface-elevated, #1e293b); + border: 1px solid var(--bp-border, #334155); + border-radius: 12px; + padding: 20px; +} + +.status-card h3 { + color: var(--bp-text, #e5e7eb); + font-size: 16px; + margin: 0 0 16px 0; + display: flex; + align-items: center; + gap: 8px; +} + +.status-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 0; + border-bottom: 1px solid var(--bp-border, #334155); +} + +.status-item:last-child { + border-bottom: none; +} + +.status-item-name { + color: var(--bp-text-muted, #9ca3af); + font-size: 14px; +} + +.status-item-value { + font-size: 14px; + font-weight: 500; +} + +.status-item-value.ok { + color: #22c55e; +} + +.status-item-value.error { + color: #ef4444; +} + +.status-item-value.warning { + color: #fbbf24; +} + +/* Docker Containers */ +.container-list { + display: flex; + flex-direction: column; + gap: 8px; +} + +.container-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: var(--bp-surface, #0f172a); + border-radius: 8px; + border: 1px solid var(--bp-border, #334155); +} + +.container-name { + color: var(--bp-text, #e5e7eb); + font-size: 14px; + font-family: monospace; +} + +.container-status { + padding: 4px 12px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; +} + +.container-status.healthy { + background: rgba(34, 197, 94, 0.2); + color: #22c55e; +} + +.container-status.running { + background: rgba(59, 130, 246, 0.2); + color: #3b82f6; +} + +.container-status.stopped { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; +} + +/* Ollama Section */ +.ollama-section { + background: var(--bp-surface-elevated, #1e293b); + border: 1px solid var(--bp-border, #334155); + border-radius: 12px; + padding: 20px; + margin-bottom: 24px; +} + +.ollama-section h3 { + color: var(--bp-text, #e5e7eb); + font-size: 18px; + margin: 0 0 16px 0; + display: flex; + align-items: center; + gap: 8px; +} + +.model-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 20px; +} + +.model-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + background: var(--bp-surface, #0f172a); + border-radius: 8px; + border: 1px solid var(--bp-border, #334155); +} + +.model-info { + display: flex; + flex-direction: column; + gap: 4px; +} + +.model-name { + color: var(--bp-text, #e5e7eb); + font-size: 16px; + font-weight: 600; +} + +.model-details { + color: var(--bp-text-muted, #9ca3af); + font-size: 13px; +} + +.model-size { + color: var(--bp-primary, #6C1B1B); + font-size: 14px; + font-weight: 600; +} + +/* Download Section */ +.download-section { + margin-top: 20px; + padding-top: 20px; + border-top: 1px solid var(--bp-border, #334155); +} + +.download-input-row { + display: flex; + gap: 12px; + margin-bottom: 16px; +} + +.download-input { + flex: 1; + padding: 12px 16px; + background: var(--bp-surface, #0f172a); + border: 1px solid var(--bp-border, #334155); + border-radius: 8px; + color: var(--bp-text, #e5e7eb); + font-size: 14px; +} + +.download-input::placeholder { + color: var(--bp-text-muted, #9ca3af); +} + +.download-btn { + padding: 12px 24px; + background: var(--bp-primary, #6C1B1B); + color: white; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.download-btn:hover:not(:disabled) { + background: #7f1d1d; +} + +.download-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Download Progress */ +.download-progress { + display: none; + margin-top: 16px; +} + +.download-progress.active { + display: block; +} + +.progress-header { + display: flex; + justify-content: space-between; + margin-bottom: 8px; +} + +.progress-model { + color: var(--bp-text, #e5e7eb); + font-weight: 600; +} + +.progress-stats { + color: var(--bp-text-muted, #9ca3af); + font-size: 13px; +} + +.progress-bar-container { + height: 24px; + background: var(--bp-surface, #0f172a); + border-radius: 12px; + overflow: hidden; + position: relative; +} + +.progress-bar-fill { + height: 100%; + background: linear-gradient(90deg, var(--bp-primary, #6C1B1B), #991b1b); + border-radius: 12px; + transition: width 0.3s ease; + position: relative; +} + +.progress-bar-fill::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient( + 90deg, + transparent, + rgba(255,255,255,0.1), + transparent + ); + animation: shimmer 1.5s infinite; +} + +@keyframes shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} + +.progress-text { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: white; + font-size: 12px; + font-weight: 600; + text-shadow: 0 1px 2px rgba(0,0,0,0.5); +} + +/* Log Output */ +.log-output { + margin-top: 16px; + padding: 16px; + background: #0a0a0a; + border-radius: 8px; + font-family: monospace; + font-size: 12px; + color: #22c55e; + max-height: 200px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-all; +} + +/* Responsive */ +@media (max-width: 768px) { + .power-controls { + flex-wrap: wrap; + } + + .power-btn { + flex: 1; + min-width: 120px; + justify-content: center; + } +} +""" + + @staticmethod + def get_html() -> str: + return """ +
    + +
    +

    + 🖥️ + Mac Mini Control +

    + + Prüfe... + +
    + + +
    + + + + +
    + + +
    + +
    +

    🌐 Verbindung

    +
    + IP-Adresse + 192.168.178.163 +
    +
    + SSH + -- +
    +
    + Ping + -- +
    +
    + + +
    +

    ⚙️ Services

    +
    + Backend API + -- +
    +
    + Ollama + -- +
    +
    + Docker + -- +
    +
    + + +
    +

    💻 System

    +
    + Uptime + -- +
    +
    + CPU Load + -- +
    +
    + Memory + -- +
    +
    +
    + + +
    +

    🐳 Docker Container

    +
    +
    + Lade Container-Status... +
    +
    +
    + + +
    +
    + + +
    +

    🤖 Ollama LLM Modelle

    + +
    +
    + Lade Modelle... +
    +
    + +
    +

    📥 Neues Modell herunterladen

    +
    + + +
    + +
    +
    + -- + -- / -- +
    +
    +
    + 0% +
    +
    +
    +
    +
    +
    +""" + + @staticmethod + def get_js() -> str: + return """ +// Mac Mini Control State +let macMiniState = { + ip: '192.168.178.163', + isOnline: false, + downloadInProgress: false, + pollInterval: null +}; + +// Initialize Mac Mini Control +function initMacMiniControl() { + macMiniRefreshStatus(); + // Auto-refresh every 30 seconds + macMiniState.pollInterval = setInterval(macMiniRefreshStatus, 30000); +} + +// Refresh all status +async function macMiniRefreshStatus() { + const statusBadge = document.getElementById('mac-mini-overall-status'); + statusBadge.className = 'mac-mini-status-badge checking'; + statusBadge.textContent = 'Prüfe...'; + + try { + const response = await fetch('/api/mac-mini/status'); + const data = await response.json(); + + macMiniState.isOnline = data.online; + macMiniState.ip = data.ip || macMiniState.ip; + + // Update overall status + if (data.online) { + statusBadge.className = 'mac-mini-status-badge online'; + statusBadge.textContent = 'Online'; + } else { + statusBadge.className = 'mac-mini-status-badge offline'; + statusBadge.textContent = 'Offline'; + } + + // Update IP + document.getElementById('mac-mini-ip').textContent = macMiniState.ip; + + // Update connection status + updateStatusValue('status-ssh', data.ssh ? 'Verbunden' : 'Nicht verfügbar', data.ssh); + updateStatusValue('status-ping', data.ping ? 'OK' : 'Timeout', data.ping); + + // Update services + updateStatusValue('status-backend', data.backend ? 'Läuft' : 'Offline', data.backend); + updateStatusValue('status-ollama', data.ollama ? 'Läuft' : 'Offline', data.ollama); + updateStatusValue('status-docker', data.docker ? 'Läuft' : 'Offline', data.docker); + + // Update system info + document.getElementById('status-uptime').textContent = data.uptime || '--'; + document.getElementById('status-cpu').textContent = data.cpu_load || '--'; + document.getElementById('status-memory').textContent = data.memory || '--'; + + // Update Docker containers + updateDockerContainers(data.containers || []); + + // Update Ollama models + updateOllamaModels(data.models || []); + + // Enable/disable buttons based on status + document.getElementById('btn-wake').disabled = data.online; + document.getElementById('btn-restart').disabled = !data.online; + document.getElementById('btn-shutdown').disabled = !data.online; + + } catch (error) { + console.error('Error fetching Mac Mini status:', error); + statusBadge.className = 'mac-mini-status-badge offline'; + statusBadge.textContent = 'Fehler'; + } +} + +function updateStatusValue(elementId, text, isOk) { + const el = document.getElementById(elementId); + el.textContent = text; + el.className = 'status-item-value ' + (isOk ? 'ok' : 'error'); +} + +function updateDockerContainers(containers) { + const list = document.getElementById('docker-container-list'); + + if (containers.length === 0) { + list.innerHTML = '
    Keine Container gefunden
    '; + return; + } + + list.innerHTML = containers.map(c => { + const statusClass = c.status.includes('healthy') ? 'healthy' : + c.status.includes('Up') ? 'running' : 'stopped'; + return ` +
    + ${c.name} + ${c.status} +
    + `; + }).join(''); +} + +function updateOllamaModels(models) { + const list = document.getElementById('ollama-model-list'); + + if (models.length === 0) { + list.innerHTML = '
    Keine Modelle installiert
    '; + return; + } + + list.innerHTML = models.map(m => { + const sizeGB = (m.size / (1024 * 1024 * 1024)).toFixed(1); + return ` +
    +
    + ${m.name} + ${m.details?.parameter_size || ''} | ${m.details?.quantization_level || ''} +
    + ${sizeGB} GB +
    + `; + }).join(''); +} + +// Power Controls +async function macMiniWake() { + if (!confirm('Mac Mini per Wake-on-LAN aufwecken?')) return; + + try { + const response = await fetch('/api/mac-mini/wake', { method: 'POST' }); + const data = await response.json(); + alert(data.message || 'Wake-on-LAN Paket gesendet'); + setTimeout(macMiniRefreshStatus, 5000); + } catch (error) { + alert('Fehler: ' + error.message); + } +} + +async function macMiniRestart() { + if (!confirm('Mac Mini wirklich neu starten?')) return; + + try { + const response = await fetch('/api/mac-mini/restart', { method: 'POST' }); + const data = await response.json(); + alert(data.message || 'Neustart ausgelöst'); + setTimeout(macMiniRefreshStatus, 60000); + } catch (error) { + alert('Fehler: ' + error.message); + } +} + +async function macMiniShutdown() { + if (!confirm('Mac Mini wirklich herunterfahren?')) return; + + try { + const response = await fetch('/api/mac-mini/shutdown', { method: 'POST' }); + const data = await response.json(); + alert(data.message || 'Shutdown ausgelöst'); + setTimeout(macMiniRefreshStatus, 10000); + } catch (error) { + alert('Fehler: ' + error.message); + } +} + +// Docker Controls +async function macMiniDockerUp() { + try { + const response = await fetch('/api/mac-mini/docker/up', { method: 'POST' }); + const data = await response.json(); + alert(data.message || 'Container werden gestartet...'); + setTimeout(macMiniRefreshStatus, 10000); + } catch (error) { + alert('Fehler: ' + error.message); + } +} + +async function macMiniDockerDown() { + if (!confirm('Alle Container stoppen?')) return; + + try { + const response = await fetch('/api/mac-mini/docker/down', { method: 'POST' }); + const data = await response.json(); + alert(data.message || 'Container werden gestoppt...'); + setTimeout(macMiniRefreshStatus, 5000); + } catch (error) { + alert('Fehler: ' + error.message); + } +} + +// Ollama Model Download +async function macMiniPullModel() { + const input = document.getElementById('model-download-input'); + const modelName = input.value.trim(); + + if (!modelName) { + alert('Bitte Modellnamen eingeben'); + return; + } + + if (macMiniState.downloadInProgress) { + alert('Download läuft bereits'); + return; + } + + macMiniState.downloadInProgress = true; + document.getElementById('btn-pull-model').disabled = true; + + const progressDiv = document.getElementById('download-progress'); + const progressBar = document.getElementById('download-progress-bar'); + const progressText = document.getElementById('download-progress-text'); + const downloadStats = document.getElementById('download-stats'); + const downloadLog = document.getElementById('download-log'); + const modelNameEl = document.getElementById('download-model-name'); + + progressDiv.classList.add('active'); + modelNameEl.textContent = modelName; + downloadLog.textContent = 'Starte Download...\\n'; + + try { + const response = await fetch('/api/mac-mini/ollama/pull', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: modelName }) + }); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const text = decoder.decode(value); + const lines = text.split('\\n').filter(l => l.trim()); + + for (const line of lines) { + try { + const data = JSON.parse(line); + + if (data.status) { + downloadLog.textContent += data.status + '\\n'; + downloadLog.scrollTop = downloadLog.scrollHeight; + } + + if (data.total && data.completed) { + const percent = Math.round((data.completed / data.total) * 100); + const completedMB = (data.completed / (1024 * 1024)).toFixed(1); + const totalMB = (data.total / (1024 * 1024)).toFixed(1); + + progressBar.style.width = percent + '%'; + progressText.textContent = percent + '%'; + downloadStats.textContent = `${completedMB} MB / ${totalMB} MB`; + } + + if (data.status === 'success') { + downloadLog.textContent += '\\n✅ Download abgeschlossen!\\n'; + progressBar.style.width = '100%'; + progressText.textContent = '100%'; + } + } catch (e) { + // Not JSON, just log it + downloadLog.textContent += line + '\\n'; + } + } + } + + // Refresh models list + setTimeout(macMiniRefreshStatus, 2000); + + } catch (error) { + downloadLog.textContent += '\\n❌ Fehler: ' + error.message + '\\n'; + } finally { + macMiniState.downloadInProgress = false; + document.getElementById('btn-pull-model').disabled = false; + } +} + +// Cleanup on panel hide +function cleanupMacMiniControl() { + if (macMiniState.pollInterval) { + clearInterval(macMiniState.pollInterval); + macMiniState.pollInterval = null; + } +} +""" + + @classmethod + def render(cls) -> str: + return f""" + +{cls.get_html()} + +""" diff --git a/backend/frontend/modules/mail_inbox.py b/backend/frontend/modules/mail_inbox.py new file mode 100644 index 0000000..9557463 --- /dev/null +++ b/backend/frontend/modules/mail_inbox.py @@ -0,0 +1,1998 @@ +""" +BreakPilot Studio - Unified Inbox Mail Modul + +Dieses Modul bietet: +- Setup-Wizard fuer E-Mail-Konten (7 Schritte, extrem benutzerfreundlich) +- Kontoverwaltung (hinzufuegen, bearbeiten, loeschen) +- Passwort-Tresor (sichere Verwaltung der Zugangsdaten) +- Unified Inbox (alle E-Mails an einem Ort) + +Zielgruppe: Lehrkraefte ohne technische Vorkenntnisse +Design-Prinzip: Ein Feld pro Bildschirm, immer erklaeren WARUM +""" + + +class MailInboxModule: + """Unified Inbox Modul mit benutzerfreundlichem Setup-Wizard.""" + + @staticmethod + def get_css() -> str: + """CSS fuer das Mail-Inbox-Modul.""" + return """ +/* ========================================== + MAIL INBOX MODULE STYLES + ========================================== */ + +/* Panel Layout */ +.panel-mail { + display: none; + flex-direction: column; + height: 100%; + background: var(--bp-bg); + overflow: hidden; +} + +.panel-mail.active { + display: flex; +} + +/* Mail Header */ +.mail-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 32px; + border-bottom: 1px solid var(--bp-border); + background: var(--bp-surface); +} + +.mail-title-section h1 { + font-size: 24px; + font-weight: 700; + color: var(--bp-text); + margin-bottom: 4px; +} + +.mail-subtitle { + font-size: 14px; + color: var(--bp-text-muted); +} + +.mail-header-actions { + display: flex; + gap: 12px; +} + +/* Mail Content */ +.mail-content { + flex: 1; + overflow-y: auto; + padding: 32px; +} + +/* Empty State - Kein Konto eingerichtet */ +.mail-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 400px; + text-align: center; + padding: 40px; +} + +.mail-empty-icon { + font-size: 80px; + margin-bottom: 24px; + opacity: 0.6; +} + +.mail-empty-title { + font-size: 24px; + font-weight: 700; + color: var(--bp-text); + margin-bottom: 12px; +} + +.mail-empty-description { + font-size: 16px; + color: var(--bp-text-muted); + max-width: 480px; + line-height: 1.6; + margin-bottom: 32px; +} + +/* ========================================== + MAIL WIZARD MODAL + ========================================== */ + +.mail-wizard-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + z-index: 10000; + justify-content: center; + align-items: center; + backdrop-filter: blur(4px); +} + +.mail-wizard-modal.active { + display: flex; +} + +.mail-wizard-content { + background: var(--bp-surface); + border-radius: 24px; + width: 95%; + max-width: 560px; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 25px 80px rgba(0,0,0,0.5); + border: 1px solid var(--bp-border); + overflow: hidden; + animation: wizardSlideIn 0.3s ease; +} + +@keyframes wizardSlideIn { + from { + opacity: 0; + transform: translateY(30px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +/* Wizard Header */ +.wizard-header { + padding: 24px 32px 20px; + border-bottom: 1px solid var(--bp-border); + background: var(--bp-surface-elevated); +} + +.wizard-close-btn { + position: absolute; + top: 16px; + right: 16px; + background: none; + border: none; + color: var(--bp-text-muted); + font-size: 24px; + cursor: pointer; + padding: 8px; + border-radius: 8px; + transition: all 0.2s; +} + +.wizard-close-btn:hover { + background: var(--bp-surface); + color: var(--bp-text); +} + +/* Step Indicator */ +.wizard-steps { + display: flex; + justify-content: center; + gap: 8px; + margin-bottom: 16px; +} + +.wizard-step-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--bp-border); + transition: all 0.3s ease; +} + +.wizard-step-dot.active { + background: var(--bp-primary); + transform: scale(1.3); + box-shadow: 0 0 0 4px var(--bp-primary-soft); +} + +.wizard-step-dot.completed { + background: var(--bp-success); +} + +.wizard-step-label { + text-align: center; + font-size: 12px; + color: var(--bp-text-muted); + margin-top: 8px; +} + +/* Wizard Body */ +.wizard-body { + flex: 1; + overflow-y: auto; + padding: 32px; +} + +/* Wizard Step Content */ +.wizard-step { + display: none; +} + +.wizard-step.active { + display: block; + animation: stepFadeIn 0.3s ease; +} + +@keyframes stepFadeIn { + from { opacity: 0; transform: translateX(20px); } + to { opacity: 1; transform: translateX(0); } +} + +.wizard-step-title { + font-size: 22px; + font-weight: 700; + color: var(--bp-text); + margin-bottom: 12px; + text-align: center; +} + +.wizard-step-description { + font-size: 15px; + color: var(--bp-text-muted); + text-align: center; + line-height: 1.6; + margin-bottom: 32px; +} + +/* Welcome Step Illustration */ +.wizard-illustration { + text-align: center; + margin-bottom: 24px; +} + +.wizard-illustration-icon { + font-size: 72px; + margin-bottom: 16px; +} + +/* Info Box (Warum brauchen wir das?) */ +.wizard-info-box { + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border); + border-radius: 12px; + padding: 16px 20px; + margin-top: 24px; +} + +.wizard-info-box-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + font-weight: 600; + color: var(--bp-text); + font-size: 14px; +} + +.wizard-info-box-icon { + font-size: 18px; +} + +.wizard-info-box-content { + font-size: 13px; + color: var(--bp-text-muted); + line-height: 1.6; +} + +/* Security Info Box */ +.wizard-info-box.security { + border-color: var(--bp-success); + background: rgba(34, 197, 94, 0.05); +} + +.wizard-info-box.security .wizard-info-box-header { + color: var(--bp-success); +} + +/* Warning Info Box */ +.wizard-info-box.warning { + border-color: var(--bp-warning); + background: rgba(245, 158, 11, 0.05); +} + +.wizard-info-box.warning .wizard-info-box-header { + color: var(--bp-warning); +} + +/* Form Fields */ +.wizard-field { + margin-bottom: 20px; +} + +.wizard-field-label { + display: block; + font-size: 14px; + font-weight: 600; + color: var(--bp-text); + margin-bottom: 8px; +} + +.wizard-input-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.wizard-input-icon { + position: absolute; + left: 16px; + font-size: 20px; + color: var(--bp-text-muted); +} + +.wizard-input { + width: 100%; + padding: 16px 16px 16px 52px; + background: var(--bp-bg); + border: 2px solid var(--bp-border); + border-radius: 12px; + font-size: 16px; + color: var(--bp-text); + transition: all 0.2s; +} + +.wizard-input:focus { + outline: none; + border-color: var(--bp-primary); + box-shadow: 0 0 0 4px var(--bp-primary-soft); +} + +.wizard-input::placeholder { + color: var(--bp-text-muted); +} + +.wizard-input-hint { + font-size: 12px; + color: var(--bp-text-muted); + margin-top: 8px; +} + +/* Password Toggle */ +.wizard-password-toggle { + position: absolute; + right: 16px; + background: none; + border: none; + color: var(--bp-text-muted); + cursor: pointer; + padding: 8px; + font-size: 18px; +} + +.wizard-password-toggle:hover { + color: var(--bp-text); +} + +/* Provider Grid */ +.provider-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-bottom: 24px; +} + +.provider-card { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px 12px; + background: var(--bp-bg); + border: 2px solid var(--bp-border); + border-radius: 12px; + cursor: pointer; + transition: all 0.2s; +} + +.provider-card:hover { + border-color: var(--bp-primary); + background: var(--bp-primary-soft); +} + +.provider-card.selected { + border-color: var(--bp-primary); + background: var(--bp-primary-soft); + box-shadow: 0 0 0 4px var(--bp-primary-soft); +} + +.provider-card-icon { + font-size: 32px; + margin-bottom: 8px; +} + +.provider-card-name { + font-size: 12px; + font-weight: 600; + color: var(--bp-text); + text-align: center; +} + +/* Provider Detected Banner */ +.provider-detected { + display: flex; + align-items: center; + gap: 16px; + padding: 20px; + background: rgba(34, 197, 94, 0.1); + border: 2px solid var(--bp-success); + border-radius: 12px; + margin-bottom: 24px; +} + +.provider-detected-icon { + font-size: 48px; +} + +.provider-detected-info h3 { + font-size: 18px; + font-weight: 700; + color: var(--bp-text); + margin-bottom: 4px; +} + +.provider-detected-info p { + font-size: 13px; + color: var(--bp-text-muted); +} + +.provider-detected-checkmarks { + margin-top: 8px; +} + +.provider-detected-checkmarks span { + display: block; + font-size: 13px; + color: var(--bp-success); + margin-bottom: 4px; +} + +/* Quick Select Buttons */ +.quick-select-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +} + +.quick-select-btn { + padding: 10px 16px; + background: var(--bp-bg); + border: 2px solid var(--bp-border); + border-radius: 20px; + font-size: 13px; + font-weight: 500; + color: var(--bp-text); + cursor: pointer; + transition: all 0.2s; +} + +.quick-select-btn:hover { + border-color: var(--bp-primary); + background: var(--bp-primary-soft); +} + +.quick-select-btn.selected { + border-color: var(--bp-primary); + background: var(--bp-primary); + color: white; +} + +/* Connection Test */ +.connection-test { + padding: 24px; + background: var(--bp-bg); + border-radius: 12px; + text-align: center; +} + +.connection-test-animation { + font-size: 64px; + margin-bottom: 16px; + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.1); opacity: 0.7; } +} + +.connection-test-status { + margin-top: 24px; +} + +.connection-test-item { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 12px; + font-size: 14px; + color: var(--bp-text-muted); +} + +.connection-test-item.success { + color: var(--bp-success); +} + +.connection-test-item.error { + color: var(--bp-danger); +} + +.connection-test-item.pending { + color: var(--bp-text-muted); +} + +/* Success Screen */ +.wizard-success { + text-align: center; + padding: 20px; +} + +.wizard-success-icon { + font-size: 80px; + margin-bottom: 24px; +} + +.wizard-success-title { + font-size: 24px; + font-weight: 700; + color: var(--bp-text); + margin-bottom: 16px; +} + +.wizard-success-card { + background: var(--bp-bg); + border: 2px solid var(--bp-success); + border-radius: 12px; + padding: 20px; + margin: 24px 0; + text-align: left; +} + +.wizard-success-card-header { + display: flex; + align-items: center; + gap: 12px; +} + +.wizard-success-card-icon { + font-size: 32px; +} + +.wizard-success-card-name { + font-size: 16px; + font-weight: 600; + color: var(--bp-text); +} + +.wizard-success-card-email { + font-size: 13px; + color: var(--bp-text-muted); +} + +.wizard-success-card-stats { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--bp-border); + font-size: 12px; + color: var(--bp-text-muted); +} + +/* Wizard Footer */ +.wizard-footer { + display: flex; + justify-content: space-between; + padding: 20px 32px; + border-top: 1px solid var(--bp-border); + background: var(--bp-surface); +} + +.wizard-btn { + padding: 14px 28px; + border-radius: 12px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 8px; +} + +.wizard-btn-back { + background: transparent; + border: 2px solid var(--bp-border); + color: var(--bp-text-muted); +} + +.wizard-btn-back:hover { + border-color: var(--bp-text-muted); + color: var(--bp-text); +} + +.wizard-btn-next { + background: var(--bp-primary); + border: none; + color: white; +} + +.wizard-btn-next:hover { + background: var(--bp-primary-hover); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(108, 27, 27, 0.3); +} + +.wizard-btn-next:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +} + +/* Error Banner */ +.wizard-error-banner { + display: none; + background: rgba(239, 68, 68, 0.1); + border: 1px solid var(--bp-danger); + border-radius: 12px; + padding: 16px 20px; + margin-bottom: 20px; +} + +.wizard-error-banner.active { + display: block; +} + +.wizard-error-banner-header { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + color: var(--bp-danger); + margin-bottom: 8px; +} + +.wizard-error-banner-content { + font-size: 13px; + color: var(--bp-text-muted); + line-height: 1.6; +} + +.wizard-error-banner-solutions { + margin-top: 12px; + padding-left: 20px; +} + +.wizard-error-banner-solutions li { + font-size: 13px; + color: var(--bp-text-muted); + margin-bottom: 8px; +} + +/* ========================================== + ACCOUNT MANAGEMENT + ========================================== */ + +.mail-accounts-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; + margin-bottom: 32px; +} + +.mail-account-card { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 16px; + padding: 24px; + transition: all 0.2s; +} + +.mail-account-card:hover { + border-color: var(--bp-primary); + box-shadow: 0 4px 20px rgba(0,0,0,0.1); +} + +.mail-account-card-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 16px; +} + +.mail-account-icon { + width: 48px; + height: 48px; + border-radius: 12px; + background: var(--bp-primary-soft); + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; +} + +.mail-account-info h3 { + font-size: 16px; + font-weight: 600; + color: var(--bp-text); + margin-bottom: 4px; +} + +.mail-account-info p { + font-size: 13px; + color: var(--bp-text-muted); +} + +.mail-account-status { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 20px; + font-size: 11px; + font-weight: 600; + margin-bottom: 16px; +} + +.mail-account-status.connected { + background: rgba(34, 197, 94, 0.1); + color: var(--bp-success); +} + +.mail-account-status.error { + background: rgba(239, 68, 68, 0.1); + color: var(--bp-danger); +} + +.mail-account-stats { + display: flex; + gap: 16px; + padding-top: 16px; + border-top: 1px solid var(--bp-border); +} + +.mail-account-stat { + flex: 1; +} + +.mail-account-stat-value { + font-size: 20px; + font-weight: 700; + color: var(--bp-text); +} + +.mail-account-stat-label { + font-size: 11px; + color: var(--bp-text-muted); +} + +.mail-account-actions { + display: flex; + gap: 8px; + margin-top: 16px; +} + +.mail-account-btn { + flex: 1; + padding: 10px; + border-radius: 8px; + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + text-align: center; +} + +.mail-account-btn-sync { + background: var(--bp-primary-soft); + border: 1px solid var(--bp-primary); + color: var(--bp-primary); +} + +.mail-account-btn-sync:hover { + background: var(--bp-primary); + color: white; +} + +.mail-account-btn-settings { + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border); + color: var(--bp-text-muted); +} + +.mail-account-btn-settings:hover { + border-color: var(--bp-text-muted); + color: var(--bp-text); +} + +/* Add Account Card */ +.mail-add-account-card { + background: var(--bp-bg); + border: 2px dashed var(--bp-border); + border-radius: 16px; + padding: 40px 24px; + text-align: center; + cursor: pointer; + transition: all 0.2s; +} + +.mail-add-account-card:hover { + border-color: var(--bp-primary); + background: var(--bp-primary-soft); +} + +.mail-add-account-icon { + font-size: 40px; + color: var(--bp-text-muted); + margin-bottom: 12px; +} + +.mail-add-account-text { + font-size: 14px; + font-weight: 600; + color: var(--bp-text-muted); +} + +/* ========================================== + PASSWORD VAULT + ========================================== */ + +.password-vault-section { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 16px; + padding: 24px; + margin-top: 32px; +} + +.password-vault-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; +} + +.password-vault-icon { + font-size: 28px; +} + +.password-vault-title { + font-size: 18px; + font-weight: 600; + color: var(--bp-text); +} + +.password-vault-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.password-vault-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px; + background: var(--bp-bg); + border-radius: 12px; +} + +.password-vault-item-info { + display: flex; + align-items: center; + gap: 12px; +} + +.password-vault-item-icon { + font-size: 24px; +} + +.password-vault-item-name { + font-weight: 600; + color: var(--bp-text); + font-size: 14px; +} + +.password-vault-item-email { + font-size: 12px; + color: var(--bp-text-muted); +} + +.password-vault-item-password { + font-family: monospace; + font-size: 14px; + color: var(--bp-text-muted); + letter-spacing: 2px; +} + +.password-vault-item-actions { + display: flex; + gap: 8px; +} + +.password-vault-btn { + padding: 6px 12px; + border-radius: 6px; + font-size: 11px; + cursor: pointer; + transition: all 0.2s; + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border); + color: var(--bp-text-muted); +} + +.password-vault-btn:hover { + border-color: var(--bp-primary); + color: var(--bp-primary); +} +""" + + @staticmethod + def get_html() -> str: + """HTML fuer das Mail-Inbox-Modul.""" + return """ + +
    + +
    +
    +

    Unified Inbox

    +

    Alle E-Mails an einem Ort

    +
    +
    + + +
    +
    + + +
    + +
    + +
    + + + + + + +
    +
    + + +
    +
    + + + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    Schritt 1 von 7
    +
    + + +
    + +
    +
    + Verbindung fehlgeschlagen +
    +
    + Das Passwort scheint nicht zu stimmen. +
    +
      +
    • Pruefen Sie Gross-/Kleinschreibung beim Passwort
    • +
    • Ist das wirklich das E-Mail-Passwort? (Nicht das Windows-Passwort!)
    • +
    • Bei Gmail/Outlook mit 2FA: Sie brauchen ein App-Passwort
    • +
    +
    + + +
    +
    +
    📫➡📥
    +
    +

    Alle Ihre E-Mails an einem Ort

    +

    + Stellen Sie sich vor: Schulleitung, Verwaltung, Regierungspraesidium - + alles in EINER Ansicht. Keine 4 verschiedenen Programme mehr oeffnen. +

    +
    +
    + 💡 + Was passiert mit meinen Daten? +
    +
    + Ihre E-Mails bleiben bei Ihrem Anbieter (z.B. GMX). + Wir speichern nur eine Kopie, damit Sie schneller arbeiten koennen. + Alles DSGVO-konform in Deutschland. +
    +
    +
    + + +
    +

    Welche E-Mail-Adresse?

    +

    + Geben Sie die E-Mail-Adresse ein, die Sie hinzufuegen moechten. +

    +
    +
    + 📧 + +
    +

    Geben Sie Ihre vollstaendige E-Mail-Adresse ein

    +
    +
    +
    + 💡 + Warum brauchen wir das? +
    +
    + Anhand Ihrer E-Mail-Adresse erkennen wir automatisch Ihren Anbieter + und fuellen die technischen Einstellungen fuer Sie aus. +
    +
    +
    + + +
    +

    Anbieter erkannt!

    +
    + 📧 +
    +

    GMX

    +

    Wir haben Ihren E-Mail-Anbieter erkannt

    +
    + ✓ Servereinstellungen automatisch ausgefuellt + ✓ Verschluesselung aktiviert +
    +
    +
    +
    +
    + 💡 + Stimmt das nicht? +
    +
    + Falls Sie einen anderen Anbieter nutzen, koennen Sie ihn hier aendern: +
    +
    +
    +
    + 📧 + Gmail +
    +
    + 📩 + Outlook +
    +
    + 📪 + GMX +
    +
    + 📫 + Web.de +
    +
    + 📬 + T-Online +
    +
    + + Andere... +
    +
    +
    + + +
    +

    Wie soll das Konto heissen?

    +

    + Geben Sie diesem Konto einen Namen, damit Sie es leicht wiedererkennen. +

    +
    + + + + + + + + +
    +
    + +
    + 🏷 + +
    +
    +
    +
    + 💡 + Wozu ist der Name? +
    +
    + In Ihrer Inbox sehen Sie spaeter auf einen Blick, von welchem Konto + eine E-Mail stammt. "Schulleitung" ist leichter zu erkennen als + "vorname.nachname@schule.niedersachsen.de" +
    +
    +
    + + +
    +

    Passwort eingeben

    +

    + Geben Sie das Passwort fuer Ihr E-Mail-Konto ein. +

    +
    +
    + 🔒 + + +
    +
    + +

    + (Damit Sie es nicht jedes Mal eingeben muessen) +

    +
    +
    + 🔒 + Wie sicher ist mein Passwort? +
    +
    + Ihr Passwort wird mit militaerischer Verschluesselung (AES-256) gespeichert. + Selbst wir koennen es nicht lesen. Es wird nur verwendet, um Ihre E-Mails + abzurufen - niemals fuer etwas anderes. +
    +
    +
    +
    + + Gmail/Outlook mit 2-Faktor-Authentifizierung? +
    +
    + Sie brauchen ein spezielles "App-Passwort" statt Ihres normalen Passworts. +

    + Anleitung fuer Gmail | + Anleitung fuer Outlook +
    +
    +
    + + +
    +

    Verbindung wird geprueft...

    +
    +
    📧
    +

    Wir pruefen jetzt, ob alles funktioniert...

    +
    +
    + Mit Server verbinden... +
    +
    + Anmeldung pruefen... +
    +
    + Posteingang laden... +
    +
    + E-Mail-Versand pruefen... +
    +
    +
    +
    +
    + 💡 + Was passiert gerade? +
    +
    + Wir verbinden uns mit Ihrem E-Mail-Anbieter und pruefen, ob wir + E-Mails abrufen und senden koennen. Das dauert nur wenige Sekunden. +
    +
    +
    + + +
    +
    +
    🎉
    +

    Geschafft!

    +

    + Ihr Konto wurde erfolgreich eingerichtet! +

    +
    +
    + 📧 +
    +
    Schulleitung
    +
    schulleitung@grundschule.de
    +
    +
    +
    + 147 E-Mails • Zuletzt synchronisiert: Jetzt +
    +
    +
    +
    + 💡 + Tipp fuer den Alltag +
    +
    + Haben Sie mehrere E-Mail-Konten? Die meisten Schulleitungen verwalten 3-4 Adressen. + Fuegen Sie jetzt alle hinzu - dann haben Sie wirklich ALLES an einem Ort! +
    +
    +
    +
    +
    + + + +
    +
    +""" + + @staticmethod + def get_js() -> str: + """JavaScript fuer das Mail-Inbox-Modul.""" + return """ +/* ========================================== + MAIL INBOX MODULE - JavaScript + ========================================== */ + +// Mail API Base URL +const MAIL_API_BASE = 'http://localhost:8086/api/v1/mail'; + +// Current user info (from auth) +let mailUserId = 'demo-user'; +let mailTenantId = 'demo-tenant'; + +// Wizard State +let wizardCurrentStep = 1; +const wizardTotalSteps = 7; +let wizardData = { + email: '', + provider: null, + accountName: '', + password: '', + savePassword: true, + // Provider settings (auto-filled) + imapHost: '', + imapPort: 993, + smtpHost: '', + smtpPort: 465, +}; + +// Provider Presets +const EMAIL_PROVIDERS = { + gmail: { + name: 'Gmail', + icon: '📧', + imapHost: 'imap.gmail.com', + imapPort: 993, + smtpHost: 'smtp.gmail.com', + smtpPort: 465, + needsAppPassword: true, + helpUrl: 'https://support.google.com/accounts/answer/185833' + }, + outlook: { + name: 'Outlook / Microsoft 365', + icon: '📩', + imapHost: 'outlook.office365.com', + imapPort: 993, + smtpHost: 'smtp.office365.com', + smtpPort: 587, + needsAppPassword: true, + helpUrl: 'https://support.microsoft.com/de-de/account-billing/app-kennw%C3%B6rter' + }, + gmx: { + name: 'GMX', + icon: '📪', + imapHost: 'imap.gmx.net', + imapPort: 993, + smtpHost: 'mail.gmx.net', + smtpPort: 465, + needsAppPassword: false, + helpUrl: 'https://hilfe.gmx.net/pop-imap/imap/index.html' + }, + webde: { + name: 'Web.de', + icon: '📫', + imapHost: 'imap.web.de', + imapPort: 993, + smtpHost: 'smtp.web.de', + smtpPort: 587, + needsAppPassword: false, + helpUrl: 'https://hilfe.web.de/pop-imap/imap/index.html' + }, + tonline: { + name: 'T-Online', + icon: '📬', + imapHost: 'secureimap.t-online.de', + imapPort: 993, + smtpHost: 'securesmtp.t-online.de', + smtpPort: 465, + needsAppPassword: false, + helpUrl: 'https://www.telekom.de/hilfe/festnetz-internet-tv/e-mail' + }, + niedersachsen: { + name: 'Niedersachsen Schulportal', + icon: '🏫', + imapHost: 'imap.schule.niedersachsen.de', + imapPort: 993, + smtpHost: 'smtp.schule.niedersachsen.de', + smtpPort: 465, + needsAppPassword: false, + helpUrl: 'https://www.nibis.de/' + }, + generic: { + name: 'Anderer Anbieter', + icon: '⚙', + imapHost: '', + imapPort: 993, + smtpHost: '', + smtpPort: 465, + needsAppPassword: false, + helpUrl: null + } +}; + +// Email domain to provider mapping +const EMAIL_DOMAIN_MAP = { + 'gmail.com': 'gmail', + 'googlemail.com': 'gmail', + 'outlook.com': 'outlook', + 'outlook.de': 'outlook', + 'hotmail.com': 'outlook', + 'hotmail.de': 'outlook', + 'live.com': 'outlook', + 'live.de': 'outlook', + 'gmx.de': 'gmx', + 'gmx.net': 'gmx', + 'gmx.at': 'gmx', + 'gmx.ch': 'gmx', + 'web.de': 'webde', + 't-online.de': 'tonline', + 'schule.niedersachsen.de': 'niedersachsen', + 'rlsb.de': 'niedersachsen', + 'mk.niedersachsen.de': 'niedersachsen', + 'nibis.de': 'niedersachsen', +}; + +// Mail accounts storage +let mailAccounts = []; + +/* ========================================== + INITIALIZATION + ========================================== */ + +function initMailModule() { + console.log('Mail Module initialized'); + loadMailAccounts(); +} + +/* ========================================== + WIZARD FUNCTIONS + ========================================== */ + +function openMailWizard() { + // Reset wizard state + wizardCurrentStep = 1; + wizardData = { + email: '', + provider: null, + accountName: '', + password: '', + savePassword: true, + imapHost: '', + imapPort: 993, + smtpHost: '', + smtpPort: 465, + }; + + // Reset form fields + document.getElementById('wizard-email').value = ''; + document.getElementById('wizard-account-name').value = ''; + document.getElementById('wizard-password').value = ''; + document.getElementById('wizard-save-password').checked = true; + + // Hide error banner + document.getElementById('wizard-error-banner').classList.remove('active'); + + // Update UI + updateWizardStep(); + + // Show modal + document.getElementById('mail-wizard-modal').classList.add('active'); +} + +function closeMailWizard() { + document.getElementById('mail-wizard-modal').classList.remove('active'); +} + +function updateWizardStep() { + // Update step dots + document.querySelectorAll('.wizard-step-dot').forEach((dot, index) => { + dot.classList.remove('active', 'completed'); + if (index + 1 < wizardCurrentStep) { + dot.classList.add('completed'); + } else if (index + 1 === wizardCurrentStep) { + dot.classList.add('active'); + } + }); + + // Update step label + document.getElementById('wizard-step-label').textContent = + `Schritt ${wizardCurrentStep} von ${wizardTotalSteps}`; + + // Show/hide steps + document.querySelectorAll('.wizard-step').forEach((step, index) => { + step.classList.remove('active'); + if (index + 1 === wizardCurrentStep) { + step.classList.add('active'); + } + }); + + // Update buttons + const backBtn = document.getElementById('wizard-btn-back'); + const nextBtn = document.getElementById('wizard-btn-next'); + + backBtn.style.visibility = wizardCurrentStep > 1 ? 'visible' : 'hidden'; + + // Update next button text based on step + if (wizardCurrentStep === 1) { + nextBtn.innerHTML = "Los geht's! "; + } else if (wizardCurrentStep === 6) { + nextBtn.innerHTML = "Verbindung testen "; + } else if (wizardCurrentStep === 7) { + nextBtn.innerHTML = "Fertig "; + } else { + nextBtn.innerHTML = "Weiter "; + } + + // Disable next button if required fields are empty + updateNextButtonState(); +} + +function updateNextButtonState() { + const nextBtn = document.getElementById('wizard-btn-next'); + let canProceed = true; + + if (wizardCurrentStep === 2) { + canProceed = isValidEmail(wizardData.email); + } else if (wizardCurrentStep === 4) { + canProceed = wizardData.accountName.length >= 2; + } else if (wizardCurrentStep === 5) { + canProceed = wizardData.password.length >= 1; + } + + nextBtn.disabled = !canProceed; +} + +function wizardNextStep() { + if (wizardCurrentStep === 6) { + // Start connection test + runConnectionTest(); + return; + } + + if (wizardCurrentStep === 7) { + // Close wizard and show success + closeMailWizard(); + loadMailAccounts(); + return; + } + + if (wizardCurrentStep < wizardTotalSteps) { + // Special handling for step 2 -> 3: detect provider + if (wizardCurrentStep === 2) { + detectProvider(wizardData.email); + } + + wizardCurrentStep++; + updateWizardStep(); + } +} + +function wizardPrevStep() { + if (wizardCurrentStep > 1) { + // Hide error banner when going back + document.getElementById('wizard-error-banner').classList.remove('active'); + + wizardCurrentStep--; + updateWizardStep(); + } +} + +/* ========================================== + EMAIL INPUT & PROVIDER DETECTION + ========================================== */ + +function onEmailInput(value) { + wizardData.email = value.trim().toLowerCase(); + updateNextButtonState(); +} + +function isValidEmail(email) { + const re = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/; + return re.test(email); +} + +function detectProvider(email) { + const domain = email.split('@')[1]; + const providerKey = EMAIL_DOMAIN_MAP[domain] || 'generic'; + const provider = EMAIL_PROVIDERS[providerKey]; + + wizardData.provider = providerKey; + wizardData.imapHost = provider.imapHost; + wizardData.imapPort = provider.imapPort; + wizardData.smtpHost = provider.smtpHost; + wizardData.smtpPort = provider.smtpPort; + + // Update UI + document.getElementById('provider-name').textContent = provider.name; + document.getElementById('provider-icon').innerHTML = provider.icon; + + // Highlight selected provider card + document.querySelectorAll('.provider-card').forEach(card => { + card.classList.remove('selected'); + }); + + // Show/hide app password hint + const appPasswordHint = document.getElementById('app-password-hint'); + if (provider.needsAppPassword) { + appPasswordHint.style.display = 'block'; + } else { + appPasswordHint.style.display = 'none'; + } +} + +function selectProvider(providerKey) { + const provider = EMAIL_PROVIDERS[providerKey]; + + wizardData.provider = providerKey; + wizardData.imapHost = provider.imapHost; + wizardData.imapPort = provider.imapPort; + wizardData.smtpHost = provider.smtpHost; + wizardData.smtpPort = provider.smtpPort; + + // Update UI + document.getElementById('provider-name').textContent = provider.name; + document.getElementById('provider-icon').innerHTML = provider.icon; + + // Highlight selected card + document.querySelectorAll('.provider-card').forEach(card => { + card.classList.remove('selected'); + }); + event.target.closest('.provider-card')?.classList.add('selected'); +} + +/* ========================================== + ACCOUNT NAME INPUT + ========================================== */ + +function selectAccountName(name) { + wizardData.accountName = name; + document.getElementById('wizard-account-name').value = name; + + // Highlight selected button + document.querySelectorAll('.quick-select-btn').forEach(btn => { + btn.classList.remove('selected'); + if (btn.textContent === name) { + btn.classList.add('selected'); + } + }); + + updateNextButtonState(); +} + +function onAccountNameInput(value) { + wizardData.accountName = value; + + // Remove selection from quick select buttons + document.querySelectorAll('.quick-select-btn').forEach(btn => { + btn.classList.remove('selected'); + }); + + updateNextButtonState(); +} + +/* ========================================== + PASSWORD INPUT + ========================================== */ + +function togglePasswordVisibility() { + const input = document.getElementById('wizard-password'); + const toggle = document.getElementById('password-toggle'); + + if (input.type === 'password') { + input.type = 'text'; + toggle.innerHTML = '👀'; + } else { + input.type = 'password'; + toggle.innerHTML = '👁'; + } +} + +// Listen for password input +document.addEventListener('DOMContentLoaded', function() { + const passwordInput = document.getElementById('wizard-password'); + if (passwordInput) { + passwordInput.addEventListener('input', function(e) { + wizardData.password = e.target.value; + updateNextButtonState(); + }); + } +}); + +function showAppPasswordHelp(provider) { + const urls = { + gmail: 'https://support.google.com/accounts/answer/185833', + outlook: 'https://support.microsoft.com/de-de/account-billing/app-kennw%C3%B6rter' + }; + window.open(urls[provider], '_blank'); +} + +/* ========================================== + CONNECTION TEST + ========================================== */ + +async function runConnectionTest() { + // Show testing step + wizardCurrentStep = 6; + updateWizardStep(); + + // Reset status indicators + const testItems = ['test-server', 'test-auth', 'test-inbox', 'test-smtp']; + testItems.forEach(id => { + const item = document.getElementById(id); + item.classList.remove('success', 'error'); + item.classList.add('pending'); + item.querySelector('span').innerHTML = '○'; + }); + + // Hide error banner + document.getElementById('wizard-error-banner').classList.remove('active'); + + // Simulate connection test with delays + try { + // Step 1: Connect to server + await delay(800); + updateTestStatus('test-server', 'success', 'Mit Server verbunden'); + + // Step 2: Authentication + await delay(1000); + + // Try to create/test the account + const testResult = await testMailConnection(); + + if (!testResult.success) { + throw new Error(testResult.error || 'Authentifizierung fehlgeschlagen'); + } + + updateTestStatus('test-auth', 'success', 'Anmeldung erfolgreich'); + + // Step 3: Load inbox + await delay(600); + const emailCount = testResult.emailCount || 0; + updateTestStatus('test-inbox', 'success', `${emailCount} E-Mails gefunden`); + + // Step 4: Test SMTP + await delay(500); + updateTestStatus('test-smtp', 'success', 'E-Mail-Versand bereit'); + + // Save account + await saveMailAccount(); + + // Update success screen + document.getElementById('success-account-name').textContent = wizardData.accountName; + document.getElementById('success-email').textContent = wizardData.email; + document.getElementById('success-provider-icon').innerHTML = EMAIL_PROVIDERS[wizardData.provider]?.icon || '📧'; + document.getElementById('success-stats').textContent = `${emailCount} E-Mails - Zuletzt synchronisiert: Jetzt`; + + // Move to success step after short delay + await delay(500); + wizardCurrentStep = 7; + updateWizardStep(); + + } catch (error) { + console.error('Connection test failed:', error); + showConnectionError(error.message); + } +} + +function updateTestStatus(itemId, status, text) { + const item = document.getElementById(itemId); + item.classList.remove('pending', 'success', 'error'); + item.classList.add(status); + + if (status === 'success') { + item.querySelector('span').innerHTML = '✓'; + } else if (status === 'error') { + item.querySelector('span').innerHTML = '✗'; + } + + // Update text + const textNode = item.childNodes[item.childNodes.length - 1]; + textNode.textContent = ' ' + text; +} + +function showConnectionError(message) { + // Show error on current step + const errorBanner = document.getElementById('wizard-error-banner'); + document.getElementById('wizard-error-content').textContent = message; + errorBanner.classList.add('active'); + + // Go back to password step + wizardCurrentStep = 5; + updateWizardStep(); +} + +async function testMailConnection() { + // For demo, simulate success + // In production, this would call the API + return { + success: true, + emailCount: Math.floor(Math.random() * 200) + 50 + }; + + /* Production code: + try { + const response = await fetch(`${MAIL_API_BASE}/accounts/test-connection`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: wizardData.email, + imap_host: wizardData.imapHost, + imap_port: wizardData.imapPort, + smtp_host: wizardData.smtpHost, + smtp_port: wizardData.smtpPort, + password: wizardData.password, + }) + }); + return await response.json(); + } catch (error) { + return { success: false, error: error.message }; + } + */ +} + +/* ========================================== + ACCOUNT MANAGEMENT + ========================================== */ + +async function saveMailAccount() { + const account = { + id: 'acc_' + Date.now(), + email: wizardData.email, + displayName: wizardData.accountName, + provider: wizardData.provider, + imapHost: wizardData.imapHost, + imapPort: wizardData.imapPort, + smtpHost: wizardData.smtpHost, + smtpPort: wizardData.smtpPort, + status: 'active', + emailCount: Math.floor(Math.random() * 200) + 50, + unreadCount: Math.floor(Math.random() * 20), + lastSync: new Date().toISOString(), + }; + + // In production, save to API + // For demo, save to local storage + mailAccounts.push(account); + localStorage.setItem('mailAccounts', JSON.stringify(mailAccounts)); + + return account; +} + +function loadMailAccounts() { + // Load from localStorage for demo + const stored = localStorage.getItem('mailAccounts'); + mailAccounts = stored ? JSON.parse(stored) : []; + + renderMailAccounts(); +} + +function renderMailAccounts() { + const grid = document.getElementById('mail-accounts-grid'); + const emptyState = document.getElementById('mail-empty-state'); + + if (mailAccounts.length === 0) { + grid.style.display = 'none'; + emptyState.style.display = 'flex'; + return; + } + + grid.style.display = 'grid'; + emptyState.style.display = 'none'; + + grid.innerHTML = mailAccounts.map(account => ` + + `).join('') + ` + + `; +} + +async function syncAccount(accountId) { + console.log('Syncing account:', accountId); + // Implementation for syncing +} + +function openAccountSettings(accountId) { + console.log('Opening settings for:', accountId); + // Implementation for account settings +} + +function deleteAccount(accountId) { + if (confirm('Moechten Sie dieses Konto wirklich entfernen?')) { + mailAccounts = mailAccounts.filter(a => a.id !== accountId); + localStorage.setItem('mailAccounts', JSON.stringify(mailAccounts)); + renderMailAccounts(); + } +} + +/* ========================================== + PASSWORD VAULT + ========================================== */ + +function openPasswordVault() { + const vaultSection = document.getElementById('password-vault-section'); + vaultSection.style.display = vaultSection.style.display === 'none' ? 'block' : 'none'; + renderPasswordVault(); +} + +function renderPasswordVault() { + const list = document.getElementById('password-vault-list'); + + if (mailAccounts.length === 0) { + list.innerHTML = '

    Noch keine Konten gespeichert

    '; + return; + } + + list.innerHTML = mailAccounts.map(account => ` +
    +
    + ${EMAIL_PROVIDERS[account.provider]?.icon || '📧'} +
    +
    ${account.displayName}
    + +
    +
    +
    ••••••••
    +
    + + +
    +
    + `).join(''); +} + +function showPassword(accountId) { + alert('Passwort-Anzeige erfordert erneute Authentifizierung (nicht implementiert in Demo)'); +} + +function changePassword(accountId) { + alert('Passwort-Aenderung (nicht implementiert in Demo)'); +} + +/* ========================================== + UTILITY FUNCTIONS + ========================================== */ + +function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// Initialize on module load +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initMailModule); +} else { + initMailModule(); +} +""" diff --git a/backend/frontend/modules/messenger.py b/backend/frontend/modules/messenger.py new file mode 100644 index 0000000..e2269f1 --- /dev/null +++ b/backend/frontend/modules/messenger.py @@ -0,0 +1,2148 @@ +""" +BreakPilot Studio - Messenger Modul + +Funktionen: +- Matrix Chat Integration (DSGVO-konform) +- Direktnachrichten an Eltern +- Gruppenchats (Klassen, Fachschaften) +- Vorlagen fuer haeufige Nachrichten +- Dateiaustausch +- Lesebestaetigungen +""" + + +class MessengerModule: + """Modul fuer sichere Kommunikation mit Matrix-Integration.""" + + @staticmethod + def get_css() -> str: + """CSS fuer das Messenger-Modul.""" + return """ +/* ============================================= + MESSENGER MODULE - Matrix Chat Integration + ============================================= */ + +/* Panel Layout */ +.panel-messenger { + display: none; + flex-direction: column; + height: 100%; + background: var(--bp-bg); + overflow: hidden; +} + +.panel-messenger.active { + display: flex; +} + +/* Messenger Container */ +.messenger-container { + display: flex; + flex: 1; + overflow: hidden; +} + +/* Left Sidebar - Conversations */ +.messenger-sidebar { + width: 320px; + border-right: 1px solid var(--bp-border); + display: flex; + flex-direction: column; + background: var(--bp-surface); +} + +.messenger-sidebar-header { + padding: 20px; + border-bottom: 1px solid var(--bp-border); +} + +.messenger-sidebar-title { + font-size: 18px; + font-weight: 600; + color: var(--bp-text); + margin-bottom: 16px; +} + +/* Search Box */ +.messenger-search { + position: relative; +} + +.messenger-search input { + width: 100%; + padding: 12px 16px 12px 40px; + border: 1px solid var(--bp-border); + border-radius: 10px; + background: var(--bp-bg); + color: var(--bp-text); + font-size: 14px; +} + +.messenger-search input:focus { + outline: none; + border-color: var(--bp-primary); +} + +.messenger-search-icon { + position: absolute; + left: 14px; + top: 50%; + transform: translateY(-50%); + color: var(--bp-text-muted); + font-size: 16px; +} + +/* Tab Navigation */ +.messenger-tabs { + display: flex; + padding: 12px 20px; + gap: 8px; + border-bottom: 1px solid var(--bp-border); +} + +.messenger-tab { + flex: 1; + padding: 10px 16px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--bp-text-muted); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.messenger-tab:hover { + background: var(--bp-surface-elevated); +} + +.messenger-tab.active { + background: var(--bp-primary); + color: white; +} + +.messenger-tab-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 6px; + margin-left: 6px; + border-radius: 9px; + background: #ef4444; + color: white; + font-size: 11px; + font-weight: 600; +} + +/* Conversation List */ +.messenger-conversations { + flex: 1; + overflow-y: auto; +} + +.messenger-conversation { + display: flex; + align-items: center; + gap: 12px; + padding: 16px 20px; + cursor: pointer; + transition: background 0.2s; + border-bottom: 1px solid var(--bp-border-subtle); +} + +.messenger-conversation:hover { + background: var(--bp-surface-elevated); +} + +.messenger-conversation.active { + background: var(--bp-primary-soft); + border-left: 3px solid var(--bp-primary); +} + +.messenger-conversation.unread { + background: rgba(59, 130, 246, 0.05); +} + +.messenger-avatar { + width: 48px; + height: 48px; + border-radius: 50%; + background: var(--bp-surface-elevated); + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + flex-shrink: 0; + position: relative; +} + +.messenger-avatar.online::after { + content: ''; + position: absolute; + bottom: 2px; + right: 2px; + width: 12px; + height: 12px; + border-radius: 50%; + background: #10b981; + border: 2px solid var(--bp-surface); +} + +.messenger-avatar.group { + border-radius: 12px; + background: var(--bp-primary-soft); +} + +.messenger-conversation-content { + flex: 1; + min-width: 0; +} + +.messenger-conversation-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +} + +.messenger-conversation-name { + font-size: 14px; + font-weight: 600; + color: var(--bp-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.messenger-conversation-time { + font-size: 11px; + color: var(--bp-text-muted); + flex-shrink: 0; +} + +.messenger-conversation-preview { + font-size: 13px; + color: var(--bp-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.messenger-conversation.unread .messenger-conversation-preview { + color: var(--bp-text); + font-weight: 500; +} + +.messenger-unread-badge { + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--bp-primary); + color: white; + font-size: 11px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +/* New Conversation Button */ +.messenger-new-btn { + margin: 16px 20px; + padding: 14px; + border: none; + border-radius: 12px; + background: var(--bp-primary); + color: white; + font-size: 14px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: all 0.2s; +} + +.messenger-new-btn:hover { + background: var(--bp-primary-hover); + transform: translateY(-1px); +} + +/* Chat Area */ +.messenger-chat { + flex: 1; + display: flex; + flex-direction: column; + background: var(--bp-bg); +} + +.messenger-chat-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + border-bottom: 1px solid var(--bp-border); + background: var(--bp-surface); +} + +.messenger-chat-info { + display: flex; + align-items: center; + gap: 12px; +} + +.messenger-chat-name { + font-size: 16px; + font-weight: 600; + color: var(--bp-text); +} + +.messenger-chat-status { + font-size: 12px; + color: var(--bp-text-muted); +} + +.messenger-chat-status.online { + color: #10b981; +} + +.messenger-chat-actions { + display: flex; + gap: 8px; +} + +.messenger-action-btn { + width: 40px; + height: 40px; + border: none; + border-radius: 10px; + background: var(--bp-surface-elevated); + color: var(--bp-text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + transition: all 0.2s; +} + +.messenger-action-btn:hover { + background: var(--bp-primary-soft); + color: var(--bp-primary); +} + +/* Messages Area */ +.messenger-messages { + flex: 1; + overflow-y: auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.messenger-message { + display: flex; + gap: 12px; + max-width: 70%; +} + +.messenger-message.sent { + align-self: flex-end; + flex-direction: row-reverse; +} + +.messenger-message-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--bp-surface-elevated); + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; +} + +.messenger-message-content { + display: flex; + flex-direction: column; + gap: 4px; +} + +.messenger-message-bubble { + padding: 12px 16px; + border-radius: 18px; + background: var(--bp-surface); + color: var(--bp-text); + font-size: 14px; + line-height: 1.5; + border: 1px solid var(--bp-border); +} + +.messenger-message.sent .messenger-message-bubble { + background: var(--bp-primary); + color: white; + border-color: var(--bp-primary); + border-bottom-right-radius: 4px; +} + +.messenger-message:not(.sent) .messenger-message-bubble { + border-bottom-left-radius: 4px; +} + +.messenger-message-meta { + display: flex; + gap: 8px; + font-size: 11px; + color: var(--bp-text-muted); + padding: 0 8px; +} + +.messenger-message.sent .messenger-message-meta { + justify-content: flex-end; +} + +.messenger-message-status { + color: #10b981; +} + +/* Message Input */ +.messenger-input-area { + padding: 16px 24px; + border-top: 1px solid var(--bp-border); + background: var(--bp-surface); +} + +.messenger-input-wrapper { + display: flex; + gap: 12px; + align-items: flex-end; +} + +.messenger-input-actions { + display: flex; + gap: 4px; +} + +.messenger-input-btn { + width: 40px; + height: 40px; + border: none; + border-radius: 10px; + background: transparent; + color: var(--bp-text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + transition: all 0.2s; +} + +.messenger-input-btn:hover { + background: var(--bp-surface-elevated); + color: var(--bp-primary); +} + +.messenger-input { + flex: 1; + padding: 12px 16px; + border: 1px solid var(--bp-border); + border-radius: 20px; + background: var(--bp-bg); + color: var(--bp-text); + font-size: 14px; + resize: none; + min-height: 44px; + max-height: 120px; + line-height: 1.4; +} + +.messenger-input:focus { + outline: none; + border-color: var(--bp-primary); +} + +.messenger-send-btn { + width: 44px; + height: 44px; + border: none; + border-radius: 50%; + background: var(--bp-primary); + color: white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + transition: all 0.2s; +} + +.messenger-send-btn:hover { + background: var(--bp-primary-hover); + transform: scale(1.05); +} + +.messenger-send-btn:disabled { + background: var(--bp-surface-elevated); + color: var(--bp-text-muted); + cursor: not-allowed; + transform: none; +} + +/* Empty State */ +.messenger-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px; + text-align: center; + color: var(--bp-text-muted); +} + +.messenger-empty-icon { + font-size: 64px; + margin-bottom: 24px; + opacity: 0.5; +} + +.messenger-empty h2 { + font-size: 20px; + font-weight: 600; + color: var(--bp-text); + margin-bottom: 8px; +} + +.messenger-empty p { + font-size: 14px; + max-width: 400px; + margin-bottom: 24px; +} + +/* Templates Panel */ +.messenger-templates { + position: absolute; + bottom: 80px; + left: 24px; + right: 24px; + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 16px; + padding: 16px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + display: none; +} + +.messenger-templates.active { + display: block; +} + +.messenger-templates-title { + font-size: 14px; + font-weight: 600; + color: var(--bp-text); + margin-bottom: 12px; +} + +.messenger-template-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 200px; + overflow-y: auto; +} + +.messenger-template-item { + padding: 12px; + border-radius: 10px; + background: var(--bp-bg); + cursor: pointer; + transition: all 0.2s; +} + +.messenger-template-item:hover { + background: var(--bp-primary-soft); +} + +.messenger-template-name { + font-size: 13px; + font-weight: 500; + color: var(--bp-text); + margin-bottom: 4px; +} + +.messenger-template-preview { + font-size: 12px; + color: var(--bp-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Connection Status */ +.messenger-connection { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 20px; + background: var(--bp-surface-elevated); + border-bottom: 1px solid var(--bp-border); + font-size: 12px; + color: var(--bp-text-muted); +} + +.messenger-connection-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #10b981; +} + +.messenger-connection-dot.connecting { + background: #f59e0b; + animation: pulse 1s infinite; +} + +.messenger-connection-dot.offline { + background: #ef4444; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* Date Separator */ +.messenger-date-separator { + display: flex; + align-items: center; + gap: 16px; + margin: 16px 0; +} + +.messenger-date-separator::before, +.messenger-date-separator::after { + content: ''; + flex: 1; + height: 1px; + background: var(--bp-border); +} + +.messenger-date-separator span { + font-size: 12px; + color: var(--bp-text-muted); + padding: 4px 12px; + background: var(--bp-surface-elevated); + border-radius: 12px; +} + +/* Typing Indicator */ +.messenger-typing { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + font-size: 12px; + color: var(--bp-text-muted); +} + +.messenger-typing-dots { + display: flex; + gap: 4px; +} + +.messenger-typing-dots span { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--bp-text-muted); + animation: typingDot 1.4s infinite; +} + +.messenger-typing-dots span:nth-child(2) { + animation-delay: 0.2s; +} + +.messenger-typing-dots span:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes typingDot { + 0%, 60%, 100% { transform: translateY(0); } + 30% { transform: translateY(-4px); } +} + +/* Modal Styles */ +.messenger-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; +} + +.messenger-modal { + background: var(--bp-surface); + border-radius: 16px; + width: 90%; + max-width: 480px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); +} + +.messenger-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px; + border-bottom: 1px solid var(--bp-border); +} + +.messenger-modal-header h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: var(--bp-text); +} + +.messenger-modal-close { + width: 32px; + height: 32px; + border: none; + background: var(--bp-surface-elevated); + border-radius: 8px; + font-size: 20px; + color: var(--bp-text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.messenger-modal-close:hover { + background: var(--bp-primary-soft); + color: var(--bp-primary); +} + +.messenger-modal-search { + padding: 16px 20px; + border-bottom: 1px solid var(--bp-border); +} + +.messenger-modal-search input { + width: 100%; + padding: 12px 16px; + border: 1px solid var(--bp-border); + border-radius: 10px; + background: var(--bp-bg); + color: var(--bp-text); + font-size: 14px; +} + +.messenger-modal-search input:focus { + outline: none; + border-color: var(--bp-primary); +} + +.messenger-modal-content { + flex: 1; + overflow-y: auto; + padding: 8px 0; +} + +.messenger-contact-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 20px; + cursor: pointer; + transition: background 0.2s; +} + +.messenger-contact-item:hover { + background: var(--bp-surface-elevated); +} + +.messenger-contact-info { + flex: 1; +} + +.messenger-contact-name { + font-size: 14px; + font-weight: 500; + color: var(--bp-text); +} + +.messenger-contact-role { + font-size: 12px; + color: var(--bp-text-muted); +} + +/* Admin Tab Styles */ +.messenger-admin-tab { + padding: 8px 16px; + background: var(--bp-surface-elevated); + color: var(--bp-text-muted); + border: none; + border-radius: 8px; + font-size: 12px; + cursor: pointer; + margin: 8px 20px; +} + +.messenger-admin-tab:hover { + background: var(--bp-primary-soft); + color: var(--bp-primary); +} +""" + + @staticmethod + def get_html() -> str: + """HTML fuer das Messenger-Modul.""" + return """ + + +""" + + @staticmethod + def get_js() -> str: + """JavaScript fuer das Messenger-Modul.""" + return """ +// ============================================= +// MESSENGER MODULE - Matrix Chat Integration +// ============================================= + +let messengerInitialized = false; +let currentConversationId = null; +let messengerSocket = null; +let messengerContacts = []; +let messengerConversations = []; +let messengerMessages = {}; +let messengerTemplates = []; +let messengerGroups = []; + +// API Base URL +const MESSENGER_API = '/api/messenger'; + +// Fallback Templates (wenn API nicht erreichbar) +const defaultTemplates = [ + { + id: 'default-1', + name: 'Terminbestaetigung', + content: 'Vielen Dank fuer Ihre Terminanfrage. Ich bestaetige den Termin am [DATUM] um [UHRZEIT]. Bitte geben Sie mir Bescheid, falls sich etwas aendern sollte.', + category: 'termin' + }, + { + id: 'default-2', + name: 'Hausaufgaben-Info', + content: 'Zur Information: Die Hausaufgaben fuer diese Woche umfassen [THEMA]. Abgabetermin ist [DATUM]. Bei Fragen stehe ich gerne zur Verfuegung.', + category: 'hausaufgaben' + }, + { + id: 'default-3', + name: 'Entschuldigung bestaetigen', + content: 'Ich bestaetige den Erhalt der Entschuldigung fuer [NAME] am [DATUM]. Die Fehlzeiten wurden entsprechend vermerkt.', + category: 'entschuldigung' + }, + { + id: 'default-4', + name: 'Gespraechsanfrage', + content: 'Ich wuerde gerne einen Termin fuer ein Gespraech mit Ihnen vereinbaren, um [THEMA] zu besprechen. Waeren Sie am [DATUM] um [UHRZEIT] verfuegbar?', + category: 'gespraech' + } +]; + +async function loadMessengerModule() { + if (messengerInitialized) { + console.log('Messenger module already initialized'); + return; + } + + console.log('Loading Messenger Module...'); + + // Initialize search + const searchInput = document.getElementById('messenger-search-input'); + if (searchInput) { + searchInput.addEventListener('input', (e) => { + filterConversations(e.target.value); + }); + } + + // Initialize message input + const messageInput = document.getElementById('messenger-input'); + if (messageInput) { + messageInput.addEventListener('input', () => { + const sendBtn = document.getElementById('messenger-send-btn'); + if (sendBtn) { + sendBtn.disabled = !messageInput.value.trim(); + } + }); + } + + // Load data from API + await loadMessengerData(); + + // Connect to Matrix (mock) + connectToMatrix(); + + messengerInitialized = true; + console.log('Messenger Module loaded successfully'); +} + +// Load all messenger data from API +async function loadMessengerData() { + try { + // Load contacts, conversations, templates, groups in parallel + const [contactsRes, convsRes, templatesRes, groupsRes] = await Promise.all([ + fetch(`${MESSENGER_API}/contacts`), + fetch(`${MESSENGER_API}/conversations`), + fetch(`${MESSENGER_API}/templates`), + fetch(`${MESSENGER_API}/groups`) + ]); + + if (contactsRes.ok) { + messengerContacts = await contactsRes.json(); + console.log(`Loaded ${messengerContacts.length} contacts`); + } + + if (convsRes.ok) { + messengerConversations = await convsRes.json(); + console.log(`Loaded ${messengerConversations.length} conversations`); + renderConversationList(); + } + + if (templatesRes.ok) { + messengerTemplates = await templatesRes.json(); + console.log(`Loaded ${messengerTemplates.length} templates`); + } else { + messengerTemplates = defaultTemplates; + } + renderTemplateList(); + + if (groupsRes.ok) { + messengerGroups = await groupsRes.json(); + console.log(`Loaded ${messengerGroups.length} groups`); + } + + } catch (error) { + console.error('Error loading messenger data:', error); + messengerTemplates = defaultTemplates; + renderTemplateList(); + } +} + +// Render conversation list from API data +function renderConversationList() { + const container = document.getElementById('messenger-conversations'); + if (!container || messengerConversations.length === 0) return; + + // Keep demo conversations if no API data + if (messengerConversations.length === 0) return; + + container.innerHTML = messengerConversations.map(conv => { + const isGroup = conv.type === 'group'; + const unreadCount = conv.unread_count || 0; + const isUnread = unreadCount > 0; + const avatar = isGroup ? '👥' : '👱'; + const lastTime = conv.last_message_time ? formatMessageTime(conv.last_message_time) : ''; + + return ` +
    +
    ${avatar}
    +
    +
    + ${escapeHtml(conv.name)} + ${lastTime} +
    +
    ${escapeHtml(conv.last_message || 'Keine Nachrichten')}
    +
    + ${isUnread ? `${unreadCount}` : ''} +
    + `; + }).join(''); + + // Update badge count + const totalUnread = messengerConversations.reduce((sum, c) => sum + (c.unread_count || 0), 0); + const badge = document.getElementById('messenger-all-badge'); + if (badge) { + badge.textContent = totalUnread; + badge.style.display = totalUnread > 0 ? 'inline-flex' : 'none'; + } +} + +// Render template list +function renderTemplateList() { + const container = document.querySelector('.messenger-template-list'); + if (!container) return; + + const templates = messengerTemplates.length > 0 ? messengerTemplates : defaultTemplates; + + container.innerHTML = templates.map((tpl, index) => ` +
    +
    ${escapeHtml(tpl.name)}
    +
    ${escapeHtml(tpl.content.substring(0, 50))}...
    +
    + `).join(''); +} + +// Format message time +function formatMessageTime(isoTime) { + const date = new Date(isoTime); + const now = new Date(); + const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) { + return date.getHours().toString().padStart(2, '0') + ':' + + date.getMinutes().toString().padStart(2, '0'); + } else if (diffDays === 1) { + return 'Gestern'; + } else if (diffDays < 7) { + const days = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']; + return days[date.getDay()]; + } else { + return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); + } +} + +// Connect to Matrix server (mock) +function connectToMatrix() { + const dot = document.getElementById('messenger-connection-dot'); + const text = document.getElementById('messenger-connection-text'); + + if (dot && text) { + dot.classList.add('connecting'); + text.textContent = 'Verbinde mit Matrix...'; + + // Simulate connection + setTimeout(() => { + dot.classList.remove('connecting'); + text.textContent = 'Verbunden mit Matrix'; + }, 1500); + } +} + +// Switch between tabs +function switchMessengerTab(tab) { + // Update tab buttons + document.querySelectorAll('.messenger-tab').forEach(btn => { + btn.classList.toggle('active', btn.dataset.tab === tab); + }); + + // Filter conversations + const convs = document.querySelectorAll('.messenger-conversation'); + convs.forEach(conv => { + if (tab === 'all') { + conv.style.display = 'flex'; + } else if (tab === 'parents') { + const isGroup = conv.querySelector('.messenger-avatar.group'); + conv.style.display = isGroup ? 'none' : 'flex'; + } else if (tab === 'groups') { + const isGroup = conv.querySelector('.messenger-avatar.group'); + conv.style.display = isGroup ? 'flex' : 'none'; + } + }); +} + +// Filter conversations by search +function filterConversations(query) { + const convs = document.querySelectorAll('.messenger-conversation'); + const lowerQuery = query.toLowerCase(); + + convs.forEach(conv => { + const name = conv.querySelector('.messenger-conversation-name')?.textContent.toLowerCase() || ''; + const preview = conv.querySelector('.messenger-conversation-preview')?.textContent.toLowerCase() || ''; + const matches = name.includes(lowerQuery) || preview.includes(lowerQuery); + conv.style.display = matches ? 'flex' : 'none'; + }); +} + +// Open a conversation +async function openConversation(id) { + currentConversationId = id; + + // Update active state + document.querySelectorAll('.messenger-conversation').forEach(conv => { + conv.classList.toggle('active', conv.dataset.id == id); + if (conv.dataset.id == id) { + conv.classList.remove('unread'); + const badge = conv.querySelector('.messenger-unread-badge'); + if (badge) badge.remove(); + } + }); + + // Show chat content + const emptyState = document.getElementById('messenger-empty-state'); + const chatContent = document.getElementById('messenger-chat-content'); + + if (emptyState) emptyState.style.display = 'none'; + if (chatContent) chatContent.style.display = 'flex'; + + // Update chat header based on conversation + const conv = document.querySelector(`.messenger-conversation[data-id="${id}"]`); + if (conv) { + const name = conv.querySelector('.messenger-conversation-name')?.textContent; + const avatar = conv.querySelector('.messenger-avatar')?.innerHTML; + const isOnline = conv.querySelector('.messenger-avatar.online'); + + document.getElementById('messenger-chat-name').textContent = name; + document.getElementById('messenger-chat-avatar').innerHTML = avatar; + + const status = document.getElementById('messenger-chat-status'); + if (status) { + status.textContent = isOnline ? 'Online' : 'Offline'; + status.className = 'messenger-chat-status' + (isOnline ? ' online' : ''); + } + } + + // Load messages from API + await loadConversationMessages(id); + + // Focus input + document.getElementById('messenger-input')?.focus(); +} + +// Load messages for a conversation +async function loadConversationMessages(conversationId) { + const container = document.getElementById('messenger-messages'); + if (!container) return; + + try { + const response = await fetch(`${MESSENGER_API}/conversations/${conversationId}/messages`); + if (!response.ok) { + console.error('Failed to load messages'); + return; + } + + const messages = await response.json(); + messengerMessages[conversationId] = messages; + + // Render messages + if (messages.length === 0) { + container.innerHTML = ` +
    + Keine Nachrichten +
    + `; + } else { + renderMessages(messages, container); + } + + // Scroll to bottom + container.scrollTop = container.scrollHeight; + + } catch (error) { + console.error('Error loading messages:', error); + } +} + +// Render messages in container +function renderMessages(messages, container) { + let html = ''; + let lastDate = null; + + messages.forEach(msg => { + const msgDate = new Date(msg.created_at).toDateString(); + + // Add date separator if needed + if (msgDate !== lastDate) { + const dateLabel = formatDateLabel(msg.created_at); + html += ` +
    + ${dateLabel} +
    + `; + lastDate = msgDate; + } + + const isSent = msg.sender_id === 'teacher'; // TODO: Get actual user ID + const time = formatMessageTime(msg.created_at); + + html += ` +
    + ${!isSent ? '
    👱
    ' : ''} +
    +
    ${escapeHtml(msg.content)}
    +
    + ${time} + ${isSent && msg.read ? '✓✓' : ''} + ${isSent && !msg.read ? '' : ''} +
    +
    +
    + `; + }); + + container.innerHTML = html; +} + +// Format date label for message separator +function formatDateLabel(isoTime) { + const date = new Date(isoTime); + const now = new Date(); + const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) return 'Heute'; + if (diffDays === 1) return 'Gestern'; + return date.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' }); +} + +// Send a message +async function sendMessage() { + const input = document.getElementById('messenger-input'); + const message = input?.value.trim(); + + if (!message || !currentConversationId) return; + + // Disable send button while sending + const sendBtn = document.getElementById('messenger-send-btn'); + sendBtn.disabled = true; + + try { + // Send message via API + const response = await fetch(`${MESSENGER_API}/conversations/${currentConversationId}/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + content: message, + sender_id: 'teacher' // TODO: Get actual user ID + }) + }); + + if (!response.ok) { + throw new Error('Failed to send message'); + } + + // Add message to UI + addMessageToUI(message, true); + + // Clear input + input.value = ''; + input.style.height = 'auto'; + + // Update conversation preview + updateConversationPreview(currentConversationId, message); + + } catch (error) { + console.error('Error sending message:', error); + alert('Nachricht konnte nicht gesendet werden.'); + } + + sendBtn.disabled = false; +} + +// Update conversation preview in sidebar +function updateConversationPreview(convId, message) { + const conv = document.querySelector(`.messenger-conversation[data-id="${convId}"]`); + if (conv) { + const preview = conv.querySelector('.messenger-conversation-preview'); + if (preview) { + preview.textContent = message.substring(0, 50) + (message.length > 50 ? '...' : ''); + } + const timeEl = conv.querySelector('.messenger-conversation-time'); + if (timeEl) { + const now = new Date(); + timeEl.textContent = now.getHours().toString().padStart(2, '0') + ':' + + now.getMinutes().toString().padStart(2, '0'); + } + } +} + +// Add message to UI +function addMessageToUI(text, isSent) { + const container = document.getElementById('messenger-messages'); + if (!container) return; + + const now = new Date(); + const time = now.getHours().toString().padStart(2, '0') + ':' + + now.getMinutes().toString().padStart(2, '0'); + + const messageHtml = ` +
    + ${!isSent ? '
    👱
    ' : ''} +
    +
    ${escapeHtml(text)}
    +
    + ${time} + ${isSent ? '' : ''} +
    +
    +
    + `; + + container.insertAdjacentHTML('beforeend', messageHtml); + container.scrollTop = container.scrollHeight; + + // Update conversation preview + if (currentConversationId) { + const conv = document.querySelector(`.messenger-conversation[data-id="${currentConversationId}"]`); + if (conv) { + const preview = conv.querySelector('.messenger-conversation-preview'); + if (preview) { + preview.textContent = text.substring(0, 50) + (text.length > 50 ? '...' : ''); + } + const timeEl = conv.querySelector('.messenger-conversation-time'); + if (timeEl) { + timeEl.textContent = time; + } + } + } +} + +// Show typing indicator +function showTypingIndicator(show) { + const indicator = document.getElementById('messenger-typing'); + if (indicator) { + indicator.style.display = show ? 'flex' : 'none'; + } +} + +// Handle keyboard in message input +function handleMessageKeydown(event) { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + sendMessage(); + } +} + +// Adjust textarea height +function adjustTextareaHeight(textarea) { + textarea.style.height = 'auto'; + textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px'; +} + +// Toggle templates panel +function toggleTemplates() { + const panel = document.getElementById('messenger-templates'); + if (panel) { + panel.classList.toggle('active'); + } +} + +// Use a template +function useTemplate(templateId) { + const templates = messengerTemplates.length > 0 ? messengerTemplates : defaultTemplates; + const template = templates.find(t => t.id === templateId) || + templates[parseInt(templateId)] || + templates[0]; + + if (!template) return; + + const input = document.getElementById('messenger-input'); + if (input) { + input.value = template.content || template.text; + input.focus(); + adjustTextareaHeight(input); + document.getElementById('messenger-send-btn').disabled = false; + } + + toggleTemplates(); +} + +// Attach file +function attachFile() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.pdf,.doc,.docx,.jpg,.jpeg,.png'; + input.onchange = (e) => { + const file = e.target.files?.[0]; + if (file) { + console.log('Attaching file:', file.name); + addMessageToUI(`[Datei: ${file.name}]`, true); + } + }; + input.click(); +} + +// Start video call +function startVideoCall() { + if (!currentConversationId) return; + console.log('Starting video call...'); + alert('Videokonferenz wird gestartet... (Demo)'); +} + +// Start voice call +function startVoiceCall() { + if (!currentConversationId) return; + console.log('Starting voice call...'); + alert('Anruf wird gestartet... (Demo)'); +} + +// Show conversation info +function showConversationInfo() { + if (!currentConversationId) return; + console.log('Showing conversation info...'); + alert('Konversations-Details (Demo)'); +} + +// Show new conversation modal +async function showNewConversationModal() { + console.log('Opening new conversation modal...'); + + // Show contact picker if contacts are loaded + if (messengerContacts.length > 0) { + showContactPicker(); + } else { + // Fallback to prompt + const recipient = prompt('Empfaenger eingeben (Name oder E-Mail):'); + if (recipient) { + await createNewConversation(recipient, null); + } + } +} + +// Show contact picker modal +function showContactPicker() { + // Create modal HTML + const modalHtml = ` +
    +
    +
    +

    Kontakt auswaehlen

    + +
    + +
    + ${messengerContacts.map(c => ` +
    +
    👱
    +
    +
    ${escapeHtml(c.name)}
    +
    ${escapeHtml(c.student_name || '')} ${c.class_name ? '(' + c.class_name + ')' : ''}
    +
    +
    + `).join('')} +
    +
    +
    + `; + + document.body.insertAdjacentHTML('beforeend', modalHtml); +} + +// Close contact picker +function closeContactPicker(event) { + if (event && event.target !== event.currentTarget) return; + const modal = document.getElementById('contact-picker-modal'); + if (modal) modal.remove(); +} + +// Filter contact list in picker +function filterContactList(query) { + const lowerQuery = query.toLowerCase(); + document.querySelectorAll('.messenger-contact-item').forEach(item => { + const name = item.querySelector('.messenger-contact-name')?.textContent.toLowerCase() || ''; + const role = item.querySelector('.messenger-contact-role')?.textContent.toLowerCase() || ''; + item.style.display = (name.includes(lowerQuery) || role.includes(lowerQuery)) ? 'flex' : 'none'; + }); +} + +// Select a contact and create conversation +async function selectContact(contactId) { + closeContactPicker(); + const contact = messengerContacts.find(c => c.id === contactId); + if (contact) { + await createNewConversation(contact.name, [contact.id]); + } +} + +// Create new conversation via API +async function createNewConversation(name, participantIds) { + try { + const response = await fetch(`${MESSENGER_API}/conversations`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: name, + type: 'direct', + participant_ids: participantIds || [] + }) + }); + + if (!response.ok) { + throw new Error('Failed to create conversation'); + } + + const newConv = await response.json(); + messengerConversations.unshift(newConv); + renderConversationList(); + openConversation(newConv.id); + + } catch (error) { + console.error('Error creating conversation:', error); + + // Fallback: Create local conversation + const newId = 'local-' + Date.now(); + const newConv = { + id: newId, + name: name, + type: 'direct', + last_message: 'Neue Konversation', + last_message_time: new Date().toISOString() + }; + messengerConversations.unshift(newConv); + renderConversationList(); + openConversation(newId); + } +} + +// Escape HTML +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Show Messenger Panel +function showMessengerPanel() { + console.log('showMessengerPanel called'); + hideAllPanels(); + const panel = document.getElementById('panel-messenger'); + if (panel) { + panel.style.display = 'flex'; + panel.classList.add('active'); + loadMessengerModule(); + console.log('Messenger panel shown'); + } else { + console.error('panel-messenger not found'); + } +} + +// ============================================= +// MESSENGER ADMIN FUNCTIONS +// ============================================= + +// Show Messenger Admin Modal +function showMessengerAdmin() { + const modalHtml = ` +
    +
    +
    +

    Kontakte verwalten

    + +
    + +
    + + + +
    + + + +
    +
    +

    Lade Kontakte...

    +
    +
    + +
    + ${messengerContacts.length} Kontakte +
    +
    +
    + `; + + document.body.insertAdjacentHTML('beforeend', modalHtml); + loadAdminContacts(); +} + +// Close admin modal +function closeMessengerAdmin(event) { + if (event && event.target !== event.currentTarget) return; + const modal = document.getElementById('messenger-admin-modal'); + if (modal) modal.remove(); +} + +// Load contacts for admin panel +async function loadAdminContacts() { + const container = document.getElementById('admin-contact-list'); + if (!container) return; + + try { + const response = await fetch(`${MESSENGER_API}/contacts`); + if (response.ok) { + messengerContacts = await response.json(); + } + } catch (error) { + console.error('Error loading contacts:', error); + } + + if (messengerContacts.length === 0) { + container.innerHTML = ` +
    +

    Keine Kontakte vorhanden.

    +

    Fuegen Sie Kontakte hinzu oder importieren Sie eine CSV-Datei.

    +
    + `; + return; + } + + container.innerHTML = messengerContacts.map(c => ` +
    +
    👱
    +
    +
    ${escapeHtml(c.name)}
    +
    + ${escapeHtml(c.email || '')} + ${c.student_name ? ' | Schueler: ' + escapeHtml(c.student_name) : ''} + ${c.class_name ? ' (' + c.class_name + ')' : ''} +
    +
    + + +
    + `).join(''); +} + +// Filter contacts in admin panel +function filterAdminContacts(query) { + const lowerQuery = query.toLowerCase(); + document.querySelectorAll('#admin-contact-list .messenger-contact-item').forEach(item => { + const name = item.querySelector('.messenger-contact-name')?.textContent.toLowerCase() || ''; + const role = item.querySelector('.messenger-contact-role')?.textContent.toLowerCase() || ''; + item.style.display = (name.includes(lowerQuery) || role.includes(lowerQuery)) ? 'flex' : 'none'; + }); +} + +// Show add contact form +function showAddContactForm() { + const formHtml = ` +
    +
    +
    +

    Neuer Kontakt

    + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    +
    + `; + document.body.insertAdjacentHTML('beforeend', formHtml); +} + +function closeAddContactForm(event) { + if (event && event.target !== event.currentTarget) return; + const form = document.getElementById('add-contact-form'); + if (form) form.remove(); +} + +async function saveNewContact(event) { + event.preventDefault(); + const form = event.target; + const data = { + name: form.name.value, + email: form.email.value || null, + phone: form.phone.value || null, + student_name: form.student_name.value || null, + class_name: form.class_name.value || null, + contact_type: form.contact_type.value + }; + + try { + const response = await fetch(`${MESSENGER_API}/contacts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + if (response.ok) { + closeAddContactForm(); + loadAdminContacts(); + loadMessengerData(); // Refresh main data + } else { + const error = await response.json(); + alert('Fehler: ' + (error.detail || 'Unbekannter Fehler')); + } + } catch (error) { + console.error('Error saving contact:', error); + alert('Kontakt konnte nicht gespeichert werden.'); + } +} + +// Delete contact +async function deleteContact(contactId) { + if (!confirm('Kontakt wirklich loeschen?')) return; + + try { + const response = await fetch(`${MESSENGER_API}/contacts/${contactId}`, { + method: 'DELETE' + }); + + if (response.ok) { + loadAdminContacts(); + loadMessengerData(); + } else { + alert('Kontakt konnte nicht geloescht werden.'); + } + } catch (error) { + console.error('Error deleting contact:', error); + alert('Fehler beim Loeschen.'); + } +} + +// Edit contact +async function editContact(contactId) { + const contact = messengerContacts.find(c => c.id === contactId); + if (!contact) return; + + const formHtml = ` +
    +
    +
    +

    Kontakt bearbeiten

    + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    +
    + `; + document.body.insertAdjacentHTML('beforeend', formHtml); +} + +function closeEditContactForm(event) { + if (event && event.target !== event.currentTarget) return; + const form = document.getElementById('edit-contact-form'); + if (form) form.remove(); +} + +async function updateContact(event, contactId) { + event.preventDefault(); + const form = event.target; + const data = { + name: form.name.value, + email: form.email.value || null, + phone: form.phone.value || null, + student_name: form.student_name.value || null, + class_name: form.class_name.value || null + }; + + try { + const response = await fetch(`${MESSENGER_API}/contacts/${contactId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + if (response.ok) { + closeEditContactForm(); + loadAdminContacts(); + loadMessengerData(); + } else { + alert('Kontakt konnte nicht aktualisiert werden.'); + } + } catch (error) { + console.error('Error updating contact:', error); + alert('Fehler beim Aktualisieren.'); + } +} + +// Show CSV Import Dialog +function showCSVImport() { + const importHtml = ` +
    +
    +
    +

    CSV Import

    + +
    +
    +

    + Importieren Sie Kontakte aus einer CSV-Datei. Unterstuetzte Spalten: +

    + Name, Kontakt, Email, Telefon, Schueler, Klasse +

    + +
    + + +
    + + +
    +
    +
    + `; + document.body.insertAdjacentHTML('beforeend', importHtml); +} + +function closeCSVImport(event) { + if (event && event.target !== event.currentTarget) return; + const form = document.getElementById('csv-import-form'); + if (form) form.remove(); +} + +let csvFileContent = null; + +function handleCSVFile(input) { + const file = input.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = function(e) { + csvFileContent = e.target.result; + const lines = csvFileContent.split('\\n').slice(0, 6); + const preview = lines.map(l => escapeHtml(l)).join('
    '); + document.getElementById('csv-preview-content').innerHTML = preview; + document.getElementById('csv-import-preview').style.display = 'block'; + }; + reader.readAsText(file); +} + +async function submitCSVImport() { + if (!csvFileContent) return; + + try { + const formData = new FormData(); + const blob = new Blob([csvFileContent], { type: 'text/csv' }); + formData.append('file', blob, 'contacts.csv'); + + const response = await fetch(`${MESSENGER_API}/contacts/import`, { + method: 'POST', + body: formData + }); + + if (response.ok) { + const result = await response.json(); + alert(`Import erfolgreich: ${result.imported} Kontakte importiert.`); + closeCSVImport(); + loadAdminContacts(); + loadMessengerData(); + } else { + const error = await response.json(); + alert('Import fehlgeschlagen: ' + (error.detail || 'Unbekannter Fehler')); + } + } catch (error) { + console.error('Error importing CSV:', error); + alert('CSV Import fehlgeschlagen.'); + } +} + +// Export contacts as CSV +async function exportContacts() { + try { + const response = await fetch(`${MESSENGER_API}/contacts/export/csv`); + if (response.ok) { + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'kontakte_export.csv'; + a.click(); + URL.revokeObjectURL(url); + } else { + alert('Export fehlgeschlagen.'); + } + } catch (error) { + console.error('Error exporting contacts:', error); + alert('Export fehlgeschlagen.'); + } +} +""" diff --git a/backend/frontend/modules/rbac_admin.py b/backend/frontend/modules/rbac_admin.py new file mode 100644 index 0000000..43bff31 --- /dev/null +++ b/backend/frontend/modules/rbac_admin.py @@ -0,0 +1,1472 @@ +""" +BreakPilot Studio - RBAC Admin Modul + +Funktionen: +- Alle Lehrer anzeigen +- Alle verfuegbaren Rollen anzeigen +- Rollen zuweisen und entziehen +- Uebersicht ueber Rollenzuweisungen + +Kommuniziert mit der RBAC API (/api/rbac/*) +""" + + +class RbacAdminModule: + """Modul fuer Lehrer- und Rollenverwaltung.""" + + @staticmethod + def get_css() -> str: + """CSS fuer das RBAC Admin Modul.""" + return """ +/* ============================================= + RBAC ADMIN MODULE - Lehrer & Rollen + ============================================= */ + +.panel-rbac-admin { + display: none; + flex-direction: column; + height: 100%; + background: var(--bp-bg); + overflow-y: auto; +} + +.panel-rbac-admin.active { + display: flex; +} + +/* RBAC Header */ +.rbac-header { + padding: 24px 32px; + background: var(--bp-surface); + border-bottom: 1px solid var(--bp-border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.rbac-header h2 { + font-size: 24px; + font-weight: 600; + color: var(--bp-text); + margin: 0; +} + +/* RBAC Tabs */ +.rbac-tabs { + display: flex; + gap: 4px; + padding: 16px 32px; + background: var(--bp-surface); + border-bottom: 1px solid var(--bp-border); +} + +.rbac-tab { + padding: 10px 20px; + border: none; + background: transparent; + color: var(--bp-text-muted); + font-size: 14px; + font-weight: 500; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; +} + +.rbac-tab:hover { + background: var(--bp-bg); + color: var(--bp-text); +} + +.rbac-tab.active { + background: var(--bp-primary); + color: white; +} + +/* RBAC Content */ +.rbac-content { + padding: 24px 32px; + flex: 1; +} + +/* Role Summary Cards */ +.rbac-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.rbac-summary-card { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + padding: 16px; + cursor: pointer; + transition: all 0.2s; +} + +.rbac-summary-card:hover { + border-color: var(--bp-primary); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.rbac-summary-card.active { + border-color: var(--bp-primary); + background: var(--bp-primary-soft); +} + +.rbac-summary-card-count { + font-size: 32px; + font-weight: 700; + color: var(--bp-primary); + margin-bottom: 4px; +} + +.rbac-summary-card-label { + font-size: 14px; + font-weight: 500; + color: var(--bp-text); +} + +.rbac-summary-card-category { + font-size: 11px; + color: var(--bp-text-muted); + margin-top: 4px; + text-transform: uppercase; +} + +/* Teacher Table */ +.rbac-table-container { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + overflow: hidden; +} + +.rbac-table { + width: 100%; + border-collapse: collapse; +} + +.rbac-table th, +.rbac-table td { + padding: 12px 16px; + text-align: left; + border-bottom: 1px solid var(--bp-border); +} + +.rbac-table th { + background: var(--bp-bg); + font-weight: 600; + font-size: 12px; + color: var(--bp-text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.rbac-table tr:last-child td { + border-bottom: none; +} + +.rbac-table tr:hover td { + background: var(--bp-bg); +} + +/* Teacher Info */ +.rbac-teacher-info { + display: flex; + align-items: center; + gap: 12px; +} + +.rbac-teacher-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--bp-primary-soft); + color: var(--bp-primary); + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 14px; +} + +.rbac-teacher-name { + font-weight: 500; + color: var(--bp-text); +} + +.rbac-teacher-email { + font-size: 12px; + color: var(--bp-text-muted); +} + +/* Role Badges */ +.rbac-role-badges { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.rbac-role-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + font-size: 11px; + font-weight: 500; + border-radius: 12px; + background: var(--bp-bg); + color: var(--bp-text-muted); + border: 1px solid var(--bp-border); +} + +.rbac-role-badge.category-klausur { + background: #fff3cd; + color: #856404; + border-color: #ffc107; +} + +.rbac-role-badge.category-zeugnis { + background: #d1ecf1; + color: #0c5460; + border-color: #17a2b8; +} + +.rbac-role-badge.category-leitung { + background: #d4edda; + color: #155724; + border-color: #28a745; +} + +.rbac-role-badge.category-verwaltung { + background: #e2e3e5; + color: #383d41; + border-color: #6c757d; +} + +.rbac-role-badge.category-admin { + background: #f8d7da; + color: #721c24; + border-color: #dc3545; +} + +/* Role Actions */ +.rbac-actions { + display: flex; + gap: 8px; +} + +.rbac-btn { + padding: 6px 12px; + font-size: 12px; + border-radius: 6px; + border: 1px solid var(--bp-border); + background: var(--bp-surface); + color: var(--bp-text); + cursor: pointer; + transition: all 0.2s; +} + +.rbac-btn:hover { + background: var(--bp-bg); + border-color: var(--bp-primary); +} + +.rbac-btn-primary { + background: var(--bp-primary); + color: white; + border-color: var(--bp-primary); +} + +.rbac-btn-primary:hover { + background: var(--bp-primary-dark); +} + +/* Role Assignment Modal */ +.rbac-modal { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; + align-items: center; + justify-content: center; +} + +.rbac-modal.active { + display: flex; +} + +.rbac-modal-content { + background: var(--bp-surface); + border-radius: 16px; + width: 90%; + max-width: 600px; + max-height: 80vh; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.rbac-modal-header { + padding: 20px 24px; + border-bottom: 1px solid var(--bp-border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.rbac-modal-header h3 { + margin: 0; + font-size: 18px; + font-weight: 600; +} + +.rbac-modal-close { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: var(--bp-text-muted); +} + +.rbac-modal-body { + padding: 24px; + overflow-y: auto; + flex: 1; +} + +.rbac-modal-footer { + padding: 16px 24px; + border-top: 1px solid var(--bp-border); + display: flex; + justify-content: flex-end; + gap: 12px; +} + +/* Role Selector in Modal */ +.rbac-role-selector { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.rbac-role-option { + padding: 12px; + border: 1px solid var(--bp-border); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; +} + +.rbac-role-option:hover { + border-color: var(--bp-primary); +} + +.rbac-role-option.selected { + border-color: var(--bp-primary); + background: var(--bp-primary-soft); +} + +.rbac-role-option-name { + font-weight: 500; + color: var(--bp-text); + margin-bottom: 4px; +} + +.rbac-role-option-desc { + font-size: 12px; + color: var(--bp-text-muted); +} + +/* Loading & Empty States */ +.rbac-loading, +.rbac-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px; + color: var(--bp-text-muted); +} + +.rbac-loading-spinner { + width: 32px; + height: 32px; + border: 3px solid var(--bp-border); + border-top-color: var(--bp-primary); + border-radius: 50%; + animation: rbac-spin 0.8s linear infinite; +} + +@keyframes rbac-spin { + to { transform: rotate(360deg); } +} +""" + + @staticmethod + def get_html() -> str: + """HTML fuer das RBAC Admin Modul.""" + return """ + +
    +
    +

    Lehrer & Rollen

    +
    + + + +
    +
    + +
    + + + + +
    + +
    + +
    +
    +
    +
    +

    Lade Rollen-Uebersicht...

    +
    +
    +
    + + + + + + + + + +
    + + +
    +
    +
    +

    Rolle zuweisen

    + +
    +
    +
    + + +
    +
    + +
    + +
    +
    +
    + +
    +
    + + +
    +
    +
    +

    Neuen Lehrer anlegen

    + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + +
    + +
    +
    +
    + +
    +
    + + +
    +
    +
    +

    Neue Rolle anlegen

    + +
    +
    +
    + + + Nur Kleinbuchstaben und Unterstriche +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + + +
    +
    +
    +

    Rollen bearbeiten:

    + +
    +
    +

    + Waehlen Sie die Rollen aus, die diesem Lehrer zugewiesen werden sollen. + Bereits zugewiesene Rollen sind markiert. +

    +
    + +
    +
    + +
    +
    +
    +""" + + @staticmethod + def get_js() -> str: + """JavaScript fuer das RBAC Admin Modul.""" + return """ +// ============================================= +// RBAC ADMIN MODULE +// ============================================= + +let rbacTeachers = []; +let rbacRoles = []; +let rbacCustomRoles = []; +let rbacSummary = null; +let rbacSelectedRole = null; +let rbacCurrentTab = 'overview'; +let rbacEditingTeacher = null; + +// Role category colors +const ROLE_CATEGORIES = { + klausur: 'category-klausur', + zeugnis: 'category-zeugnis', + leitung: 'category-leitung', + verwaltung: 'category-verwaltung', + admin: 'category-admin', + other: '' +}; + +// Initialize RBAC Module +window.loadRbacAdminModule = function() { + console.log('Loading RBAC Admin module'); + rbacLoadData(); +}; + +// Load all data +async function rbacLoadData() { + try { + await Promise.all([ + rbacLoadSummary(), + rbacLoadTeachers(), + rbacLoadRoles(), + rbacLoadCustomRoles() + ]); + } catch (error) { + console.error('Error loading RBAC data:', error); + } +} + +// Load summary +async function rbacLoadSummary() { + try { + const response = await fetch('/api/rbac/summary'); + if (response.ok) { + rbacSummary = await response.json(); + rbacRenderSummary(); + } + } catch (error) { + console.error('Error loading summary:', error); + } +} + +// Load teachers +async function rbacLoadTeachers() { + try { + const response = await fetch('/api/rbac/teachers'); + if (response.ok) { + rbacTeachers = await response.json(); + rbacRenderTeachers(); + } + } catch (error) { + console.error('Error loading teachers:', error); + } +} + +// Load roles +async function rbacLoadRoles() { + try { + const response = await fetch('/api/rbac/roles'); + if (response.ok) { + rbacRoles = await response.json(); + rbacPopulateRoleSelector(); + rbacPopulateRoleFilter(); + } + } catch (error) { + console.error('Error loading roles:', error); + } +} + +// Render summary cards +function rbacRenderSummary() { + const grid = document.getElementById('rbac-summary-grid'); + if (!rbacSummary) return; + + // Total teachers card + let html = ` +
    +
    ${rbacSummary.total_teachers}
    +
    Lehrer gesamt
    +
    Aktive Lehrkraefte
    +
    + `; + + // Role cards + rbacSummary.roles.forEach(role => { + const categoryClass = ROLE_CATEGORIES[role.category] || ''; + html += ` +
    +
    ${role.count}
    +
    ${role.display_name}
    +
    ${role.category}
    +
    + `; + }); + + grid.innerHTML = html; +} + +// Render teachers table +function rbacRenderTeachers() { + const tbody = document.getElementById('rbac-teachers-tbody'); + if (!rbacTeachers.length) { + tbody.innerHTML = ` + + +
    +

    Keine Lehrer gefunden.

    +
    + + + `; + return; + } + + let html = ''; + rbacTeachers.forEach(teacher => { + const initials = (teacher.first_name[0] + teacher.last_name[0]).toUpperCase(); + const roleBadges = teacher.roles.map(role => { + const roleInfo = rbacRoles.find(r => r.role === role); + const category = roleInfo ? roleInfo.category : 'other'; + const displayName = roleInfo ? roleInfo.display_name : role; + return `${displayName}`; + }).join(''); + + html += ` + + +
    +
    ${initials}
    +
    +
    ${teacher.title || ''} ${teacher.first_name} ${teacher.last_name}
    +
    ${teacher.email}
    +
    +
    + + ${teacher.teacher_code || '-'} + +
    + ${roleBadges || 'Keine Rolle'} +
    + + +
    + +
    + + + `; + }); + + tbody.innerHTML = html; +} + +// Populate role selector in modal +function rbacPopulateRoleSelector() { + const selector = document.getElementById('rbac-role-selector'); + let html = ''; + + rbacRoles.forEach(role => { + html += ` +
    +
    ${role.display_name}
    +
    ${role.description}
    +
    + `; + }); + + selector.innerHTML = html; +} + +// Populate role filter dropdown +function rbacPopulateRoleFilter() { + const filter = document.getElementById('rbac-role-filter'); + let html = ''; + + rbacRoles.forEach(role => { + html += ``; + }); + + filter.innerHTML = html; +} + +// Toggle role option in modal +function rbacToggleRoleOption(element) { + // Remove selected from all + document.querySelectorAll('.rbac-role-option').forEach(el => { + el.classList.remove('selected'); + }); + // Add selected to clicked + element.classList.add('selected'); +} + +// Switch tabs +function rbacSwitchTab(tab) { + rbacCurrentTab = tab; + + // Update tab buttons + document.querySelectorAll('.rbac-tab').forEach((btn, index) => { + btn.classList.remove('active'); + const tabNames = ['overview', 'teachers', 'roles', 'manage-roles']; + if (tabNames[index] === tab) { + btn.classList.add('active'); + } + }); + + // Update tab content + document.querySelectorAll('.rbac-tab-content').forEach(content => { + content.style.display = 'none'; + }); + document.getElementById(`rbac-tab-${tab}`).style.display = 'block'; + + // Load manage roles data when switching to that tab + if (tab === 'manage-roles') { + rbacRenderManageRoles(); + } +} + +// Select role from summary +function rbacSelectRoleFromSummary(role) { + rbacSelectedRole = role; + rbacRenderSummary(); + rbacSwitchTab('roles'); + document.getElementById('rbac-role-filter').value = role; + rbacFilterByRole(); +} + +// Filter by role +async function rbacFilterByRole() { + const role = document.getElementById('rbac-role-filter').value; + const tbody = document.getElementById('rbac-roles-tbody'); + + if (!role) { + tbody.innerHTML = ` + + +
    +

    Bitte waehlen Sie eine Rolle aus.

    +
    + + + `; + return; + } + + tbody.innerHTML = ` + + +
    +
    +

    Lade Lehrer...

    +
    + + + `; + + try { + const response = await fetch(`/api/rbac/roles/${role}/teachers`); + if (response.ok) { + const teachers = await response.json(); + rbacRenderRoleTeachers(teachers); + } + } catch (error) { + console.error('Error filtering by role:', error); + } +} + +// Render teachers for role +function rbacRenderRoleTeachers(teachers) { + const tbody = document.getElementById('rbac-roles-tbody'); + + if (!teachers.length) { + tbody.innerHTML = ` + + +
    +

    Keine Lehrer mit dieser Rolle.

    +
    + + + `; + return; + } + + let html = ''; + teachers.forEach(teacher => { + const initials = (teacher.first_name[0] + teacher.last_name[0]).toUpperCase(); + const roleBadges = teacher.roles.map(role => { + const roleInfo = rbacRoles.find(r => r.role === role); + const category = roleInfo ? roleInfo.category : 'other'; + const displayName = roleInfo ? roleInfo.display_name : role; + return `${displayName}`; + }).join(''); + + html += ` + + +
    +
    ${initials}
    +
    +
    ${teacher.title || ''} ${teacher.first_name} ${teacher.last_name}
    +
    +
    + + ${teacher.email} + +
    + ${roleBadges} +
    + + +
    + +
    + + + `; + }); + + tbody.innerHTML = html; +} + +// Show assign modal +function rbacShowAssignModal() { + // Populate teacher dropdown + const teacherSelect = document.getElementById('rbac-modal-teacher'); + let html = ''; + rbacTeachers.forEach(teacher => { + html += ``; + }); + teacherSelect.innerHTML = html; + + // Show modal + document.getElementById('rbac-assign-modal').classList.add('active'); +} + +// Close modal +function rbacCloseModal() { + document.getElementById('rbac-assign-modal').classList.remove('active'); + // Reset selections + document.querySelectorAll('.rbac-role-option').forEach(el => { + el.classList.remove('selected'); + }); + document.getElementById('rbac-modal-teacher').value = ''; +} + +// Assign role +async function rbacAssignRole() { + const teacherId = document.getElementById('rbac-modal-teacher').value; + const selectedRole = document.querySelector('.rbac-role-option.selected'); + + if (!teacherId) { + alert('Bitte waehlen Sie einen Lehrer aus.'); + return; + } + + if (!selectedRole) { + alert('Bitte waehlen Sie eine Rolle aus.'); + return; + } + + const role = selectedRole.dataset.role; + + try { + const response = await fetch('/api/rbac/assignments', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: teacherId, + role: role, + resource_type: 'tenant', + resource_id: 'a0000000-0000-0000-0000-000000000001' + }) + }); + + if (response.ok) { + rbacCloseModal(); + await rbacLoadData(); + alert('Rolle erfolgreich zugewiesen!'); + } else if (response.status === 409) { + alert('Diese Rolle ist bereits zugewiesen.'); + } else { + const error = await response.json(); + alert('Fehler: ' + (error.detail || 'Unbekannter Fehler')); + } + } catch (error) { + console.error('Error assigning role:', error); + alert('Fehler beim Zuweisen der Rolle.'); + } +} + +// Remove role +async function rbacRemoveRole(userId, role) { + if (!confirm('Moechten Sie diese Rolle wirklich entziehen?')) { + return; + } + + // Find the assignment ID + try { + const response = await fetch(`/api/rbac/teachers/${userId}/roles`); + if (response.ok) { + // Get teacher by user_id - need to find assignment + // For simplicity, we reload data after removal + } + } catch (error) { + console.error('Error:', error); + } +} + +// Edit teacher roles - opens modal with multi-select +function rbacEditTeacherRoles(teacherId) { + const teacher = rbacTeachers.find(t => t.id === teacherId); + if (!teacher) return; + + rbacEditingTeacher = teacher; + document.getElementById('rbac-edit-teacher-name').textContent = `${teacher.first_name} ${teacher.last_name}`; + + // Populate role selector with checkboxes + const selector = document.getElementById('rbac-edit-roles-selector'); + let html = ''; + + rbacRoles.forEach(role => { + const isAssigned = teacher.roles.includes(role.role); + html += ` +
    +
    + + ${role.display_name} +
    +
    ${role.description}
    +
    + `; + }); + + // Also include custom roles + rbacCustomRoles.forEach(role => { + const isAssigned = teacher.roles.includes(role.role); + html += ` +
    +
    + + ${role.display_name} (Eigene Rolle) +
    +
    ${role.description}
    +
    + `; + }); + + selector.innerHTML = html; + document.getElementById('rbac-edit-roles-modal').classList.add('active'); +} + +// Toggle multi-role selection +function rbacToggleMultiRole(element) { + element.classList.toggle('selected'); + const checkbox = element.querySelector('input[type="checkbox"]'); + if (checkbox) checkbox.checked = element.classList.contains('selected'); +} + +// Save teacher roles +async function rbacSaveTeacherRoles() { + if (!rbacEditingTeacher) return; + + const selectedRoles = []; + document.querySelectorAll('#rbac-edit-roles-selector .rbac-role-option.selected').forEach(el => { + selectedRoles.push(el.dataset.role); + }); + + const currentRoles = rbacEditingTeacher.roles; + const rolesToAdd = selectedRoles.filter(r => !currentRoles.includes(r)); + const rolesToRemove = currentRoles.filter(r => !selectedRoles.includes(r)); + + try { + // Add new roles + for (const role of rolesToAdd) { + await fetch('/api/rbac/assignments', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + user_id: rbacEditingTeacher.user_id, + role: role, + resource_type: 'tenant', + resource_id: 'a0000000-0000-0000-0000-000000000001' + }) + }); + } + + // Remove roles + for (const role of rolesToRemove) { + // First get the assignment ID + const response = await fetch(`/api/rbac/teachers/${rbacEditingTeacher.id}/roles`); + if (response.ok) { + const assignments = await response.json(); + const assignment = assignments.find(a => a.role === role && a.is_active); + if (assignment) { + await fetch(`/api/rbac/assignments/${assignment.id}`, { method: 'DELETE' }); + } + } + } + + rbacCloseEditRolesModal(); + await rbacLoadData(); + alert('Rollen erfolgreich aktualisiert!'); + } catch (error) { + console.error('Error saving roles:', error); + alert('Fehler beim Speichern der Rollen.'); + } +} + +// Close edit roles modal +function rbacCloseEditRolesModal() { + document.getElementById('rbac-edit-roles-modal').classList.remove('active'); + rbacEditingTeacher = null; +} + +// ============================================= +// CREATE TEACHER FUNCTIONS +// ============================================= + +function rbacShowCreateTeacherModal() { + // Populate role selector for new teacher + const selector = document.getElementById('rbac-new-teacher-roles'); + let html = ''; + + rbacRoles.forEach(role => { + html += ` +
    +
    + + ${role.display_name} +
    +
    + `; + }); + + selector.innerHTML = html; + document.getElementById('rbac-create-teacher-modal').classList.add('active'); +} + +function rbacCloseCreateTeacherModal() { + document.getElementById('rbac-create-teacher-modal').classList.remove('active'); + // Clear form + document.getElementById('rbac-teacher-firstname').value = ''; + document.getElementById('rbac-teacher-lastname').value = ''; + document.getElementById('rbac-teacher-email').value = ''; + document.getElementById('rbac-teacher-code').value = ''; + document.getElementById('rbac-teacher-title').value = ''; +} + +async function rbacCreateTeacher() { + const firstName = document.getElementById('rbac-teacher-firstname').value.trim(); + const lastName = document.getElementById('rbac-teacher-lastname').value.trim(); + const email = document.getElementById('rbac-teacher-email').value.trim(); + const teacherCode = document.getElementById('rbac-teacher-code').value.trim(); + const title = document.getElementById('rbac-teacher-title').value.trim(); + + if (!firstName || !lastName || !email) { + alert('Bitte fuellen Sie alle Pflichtfelder aus (Vorname, Nachname, E-Mail).'); + return; + } + + // Get selected roles + const roles = []; + document.querySelectorAll('#rbac-new-teacher-roles .rbac-role-option.selected').forEach(el => { + roles.push(el.dataset.role); + }); + + try { + const response = await fetch('/api/rbac/teachers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + first_name: firstName, + last_name: lastName, + email: email, + teacher_code: teacherCode || null, + title: title || null, + roles: roles + }) + }); + + if (response.ok) { + rbacCloseCreateTeacherModal(); + await rbacLoadData(); + alert('Lehrer erfolgreich angelegt!'); + } else if (response.status === 409) { + alert('Ein Lehrer mit dieser E-Mail existiert bereits.'); + } else { + const error = await response.json(); + alert('Fehler: ' + (error.detail || 'Unbekannter Fehler')); + } + } catch (error) { + console.error('Error creating teacher:', error); + alert('Fehler beim Anlegen des Lehrers.'); + } +} + +// ============================================= +// CREATE/MANAGE ROLE FUNCTIONS +// ============================================= + +// Load custom roles +async function rbacLoadCustomRoles() { + try { + const response = await fetch('/api/rbac/custom-roles'); + if (response.ok) { + rbacCustomRoles = await response.json(); + rbacRenderManageRoles(); + } + } catch (error) { + console.error('Error loading custom roles:', error); + } +} + +// Render manage roles tab +function rbacRenderManageRoles() { + // System roles (built-in) + const systemTbody = document.getElementById('rbac-system-roles-tbody'); + let systemHtml = ''; + rbacRoles.forEach(role => { + systemHtml += ` + + ${role.role} + ${role.display_name} + ${role.description} + ${role.category} + + `; + }); + systemTbody.innerHTML = systemHtml; + + // Custom roles + const customTbody = document.getElementById('rbac-custom-roles-tbody'); + if (!rbacCustomRoles.length) { + customTbody.innerHTML = ` + + +
    +

    Keine eigenen Rollen angelegt.

    +
    + + + `; + } else { + let customHtml = ''; + rbacCustomRoles.forEach(role => { + customHtml += ` + + ${role.role} + ${role.display_name} + ${role.description} + ${role.category} + +
    + +
    + + + `; + }); + customTbody.innerHTML = customHtml; + } +} + +function rbacShowCreateRoleModal() { + document.getElementById('rbac-create-role-modal').classList.add('active'); +} + +function rbacCloseCreateRoleModal() { + document.getElementById('rbac-create-role-modal').classList.remove('active'); + // Clear form + document.getElementById('rbac-role-key').value = ''; + document.getElementById('rbac-role-displayname').value = ''; + document.getElementById('rbac-role-description').value = ''; + document.getElementById('rbac-role-category').value = 'other'; +} + +async function rbacCreateRole() { + const roleKey = document.getElementById('rbac-role-key').value.trim().toLowerCase().replace(/[^a-z_]/g, ''); + const displayName = document.getElementById('rbac-role-displayname').value.trim(); + const description = document.getElementById('rbac-role-description').value.trim(); + const category = document.getElementById('rbac-role-category').value; + + if (!roleKey || !displayName || !description) { + alert('Bitte fuellen Sie alle Pflichtfelder aus.'); + return; + } + + try { + const response = await fetch('/api/rbac/custom-roles', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + role_key: roleKey, + display_name: displayName, + description: description, + category: category + }) + }); + + if (response.ok) { + rbacCloseCreateRoleModal(); + await rbacLoadData(); + alert('Rolle erfolgreich angelegt!'); + } else if (response.status === 409) { + alert('Eine Rolle mit diesem Key existiert bereits.'); + } else { + const error = await response.json(); + alert('Fehler: ' + (error.detail || 'Unbekannter Fehler')); + } + } catch (error) { + console.error('Error creating role:', error); + alert('Fehler beim Anlegen der Rolle.'); + } +} + +async function rbacDeleteCustomRole(roleKey) { + if (!confirm(`Moechten Sie die Rolle "${roleKey}" wirklich loeschen? Alle Zuweisungen dieser Rolle werden ebenfalls entfernt.`)) { + return; + } + + try { + const response = await fetch(`/api/rbac/custom-roles/${roleKey}`, { + method: 'DELETE' + }); + + if (response.ok) { + await rbacLoadData(); + alert('Rolle erfolgreich geloescht!'); + } else { + const error = await response.json(); + alert('Fehler: ' + (error.detail || 'Unbekannter Fehler')); + } + } catch (error) { + console.error('Error deleting role:', error); + alert('Fehler beim Loeschen der Rolle.'); + } +} + +// Initialize on panel show +document.addEventListener('DOMContentLoaded', function() { + // Auto-load when panel becomes active + const observer = new MutationObserver((mutations) => { + mutations.forEach((mutation) => { + if (mutation.target.classList && mutation.target.classList.contains('active')) { + if (mutation.target.id === 'panel-rbac-admin') { + rbacLoadData(); + } + } + }); + }); + + const panel = document.getElementById('panel-rbac-admin'); + if (panel) { + observer.observe(panel, { attributes: true, attributeFilter: ['class'] }); + } +}); +""" diff --git a/backend/frontend/modules/school.py b/backend/frontend/modules/school.py new file mode 100644 index 0000000..46e8d40 --- /dev/null +++ b/backend/frontend/modules/school.py @@ -0,0 +1,2466 @@ +""" +BreakPilot Studio - School Service Modul + +Funktionen: +- Klassen & Schueler verwalten +- Klausuren & Tests erstellen und bewerten +- Notenspiegel fuehren +- Klassenbuch (Fehlzeiten, Eintragungen) +- Zeugnisse generieren + +Kommuniziert mit dem Go School-Service (Port 8084) +""" + + +class SchoolModule: + """Modul fuer Schulverwaltung und Leistungsbewertung.""" + + @staticmethod + def get_css() -> str: + """CSS fuer das School-Modul.""" + return """ +/* ============================================= + SCHOOL MODULE - Leistungsbewertung + ============================================= */ + +/* Gemeinsame Panel-Styles */ +.panel-school-classes, +.panel-school-exams, +.panel-school-grades, +.panel-school-gradebook, +.panel-school-certificates { + display: none; + flex-direction: column; + height: 100%; + background: var(--bp-bg); + overflow-y: auto; +} + +.panel-school-classes.active, +.panel-school-exams.active, +.panel-school-grades.active, +.panel-school-gradebook.active, +.panel-school-certificates.active { + display: flex; +} + +/* School Header */ +.school-header { + padding: 24px 32px; + background: var(--bp-surface); + border-bottom: 1px solid var(--bp-border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.school-header h2 { + font-size: 24px; + font-weight: 600; + color: var(--bp-text); + margin: 0; +} + +.school-header-actions { + display: flex; + gap: 12px; +} + +/* School Content */ +.school-content { + padding: 24px 32px; + flex: 1; +} + +/* Cards Grid */ +.school-cards-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 20px; + margin-bottom: 24px; +} + +/* School Card */ +.school-card { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + padding: 20px; + transition: all 0.2s ease; +} + +.school-card:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + border-color: var(--bp-primary); +} + +.school-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 12px; +} + +.school-card-title { + font-size: 16px; + font-weight: 600; + color: var(--bp-text); +} + +.school-card-badge { + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; + background: var(--bp-primary-soft); + color: var(--bp-primary); +} + +.school-card-info { + font-size: 13px; + color: var(--bp-text-muted); + margin-bottom: 16px; +} + +.school-card-actions { + display: flex; + gap: 8px; +} + +/* School Table */ +.school-table { + width: 100%; + border-collapse: collapse; + background: var(--bp-surface); + border-radius: 12px; + overflow: hidden; + border: 1px solid var(--bp-border); +} + +.school-table th, +.school-table td { + padding: 12px 16px; + text-align: left; + border-bottom: 1px solid var(--bp-border); +} + +.school-table th { + background: var(--bp-bg); + font-weight: 600; + font-size: 12px; + color: var(--bp-text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.school-table td { + font-size: 14px; + color: var(--bp-text); +} + +.school-table tr:last-child td { + border-bottom: none; +} + +.school-table tr:hover td { + background: var(--bp-bg); +} + +/* Grade Badge */ +.grade-badge { + display: inline-block; + padding: 4px 10px; + border-radius: 8px; + font-weight: 600; + font-size: 13px; +} + +.grade-badge.grade-1 { background: #d4edda; color: #155724; } +.grade-badge.grade-2 { background: #d1ecf1; color: #0c5460; } +.grade-badge.grade-3 { background: #fff3cd; color: #856404; } +.grade-badge.grade-4 { background: #ffe5d0; color: #a94442; } +.grade-badge.grade-5 { background: #f8d7da; color: #721c24; } +.grade-badge.grade-6 { background: #f5c6cb; color: #721c24; } + +/* Status Badge */ +.status-badge { + display: inline-block; + padding: 4px 10px; + border-radius: 8px; + font-size: 12px; + font-weight: 500; +} + +.status-badge.status-present { background: #d4edda; color: #155724; } +.status-badge.status-absent { background: #f8d7da; color: #721c24; } +.status-badge.status-excused { background: #fff3cd; color: #856404; } +.status-badge.status-late { background: #d1ecf1; color: #0c5460; } + +/* School Form */ +.school-form { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + padding: 24px; + margin-bottom: 24px; +} + +.school-form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-bottom: 16px; +} + +.school-form-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.school-form-group label { + font-size: 13px; + font-weight: 500; + color: var(--bp-text-muted); +} + +.school-form-group input, +.school-form-group select, +.school-form-group textarea { + padding: 10px 14px; + border: 1px solid var(--bp-border); + border-radius: 8px; + font-size: 14px; + background: var(--bp-bg); + color: var(--bp-text); + transition: border-color 0.2s; +} + +.school-form-group input:focus, +.school-form-group select:focus, +.school-form-group textarea:focus { + outline: none; + border-color: var(--bp-primary); +} + +/* School Tabs */ +.school-tabs { + display: flex; + gap: 4px; + padding: 4px; + background: var(--bp-bg); + border-radius: 10px; + margin-bottom: 24px; +} + +.school-tab { + padding: 10px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + color: var(--bp-text-muted); + cursor: pointer; + transition: all 0.2s; + border: none; + background: transparent; +} + +.school-tab:hover { + color: var(--bp-text); +} + +.school-tab.active { + background: var(--bp-surface); + color: var(--bp-primary); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +/* Empty State */ +.school-empty-state { + text-align: center; + padding: 60px 20px; + color: var(--bp-text-muted); +} + +.school-empty-state-icon { + font-size: 48px; + margin-bottom: 16px; +} + +.school-empty-state h3 { + font-size: 18px; + font-weight: 600; + color: var(--bp-text); + margin-bottom: 8px; +} + +.school-empty-state p { + font-size: 14px; + margin-bottom: 24px; +} + +/* Calendar View for Gradebook */ +.school-calendar { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + padding: 16px; +} + +.school-calendar-header { + font-size: 12px; + font-weight: 600; + color: var(--bp-text-muted); + text-align: center; + padding: 8px; +} + +.school-calendar-day { + aspect-ratio: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + font-size: 13px; +} + +.school-calendar-day:hover { + background: var(--bp-bg); +} + +.school-calendar-day.today { + background: var(--bp-primary-soft); + color: var(--bp-primary); + font-weight: 600; +} + +.school-calendar-day.has-entries { + position: relative; +} + +.school-calendar-day.has-entries::after { + content: ''; + position: absolute; + bottom: 4px; + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--bp-primary); +} + +/* Modal for School */ +.school-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: none; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.school-modal.active { + display: flex; +} + +.school-modal-content { + background: var(--bp-surface); + border-radius: 16px; + width: 90%; + max-width: 600px; + max-height: 90vh; + overflow-y: auto; +} + +.school-modal-header { + padding: 20px 24px; + border-bottom: 1px solid var(--bp-border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.school-modal-header h3 { + font-size: 18px; + font-weight: 600; + color: var(--bp-text); + margin: 0; +} + +.school-modal-close { + width: 32px; + height: 32px; + border-radius: 8px; + border: none; + background: var(--bp-bg); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: var(--bp-text-muted); +} + +.school-modal-body { + padding: 24px; +} + +.school-modal-footer { + padding: 16px 24px; + border-top: 1px solid var(--bp-border); + display: flex; + justify-content: flex-end; + gap: 12px; +} + +/* Statistics Cards */ +.school-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.school-stat-card { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + padding: 16px; + text-align: center; +} + +.school-stat-value { + font-size: 28px; + font-weight: 700; + color: var(--bp-primary); + margin-bottom: 4px; +} + +.school-stat-label { + font-size: 12px; + color: var(--bp-text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +/* Loading Spinner */ +.school-loading { + display: flex; + justify-content: center; + padding: 40px; +} + +.school-spinner { + width: 32px; + height: 32px; + border: 3px solid var(--bp-border); + border-top-color: var(--bp-primary); + border-radius: 50%; + animation: school-spin 1s linear infinite; +} + +@keyframes school-spin { + to { transform: rotate(360deg); } +} +""" + + @staticmethod + def get_html() -> str: + """HTML fuer das School-Modul.""" + return """ + +
    +
    +

    Klassen & Schueler

    +
    + + +
    +
    + +
    + +
    +
    + + +
    +
    + + +
    +
    +
    👥
    +

    Keine Klassen vorhanden

    +

    Erstellen Sie Ihre erste Klasse, um Schueler zu verwalten.

    + +
    +
    +
    +
    + + +
    +
    +

    Klausuren & Tests

    +
    + +
    +
    + +
    + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    +
    📄
    +

    Keine Klausuren vorhanden

    +

    Erstellen Sie Ihre erste Klausur oder Test.

    + +
    +
    +
    +
    + + +
    +
    +

    Notenspiegel

    +
    + + +
    +
    + +
    + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + + + + +
    +
    +
    📊
    +

    Klasse waehlen

    +

    Waehlen Sie eine Klasse, um den Notenspiegel anzuzeigen.

    +
    +
    +
    +
    + + +
    +
    +

    Klassenbuch

    +
    + + +
    +
    + +
    + +
    + + +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    +
    📖
    +

    Klasse waehlen

    +

    Waehlen Sie eine Klasse, um das Klassenbuch anzuzeigen.

    +
    +
    + + + +
    +
    + + +
    +
    +

    Zeugnisse

    +
    + + +
    +
    + +
    + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + + + + + + + + + + +
    +
    +
    🏆
    +

    Keine Zeugnisse

    +

    Waehlen Sie eine Klasse und generieren Sie Zeugnisse.

    +
    +
    +
    +
    + + +
    +
    +
    +

    Zeugnis-Wizard

    + +
    +
    + +
    +
    +
    1. Klasse
    +
    Klasse auswaehlen
    +
    +
    +
    2. Noten
    +
    Noten pruefen
    +
    +
    +
    3. Vorlage
    +
    Zeugnisvorlage
    +
    +
    +
    4. Bemerkungen
    +
    Bemerkungen
    +
    +
    +
    5. Generieren
    +
    Erstellen
    +
    +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    + + + + + + + + + + + + +
    + +
    +
    + + + + +
    +
    +
    +

    Neue Klasse

    + +
    +
    +
    + +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + +
    +
    + + +
    +
    +
    +

    Schueler hinzufuegen

    + +
    +
    +
    + + +
    + +
    +
    + +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    + +
    +
    + + +
    +
    +
    +

    Neue Klausur

    + +
    +
    +
    + +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    +
    + +
    +
    + + +
    +
    +
    +

    Muendliche Note eintragen

    + +
    +
    +
    + + +
    + + +
    +
    + + +
    +
    +
    + +
    +
    +""" + + @staticmethod + def get_js() -> str: + """JavaScript fuer das School-Modul.""" + return """ +/* ============================================= + SCHOOL MODULE - JavaScript + ============================================= */ + +// School API Base URL +const SCHOOL_API_BASE = '/api/school'; + +// State +let schoolState = { + years: [], + classes: [], + subjects: [], + currentYearId: null, + currentClassId: null, +}; + +// ========== INITIALIZATION ========== + +async function schoolInit() { + console.log('School module initializing...'); + await schoolLoadYears(); + await schoolLoadSubjects(); + schoolSetTodayDate(); +} + +function schoolSetTodayDate() { + const today = new Date().toISOString().split('T')[0]; + const dateInput = document.getElementById('gradebook-date'); + if (dateInput) { + dateInput.value = today; + } +} + +// ========== API CALLS ========== + +async function schoolApiCall(endpoint, method = 'GET', data = null) { + const options = { + method, + headers: { + 'Content-Type': 'application/json', + } + }; + + if (data) { + options.body = JSON.stringify(data); + } + + try { + const response = await fetch(`${SCHOOL_API_BASE}${endpoint}`, options); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'API Error'); + } + return await response.json(); + } catch (error) { + console.error('School API Error:', error); + showToast('Fehler: ' + error.message, 'error'); + throw error; + } +} + +// ========== YEARS ========== + +async function schoolLoadYears() { + try { + const years = await schoolApiCall('/years'); + schoolState.years = years || []; + + const select = document.getElementById('school-year-select'); + if (select) { + select.innerHTML = ''; + schoolState.years.forEach(year => { + const option = document.createElement('option'); + option.value = year.id; + option.textContent = year.name; + if (year.is_current) { + option.selected = true; + schoolState.currentYearId = year.id; + } + select.appendChild(option); + }); + } + + if (schoolState.currentYearId) { + schoolLoadClasses(); + } + } catch (error) { + console.log('No years found or error loading years'); + } +} + +function schoolShowYearModal() { + // Simplified - just show a prompt + const name = prompt('Schuljahr Name (z.B. 2024/2025):'); + if (name) { + const startDate = prompt('Startdatum (YYYY-MM-DD):'); + const endDate = prompt('Enddatum (YYYY-MM-DD):'); + if (startDate && endDate) { + schoolCreateYear(name, startDate, endDate); + } + } +} + +async function schoolCreateYear(name, startDate, endDate) { + try { + await schoolApiCall('/years', 'POST', { + name, + start_date: startDate, + end_date: endDate, + is_current: true + }); + showToast('Schuljahr erstellt', 'success'); + schoolLoadYears(); + } catch (error) { + // Error handled in schoolApiCall + } +} + +// ========== CLASSES ========== + +async function schoolLoadClasses() { + const yearId = document.getElementById('school-year-select')?.value; + if (!yearId) return; + + schoolState.currentYearId = yearId; + + try { + const classes = await schoolApiCall('/classes'); + schoolState.classes = classes || []; + + schoolRenderClasses(); + schoolUpdateClassSelects(); + } catch (error) { + schoolRenderClasses(); + } +} + +function schoolRenderClasses() { + const container = document.getElementById('school-classes-list'); + if (!container) return; + + if (!schoolState.classes || schoolState.classes.length === 0) { + container.innerHTML = ` +
    +
    👥
    +

    Keine Klassen vorhanden

    +

    Erstellen Sie Ihre erste Klasse, um Schueler zu verwalten.

    + +
    + `; + return; + } + + container.innerHTML = schoolState.classes.map(cls => ` +
    +
    +
    ${cls.name}
    +
    ${cls.grade_level}. Klasse
    +
    +
    + ${cls.school_type || 'Gymnasium'} | ${cls.student_count || 0} Schueler +
    +
    + + + +
    +
    + `).join(''); +} + +function schoolUpdateClassSelects() { + const selects = [ + 'exam-class-filter', + 'grades-class-select', + 'gradebook-class-select', + 'cert-class-select', + 'exam-class' + ]; + + selects.forEach(id => { + const select = document.getElementById(id); + if (select) { + const currentValue = select.value; + select.innerHTML = ''; + schoolState.classes.forEach(cls => { + const option = document.createElement('option'); + option.value = cls.id; + option.textContent = cls.name; + select.appendChild(option); + }); + if (currentValue) { + select.value = currentValue; + } + } + }); +} + +function schoolShowClassModal(classId = null) { + document.getElementById('class-modal-title').textContent = classId ? 'Klasse bearbeiten' : 'Neue Klasse'; + document.getElementById('class-edit-id').value = classId || ''; + document.getElementById('school-class-form').reset(); + document.getElementById('school-class-modal').classList.add('active'); +} + +async function schoolSaveClass() { + const editId = document.getElementById('class-edit-id').value; + const data = { + name: document.getElementById('class-name').value, + grade_level: parseInt(document.getElementById('class-grade-level').value), + school_type: document.getElementById('class-school-type').value, + federal_state: document.getElementById('class-federal-state').value, + school_year_id: schoolState.currentYearId + }; + + try { + if (editId) { + await schoolApiCall(`/classes/${editId}`, 'PUT', data); + showToast('Klasse aktualisiert', 'success'); + } else { + await schoolApiCall('/classes', 'POST', data); + showToast('Klasse erstellt', 'success'); + } + schoolCloseModal('class'); + schoolLoadClasses(); + } catch (error) { + // Error handled in schoolApiCall + } +} + +async function schoolDeleteClass(classId) { + if (!confirm('Klasse wirklich loeschen? Alle Schueler werden ebenfalls geloescht.')) { + return; + } + + try { + await schoolApiCall(`/classes/${classId}`, 'DELETE'); + showToast('Klasse geloescht', 'success'); + schoolLoadClasses(); + } catch (error) { + // Error handled in schoolApiCall + } +} + +function schoolEditClass(classId) { + const cls = schoolState.classes.find(c => c.id === classId); + if (!cls) return; + + document.getElementById('class-modal-title').textContent = 'Klasse bearbeiten'; + document.getElementById('class-edit-id').value = classId; + document.getElementById('class-name').value = cls.name; + document.getElementById('class-grade-level').value = cls.grade_level; + document.getElementById('class-school-type').value = cls.school_type || 'gymnasium'; + document.getElementById('class-federal-state').value = cls.federal_state || 'niedersachsen'; + document.getElementById('school-class-modal').classList.add('active'); +} + +// ========== STUDENTS ========== + +async function schoolViewStudents(classId) { + schoolState.currentClassId = classId; + document.getElementById('student-class-id').value = classId; + + try { + const students = await schoolApiCall(`/classes/${classId}/students`); + schoolShowStudentList(students || []); + } catch (error) { + schoolShowStudentList([]); + } +} + +function schoolShowStudentList(students) { + const cls = schoolState.classes.find(c => c.id === schoolState.currentClassId); + const clsName = cls ? cls.name : 'Klasse'; + + let html = ` +
    +
    +
    +

    Schueler - ${clsName}

    + +
    +
    + + `; + + if (students.length === 0) { + html += ` +
    +
    👥
    +

    Keine Schueler

    +

    Fuegen Sie Schueler zur Klasse hinzu.

    +
    + `; + } else { + html += ` + + + + + + + + + + + `; + students.forEach(student => { + html += ` + + + + + + + `; + }); + html += '
    NameGeburtsdatumSchuelernr.Aktionen
    ${student.last_name}, ${student.first_name}${student.birth_date || '-'}${student.student_number || '-'} + +
    '; + } + + html += '
    '; + + // Remove existing modal if any + const existing = document.getElementById('student-list-modal'); + if (existing) existing.remove(); + + document.body.insertAdjacentHTML('beforeend', html); +} + +function schoolShowStudentModal() { + document.getElementById('school-student-form').reset(); + document.getElementById('school-student-modal').classList.add('active'); +} + +function schoolStudentTab(tab) { + const singleForm = document.getElementById('student-single-form'); + const csvForm = document.getElementById('student-csv-form'); + const tabs = document.querySelectorAll('#school-student-modal .school-tab'); + + tabs.forEach(t => t.classList.remove('active')); + + if (tab === 'single') { + singleForm.style.display = 'block'; + csvForm.style.display = 'none'; + tabs[0].classList.add('active'); + } else { + singleForm.style.display = 'none'; + csvForm.style.display = 'block'; + tabs[1].classList.add('active'); + } +} + +async function schoolSaveStudent() { + const classId = schoolState.currentClassId; + if (!classId) return; + + const data = { + first_name: document.getElementById('student-first-name').value, + last_name: document.getElementById('student-last-name').value, + birth_date: document.getElementById('student-birth-date').value || null, + student_number: document.getElementById('student-number').value || null + }; + + try { + await schoolApiCall(`/classes/${classId}/students`, 'POST', data); + showToast('Schueler hinzugefuegt', 'success'); + schoolCloseModal('student'); + schoolViewStudents(classId); + schoolLoadClasses(); + } catch (error) { + // Error handled in schoolApiCall + } +} + +async function schoolDeleteStudent(studentId) { + if (!confirm('Schueler wirklich loeschen?')) return; + + const classId = schoolState.currentClassId; + try { + await schoolApiCall(`/classes/${classId}/students/${studentId}`, 'DELETE'); + showToast('Schueler geloescht', 'success'); + schoolViewStudents(classId); + schoolLoadClasses(); + } catch (error) { + // Error handled in schoolApiCall + } +} + +// ========== SUBJECTS ========== + +async function schoolLoadSubjects() { + try { + const subjects = await schoolApiCall('/subjects'); + schoolState.subjects = subjects || []; + schoolUpdateSubjectSelects(); + } catch (error) { + // Create some default subjects if none exist + schoolState.subjects = []; + } +} + +function schoolUpdateSubjectSelects() { + const selects = ['exam-subject-filter', 'exam-subject']; + + selects.forEach(id => { + const select = document.getElementById(id); + if (select) { + select.innerHTML = ''; + schoolState.subjects.forEach(subj => { + const option = document.createElement('option'); + option.value = subj.id; + option.textContent = subj.name; + select.appendChild(option); + }); + } + }); +} + +// ========== EXAMS ========== + +async function schoolLoadExams() { + const classId = document.getElementById('exam-class-filter')?.value; + const subjectId = document.getElementById('exam-subject-filter')?.value; + const status = document.getElementById('exam-status-filter')?.value; + + let url = '/exams?'; + if (classId) url += `class_id=${classId}&`; + if (subjectId) url += `subject_id=${subjectId}&`; + if (status) url += `status=${status}&`; + + try { + const exams = await schoolApiCall(url); + schoolRenderExams(exams || []); + } catch (error) { + schoolRenderExams([]); + } +} + +function schoolRenderExams(exams) { + const container = document.getElementById('school-exams-list'); + if (!container) return; + + if (exams.length === 0) { + container.innerHTML = ` +
    +
    📄
    +

    Keine Klausuren vorhanden

    +

    Erstellen Sie Ihre erste Klausur oder Test.

    + +
    + `; + return; + } + + container.innerHTML = ` + + + + + + + + + + + + + + ${exams.map(exam => ` + + + + + + + + + + `).join('')} + +
    TitelKlasseFachTypDatumStatusAktionen
    ${exam.title}${exam.class_name || '-'}${exam.subject_name || '-'}${exam.exam_type}${exam.exam_date || '-'}${exam.status} + + +
    + `; +} + +function schoolShowExamModal(examId = null) { + document.getElementById('exam-modal-title').textContent = examId ? 'Klausur bearbeiten' : 'Neue Klausur'; + document.getElementById('exam-edit-id').value = examId || ''; + document.getElementById('school-exam-form').reset(); + document.getElementById('school-exam-modal').classList.add('active'); +} + +async function schoolSaveExam() { + const editId = document.getElementById('exam-edit-id').value; + const data = { + title: document.getElementById('exam-title').value, + class_id: document.getElementById('exam-class').value, + subject_id: document.getElementById('exam-subject').value, + exam_type: document.getElementById('exam-type').value, + exam_date: document.getElementById('exam-date').value || null, + topic: document.getElementById('exam-topic').value || null, + max_points: parseFloat(document.getElementById('exam-max-points').value) || null, + content: document.getElementById('exam-content').value || null + }; + + try { + if (editId) { + await schoolApiCall(`/exams/${editId}`, 'PUT', data); + showToast('Klausur aktualisiert', 'success'); + } else { + await schoolApiCall('/exams', 'POST', data); + showToast('Klausur erstellt', 'success'); + } + schoolCloseModal('exam'); + schoolLoadExams(); + } catch (error) { + // Error handled in schoolApiCall + } +} + +function schoolEditExam(examId) { + // TODO: Load exam data and populate form + schoolShowExamModal(examId); +} + +function schoolShowResults(examId) { + // TODO: Show exam results modal + showToast('Ergebnisse-Ansicht in Entwicklung', 'info'); +} + +// ========== GRADES ========== + +async function schoolLoadGrades() { + const classId = document.getElementById('grades-class-select')?.value; + const semester = document.getElementById('grades-semester-select')?.value; + + if (!classId) return; + + try { + const grades = await schoolApiCall(`/grades/${classId}?semester=${semester}`); + schoolRenderGrades(grades); + document.getElementById('grades-stats').style.display = 'grid'; + } catch (error) { + schoolRenderGrades(null); + } +} + +function schoolRenderGrades(grades) { + const container = document.getElementById('school-grades-table'); + if (!container) return; + + if (!grades || !grades.students || grades.students.length === 0) { + container.innerHTML = ` +
    +
    📊
    +

    Keine Noten vorhanden

    +

    Es wurden noch keine Noten fuer diese Klasse eingetragen.

    +
    + `; + return; + } + + // Calculate stats + let totalGrades = 0; + let gradeSum = 0; + let bestGrade = 6; + let pendingCount = 0; + + grades.students.forEach(student => { + if (student.final_grade) { + totalGrades++; + gradeSum += student.final_grade; + if (student.final_grade < bestGrade) { + bestGrade = student.final_grade; + } + } else { + pendingCount++; + } + }); + + document.getElementById('stat-avg-grade').textContent = totalGrades > 0 ? (gradeSum / totalGrades).toFixed(2) : '-'; + document.getElementById('stat-best-grade').textContent = bestGrade < 6 ? bestGrade.toFixed(1) : '-'; + document.getElementById('stat-students').textContent = grades.students.length; + document.getElementById('stat-pending').textContent = pendingCount; + + // Render table + container.innerHTML = ` + + + + + + + + + + + + ${grades.students.map(student => ` + + + + + + + + `).join('')} + +
    NameSchriftl.Muendl.EndnoteAktionen
    ${student.last_name}, ${student.first_name}${student.written_grade_avg ? student.written_grade_avg.toFixed(2) : '-'}${student.oral_grade ? student.oral_grade.toFixed(1) : '-'} + ${student.final_grade + ? `${student.final_grade.toFixed(1)}` + : '-' + } + + +
    + `; +} + +function schoolShowOralGradeModal(studentId, subjectId) { + document.getElementById('oral-student-id').value = studentId; + document.getElementById('oral-subject-id').value = subjectId || ''; + document.getElementById('school-oral-grade-form').reset(); + document.getElementById('school-oral-grade-modal').classList.add('active'); +} + +async function schoolSaveOralGrade() { + const studentId = document.getElementById('oral-student-id').value; + const subjectId = document.getElementById('oral-subject-id').value; + const grade = parseFloat(document.getElementById('oral-grade').value); + const notes = document.getElementById('oral-notes').value; + + try { + await schoolApiCall(`/grades/${studentId}/${subjectId}/oral`, 'PUT', { + oral_grade: grade, + oral_notes: notes + }); + showToast('Muendliche Note gespeichert', 'success'); + schoolCloseModal('oral-grade'); + schoolLoadGrades(); + } catch (error) { + // Error handled in schoolApiCall + } +} + +async function schoolCalculateGrades() { + const classId = document.getElementById('grades-class-select')?.value; + const semester = document.getElementById('grades-semester-select')?.value; + + if (!classId) { + showToast('Bitte waehlen Sie eine Klasse', 'warning'); + return; + } + + try { + await schoolApiCall('/grades/calculate', 'POST', { + class_id: classId, + semester: parseInt(semester) + }); + showToast('Noten berechnet', 'success'); + schoolLoadGrades(); + } catch (error) { + // Error handled in schoolApiCall + } +} + +function schoolExportGrades() { + showToast('Export-Funktion in Entwicklung', 'info'); +} + +// ========== GRADEBOOK ========== + +function schoolSwitchGradebookTab(tab) { + const attendanceTab = document.getElementById('gradebook-attendance-tab'); + const entriesTab = document.getElementById('gradebook-entries-tab'); + const tabs = document.querySelectorAll('#panel-school-gradebook .school-tab'); + + tabs.forEach(t => t.classList.remove('active')); + + if (tab === 'attendance') { + attendanceTab.style.display = 'block'; + entriesTab.style.display = 'none'; + tabs[0].classList.add('active'); + } else { + attendanceTab.style.display = 'none'; + entriesTab.style.display = 'block'; + tabs[1].classList.add('active'); + } +} + +async function schoolLoadGradebook() { + const classId = document.getElementById('gradebook-class-select')?.value; + const date = document.getElementById('gradebook-date')?.value; + + if (!classId) return; + + try { + const attendance = await schoolApiCall(`/attendance/${classId}?date=${date}`); + schoolRenderAttendance(attendance); + } catch (error) { + schoolRenderAttendance([]); + } +} + +function schoolRenderAttendance(attendance) { + const container = document.getElementById('gradebook-attendance-tab'); + if (!container) return; + + if (!attendance || attendance.length === 0) { + container.innerHTML = ` +
    +
    📖
    +

    Keine Fehlzeiten

    +

    Erfassen Sie Fehlzeiten fuer die Klasse.

    + +
    + `; + return; + } + + container.innerHTML = ` + + + + + + + + + + + ${attendance.map(a => ` + + + + + + + `).join('')} + +
    NameStatusStundenGrund
    ${a.student_name}${a.status}${a.periods || 1}${a.reason || '-'}
    + `; +} + +function schoolShowAttendanceModal() { + showToast('Fehlzeiten-Erfassung in Entwicklung', 'info'); +} + +function schoolShowEntryModal() { + showToast('Eintrag-Funktion in Entwicklung', 'info'); +} + +// ========== CERTIFICATES ========== + +// Wizard state +let wizardState = { + currentStep: 1, + classId: null, + semester: 1, + certType: 'halbjahr', + template: 'generic_sekundarstufe1', + students: [], + remarks: {}, + defaultRemark: '' +}; + +async function schoolLoadCertificates() { + const classId = document.getElementById('cert-class-select')?.value; + const semester = document.getElementById('cert-semester-select')?.value; + + if (!classId) { + document.getElementById('cert-stats').style.display = 'none'; + document.getElementById('cert-notenspiegel').style.display = 'none'; + document.getElementById('cert-workflow').style.display = 'none'; + return; + } + + try { + // Load class statistics + const stats = await schoolApiCall(`/statistics/${classId}?semester=${semester}`); + schoolRenderCertificateStats(stats); + + // Load notenspiegel + const notenspiegel = await schoolApiCall(`/statistics/${classId}/notenspiegel?semester=${semester}`); + schoolRenderNotenspiegel(notenspiegel); + + // Load certificates + const certs = await schoolApiCall(`/certificates/class/${classId}?semester=${semester}`); + schoolRenderCertificatesList(certs); + + // Show workflow + document.getElementById('cert-workflow').style.display = 'block'; + + } catch (error) { + console.error('Error loading certificates:', error); + document.getElementById('cert-stats').style.display = 'none'; + document.getElementById('cert-notenspiegel').style.display = 'none'; + schoolRenderCertificatesList([]); + } +} + +function schoolRenderCertificateStats(stats) { + if (!stats) return; + + document.getElementById('cert-stats').style.display = 'grid'; + document.getElementById('cert-stat-avg').textContent = stats.class_average ? stats.class_average.toFixed(2) : '-'; + document.getElementById('cert-stat-pass').textContent = stats.pass_rate ? Math.round(stats.pass_rate) + '%' : '-'; + document.getElementById('cert-stat-risk').textContent = stats.students_at_risk || '0'; + document.getElementById('cert-stat-ready').textContent = stats.student_count || '0'; +} + +function schoolRenderNotenspiegel(data) { + if (!data || !data.distribution) return; + + document.getElementById('cert-notenspiegel').style.display = 'block'; + + const maxCount = Math.max(...Object.values(data.distribution), 1); + + for (let grade = 1; grade <= 6; grade++) { + const bar = document.querySelector(`.notenspiegel-bar[data-grade="${grade}"] .notenspiegel-bar-fill`); + if (bar) { + const count = data.distribution[String(grade)] || 0; + const height = (count / maxCount) * 100; + bar.style.height = height + '%'; + bar.title = count + ' Schueler'; + } + } +} + +function schoolRenderCertificatesList(certs) { + const container = document.getElementById('school-certificates-list'); + if (!container) return; + + if (!certs || certs.length === 0) { + container.innerHTML = ` +
    +
    🏆
    +

    Bereit zur Generierung

    +

    Klicken Sie auf "Zeugnisse generieren" oder nutzen Sie den Wizard.

    +
    + `; + return; + } + + container.innerHTML = ` + + + + + + + + + + + + ${certs.map(cert => ` + + + + + + + + `).join('')} + +
    SchuelerTypStatusErstelltAktionen
    ${cert.student_name}${cert.certificate_type}${cert.status}${cert.created_at ? new Date(cert.created_at).toLocaleDateString('de-DE') : '-'} + + +
    + `; +} + +async function schoolGenerateCertificates() { + const classId = document.getElementById('cert-class-select')?.value; + const semester = document.getElementById('cert-semester-select')?.value; + const template = document.getElementById('cert-template-select')?.value; + + if (!classId) { + showToast('Bitte waehlen Sie eine Klasse', 'warning'); + return; + } + + try { + await schoolApiCall('/certificates/generate-bulk', 'POST', { + class_id: classId, + semester: parseInt(semester), + template_name: template, + certificate_type: 'halbjahr' + }); + showToast('Zeugnisse werden generiert...', 'success'); + setTimeout(() => schoolLoadCertificates(), 2000); + } catch (error) { + showToast('Fehler bei der Generierung', 'error'); + } +} + +function schoolViewCertificate(certId) { + showToast('Zeugnis-Ansicht in Entwicklung', 'info'); +} + +function schoolDownloadCertificate(certId) { + window.open(`${SCHOOL_API_BASE}/certificates/detail/${certId}/pdf`, '_blank'); +} + +// ========== WIZARD FUNCTIONS ========== + +function schoolShowCertificateWizard() { + wizardState = { + currentStep: 1, + classId: null, + semester: 1, + certType: 'halbjahr', + template: 'generic_sekundarstufe1', + students: [], + remarks: {}, + defaultRemark: '' + }; + + // Populate class select + const select = document.getElementById('wizard-class'); + select.innerHTML = ''; + schoolState.classes.forEach(cls => { + const option = document.createElement('option'); + option.value = cls.id; + option.textContent = cls.name; + select.appendChild(option); + }); + + // Show first step + wizardShowStep(1); + document.getElementById('school-cert-wizard-modal').classList.add('active'); +} + +function wizardShowStep(step) { + wizardState.currentStep = step; + + // Hide all steps + for (let i = 1; i <= 5; i++) { + const stepEl = document.getElementById(`wizard-step-${i}`); + if (stepEl) stepEl.style.display = 'none'; + } + + // Show current step + const currentStep = document.getElementById(`wizard-step-${step}`); + if (currentStep) currentStep.style.display = 'block'; + + // Update step indicators + document.querySelectorAll('.wizard-step').forEach((el, idx) => { + const stepNum = idx + 1; + const title = el.querySelector('div:first-child'); + if (stepNum <= step) { + el.classList.add('active'); + if (title) title.style.color = 'var(--bp-primary)'; + } else { + el.classList.remove('active'); + if (title) title.style.color = 'var(--bp-text-muted)'; + } + }); + + // Update buttons + document.getElementById('wizard-prev-btn').style.display = step > 1 ? 'inline-flex' : 'none'; + document.getElementById('wizard-next-btn').textContent = step === 5 ? 'Fertig' : 'Weiter'; + + // Load step content + if (step === 2) wizardLoadGrades(); + if (step === 4) wizardLoadRemarks(); + if (step === 5) wizardLoadSummary(); +} + +function wizardNextStep() { + if (wizardState.currentStep === 5) { + schoolCloseModal('cert-wizard'); + return; + } + + // Validate current step + if (wizardState.currentStep === 1) { + const classId = document.getElementById('wizard-class').value; + if (!classId) { + showToast('Bitte waehlen Sie eine Klasse', 'warning'); + return; + } + wizardState.classId = classId; + wizardState.semester = parseInt(document.getElementById('wizard-semester').value); + wizardState.certType = document.getElementById('wizard-cert-type').value; + } + + if (wizardState.currentStep === 3) { + wizardState.template = document.getElementById('wizard-template').value; + } + + if (wizardState.currentStep === 4) { + wizardState.defaultRemark = document.getElementById('wizard-default-remark').value; + } + + wizardShowStep(wizardState.currentStep + 1); +} + +function wizardPrevStep() { + if (wizardState.currentStep > 1) { + wizardShowStep(wizardState.currentStep - 1); + } +} + +async function wizardLoadClassPreview() { + const classId = document.getElementById('wizard-class').value; + if (!classId) { + document.getElementById('wizard-class-preview').style.display = 'none'; + return; + } + + try { + const stats = await schoolApiCall(`/statistics/${classId}`); + document.getElementById('wizard-class-preview').style.display = 'block'; + document.getElementById('wizard-class-stats').innerHTML = ` +
    +
    Schueler: ${stats.student_count || 0}
    +
    Durchschnitt: ${stats.class_average ? stats.class_average.toFixed(2) : '-'}
    +
    Gefaehrdet: ${stats.students_at_risk || 0}
    +
    + `; + } catch (error) { + document.getElementById('wizard-class-preview').style.display = 'none'; + } +} + +async function wizardLoadGrades() { + const container = document.getElementById('wizard-grades-table'); + container.innerHTML = '
    '; + + try { + // Get students + const students = await schoolApiCall(`/classes/${wizardState.classId}/students`); + wizardState.students = students || []; + + // Get grades + const grades = await schoolApiCall(`/grades/${wizardState.classId}?semester=${wizardState.semester}`); + + container.innerHTML = ` + + + + + + + + + + + + ${wizardState.students.map(student => { + const grade = grades?.find?.(g => g.student_id === student.id); + const hasAllGrades = grade?.final_grade != null; + return ` + + + + + + + + `; + }).join('')} + +
    NameSchnittMuendl.EndnoteStatus
    ${student.last_name}, ${student.first_name}${grade?.written_grade_avg?.toFixed(2) || '-'}${grade?.oral_grade?.toFixed(1) || '-'} + ${grade?.final_grade + ? `${grade.final_grade.toFixed(1)}` + : 'Fehlt' + } + ${hasAllGrades ? '✓' : '✗'}
    + `; + } catch (error) { + container.innerHTML = '

    Fehler beim Laden der Noten.

    '; + } +} + +function wizardUpdateTemplates() { + const bundesland = document.getElementById('wizard-bundesland').value; + const templateSelect = document.getElementById('wizard-template'); + + // Templates based on Bundesland + const templates = { + 'niedersachsen': [ + { value: 'niedersachsen_gymnasium', text: 'Niedersachsen Gymnasium' }, + { value: 'niedersachsen_realschule', text: 'Niedersachsen Realschule' } + ], + 'nordrhein-westfalen': [ + { value: 'nrw_gymnasium', text: 'NRW Gymnasium' }, + { value: 'nrw_gesamtschule', text: 'NRW Gesamtschule' } + ], + 'bayern': [ + { value: 'bayern_gymnasium', text: 'Bayern Gymnasium' }, + { value: 'bayern_realschule', text: 'Bayern Realschule' } + ] + }; + + const defaultTemplates = [ + { value: 'generic_sekundarstufe1', text: 'Standard Sek I' }, + { value: 'generic_sekundarstufe2', text: 'Standard Sek II' } + ]; + + const options = templates[bundesland] || defaultTemplates; + + templateSelect.innerHTML = options.map(opt => + `` + ).join(''); +} + +function wizardLoadRemarks() { + const container = document.getElementById('wizard-remarks-list'); + + container.innerHTML = wizardState.students.map(student => ` +
    + ${student.last_name}, ${student.first_name} + +
    + `).join(''); +} + +function wizardLoadSummary() { + const cls = schoolState.classes.find(c => c.id === wizardState.classId); + + document.getElementById('wizard-summary').innerHTML = ` +
    + Klasse: ${cls?.name || wizardState.classId} + Halbjahr: ${wizardState.semester}. Halbjahr + Zeugnisart: ${wizardState.certType} + Vorlage: ${wizardState.template} + Schueler: ${wizardState.students.length} + Mit Bemerkungen: ${Object.keys(wizardState.remarks).filter(k => wizardState.remarks[k]).length} +
    + `; +} + +async function wizardGenerateCertificates() { + const progressDiv = document.getElementById('wizard-progress'); + const progressBar = document.getElementById('wizard-progress-bar'); + const progressText = document.getElementById('wizard-progress-text'); + + progressDiv.style.display = 'block'; + progressBar.style.width = '0%'; + progressText.textContent = 'Starte Generierung...'; + + try { + let completed = 0; + const total = wizardState.students.length; + + for (const student of wizardState.students) { + progressText.textContent = `Generiere Zeugnis fuer ${student.first_name} ${student.last_name}...`; + + await schoolApiCall('/certificates/generate', 'POST', { + student_id: student.id, + school_year_id: schoolState.currentYearId, + semester: wizardState.semester, + certificate_type: wizardState.certType, + template_name: wizardState.template, + remarks: wizardState.remarks[student.id] || wizardState.defaultRemark + }); + + completed++; + progressBar.style.width = (completed / total * 100) + '%'; + } + + progressText.textContent = 'Alle Zeugnisse generiert!'; + showToast(`${total} Zeugnisse erfolgreich generiert`, 'success'); + + setTimeout(() => { + schoolCloseModal('cert-wizard'); + schoolLoadCertificates(); + }, 1500); + + } catch (error) { + progressText.textContent = 'Fehler bei der Generierung'; + showToast('Fehler: ' + error.message, 'error'); + } +} + +// ========== MODAL HELPERS ========== + +function schoolCloseModal(type) { + const modal = document.getElementById(`school-${type}-modal`); + if (modal) { + modal.classList.remove('active'); + } +} + +// ========== MODULE LOADING HOOKS ========== + +// Define load functions for each school module panel +// These are called by the global loadModule function in base.py +window.loadSchoolClassesModule = function() { + console.log('Loading School Classes module'); + schoolInit(); +}; + +window.loadSchoolExamsModule = function() { + console.log('Loading School Exams module'); + schoolInit(); +}; + +window.loadSchoolGradesModule = function() { + console.log('Loading School Grades module'); + schoolInit(); +}; + +window.loadSchoolGradebookModule = function() { + console.log('Loading School Gradebook module'); + schoolInit(); +}; + +window.loadSchoolCertificatesModule = function() { + console.log('Loading School Certificates module'); + schoolInit(); +}; + +// Initialize on DOM ready if school panel is active +document.addEventListener('DOMContentLoaded', function() { + const activeSchoolPanel = document.querySelector('.panel-school-classes.active, .panel-school-exams.active, .panel-school-grades.active, .panel-school-gradebook.active, .panel-school-certificates.active'); + if (activeSchoolPanel) { + schoolInit(); + } +}); + +console.log('School module loaded'); +""" diff --git a/backend/frontend/modules/security.py b/backend/frontend/modules/security.py new file mode 100644 index 0000000..49c159d --- /dev/null +++ b/backend/frontend/modules/security.py @@ -0,0 +1,1461 @@ +""" +BreakPilot Studio - Security Dashboard Module + +DevSecOps Dashboard fuer Entwickler, Security-Experten und Ops: + +Features fuer Developer: +- Scan-Ergebnisse auf einen Blick +- Pre-commit Hook Status +- Code Quality Metriken +- Quick-Fix Suggestions + +Features fuer Security: +- Vulnerability Severity Distribution +- CVE-Tracking und Trends +- SBOM-Viewer +- Compliance-Status (OWASP Top 10) +- Secrets Detection History + +Features fuer Ops: +- Container Image Scan Results +- Dependency Update Status +- Security Scan Scheduling +- CI/CD Pipeline Integration Status +- Runtime Security Alerts (Falco) +""" + + +class SecurityModule: + """DevSecOps Security Dashboard Modul.""" + + @staticmethod + def get_css() -> str: + """CSS fuer das Security Dashboard.""" + return """ +/* ============================================= + SECURITY DASHBOARD MODULE - DevSecOps + ============================================= */ + +.panel-security { + display: none; + flex-direction: column; + height: 100%; + background: var(--bp-bg); + overflow-y: auto; +} + +.panel-security.active { + display: flex; +} + +/* Security Header */ +.security-header { + padding: 24px 32px; + background: var(--bp-surface); + border-bottom: 1px solid var(--bp-border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.security-header h2 { + font-size: 24px; + font-weight: 600; + color: var(--bp-text); + margin: 0; + display: flex; + align-items: center; + gap: 12px; +} + +.security-header-icon { + font-size: 28px; +} + +.security-header-actions { + display: flex; + gap: 12px; + align-items: center; +} + +.security-status-badge { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; +} + +.security-status-badge.secure { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; +} + +.security-status-badge.warning { + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; +} + +.security-status-badge.critical { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; +} + +/* Security Tabs */ +.security-tabs { + display: flex; + gap: 4px; + padding: 16px 32px; + background: var(--bp-surface); + border-bottom: 1px solid var(--bp-border); + flex-wrap: wrap; +} + +.security-tab { + padding: 10px 20px; + border: none; + background: transparent; + color: var(--bp-text-muted); + font-size: 14px; + font-weight: 500; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 8px; +} + +.security-tab:hover { + background: var(--bp-bg); + color: var(--bp-text); +} + +.security-tab.active { + background: var(--bp-primary); + color: white; +} + +.security-tab-badge { + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + background: rgba(255, 255, 255, 0.2); +} + +.security-tab.active .security-tab-badge { + background: rgba(255, 255, 255, 0.3); +} + +/* Security Content */ +.security-content { + padding: 24px 32px; + flex: 1; +} + +/* Security Summary Cards */ +.security-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.security-summary-card { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + padding: 20px; + transition: all 0.2s; +} + +.security-summary-card:hover { + border-color: var(--bp-primary); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.security-summary-card.critical { + border-left: 4px solid #ef4444; +} + +.security-summary-card.high { + border-left: 4px solid #f97316; +} + +.security-summary-card.medium { + border-left: 4px solid #f59e0b; +} + +.security-summary-card.low { + border-left: 4px solid #22c55e; +} + +.security-summary-card.info { + border-left: 4px solid #3b82f6; +} + +.security-summary-icon { + font-size: 24px; + margin-bottom: 8px; +} + +.security-summary-count { + font-size: 36px; + font-weight: 700; + margin-bottom: 4px; +} + +.security-summary-card.critical .security-summary-count { color: #ef4444; } +.security-summary-card.high .security-summary-count { color: #f97316; } +.security-summary-card.medium .security-summary-count { color: #f59e0b; } +.security-summary-card.low .security-summary-count { color: #22c55e; } +.security-summary-card.info .security-summary-count { color: #3b82f6; } + +.security-summary-label { + font-size: 13px; + font-weight: 500; + color: var(--bp-text); +} + +.security-summary-sublabel { + font-size: 11px; + color: var(--bp-text-muted); + margin-top: 4px; +} + +/* Tool Status Section */ +.security-tools-section { + margin-bottom: 24px; +} + +.security-section-title { + font-size: 16px; + font-weight: 600; + color: var(--bp-text); + margin-bottom: 16px; + display: flex; + align-items: center; + gap: 8px; +} + +.security-tools-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; +} + +.security-tool-card { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + padding: 20px; +} + +.security-tool-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.security-tool-name { + font-size: 16px; + font-weight: 600; + color: var(--bp-text); + display: flex; + align-items: center; + gap: 8px; +} + +.security-tool-status { + padding: 4px 10px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; +} + +.security-tool-status.installed { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; +} + +.security-tool-status.not-installed { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; +} + +.security-tool-status.running { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; +} + +.security-tool-description { + font-size: 13px; + color: var(--bp-text-muted); + margin-bottom: 12px; + line-height: 1.5; +} + +.security-tool-meta { + display: flex; + gap: 16px; + font-size: 12px; + color: var(--bp-text-muted); +} + +.security-tool-meta-item { + display: flex; + align-items: center; + gap: 4px; +} + +.security-tool-actions { + margin-top: 12px; + display: flex; + gap: 8px; +} + +.security-tool-btn { + padding: 8px 16px; + border-radius: 6px; + font-size: 12px; + font-weight: 500; + border: none; + cursor: pointer; + transition: all 0.2s; +} + +.security-tool-btn.primary { + background: var(--bp-primary); + color: white; +} + +.security-tool-btn.primary:hover { + background: var(--bp-primary-hover); +} + +.security-tool-btn.secondary { + background: var(--bp-surface-elevated); + color: var(--bp-text); + border: 1px solid var(--bp-border); +} + +.security-tool-btn.secondary:hover { + background: var(--bp-bg); +} + +/* Findings Table */ +.security-findings-container { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + overflow: hidden; +} + +.security-findings-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--bp-border); +} + +.security-findings-title { + font-size: 16px; + font-weight: 600; + color: var(--bp-text); +} + +.security-findings-filter { + display: flex; + gap: 8px; +} + +.security-filter-btn { + padding: 6px 12px; + border-radius: 6px; + font-size: 12px; + border: 1px solid var(--bp-border); + background: transparent; + color: var(--bp-text-muted); + cursor: pointer; + transition: all 0.2s; +} + +.security-filter-btn:hover, +.security-filter-btn.active { + background: var(--bp-primary); + color: white; + border-color: var(--bp-primary); +} + +.security-findings-table { + width: 100%; + border-collapse: collapse; +} + +.security-findings-table th, +.security-findings-table td { + padding: 12px 16px; + text-align: left; + border-bottom: 1px solid var(--bp-border); +} + +.security-findings-table th { + background: var(--bp-surface-elevated); + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + color: var(--bp-text-muted); +} + +.security-findings-table td { + font-size: 13px; + color: var(--bp-text); +} + +.security-findings-table tr:hover { + background: var(--bp-bg); +} + +.severity-badge { + padding: 4px 10px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; +} + +.severity-badge.critical { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; +} + +.severity-badge.high { + background: rgba(249, 115, 22, 0.15); + color: #f97316; +} + +.severity-badge.medium { + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; +} + +.severity-badge.low { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; +} + +.severity-badge.info { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; +} + +/* SBOM Viewer */ +.sbom-container { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + overflow: hidden; +} + +.sbom-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--bp-border); +} + +.sbom-search { + padding: 8px 16px; + border-radius: 8px; + border: 1px solid var(--bp-border); + background: var(--bp-surface-elevated); + color: var(--bp-text); + font-size: 13px; + width: 300px; +} + +.sbom-stats { + display: flex; + gap: 24px; + padding: 16px 20px; + background: var(--bp-bg); + border-bottom: 1px solid var(--bp-border); +} + +.sbom-stat { + text-align: center; +} + +.sbom-stat-value { + font-size: 24px; + font-weight: 700; + color: var(--bp-primary); +} + +.sbom-stat-label { + font-size: 12px; + color: var(--bp-text-muted); +} + +/* Timeline / History */ +.security-timeline { + padding: 20px; +} + +.security-timeline-item { + display: flex; + gap: 16px; + padding-bottom: 20px; + border-left: 2px solid var(--bp-border); + margin-left: 8px; + padding-left: 20px; + position: relative; +} + +.security-timeline-item::before { + content: ''; + position: absolute; + left: -6px; + top: 0; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--bp-primary); +} + +.security-timeline-item.success::before { + background: #22c55e; +} + +.security-timeline-item.warning::before { + background: #f59e0b; +} + +.security-timeline-item.error::before { + background: #ef4444; +} + +.security-timeline-time { + font-size: 12px; + color: var(--bp-text-muted); + min-width: 100px; +} + +.security-timeline-content { + flex: 1; +} + +.security-timeline-title { + font-size: 14px; + font-weight: 500; + color: var(--bp-text); +} + +.security-timeline-desc { + font-size: 13px; + color: var(--bp-text-muted); + margin-top: 4px; +} + +/* Loading Spinner */ +.security-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 60px; + color: var(--bp-text-muted); +} + +.security-spinner { + width: 40px; + height: 40px; + border: 3px solid var(--bp-border); + border-top-color: var(--bp-primary); + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 16px; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Empty State */ +.security-empty { + text-align: center; + padding: 60px 20px; + color: var(--bp-text-muted); +} + +.security-empty-icon { + font-size: 48px; + margin-bottom: 16px; +} + +.security-empty-title { + font-size: 18px; + font-weight: 600; + color: var(--bp-text); + margin-bottom: 8px; +} + +.security-empty-desc { + font-size: 14px; + max-width: 400px; + margin: 0 auto; +} + +/* Scan Progress */ +.security-scan-progress { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + padding: 20px; + margin-bottom: 24px; +} + +.security-scan-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.security-scan-title { + font-size: 14px; + font-weight: 600; + color: var(--bp-text); +} + +.security-scan-percentage { + font-size: 14px; + font-weight: 600; + color: var(--bp-primary); +} + +.security-progress-bar { + height: 8px; + background: var(--bp-bg); + border-radius: 4px; + overflow: hidden; +} + +.security-progress-fill { + height: 100%; + background: var(--bp-primary); + border-radius: 4px; + transition: width 0.3s ease; +} + +.security-scan-steps { + display: flex; + justify-content: space-between; + margin-top: 12px; + font-size: 12px; + color: var(--bp-text-muted); +} + +.security-scan-step { + display: flex; + align-items: center; + gap: 4px; +} + +.security-scan-step.completed { + color: #22c55e; +} + +.security-scan-step.active { + color: var(--bp-primary); +} + +/* Auto-refresh indicator */ +.security-auto-refresh { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + color: var(--bp-text-muted); +} + +.security-auto-refresh-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #22c55e; + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* Responsive */ +@media (max-width: 768px) { + .security-header { + flex-direction: column; + gap: 16px; + align-items: flex-start; + } + + .security-summary-grid { + grid-template-columns: repeat(2, 1fr); + } + + .security-tools-grid { + grid-template-columns: 1fr; + } + + .security-tabs { + padding: 12px 16px; + } + + .security-tab { + padding: 8px 12px; + font-size: 12px; + } +} +""" + + @staticmethod + def get_html() -> str: + """HTML-Struktur fuer das Security Dashboard.""" + return """ + +
    + +
    +

    + 🛡 + Security Dashboard +

    +
    +
    + + Auto-Refresh aktiv +
    + + Sicher + + +
    +
    + + +
    + + + + + + + +
    + + +
    + + + + +
    + +
    +
    +
    🚨
    +
    0
    +
    Critical
    +
    Sofort beheben
    +
    +
    +
    +
    0
    +
    High
    +
    Hohe Prioritaet
    +
    +
    +
    🟡
    +
    0
    +
    Medium
    +
    Bald beheben
    +
    +
    +
    🟢
    +
    0
    +
    Low
    +
    Bei Gelegenheit
    +
    +
    +
    🛈
    +
    0
    +
    Info
    +
    Zur Kenntnis
    +
    +
    + + +
    +
    + 🔧 DevSecOps Tools +
    +
    + +
    +
    + + +
    + 🔍 Aktuelle Findings +
    +
    +
    + Letzte Scan-Ergebnisse +
    + + + + +
    +
    + + + + + + + + + + + + + +
    SeverityToolFindingDateiGefunden
    +
    +
    + + + + + + + + + + + + + + + + + + +
    +
    +""" + + @staticmethod + def get_js() -> str: + """JavaScript fuer das Security Dashboard.""" + return """ +// ========================================== +// SECURITY DASHBOARD MODULE +// ========================================== + +const SecurityDashboard = { + autoRefreshInterval: null, + scanPollingInterval: null, + currentTab: 'overview', + findings: [], + sbomData: [], + tools: [], + + // Initialize + init() { + console.log('SecurityDashboard initialized'); + this.loadDashboard(); + this.startAutoRefresh(); + }, + + // Load all dashboard data + async loadDashboard() { + try { + await Promise.all([ + this.loadToolStatus(), + this.loadFindings(), + this.loadSummary(), + this.loadSBOM(), + this.loadHistory() + ]); + } catch (error) { + console.error('Error loading security dashboard:', error); + } + }, + + // Switch tabs + switchTab(tabName) { + this.currentTab = tabName; + + // Update tab buttons + document.querySelectorAll('.security-tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.tab === tabName); + }); + + // Update tab content + document.querySelectorAll('.security-tab-content').forEach(content => { + content.classList.add('hidden'); + }); + const tabContent = document.getElementById('tab-' + tabName); + if (tabContent) { + tabContent.classList.remove('hidden'); + } + }, + + // Load tool status + async loadToolStatus() { + try { + const response = await fetch('/api/v1/security/tools'); + if (response.ok) { + this.tools = await response.json(); + this.renderToolStatus(); + } + } catch (error) { + console.error('Error loading tool status:', error); + this.renderDefaultToolStatus(); + } + }, + + // Render tool status + renderToolStatus() { + const grid = document.getElementById('security-tools-grid'); + if (!grid) return; + + const defaultTools = [ + { name: 'Gitleaks', icon: '🔑', desc: 'Secrets Detection in Git History', license: 'MIT', version: '8.18.x' }, + { name: 'Semgrep', icon: '🔎', desc: 'Static Application Security Testing (SAST)', license: 'LGPL-2.1', version: '1.52.x' }, + { name: 'Bandit', icon: '🐍', desc: 'Python Security Linter', license: 'Apache-2.0', version: '1.7.x' }, + { name: 'Trivy', icon: '🛠', desc: 'Container & Filesystem Vulnerability Scanner', license: 'Apache-2.0', version: '0.48.x' }, + { name: 'Grype', icon: '🐞', desc: 'Vulnerability Scanner', license: 'Apache-2.0', version: '0.74.x' }, + { name: 'Syft', icon: '📦', desc: 'SBOM Generator (CycloneDX/SPDX)', license: 'Apache-2.0', version: '0.100.x' } + ]; + + grid.innerHTML = defaultTools.map(tool => { + const serverTool = this.tools.find(t => t.name === tool.name) || {}; + const isInstalled = serverTool.installed !== false; + const lastRun = serverTool.last_run || 'Nie'; + + return ` +
    +
    + ${tool.icon} ${tool.name} + + ${isInstalled ? 'Installiert' : 'Nicht installiert'} + +
    +
    ${tool.desc}
    +
    + 📄 ${tool.license} + 📈 v${tool.version} + 🕑 ${lastRun} +
    +
    + + +
    +
    + `; + }).join(''); + }, + + renderDefaultToolStatus() { + this.tools = []; + this.renderToolStatus(); + }, + + // Load findings + async loadFindings() { + try { + const response = await fetch('/api/v1/security/findings'); + if (response.ok) { + this.findings = await response.json(); + this.renderFindings(); + } + } catch (error) { + console.error('Error loading findings:', error); + this.renderEmptyFindings(); + } + }, + + // Render findings table + renderFindings() { + const tbody = document.getElementById('security-findings-body'); + if (!tbody) return; + + if (!this.findings || this.findings.length === 0) { + this.renderEmptyFindings(); + return; + } + + tbody.innerHTML = this.findings.slice(0, 20).map(finding => ` + + ${finding.severity} + ${finding.tool || 'Unknown'} + ${finding.title || finding.message || 'No description'} + ${finding.file || '-'} + ${finding.found_at ? new Date(finding.found_at).toLocaleString('de-DE') : '-'} + + `).join(''); + + // Update counts + document.getElementById('secrets-count').textContent = this.findings.filter(f => f.tool === 'gitleaks').length; + document.getElementById('vuln-count').textContent = this.findings.filter(f => f.tool === 'trivy' || f.tool === 'grype').length; + }, + + renderEmptyFindings() { + const tbody = document.getElementById('security-findings-body'); + if (!tbody) return; + + tbody.innerHTML = ` + + + 🔒 Keine Findings gefunden. Das ist gut! + + + `; + }, + + // Load summary + async loadSummary() { + try { + const response = await fetch('/api/v1/security/summary'); + if (response.ok) { + const summary = await response.json(); + this.renderSummary(summary); + } + } catch (error) { + console.error('Error loading summary:', error); + this.renderSummary({ critical: 0, high: 0, medium: 0, low: 0, info: 0 }); + } + }, + + renderSummary(summary) { + document.getElementById('critical-count').textContent = summary.critical || 0; + document.getElementById('high-count').textContent = summary.high || 0; + document.getElementById('medium-count').textContent = summary.medium || 0; + document.getElementById('low-count').textContent = summary.low || 0; + document.getElementById('info-count').textContent = summary.info || 0; + + // Update overall status + const statusBadge = document.getElementById('security-overall-status'); + if (summary.critical > 0) { + statusBadge.className = 'security-status-badge critical'; + statusBadge.innerHTML = ' Critical Issues'; + } else if (summary.high > 0) { + statusBadge.className = 'security-status-badge warning'; + statusBadge.innerHTML = ' Warnings'; + } else { + statusBadge.className = 'security-status-badge secure'; + statusBadge.innerHTML = ' Sicher'; + } + }, + + // Load SBOM + async loadSBOM() { + try { + const response = await fetch('/api/v1/security/sbom'); + if (response.ok) { + this.sbomData = await response.json(); + this.renderSBOM(); + } + } catch (error) { + console.error('Error loading SBOM:', error); + } + }, + + renderSBOM() { + if (!this.sbomData || !this.sbomData.components) return; + + const components = this.sbomData.components || []; + + // Update stats + document.getElementById('sbom-total').textContent = components.length; + document.getElementById('sbom-python').textContent = components.filter(c => c.type === 'python' || c.purl?.startsWith('pkg:pypi')).length; + document.getElementById('sbom-go').textContent = components.filter(c => c.type === 'go' || c.purl?.startsWith('pkg:golang')).length; + document.getElementById('sbom-npm').textContent = components.filter(c => c.type === 'npm' || c.purl?.startsWith('pkg:npm')).length; + + // Render table + const tbody = document.getElementById('sbom-body'); + if (!tbody) return; + + tbody.innerHTML = components.slice(0, 50).map(comp => ` + + ${comp.name || 'Unknown'} + ${comp.version || '-'} + ${comp.type || this.getTypeFromPurl(comp.purl)} + ${comp.licenses?.[0]?.license?.id || comp.licenses?.[0]?.license?.name || '-'} + + `).join(''); + }, + + getTypeFromPurl(purl) { + if (!purl) return 'unknown'; + if (purl.startsWith('pkg:pypi')) return 'Python'; + if (purl.startsWith('pkg:golang')) return 'Go'; + if (purl.startsWith('pkg:npm')) return 'NPM'; + return 'other'; + }, + + filterSBOM() { + const search = document.getElementById('sbom-search').value.toLowerCase(); + const tbody = document.getElementById('sbom-body'); + if (!tbody || !this.sbomData?.components) return; + + const filtered = this.sbomData.components.filter(c => + c.name?.toLowerCase().includes(search) || + c.version?.toLowerCase().includes(search) + ); + + tbody.innerHTML = filtered.slice(0, 50).map(comp => ` + + ${comp.name || 'Unknown'} + ${comp.version || '-'} + ${comp.type || this.getTypeFromPurl(comp.purl)} + ${comp.licenses?.[0]?.license?.id || '-'} + + `).join(''); + }, + + // Load history + async loadHistory() { + try { + const response = await fetch('/api/v1/security/history'); + if (response.ok) { + const history = await response.json(); + this.renderHistory(history); + } + } catch (error) { + console.error('Error loading history:', error); + this.renderDefaultHistory(); + } + }, + + renderHistory(history) { + const timeline = document.getElementById('security-timeline'); + if (!timeline) return; + + if (!history || history.length === 0) { + this.renderDefaultHistory(); + return; + } + + timeline.innerHTML = history.map(item => ` +
    +
    ${new Date(item.timestamp).toLocaleString('de-DE')}
    +
    +
    ${item.title}
    +
    ${item.description || ''}
    +
    +
    + `).join(''); + }, + + renderDefaultHistory() { + const timeline = document.getElementById('security-timeline'); + if (!timeline) return; + + timeline.innerHTML = ` +
    +
    ${new Date().toLocaleString('de-DE')}
    +
    +
    Security Dashboard initialisiert
    +
    DevSecOps Pipeline bereit
    +
    +
    + `; + }, + + // Run full scan + async runFullScan() { + const progressEl = document.getElementById('security-scan-progress'); + progressEl.classList.remove('hidden'); + + const steps = ['secrets', 'sast', 'deps', 'containers', 'sbom']; + let progress = 0; + + for (const step of steps) { + document.getElementById('step-' + step).classList.add('active'); + + try { + await fetch('/api/v1/security/scan/' + step, { method: 'POST' }); + } catch (error) { + console.error('Scan step failed:', step, error); + } + + progress += 20; + document.getElementById('scan-progress-fill').style.width = progress + '%'; + document.getElementById('scan-percentage').textContent = progress + '%'; + + document.getElementById('step-' + step).classList.remove('active'); + document.getElementById('step-' + step).classList.add('completed'); + + await new Promise(r => setTimeout(r, 500)); + } + + // Reload dashboard data + await this.loadDashboard(); + + // Hide progress after delay + setTimeout(() => { + progressEl.classList.add('hidden'); + // Reset steps + steps.forEach(step => { + document.getElementById('step-' + step).classList.remove('completed', 'active'); + }); + document.getElementById('scan-progress-fill').style.width = '0%'; + document.getElementById('scan-percentage').textContent = '0%'; + }, 2000); + }, + + // Run individual tool scan + async runToolScan(tool) { + try { + const response = await fetch('/api/v1/security/scan/' + tool, { method: 'POST' }); + if (response.ok) { + await this.loadDashboard(); + console.log('Scan completed:', tool); + } + } catch (error) { + console.error('Scan failed:', tool, error); + } + }, + + // Run secrets scan + async runSecretsScan() { + await this.runToolScan('secrets'); + }, + + // View tool report + async viewToolReport(tool) { + try { + const response = await fetch('/api/v1/security/reports/' + tool); + if (response.ok) { + const report = await response.json(); + console.log('Report:', tool, report); + // Could open a modal with full report + } + } catch (error) { + console.error('Error loading report:', tool, error); + } + }, + + // Auto-refresh + startAutoRefresh() { + this.autoRefreshInterval = setInterval(() => { + this.loadSummary(); + }, 30000); // Every 30 seconds + }, + + stopAutoRefresh() { + if (this.autoRefreshInterval) { + clearInterval(this.autoRefreshInterval); + this.autoRefreshInterval = null; + } + } +}; + +// Initialize when security panel is shown +function initSecurityModule() { + SecurityDashboard.init(); +} + +// Clean up when leaving +function cleanupSecurityModule() { + SecurityDashboard.stopAutoRefresh(); +} +""" diff --git a/backend/frontend/modules/system_info.py b/backend/frontend/modules/system_info.py new file mode 100644 index 0000000..e02115f --- /dev/null +++ b/backend/frontend/modules/system_info.py @@ -0,0 +1,966 @@ +""" +BreakPilot Studio - System Info Module + +Zeigt System-Informationen und Dokumentation an, analog zu den Admin-Seiten +im Next.js Frontend. +""" + + +class SystemInfoModule: + """System-Info Modul fuer BreakPilot Studio.""" + + @staticmethod + def get_css() -> str: + """CSS fuer das System-Info Panel.""" + return """ +/* ========================================== + SYSTEM INFO MODULE + ========================================== */ + +#panel-system-info { + display: none; + flex-direction: column; + padding: 24px; + min-height: calc(100vh - 104px); +} + +#panel-system-info.active { + display: flex; +} + +.system-info-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 24px; +} + +.system-info-title { + font-size: 24px; + font-weight: 700; + color: var(--bp-text); + margin-bottom: 4px; +} + +.system-info-subtitle { + font-size: 14px; + color: var(--bp-text-muted); +} + +.system-info-version { + padding: 6px 12px; + background: var(--bp-accent-soft); + color: var(--bp-accent); + border-radius: 999px; + font-size: 12px; + font-weight: 600; +} + +/* Privacy Notes */ +.privacy-notes { + background: rgba(59, 130, 246, 0.1); + border: 1px solid rgba(59, 130, 246, 0.3); + border-radius: 12px; + padding: 16px; + margin-bottom: 24px; +} + +.privacy-notes-title { + font-size: 14px; + font-weight: 600; + color: #3b82f6; + margin-bottom: 8px; +} + +.privacy-notes-list { + list-style: none; +} + +.privacy-notes-list li { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 13px; + color: #60a5fa; + margin-bottom: 4px; +} + +.privacy-notes-list li::before { + content: "✓"; + color: #3b82f6; + flex-shrink: 0; +} + +/* Tabs */ +.system-info-tabs { + display: flex; + gap: 16px; + border-bottom: 1px solid var(--bp-border); + margin-bottom: 24px; +} + +.system-info-tab { + padding: 12px 4px; + font-size: 14px; + font-weight: 500; + color: var(--bp-text-muted); + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.2s; + background: none; + border-top: none; + border-left: none; + border-right: none; +} + +.system-info-tab:hover { + color: var(--bp-text); +} + +.system-info-tab.active { + color: var(--bp-primary); + border-bottom-color: var(--bp-primary); +} + +/* Tab Content */ +.system-info-content { + flex: 1; + min-height: 400px; +} + +.tab-pane { + display: none; +} + +.tab-pane.active { + display: block; +} + +/* Features Section */ +.features-grid { + display: flex; + flex-direction: column; + gap: 12px; +} + +.feature-card { + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border); + border-radius: 8px; + padding: 16px; +} + +.feature-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.feature-name { + font-size: 15px; + font-weight: 600; + color: var(--bp-text); +} + +.feature-status { + padding: 2px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 500; +} + +.feature-status.active { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; +} + +.feature-status.planned { + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; +} + +.feature-status.disabled { + background: rgba(100, 116, 139, 0.15); + color: #64748b; +} + +.feature-description { + font-size: 13px; + color: var(--bp-text-muted); +} + +/* Architecture Section */ +.architecture-diagram { + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border); + border-radius: 12px; + padding: 24px; +} + +.architecture-layer { + margin-bottom: 16px; + padding: 16px; + border-radius: 8px; + border: 2px solid; + text-align: center; +} + +.architecture-layer-title { + font-size: 13px; + font-weight: 600; + margin-bottom: 8px; +} + +.architecture-components { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; +} + +.architecture-component { + padding: 4px 12px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; +} + +.architecture-arrow { + text-align: center; + color: var(--bp-text-muted); + font-size: 20px; + margin: 8px 0; +} + +/* Roadmap Section */ +.roadmap-phases { + display: flex; + flex-direction: column; + gap: 16px; +} + +.roadmap-phase { + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border); + border-radius: 8px; + padding: 16px; +} + +.roadmap-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.roadmap-title { + font-size: 15px; + font-weight: 600; + color: var(--bp-text); +} + +.roadmap-priority { + padding: 2px 8px; + border-radius: 4px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; +} + +.roadmap-priority.high { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; +} + +.roadmap-priority.medium { + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; +} + +.roadmap-priority.low { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; +} + +.roadmap-items { + list-style: none; +} + +.roadmap-items li { + font-size: 13px; + color: var(--bp-text-muted); + padding: 4px 0; + padding-left: 16px; + position: relative; +} + +.roadmap-items li::before { + content: "•"; + position: absolute; + left: 0; + color: var(--bp-text-muted); +} + +/* Technical Table */ +.technical-table { + width: 100%; + border-collapse: collapse; + background: var(--bp-surface-elevated); + border-radius: 8px; + overflow: hidden; +} + +.technical-table th, +.technical-table td { + padding: 12px 16px; + text-align: left; + font-size: 13px; + border-bottom: 1px solid var(--bp-border); +} + +.technical-table th { + background: var(--bp-surface); + font-weight: 600; + color: var(--bp-text-muted); + text-transform: uppercase; + font-size: 11px; +} + +.technical-table td { + color: var(--bp-text); +} + +.technical-table tr:last-child td { + border-bottom: none; +} + +/* Audit Section */ +.audit-sections { + display: flex; + flex-direction: column; + gap: 16px; +} + +.audit-section { + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border); + border-radius: 8px; + padding: 16px; +} + +.audit-section-title { + font-size: 15px; + font-weight: 600; + color: var(--bp-text); + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 8px; +} + +.audit-section-title::before { + content: "✓"; + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + background: var(--bp-primary); + color: white; + border-radius: 50%; + font-size: 12px; +} + +.audit-items { + display: flex; + flex-direction: column; + gap: 8px; +} + +.audit-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid var(--bp-border-subtle); +} + +.audit-item:last-child { + border-bottom: none; +} + +.audit-item-label { + font-size: 13px; + color: var(--bp-text-muted); +} + +.audit-item-value { + font-size: 13px; + font-weight: 500; + padding: 2px 8px; + border-radius: 4px; +} + +.audit-item-value.ok { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; +} + +.audit-item-value.warning { + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; +} + +.audit-item-value.critical { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; +} + +/* Documentation Section */ +.documentation-container { + background: var(--bp-bg); + color: var(--bp-text); + padding: 24px; + border-radius: 12px; + overflow: auto; + max-height: 600px; +} + +.documentation-container h2 { + font-size: 20px; + margin: 24px 0 12px 0; + color: var(--bp-text); +} + +.documentation-container h2:first-child { + margin-top: 0; +} + +.documentation-container h3 { + font-size: 16px; + margin: 20px 0 8px 0; + color: var(--bp-text); +} + +.documentation-container p { + font-size: 14px; + line-height: 1.6; + margin-bottom: 12px; + color: var(--bp-text-muted); +} + +.documentation-container pre { + background: var(--bp-surface); + padding: 16px; + border-radius: 8px; + overflow-x: auto; + font-size: 12px; + font-family: 'Monaco', 'Menlo', monospace; + margin: 12px 0; + color: var(--bp-text); +} + +.documentation-container ul, +.documentation-container ol { + margin: 12px 0; + padding-left: 24px; +} + +.documentation-container li { + font-size: 14px; + margin-bottom: 4px; + color: var(--bp-text-muted); +} + +.documentation-container table { + width: 100%; + border-collapse: collapse; + margin: 12px 0; +} + +.documentation-container th, +.documentation-container td { + padding: 8px 12px; + border: 1px solid var(--bp-border); + text-align: left; + font-size: 13px; +} + +.documentation-container th { + background: var(--bp-surface); +} + +/* Export Buttons */ +.export-buttons { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 16px; +} +""" + + @staticmethod + def get_html() -> str: + """HTML fuer das System-Info Panel.""" + return """ + +
    + +
    +
    +

    BreakPilot Studio - System Info

    +

    Plattform-Dokumentation und technische Details

    +
    + Version 2.0 +
    + + +
    +

    Datenschutz-Hinweise

    +
      +
    • Alle Daten werden DSGVO-konform verarbeitet
    • +
    • Verschluesselte Datenuebertragung (TLS 1.3)
    • +
    • Daten werden in deutschen Rechenzentren gehostet
    • +
    • Regelmaessige Sicherheitsaudits
    • +
    +
    + + +
    + + + + + + +
    + + +
    + +
    +

    Features

    +
    +
    +
    + Arbeitsblaetter-Generator + Aktiv +
    +

    KI-gestuetzte Erstellung von Arbeitsblaettern und Lernmaterialien

    +
    +
    +
    + Klausurkorrektur + Aktiv +
    +

    Automatische Klausurkorrektur mit OCR und KI-Bewertung

    +
    +
    +
    + Elternkommunikation + Aktiv +
    +

    Rechtssichere Elternbriefe und Benachrichtigungen

    +
    +
    +
    + Videokonferenzen + Aktiv +
    +

    Integrierte Jitsi-Videokonferenzen fuer Elterngespraeche

    +
    +
    +
    + Messenger + Aktiv +
    +

    Sichere Matrix-basierte Kommunikation

    +
    +
    +
    + Unified Inbox + Geplant +
    +

    Zentrale E-Mail-Verwaltung mit KI-Unterstuetzung

    +
    +
    +
    + + +
    +

    System-Architektur

    +
    +
    +
    Frontend (Next.js / Python)
    +
    + Admin Dashboard + Studio UI + API Routes +
    +
    +
    +
    +
    Backend Services
    +
    + FastAPI Backend + Consent Service (Go) + Klausur Service +
    +
    +
    +
    +
    KI & Processing
    +
    + OpenAI GPT-4o + Claude 3.5 + vast.ai GPU +
    +
    +
    +
    +
    Datenbanken
    +
    + PostgreSQL + Qdrant + Valkey + MinIO +
    +
    +
    +
    + + +
    +

    Optimierungs-Roadmap

    +
    +
    +
    + Phase 1: KI-Erweiterung + High +
    +
      +
    • Multi-Provider LLM-Unterstuetzung
    • +
    • Lokale Modelle mit Ollama
    • +
    • RAG-Verbesserungen
    • +
    • Automatische Qualitaetspruefung
    • +
    +
    +
    +
    + Phase 2: Collaboration + Medium +
    +
      +
    • Echtzeit-Zusammenarbeit
    • +
    • Kommentar-System
    • +
    • Versionskontrolle
    • +
    • Team-Workspaces
    • +
    +
    +
    +
    + Phase 3: Analytics + Low +
    +
      +
    • Nutzungsstatistiken
    • +
    • Lernfortschritt-Tracking
    • +
    • KI-Insights
    • +
    • Reporting-Dashboard
    • +
    +
    +
    +
    + + +
    +

    Technische Details

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    KomponenteTechnologieVersionBeschreibung
    BackendFastAPI0.109+Python Async API
    Consent ServiceGo + Gin1.21+DSGVO-Consent-Verwaltung
    DatabasePostgreSQL16Relationale Daten
    Vector DBQdrant1.12+RAG & Semantic Search
    CacheValkey8.xRedis-kompatibel
    StorageMinIOLatestS3-kompatibel
    KIOpenAI / AnthropicGPT-4o / Claude 3.5LLM Provider
    +
    + + +
    +

    Audit-relevante Informationen

    +
    +
    +

    DSGVO-Compliance

    +
    +
    + Art. 7 Einwilligung + Implementiert +
    +
    + Art. 13/14 Informationspflichten + Implementiert +
    +
    + Art. 17 Recht auf Loeschung + Implementiert +
    +
    + Art. 20 Datenportabilitaet + Implementiert +
    +
    +
    +
    +

    Technische Sicherheit

    +
    +
    + Verschluesselung + AES-256 at rest +
    +
    + TLS + 1.3 +
    +
    + Audit-Log + Lueckenlos +
    +
    + Backup + Taeglich, 30 Tage +
    +
    +
    +
    +

    Betrieb

    +
    +
    + Hosting + Deutschland (Hetzner) +
    +
    + Uptime SLA + > 99.9% +
    +
    + Monitoring + 24/7 +
    +
    + Penetration Tests + Quartalsweise +
    +
    +
    +
    +
    + + +
    +

    Vollstaendige Dokumentation

    +
    +

    BreakPilot Studio - Plattformdokumentation

    + +

    1. Uebersicht

    +

    BreakPilot Studio ist eine umfassende Plattform fuer Lehrkraefte zur Erstellung von Lernmaterialien, Klausurkorrektur und Elternkommunikation. Die Plattform nutzt modernste KI-Technologie fuer automatisierte Workflows.

    + +

    2. Module

    + + + + + + + + +
    ModulBeschreibungStatus
    ArbeitsblaetterKI-gestuetzte Erstellung von LernmaterialienAktiv
    KlausurkorrekturAutomatische Korrektur mit FeedbackAktiv
    ElternbriefeRechtssichere KommunikationAktiv
    VideokonferenzIntegrierte Jitsi-MeetingsAktiv
    MessengerMatrix-basierte KommunikationAktiv
    Content CreatorInteraktive LerneinheitenAktiv
    + +

    3. API-Dokumentation

    +

    Die API ist unter /docs (Swagger) und /redoc (ReDoc) dokumentiert.

    +
    +# Beispiel: Arbeitsblatt generieren
    +POST /api/worksheets/generate
    +{
    +  "topic": "Quadratische Funktionen",
    +  "grade": 10,
    +  "difficulty": "medium"
    +}
    +        
    + +

    4. Sicherheit

    +
      +
    • JWT-basierte Authentifizierung
    • +
    • Role-Based Access Control (RBAC)
    • +
    • Verschluesselte Datenspeicherung
    • +
    • Regelmaessige Security-Audits
    • +
    + +

    5. Datenschutz

    +

    Alle personenbezogenen Daten werden DSGVO-konform verarbeitet. Details finden sich in der Datenschutzerklaerung.

    + +

    6. Support

    +

    Bei Fragen oder Problemen wenden Sie sich an den Support unter support@breakpilot.de

    +
    + +
    + + +
    +
    +
    +
    +""" + + @staticmethod + def get_js() -> str: + """JavaScript fuer das System-Info Panel.""" + return """ +// ========================================== +// SYSTEM INFO MODULE +// ========================================== + +console.log('System Info Module loaded'); + +// Tab-Wechsel +document.querySelectorAll('.system-info-tab').forEach(tab => { + tab.addEventListener('click', function() { + const tabId = this.dataset.tab; + + // Alle Tabs deaktivieren + document.querySelectorAll('.system-info-tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.tab-pane').forEach(p => p.classList.remove('active')); + + // Ausgewaehlten Tab aktivieren + this.classList.add('active'); + document.getElementById('tab-' + tabId).classList.add('active'); + }); +}); + +// JSON Export +function exportSystemInfoJSON() { + const data = { + title: 'BreakPilot Studio System-Info', + version: '2.0', + exported_at: new Date().toISOString(), + features: [ + { name: 'Arbeitsblaetter-Generator', status: 'active' }, + { name: 'Klausurkorrektur', status: 'active' }, + { name: 'Elternkommunikation', status: 'active' }, + { name: 'Videokonferenzen', status: 'active' }, + { name: 'Messenger', status: 'active' }, + { name: 'Unified Inbox', status: 'planned' } + ], + technical: [ + { component: 'Backend', technology: 'FastAPI', version: '0.109+' }, + { component: 'Consent Service', technology: 'Go + Gin', version: '1.21+' }, + { component: 'Database', technology: 'PostgreSQL', version: '16' }, + { component: 'Vector DB', technology: 'Qdrant', version: '1.12+' }, + { component: 'Cache', technology: 'Valkey', version: '8.x' } + ] + }; + + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'breakpilot-system-info.json'; + a.click(); + URL.revokeObjectURL(url); +} + +// Print/PDF +function printSystemInfo() { + const printWindow = window.open('', '_blank'); + if (printWindow) { + const docContent = document.querySelector('.documentation-container')?.innerHTML || ''; + printWindow.document.write(` + + + + BreakPilot Studio - System-Info + + + +

    BreakPilot Studio - System-Info

    +

    Exportiert am: ${new Date().toLocaleString('de-DE')}

    +
    + ${docContent} + + + `); + printWindow.document.close(); + printWindow.print(); + } +} + +// Show System Info Panel +function showSystemInfoPanel() { + console.log('Showing System Info Panel'); +} + +// Load function for module loader +function loadSystemInfoModule() { + console.log('System Info Module initialized'); + showSystemInfoPanel(); +} + +// Expose globally +window.loadSystemInfoModule = loadSystemInfoModule; +window.exportSystemInfoJSON = exportSystemInfoJSON; +window.printSystemInfo = printSystemInfo; +""" diff --git a/backend/frontend/modules/unit_creator.py b/backend/frontend/modules/unit_creator.py new file mode 100644 index 0000000..e3e1909 --- /dev/null +++ b/backend/frontend/modules/unit_creator.py @@ -0,0 +1,2141 @@ +""" +BreakPilot Studio - Unit Creator Module + +Ermoeglicht Lehrern das Erstellen und Bearbeiten von Learning Units. +Features: +- Metadaten-Editor (Template, Fach, Klassenstufe) +- Stop-Editor mit Drag & Drop +- Interaktionstyp-Konfiguration +- Validierung in Echtzeit +- JSON-Import/Export +""" + + +class UnitCreatorModule: + """Unit Creator Modul fuer das BreakPilot Studio.""" + + @staticmethod + def get_css() -> str: + """CSS fuer Unit Creator.""" + return """ +/* ============================================== + UNIT CREATOR MODULE STYLES + ============================================== */ + +#panel-unit-creator { + padding: 24px; + display: none; + flex-direction: column; + gap: 20px; +} + +/* Header */ +.uc-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 16px; + border-bottom: 1px solid var(--bp-border); +} + +.uc-title { + font-size: 1.5rem; + font-weight: 700; +} + +.uc-actions { + display: flex; + gap: 12px; +} + +/* Tabs */ +.uc-tabs { + display: flex; + gap: 4px; + background: var(--bp-surface-elevated); + padding: 4px; + border-radius: 8px; + width: fit-content; +} + +.uc-tab { + padding: 8px 16px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + font-weight: 500; + color: var(--bp-text-muted); + background: transparent; + border: none; + transition: all 0.2s; +} + +.uc-tab:hover { + color: var(--bp-text); +} + +.uc-tab.active { + background: var(--bp-primary); + color: white; +} + +/* Tab Content */ +.uc-content { + flex: 1; + overflow-y: auto; +} + +.uc-tab-panel { + display: none; +} + +.uc-tab-panel.active { + display: block; +} + +/* Form Styles */ +.uc-form-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; +} + +.uc-form-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.uc-form-group.full-width { + grid-column: span 2; +} + +.uc-form-row { + display: flex; + gap: 12px; + align-items: flex-start; +} + +.uc-params-section { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 8px; + padding: 16px; +} + +.uc-params-title { + font-weight: 600; + font-size: 14px; + color: var(--bp-primary); + margin-bottom: 12px; + padding-bottom: 8px; + border-bottom: 1px solid var(--bp-border); +} + +.uc-label { + font-size: 13px; + font-weight: 500; + color: var(--bp-text); +} + +.uc-input, +.uc-select, +.uc-textarea { + padding: 10px 12px; + border-radius: 8px; + border: 1px solid var(--bp-border); + background: var(--bp-surface-elevated); + color: var(--bp-text); + font-size: 14px; + font-family: inherit; +} + +.uc-input:focus, +.uc-select:focus, +.uc-textarea:focus { + outline: none; + border-color: var(--bp-primary); +} + +.uc-textarea { + min-height: 100px; + resize: vertical; +} + +/* Checkbox Group */ +.uc-checkbox-group { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.uc-checkbox-item { + display: flex; + align-items: center; + gap: 6px; +} + +.uc-checkbox-item input { + width: 18px; + height: 18px; + accent-color: var(--bp-primary); +} + +/* Radio Group */ +.uc-radio-group { + display: flex; + gap: 16px; +} + +.uc-radio-item { + display: flex; + align-items: center; + gap: 6px; +} + +.uc-radio-item input { + width: 18px; + height: 18px; + accent-color: var(--bp-primary); +} + +/* Stops List */ +.uc-stops-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.uc-stop-card { + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border); + border-radius: 12px; + padding: 16px; + cursor: grab; +} + +.uc-stop-card:active { + cursor: grabbing; +} + +.uc-stop-card.dragging { + opacity: 0.5; + border-color: var(--bp-primary); +} + +.uc-stop-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.uc-stop-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; +} + +.uc-stop-order { + width: 24px; + height: 24px; + background: var(--bp-primary-soft); + color: var(--bp-primary); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; +} + +.uc-stop-actions { + display: flex; + gap: 8px; +} + +.uc-stop-btn { + padding: 4px 8px; + border-radius: 4px; + border: 1px solid var(--bp-border); + background: transparent; + color: var(--bp-text-muted); + font-size: 12px; + cursor: pointer; +} + +.uc-stop-btn:hover { + background: var(--bp-surface); + color: var(--bp-text); +} + +.uc-stop-btn.delete:hover { + background: rgba(239, 68, 68, 0.1); + color: var(--bp-danger); + border-color: var(--bp-danger); +} + +.uc-stop-content { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.uc-stop-field { + display: flex; + flex-direction: column; + gap: 4px; +} + +.uc-stop-field.full { + grid-column: span 2; +} + +.uc-stop-field label { + font-size: 11px; + color: var(--bp-text-muted); + text-transform: uppercase; +} + +/* Add Stop Button */ +.uc-add-stop { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 16px; + border: 2px dashed var(--bp-border); + border-radius: 12px; + background: transparent; + color: var(--bp-text-muted); + cursor: pointer; + transition: all 0.2s; +} + +.uc-add-stop:hover { + border-color: var(--bp-primary); + color: var(--bp-primary); + background: var(--bp-primary-soft); +} + +/* JSON Editor */ +.uc-json-editor { + width: 100%; + min-height: 400px; + font-family: 'Monaco', 'Menlo', monospace; + font-size: 12px; + line-height: 1.5; + padding: 16px; + border-radius: 8px; + border: 1px solid var(--bp-border); + background: #1e1e1e; + color: #d4d4d4; + resize: vertical; +} + +/* Preview */ +.uc-preview { + background: var(--bp-surface-elevated); + border-radius: 12px; + padding: 20px; +} + +.uc-preview-header { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid var(--bp-border); +} + +.uc-preview-flow { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.uc-preview-stop { + padding: 8px 12px; + background: var(--bp-primary-soft); + color: var(--bp-primary); + border-radius: 8px; + font-size: 12px; + font-weight: 500; +} + +.uc-preview-arrow { + color: var(--bp-text-muted); +} + +/* Validation */ +.uc-validation { + padding: 16px; + border-radius: 8px; + margin-top: 16px; +} + +.uc-validation.valid { + background: rgba(34, 197, 94, 0.1); + border: 1px solid var(--bp-success); +} + +.uc-validation.invalid { + background: rgba(239, 68, 68, 0.1); + border: 1px solid var(--bp-danger); +} + +.uc-validation-title { + font-weight: 600; + margin-bottom: 8px; + display: flex; + align-items: center; + gap: 8px; +} + +.uc-validation-list { + font-size: 13px; + list-style: none; + padding-left: 24px; +} + +.uc-validation-list li { + margin-bottom: 4px; +} + +.uc-validation-list li.error { + color: var(--bp-danger); +} + +.uc-validation-list li.warning { + color: var(--bp-warning); +} + +/* Footer */ +.uc-footer { + display: flex; + justify-content: space-between; + padding-top: 16px; + border-top: 1px solid var(--bp-border); +} + +/* Multi-Input */ +.uc-multi-input { + display: flex; + flex-direction: column; + gap: 8px; +} + +.uc-multi-input-items { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.uc-multi-input-item { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 4px; + font-size: 12px; +} + +.uc-multi-input-item button { + background: none; + border: none; + color: var(--bp-text-muted); + cursor: pointer; + padding: 0; + font-size: 14px; +} + +.uc-multi-input-item button:hover { + color: var(--bp-danger); +} + +.uc-multi-input-add { + display: flex; + gap: 8px; +} + +.uc-multi-input-add input { + flex: 1; +} + +/* Empty State */ +.uc-empty { + text-align: center; + padding: 40px; + color: var(--bp-text-muted); +} + +.uc-empty-icon { + font-size: 48px; + margin-bottom: 12px; +} + +/* ========================================== + WIZARD - Interaktive Bedienungsanleitung + ========================================== */ + +.uc-wizard-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + z-index: 9998; + opacity: 0; + visibility: hidden; + transition: all 0.3s; +} + +.uc-wizard-overlay.active { + opacity: 1; + visibility: visible; +} + +.uc-wizard-highlight { + position: absolute; + box-shadow: 0 0 0 4px var(--bp-primary), 0 0 0 9999px rgba(0, 0, 0, 0.7); + border-radius: 8px; + z-index: 9999; + pointer-events: none; + transition: all 0.4s ease; +} + +.uc-wizard-tooltip { + position: fixed; + background: white; + border-radius: 12px; + padding: 24px; + max-width: 400px; + box-shadow: 0 20px 40px rgba(0,0,0,0.3); + z-index: 10000; + animation: ucWizardFadeIn 0.3s ease; +} + +@keyframes ucWizardFadeIn { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.uc-wizard-step { + font-size: 12px; + color: var(--bp-primary); + font-weight: 600; + margin-bottom: 8px; +} + +.uc-wizard-title { + font-size: 18px; + font-weight: 700; + color: #1a1a1a; + margin-bottom: 12px; +} + +.uc-wizard-text { + font-size: 14px; + line-height: 1.6; + color: #444; + margin-bottom: 20px; +} + +.uc-wizard-actions { + display: flex; + gap: 12px; + justify-content: space-between; + align-items: center; +} + +.uc-wizard-btn { + padding: 10px 20px; + border-radius: 8px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + border: none; +} + +.uc-wizard-btn-primary { + background: var(--bp-primary); + color: white; +} + +.uc-wizard-btn-primary:hover { + background: var(--bp-primary-hover); +} + +.uc-wizard-btn-secondary { + background: #f0f0f0; + color: #333; +} + +.uc-wizard-btn-secondary:hover { + background: #e0e0e0; +} + +.uc-wizard-btn-skip { + background: transparent; + color: #888; + font-size: 13px; +} + +.uc-wizard-btn-skip:hover { + color: #555; +} + +.uc-wizard-progress { + display: flex; + gap: 6px; + margin-top: 16px; +} + +.uc-wizard-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #ddd; +} + +.uc-wizard-dot.active { + background: var(--bp-primary); +} + +.uc-wizard-dot.done { + background: var(--bp-primary); + opacity: 0.5; +} + +/* Wizard Start Button */ +.uc-wizard-start-btn { + position: fixed; + bottom: 24px; + right: 24px; + background: var(--bp-primary); + color: white; + border: none; + padding: 12px 20px; + border-radius: 30px; + font-weight: 500; + cursor: pointer; + box-shadow: 0 4px 12px rgba(108, 27, 27, 0.3); + z-index: 1000; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.2s; +} + +.uc-wizard-start-btn:hover { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(108, 27, 27, 0.4); +} +""" + + @staticmethod + def get_html() -> str: + """HTML fuer Unit Creator Panel.""" + return """ + +
    + +
    +

    Unit Creator

    +
    + + + +
    +
    + + +
    + + + + + +
    + + +
    + +
    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    + + + + + + + +
    +
    +
    + +
    + + +
    +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    + + +
    +
    +
    +
    +
    + + +
    +
    + +
    + +
    + + +
    +
    +
    +

    Pre-Check

    +
    +
    + + +
    +
    + + +
    +
    + +
    + +
    +

    Post-Check

    +
    +
    + + +
    +
    + + +
    +
    + +
    +
    +
    + + +
    +
    +
    Unit Vorschau
    +
    +
    +
    📝
    +

    Fuegen Sie Metadaten und Stops hinzu, um eine Vorschau zu sehen.

    +
    +
    +
    +
    +
    + + +
    + +
    + + + +
    +
    +
    + + + +
    + + + + + + + + +
    + + + +""" + + @staticmethod + def get_js() -> str: + """JavaScript fuer Unit Creator.""" + return """ +// ============================================== +// UNIT CREATOR MODULE +// ============================================== + +console.log('Unit Creator Module loaded'); + +// Current unit data +let ucUnitData = { + unit_id: '', + template: '', + version: '1.0.0', + locale: ['de-DE'], + grade_band: ['5', '6', '7'], + duration_minutes: 8, + difficulty: 'base', + subject: '', + topic: '', + learning_objectives: [], + stops: [], + precheck: { + question_set_id: '', + required: true, + time_limit_seconds: 120 + }, + postcheck: { + question_set_id: '', + required: true, + time_limit_seconds: 180 + }, + teacher_controls: { + allow_skip: true, + allow_replay: true, + max_time_per_stop_sec: 90, + show_hints: true, + require_precheck: true, + require_postcheck: true + }, + assets: {}, + metadata: {} +}; + +// Current editing stop index +let ucEditingStopIndex = -1; + +// Interaction types +const UC_INTERACTION_TYPES = [ + { value: 'aim_and_pass', label: 'Ziel treffen (Aim & Pass)' }, + { value: 'slider_adjust', label: 'Wert einstellen (Slider)' }, + { value: 'slider_equivalence', label: 'Werte synchronisieren (Equivalence)' }, + { value: 'sequence_arrange', label: 'Reihenfolge sortieren (Sequence)' }, + { value: 'toggle_switch', label: 'Auswahl treffen (Toggle)' }, + { value: 'drag_match', label: 'Zuordnung (Drag & Match)' }, + { value: 'error_find', label: 'Fehler finden (Error Find)' }, + { value: 'transfer_apply', label: 'Konzept anwenden (Transfer)' } +]; + +// ============================================== +// INITIALIZATION +// ============================================== + +function loadUnitCreatorModule() { + console.log('Initializing Unit Creator...'); + ucRenderStops(); + ucUpdateJsonEditor(); + ucUpdatePreview(); +} + +// ============================================== +// TAB NAVIGATION +// ============================================== + +function ucSwitchTab(tabId) { + // Update tab buttons + document.querySelectorAll('.uc-tab').forEach(tab => { + tab.classList.toggle('active', tab.dataset.tab === tabId); + }); + + // Update tab panels + document.querySelectorAll('.uc-tab-panel').forEach(panel => { + panel.classList.toggle('active', panel.id === 'uc-tab-' + tabId); + }); + + // Special handling + if (tabId === 'json') { + ucUpdateJsonEditor(); + } else if (tabId === 'preview') { + ucUpdatePreview(); + } +} + +// ============================================== +// FIELD UPDATES +// ============================================== + +function ucUpdateField(field, value) { + ucUnitData[field] = value; + console.log('Updated field:', field, value); +} + +function ucUpdateDuration(value) { + ucUnitData.duration_minutes = parseInt(value); + document.getElementById('uc-duration-value').textContent = value; +} + +function ucToggleGrade(grade) { + const index = ucUnitData.grade_band.indexOf(grade); + if (index > -1) { + ucUnitData.grade_band.splice(index, 1); + } else { + ucUnitData.grade_band.push(grade); + ucUnitData.grade_band.sort(); + } + console.log('Grade band:', ucUnitData.grade_band); +} + +function ucUpdatePrecheck(field, value) { + ucUnitData.precheck[field] = value; +} + +function ucUpdatePostcheck(field, value) { + ucUnitData.postcheck[field] = value; +} + +// ============================================== +// LEARNING OBJECTIVES +// ============================================== + +function ucAddObjective() { + const input = document.getElementById('uc-objective-input'); + const value = input.value.trim(); + if (value) { + ucUnitData.learning_objectives.push(value); + input.value = ''; + ucRenderObjectives(); + } +} + +function ucRemoveObjective(index) { + ucUnitData.learning_objectives.splice(index, 1); + ucRenderObjectives(); +} + +function ucRenderObjectives() { + const container = document.getElementById('uc-objectives-items'); + container.innerHTML = ucUnitData.learning_objectives.map((obj, i) => ` +
    + ${obj} + +
    + `).join(''); +} + +// ============================================== +// STOPS MANAGEMENT +// ============================================== + +function ucAddStop() { + const newStop = { + stop_id: 'stop_' + (ucUnitData.stops.length + 1), + order: ucUnitData.stops.length, + label: { 'de-DE': '' }, + narration: { 'de-DE': '' }, + interaction: { type: '', params: {} }, + concept: { why: { 'de-DE': '' }, common_misconception: { 'de-DE': '' } }, + vocab: [], + telemetry_tags: [] + }; + ucUnitData.stops.push(newStop); + ucRenderStops(); +} + +function ucRemoveStop(index) { + if (confirm('Stop wirklich loeschen?')) { + ucUnitData.stops.splice(index, 1); + // Update order + ucUnitData.stops.forEach((stop, i) => stop.order = i); + ucRenderStops(); + } +} + +function ucEditStop(index) { + ucEditingStopIndex = index; + const stop = ucUnitData.stops[index]; + + const content = document.getElementById('uc-stop-modal-content'); + content.innerHTML = ` +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    + +
    + +
    + +
    +
    + `; + + // Interaktions-Parameter und Vokabeln rendern + ucUpdateInteractionParams(); + ucRenderVocabList(); + + document.getElementById('uc-stop-modal').classList.add('active'); +} + +// Dynamische Parameter je nach Interaktionstyp +function ucUpdateInteractionParams() { + const container = document.getElementById('uc-interaction-params-container'); + const type = document.getElementById('uc-edit-stop-interaction').value; + const stop = ucUnitData.stops[ucEditingStopIndex]; + const params = stop.interaction.params || {}; + + let html = ''; + + switch (type) { + case 'aim_and_pass': + html = ` +
    +
    Parameter: Ziel treffen
    +
    +
    + + +
    +
    + + +
    +
    +
    + `; + break; + + case 'slider_adjust': + html = ` +
    +
    Parameter: Slider einstellen
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    + + +
    +
    + + +
    +
    +
    + `; + break; + + case 'slider_equivalence': + const eqPairs = params.pairs || [{ left: 50, right: 50 }]; + html = ` +
    +
    Parameter: Werte synchronisieren
    +
    + ${eqPairs.map((p, i) => ` +
    +
    + + +
    +
    + + +
    + +
    + `).join('')} +
    + +
    + `; + break; + + case 'sequence_arrange': + const items = params.items || ['Item 1', 'Item 2', 'Item 3']; + html = ` +
    +
    Parameter: Reihenfolge (korrekte Reihenfolge eingeben)
    +
    + ${items.map((item, i) => ` +
    + ${i + 1}. + + +
    + `).join('')} +
    + +
    + `; + break; + + case 'toggle_switch': + const options = params.options || [{ value: 'a', label: 'Option A', correct: true }, { value: 'b', label: 'Option B', correct: false }]; + html = ` +
    +
    Parameter: Auswahloptionen
    +
    + ${options.map((opt, i) => ` +
    + + + + +
    + `).join('')} +
    + +
    + `; + break; + + case 'drag_match': + const matchPairs = params.pairs || [{ left: 'Begriff 1', right: 'Definition 1' }]; + html = ` +
    +
    Parameter: Zuordnungspaare
    +
    + ${matchPairs.map((p, i) => ` +
    + + + + +
    + `).join('')} +
    + +
    + `; + break; + + case 'error_find': + const errors = params.errors || ['Fehler 1']; + html = ` +
    +
    Parameter: Fehler finden
    +
    + + +
    +
    + +
    + ${errors.map((err, i) => ` +
    + + +
    + `).join('')} +
    + +
    +
    + `; + break; + + case 'transfer_apply': + html = ` +
    +
    Parameter: Konzept anwenden
    +
    + + +
    +
    + + +
    +
    + `; + break; + + default: + html = '

    Waehlen Sie einen Interaktionstyp, um Parameter zu konfigurieren.

    '; + } + + container.innerHTML = html; +} + +// Helper-Funktionen fuer dynamische Listen +function ucAddEquivalencePair() { + const container = document.getElementById('uc-equivalence-pairs'); + const div = document.createElement('div'); + div.className = 'uc-form-row uc-pair-row'; + div.innerHTML = ` +
    + + +
    +
    + + +
    + + `; + container.appendChild(div); +} + +function ucAddSequenceItem() { + const container = document.getElementById('uc-sequence-items'); + const count = container.querySelectorAll('.uc-sequence-row').length; + const div = document.createElement('div'); + div.className = 'uc-form-row uc-sequence-row'; + div.innerHTML = ` + ${count + 1}. + + + `; + container.appendChild(div); +} + +function ucAddToggleOption() { + const container = document.getElementById('uc-toggle-options'); + const div = document.createElement('div'); + div.className = 'uc-form-row uc-toggle-row'; + div.innerHTML = ` + + + + + `; + container.appendChild(div); +} + +function ucAddMatchPair() { + const container = document.getElementById('uc-match-pairs'); + const div = document.createElement('div'); + div.className = 'uc-form-row uc-match-row'; + div.innerHTML = ` + + + + + `; + container.appendChild(div); +} + +function ucAddErrorItem() { + const container = document.getElementById('uc-error-items'); + const div = document.createElement('div'); + div.className = 'uc-form-row uc-error-row'; + div.innerHTML = ` + + + `; + container.appendChild(div); +} + +// Vokabeln-Editor +function ucRenderVocabList() { + const container = document.getElementById('uc-vocab-list'); + const stop = ucUnitData.stops[ucEditingStopIndex]; + const vocab = stop.vocab || []; + + if (vocab.length === 0) { + container.innerHTML = '

    Keine Vokabeln

    '; + return; + } + + container.innerHTML = vocab.map((v, i) => ` +
    + + + +
    + `).join(''); +} + +function ucAddVocab() { + const stop = ucUnitData.stops[ucEditingStopIndex]; + if (!stop.vocab) stop.vocab = []; + stop.vocab.push({ term: { 'de-DE': '' }, hint: { 'de-DE': '' } }); + ucRenderVocabList(); +} + +function ucRemoveVocab(index) { + const stop = ucUnitData.stops[ucEditingStopIndex]; + stop.vocab.splice(index, 1); + ucRenderVocabList(); +} + +// Interaktions-Parameter aus dem UI auslesen +function ucCollectInteractionParams() { + const type = document.getElementById('uc-edit-stop-interaction').value; + const params = {}; + + switch (type) { + case 'aim_and_pass': + params.target = document.getElementById('uc-param-target')?.value || ''; + params.tolerance = parseFloat(document.getElementById('uc-param-tolerance')?.value) || 5; + break; + + case 'slider_adjust': + params.min = parseFloat(document.getElementById('uc-param-min')?.value) ?? 0; + params.max = parseFloat(document.getElementById('uc-param-max')?.value) ?? 100; + params.correct = parseFloat(document.getElementById('uc-param-correct')?.value) ?? 50; + params.tolerance = parseFloat(document.getElementById('uc-param-tolerance')?.value) ?? 5; + break; + + case 'slider_equivalence': + params.pairs = []; + document.querySelectorAll('.uc-pair-row').forEach(row => { + params.pairs.push({ + left: parseFloat(row.querySelector('.uc-eq-left')?.value) || 50, + right: parseFloat(row.querySelector('.uc-eq-right')?.value) || 50 + }); + }); + break; + + case 'sequence_arrange': + params.items = []; + document.querySelectorAll('.uc-seq-item').forEach(input => { + if (input.value.trim()) params.items.push(input.value.trim()); + }); + break; + + case 'toggle_switch': + params.options = []; + document.querySelectorAll('.uc-toggle-row').forEach(row => { + params.options.push({ + value: row.querySelector('.uc-opt-value')?.value || '', + label: row.querySelector('.uc-opt-label')?.value || '', + correct: row.querySelector('.uc-opt-correct')?.checked || false + }); + }); + break; + + case 'drag_match': + params.pairs = []; + document.querySelectorAll('.uc-match-row').forEach(row => { + params.pairs.push({ + left: row.querySelector('.uc-match-left')?.value || '', + right: row.querySelector('.uc-match-right')?.value || '' + }); + }); + break; + + case 'error_find': + params.correct = document.getElementById('uc-param-correct')?.value || ''; + params.errors = []; + document.querySelectorAll('.uc-error-item').forEach(input => { + if (input.value.trim()) params.errors.push(input.value.trim()); + }); + break; + + case 'transfer_apply': + params.context = document.getElementById('uc-param-context')?.value || ''; + params.template = document.getElementById('uc-param-template')?.value || ''; + break; + } + + return params; +} + +// Vokabeln aus dem UI auslesen +function ucCollectVocab() { + const vocab = []; + document.querySelectorAll('.uc-vocab-row').forEach(row => { + const term = row.querySelector('.uc-vocab-term')?.value || ''; + const hint = row.querySelector('.uc-vocab-hint')?.value || ''; + if (term || hint) { + vocab.push({ + term: { 'de-DE': term }, + hint: { 'de-DE': hint } + }); + } + }); + return vocab; +} + +function ucSaveStop() { + if (ucEditingStopIndex < 0) return; + + const stop = ucUnitData.stops[ucEditingStopIndex]; + stop.stop_id = document.getElementById('uc-edit-stop-id').value; + stop.label['de-DE'] = document.getElementById('uc-edit-stop-label').value; + stop.interaction.type = document.getElementById('uc-edit-stop-interaction').value; + stop.interaction.params = ucCollectInteractionParams(); + stop.narration['de-DE'] = document.getElementById('uc-edit-stop-narration').value; + stop.concept = stop.concept || { why: {}, common_misconception: {} }; + stop.concept.why['de-DE'] = document.getElementById('uc-edit-stop-why').value; + stop.concept.common_misconception['de-DE'] = document.getElementById('uc-edit-stop-misconception').value; + stop.vocab = ucCollectVocab(); + + ucCloseStopModal(); + ucRenderStops(); +} + +function ucCloseStopModal() { + document.getElementById('uc-stop-modal').classList.remove('active'); + ucEditingStopIndex = -1; +} + +function ucRenderStops() { + const container = document.getElementById('uc-stops-list'); + + if (ucUnitData.stops.length === 0) { + container.innerHTML = ` +
    +
    📋
    +

    Noch keine Stops. Klicken Sie unten, um einen Stop hinzuzufuegen.

    +
    + `; + return; + } + + container.innerHTML = ucUnitData.stops.map((stop, i) => ` +
    +
    +
    + ${i + 1} + ${stop.label['de-DE'] || stop.stop_id || 'Unbenannt'} +
    +
    + + +
    +
    +
    +
    + + ${UC_INTERACTION_TYPES.find(t => t.value === stop.interaction.type)?.label || 'Nicht gesetzt'} +
    +
    + + ${stop.stop_id} +
    +
    +
    + `).join(''); + + // Add drag and drop + ucInitStopDragDrop(); +} + +function ucInitStopDragDrop() { + const cards = document.querySelectorAll('.uc-stop-card'); + cards.forEach(card => { + card.addEventListener('dragstart', ucHandleDragStart); + card.addEventListener('dragend', ucHandleDragEnd); + card.addEventListener('dragover', ucHandleDragOver); + card.addEventListener('drop', ucHandleDrop); + }); +} + +let ucDraggedIndex = -1; + +function ucHandleDragStart(e) { + ucDraggedIndex = parseInt(e.target.dataset.index); + e.target.classList.add('dragging'); +} + +function ucHandleDragEnd(e) { + e.target.classList.remove('dragging'); +} + +function ucHandleDragOver(e) { + e.preventDefault(); +} + +function ucHandleDrop(e) { + e.preventDefault(); + const dropIndex = parseInt(e.target.closest('.uc-stop-card').dataset.index); + + if (ucDraggedIndex !== dropIndex) { + const [draggedStop] = ucUnitData.stops.splice(ucDraggedIndex, 1); + ucUnitData.stops.splice(dropIndex, 0, draggedStop); + // Update order + ucUnitData.stops.forEach((stop, i) => stop.order = i); + ucRenderStops(); + } +} + +// ============================================== +// JSON EDITOR +// ============================================== + +function ucUpdateJsonEditor() { + const editor = document.getElementById('uc-json-editor'); + editor.value = JSON.stringify(ucUnitData, null, 2); +} + +function ucApplyJson() { + const editor = document.getElementById('uc-json-editor'); + try { + ucUnitData = JSON.parse(editor.value); + ucRenderStops(); + ucRenderObjectives(); + ucPopulateFormFromData(); + alert('JSON erfolgreich angewendet!'); + } catch (e) { + alert('Ungueltiges JSON: ' + e.message); + } +} + +function ucFormatJson() { + const editor = document.getElementById('uc-json-editor'); + try { + const data = JSON.parse(editor.value); + editor.value = JSON.stringify(data, null, 2); + } catch (e) { + alert('Ungueltiges JSON: ' + e.message); + } +} + +function ucCopyJson() { + const editor = document.getElementById('uc-json-editor'); + navigator.clipboard.writeText(editor.value).then(() => { + alert('JSON in Zwischenablage kopiert!'); + }); +} + +function ucPopulateFormFromData() { + document.getElementById('uc-unit-id').value = ucUnitData.unit_id || ''; + document.getElementById('uc-template').value = ucUnitData.template || ''; + document.getElementById('uc-subject').value = ucUnitData.subject || ''; + document.getElementById('uc-topic').value = ucUnitData.topic || ''; + document.getElementById('uc-version').value = ucUnitData.version || '1.0.0'; + document.getElementById('uc-duration').value = ucUnitData.duration_minutes || 8; + document.getElementById('uc-duration-value').textContent = ucUnitData.duration_minutes || 8; + + // Difficulty + document.querySelector(`input[name="difficulty"][value="${ucUnitData.difficulty || 'base'}"]`).checked = true; + + // Grade band checkboxes + document.querySelectorAll('.uc-checkbox-group input[type="checkbox"]').forEach(cb => { + cb.checked = ucUnitData.grade_band.includes(cb.value); + }); + + // Pre/Post check + document.getElementById('uc-precheck-id').value = ucUnitData.precheck?.question_set_id || ''; + document.getElementById('uc-precheck-time').value = ucUnitData.precheck?.time_limit_seconds || 120; + document.getElementById('uc-precheck-required').checked = ucUnitData.precheck?.required !== false; + document.getElementById('uc-postcheck-id').value = ucUnitData.postcheck?.question_set_id || ''; + document.getElementById('uc-postcheck-time').value = ucUnitData.postcheck?.time_limit_seconds || 180; + document.getElementById('uc-postcheck-required').checked = ucUnitData.postcheck?.required !== false; +} + +// ============================================== +// PREVIEW +// ============================================== + +function ucUpdatePreview() { + const title = document.getElementById('uc-preview-title'); + const content = document.getElementById('uc-preview-content'); + const validation = document.getElementById('uc-validation-result'); + + title.textContent = ucUnitData.unit_id || ucUnitData.topic || 'Unit Vorschau'; + + if (ucUnitData.stops.length === 0) { + content.innerHTML = ` +
    +
    📝
    +

    Fuegen Sie Stops hinzu, um eine Vorschau zu sehen.

    +
    + `; + } else { + let html = '
    '; + html += `

    Template: ${ucUnitData.template || 'Nicht gesetzt'}

    `; + html += `

    Fach: ${ucUnitData.subject || 'Nicht gesetzt'} - ${ucUnitData.topic || ''}

    `; + html += `

    Dauer: ${ucUnitData.duration_minutes} Min | Klassen: ${ucUnitData.grade_band.join(', ')}

    `; + html += '
    '; + + html += '
    '; + ucUnitData.stops.forEach((stop, i) => { + if (i > 0) html += ''; + html += `${stop.label['de-DE'] || stop.stop_id}`; + }); + html += '
    '; + + content.innerHTML = html; + } + + // Validate + ucValidateAndShow(validation); +} + +async function ucValidateAndShow(container) { + try { + const response = await fetch('/api/units/definitions/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(ucUnitData) + }); + const result = await response.json(); + + if (result.valid) { + container.innerHTML = ` +
    +
    ✅ Validierung erfolgreich
    + ${result.warnings.length > 0 ? ` +
      + ${result.warnings.map(w => `
    • ⚠ ${w.message}
    • `).join('')} +
    + ` : ''} +
    + `; + } else { + container.innerHTML = ` +
    +
    ❌ Validierung fehlgeschlagen
    +
      + ${result.errors.map(e => `
    • ❌ ${e.message}
    • `).join('')} + ${result.warnings.map(w => `
    • ⚠ ${w.message}
    • `).join('')} +
    +
    + `; + } + } catch (e) { + container.innerHTML = ` +
    +
    ❌ Validierung nicht moeglich
    +

    Backend nicht erreichbar

    +
    + `; + } +} + +// ============================================== +// VALIDATION +// ============================================== + +async function ucValidate() { + ucSwitchTab('preview'); +} + +// ============================================== +// IMPORT / EXPORT +// ============================================== + +function ucImportJson() { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.onchange = e => { + const file = e.target.files[0]; + const reader = new FileReader(); + reader.onload = event => { + try { + ucUnitData = JSON.parse(event.target.result); + ucRenderStops(); + ucRenderObjectives(); + ucPopulateFormFromData(); + ucUpdateJsonEditor(); + alert('JSON importiert!'); + } catch (err) { + alert('Ungueltiges JSON: ' + err.message); + } + }; + reader.readAsText(file); + }; + input.click(); +} + +function ucExportJson() { + const blob = new Blob([JSON.stringify(ucUnitData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = (ucUnitData.unit_id || 'unit') + '.json'; + a.click(); + URL.revokeObjectURL(url); +} + +// ============================================== +// LOAD EXISTING +// ============================================== + +async function ucLoadExisting() { + const select = document.getElementById('uc-load-select'); + + try { + const response = await fetch('/api/units/definitions'); + const units = await response.json(); + + select.innerHTML = ''; + units.forEach(unit => { + select.innerHTML += ``; + }); + + document.getElementById('uc-load-modal').classList.add('active'); + } catch (e) { + alert('Fehler beim Laden der Units: ' + e.message); + } +} + +async function ucLoadSelectedUnit() { + const select = document.getElementById('uc-load-select'); + const unitId = select.value; + + if (!unitId) { + alert('Bitte eine Unit waehlen'); + return; + } + + try { + const response = await fetch('/api/units/definitions/' + unitId); + const data = await response.json(); + + ucUnitData = data.definition || data; + ucRenderStops(); + ucRenderObjectives(); + ucPopulateFormFromData(); + ucUpdateJsonEditor(); + ucCloseLoadModal(); + alert('Unit geladen: ' + unitId); + } catch (e) { + alert('Fehler beim Laden: ' + e.message); + } +} + +function ucCloseLoadModal() { + document.getElementById('uc-load-modal').classList.remove('active'); +} + +// ============================================== +// SAVE / PUBLISH +// ============================================== + +async function ucSaveDraft() { + ucUnitData.status = 'draft'; + await ucSaveUnit(); +} + +async function ucPublish() { + if (!confirm('Unit wirklich veroeffentlichen? Sie kann dann von Lehrern zugewiesen werden.')) { + return; + } + ucUnitData.status = 'published'; + await ucSaveUnit(); +} + +async function ucSaveUnit() { + // Auto-fill pre/post check IDs if empty + if (!ucUnitData.precheck.question_set_id) { + ucUnitData.precheck.question_set_id = ucUnitData.unit_id + '_precheck'; + } + if (!ucUnitData.postcheck.question_set_id) { + ucUnitData.postcheck.question_set_id = ucUnitData.unit_id + '_postcheck'; + } + + try { + // Check if unit exists + let method = 'POST'; + let url = '/api/units/definitions'; + + try { + const checkResponse = await fetch('/api/units/definitions/' + ucUnitData.unit_id); + if (checkResponse.ok) { + method = 'PUT'; + url = '/api/units/definitions/' + ucUnitData.unit_id; + } + } catch (e) { + // Unit doesn't exist, use POST + } + + const response = await fetch(url, { + method: method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(ucUnitData) + }); + + if (response.ok) { + const result = await response.json(); + alert('Unit erfolgreich gespeichert: ' + result.unit_id); + } else { + const error = await response.json(); + alert('Fehler: ' + (error.detail || 'Unbekannter Fehler')); + } + } catch (e) { + alert('Fehler beim Speichern: ' + e.message); + } +} + +// ============================================== +// WIZARD - Interaktive Bedienungsanleitung +// ============================================== + +const UC_WIZARD_STEPS = [ + { + title: 'Willkommen im Unit Creator!', + text: 'Mit diesem Tool koennen Sie eigene Lerneinheiten (Units) fuer BreakpilotDrive erstellen. Der Wizard fuehrt Sie durch die wichtigsten Funktionen.', + element: null, // Kein Element hervorheben + position: 'center' + }, + { + title: 'Metadaten eingeben', + text: 'Hier geben Sie die Grundinformationen ein: Unit-ID (eindeutiger Name), Template-Typ, Fach, Thema und Klassenstufen. Diese Daten helfen beim Filtern und Zuweisen.', + element: '#uc-tab-metadata', + position: 'right' + }, + { + title: 'Stops hinzufuegen', + text: 'Jede Unit besteht aus mehreren "Stops" - das sind die einzelnen Lernstationen. Wechseln Sie zum Stops-Tab und fuegen Sie Ihre Stationen hinzu.', + element: '[data-tab="stops"]', + position: 'bottom' + }, + { + title: 'Interaktionstypen waehlen', + text: 'Jeder Stop hat einen Interaktionstyp: Slider einstellen, Reihenfolge sortieren, Zuordnungen treffen, etc. Im Stop-Editor waehlen Sie den Typ und konfigurieren die Parameter.', + element: '#uc-stops-list', + position: 'left' + }, + { + title: 'Pre/Post-Check konfigurieren', + text: 'Units haben einen Vortest (Precheck) und Nachtest (Postcheck) um den Lernerfolg zu messen. Konfigurieren Sie diese im dritten Tab.', + element: '[data-tab="checks"]', + position: 'bottom' + }, + { + title: 'Vorschau und JSON', + text: 'Im Vorschau-Tab sehen Sie eine Zusammenfassung. Im JSON-Tab koennen Sie das Rohdatenformat bearbeiten oder Units importieren/exportieren.', + element: '[data-tab="preview"]', + position: 'bottom' + }, + { + title: 'Validieren und Speichern', + text: 'Klicken Sie auf "Validieren" um Fehler zu pruefen. Mit "Als Entwurf speichern" sichern Sie Ihre Arbeit. "Veroeffentlichen" macht die Unit fuer Lehrer verfuegbar.', + element: '.uc-footer', + position: 'top' + }, + { + title: 'Geschafft!', + text: 'Sie kennen jetzt die wichtigsten Funktionen. Der "Anleitung"-Button unten rechts startet diesen Wizard erneut. Viel Erfolg beim Erstellen Ihrer ersten Unit!', + element: null, + position: 'center' + } +]; + +let ucWizardCurrentStep = 0; +let ucWizardActive = false; + +function ucWizardInit() { + // Pruefen ob Wizard schon gesehen wurde + const seen = localStorage.getItem('uc_wizard_seen'); + if (!seen) { + // Beim ersten Mal automatisch starten + setTimeout(() => ucWizardStart(), 500); + } + // Start-Button immer anzeigen + document.getElementById('uc-wizard-start-btn').style.display = 'flex'; +} + +function ucWizardStart() { + ucWizardCurrentStep = 0; + ucWizardActive = true; + document.getElementById('uc-wizard-overlay').classList.add('active'); + document.getElementById('uc-wizard-tooltip').style.display = 'block'; + document.getElementById('uc-wizard-start-btn').style.display = 'none'; + ucWizardShowStep(); +} + +function ucWizardShowStep() { + const step = UC_WIZARD_STEPS[ucWizardCurrentStep]; + const total = UC_WIZARD_STEPS.length; + + // Step-Nummer und Text + document.getElementById('uc-wizard-step-num').textContent = 'Schritt ' + (ucWizardCurrentStep + 1) + ' von ' + total; + document.getElementById('uc-wizard-title').textContent = step.title; + document.getElementById('uc-wizard-text').textContent = step.text; + + // Back-Button + document.getElementById('uc-wizard-back').style.display = ucWizardCurrentStep > 0 ? 'inline-block' : 'none'; + + // Next-Button Text + const nextBtn = document.getElementById('uc-wizard-next'); + nextBtn.textContent = ucWizardCurrentStep === total - 1 ? 'Fertig' : 'Weiter'; + + // Progress dots + const progress = document.getElementById('uc-wizard-progress'); + progress.innerHTML = UC_WIZARD_STEPS.map((_, i) => { + let cls = 'uc-wizard-dot'; + if (i < ucWizardCurrentStep) cls += ' done'; + if (i === ucWizardCurrentStep) cls += ' active'; + return '
    '; + }).join(''); + + // Element hervorheben + const highlight = document.getElementById('uc-wizard-highlight'); + const tooltip = document.getElementById('uc-wizard-tooltip'); + + if (step.element) { + const el = document.querySelector(step.element); + if (el) { + const rect = el.getBoundingClientRect(); + highlight.style.display = 'block'; + highlight.style.top = (rect.top + window.scrollY - 4) + 'px'; + highlight.style.left = (rect.left + window.scrollX - 4) + 'px'; + highlight.style.width = (rect.width + 8) + 'px'; + highlight.style.height = (rect.height + 8) + 'px'; + + // Tooltip positionieren + const tooltipRect = tooltip.getBoundingClientRect(); + let top, left; + + switch (step.position) { + case 'right': + top = rect.top; + left = rect.right + 20; + break; + case 'left': + top = rect.top; + left = rect.left - tooltipRect.width - 20; + break; + case 'bottom': + top = rect.bottom + 20; + left = rect.left; + break; + case 'top': + top = rect.top - tooltipRect.height - 20; + left = rect.left; + break; + default: + top = window.innerHeight / 2 - 100; + left = window.innerWidth / 2 - 200; + } + + // Grenzen pruefen + if (left < 20) left = 20; + if (left + 400 > window.innerWidth) left = window.innerWidth - 420; + if (top < 20) top = 20; + + tooltip.style.top = top + 'px'; + tooltip.style.left = left + 'px'; + } else { + highlight.style.display = 'none'; + tooltip.style.top = '50%'; + tooltip.style.left = '50%'; + tooltip.style.transform = 'translate(-50%, -50%)'; + } + } else { + // Zentriert anzeigen + highlight.style.display = 'none'; + tooltip.style.top = '50%'; + tooltip.style.left = '50%'; + tooltip.style.transform = 'translate(-50%, -50%)'; + } +} + +function ucWizardNext() { + if (ucWizardCurrentStep < UC_WIZARD_STEPS.length - 1) { + ucWizardCurrentStep++; + document.getElementById('uc-wizard-tooltip').style.transform = ''; + ucWizardShowStep(); + } else { + ucWizardEnd(); + } +} + +function ucWizardBack() { + if (ucWizardCurrentStep > 0) { + ucWizardCurrentStep--; + document.getElementById('uc-wizard-tooltip').style.transform = ''; + ucWizardShowStep(); + } +} + +function ucWizardSkip() { + ucWizardEnd(); +} + +function ucWizardEnd() { + ucWizardActive = false; + document.getElementById('uc-wizard-overlay').classList.remove('active'); + document.getElementById('uc-wizard-tooltip').style.display = 'none'; + document.getElementById('uc-wizard-highlight').style.display = 'none'; + document.getElementById('uc-wizard-start-btn').style.display = 'flex'; + localStorage.setItem('uc_wizard_seen', 'true'); +} + +// Wizard beim Laden des Moduls initialisieren +document.addEventListener('DOMContentLoaded', function() { + // Nur starten wenn Unit Creator Panel aktiv wird + const observer = new MutationObserver(function(mutations) { + mutations.forEach(function(mutation) { + const panel = document.getElementById('panel-unit-creator'); + if (panel && panel.classList.contains('active') && !localStorage.getItem('uc_wizard_seen')) { + setTimeout(() => ucWizardInit(), 300); + observer.disconnect(); + } + }); + }); + + const panel = document.getElementById('panel-unit-creator'); + if (panel) { + observer.observe(panel, { attributes: true, attributeFilter: ['class'] }); + } +}); + +// Alternativ: Manueller Aufruf wenn loadModule aufgerufen wird +if (typeof window.ucWizardInitCalled === 'undefined') { + window.ucWizardInitCalled = false; + const origLoadModule = window.loadModule; + if (origLoadModule) { + window.loadModule = function(moduleName) { + origLoadModule(moduleName); + if (moduleName === 'unit-creator' && !window.ucWizardInitCalled) { + window.ucWizardInitCalled = true; + setTimeout(() => ucWizardInit(), 500); + } + }; + } +} +""" + + +def get_unit_creator_module() -> dict: + """Gibt das komplette Unit Creator Modul als Dictionary zurueck.""" + module = UnitCreatorModule() + return { + 'css': module.get_css(), + 'html': module.get_html(), + 'js': module.get_js() + } diff --git a/backend/frontend/modules/widgets/__init__.py b/backend/frontend/modules/widgets/__init__.py new file mode 100644 index 0000000..86b2cf5 --- /dev/null +++ b/backend/frontend/modules/widgets/__init__.py @@ -0,0 +1,50 @@ +""" +Widget-Registry fuer das Lehrer-Dashboard. + +Alle verfuegbaren Widgets werden hier registriert und exportiert. +""" + +from .todos_widget import TodosWidget +from .schnellzugriff_widget import SchnellzugriffWidget +from .notizen_widget import NotizenWidget +from .stundenplan_widget import StundenplanWidget +from .klassen_widget import KlassenWidget +from .fehlzeiten_widget import FehlzeitenWidget +from .arbeiten_widget import ArbeitenWidget +from .nachrichten_widget import NachrichtenWidget +from .matrix_widget import MatrixWidget +from .alerts_widget import AlertsWidget +from .statistik_widget import StatistikWidget +from .kalender_widget import KalenderWidget + +# Widget-Registry mit allen verfuegbaren Widgets +WIDGET_REGISTRY = { + 'todos': TodosWidget, + 'schnellzugriff': SchnellzugriffWidget, + 'notizen': NotizenWidget, + 'stundenplan': StundenplanWidget, + 'klassen': KlassenWidget, + 'fehlzeiten': FehlzeitenWidget, + 'arbeiten': ArbeitenWidget, + 'nachrichten': NachrichtenWidget, + 'matrix': MatrixWidget, + 'alerts': AlertsWidget, + 'statistik': StatistikWidget, + 'kalender': KalenderWidget, +} + +__all__ = [ + 'WIDGET_REGISTRY', + 'TodosWidget', + 'SchnellzugriffWidget', + 'NotizenWidget', + 'StundenplanWidget', + 'KlassenWidget', + 'FehlzeitenWidget', + 'ArbeitenWidget', + 'NachrichtenWidget', + 'MatrixWidget', + 'AlertsWidget', + 'StatistikWidget', + 'KalenderWidget', +] diff --git a/backend/frontend/modules/widgets/alerts_widget.py b/backend/frontend/modules/widgets/alerts_widget.py new file mode 100644 index 0000000..7f3dc16 --- /dev/null +++ b/backend/frontend/modules/widgets/alerts_widget.py @@ -0,0 +1,272 @@ +""" +Alerts Widget fuer das Lehrer-Dashboard. + +Zeigt Google Alerts und andere Benachrichtigungen. +""" + + +class AlertsWidget: + widget_id = 'alerts' + widget_name = 'Alerts' + widget_icon = '🔔' # Bell + widget_color = '#f59e0b' # Orange + default_width = 'half' + has_settings = True + + @staticmethod + def get_css() -> str: + return """ +/* ===== Alerts Widget Styles ===== */ +.widget-alerts { + background: var(--bp-surface, #1e293b); + border: 1px solid var(--bp-border, #475569); + border-radius: 12px; + padding: 16px; + height: 100%; + display: flex; + flex-direction: column; +} + +.widget-alerts .widget-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.1)); +} + +.widget-alerts .widget-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: var(--bp-text, #e5e7eb); +} + +.widget-alerts .widget-icon { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; + border-radius: 8px; + font-size: 14px; +} + +.widget-alerts .alerts-list { + flex: 1; + overflow-y: auto; +} + +.widget-alerts .alert-item { + display: flex; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.05)); + cursor: pointer; + transition: background 0.2s; +} + +.widget-alerts .alert-item:last-child { + border-bottom: none; +} + +.widget-alerts .alert-item:hover { + background: var(--bp-surface-elevated, #334155); + margin: 0 -12px; + padding: 12px; + border-radius: 8px; +} + +.widget-alerts .alert-icon { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; + border-radius: 8px; + font-size: 14px; + flex-shrink: 0; +} + +.widget-alerts .alert-content { + flex: 1; + min-width: 0; +} + +.widget-alerts .alert-title { + font-size: 13px; + font-weight: 500; + color: var(--bp-text, #e5e7eb); + margin-bottom: 4px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.widget-alerts .alert-meta { + font-size: 11px; + color: var(--bp-text-muted, #9ca3af); + display: flex; + gap: 8px; +} + +.widget-alerts .alert-source { + color: #f59e0b; +} + +.widget-alerts .alerts-empty { + text-align: center; + padding: 24px; + color: var(--bp-text-muted, #9ca3af); + font-size: 13px; +} + +.widget-alerts .alerts-empty-icon { + font-size: 32px; + margin-bottom: 8px; + opacity: 0.5; +} + +.widget-alerts .alerts-footer { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.1)); +} + +.widget-alerts .alerts-all-btn { + width: 100%; + padding: 10px; + background: transparent; + border: 1px dashed var(--bp-border, #475569); + border-radius: 8px; + color: var(--bp-text-muted, #9ca3af); + font-size: 12px; + cursor: pointer; + transition: all 0.2s; +} + +.widget-alerts .alerts-all-btn:hover { + border-color: #f59e0b; + color: #f59e0b; +} +""" + + @staticmethod + def get_html() -> str: + return """ +
    +
    +
    + 🔔 + Google Alerts +
    + +
    +
    + +
    + +
    +""" + + @staticmethod + def get_js() -> str: + return """ +// ===== Alerts Widget JavaScript ===== + +function getDefaultAlerts() { + const now = Date.now(); + return [ + { + id: 1, + title: 'Neue Mathelehrer-Studie zeigt verbesserte Lernergebnisse durch digitale Tools', + source: 'Google Alert: Digitales Lernen', + url: '#', + time: new Date(now - 60 * 60 * 1000).toISOString() + }, + { + id: 2, + title: 'Kultusministerium kuendigt neue Fortbildungsreihe an', + source: 'Google Alert: Bildungspolitik NI', + url: '#', + time: new Date(now - 5 * 60 * 60 * 1000).toISOString() + }, + { + id: 3, + title: 'Best Practices: Differenzierter Deutschunterricht', + source: 'Google Alert: Deutschunterricht', + url: '#', + time: new Date(now - 24 * 60 * 60 * 1000).toISOString() + } + ]; +} + +function formatAlertTime(timeStr) { + const time = new Date(timeStr); + const now = new Date(); + const diffMs = now - time; + const diffHours = Math.floor(diffMs / (60 * 60 * 1000)); + const diffDays = Math.floor(diffMs / (24 * 60 * 60 * 1000)); + + if (diffHours < 1) return 'gerade eben'; + if (diffHours < 24) return `vor ${diffHours} Std.`; + if (diffDays === 1) return 'gestern'; + return `vor ${diffDays} Tagen`; +} + +function renderAlertsWidget() { + const list = document.getElementById('alerts-widget-list'); + if (!list) return; + + const alerts = getDefaultAlerts(); + + if (alerts.length === 0) { + list.innerHTML = ` +
    +
    🔔
    +
    Keine neuen Alerts
    +
    + `; + return; + } + + list.innerHTML = alerts.map(alert => ` +
    +
    📰
    +
    +
    ${alert.title}
    +
    + ${alert.source} + ${formatAlertTime(alert.time)} +
    +
    +
    + `).join(''); +} + +function openAlert(url) { + if (url && url !== '#') { + window.open(url, '_blank'); + } +} + +function openAlertsModule() { + if (typeof loadModule === 'function') { + loadModule('alerts'); + } +} + +function initAlertsWidget() { + renderAlertsWidget(); +} +""" diff --git a/backend/frontend/modules/widgets/arbeiten_widget.py b/backend/frontend/modules/widgets/arbeiten_widget.py new file mode 100644 index 0000000..3261f1c --- /dev/null +++ b/backend/frontend/modules/widgets/arbeiten_widget.py @@ -0,0 +1,341 @@ +""" +Arbeiten Widget fuer das Lehrer-Dashboard. + +Zeigt anstehende Arbeiten und Fristen. +""" + + +class ArbeitenWidget: + widget_id = 'arbeiten' + widget_name = 'Anstehende Arbeiten' + widget_icon = '📝' # Memo + widget_color = '#f59e0b' # Orange + default_width = 'half' + has_settings = True + + @staticmethod + def get_css() -> str: + return """ +/* ===== Arbeiten Widget Styles ===== */ +.widget-arbeiten { + background: var(--bp-surface, #1e293b); + border: 1px solid var(--bp-border, #475569); + border-radius: 12px; + padding: 16px; + height: 100%; + display: flex; + flex-direction: column; +} + +.widget-arbeiten .widget-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.1)); +} + +.widget-arbeiten .widget-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: var(--bp-text, #e5e7eb); +} + +.widget-arbeiten .widget-icon { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; + border-radius: 8px; + font-size: 14px; +} + +.widget-arbeiten .arbeiten-list { + flex: 1; + overflow-y: auto; +} + +.widget-arbeiten .arbeit-item { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px; + margin-bottom: 8px; + background: var(--bp-bg, #0f172a); + border: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.1)); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; +} + +.widget-arbeiten .arbeit-item:last-child { + margin-bottom: 0; +} + +.widget-arbeiten .arbeit-item:hover { + background: var(--bp-surface-elevated, #334155); + border-color: #f59e0b; +} + +.widget-arbeiten .arbeit-item.urgent { + border-left: 3px solid #ef4444; +} + +.widget-arbeiten .arbeit-item.soon { + border-left: 3px solid #f59e0b; +} + +.widget-arbeiten .arbeit-icon { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; + border-radius: 8px; + font-size: 16px; + flex-shrink: 0; +} + +.widget-arbeiten .arbeit-content { + flex: 1; + min-width: 0; +} + +.widget-arbeiten .arbeit-title { + font-size: 14px; + font-weight: 500; + color: var(--bp-text, #e5e7eb); + margin-bottom: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.widget-arbeiten .arbeit-meta { + font-size: 12px; + color: var(--bp-text-muted, #9ca3af); + display: flex; + align-items: center; + gap: 8px; +} + +.widget-arbeiten .arbeit-frist { + display: flex; + align-items: center; + gap: 4px; +} + +.widget-arbeiten .arbeit-frist.urgent { + color: #ef4444; +} + +.widget-arbeiten .arbeit-frist.soon { + color: #f59e0b; +} + +.widget-arbeiten .arbeit-type { + padding: 2px 6px; + background: rgba(107, 114, 128, 0.2); + border-radius: 4px; + font-size: 10px; + text-transform: uppercase; +} + +.widget-arbeiten .arbeiten-empty { + text-align: center; + padding: 24px; + color: var(--bp-text-muted, #9ca3af); + font-size: 13px; +} + +.widget-arbeiten .arbeiten-empty-icon { + font-size: 32px; + margin-bottom: 8px; + opacity: 0.5; +} + +.widget-arbeiten .arbeiten-footer { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.1)); +} + +.widget-arbeiten .arbeiten-all-btn { + width: 100%; + padding: 10px; + background: transparent; + border: 1px dashed var(--bp-border, #475569); + border-radius: 8px; + color: var(--bp-text-muted, #9ca3af); + font-size: 12px; + cursor: pointer; + transition: all 0.2s; +} + +.widget-arbeiten .arbeiten-all-btn:hover { + border-color: #f59e0b; + color: #f59e0b; +} +""" + + @staticmethod + def get_html() -> str: + return """ +
    +
    +
    + 📝 + Anstehende Arbeiten +
    + +
    +
    + +
    + +
    +""" + + @staticmethod + def get_js() -> str: + return """ +// ===== Arbeiten Widget JavaScript ===== +const ARBEITEN_STORAGE_KEY = 'bp-lehrer-arbeiten'; + +function getDefaultArbeiten() { + const today = new Date(); + return [ + { + id: 1, + titel: 'Klausur Deutsch - Gedichtanalyse', + klasse: '12c', + typ: 'klausur', + frist: new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(), + status: 'geplant' + }, + { + id: 2, + titel: 'Aufsatz Korrektur', + klasse: '10a', + typ: 'korrektur', + frist: new Date(today.getTime() + 4 * 24 * 60 * 60 * 1000).toISOString(), + status: 'korrektur' + }, + { + id: 3, + titel: 'Vokabeltest', + klasse: '11b', + typ: 'test', + frist: new Date(today.getTime() + 2 * 24 * 60 * 60 * 1000).toISOString(), + status: 'geplant' + } + ]; +} + +function loadArbeiten() { + const stored = localStorage.getItem(ARBEITEN_STORAGE_KEY); + return stored ? JSON.parse(stored) : getDefaultArbeiten(); +} + +function saveArbeiten(arbeiten) { + localStorage.setItem(ARBEITEN_STORAGE_KEY, JSON.stringify(arbeiten)); +} + +function getDaysUntil(dateStr) { + const date = new Date(dateStr); + const today = new Date(); + today.setHours(0, 0, 0, 0); + date.setHours(0, 0, 0, 0); + return Math.ceil((date - today) / (24 * 60 * 60 * 1000)); +} + +function formatFrist(dateStr) { + const days = getDaysUntil(dateStr); + if (days < 0) return 'ueberfaellig'; + if (days === 0) return 'heute'; + if (days === 1) return 'morgen'; + return `in ${days} Tagen`; +} + +function getFristClass(dateStr) { + const days = getDaysUntil(dateStr); + if (days <= 2) return 'urgent'; + if (days <= 5) return 'soon'; + return ''; +} + +function getTypIcon(typ) { + const icons = { + klausur: '📄', + test: '📋', + korrektur: '📝', + abgabe: '📦' + }; + return icons[typ] || '📄'; +} + +function renderArbeiten() { + const list = document.getElementById('arbeiten-list'); + if (!list) return; + + let arbeiten = loadArbeiten(); + + // Sort by deadline + arbeiten.sort((a, b) => new Date(a.frist) - new Date(b.frist)); + + if (arbeiten.length === 0) { + list.innerHTML = ` +
    +
    🎉
    +
    Keine anstehenden Arbeiten
    +
    + `; + return; + } + + list.innerHTML = arbeiten.map(arbeit => { + const fristClass = getFristClass(arbeit.frist); + return ` +
    +
    ${getTypIcon(arbeit.typ)}
    +
    +
    ${arbeit.titel}
    +
    + ${arbeit.typ} + ${arbeit.klasse} + 📅 ${formatFrist(arbeit.frist)} +
    +
    +
    + `; + }).join(''); +} + +function openArbeit(arbeitId) { + if (typeof loadModule === 'function') { + loadModule('klausur-korrektur'); + console.log('Opening work:', arbeitId); + } +} + +function openAllArbeiten() { + if (typeof loadModule === 'function') { + loadModule('klausur-korrektur'); + } +} + +function initArbeitenWidget() { + renderArbeiten(); +} +""" diff --git a/backend/frontend/modules/widgets/fehlzeiten_widget.py b/backend/frontend/modules/widgets/fehlzeiten_widget.py new file mode 100644 index 0000000..462f85b --- /dev/null +++ b/backend/frontend/modules/widgets/fehlzeiten_widget.py @@ -0,0 +1,302 @@ +""" +Fehlzeiten Widget fuer das Lehrer-Dashboard. + +Zeigt abwesende Schueler fuer heute. +""" + + +class FehlzeitenWidget: + widget_id = 'fehlzeiten' + widget_name = 'Fehlzeiten' + widget_icon = '⚠' # Warning + widget_color = '#ef4444' # Red + default_width = 'half' + has_settings = True + + @staticmethod + def get_css() -> str: + return """ +/* ===== Fehlzeiten Widget Styles ===== */ +.widget-fehlzeiten { + background: var(--bp-surface, #1e293b); + border: 1px solid var(--bp-border, #475569); + border-radius: 12px; + padding: 16px; + height: 100%; + display: flex; + flex-direction: column; +} + +.widget-fehlzeiten .widget-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.1)); +} + +.widget-fehlzeiten .widget-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: var(--bp-text, #e5e7eb); +} + +.widget-fehlzeiten .widget-icon { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(239, 68, 68, 0.15); + color: #ef4444; + border-radius: 8px; + font-size: 14px; +} + +.widget-fehlzeiten .fehlzeiten-count { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; + padding: 4px 10px; + border-radius: 12px; + font-size: 12px; + font-weight: 600; +} + +.widget-fehlzeiten .fehlzeiten-list { + flex: 1; + overflow-y: auto; +} + +.widget-fehlzeiten .fehlzeit-item { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.05)); +} + +.widget-fehlzeiten .fehlzeit-item:last-child { + border-bottom: none; +} + +.widget-fehlzeiten .fehlzeit-status { + width: 10px; + height: 10px; + border-radius: 50%; + margin-top: 5px; + flex-shrink: 0; +} + +.widget-fehlzeiten .fehlzeit-status.krank { + background: #ef4444; +} + +.widget-fehlzeiten .fehlzeit-status.entschuldigt { + background: #f59e0b; +} + +.widget-fehlzeiten .fehlzeit-status.unentschuldigt { + background: #6b7280; +} + +.widget-fehlzeiten .fehlzeit-content { + flex: 1; +} + +.widget-fehlzeiten .fehlzeit-name { + font-size: 14px; + font-weight: 500; + color: var(--bp-text, #e5e7eb); + margin-bottom: 4px; +} + +.widget-fehlzeiten .fehlzeit-details { + font-size: 12px; + color: var(--bp-text-muted, #9ca3af); +} + +.widget-fehlzeiten .fehlzeit-details .grund { + display: inline-flex; + align-items: center; + gap: 4px; + margin-right: 8px; +} + +.widget-fehlzeiten .fehlzeiten-empty { + text-align: center; + padding: 24px; + color: var(--bp-text-muted, #9ca3af); + font-size: 13px; +} + +.widget-fehlzeiten .fehlzeiten-empty-icon { + font-size: 32px; + margin-bottom: 8px; +} + +.widget-fehlzeiten .fehlzeiten-empty.success { + color: #10b981; +} + +.widget-fehlzeiten .fehlzeiten-empty.success .fehlzeiten-empty-icon { + opacity: 1; +} + +.widget-fehlzeiten .fehlzeiten-footer { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.1)); +} + +.widget-fehlzeiten .fehlzeiten-all-btn { + width: 100%; + padding: 10px; + background: transparent; + border: 1px dashed var(--bp-border, #475569); + border-radius: 8px; + color: var(--bp-text-muted, #9ca3af); + font-size: 12px; + cursor: pointer; + transition: all 0.2s; +} + +.widget-fehlzeiten .fehlzeiten-all-btn:hover { + border-color: #ef4444; + color: #ef4444; +} +""" + + @staticmethod + def get_html() -> str: + return """ +
    +
    +
    + + Fehlzeiten heute +
    + 0 +
    +
    + +
    + +
    +""" + + @staticmethod + def get_js() -> str: + return """ +// ===== Fehlzeiten Widget JavaScript ===== +const FEHLZEITEN_STORAGE_KEY = 'bp-lehrer-fehlzeiten'; + +function getDefaultFehlzeiten() { + // Demo data - in production this would come from an API + return [ + { + id: 1, + name: 'Max Mueller', + klasse: '10a', + grund: 'krank', + seit: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), + notiz: 'Attest liegt vor' + }, + { + id: 2, + name: 'Lisa Schmidt', + klasse: '11b', + grund: 'entschuldigt', + seit: new Date().toISOString(), + notiz: 'Arzttermin' + }, + { + id: 3, + name: 'Tom Weber', + klasse: '10a', + grund: 'unentschuldigt', + seit: new Date().toISOString(), + notiz: null + } + ]; +} + +function loadFehlzeiten() { + const stored = localStorage.getItem(FEHLZEITEN_STORAGE_KEY); + return stored ? JSON.parse(stored) : getDefaultFehlzeiten(); +} + +function saveFehlzeiten(fehlzeiten) { + localStorage.setItem(FEHLZEITEN_STORAGE_KEY, JSON.stringify(fehlzeiten)); +} + +function formatFehlzeitDauer(seit) { + const seitDate = new Date(seit); + const now = new Date(); + const diffDays = Math.floor((now - seitDate) / (24 * 60 * 60 * 1000)); + + if (diffDays === 0) return 'heute'; + if (diffDays === 1) return 'seit gestern'; + return `seit ${diffDays} Tagen`; +} + +function getGrundLabel(grund) { + const labels = { + krank: '😷 Krank', + entschuldigt: '✅ Entschuldigt', + unentschuldigt: '❓ Unentschuldigt' + }; + return labels[grund] || grund; +} + +function renderFehlzeiten() { + const list = document.getElementById('fehlzeiten-list'); + const countEl = document.getElementById('fehlzeiten-count'); + if (!list) return; + + const fehlzeiten = loadFehlzeiten(); + + if (countEl) { + countEl.textContent = fehlzeiten.length; + } + + if (fehlzeiten.length === 0) { + list.innerHTML = ` +
    +
    +
    Alle Schueler anwesend!
    +
    + `; + return; + } + + list.innerHTML = fehlzeiten.map(f => ` +
    +
    +
    +
    ${f.name} (${f.klasse})
    +
    + ${getGrundLabel(f.grund)} + ${formatFehlzeitDauer(f.seit)} +
    +
    +
    + `).join(''); +} + +function openAllFehlzeiten() { + if (typeof loadModule === 'function') { + loadModule('school'); + console.log('Opening all absences'); + } +} + +function initFehlzeitenWidget() { + renderFehlzeiten(); +} +""" diff --git a/backend/frontend/modules/widgets/kalender_widget.py b/backend/frontend/modules/widgets/kalender_widget.py new file mode 100644 index 0000000..4f58e00 --- /dev/null +++ b/backend/frontend/modules/widgets/kalender_widget.py @@ -0,0 +1,313 @@ +""" +Kalender Widget fuer das Lehrer-Dashboard. + +Zeigt anstehende Termine und Events. +""" + + +class KalenderWidget: + widget_id = 'kalender' + widget_name = 'Termine' + widget_icon = '📆' # Calendar + widget_color = '#ec4899' # Pink + default_width = 'half' + has_settings = True + + @staticmethod + def get_css() -> str: + return """ +/* ===== Kalender Widget Styles ===== */ +.widget-kalender { + background: var(--bp-surface, #1e293b); + border: 1px solid var(--bp-border, #475569); + border-radius: 12px; + padding: 16px; + height: 100%; + display: flex; + flex-direction: column; +} + +.widget-kalender .widget-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.1)); +} + +.widget-kalender .widget-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: var(--bp-text, #e5e7eb); +} + +.widget-kalender .widget-icon { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(236, 72, 153, 0.15); + color: #ec4899; + border-radius: 8px; + font-size: 14px; +} + +.widget-kalender .kalender-list { + flex: 1; + overflow-y: auto; +} + +.widget-kalender .termin-item { + display: flex; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.05)); +} + +.widget-kalender .termin-item:last-child { + border-bottom: none; +} + +.widget-kalender .termin-date { + width: 48px; + text-align: center; + flex-shrink: 0; +} + +.widget-kalender .termin-day { + font-size: 20px; + font-weight: 700; + color: var(--bp-text, #e5e7eb); + line-height: 1; +} + +.widget-kalender .termin-month { + font-size: 11px; + color: var(--bp-text-muted, #9ca3af); + text-transform: uppercase; +} + +.widget-kalender .termin-content { + flex: 1; + min-width: 0; +} + +.widget-kalender .termin-title { + font-size: 13px; + font-weight: 500; + color: var(--bp-text, #e5e7eb); + margin-bottom: 4px; +} + +.widget-kalender .termin-meta { + font-size: 11px; + color: var(--bp-text-muted, #9ca3af); + display: flex; + gap: 8px; +} + +.widget-kalender .termin-type { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + border-radius: 4px; + font-size: 10px; +} + +.widget-kalender .termin-type.konferenz { + background: rgba(139, 92, 246, 0.15); + color: #8b5cf6; +} + +.widget-kalender .termin-type.elterngespraech { + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; +} + +.widget-kalender .termin-type.fortbildung { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; +} + +.widget-kalender .termin-type.pruefung { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; +} + +.widget-kalender .kalender-empty { + text-align: center; + padding: 24px; + color: var(--bp-text-muted, #9ca3af); + font-size: 13px; +} + +.widget-kalender .kalender-empty-icon { + font-size: 32px; + margin-bottom: 8px; + opacity: 0.5; +} + +.widget-kalender .kalender-footer { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.1)); +} + +.widget-kalender .kalender-add-btn { + width: 100%; + padding: 10px; + background: transparent; + border: 1px dashed var(--bp-border, #475569); + border-radius: 8px; + color: var(--bp-text-muted, #9ca3af); + font-size: 12px; + cursor: pointer; + transition: all 0.2s; +} + +.widget-kalender .kalender-add-btn:hover { + border-color: #ec4899; + color: #ec4899; +} +""" + + @staticmethod + def get_html() -> str: + return """ +
    +
    +
    + 📆 + Anstehende Termine +
    + +
    +
    + +
    + +
    +""" + + @staticmethod + def get_js() -> str: + return """ +// ===== Kalender Widget JavaScript ===== +const KALENDER_STORAGE_KEY = 'bp-lehrer-kalender'; + +function getDefaultTermine() { + const today = new Date(); + return [ + { + id: 1, + titel: 'Fachkonferenz Deutsch', + datum: new Date(today.getTime() + 2 * 24 * 60 * 60 * 1000).toISOString(), + zeit: '14:00 - 16:00', + ort: 'Konferenzraum A', + typ: 'konferenz' + }, + { + id: 2, + titel: 'Elterngespraech Mueller', + datum: new Date(today.getTime() + 4 * 24 * 60 * 60 * 1000).toISOString(), + zeit: '17:30 - 18:00', + ort: 'Klassenraum 204', + typ: 'elterngespraech' + }, + { + id: 3, + titel: 'Fortbildung: Digitale Medien', + datum: new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(), + zeit: '09:00 - 15:00', + ort: 'Online', + typ: 'fortbildung' + } + ]; +} + +function loadTermine() { + const stored = localStorage.getItem(KALENDER_STORAGE_KEY); + return stored ? JSON.parse(stored) : getDefaultTermine(); +} + +function saveTermine(termine) { + localStorage.setItem(KALENDER_STORAGE_KEY, JSON.stringify(termine)); +} + +function formatTerminDate(dateStr) { + const date = new Date(dateStr); + return { + day: date.getDate(), + month: date.toLocaleDateString('de-DE', { month: 'short' }) + }; +} + +function getTypLabel(typ) { + const labels = { + konferenz: '👥 Konferenz', + elterngespraech: '👪 Eltern', + fortbildung: '📚 Fortbildung', + pruefung: '📝 Pruefung' + }; + return labels[typ] || typ; +} + +function renderKalender() { + const list = document.getElementById('kalender-list'); + if (!list) return; + + let termine = loadTermine(); + + // Sort by date + termine.sort((a, b) => new Date(a.datum) - new Date(b.datum)); + + // Filter only future events + const now = new Date(); + termine = termine.filter(t => new Date(t.datum) >= now); + + if (termine.length === 0) { + list.innerHTML = ` +
    +
    📆
    +
    Keine anstehenden Termine
    +
    + `; + return; + } + + list.innerHTML = termine.slice(0, 4).map(termin => { + const dateInfo = formatTerminDate(termin.datum); + return ` +
    +
    +
    ${dateInfo.day}
    +
    ${dateInfo.month}
    +
    +
    +
    ${termin.titel}
    +
    + ${getTypLabel(termin.typ)} + 🕑 ${termin.zeit} +
    +
    +
    + `; + }).join(''); +} + +function addTermin() { + alert('Termin-Editor wird in einer zukuenftigen Version verfuegbar sein.'); +} + +function initKalenderWidget() { + renderKalender(); +} +""" diff --git a/backend/frontend/modules/widgets/klassen_widget.py b/backend/frontend/modules/widgets/klassen_widget.py new file mode 100644 index 0000000..dedd5b7 --- /dev/null +++ b/backend/frontend/modules/widgets/klassen_widget.py @@ -0,0 +1,263 @@ +""" +Klassen Widget fuer das Lehrer-Dashboard. + +Zeigt eine Uebersicht aller Klassen des Lehrers. +""" + + +class KlassenWidget: + widget_id = 'klassen' + widget_name = 'Meine Klassen' + widget_icon = '📊' # Chart + widget_color = '#8b5cf6' # Purple + default_width = 'half' + has_settings = True + + @staticmethod + def get_css() -> str: + return """ +/* ===== Klassen Widget Styles ===== */ +.widget-klassen { + background: var(--bp-surface, #1e293b); + border: 1px solid var(--bp-border, #475569); + border-radius: 12px; + padding: 16px; + height: 100%; + display: flex; + flex-direction: column; +} + +.widget-klassen .widget-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.1)); +} + +.widget-klassen .widget-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: var(--bp-text, #e5e7eb); +} + +.widget-klassen .widget-icon { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(139, 92, 246, 0.15); + color: #8b5cf6; + border-radius: 8px; + font-size: 14px; +} + +.widget-klassen .klassen-list { + flex: 1; + overflow-y: auto; +} + +.widget-klassen .klasse-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + margin-bottom: 8px; + background: var(--bp-bg, #0f172a); + border: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.1)); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; +} + +.widget-klassen .klasse-item:last-child { + margin-bottom: 0; +} + +.widget-klassen .klasse-item:hover { + background: var(--bp-surface-elevated, #334155); + border-color: #8b5cf6; + transform: translateX(4px); +} + +.widget-klassen .klasse-info { + display: flex; + align-items: center; + gap: 12px; +} + +.widget-klassen .klasse-badge { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(139, 92, 246, 0.15); + color: #8b5cf6; + border-radius: 8px; + font-size: 14px; + font-weight: 700; +} + +.widget-klassen .klasse-details { + display: flex; + flex-direction: column; + gap: 2px; +} + +.widget-klassen .klasse-name { + font-size: 14px; + font-weight: 600; + color: var(--bp-text, #e5e7eb); +} + +.widget-klassen .klasse-meta { + font-size: 12px; + color: var(--bp-text-muted, #9ca3af); +} + +.widget-klassen .klasse-arrow { + color: var(--bp-text-muted, #9ca3af); + font-size: 16px; + transition: transform 0.2s; +} + +.widget-klassen .klasse-item:hover .klasse-arrow { + transform: translateX(4px); + color: #8b5cf6; +} + +.widget-klassen .klassen-footer { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.1)); +} + +.widget-klassen .klassen-add-btn { + width: 100%; + padding: 10px; + background: transparent; + border: 1px dashed var(--bp-border, #475569); + border-radius: 8px; + color: var(--bp-text-muted, #9ca3af); + font-size: 12px; + cursor: pointer; + transition: all 0.2s; +} + +.widget-klassen .klassen-add-btn:hover { + border-color: #8b5cf6; + color: #8b5cf6; +} + +.widget-klassen .klassen-empty { + text-align: center; + padding: 24px; + color: var(--bp-text-muted, #9ca3af); + font-size: 13px; +} + +.widget-klassen .klassen-empty-icon { + font-size: 32px; + margin-bottom: 8px; + opacity: 0.5; +} +""" + + @staticmethod + def get_html() -> str: + return """ +
    +
    +
    + 📊 + Meine Klassen +
    + +
    +
    + +
    + +
    +""" + + @staticmethod + def get_js() -> str: + return """ +// ===== Klassen Widget JavaScript ===== +const KLASSEN_STORAGE_KEY = 'bp-lehrer-klassen'; + +function getDefaultKlassen() { + return [ + { id: '10a', name: 'Klasse 10a', schueler: 28, fach: 'Deutsch', klassenlehrer: false }, + { id: '11b', name: 'Klasse 11b', schueler: 26, fach: 'Deutsch', klassenlehrer: true }, + { id: '12c', name: 'Klasse 12c', schueler: 24, fach: 'Deutsch', klassenlehrer: false } + ]; +} + +function loadKlassen() { + const stored = localStorage.getItem(KLASSEN_STORAGE_KEY); + return stored ? JSON.parse(stored) : getDefaultKlassen(); +} + +function saveKlassen(klassen) { + localStorage.setItem(KLASSEN_STORAGE_KEY, JSON.stringify(klassen)); +} + +function renderKlassen() { + const list = document.getElementById('klassen-list'); + if (!list) return; + + const klassen = loadKlassen(); + + if (klassen.length === 0) { + list.innerHTML = ` +
    +
    🏫
    +
    Keine Klassen zugewiesen
    +
    + `; + return; + } + + list.innerHTML = klassen.map(klasse => ` +
    +
    +
    ${klasse.id}
    +
    + ${klasse.name}${klasse.klassenlehrer ? ' ⭐' : ''} + ${klasse.schueler} Schueler · ${klasse.fach} +
    +
    + +
    + `).join(''); +} + +function openKlasse(klasseId) { + // Navigate to school module with class selected + if (typeof loadModule === 'function') { + loadModule('school'); + // Could set a flag to auto-select the class + console.log('Opening class:', klasseId); + } +} + +function openKlassenManagement() { + if (typeof loadModule === 'function') { + loadModule('school'); + } +} + +function initKlassenWidget() { + renderKlassen(); +} +""" diff --git a/backend/frontend/modules/widgets/matrix_widget.py b/backend/frontend/modules/widgets/matrix_widget.py new file mode 100644 index 0000000..ffdf4e4 --- /dev/null +++ b/backend/frontend/modules/widgets/matrix_widget.py @@ -0,0 +1,289 @@ +""" +Matrix Chat Widget fuer das Lehrer-Dashboard. + +Zeigt die letzten Chat-Nachrichten aus dem Matrix Messenger. +""" + + +class MatrixWidget: + widget_id = 'matrix' + widget_name = 'Matrix-Chat' + widget_icon = '💬' # Speech bubble + widget_color = '#8b5cf6' # Purple + default_width = 'half' + has_settings = True + + @staticmethod + def get_css() -> str: + return """ +/* ===== Matrix Widget Styles ===== */ +.widget-matrix { + background: var(--bp-surface, #1e293b); + border: 1px solid var(--bp-border, #475569); + border-radius: 12px; + padding: 16px; + height: 100%; + display: flex; + flex-direction: column; +} + +.widget-matrix .widget-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.1)); +} + +.widget-matrix .widget-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: var(--bp-text, #e5e7eb); +} + +.widget-matrix .widget-icon { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(139, 92, 246, 0.15); + color: #8b5cf6; + border-radius: 8px; + font-size: 14px; +} + +.widget-matrix .matrix-list { + flex: 1; + overflow-y: auto; +} + +.widget-matrix .chat-item { + display: flex; + gap: 10px; + padding: 10px 0; + border-bottom: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.05)); + cursor: pointer; + transition: background 0.2s; +} + +.widget-matrix .chat-item:last-child { + border-bottom: none; +} + +.widget-matrix .chat-item:hover { + background: var(--bp-surface-elevated, #334155); + margin: 0 -12px; + padding: 10px 12px; + border-radius: 8px; +} + +.widget-matrix .chat-avatar { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(139, 92, 246, 0.15); + color: #8b5cf6; + border-radius: 50%; + font-size: 12px; + flex-shrink: 0; +} + +.widget-matrix .chat-content { + flex: 1; + min-width: 0; +} + +.widget-matrix .chat-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2px; +} + +.widget-matrix .chat-room { + font-size: 12px; + font-weight: 600; + color: var(--bp-text, #e5e7eb); +} + +.widget-matrix .chat-time { + font-size: 10px; + color: var(--bp-text-muted, #9ca3af); +} + +.widget-matrix .chat-message { + font-size: 12px; + color: var(--bp-text-muted, #9ca3af); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.widget-matrix .chat-unread { + width: 8px; + height: 8px; + background: #8b5cf6; + border-radius: 50%; + margin-left: 8px; +} + +.widget-matrix .matrix-empty { + text-align: center; + padding: 24px; + color: var(--bp-text-muted, #9ca3af); + font-size: 13px; +} + +.widget-matrix .matrix-empty-icon { + font-size: 32px; + margin-bottom: 8px; + opacity: 0.5; +} + +.widget-matrix .matrix-footer { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.1)); +} + +.widget-matrix .matrix-all-btn { + width: 100%; + padding: 10px; + background: transparent; + border: 1px dashed var(--bp-border, #475569); + border-radius: 8px; + color: var(--bp-text-muted, #9ca3af); + font-size: 12px; + cursor: pointer; + transition: all 0.2s; +} + +.widget-matrix .matrix-all-btn:hover { + border-color: #8b5cf6; + color: #8b5cf6; +} +""" + + @staticmethod + def get_html() -> str: + return """ +
    +
    +
    + 💬 + Matrix-Chat +
    + +
    +
    + +
    + +
    +""" + + @staticmethod + def get_js() -> str: + return """ +// ===== Matrix Widget JavaScript ===== + +function getDefaultMatrixChats() { + const now = Date.now(); + return [ + { + id: 'room1', + room: 'Kollegium Deutsch', + lastMessage: 'Hat jemand das neue Curriculum?', + sender: 'Fr. Becker', + time: new Date(now - 30 * 60 * 1000).toISOString(), + unread: true + }, + { + id: 'room2', + room: 'Klassenfahrt 10a', + lastMessage: 'Die Anmeldungen sind komplett!', + sender: 'Hr. Klein', + time: new Date(now - 3 * 60 * 60 * 1000).toISOString(), + unread: false + }, + { + id: 'room3', + room: 'Fachschaft', + lastMessage: 'Termin fuer naechste Sitzung...', + sender: 'Sie', + time: new Date(now - 24 * 60 * 60 * 1000).toISOString(), + unread: false + } + ]; +} + +function formatMatrixTime(timeStr) { + const time = new Date(timeStr); + const now = new Date(); + const diffMs = now - time; + const diffMins = Math.floor(diffMs / (60 * 1000)); + const diffHours = Math.floor(diffMs / (60 * 60 * 1000)); + + if (diffMins < 1) return 'jetzt'; + if (diffMins < 60) return `${diffMins}m`; + if (diffHours < 24) return `${diffHours}h`; + return time.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); +} + +function renderMatrixChats() { + const list = document.getElementById('matrix-list'); + if (!list) return; + + const chats = getDefaultMatrixChats(); + + if (chats.length === 0) { + list.innerHTML = ` +
    +
    💬
    +
    Keine Chats verfuegbar
    +
    + `; + return; + } + + list.innerHTML = chats.map(chat => ` +
    +
    💬
    +
    +
    + ${chat.room} + ${formatMatrixTime(chat.time)} +
    +
    ${chat.sender}: ${chat.lastMessage}
    +
    + ${chat.unread ? '
    ' : ''} +
    + `).join(''); +} + +function openMatrixRoom(roomId) { + if (typeof loadModule === 'function') { + loadModule('messenger'); + console.log('Opening room:', roomId); + } +} + +function openMessenger() { + if (typeof loadModule === 'function') { + loadModule('messenger'); + } +} + +function initMatrixWidget() { + renderMatrixChats(); +} +""" diff --git a/backend/frontend/modules/widgets/nachrichten_widget.py b/backend/frontend/modules/widgets/nachrichten_widget.py new file mode 100644 index 0000000..0ec8b70 --- /dev/null +++ b/backend/frontend/modules/widgets/nachrichten_widget.py @@ -0,0 +1,317 @@ +""" +Nachrichten Widget fuer das Lehrer-Dashboard. + +Zeigt die letzten E-Mails aus dem Mail-Inbox Modul. +""" + + +class NachrichtenWidget: + widget_id = 'nachrichten' + widget_name = 'E-Mails' + widget_icon = '📧' # Email + widget_color = '#06b6d4' # Cyan + default_width = 'half' + has_settings = True + + @staticmethod + def get_css() -> str: + return """ +/* ===== Nachrichten Widget Styles ===== */ +.widget-nachrichten { + background: var(--bp-surface, #1e293b); + border: 1px solid var(--bp-border, #475569); + border-radius: 12px; + padding: 16px; + height: 100%; + display: flex; + flex-direction: column; +} + +.widget-nachrichten .widget-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.1)); +} + +.widget-nachrichten .widget-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: var(--bp-text, #e5e7eb); +} + +.widget-nachrichten .widget-icon { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(6, 182, 212, 0.15); + color: #06b6d4; + border-radius: 8px; + font-size: 14px; +} + +.widget-nachrichten .nachrichten-unread { + background: #ef4444; + color: white; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; +} + +.widget-nachrichten .nachrichten-list { + flex: 1; + overflow-y: auto; +} + +.widget-nachrichten .nachricht-item { + display: flex; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.05)); + cursor: pointer; + transition: background 0.2s; +} + +.widget-nachrichten .nachricht-item:last-child { + border-bottom: none; +} + +.widget-nachrichten .nachricht-item:hover { + background: var(--bp-surface-elevated, #334155); + margin: 0 -12px; + padding: 12px; + border-radius: 8px; +} + +.widget-nachrichten .nachricht-item.unread .nachricht-sender { + font-weight: 600; +} + +.widget-nachrichten .nachricht-avatar { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(6, 182, 212, 0.15); + color: #06b6d4; + border-radius: 50%; + font-size: 14px; + flex-shrink: 0; +} + +.widget-nachrichten .nachricht-content { + flex: 1; + min-width: 0; +} + +.widget-nachrichten .nachricht-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; +} + +.widget-nachrichten .nachricht-sender { + font-size: 13px; + color: var(--bp-text, #e5e7eb); +} + +.widget-nachrichten .nachricht-time { + font-size: 11px; + color: var(--bp-text-muted, #9ca3af); +} + +.widget-nachrichten .nachricht-preview { + font-size: 12px; + color: var(--bp-text-muted, #9ca3af); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.widget-nachrichten .nachrichten-empty { + text-align: center; + padding: 24px; + color: var(--bp-text-muted, #9ca3af); + font-size: 13px; +} + +.widget-nachrichten .nachrichten-empty-icon { + font-size: 32px; + margin-bottom: 8px; + opacity: 0.5; +} + +.widget-nachrichten .nachrichten-footer { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.1)); +} + +.widget-nachrichten .nachrichten-all-btn { + width: 100%; + padding: 10px; + background: transparent; + border: 1px dashed var(--bp-border, #475569); + border-radius: 8px; + color: var(--bp-text-muted, #9ca3af); + font-size: 12px; + cursor: pointer; + transition: all 0.2s; +} + +.widget-nachrichten .nachrichten-all-btn:hover { + border-color: #06b6d4; + color: #06b6d4; +} +""" + + @staticmethod + def get_html() -> str: + return """ +
    +
    +
    + 📧 + Letzte Nachrichten +
    + +
    +
    + +
    + +
    +""" + + @staticmethod + def get_js() -> str: + return """ +// ===== Nachrichten Widget JavaScript ===== + +function getDefaultNachrichten() { + const now = Date.now(); + return [ + { + id: 1, + sender: 'Fr. Mueller (Eltern)', + email: 'mueller@example.com', + preview: 'Frage zu den Hausaufgaben von gestern...', + time: new Date(now - 2 * 60 * 60 * 1000).toISOString(), + unread: true, + type: 'eltern' + }, + { + id: 2, + sender: 'Hr. Weber (Schulleitung)', + email: 'weber@schule.de', + preview: 'Terminabsprache fuer naechste Woche...', + time: new Date(now - 24 * 60 * 60 * 1000).toISOString(), + unread: false, + type: 'kollegium' + }, + { + id: 3, + sender: 'Lisa Schmidt (11b)', + email: 'schmidt@schueler.de', + preview: 'Krankmeldung fuer morgen...', + time: new Date(now - 48 * 60 * 60 * 1000).toISOString(), + unread: false, + type: 'schueler' + } + ]; +} + +function formatNachrichtenTime(timeStr) { + const time = new Date(timeStr); + const now = new Date(); + const diffMs = now - time; + const diffMins = Math.floor(diffMs / (60 * 1000)); + const diffHours = Math.floor(diffMs / (60 * 60 * 1000)); + const diffDays = Math.floor(diffMs / (24 * 60 * 60 * 1000)); + + if (diffMins < 1) return 'gerade eben'; + if (diffMins < 60) return `vor ${diffMins} Min.`; + if (diffHours < 24) return `vor ${diffHours} Std.`; + if (diffDays === 1) return 'gestern'; + return `vor ${diffDays} Tagen`; +} + +function getTypeIcon(type) { + const icons = { + eltern: '👪', + schueler: '🧑', + kollegium: '💼' + }; + return icons[type] || '📧'; +} + +function renderNachrichten() { + const list = document.getElementById('nachrichten-list'); + const unreadBadge = document.getElementById('nachrichten-unread'); + if (!list) return; + + const nachrichten = getDefaultNachrichten(); + const unreadCount = nachrichten.filter(n => n.unread).length; + + if (unreadBadge) { + if (unreadCount > 0) { + unreadBadge.textContent = unreadCount; + unreadBadge.style.display = 'inline'; + } else { + unreadBadge.style.display = 'none'; + } + } + + if (nachrichten.length === 0) { + list.innerHTML = ` +
    +
    📧
    +
    Keine Nachrichten
    +
    + `; + return; + } + + list.innerHTML = nachrichten.map(n => ` +
    +
    ${getTypeIcon(n.type)}
    +
    +
    + ${n.sender} + ${formatNachrichtenTime(n.time)} +
    +
    ${n.preview}
    +
    +
    + `).join(''); +} + +function openNachricht(nachrichtId) { + if (typeof loadModule === 'function') { + loadModule('mail-inbox'); + console.log('Opening message:', nachrichtId); + } +} + +function openAllNachrichten() { + if (typeof loadModule === 'function') { + loadModule('mail-inbox'); + } +} + +function initNachrichtenWidget() { + renderNachrichten(); +} +""" diff --git a/backend/frontend/modules/widgets/notizen_widget.py b/backend/frontend/modules/widgets/notizen_widget.py new file mode 100644 index 0000000..177a6bc --- /dev/null +++ b/backend/frontend/modules/widgets/notizen_widget.py @@ -0,0 +1,182 @@ +""" +Notizen Widget fuer das Lehrer-Dashboard. + +Zeigt ein einfaches Notizfeld mit localStorage-Persistierung. +""" + + +class NotizenWidget: + widget_id = 'notizen' + widget_name = 'Notizen' + widget_icon = '📋' # Clipboard + widget_color = '#fbbf24' # Yellow + default_width = 'half' + has_settings = False + + @staticmethod + def get_css() -> str: + return """ +/* ===== Notizen Widget Styles ===== */ +.widget-notizen { + background: var(--bp-surface, #1e293b); + border: 1px solid var(--bp-border, #475569); + border-radius: 12px; + padding: 16px; + height: 100%; + display: flex; + flex-direction: column; +} + +.widget-notizen .widget-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.widget-notizen .widget-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: var(--bp-text, #e5e7eb); +} + +.widget-notizen .widget-icon { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(251, 191, 36, 0.15); + color: #fbbf24; + border-radius: 8px; + font-size: 14px; +} + +.widget-notizen .notizen-save-indicator { + font-size: 11px; + color: var(--bp-text-muted, #9ca3af); + opacity: 0; + transition: opacity 0.3s; +} + +.widget-notizen .notizen-save-indicator.visible { + opacity: 1; +} + +.widget-notizen .notizen-textarea { + flex: 1; + width: 100%; + min-height: 120px; + background: var(--bp-bg, #0f172a); + border: 1px solid var(--bp-border, #475569); + border-radius: 8px; + padding: 12px; + font-size: 13px; + font-family: inherit; + color: var(--bp-text, #e5e7eb); + resize: none; + outline: none; + transition: border-color 0.2s; + line-height: 1.5; +} + +.widget-notizen .notizen-textarea:focus { + border-color: #fbbf24; +} + +.widget-notizen .notizen-textarea::placeholder { + color: var(--bp-text-muted, #9ca3af); +} + +.widget-notizen .notizen-footer { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 8px; + font-size: 11px; + color: var(--bp-text-muted, #9ca3af); +} + +.widget-notizen .notizen-char-count { + opacity: 0.7; +} +""" + + @staticmethod + def get_html() -> str: + return """ +
    +
    +
    + 📋 + Schnelle Notizen +
    + Gespeichert +
    + + +
    +""" + + @staticmethod + def get_js() -> str: + return """ +// ===== Notizen Widget JavaScript ===== +const NOTIZEN_STORAGE_KEY = 'bp-lehrer-notizen'; +let notizenSaveTimeout = null; + +function loadNotizen() { + const stored = localStorage.getItem(NOTIZEN_STORAGE_KEY); + return stored || ''; +} + +function saveNotizen(text) { + localStorage.setItem(NOTIZEN_STORAGE_KEY, text); + showNotizenSaved(); +} + +function showNotizenSaved() { + const indicator = document.getElementById('notizen-save-indicator'); + if (indicator) { + indicator.classList.add('visible'); + setTimeout(() => { + indicator.classList.remove('visible'); + }, 2000); + } +} + +function updateNotizenCharCount() { + const textarea = document.getElementById('notizen-textarea'); + const counter = document.getElementById('notizen-char-count'); + if (textarea && counter) { + counter.textContent = textarea.value.length + ' Zeichen'; + } +} + +function initNotizenWidget() { + const textarea = document.getElementById('notizen-textarea'); + if (!textarea) return; + + // Load saved notes + textarea.value = loadNotizen(); + updateNotizenCharCount(); + + // Auto-save on input with debounce + textarea.addEventListener('input', function() { + updateNotizenCharCount(); + + if (notizenSaveTimeout) { + clearTimeout(notizenSaveTimeout); + } + + notizenSaveTimeout = setTimeout(() => { + saveNotizen(textarea.value); + }, 500); + }); +} +""" diff --git a/backend/frontend/modules/widgets/schnellzugriff_widget.py b/backend/frontend/modules/widgets/schnellzugriff_widget.py new file mode 100644 index 0000000..e91c55c --- /dev/null +++ b/backend/frontend/modules/widgets/schnellzugriff_widget.py @@ -0,0 +1,196 @@ +""" +Schnellzugriff Widget fuer das Lehrer-Dashboard. + +Zeigt schnelle Links zu den wichtigsten Modulen. +""" + + +class SchnellzugriffWidget: + widget_id = 'schnellzugriff' + widget_name = 'Schnellzugriff' + widget_icon = '⚡' # Lightning + widget_color = '#6b7280' # Gray + default_width = 'full' + has_settings = True + + @staticmethod + def get_css() -> str: + return """ +/* ===== Schnellzugriff Widget Styles ===== */ +.widget-schnellzugriff { + background: var(--bp-surface, #1e293b); + border: 1px solid var(--bp-border, #475569); + border-radius: 12px; + padding: 16px; +} + +.widget-schnellzugriff .widget-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.widget-schnellzugriff .widget-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: var(--bp-text, #e5e7eb); +} + +.widget-schnellzugriff .widget-icon { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(107, 114, 128, 0.15); + color: #6b7280; + border-radius: 8px; + font-size: 14px; +} + +.widget-schnellzugriff .quick-links { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); + gap: 12px; +} + +.widget-schnellzugriff .quick-link { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 16px 12px; + background: var(--bp-bg, #0f172a); + border: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.1)); + border-radius: 10px; + cursor: pointer; + transition: all 0.2s; + text-decoration: none; +} + +.widget-schnellzugriff .quick-link:hover { + background: var(--bp-surface-elevated, #334155); + border-color: var(--bp-primary, #6C1B1B); + transform: translateY(-2px); +} + +.widget-schnellzugriff .quick-link-icon { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 10px; + font-size: 20px; +} + +.widget-schnellzugriff .quick-link-label { + font-size: 12px; + font-weight: 500; + color: var(--bp-text, #e5e7eb); + text-align: center; +} + +/* Icon colors */ +.widget-schnellzugriff .quick-link[data-module="worksheets"] .quick-link-icon { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; +} + +.widget-schnellzugriff .quick-link[data-module="correction"] .quick-link-icon { + background: rgba(16, 185, 129, 0.15); + color: #10b981; +} + +.widget-schnellzugriff .quick-link[data-module="letters"] .quick-link-icon { + background: rgba(139, 92, 246, 0.15); + color: #8b5cf6; +} + +.widget-schnellzugriff .quick-link[data-module="jitsi"] .quick-link-icon { + background: rgba(236, 72, 153, 0.15); + color: #ec4899; +} + +.widget-schnellzugriff .quick-link[data-module="klausur-korrektur"] .quick-link-icon { + background: rgba(168, 85, 247, 0.15); + color: #a855f7; +} + +.widget-schnellzugriff .quick-link[data-module="messenger"] .quick-link-icon { + background: rgba(6, 182, 212, 0.15); + color: #06b6d4; +} + +.widget-schnellzugriff .quick-link[data-module="school"] .quick-link-icon { + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; +} + +.widget-schnellzugriff .quick-link[data-module="companion"] .quick-link-icon { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; +} +""" + + @staticmethod + def get_html() -> str: + return """ +
    +
    +
    + + Schnellzugriff +
    + +
    + +
    +""" + + @staticmethod + def get_js() -> str: + return """ +// ===== Schnellzugriff Widget JavaScript ===== +function initSchnellzugriffWidget() { + // Quick links are already set up with onclick handlers + console.log('Schnellzugriff widget initialized'); +} +""" diff --git a/backend/frontend/modules/widgets/statistik_widget.py b/backend/frontend/modules/widgets/statistik_widget.py new file mode 100644 index 0000000..808b5fd --- /dev/null +++ b/backend/frontend/modules/widgets/statistik_widget.py @@ -0,0 +1,311 @@ +""" +Statistik Widget fuer das Lehrer-Dashboard. + +Zeigt Noten-Statistiken und Klassenauswertungen. +""" + + +class StatistikWidget: + widget_id = 'statistik' + widget_name = 'Klassenstatistik' + widget_icon = '📈' # Chart + widget_color = '#3b82f6' # Blue + default_width = 'full' + has_settings = True + + @staticmethod + def get_css() -> str: + return """ +/* ===== Statistik Widget Styles ===== */ +.widget-statistik { + background: var(--bp-surface, #1e293b); + border: 1px solid var(--bp-border, #475569); + border-radius: 12px; + padding: 16px; +} + +.widget-statistik .widget-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.widget-statistik .widget-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: var(--bp-text, #e5e7eb); +} + +.widget-statistik .widget-icon { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; + border-radius: 8px; + font-size: 14px; +} + +.widget-statistik .statistik-select { + background: var(--bp-bg, #0f172a); + border: 1px solid var(--bp-border, #475569); + border-radius: 6px; + padding: 6px 12px; + font-size: 12px; + color: var(--bp-text, #e5e7eb); + cursor: pointer; +} + +.widget-statistik .statistik-content { + display: grid; + grid-template-columns: 1fr auto; + gap: 24px; +} + +.widget-statistik .statistik-chart { + min-height: 120px; +} + +.widget-statistik .statistik-bars { + display: flex; + align-items: flex-end; + gap: 8px; + height: 100px; + padding: 0 8px; +} + +.widget-statistik .statistik-bar { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.widget-statistik .statistik-bar-fill { + width: 100%; + background: linear-gradient(180deg, #3b82f6 0%, #1d4ed8 100%); + border-radius: 4px 4px 0 0; + min-height: 4px; + transition: height 0.5s ease; +} + +.widget-statistik .statistik-bar-label { + font-size: 11px; + color: var(--bp-text-muted, #9ca3af); +} + +.widget-statistik .statistik-bar-value { + font-size: 10px; + color: var(--bp-text, #e5e7eb); + font-weight: 600; +} + +.widget-statistik .statistik-summary { + min-width: 160px; + padding: 12px; + background: var(--bp-bg, #0f172a); + border-radius: 8px; +} + +.widget-statistik .statistik-item { + display: flex; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.05)); +} + +.widget-statistik .statistik-item:last-child { + border-bottom: none; +} + +.widget-statistik .statistik-label { + font-size: 12px; + color: var(--bp-text-muted, #9ca3af); +} + +.widget-statistik .statistik-value { + font-size: 12px; + font-weight: 600; + color: var(--bp-text, #e5e7eb); +} + +.widget-statistik .statistik-value.good { + color: #10b981; +} + +.widget-statistik .statistik-value.warning { + color: #f59e0b; +} + +.widget-statistik .statistik-value.bad { + color: #ef4444; +} + +.widget-statistik .statistik-test-info { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.1)); + font-size: 11px; + color: var(--bp-text-muted, #9ca3af); +} + +.widget-statistik .statistik-empty { + text-align: center; + padding: 32px; + color: var(--bp-text-muted, #9ca3af); +} + +.widget-statistik .statistik-empty-icon { + font-size: 40px; + margin-bottom: 8px; + opacity: 0.5; +} + +@media (max-width: 600px) { + .widget-statistik .statistik-content { + grid-template-columns: 1fr; + } +} +""" + + @staticmethod + def get_html() -> str: + return """ +
    +
    +
    + 📈 + Klassenstatistik +
    +
    + + +
    +
    +
    + +
    +
    +""" + + @staticmethod + def get_js() -> str: + return """ +// ===== Statistik Widget JavaScript ===== + +function getDefaultStatistik() { + return { + '10a': { + testName: 'Klassenarbeit: Gedichtanalyse', + testDate: '12.01.2026', + noten: { 1: 3, 2: 5, 3: 8, 4: 7, 5: 4, 6: 1 }, + durchschnitt: 3.2, + median: 3, + bestNote: 1, + schlechteste: 6, + schuelerAnzahl: 28 + }, + '11b': { + testName: 'Vokabeltest', + testDate: '18.01.2026', + noten: { 1: 6, 2: 8, 3: 7, 4: 4, 5: 1, 6: 0 }, + durchschnitt: 2.3, + median: 2, + bestNote: 1, + schlechteste: 5, + schuelerAnzahl: 26 + }, + '12c': { + testName: 'Probeabitur', + testDate: '05.01.2026', + noten: { 1: 2, 2: 4, 3: 9, 4: 6, 5: 2, 6: 1 }, + durchschnitt: 3.1, + median: 3, + bestNote: 1, + schlechteste: 6, + schuelerAnzahl: 24 + } + }; +} + +function getDurchschnittClass(durchschnitt) { + if (durchschnitt <= 2.5) return 'good'; + if (durchschnitt <= 3.5) return 'warning'; + return 'bad'; +} + +function renderStatistik() { + const content = document.getElementById('statistik-content'); + const klasseSelect = document.getElementById('statistik-klasse'); + if (!content) return; + + const selectedKlasse = klasseSelect ? klasseSelect.value : '10a'; + const allStats = getDefaultStatistik(); + const stats = allStats[selectedKlasse]; + + if (!stats) { + content.innerHTML = ` +
    +
    📈
    +
    Keine Statistik verfuegbar
    +
    + `; + return; + } + + const maxCount = Math.max(...Object.values(stats.noten)); + + content.innerHTML = ` +
    +
    +
    + ${Object.entries(stats.noten).map(([note, count]) => ` +
    +
    ${count}
    +
    +
    ${note}
    +
    + `).join('')} +
    +
    +
    +
    + Durchschnitt + ${stats.durchschnitt.toFixed(1)} +
    +
    + Median + ${stats.median} +
    +
    + Beste Note + ${stats.bestNote} (${stats.noten[stats.bestNote]}x) +
    +
    + Schueler + ${stats.schuelerAnzahl} +
    +
    + ${stats.testName}
    + ${stats.testDate} +
    +
    +
    + `; +} + +function initStatistikWidget() { + renderStatistik(); +} +""" diff --git a/backend/frontend/modules/widgets/stundenplan_widget.py b/backend/frontend/modules/widgets/stundenplan_widget.py new file mode 100644 index 0000000..0d8152c --- /dev/null +++ b/backend/frontend/modules/widgets/stundenplan_widget.py @@ -0,0 +1,323 @@ +""" +Stundenplan Widget fuer das Lehrer-Dashboard. + +Zeigt den heutigen Stundenplan des Lehrers. +""" + + +class StundenplanWidget: + widget_id = 'stundenplan' + widget_name = 'Stundenplan' + widget_icon = '📅' # Calendar + widget_color = '#3b82f6' # Blue + default_width = 'half' + has_settings = True + + @staticmethod + def get_css() -> str: + return """ +/* ===== Stundenplan Widget Styles ===== */ +.widget-stundenplan { + background: var(--bp-surface, #1e293b); + border: 1px solid var(--bp-border, #475569); + border-radius: 12px; + padding: 16px; + height: 100%; + display: flex; + flex-direction: column; +} + +.widget-stundenplan .widget-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.1)); +} + +.widget-stundenplan .widget-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: var(--bp-text, #e5e7eb); +} + +.widget-stundenplan .widget-icon { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; + border-radius: 8px; + font-size: 14px; +} + +.widget-stundenplan .stundenplan-date { + font-size: 12px; + color: var(--bp-text-muted, #9ca3af); +} + +.widget-stundenplan .stundenplan-list { + flex: 1; + overflow-y: auto; +} + +.widget-stundenplan .stunde-item { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.05)); +} + +.widget-stundenplan .stunde-item:last-child { + border-bottom: none; +} + +.widget-stundenplan .stunde-item.current { + background: rgba(59, 130, 246, 0.1); + margin: 0 -12px; + padding: 12px; + border-radius: 8px; + border-left: 3px solid #3b82f6; +} + +.widget-stundenplan .stunde-item.frei { + opacity: 0.5; +} + +.widget-stundenplan .stunde-zeit { + width: 70px; + flex-shrink: 0; +} + +.widget-stundenplan .stunde-zeit-von, +.widget-stundenplan .stunde-zeit-bis { + font-size: 12px; + color: var(--bp-text-muted, #9ca3af); +} + +.widget-stundenplan .stunde-zeit-von { + font-weight: 600; + color: var(--bp-text, #e5e7eb); +} + +.widget-stundenplan .stunde-content { + flex: 1; +} + +.widget-stundenplan .stunde-fach { + font-size: 14px; + font-weight: 600; + color: var(--bp-text, #e5e7eb); + margin-bottom: 4px; +} + +.widget-stundenplan .stunde-details { + font-size: 12px; + color: var(--bp-text-muted, #9ca3af); + display: flex; + gap: 12px; +} + +.widget-stundenplan .stunde-details span { + display: flex; + align-items: center; + gap: 4px; +} + +.widget-stundenplan .stundenplan-empty { + text-align: center; + padding: 24px; + color: var(--bp-text-muted, #9ca3af); + font-size: 13px; +} + +.widget-stundenplan .stundenplan-empty-icon { + font-size: 32px; + margin-bottom: 8px; + opacity: 0.5; +} + +.widget-stundenplan .stundenplan-edit-btn { + margin-top: 12px; + width: 100%; + padding: 10px; + background: var(--bp-bg, #0f172a); + border: 1px dashed var(--bp-border, #475569); + border-radius: 8px; + color: var(--bp-text-muted, #9ca3af); + font-size: 12px; + cursor: pointer; + transition: all 0.2s; +} + +.widget-stundenplan .stundenplan-edit-btn:hover { + border-color: #3b82f6; + color: #3b82f6; +} +""" + + @staticmethod + def get_html() -> str: + return """ +
    +
    +
    + 📅 + Stundenplan heute +
    + +
    +
    +
    + +
    + +
    +""" + + @staticmethod + def get_js() -> str: + return """ +// ===== Stundenplan Widget JavaScript ===== +const STUNDENPLAN_STORAGE_KEY = 'bp-lehrer-stundenplan'; + +function getDefaultStundenplan() { + return { + montag: [ + { von: '08:00', bis: '09:30', fach: 'Deutsch', klasse: '10a', raum: '204' }, + { von: '09:45', bis: '11:15', fach: 'Deutsch', klasse: '11b', raum: '108' }, + { von: '11:30', bis: '13:00', fach: null, klasse: null, raum: null }, + { von: '14:00', bis: '15:30', fach: 'Deutsch', klasse: '12c', raum: '301' } + ], + dienstag: [ + { von: '08:00', bis: '09:30', fach: 'Deutsch', klasse: '12c', raum: '301' }, + { von: '09:45', bis: '11:15', fach: null, klasse: null, raum: null }, + { von: '11:30', bis: '13:00', fach: 'Deutsch', klasse: '10a', raum: '204' } + ], + mittwoch: [ + { von: '08:00', bis: '09:30', fach: null, klasse: null, raum: null }, + { von: '09:45', bis: '11:15', fach: 'Deutsch', klasse: '11b', raum: '108' }, + { von: '11:30', bis: '13:00', fach: 'Deutsch', klasse: '10a', raum: '204' } + ], + donnerstag: [ + { von: '08:00', bis: '09:30', fach: 'Deutsch', klasse: '10a', raum: '204' }, + { von: '09:45', bis: '11:15', fach: 'Deutsch', klasse: '12c', raum: '301' }, + { von: '11:30', bis: '13:00', fach: null, klasse: null, raum: null } + ], + freitag: [ + { von: '08:00', bis: '09:30', fach: 'Deutsch', klasse: '11b', raum: '108' }, + { von: '09:45', bis: '11:15', fach: null, klasse: null, raum: null } + ] + }; +} + +function loadStundenplan() { + const stored = localStorage.getItem(STUNDENPLAN_STORAGE_KEY); + return stored ? JSON.parse(stored) : getDefaultStundenplan(); +} + +function saveStundenplan(plan) { + localStorage.setItem(STUNDENPLAN_STORAGE_KEY, JSON.stringify(plan)); +} + +function getTodayKey() { + const days = ['sonntag', 'montag', 'dienstag', 'mittwoch', 'donnerstag', 'freitag', 'samstag']; + return days[new Date().getDay()]; +} + +function formatDate() { + const options = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }; + return new Date().toLocaleDateString('de-DE', options); +} + +function isCurrentStunde(von, bis) { + const now = new Date(); + const currentTime = now.getHours() * 60 + now.getMinutes(); + + const [vonH, vonM] = von.split(':').map(Number); + const [bisH, bisM] = bis.split(':').map(Number); + + const vonTime = vonH * 60 + vonM; + const bisTime = bisH * 60 + bisM; + + return currentTime >= vonTime && currentTime <= bisTime; +} + +function renderStundenplan() { + const list = document.getElementById('stundenplan-list'); + const dateEl = document.getElementById('stundenplan-date'); + + if (!list) return; + + if (dateEl) { + dateEl.textContent = formatDate(); + } + + const plan = loadStundenplan(); + const todayKey = getTodayKey(); + + if (todayKey === 'samstag' || todayKey === 'sonntag') { + list.innerHTML = ` +
    +
    🌞
    +
    Heute ist Wochenende!
    +
    + `; + return; + } + + const todayPlan = plan[todayKey] || []; + + if (todayPlan.length === 0) { + list.innerHTML = ` +
    +
    📅
    +
    Kein Stundenplan fuer heute
    +
    + `; + return; + } + + list.innerHTML = todayPlan.map(stunde => { + const isCurrent = isCurrentStunde(stunde.von, stunde.bis); + const isFrei = !stunde.fach; + + return ` +
    +
    +
    ${stunde.von}
    +
    ${stunde.bis}
    +
    +
    +
    ${isFrei ? 'Freistunde' : stunde.fach}
    + ${!isFrei ? ` +
    + 🏫 ${stunde.klasse} + 📍 Raum ${stunde.raum} +
    + ` : ''} +
    +
    + `; + }).join(''); +} + +function editStundenplan() { + // Open a modal or redirect to settings + alert('Stundenplan-Editor wird in einer zukuenftigen Version verfuegbar sein.\\n\\nVorlaeufig koennen Sie den Stundenplan in den Widget-Einstellungen anpassen.'); +} + +function initStundenplanWidget() { + renderStundenplan(); + + // Update every minute to highlight current lesson + setInterval(renderStundenplan, 60000); +} +""" diff --git a/backend/frontend/modules/widgets/todos_widget.py b/backend/frontend/modules/widgets/todos_widget.py new file mode 100644 index 0000000..cc21f28 --- /dev/null +++ b/backend/frontend/modules/widgets/todos_widget.py @@ -0,0 +1,316 @@ +""" +To-Do Widget fuer das Lehrer-Dashboard. + +Zeigt eine interaktive To-Do-Liste mit localStorage-Persistierung. +""" + + +class TodosWidget: + widget_id = 'todos' + widget_name = 'To-Dos' + widget_icon = '✓' # Checkmark + widget_color = '#10b981' # Green + default_width = 'half' + has_settings = True + + @staticmethod + def get_css() -> str: + return """ +/* ===== To-Do Widget Styles ===== */ +.widget-todos { + background: var(--bp-surface, #1e293b); + border: 1px solid var(--bp-border, #475569); + border-radius: 12px; + padding: 16px; + height: 100%; + display: flex; + flex-direction: column; +} + +.widget-todos .widget-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.1)); +} + +.widget-todos .widget-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: var(--bp-text, #e5e7eb); +} + +.widget-todos .widget-icon { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(16, 185, 129, 0.15); + color: #10b981; + border-radius: 8px; + font-size: 14px; +} + +.widget-todos .todo-list { + flex: 1; + overflow-y: auto; + margin-bottom: 12px; +} + +.widget-todos .todo-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 0; + border-bottom: 1px solid var(--bp-border-subtle, rgba(255,255,255,0.05)); + transition: background 0.2s; +} + +.widget-todos .todo-item:last-child { + border-bottom: none; +} + +.widget-todos .todo-item:hover { + background: var(--bp-surface-elevated, #334155); + margin: 0 -8px; + padding: 10px 8px; + border-radius: 6px; +} + +.widget-todos .todo-checkbox { + width: 18px; + height: 18px; + border: 2px solid var(--bp-border, #475569); + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + flex-shrink: 0; +} + +.widget-todos .todo-checkbox:hover { + border-color: #10b981; +} + +.widget-todos .todo-checkbox.checked { + background: #10b981; + border-color: #10b981; +} + +.widget-todos .todo-checkbox.checked::after { + content: '\\2713'; + color: white; + font-size: 12px; + font-weight: bold; +} + +.widget-todos .todo-text { + flex: 1; + font-size: 13px; + color: var(--bp-text, #e5e7eb); + line-height: 1.4; +} + +.widget-todos .todo-item.completed .todo-text { + text-decoration: line-through; + color: var(--bp-text-muted, #9ca3af); +} + +.widget-todos .todo-delete { + opacity: 0; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(239, 68, 68, 0.1); + color: #ef4444; + border: none; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + font-size: 14px; +} + +.widget-todos .todo-item:hover .todo-delete { + opacity: 1; +} + +.widget-todos .todo-delete:hover { + background: rgba(239, 68, 68, 0.2); +} + +.widget-todos .todo-add { + display: flex; + gap: 8px; +} + +.widget-todos .todo-input { + flex: 1; + background: var(--bp-bg, #0f172a); + border: 1px solid var(--bp-border, #475569); + border-radius: 6px; + padding: 8px 12px; + font-size: 13px; + color: var(--bp-text, #e5e7eb); + outline: none; + transition: border-color 0.2s; +} + +.widget-todos .todo-input:focus { + border-color: #10b981; +} + +.widget-todos .todo-input::placeholder { + color: var(--bp-text-muted, #9ca3af); +} + +.widget-todos .todo-add-btn { + background: #10b981; + color: white; + border: none; + border-radius: 6px; + padding: 8px 14px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.widget-todos .todo-add-btn:hover { + background: #059669; +} + +.widget-todos .todo-empty { + text-align: center; + padding: 24px; + color: var(--bp-text-muted, #9ca3af); + font-size: 13px; +} + +.widget-todos .todo-empty-icon { + font-size: 32px; + margin-bottom: 8px; + opacity: 0.5; +} +""" + + @staticmethod + def get_html() -> str: + return """ +
    +
    +
    + + Meine To-Dos +
    + +
    +
    +
    +
    📝
    +
    Keine Aufgaben vorhanden
    +
    +
    +
    + + +
    +
    +""" + + @staticmethod + def get_js() -> str: + return """ +// ===== To-Do Widget JavaScript ===== +const TODOS_STORAGE_KEY = 'bp-lehrer-todos'; + +function loadTodos() { + const stored = localStorage.getItem(TODOS_STORAGE_KEY); + return stored ? JSON.parse(stored) : []; +} + +function saveTodos(todos) { + localStorage.setItem(TODOS_STORAGE_KEY, JSON.stringify(todos)); +} + +function renderTodos() { + const todoList = document.getElementById('todo-list'); + if (!todoList) return; + + const todos = loadTodos(); + + if (todos.length === 0) { + todoList.innerHTML = ` +
    +
    📝
    +
    Keine Aufgaben vorhanden
    +
    + `; + return; + } + + todoList.innerHTML = todos.map((todo, index) => ` +
    +
    + ${escapeHtml(todo.text)} + +
    + `).join(''); +} + +function addTodo() { + const input = document.getElementById('todo-input'); + if (!input) return; + + const text = input.value.trim(); + if (!text) return; + + const todos = loadTodos(); + todos.unshift({ + id: Date.now(), + text: text, + completed: false, + createdAt: new Date().toISOString() + }); + + saveTodos(todos); + renderTodos(); + input.value = ''; +} + +function toggleTodo(index) { + const todos = loadTodos(); + if (todos[index]) { + todos[index].completed = !todos[index].completed; + saveTodos(todos); + renderTodos(); + } +} + +function deleteTodo(index) { + const todos = loadTodos(); + todos.splice(index, 1); + saveTodos(todos); + renderTodos(); +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Initialize todos when widget is rendered +function initTodosWidget() { + renderTodos(); +} +""" diff --git a/backend/frontend/modules/workflow.py b/backend/frontend/modules/workflow.py new file mode 100644 index 0000000..d6b3bf1 --- /dev/null +++ b/backend/frontend/modules/workflow.py @@ -0,0 +1,914 @@ +""" +BreakPilot Studio - Workflow/BPMN Module + +BPMN 2.0 Prozess-Editor mit bpmn-js Integration. +Ermoeglicht das Modellieren, Speichern und Deployen von Geschaeftsprozessen. +""" + + +class WorkflowModule: + """BPMN Workflow Editor Modul fuer BreakPilot Studio.""" + + @staticmethod + def get_css() -> str: + """CSS fuer das Workflow/BPMN Panel.""" + return """ +/* ========================================== + WORKFLOW/BPMN MODULE + ========================================== */ + +#panel-workflow { + display: none; + flex-direction: column; + padding: 24px; + min-height: calc(100vh - 104px); + height: calc(100vh - 104px); +} + +#panel-workflow.active { + display: flex; +} + +/* Header */ +.workflow-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 20px; +} + +.workflow-title { + font-size: 24px; + font-weight: 700; + color: var(--bp-text); + margin-bottom: 4px; +} + +.workflow-subtitle { + font-size: 14px; + color: var(--bp-text-muted); +} + +/* Toolbar */ +.workflow-toolbar { + display: flex; + gap: 12px; + margin-bottom: 16px; + padding: 12px 16px; + background: var(--bp-surface-elevated); + border-radius: 8px; + border: 1px solid var(--bp-border); + flex-wrap: wrap; +} + +.workflow-toolbar-group { + display: flex; + gap: 8px; + align-items: center; +} + +.workflow-toolbar-separator { + width: 1px; + height: 24px; + background: var(--bp-border); + margin: 0 8px; +} + +.workflow-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + border: 1px solid var(--bp-border); + background: var(--bp-surface); + color: var(--bp-text); +} + +.workflow-btn:hover { + background: var(--bp-surface-elevated); + border-color: var(--bp-primary); +} + +.workflow-btn.primary { + background: var(--bp-primary); + border-color: var(--bp-primary); + color: white; +} + +.workflow-btn.primary:hover { + background: var(--bp-primary-hover); +} + +.workflow-btn.success { + background: var(--bp-success); + border-color: var(--bp-success); + color: white; +} + +.workflow-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Canvas Container */ +.workflow-canvas-container { + flex: 1; + display: flex; + flex-direction: column; + border: 1px solid var(--bp-border); + border-radius: 12px; + overflow: hidden; + background: white; + min-height: 400px; +} + +.workflow-canvas { + flex: 1; + width: 100%; + height: 100%; +} + +/* bpmn-js Overrides for Dark Theme Support */ +.bjs-powered-by { + display: none !important; +} + +.djs-palette { + background: var(--bp-surface) !important; + border-color: var(--bp-border) !important; +} + +.djs-palette-entries .entry { + color: var(--bp-text) !important; +} + +/* Status Bar */ +.workflow-status-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 16px; + background: var(--bp-surface); + border-top: 1px solid var(--bp-border); + font-size: 12px; + color: var(--bp-text-muted); +} + +.workflow-status-item { + display: flex; + align-items: center; + gap: 6px; +} + +.workflow-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--bp-text-muted); +} + +.workflow-status-dot.connected { + background: var(--bp-success); +} + +.workflow-status-dot.disconnected { + background: var(--bp-danger); +} + +/* Process List Panel */ +.workflow-processes-panel { + position: fixed; + top: 56px; + right: 0; + bottom: 0; + width: 320px; + background: var(--bp-surface); + border-left: 1px solid var(--bp-border); + transform: translateX(100%); + transition: transform 0.3s ease; + z-index: 60; + display: flex; + flex-direction: column; +} + +.workflow-processes-panel.open { + transform: translateX(0); +} + +.workflow-processes-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + border-bottom: 1px solid var(--bp-border); +} + +.workflow-processes-title { + font-size: 16px; + font-weight: 600; + color: var(--bp-text); +} + +.workflow-processes-close { + background: none; + border: none; + font-size: 20px; + color: var(--bp-text-muted); + cursor: pointer; +} + +.workflow-processes-list { + flex: 1; + overflow-y: auto; + padding: 16px; +} + +.workflow-process-item { + padding: 12px; + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border); + border-radius: 8px; + margin-bottom: 8px; + cursor: pointer; + transition: all 0.2s; +} + +.workflow-process-item:hover { + border-color: var(--bp-primary); +} + +.workflow-process-name { + font-size: 14px; + font-weight: 600; + color: var(--bp-text); + margin-bottom: 4px; +} + +.workflow-process-meta { + font-size: 12px; + color: var(--bp-text-muted); +} + +/* Task Inbox Panel */ +.workflow-tasks-panel { + margin-top: 16px; + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border); + border-radius: 12px; + max-height: 300px; + overflow: hidden; + display: none; +} + +.workflow-tasks-panel.visible { + display: block; +} + +.workflow-tasks-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--bp-border); +} + +.workflow-tasks-title { + font-size: 14px; + font-weight: 600; + color: var(--bp-text); +} + +.workflow-tasks-count { + padding: 2px 8px; + background: var(--bp-primary); + color: white; + border-radius: 999px; + font-size: 11px; + font-weight: 600; +} + +.workflow-tasks-list { + max-height: 240px; + overflow-y: auto; +} + +.workflow-task-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + border-bottom: 1px solid var(--bp-border-subtle); +} + +.workflow-task-item:last-child { + border-bottom: none; +} + +.workflow-task-info { + flex: 1; +} + +.workflow-task-name { + font-size: 13px; + font-weight: 500; + color: var(--bp-text); + margin-bottom: 2px; +} + +.workflow-task-process { + font-size: 11px; + color: var(--bp-text-muted); +} + +.workflow-task-action { + padding: 6px 12px; + background: var(--bp-primary); + color: white; + border: none; + border-radius: 4px; + font-size: 12px; + cursor: pointer; +} + +/* Loading Overlay */ +.workflow-loading { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.workflow-loading-spinner { + width: 40px; + height: 40px; + border: 3px solid var(--bp-border); + border-top-color: var(--bp-primary); + border-radius: 50%; + animation: workflow-spin 1s linear infinite; +} + +@keyframes workflow-spin { + to { transform: rotate(360deg); } +} + +/* Toast Notifications */ +.workflow-toast { + position: fixed; + bottom: 24px; + right: 24px; + padding: 12px 20px; + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 200; + animation: workflow-toast-in 0.3s ease; +} + +.workflow-toast.success { + border-color: var(--bp-success); +} + +.workflow-toast.error { + border-color: var(--bp-danger); +} + +@keyframes workflow-toast-in { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} +""" + + @staticmethod + def get_html() -> str: + """HTML fuer das Workflow/BPMN Panel.""" + return """ + +
    + +
    +
    +

    BPMN Workflow Editor

    +

    Geschaeftsprozesse modellieren und automatisieren (Camunda 7)

    +
    +
    + + +
    +
    + + + +
    + +
    + +
    + + +
    + +
    + +
    + + + +
    + +
    + +
    + + + +
    +
    + + +
    +
    +
    +
    + + Camunda: Pruefe... +
    +
    + Elemente: 0 +
    +
    +
    + + +
    +
    + Offene Tasks + 0 +
    +
    +
    + Keine offenen Tasks +
    +
    +
    +
    + + +
    +
    + Deployed Processes + +
    +
    +
    + Lade Prozesse... +
    +
    +
    + + + + + + +""" + + @staticmethod + def get_js() -> str: + """JavaScript fuer das Workflow/BPMN Panel.""" + return """ +// ========================================== +// WORKFLOW/BPMN MODULE +// ========================================== + +console.log('Workflow Module loaded'); + +let workflowModeler = null; +let workflowCamundaConnected = false; + +// Default empty BPMN diagram +const WORKFLOW_EMPTY_BPMN = ` + + + + + + + + + + + + + + +`; + +// Initialize BPMN Modeler +async function initWorkflowModule() { + console.log('Initializing Workflow Module...'); + + const container = document.getElementById('workflow-canvas'); + if (!container) { + console.error('Workflow canvas container not found'); + return; + } + + // Check if BpmnJS is loaded + if (typeof BpmnJS === 'undefined') { + console.error('bpmn-js not loaded'); + workflowShowToast('bpmn-js konnte nicht geladen werden', 'error'); + return; + } + + try { + // Create modeler instance + workflowModeler = new BpmnJS({ + container: container, + keyboard: { + bindTo: document + } + }); + + // Load empty diagram + await workflowModeler.importXML(WORKFLOW_EMPTY_BPMN); + + // Center the diagram + const canvas = workflowModeler.get('canvas'); + canvas.zoom('fit-viewport'); + + // Update element count on changes + workflowModeler.on('elements.changed', workflowUpdateElementCount); + workflowUpdateElementCount(); + + console.log('Workflow Modeler initialized'); + + // Check Camunda connection + workflowCheckCamundaStatus(); + + } catch (err) { + console.error('Error initializing workflow modeler:', err); + workflowShowToast('Fehler beim Initialisieren des Editors', 'error'); + } +} + +// Check Camunda connection status +async function workflowCheckCamundaStatus() { + const statusDot = document.getElementById('workflow-camunda-status'); + const statusText = document.getElementById('workflow-camunda-status-text'); + + try { + const response = await fetch('/api/bpmn/health'); + const data = await response.json(); + + if (data.connected) { + statusDot.classList.add('connected'); + statusDot.classList.remove('disconnected'); + statusText.textContent = 'Camunda: Verbunden'; + workflowCamundaConnected = true; + } else { + throw new Error('Not connected'); + } + } catch (err) { + statusDot.classList.add('disconnected'); + statusDot.classList.remove('connected'); + statusText.textContent = 'Camunda: Nicht verbunden'; + workflowCamundaConnected = false; + } +} + +// Create new diagram +async function workflowNewDiagram() { + if (!workflowModeler) return; + + try { + await workflowModeler.importXML(WORKFLOW_EMPTY_BPMN); + workflowModeler.get('canvas').zoom('fit-viewport'); + workflowUpdateElementCount(); + workflowShowToast('Neues Diagramm erstellt', 'success'); + } catch (err) { + console.error('Error creating new diagram:', err); + workflowShowToast('Fehler beim Erstellen', 'error'); + } +} + +// Open file dialog +function workflowOpenFile() { + document.getElementById('workflow-file-input').click(); +} + +// Load file from input +async function workflowLoadFile(event) { + const file = event.target.files[0]; + if (!file) return; + + try { + const xml = await file.text(); + await workflowModeler.importXML(xml); + workflowModeler.get('canvas').zoom('fit-viewport'); + workflowUpdateElementCount(); + workflowShowToast('Datei geladen: ' + file.name, 'success'); + } catch (err) { + console.error('Error loading file:', err); + workflowShowToast('Fehler beim Laden der Datei', 'error'); + } + + // Reset input + event.target.value = ''; +} + +// Save as XML +async function workflowSaveXML() { + if (!workflowModeler) return; + + try { + const { xml } = await workflowModeler.saveXML({ format: true }); + workflowDownload(xml, 'process.bpmn', 'application/xml'); + workflowShowToast('XML exportiert', 'success'); + } catch (err) { + console.error('Error saving XML:', err); + workflowShowToast('Fehler beim Speichern', 'error'); + } +} + +// Save as SVG +async function workflowSaveSVG() { + if (!workflowModeler) return; + + try { + const { svg } = await workflowModeler.saveSVG(); + workflowDownload(svg, 'process.svg', 'image/svg+xml'); + workflowShowToast('SVG exportiert', 'success'); + } catch (err) { + console.error('Error saving SVG:', err); + workflowShowToast('Fehler beim Speichern', 'error'); + } +} + +// Deploy to Camunda +async function workflowDeploy() { + if (!workflowModeler) return; + + if (!workflowCamundaConnected) { + workflowShowToast('Camunda nicht verbunden', 'error'); + return; + } + + try { + const { xml } = await workflowModeler.saveXML({ format: true }); + + // Create form data + const formData = new FormData(); + formData.append('deployment-name', 'BreakPilot-Process-' + Date.now()); + formData.append('data', new Blob([xml], { type: 'application/octet-stream' }), 'process.bpmn'); + + const response = await fetch('/api/bpmn/deployment/create', { + method: 'POST', + body: formData + }); + + if (response.ok) { + const result = await response.json(); + workflowShowToast('Deployment erfolgreich: ' + result.name, 'success'); + console.log('Deployment result:', result); + } else { + const error = await response.text(); + throw new Error(error); + } + } catch (err) { + console.error('Error deploying:', err); + workflowShowToast('Deployment fehlgeschlagen', 'error'); + } +} + +// Show deployed processes panel +async function workflowShowProcesses() { + const panel = document.getElementById('workflow-processes-panel'); + const list = document.getElementById('workflow-processes-list'); + + panel.classList.add('open'); + + try { + const response = await fetch('/api/bpmn/process-definition'); + const processes = await response.json(); + + if (processes.length === 0) { + list.innerHTML = '
    Keine Prozesse deployed
    '; + return; + } + + list.innerHTML = processes.map(p => ` +
    +
    ${p.name || p.key}
    +
    Version ${p.version} | ${p.key}
    +
    + `).join(''); + + } catch (err) { + console.error('Error loading processes:', err); + list.innerHTML = '
    Fehler beim Laden
    '; + } +} + +// Hide processes panel +function workflowHideProcesses() { + document.getElementById('workflow-processes-panel').classList.remove('open'); +} + +// Load process definition XML +async function workflowLoadProcess(definitionId) { + try { + const response = await fetch('/api/bpmn/process-definition/' + definitionId + '/xml'); + const data = await response.json(); + + if (data.bpmn20Xml) { + await workflowModeler.importXML(data.bpmn20Xml); + workflowModeler.get('canvas').zoom('fit-viewport'); + workflowUpdateElementCount(); + workflowHideProcesses(); + workflowShowToast('Prozess geladen', 'success'); + } + } catch (err) { + console.error('Error loading process:', err); + workflowShowToast('Fehler beim Laden des Prozesses', 'error'); + } +} + +// Toggle tasks panel +function workflowToggleTasks() { + const panel = document.getElementById('workflow-tasks-panel'); + panel.classList.toggle('visible'); + + if (panel.classList.contains('visible')) { + workflowLoadTasks(); + } +} + +// Load pending tasks +async function workflowLoadTasks() { + const list = document.getElementById('workflow-tasks-list'); + const count = document.getElementById('workflow-tasks-count'); + + try { + const response = await fetch('/api/bpmn/tasks/pending'); + const tasks = await response.json(); + + count.textContent = tasks.length; + + if (tasks.length === 0) { + list.innerHTML = '
    Keine offenen Tasks
    '; + return; + } + + list.innerHTML = tasks.map(t => ` +
    +
    +
    ${t.name}
    +
    ${t.processDefinitionId || 'Prozess'}
    +
    + +
    + `).join(''); + + } catch (err) { + console.error('Error loading tasks:', err); + list.innerHTML = '
    Fehler beim Laden
    '; + } +} + +// Complete a task +async function workflowCompleteTask(taskId) { + try { + const response = await fetch('/api/bpmn/task/' + taskId + '/complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + + if (response.ok) { + workflowShowToast('Task abgeschlossen', 'success'); + workflowLoadTasks(); + } else { + throw new Error('Failed to complete task'); + } + } catch (err) { + console.error('Error completing task:', err); + workflowShowToast('Fehler beim Abschliessen', 'error'); + } +} + +// Zoom controls +function workflowZoomIn() { + if (!workflowModeler) return; + const canvas = workflowModeler.get('canvas'); + canvas.zoom(canvas.zoom() * 1.2); +} + +function workflowZoomOut() { + if (!workflowModeler) return; + const canvas = workflowModeler.get('canvas'); + canvas.zoom(canvas.zoom() / 1.2); +} + +function workflowZoomFit() { + if (!workflowModeler) return; + workflowModeler.get('canvas').zoom('fit-viewport'); +} + +// Update element count +function workflowUpdateElementCount() { + if (!workflowModeler) return; + + const elementRegistry = workflowModeler.get('elementRegistry'); + const count = elementRegistry.getAll().length; + document.getElementById('workflow-element-count').textContent = 'Elemente: ' + count; +} + +// Download helper +function workflowDownload(content, filename, contentType) { + const blob = new Blob([content], { type: contentType }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); +} + +// Toast notification +function workflowShowToast(message, type = 'info') { + const toast = document.createElement('div'); + toast.className = 'workflow-toast ' + type; + toast.textContent = message; + document.body.appendChild(toast); + + setTimeout(() => { + toast.remove(); + }, 3000); +} + +// Module loader function +function loadWorkflowModule() { + console.log('Loading Workflow Module...'); + // Small delay to ensure DOM is ready + setTimeout(initWorkflowModule, 100); +} + +// Show panel function for module loader +function showWorkflowPanel() { + loadWorkflowModule(); +} + +// Expose globally +window.loadWorkflowModule = loadWorkflowModule; +window.workflowNewDiagram = workflowNewDiagram; +window.workflowOpenFile = workflowOpenFile; +window.workflowLoadFile = workflowLoadFile; +window.workflowSaveXML = workflowSaveXML; +window.workflowSaveSVG = workflowSaveSVG; +window.workflowDeploy = workflowDeploy; +window.workflowShowProcesses = workflowShowProcesses; +window.workflowHideProcesses = workflowHideProcesses; +window.workflowLoadProcess = workflowLoadProcess; +window.workflowToggleTasks = workflowToggleTasks; +window.workflowCompleteTask = workflowCompleteTask; +window.workflowZoomIn = workflowZoomIn; +window.workflowZoomOut = workflowZoomOut; +window.workflowZoomFit = workflowZoomFit; +""" diff --git a/backend/frontend/modules/worksheets.py b/backend/frontend/modules/worksheets.py new file mode 100644 index 0000000..fcea2c7 --- /dev/null +++ b/backend/frontend/modules/worksheets.py @@ -0,0 +1,1918 @@ +""" +BreakPilot Studio - Lerneinheiten/Arbeitsblaetter Modul + +Refactored: 2024-12-18 +- Kachel-basierte Startansicht +- Sub-Module fuer verschiedene Funktionen + +Funktionen (als Kacheln): +- Lerneinheiten erstellen und verwalten +- Dateien hochladen +- Dateien bereinigen (OCR, Neuaufbau) +- Lernflows (Interaktive Uebungen) +- Multiple Choice Tests +- Lueckentexte +- Mindmap Generator +- Uebersetzungen +""" + + +class WorksheetsModule: + """Modul fuer Lerneinheiten und Arbeitsblaetter.""" + + @staticmethod + def get_css() -> str: + """CSS fuer das Worksheets-Modul.""" + return """ +/* ============================================= + WORKSHEETS MODULE - Lerneinheiten & Arbeitsblaetter + ============================================= */ + +/* Panel Layout */ +.panel-worksheets { + display: none; + flex-direction: column; + height: 100%; + background: var(--bp-bg); + overflow: hidden; +} + +.panel-worksheets.active { + display: flex; +} + +/* Worksheets Header */ +.worksheets-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 32px; + border-bottom: 1px solid var(--bp-border); + background: var(--bp-surface); +} + +.worksheets-title-section h1 { + font-size: 24px; + font-weight: 700; + color: var(--bp-text); + margin-bottom: 4px; +} + +.worksheets-subtitle { + font-size: 14px; + color: var(--bp-text-muted); +} + +.worksheets-nav { + display: flex; + gap: 8px; +} + +.worksheets-nav-btn { + padding: 8px 16px; + border-radius: 8px; + border: 1px solid var(--bp-border); + background: var(--bp-surface); + color: var(--bp-text-muted); + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} + +.worksheets-nav-btn:hover { + background: var(--bp-surface-elevated); + color: var(--bp-text); +} + +.worksheets-nav-btn.active { + background: var(--bp-primary); + border-color: var(--bp-primary); + color: white; +} + +/* Worksheets Content - Kacheln */ +.worksheets-content { + flex: 1; + overflow-y: auto; + padding: 32px; +} + +/* Tiles Grid */ +.worksheets-tiles { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 24px; + max-width: 1400px; + margin: 0 auto; +} + +/* Section Header */ +.tiles-section { + margin-bottom: 32px; +} + +.tiles-section-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 20px; +} + +.tiles-section-icon { + width: 40px; + height: 40px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; +} + +.tiles-section-icon.create { background: rgba(59, 130, 246, 0.15); } +.tiles-section-icon.process { background: rgba(16, 185, 129, 0.15); } +.tiles-section-icon.generate { background: rgba(245, 158, 11, 0.15); } +.tiles-section-icon.translate { background: rgba(139, 92, 246, 0.15); } + +.tiles-section-title { + font-size: 16px; + font-weight: 600; + color: var(--bp-text); +} + +.tiles-section-desc { + font-size: 13px; + color: var(--bp-text-muted); +} + +/* Worksheet Tile */ +.worksheet-tile { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 16px; + padding: 24px; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.worksheet-tile:hover { + transform: translateY(-4px); + box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1); + border-color: var(--bp-primary); +} + +.worksheet-tile::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 4px; + background: var(--bp-primary); + opacity: 0; + transition: opacity 0.3s; +} + +.worksheet-tile:hover::before { + opacity: 1; +} + +.tile-icon-container { + width: 56px; + height: 56px; + border-radius: 14px; + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; + margin-bottom: 16px; +} + +/* Icon Colors */ +.tile-icon-container.blue { background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); } +.tile-icon-container.green { background: linear-gradient(135deg, #10b981 0%, #059669 100%); } +.tile-icon-container.orange { background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); } +.tile-icon-container.purple { background: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%); } +.tile-icon-container.red { background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); } +.tile-icon-container.cyan { background: linear-gradient(135deg, #06b6d4 0%, #0891b2 100%); } +.tile-icon-container.pink { background: linear-gradient(135deg, #ec4899 0%, #be185d 100%); } +.tile-icon-container.teal { background: linear-gradient(135deg, #14b8a6 0%, #0d9488 100%); } + +.tile-title { + font-size: 16px; + font-weight: 600; + color: var(--bp-text); + margin-bottom: 8px; +} + +.tile-description { + font-size: 13px; + color: var(--bp-text-muted); + line-height: 1.5; + margin-bottom: 16px; +} + +.tile-features { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.tile-feature-tag { + padding: 4px 10px; + background: var(--bp-bg); + border-radius: 12px; + font-size: 11px; + color: var(--bp-text-muted); +} + +.tile-arrow { + position: absolute; + bottom: 20px; + right: 20px; + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--bp-bg); + display: flex; + align-items: center; + justify-content: center; + color: var(--bp-text-muted); + transition: all 0.3s; +} + +.worksheet-tile:hover .tile-arrow { + background: var(--bp-primary); + color: white; + transform: translateX(4px); +} + +/* Sub-Panel (hidden by default, shown when tile clicked) */ +.worksheets-subpanel { + display: none; + flex-direction: column; + height: 100%; +} + +.worksheets-subpanel.active { + display: flex; +} + +.subpanel-header { + display: flex; + align-items: center; + gap: 16px; + padding: 16px 24px; + border-bottom: 1px solid var(--bp-border); + background: var(--bp-surface); +} + +.subpanel-back { + width: 36px; + height: 36px; + border-radius: 8px; + border: 1px solid var(--bp-border); + background: var(--bp-bg); + color: var(--bp-text); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + transition: all 0.2s; +} + +.subpanel-back:hover { + background: var(--bp-surface-elevated); +} + +.subpanel-title { + font-size: 18px; + font-weight: 600; +} + +.subpanel-content { + flex: 1; + overflow-y: auto; + padding: 24px; +} + +/* Status Bar */ +.worksheets-status { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 24px; + border-top: 1px solid var(--bp-border); + background: var(--bp-surface); + font-size: 12px; +} + +.status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background: #10b981; +} + +.status-indicator.busy { + background: #f59e0b; + animation: statusPulse 1.5s infinite; +} + +.status-indicator.error { + background: #ef4444; +} + +@keyframes statusPulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.status-text { + color: var(--bp-text); +} + +.status-detail { + color: var(--bp-text-muted); +} + +/* ============================================ + SUB-MODULE STYLES: Lerneinheiten Manager + ============================================ */ + +.units-manager { + display: grid; + grid-template-columns: 320px 1fr; + gap: 24px; + height: 100%; +} + +.units-sidebar { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.units-form { + padding: 16px; + border-bottom: 1px solid var(--bp-border); +} + +.units-form h3 { + font-size: 14px; + font-weight: 600; + margin-bottom: 12px; + color: var(--bp-text-muted); +} + +.units-form input { + width: 100%; + padding: 10px 12px; + margin-bottom: 8px; + border: 1px solid var(--bp-border); + border-radius: 8px; + background: var(--bp-bg); + color: var(--bp-text); + font-size: 13px; +} + +.units-form input:focus { + outline: none; + border-color: var(--bp-primary); +} + +.units-form .form-row { + display: flex; + gap: 8px; +} + +.units-form .form-row input { + flex: 1; +} + +.units-list { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.unit-list-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + margin-bottom: 4px; +} + +.unit-list-item:hover { + background: var(--bp-bg); +} + +.unit-list-item.selected { + background: var(--bp-primary); + color: white; +} + +.unit-info { + flex: 1; + min-width: 0; +} + +.unit-name { + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.unit-meta { + font-size: 11px; + opacity: 0.7; + margin-top: 2px; +} + +.unit-delete { + opacity: 0; + cursor: pointer; + padding: 4px; + font-size: 12px; +} + +.unit-list-item:hover .unit-delete { + opacity: 0.6; +} + +.unit-list-item:hover .unit-delete:hover { + opacity: 1; + color: #ef4444; +} + +.units-main { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + padding: 24px; + overflow-y: auto; +} + +.units-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 300px; + text-align: center; + color: var(--bp-text-muted); +} + +.units-empty-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.5; +} + +/* ============================================ + SUB-MODULE STYLES: File Upload + ============================================ */ + +.upload-container { + max-width: 800px; + margin: 0 auto; +} + +.upload-drop-zone { + border: 2px dashed var(--bp-border); + border-radius: 16px; + padding: 48px; + text-align: center; + cursor: pointer; + transition: all 0.3s; + background: var(--bp-surface); +} + +.upload-drop-zone:hover, +.upload-drop-zone.dragover { + border-color: var(--bp-primary); + background: rgba(59, 130, 246, 0.05); +} + +.upload-icon { + font-size: 48px; + margin-bottom: 16px; +} + +.upload-title { + font-size: 18px; + font-weight: 600; + margin-bottom: 8px; +} + +.upload-hint { + font-size: 14px; + color: var(--bp-text-muted); + margin-bottom: 16px; +} + +.upload-formats { + font-size: 12px; + color: var(--bp-text-muted); + padding: 8px 16px; + background: var(--bp-bg); + border-radius: 20px; + display: inline-block; +} + +.upload-progress { + margin-top: 24px; + padding: 16px; + background: var(--bp-bg); + border-radius: 12px; +} + +.upload-file-item { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 0; +} + +.upload-file-name { + flex: 1; + font-size: 13px; +} + +.upload-file-status { + font-size: 12px; + color: var(--bp-text-muted); +} + +/* ============================================ + SUB-MODULE STYLES: Generator Tiles + ============================================ */ + +.generator-options { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 16px; + margin-top: 24px; +} + +.generator-option { + padding: 20px; + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + cursor: pointer; + transition: all 0.2s; + text-align: center; +} + +.generator-option:hover { + border-color: var(--bp-primary); + transform: translateY(-2px); +} + +.generator-option.selected { + border-color: var(--bp-primary); + background: rgba(59, 130, 246, 0.05); +} + +.generator-option-icon { + font-size: 32px; + margin-bottom: 8px; +} + +.generator-option-title { + font-weight: 600; + margin-bottom: 4px; +} + +.generator-option-desc { + font-size: 12px; + color: var(--bp-text-muted); +} + +/* ============================================ + Legacy Support: Original 3-Column Layout + ============================================ */ + +/* Linke Spalte - Lerneinheiten */ +.worksheets-units-panel { + width: 280px; + border-right: 1px solid var(--bp-border); + display: flex; + flex-direction: column; + background: var(--bp-surface); +} + +.units-header { + padding: 16px; + border-bottom: 1px solid var(--bp-border); +} + +.units-header h3 { + font-size: 14px; + font-weight: 600; + color: var(--bp-text-muted); + margin-bottom: 12px; +} + +/* Mittlere Spalte - Dateiliste */ +.worksheets-files-panel { + width: 260px; + border-right: 1px solid var(--bp-border); + display: flex; + flex-direction: column; + background: var(--bp-bg); +} + +.files-header { + padding: 12px 16px; + border-bottom: 1px solid var(--bp-border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.files-list { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +.file-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + margin-bottom: 2px; + border-radius: 6px; + cursor: pointer; + transition: background 0.2s; +} + +.file-item:hover { + background: var(--bp-surface); +} + +.file-item.active { + background: var(--bp-primary); + color: white; +} + +/* Rechte Spalte - Preview */ +.worksheets-preview-panel { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +/* Compare Wrapper */ +.compare-wrapper { + flex: 1; + display: flex; + gap: 16px; + padding: 16px; + overflow: hidden; +} + +.compare-section { + flex: 1; + display: flex; + flex-direction: column; + border-radius: 12px; + overflow: hidden; + background: var(--bp-surface); + border: 1px solid var(--bp-border); +} + +.compare-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 16px; + background: var(--bp-bg); + border-bottom: 1px solid var(--bp-border); + font-size: 13px; + font-weight: 500; +} + +.compare-body { + flex: 1; + overflow: auto; + padding: 16px; + display: flex; + justify-content: center; + align-items: flex-start; +} + +.preview-img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: 4px; + cursor: zoom-in; +} + +/* Lightbox */ +.lightbox { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.9); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 10000; + padding: 20px; +} + +.lightbox.hidden { + display: none; +} + +.lightbox-close { + position: absolute; + top: 20px; + right: 20px; + font-size: 32px; + color: white; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.2s; +} + +.lightbox-close:hover { + opacity: 1; +} + +.lightbox-img { + max-width: 95%; + max-height: 85%; + object-fit: contain; +} + +.lightbox-caption { + margin-top: 16px; + color: rgba(255, 255, 255, 0.7); + font-size: 14px; +} +""" + + @staticmethod + def get_html() -> str: + """HTML fuer das Worksheets-Modul mit Kachel-basierter Startansicht.""" + return """ + +
    + + +
    + +
    +
    +

    Arbeitsblaetter Studio

    +

    Erstelle und verwalte Lernmaterialien fuer deine Schueler

    +
    +
    + + +
    +
    + + +
    + +
    +
    +
    📝
    +
    +
    Erstellen & Verwalten
    +
    Lerneinheiten anlegen und Materialien hochladen
    +
    +
    +
    + +
    +
    📚
    +
    Lerneinheiten
    +
    Erstelle und verwalte Lerneinheiten fuer deine Schueler. Ordne Materialien zu und verfolge den Fortschritt.
    +
    + CRUD + Organisation +
    +
    +
    + + +
    +
    📤
    +
    Dateien hochladen
    +
    Lade Arbeitsblaetter, Bilder und PDFs hoch. Unterstuetzt Drag & Drop und Batch-Upload.
    +
    + PDF + Bilder + Batch +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    Verarbeiten
    +
    Automatische Aufbereitung und Bereinigung
    +
    +
    +
    + +
    +
    🔧
    +
    Dateien bereinigen
    +
    OCR-Verarbeitung und automatischer Neuaufbau von gescannten Arbeitsblaettern.
    +
    + OCR + Neuaufbau + AI +
    +
    +
    + + +
    +
    🎯
    +
    Lernflows
    +
    Interaktive Lernpfade erstellen. Kombiniere verschiedene Aufgabentypen zu einem Flow.
    +
    + Interaktiv + Sequenzen +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    Druckdateien generieren
    +
    AI-gestuetzte Erstellung von Uebungsmaterialien
    +
    +
    +
    + +
    +
    +
    Multiple Choice
    +
    Generiere Multiple-Choice-Tests aus deinen Lernmaterialien. Inkl. Antwortschluessel.
    +
    + Tests + AI + Druckbar +
    +
    +
    + + +
    +
    📝
    +
    Lueckentexte
    +
    Erstelle Lueckentexte fuer effektives Vokabel- und Faktenlernen.
    +
    + Uebung + AI + Druckbar +
    +
    +
    + + +
    +
    🗺
    +
    Mindmap
    +
    Visualisiere Zusammenhaenge in einer uebersichtlichen Mindmap.
    +
    + Visualisierung + AI +
    +
    +
    + + +
    +
    +
    Fragen & Antworten
    +
    Generiere Fragenkataloge und Lernkarten aus deinen Materialien.
    +
    + Lernkarten + AI +
    +
    +
    +
    +
    + + +
    +
    +
    🌐
    +
    +
    Uebersetzen
    +
    Mehrsprachige Unterstuetzung fuer alle Materialien
    +
    +
    +
    + +
    +
    🗣
    +
    Uebersetzungen
    +
    Uebersetze Arbeitsblaetter und generierte Inhalte in verschiedene Sprachen.
    +
    + DE + EN + TR + AR + +10 +
    +
    +
    +
    +
    +
    +
    + + +
    +
    + +
    Lerneinheiten verwalten
    +
    +
    +
    +
    +
    +

    Neue Lerneinheit

    + +
    + + +
    + + +
    +
    + +
    +
    +
    +
    +
    📚
    +

    Lerneinheit auswaehlen

    +

    Waehle eine Lerneinheit aus der Liste oder erstelle eine neue.

    +
    +
    +
    +
    +
    + + +
    +
    + +
    Dateien hochladen
    +
    +
    +
    +
    +
    📤
    +
    Dateien hochladen
    +
    Dateien hierher ziehen oder klicken zum Auswaehlen
    +
    PDF, JPG, PNG - max. 50MB pro Datei
    + +
    + +
    +
    +
    + + +
    +
    + +
    Dateien bereinigen
    +
    +
    +

    Waehle Dateien aus einer Lerneinheit und starte den OCR-Neuaufbau.

    +
    + +
    +
    +
    + + +
    +
    + +
    Multiple Choice Generator
    +
    +
    +

    Generiere Multiple-Choice-Tests aus deinen Lernmaterialien.

    +
    +
    +
    🌟
    +
    Einfach
    +
    5 Fragen, grundlegend
    +
    +
    +
    💪
    +
    Mittel
    +
    10 Fragen, ausgewogen
    +
    +
    +
    🔥
    +
    Schwer
    +
    15 Fragen, anspruchsvoll
    +
    +
    + +
    +
    + + +
    +
    + +
    Lueckentext Generator
    +
    +
    +

    Erstelle Lueckentexte aus deinen Lernmaterialien.

    + +
    +
    + + +
    +
    + +
    Mindmap Generator
    +
    +
    +

    Erstelle eine visuelle Mindmap aus deinen Lernmaterialien.

    + +
    +
    + + +
    +
    + +
    Fragen & Antworten Generator
    +
    +
    +

    Generiere Fragen und Antworten fuer Lernkarten.

    + +
    +
    + + +
    +
    + +
    Lernflows erstellen
    +
    +
    +

    Erstelle interaktive Lernpfade aus verschiedenen Aufgabentypen.

    + +
    +
    + + +
    +
    + +
    Uebersetzungen
    +
    +
    +

    Uebersetze deine Materialien in verschiedene Sprachen.

    +
    +
    +
    🇬🇧
    +
    Englisch
    +
    +
    +
    🇹🇷
    +
    Tuerkisch
    +
    +
    +
    🇦🇪
    +
    Arabisch
    +
    +
    +
    🇺🇦
    +
    Ukrainisch
    +
    +
    +
    +
    + + +
    + + Bereit + +
    +
    + + + +""" + + @staticmethod + def get_js() -> str: + """JavaScript fuer das Worksheets-Modul.""" + return """ +// ============================================= +// WORKSHEETS MODULE - Refactored with Tiles +// ============================================= + +let worksheetsInitialized = false; +let worksheetsUnits = []; +let worksheetsCurrentUnit = null; + +function loadWorksheetsModule() { + if (worksheetsInitialized) { + console.log('Worksheets module already initialized'); + return; + } + + console.log('Loading Worksheets Module (Tiles)...'); + + // Initialize upload zone + initWorksheetsUpload(); + + // Load units + loadWorksheetsUnits(); + + // Init lightbox + initWorksheetsLightbox(); + + worksheetsInitialized = true; + console.log('Worksheets Module loaded successfully'); +} + +// ============================================= +// VIEW SWITCHING +// ============================================= + +function showWorksheetsTiles() { + document.getElementById('worksheets-tiles-view').style.display = 'block'; + closeWorksheetsSubpanel(); + + document.querySelectorAll('.worksheets-nav-btn').forEach(btn => btn.classList.remove('active')); + document.querySelector('.worksheets-nav-btn').classList.add('active'); +} + +function showWorksheetsManager() { + openWorksheetsSubpanel('units'); + + document.querySelectorAll('.worksheets-nav-btn').forEach(btn => btn.classList.remove('active')); + document.querySelectorAll('.worksheets-nav-btn')[1].classList.add('active'); +} + +// ============================================= +// SUBPANEL NAVIGATION +// ============================================= + +function openWorksheetsSubpanel(panelId) { + // Hide tiles view + document.getElementById('worksheets-tiles-view').style.display = 'none'; + + // Hide all subpanels + document.querySelectorAll('.worksheets-subpanel').forEach(p => { + p.classList.remove('active'); + }); + + // Show selected subpanel + const panel = document.getElementById('worksheets-subpanel-' + panelId); + if (panel) { + panel.classList.add('active'); + } +} + +function closeWorksheetsSubpanel() { + document.querySelectorAll('.worksheets-subpanel').forEach(p => { + p.classList.remove('active'); + }); + document.getElementById('worksheets-tiles-view').style.display = 'block'; + + // Reset nav buttons + document.querySelectorAll('.worksheets-nav-btn').forEach(btn => btn.classList.remove('active')); + document.querySelector('.worksheets-nav-btn').classList.add('active'); +} + +// ============================================= +// STATUS +// ============================================= + +function setWorksheetsStatus(text, detail = '', state = 'idle') { + const indicator = document.getElementById('ws-status-indicator'); + const textEl = document.getElementById('ws-status-text'); + const detailEl = document.getElementById('ws-status-detail'); + + if (textEl) textEl.textContent = text; + if (detailEl) detailEl.textContent = detail; + if (indicator) { + indicator.classList.remove('busy', 'error'); + if (state === 'busy') indicator.classList.add('busy'); + else if (state === 'error') indicator.classList.add('error'); + } +} + +// ============================================= +// LERNEINHEITEN +// ============================================= + +async function loadWorksheetsUnits() { + try { + const resp = await fetch('/api/learning-units/'); + if (!resp.ok) { + console.error('Failed to load units'); + return; + } + worksheetsUnits = await resp.json(); + renderWorksheetsUnits(); + } catch (e) { + console.error('Error loading units:', e); + } +} + +function renderWorksheetsUnits() { + const container = document.getElementById('units-list-container'); + if (!container) return; + + if (!worksheetsUnits.length) { + container.innerHTML = '
    Keine Lerneinheiten vorhanden
    '; + return; + } + + container.innerHTML = worksheetsUnits.map(unit => ` +
    +
    +
    ${unit.label || unit.title || 'Lerneinheit'}
    +
    ${unit.subject || ''} ${unit.grade || ''} - ${(unit.worksheet_files || []).length} Dateien
    +
    + 🗑 +
    + `).join(''); +} + +function selectWorksheetsUnit(unitId) { + worksheetsCurrentUnit = worksheetsUnits.find(u => u.id === unitId); + renderWorksheetsUnits(); + showUnitDetail(); +} + +function showUnitDetail() { + const container = document.getElementById('units-detail-container'); + if (!container || !worksheetsCurrentUnit) return; + + const unit = worksheetsCurrentUnit; + const files = unit.worksheet_files || []; + + container.innerHTML = ` +

    ${unit.label || unit.title}

    +

    + ${unit.student_name ? 'Schueler: ' + unit.student_name + ' | ' : ''} + ${unit.subject || ''} ${unit.grade || ''} +

    + +

    + Zugeordnete Dateien (${files.length}) +

    + + ${files.length ? ` +
    + ${files.map(f => ` +
    + ${f} + +
    + `).join('')} +
    + ` : '

    Keine Dateien zugeordnet

    '} + +
    + + +
    + `; +} + +async function createNewUnit() { + const student = document.getElementById('new-unit-student')?.value.trim() || ''; + const subject = document.getElementById('new-unit-subject')?.value.trim() || ''; + const grade = document.getElementById('new-unit-grade')?.value.trim() || ''; + const title = document.getElementById('new-unit-title')?.value.trim() || ''; + + if (!title) { + alert('Bitte einen Titel eingeben'); + return; + } + + try { + setWorksheetsStatus('Erstelle Lerneinheit...', '', 'busy'); + const resp = await fetch('/api/learning-units/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + label: title, + student_name: student, + subject: subject, + grade: grade, + title: title + }) + }); + + if (!resp.ok) { + throw new Error('Failed to create unit'); + } + + const newUnit = await resp.json(); + worksheetsUnits.push(newUnit); + worksheetsCurrentUnit = newUnit; + + // Clear form + ['new-unit-student', 'new-unit-subject', 'new-unit-grade', 'new-unit-title'].forEach(id => { + const el = document.getElementById(id); + if (el) el.value = ''; + }); + + renderWorksheetsUnits(); + showUnitDetail(); + setWorksheetsStatus('Lerneinheit erstellt', ''); + } catch (e) { + console.error(e); + setWorksheetsStatus('Fehler', String(e), 'error'); + } +} + +async function deleteWorksheetsUnit(unitId) { + if (!confirm('Lerneinheit wirklich loeschen?')) return; + + try { + setWorksheetsStatus('Loesche Lerneinheit...', '', 'busy'); + const resp = await fetch(`/api/learning-units/${unitId}`, { method: 'DELETE' }); + + if (!resp.ok) { + throw new Error('Failed to delete unit'); + } + + worksheetsUnits = worksheetsUnits.filter(u => u.id !== unitId); + if (worksheetsCurrentUnit?.id === unitId) { + worksheetsCurrentUnit = null; + } + + renderWorksheetsUnits(); + setWorksheetsStatus('Lerneinheit geloescht', ''); + } catch (e) { + console.error(e); + setWorksheetsStatus('Fehler', String(e), 'error'); + } +} + +// ============================================= +// FILE UPLOAD +// ============================================= + +function initWorksheetsUpload() { + const dropZone = document.getElementById('worksheets-upload-zone'); + const fileInput = document.getElementById('worksheets-file-input'); + + if (!dropZone || !fileInput) return; + + dropZone.addEventListener('click', () => fileInput.click()); + + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('dragover'); + }); + + dropZone.addEventListener('dragleave', () => { + dropZone.classList.remove('dragover'); + }); + + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('dragover'); + if (e.dataTransfer.files.length) { + uploadWorksheetsFiles(e.dataTransfer.files); + } + }); + + fileInput.addEventListener('change', () => { + if (fileInput.files.length) { + uploadWorksheetsFiles(fileInput.files); + } + }); +} + +async function uploadWorksheetsFiles(files) { + const formData = new FormData(); + for (const file of files) { + formData.append('files', file); + } + + try { + setWorksheetsStatus('Lade hoch...', `${files.length} Datei(en)`, 'busy'); + + const resp = await fetch('/api/upload', { + method: 'POST', + body: formData + }); + + if (!resp.ok) { + throw new Error('Upload failed'); + } + + setWorksheetsStatus('Upload erfolgreich', `${files.length} Datei(en)`); + + // If unit selected, offer to add files + if (worksheetsCurrentUnit) { + // Could auto-add here + } + } catch (e) { + console.error(e); + setWorksheetsStatus('Upload fehlgeschlagen', String(e), 'error'); + } +} + +// ============================================= +// GENERATORS - Connected to /api/worksheets/ +// ============================================= + +let generatorDifficulty = 'medium'; +let generatorNumQuestions = 10; +let generatedContent = null; + +function selectGeneratorOption(el, difficulty) { + document.querySelectorAll('.generator-option').forEach(o => o.classList.remove('selected')); + el.classList.add('selected'); + generatorDifficulty = difficulty; + generatorNumQuestions = difficulty === 'easy' ? 5 : difficulty === 'medium' ? 10 : 15; +} + +// Get source text from current unit or prompt user +async function getSourceText() { + if (worksheetsCurrentUnit && worksheetsCurrentUnit.worksheet_files?.length > 0) { + // In production: extract text from files via OCR/parser + // For now, prompt user for text input + const text = prompt('Gib den Quelltext ein (min. 50 Zeichen) oder fuege Inhalt aus deiner Lerneinheit ein:'); + return text; + } + return prompt('Gib den Quelltext ein (min. 50 Zeichen):'); +} + +async function generateMC() { + const sourceText = await getSourceText(); + if (!sourceText || sourceText.length < 50) { + alert('Bitte einen laengeren Text eingeben (mind. 50 Zeichen).'); + return; + } + + setWorksheetsStatus('Generiere MC-Test...', '', 'busy'); + + try { + const resp = await fetch('/api/worksheets/generate/multiple-choice', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + source_text: sourceText, + num_questions: generatorNumQuestions, + difficulty: generatorDifficulty, + topic: worksheetsCurrentUnit?.title || 'Lerneinheit', + subject: worksheetsCurrentUnit?.subject || null + }) + }); + + const result = await resp.json(); + + if (!result.success) { + throw new Error(result.error || 'Generation failed'); + } + + generatedContent = result.content; + setWorksheetsStatus('MC-Test generiert', `${result.content.data.questions.length} Fragen`); + showGeneratedMC(result.content); + + } catch (e) { + console.error(e); + setWorksheetsStatus('Generation fehlgeschlagen', String(e), 'error'); + alert('Fehler bei der MC-Generierung: ' + e.message); + } +} + +function showGeneratedMC(content) { + const questions = content.data.questions; + const container = document.querySelector('#worksheets-subpanel-mc .subpanel-content'); + + let html = ` +
    +

    Generierte Fragen (${questions.length})

    +
    + `; + + questions.forEach((q, i) => { + html += ` +
    + Frage ${i + 1}: ${q.question} +
      + ${q.options.map(opt => ` +
    • + ${opt.text} ${opt.is_correct ? '✓' : ''} +
    • + `).join('')} +
    +
    + `; + }); + + html += ` +
    +
    + + + +
    +
    + `; + + container.innerHTML = html; +} + +async function generateCloze() { + const sourceText = await getSourceText(); + if (!sourceText || sourceText.length < 50) { + alert('Bitte einen laengeren Text eingeben (mind. 50 Zeichen).'); + return; + } + + setWorksheetsStatus('Generiere Lueckentext...', '', 'busy'); + + try { + const resp = await fetch('/api/worksheets/generate/cloze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + source_text: sourceText, + num_gaps: generatorDifficulty === 'easy' ? 3 : generatorDifficulty === 'medium' ? 5 : 8, + difficulty: generatorDifficulty, + cloze_type: 'fill_in', + topic: worksheetsCurrentUnit?.title || null + }) + }); + + const result = await resp.json(); + + if (!result.success) { + throw new Error(result.error || 'Generation failed'); + } + + generatedContent = result.content; + setWorksheetsStatus('Lueckentext generiert', `${result.content.data.gaps.length} Luecken`); + showGeneratedCloze(result.content); + + } catch (e) { + console.error(e); + setWorksheetsStatus('Generation fehlgeschlagen', String(e), 'error'); + alert('Fehler bei der Lueckentext-Generierung: ' + e.message); + } +} + +function showGeneratedCloze(content) { + const container = document.querySelector('#worksheets-subpanel-cloze .subpanel-content'); + const clozeData = content.data; + + let html = ` +
    +

    Generierter Lueckentext

    +
    + ${clozeData.text_with_gaps} +
    +

    Loesungen:

    +
    + ${clozeData.gaps.map((g, i) => ` + + ${i + 1}. ${g.answer} + + `).join('')} +
    +
    + + +
    +
    + `; + + container.innerHTML = html; +} + +async function generateMindmap() { + const sourceText = await getSourceText(); + if (!sourceText || sourceText.length < 50) { + alert('Bitte einen laengeren Text eingeben (mind. 50 Zeichen).'); + return; + } + + setWorksheetsStatus('Generiere Mindmap...', '', 'busy'); + + try { + const resp = await fetch('/api/worksheets/generate/mindmap', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + source_text: sourceText, + topic: worksheetsCurrentUnit?.title || 'Thema', + max_depth: 3 + }) + }); + + const result = await resp.json(); + + if (!result.success) { + throw new Error(result.error || 'Generation failed'); + } + + generatedContent = result.content; + setWorksheetsStatus('Mindmap generiert', `${result.content.data.mindmap.total_nodes} Knoten`); + showGeneratedMindmap(result.content); + + } catch (e) { + console.error(e); + setWorksheetsStatus('Generation fehlgeschlagen', String(e), 'error'); + alert('Fehler bei der Mindmap-Generierung: ' + e.message); + } +} + +function showGeneratedMindmap(content) { + const container = document.querySelector('#worksheets-subpanel-mindmap .subpanel-content'); + const mindmap = content.data.mindmap; + const mermaid = content.data.mermaid; + + let html = ` +
    +

    Generierte Mindmap: ${mindmap.title}

    + +
    +
    ${mermaid}
    +
    + +

    Struktur:

    +
    + ${renderMindmapNode(mindmap.root)} +
    + +
    + + +
    +
    + `; + + container.innerHTML = html; +} + +function renderMindmapNode(node, indent = 0) { + const padding = indent * 20; + let html = `
    + + ${indent > 0 ? '├─ ' : ''}${node.label} + +
    `; + + if (node.children) { + node.children.forEach(child => { + html += renderMindmapNode(child, indent + 1); + }); + } + return html; +} + +function copyMermaidCode() { + if (generatedContent?.data?.mermaid) { + navigator.clipboard.writeText(generatedContent.data.mermaid); + alert('Mermaid-Code in Zwischenablage kopiert!'); + } +} + +async function generateQA() { + const sourceText = await getSourceText(); + if (!sourceText || sourceText.length < 50) { + alert('Bitte einen laengeren Text eingeben (mind. 50 Zeichen).'); + return; + } + + setWorksheetsStatus('Generiere Quiz...', '', 'busy'); + + try { + const resp = await fetch('/api/worksheets/generate/quiz', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + source_text: sourceText, + quiz_types: ['true_false', 'matching'], + num_items: generatorDifficulty === 'easy' ? 3 : 5, + difficulty: generatorDifficulty, + topic: worksheetsCurrentUnit?.title || null + }) + }); + + const result = await resp.json(); + + if (!result.success) { + throw new Error(result.error || 'Generation failed'); + } + + generatedContent = result.content; + const totalItems = (result.content.data.true_false_questions?.length || 0) + + (result.content.data.matching_pairs?.length || 0); + setWorksheetsStatus('Quiz generiert', `${totalItems} Items`); + showGeneratedQuiz(result.content); + + } catch (e) { + console.error(e); + setWorksheetsStatus('Generation fehlgeschlagen', String(e), 'error'); + alert('Fehler bei der Quiz-Generierung: ' + e.message); + } +} + +function showGeneratedQuiz(content) { + const container = document.querySelector('#worksheets-subpanel-qa .subpanel-content'); + const quiz = content.data; + + let html = ` +
    +

    Generiertes Quiz

    + `; + + if (quiz.true_false_questions?.length > 0) { + html += `

    Richtig/Falsch Fragen:

    `; + quiz.true_false_questions.forEach((q, i) => { + html += ` +
    + ${i + 1}. ${q.statement} + + ${q.is_true ? '✓ Richtig' : '✗ Falsch'} + +
    + `; + }); + } + + if (quiz.matching_pairs?.length > 0) { + html += `

    Zuordnungsaufgaben:

    `; + quiz.matching_pairs.forEach((p, i) => { + html += ` +
    + ${p.left} + + ${p.right} +
    + `; + }); + } + + html += ` +
    + + +
    +
    + `; + + container.innerHTML = html; +} + +// Export functions +async function exportGeneratedContent(format) { + if (!generatedContent) { + alert('Kein generierter Inhalt vorhanden.'); + return; + } + + if (format === 'h5p' && generatedContent.h5p_format) { + // Download H5P format as JSON (real H5P package would need backend support) + const blob = new Blob([JSON.stringify(generatedContent.h5p_format, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `content_h5p_${generatedContent.id}.json`; + a.click(); + URL.revokeObjectURL(url); + } else if (format === 'json') { + const blob = new Blob([JSON.stringify(generatedContent.data, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `content_${generatedContent.content_type}_${generatedContent.id}.json`; + a.click(); + URL.revokeObjectURL(url); + } +} + +function printGeneratedContent() { + window.print(); +} + +function createLernflow() { + alert('Lernflow-Editor: Kombiniere mehrere Generatoren zu einem interaktiven Lernpfad. Kommt in einer spaeteren Version!'); +} + +function selectLanguage(lang) { + alert('Uebersetzung nach ' + lang.toUpperCase() + ': Diese Funktion wird mit DeepL/LLM-Integration umgesetzt. Kommt bald!'); +} + +// ============================================= +// LIGHTBOX +// ============================================= + +function initWorksheetsLightbox() { + const lightbox = document.getElementById('lightbox'); + const closeBtn = document.getElementById('lightbox-close'); + + if (closeBtn) { + closeBtn.addEventListener('click', closeLightbox); + } + if (lightbox) { + lightbox.addEventListener('click', (e) => { + if (e.target === lightbox) closeLightbox(); + }); + } +} + +function openLightbox(src, caption) { + const lightbox = document.getElementById('lightbox'); + const img = document.getElementById('lightbox-img'); + const captionEl = document.getElementById('lightbox-caption'); + + if (!lightbox || !src) return; + + img.src = src; + captionEl.textContent = caption || ''; + lightbox.classList.remove('hidden'); +} + +function closeLightbox() { + const lightbox = document.getElementById('lightbox'); + if (lightbox) lightbox.classList.add('hidden'); +} + +// ============================================= +// SHOW PANEL +// ============================================= + +function showWorksheetsPanel() { + console.log('showWorksheetsPanel called'); + hideAllPanels(); + if (typeof hideStudioSubMenu === 'function') hideStudioSubMenu(); + const panel = document.getElementById('panel-worksheets'); + if (panel) { + panel.style.display = 'flex'; + loadWorksheetsModule(); + console.log('Worksheets panel shown'); + } else { + console.error('panel-worksheets not found'); + } +} + +// Escape key to close subpanels +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeLightbox(); + if (document.querySelector('.worksheets-subpanel.active')) { + closeWorksheetsSubpanel(); + } + } +}); +""" diff --git a/backend/frontend/paths.py b/backend/frontend/paths.py new file mode 100644 index 0000000..19f0353 --- /dev/null +++ b/backend/frontend/paths.py @@ -0,0 +1,7 @@ +from pathlib import Path + +# Zentrale Ordnerpfade für die BreakPilot-Arbeitsblätter +BASE_DIR = Path.home() / "Arbeitsblaetter" +EINGANG_DIR = BASE_DIR / "Eingang" +BEREINIGT_DIR = BASE_DIR / "Bereinigt" + diff --git a/backend/frontend/preview.py b/backend/frontend/preview.py new file mode 100644 index 0000000..8d63d3e --- /dev/null +++ b/backend/frontend/preview.py @@ -0,0 +1,27 @@ +from fastapi import APIRouter +from fastapi.responses import FileResponse + +from .paths import EINGANG_DIR, BEREINIGT_DIR + +router = APIRouter() + + +@router.get("/preview-file/{filename}") +def preview_file(filename: str): + path = EINGANG_DIR / filename + if not path.exists(): + return {"error": "Datei nicht gefunden"} + if path.suffix.lower() not in {".jpg", ".jpeg", ".png"}: + return {"error": "Vorschau nur für JPG/PNG möglich"} + return FileResponse(str(path)) + + +@router.get("/preview-clean-file/{filename}") +def preview_clean_file(filename: str): + path = BEREINIGT_DIR / filename + if not path.exists(): + return {"error": "Datei nicht gefunden"} + if path.suffix.lower() not in {".jpg", ".jpeg", ".png"}: + return {"error": "Vorschau nur für JPG/PNG möglich"} + return FileResponse(str(path)) + diff --git a/backend/frontend/school.py b/backend/frontend/school.py new file mode 100644 index 0000000..3de456d --- /dev/null +++ b/backend/frontend/school.py @@ -0,0 +1,16 @@ +""" +Schulverwaltung Frontend Module - Legacy Compatibility Wrapper + +This file provides backward compatibility for code importing from school.py. +All functionality has been moved to the school/ module. + +For new code, import directly from: + from frontend.school import router +""" + +# Re-export the router from the modular structure +from .school import router + +__all__ = [ + "router", +] diff --git a/backend/frontend/school/__init__.py b/backend/frontend/school/__init__.py new file mode 100644 index 0000000..48151ea --- /dev/null +++ b/backend/frontend/school/__init__.py @@ -0,0 +1,63 @@ +""" +School Module + +Modular structure for the School frontend (Schulverwaltung). +Matrix-based communication for schools. + +Modular Refactoring (2026-02-03): +- Split into sub-modules for maintainability +- Original file: school.py (3,732 lines) +- Now split into: styles.py, templates.py, pages/ +""" + +from fastapi import APIRouter +from fastapi.responses import HTMLResponse + +from .pages import ( + school_dashboard, + attendance_page, + grades_page, + timetable_page, + parent_onboarding, +) + +router = APIRouter() + + +# ============================================ +# API Routes +# ============================================ + +@router.get("/school", response_class=HTMLResponse) +def get_school_dashboard(): + """Main school dashboard""" + return school_dashboard() + + +@router.get("/school/attendance", response_class=HTMLResponse) +def get_attendance_page(): + """Attendance tracking page""" + return attendance_page() + + +@router.get("/school/grades", response_class=HTMLResponse) +def get_grades_page(): + """Grades overview page""" + return grades_page() + + +@router.get("/school/timetable", response_class=HTMLResponse) +def get_timetable_page(): + """Timetable page""" + return timetable_page() + + +@router.get("/onboard-parent", response_class=HTMLResponse) +def get_parent_onboarding(): + """Parent onboarding page (QR code landing)""" + return parent_onboarding() + + +__all__ = [ + "router", +] diff --git a/backend/frontend/school/pages/__init__.py b/backend/frontend/school/pages/__init__.py new file mode 100644 index 0000000..81296c2 --- /dev/null +++ b/backend/frontend/school/pages/__init__.py @@ -0,0 +1,18 @@ +""" +School Module - Pages +Individual page renderers for the school frontend +""" + +from .dashboard import school_dashboard +from .attendance import attendance_page +from .grades import grades_page +from .timetable import timetable_page +from .parent_onboarding import parent_onboarding + +__all__ = [ + "school_dashboard", + "attendance_page", + "grades_page", + "timetable_page", + "parent_onboarding", +] diff --git a/backend/frontend/school/pages/attendance.py b/backend/frontend/school/pages/attendance.py new file mode 100644 index 0000000..57da9bb --- /dev/null +++ b/backend/frontend/school/pages/attendance.py @@ -0,0 +1,249 @@ +""" +School Module - Attendance Page +Attendance tracking for students +""" + +from ..styles import SCHOOL_BASE_STYLES, ATTENDANCE_STYLES +from ..templates import ICONS, render_base_page, COMMON_SCRIPTS + + +def attendance_page() -> str: + """Attendance tracking page""" + styles = SCHOOL_BASE_STYLES + ATTENDANCE_STYLES + + content = f''' +
    + + + +
    +
    + +
    +
    + +
    + +
    + + +
    +
    + + 24 + Anwesend +
    +
    + + 1 + Abwesend +
    +
    + + 1 + Verspätet +
    +
    + + 0 + Entschuldigt +
    +
    + + +
    +
    + Schülerliste + +
    + + + + + + + + + + + +
    SchülerStatusAnmerkung
    +
    +
    + +
    ''' + + scripts = COMMON_SCRIPTS + ''' + ''' + + return render_base_page("Anwesenheit", styles, content, scripts, "attendance") diff --git a/backend/frontend/school/pages/dashboard.py b/backend/frontend/school/pages/dashboard.py new file mode 100644 index 0000000..0034715 --- /dev/null +++ b/backend/frontend/school/pages/dashboard.py @@ -0,0 +1,183 @@ +""" +School Module - Dashboard Page +Main school dashboard with stats and quick actions +""" + +from ..styles import SCHOOL_BASE_STYLES, DASHBOARD_STYLES +from ..templates import ICONS, render_base_page, COMMON_SCRIPTS + + +def school_dashboard() -> str: + """Main school dashboard""" + styles = SCHOOL_BASE_STYLES + DASHBOARD_STYLES + + content = f''' +
    + + + +
    +
    +
    + Anwesend heute +
    + {ICONS['check_circle']} +
    +
    +
    24/26
    +
    92% Anwesenheitsrate
    +
    + +
    +
    + Offene Entschuldigungen +
    + {ICONS['warning']} +
    +
    +
    3
    +
    Warten auf Bestätigung
    +
    + +
    +
    + Ungelesene Nachrichten +
    + {ICONS['mail']} +
    +
    +
    5
    +
    Neue Elternnachrichten
    +
    + +
    +
    + Nächster Elternsprechtag +
    + {ICONS['calendar']} +
    +
    +
    15.01.
    +
    8 Termine gebucht
    +
    +
    + + +

    Schnellzugriff

    + + + +
    +
    + Heutige Abwesenheiten + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    SchülerKlasseStundenStatusAktion
    Anna Schmidt5a1.-4. Stunde⚠ Unentschuldigt
    Ben Müller5aGanztägig⏳ Gemeldet
    Clara Weber5a3. Stunde⏰ Verspätet
    +
    +
    ''' + + scripts = COMMON_SCRIPTS + ''' + ''' + + return render_base_page("Schulverwaltung", styles, content, scripts, "dashboard") diff --git a/backend/frontend/school/pages/grades.py b/backend/frontend/school/pages/grades.py new file mode 100644 index 0000000..03d81f5 --- /dev/null +++ b/backend/frontend/school/pages/grades.py @@ -0,0 +1,341 @@ +""" +School Module - Grades Page +Grades overview and entry +""" + +from ..styles import SCHOOL_BASE_STYLES, GRADES_STYLES +from ..templates import ICONS, render_base_page, COMMON_SCRIPTS + + +def grades_page() -> str: + """Grades overview page""" + styles = SCHOOL_BASE_STYLES + GRADES_STYLES + + content = f''' +
    + + + +
    +
    + +
    +
    + +
    +
    + +
    +
    + + +
    +
    +
    2.4
    +
    Klassendurchschnitt
    +
    +
    +
    1
    +
    Beste Note
    +
    +
    +
    26
    +
    Eingetragene Noten
    +
    +
    + + +
    +
    + Notenverteilung +
    +
    +
    +
    4
    +
    7
    +
    8
    +
    5
    +
    2
    +
    +
    +
    +
    + + +
    +
    + Noten - Mathematik (Klassenarbeit 1) + +
    + + + + + + + + + + + + + + +
    SchülerNotePunkteDatumKommentarAktion
    +
    +
    + + + + +
    ''' + + scripts = COMMON_SCRIPTS + ''' + ''' + + return render_base_page("Notenspiegel", styles, content, scripts, "grades") diff --git a/backend/frontend/school/pages/parent_onboarding.py b/backend/frontend/school/pages/parent_onboarding.py new file mode 100644 index 0000000..d65ad9e --- /dev/null +++ b/backend/frontend/school/pages/parent_onboarding.py @@ -0,0 +1,180 @@ +""" +School Module - Parent Onboarding Page +QR code landing page for parent registration +""" + +from ..styles import SCHOOL_BASE_STYLES, ONBOARDING_STYLES +from ..templates import COMMON_SCRIPTS + + +def parent_onboarding() -> str: + """Parent onboarding page (QR code landing)""" + # Onboarding page uses its own simplified styles without sidebar + styles = f""" +:root {{ + --bp-primary: #6C1B1B; + --bp-bg: #F8F8F8; + --bp-surface: #FFFFFF; + --bp-text: #4A4A4A; + --bp-text-muted: #6B6B6B; + --bp-accent: #5ABF60; + --bp-border: #E0E0E0; +}} + +* {{ box-sizing: border-box; margin: 0; padding: 0; }} + +{ONBOARDING_STYLES} +""" + + content = ''' +
    + + +
    +
    +

    QR-Code wird validiert...

    +
    + + +
    ''' + + scripts = ''' + ''' + + # This page doesn't use the standard base template with sidebar + return f''' + + + + BreakPilot – Eltern-Onboarding + + + + + + {content} + {scripts} + +''' diff --git a/backend/frontend/school/pages/timetable.py b/backend/frontend/school/pages/timetable.py new file mode 100644 index 0000000..5c73e50 --- /dev/null +++ b/backend/frontend/school/pages/timetable.py @@ -0,0 +1,304 @@ +""" +School Module - Timetable Page +Weekly timetable view +""" + +from ..styles import SCHOOL_BASE_STYLES, TIMETABLE_STYLES +from ..templates import ICONS, render_base_page, COMMON_SCRIPTS + + +def timetable_page() -> str: + """Timetable page""" + styles = SCHOOL_BASE_STYLES + TIMETABLE_STYLES + + content = f''' +
    + + + +
    +
    + +
    + +
    + + 16. - 20. Dezember 2024 + +
    + + +
    + + +
    +
    + +
    +
    +
    Montag
    16.12.
    +
    Dienstag
    17.12.
    +
    Mittwoch
    18.12.
    +
    Donnerstag
    19.12.
    +
    Freitag
    20.12.
    +
    + +
    +
    + + +
    +
    +
    + Mathematik +
    +
    +
    + Deutsch +
    +
    +
    + Englisch +
    +
    +
    + Vertretung +
    +
    +
    + Entfall +
    +
    +
    + + + + +
    ''' + + scripts = COMMON_SCRIPTS + ''' + ''' + + return render_base_page("Stundenplan", styles, content, scripts, "timetable") diff --git a/backend/frontend/school/styles.py b/backend/frontend/school/styles.py new file mode 100644 index 0000000..a55cd46 --- /dev/null +++ b/backend/frontend/school/styles.py @@ -0,0 +1,1237 @@ +""" +School Module - Shared CSS Styles +BreakPilot Schulverwaltung styling +""" + +# Shared CSS Variables and Base Styles +SCHOOL_BASE_STYLES = """ +:root { + --bp-primary: #6C1B1B; + --bp-primary-soft: rgba(108, 27, 27, 0.1); + --bp-bg: #F8F8F8; + --bp-surface: #FFFFFF; + --bp-surface-elevated: #FFFFFF; + --bp-border: #E0E0E0; + --bp-border-subtle: rgba(108, 27, 27, 0.15); + --bp-accent: #5ABF60; + --bp-accent-soft: rgba(90, 191, 96, 0.15); + --bp-text: #4A4A4A; + --bp-text-muted: #6B6B6B; + --bp-danger: #ef4444; + --bp-warning: #F1C40F; + --bp-info: #3b82f6; + --bp-gold: #F1C40F; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: 'Manrope', system-ui, -apple-system, sans-serif; + background: var(--bp-bg); + color: var(--bp-text); + min-height: 100vh; +} + +/* Layout */ +.app-container { + display: flex; + min-height: 100vh; +} + +/* Sidebar */ +.sidebar { + width: 260px; + background: var(--bp-surface); + border-right: 1px solid var(--bp-border); + padding: 1.5rem; + display: flex; + flex-direction: column; +} + +.logo { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 2rem; +} + +.logo-icon { + width: 36px; + height: 36px; + background: var(--bp-primary); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 700; +} + +.logo-text { + font-size: 1.25rem; + font-weight: 700; + color: var(--bp-primary); +} + +.nav-section { + margin-bottom: 1.5rem; +} + +.nav-section-title { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + color: var(--bp-text-muted); + margin-bottom: 0.75rem; + padding-left: 0.75rem; +} + +.nav-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; + color: var(--bp-text); + text-decoration: none; +} + +.nav-item:hover { + background: var(--bp-primary-soft); +} + +.nav-item.active { + background: var(--bp-primary); + color: white; +} + +.nav-item svg { + width: 20px; + height: 20px; +} + +/* Main Content */ +.main-content { + flex: 1; + padding: 2rem; + overflow-y: auto; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.page-title { + font-size: 1.75rem; + font-weight: 700; + margin-bottom: 0.5rem; +} + +.page-subtitle { + color: var(--bp-text-muted); +} + +/* Cards */ +.card { + background: var(--bp-surface); + border-radius: 12px; + border: 1px solid var(--bp-border); + padding: 1.5rem; + overflow: hidden; +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.card-title { + font-size: 1rem; + font-weight: 600; +} + +.card-icon { + width: 40px; + height: 40px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; +} + +.card-icon.primary { background: var(--bp-primary-soft); color: var(--bp-primary); } +.card-icon.accent { background: var(--bp-accent-soft); color: var(--bp-accent); } +.card-icon.warning { background: rgba(241, 196, 15, 0.15); color: var(--bp-warning); } +.card-icon.info { background: rgba(59, 130, 246, 0.15); color: var(--bp-info); } + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 1.25rem; + border-radius: 8px; + font-weight: 500; + font-size: 0.875rem; + border: none; + cursor: pointer; + transition: all 0.2s; +} + +.btn-primary { + background: var(--bp-primary); + color: white; +} + +.btn-primary:hover { + background: #8B2323; +} + +.btn-secondary { + background: var(--bp-bg); + color: var(--bp-text); + border: 1px solid var(--bp-border); +} + +.btn-secondary:hover { + background: var(--bp-border); +} + +.btn-success { + background: var(--bp-accent); + color: white; +} + +/* Tables */ +table { + width: 100%; + border-collapse: collapse; +} + +th, td { + padding: 1rem 1.5rem; + text-align: left; +} + +th { + background: var(--bp-bg); + font-weight: 600; + font-size: 0.875rem; + color: var(--bp-text-muted); +} + +tr:not(:last-child) td { + border-bottom: 1px solid var(--bp-border); +} + +tr:hover td { + background: var(--bp-bg); +} + +/* Status Badges */ +.badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.25rem 0.75rem; + border-radius: 100px; + font-size: 0.75rem; + font-weight: 600; +} + +.badge.present { background: var(--bp-accent-soft); color: var(--bp-accent); } +.badge.absent { background: rgba(239, 68, 68, 0.15); color: var(--bp-danger); } +.badge.late { background: rgba(241, 196, 15, 0.15); color: #b8860b; } +.badge.pending { background: rgba(59, 130, 246, 0.15); color: var(--bp-info); } + +/* Controls */ +.controls { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + flex-wrap: wrap; +} + +.select-wrapper { + position: relative; +} + +select { + padding: 0.625rem 2.5rem 0.625rem 1rem; + border: 1px solid var(--bp-border); + border-radius: 8px; + background: var(--bp-surface); + font-size: 0.875rem; + appearance: none; + cursor: pointer; + min-width: 180px; +} + +.select-wrapper::after { + content: '▼'; + position: absolute; + right: 1rem; + top: 50%; + transform: translateY(-50%); + font-size: 0.75rem; + color: var(--bp-text-muted); + pointer-events: none; +} + +input[type="date"] { + padding: 0.625rem 1rem; + border: 1px solid var(--bp-border); + border-radius: 8px; + background: var(--bp-surface); + font-size: 0.875rem; +} + +/* User Info */ +.user-info { + margin-top: auto; + padding-top: 1rem; + border-top: 1px solid var(--bp-border); +} + +.user-card { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + border-radius: 8px; + background: var(--bp-bg); +} + +.user-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--bp-primary); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; +} + +.user-name { + font-weight: 500; + font-size: 0.875rem; +} + +.user-role { + font-size: 0.75rem; + color: var(--bp-text-muted); +} + +/* Student Info */ +.student-info { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.student-avatar { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--bp-primary-soft); + color: var(--bp-primary); + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 0.875rem; +} + +.student-name { + font-weight: 500; +} + +.student-number { + font-size: 0.75rem; + color: var(--bp-text-muted); +} + +/* Toast */ +.toast { + position: fixed; + bottom: 2rem; + right: 2rem; + padding: 1rem 1.5rem; + border-radius: 10px; + background: var(--bp-text); + color: white; + font-weight: 500; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + transform: translateY(100px); + opacity: 0; + transition: all 0.3s; +} + +.toast.show { + transform: translateY(0); + opacity: 1; +} + +.toast.success { + background: var(--bp-accent); +} + +/* Modal */ +.modal-overlay { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,0.5); + z-index: 1000; + align-items: center; + justify-content: center; +} + +.modal-overlay.show { + display: flex; +} + +.modal { + background: var(--bp-surface); + border-radius: 16px; + width: 100%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1.5rem; + border-bottom: 1px solid var(--bp-border); +} + +.modal-title { + font-size: 1.25rem; + font-weight: 600; +} + +.modal-close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--bp-text-muted); +} + +.modal-body { + padding: 1.5rem; +} + +.modal-footer { + display: flex; + gap: 1rem; + justify-content: flex-end; + padding: 1.5rem; + border-top: 1px solid var(--bp-border); +} + +/* Form Elements */ +.form-group { + margin-bottom: 1.25rem; +} + +.form-label { + display: block; + font-weight: 500; + margin-bottom: 0.5rem; +} + +.form-input, .form-select, .form-textarea { + width: 100%; + padding: 0.75rem 1rem; + border: 1px solid var(--bp-border); + border-radius: 8px; + font-size: 0.875rem; +} + +.form-textarea { + min-height: 80px; + resize: vertical; +} + +.form-input:focus, .form-select:focus, .form-textarea:focus { + outline: none; + border-color: var(--bp-primary); +} + +/* Grade Colors */ +.grade { font-weight: 600; } +.grade-1 { color: #16a34a; } +.grade-2 { color: #22c55e; } +.grade-3 { color: #f59e0b; } +.grade-4 { color: #f97316; } +.grade-5 { color: #ef4444; } +.grade-6 { color: #dc2626; } + +/* Responsive */ +@media (max-width: 768px) { + .sidebar { + position: fixed; + left: -260px; + top: 0; + bottom: 0; + z-index: 100; + transition: left 0.3s; + } + + .sidebar.open { + left: 0; + } + + .controls { + flex-direction: column; + align-items: stretch; + } +} +""" + + +# Dashboard-specific styles +DASHBOARD_STYLES = """ +/* Dashboard Grid */ +.dashboard-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.stat-value { + font-size: 2rem; + font-weight: 700; + margin-bottom: 0.25rem; +} + +.stat-label { + color: var(--bp-text-muted); + font-size: 0.875rem; +} + +/* Quick Actions */ +.quick-actions { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} + +.quick-action { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem 1.25rem; + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 10px; + cursor: pointer; + transition: all 0.2s; + text-decoration: none; + color: var(--bp-text); +} + +.quick-action:hover { + border-color: var(--bp-primary); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0,0,0,0.08); +} + +.quick-action-icon { + width: 44px; + height: 44px; + border-radius: 10px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bp-primary-soft); + color: var(--bp-primary); +} + +.quick-action-text { + font-weight: 500; +} + +/* Table Container */ +.table-container { + background: var(--bp-surface); + border-radius: 12px; + border: 1px solid var(--bp-border); + overflow: hidden; +} + +.table-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--bp-border); +} + +.table-title { + font-weight: 600; +} + +@media (max-width: 768px) { + .dashboard-grid { + grid-template-columns: 1fr; + } +} +""" + + +# Attendance-specific styles +ATTENDANCE_STYLES = """ +/* Status Toggle Buttons */ +.status-toggle { + display: flex; + gap: 0.5rem; +} + +.status-btn { + padding: 0.375rem 0.75rem; + border: 1px solid var(--bp-border); + border-radius: 6px; + background: var(--bp-surface); + cursor: pointer; + font-size: 0.75rem; + font-weight: 500; + transition: all 0.2s; +} + +.status-btn:hover { + border-color: var(--bp-primary); +} + +.status-btn.present.active { + background: var(--bp-accent); + color: white; + border-color: var(--bp-accent); +} + +.status-btn.absent.active { + background: var(--bp-danger); + color: white; + border-color: var(--bp-danger); +} + +.status-btn.late.active { + background: var(--bp-warning); + color: #333; + border-color: var(--bp-warning); +} + +.status-btn.excused.active { + background: var(--bp-info); + color: white; + border-color: var(--bp-info); +} + +/* Notes Input */ +.notes-input { + padding: 0.375rem 0.625rem; + border: 1px solid var(--bp-border); + border-radius: 6px; + font-size: 0.875rem; + width: 100%; + max-width: 200px; +} + +.notes-input:focus { + outline: none; + border-color: var(--bp-primary); +} + +/* Stats Bar */ +.stats-bar { + display: flex; + gap: 2rem; + padding: 1rem 1.5rem; + background: var(--bp-bg); + border-radius: 10px; + margin-bottom: 1.5rem; +} + +.stat-item { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.stat-dot { + width: 10px; + height: 10px; + border-radius: 50%; +} + +.stat-dot.present { background: var(--bp-accent); } +.stat-dot.absent { background: var(--bp-danger); } +.stat-dot.late { background: var(--bp-warning); } +.stat-dot.excused { background: var(--bp-info); } + +.stat-value { + font-weight: 600; +} + +.stat-label { + color: var(--bp-text-muted); + font-size: 0.875rem; +} + +@media (max-width: 768px) { + .stats-bar { + flex-wrap: wrap; + gap: 1rem; + } +} +""" + + +# Grades-specific styles +GRADES_STYLES = """ +/* Grade Badge */ +.grade-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 8px; + font-weight: 700; + font-size: 0.875rem; +} + +.grade-1 { background: #dcfce7; color: #16a34a; } +.grade-2 { background: #d1fae5; color: #059669; } +.grade-3 { background: #fef3c7; color: #d97706; } +.grade-4 { background: #fed7aa; color: #ea580c; } +.grade-5 { background: #fee2e2; color: #dc2626; } +.grade-6 { background: #fecaca; color: #991b1b; } + +.average { + font-weight: 600; +} + +.card-body { + padding: 1.5rem; +} + +/* Grade Buttons in Modal */ +.grade-buttons { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.grade-btn { + width: 48px; + height: 48px; + border: 2px solid var(--bp-border); + border-radius: 10px; + background: var(--bp-surface); + font-weight: 700; + font-size: 1rem; + cursor: pointer; + transition: all 0.2s; +} + +.grade-btn:hover { + border-color: var(--bp-primary); +} + +.grade-btn.selected { + background: var(--bp-primary); + color: white; + border-color: var(--bp-primary); +} + +/* Stats Row */ +.stats-row { + display: flex; + gap: 1.5rem; + margin-bottom: 1.5rem; +} + +.stat-card { + flex: 1; + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + padding: 1.25rem; +} + +.stat-card-value { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 0.25rem; +} + +.stat-card-label { + color: var(--bp-text-muted); + font-size: 0.875rem; +} + +/* Distribution Bar */ +.distribution-bar { + display: flex; + height: 24px; + border-radius: 6px; + overflow: hidden; + margin-top: 1rem; +} + +.distribution-segment { + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 600; + color: white; +} + +.dist-1 { background: #16a34a; } +.dist-2 { background: #22c55e; } +.dist-3 { background: #f59e0b; } +.dist-4 { background: #f97316; } +.dist-5 { background: #ef4444; } +.dist-6 { background: #dc2626; } + +@media (max-width: 768px) { + .stats-row { + flex-direction: column; + } +} +""" + + +# Timetable-specific styles +TIMETABLE_STYLES = """ +/* Week Navigation */ +.week-nav { + display: flex; + align-items: center; + gap: 1rem; + background: var(--bp-surface); + padding: 0.5rem; + border-radius: 10px; + border: 1px solid var(--bp-border); +} + +.week-nav-btn { + width: 36px; + height: 36px; + border: none; + border-radius: 8px; + background: var(--bp-bg); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; +} + +.week-nav-btn:hover { + background: var(--bp-primary-soft); +} + +.week-label { + font-weight: 600; + min-width: 200px; + text-align: center; +} + +.btn-today { + background: var(--bp-accent); + color: white; +} + +/* Timetable Grid */ +.timetable-container { + background: var(--bp-surface); + border-radius: 12px; + border: 1px solid var(--bp-border); + overflow: hidden; +} + +.timetable { + display: grid; + grid-template-columns: 80px repeat(5, 1fr); + width: 100%; +} + +.timetable-header { + display: contents; +} + +.timetable-header > div { + padding: 1rem; + background: var(--bp-bg); + font-weight: 600; + text-align: center; + border-bottom: 1px solid var(--bp-border); +} + +.timetable-row { + display: contents; +} + +.time-cell { + padding: 1rem 0.5rem; + background: var(--bp-bg); + text-align: center; + font-size: 0.75rem; + color: var(--bp-text-muted); + border-bottom: 1px solid var(--bp-border); + border-right: 1px solid var(--bp-border); +} + +.time-cell .lesson-num { + font-weight: 600; + font-size: 0.875rem; + color: var(--bp-text); + margin-bottom: 0.25rem; +} + +.lesson-cell { + padding: 0.5rem; + border-bottom: 1px solid var(--bp-border); + border-right: 1px solid var(--bp-border); + min-height: 80px; +} + +.lesson-cell:last-child { + border-right: none; +} + +/* Lesson Card */ +.lesson-card { + height: 100%; + padding: 0.75rem; + border-radius: 8px; + cursor: pointer; + transition: all 0.2s; +} + +.lesson-card:hover { + transform: scale(1.02); + box-shadow: 0 4px 12px rgba(0,0,0,0.1); +} + +.lesson-card.math { background: #e0f2fe; border-left: 3px solid #0ea5e9; } +.lesson-card.german { background: #fef3c7; border-left: 3px solid #f59e0b; } +.lesson-card.english { background: #dbeafe; border-left: 3px solid #3b82f6; } +.lesson-card.physics { background: #f3e8ff; border-left: 3px solid #a855f7; } +.lesson-card.biology { background: #dcfce7; border-left: 3px solid #22c55e; } +.lesson-card.history { background: #fee2e2; border-left: 3px solid #ef4444; } +.lesson-card.sport { background: #ffedd5; border-left: 3px solid #f97316; } +.lesson-card.music { background: #fce7f3; border-left: 3px solid #ec4899; } +.lesson-card.art { background: #e0e7ff; border-left: 3px solid #6366f1; } + +.lesson-card.substitution { + background: repeating-linear-gradient( + 45deg, + #fef3c7, + #fef3c7 10px, + #fde68a 10px, + #fde68a 20px + ); + border-left: 3px solid var(--bp-warning); +} + +.lesson-card.cancelled { + background: #fee2e2; + border-left: 3px solid var(--bp-danger); + opacity: 0.7; +} + +.lesson-card.cancelled .lesson-subject { + text-decoration: line-through; +} + +.lesson-subject { + font-weight: 600; + font-size: 0.875rem; + margin-bottom: 0.25rem; +} + +.lesson-teacher { + font-size: 0.75rem; + color: var(--bp-text-muted); +} + +.lesson-room { + font-size: 0.75rem; + color: var(--bp-text-muted); +} + +.lesson-badge { + display: inline-block; + font-size: 0.625rem; + padding: 0.125rem 0.375rem; + border-radius: 4px; + margin-top: 0.25rem; + font-weight: 600; +} + +.badge-substitution { + background: var(--bp-warning); + color: #333; +} + +.badge-cancelled { + background: var(--bp-danger); + color: white; +} + +/* Legend */ +.legend { + display: flex; + gap: 1.5rem; + margin-top: 1.5rem; + flex-wrap: wrap; +} + +.legend-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; +} + +.legend-color { + width: 16px; + height: 16px; + border-radius: 4px; +} + +/* Modal Info */ +.modal-info { + display: grid; + gap: 0.75rem; +} + +.modal-info-row { + display: flex; + justify-content: space-between; +} + +.modal-info-label { + color: var(--bp-text-muted); +} + +.modal-info-value { + font-weight: 500; +} + +@media (max-width: 1024px) { + .timetable { + grid-template-columns: 60px repeat(5, 1fr); + font-size: 0.75rem; + } + + .lesson-card { + padding: 0.5rem; + } +} + +@media (max-width: 768px) { + .timetable { + grid-template-columns: 50px repeat(5, minmax(80px, 1fr)); + overflow-x: auto; + } + + .week-nav { + justify-content: space-between; + } +} +""" + + +# Parent onboarding styles +ONBOARDING_STYLES = """ +body { + font-family: 'Manrope', system-ui, sans-serif; + background: var(--bp-bg); + color: var(--bp-text); + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 2rem; +} + +.onboarding-container { + background: var(--bp-surface); + border-radius: 16px; + padding: 2.5rem; + max-width: 480px; + width: 100%; + box-shadow: 0 4px 24px rgba(0,0,0,0.08); +} + +.logo { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 2rem; + justify-content: center; +} + +.logo-icon { + width: 48px; + height: 48px; + background: var(--bp-primary); + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 700; + font-size: 1.25rem; +} + +.logo-text { + font-size: 1.5rem; + font-weight: 700; + color: var(--bp-primary); +} + +h1 { + font-size: 1.5rem; + text-align: center; + margin-bottom: 0.5rem; +} + +.subtitle { + text-align: center; + color: var(--bp-text-muted); + margin-bottom: 2rem; +} + +.info-card { + background: var(--bp-bg); + border-radius: 12px; + padding: 1.5rem; + margin-bottom: 1.5rem; +} + +.info-row { + display: flex; + justify-content: space-between; + padding: 0.75rem 0; + border-bottom: 1px solid var(--bp-border); +} + +.info-row:last-child { + border-bottom: none; +} + +.info-label { + color: var(--bp-text-muted); +} + +.info-value { + font-weight: 600; +} + +.checkbox-group { + margin-bottom: 1.5rem; +} + +.checkbox-item { + display: flex; + align-items: flex-start; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.checkbox-item input { + margin-top: 0.25rem; +} + +.checkbox-label { + font-size: 0.875rem; + line-height: 1.5; +} + +.checkbox-label a { + color: var(--bp-primary); +} + +.btn { + width: 100%; + padding: 1rem; + border: none; + border-radius: 10px; + font-weight: 600; + font-size: 1rem; + cursor: pointer; + transition: all 0.2s; +} + +.btn-primary { + background: var(--bp-primary); + color: white; +} + +.btn-primary:hover { + background: #8B2323; +} + +.btn-primary:disabled { + background: var(--bp-border); + cursor: not-allowed; +} + +.error { + background: rgba(239, 68, 68, 0.1); + color: #dc2626; + padding: 1rem; + border-radius: 8px; + margin-bottom: 1rem; + text-align: center; +} + +.success { + background: rgba(90, 191, 96, 0.1); + color: var(--bp-accent); + padding: 1rem; + border-radius: 8px; + margin-bottom: 1rem; + text-align: center; +} + +#loading { + text-align: center; + padding: 2rem; +} + +.spinner { + width: 40px; + height: 40px; + border: 3px solid var(--bp-border); + border-top-color: var(--bp-primary); + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 1rem; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} +""" diff --git a/backend/frontend/school/templates.py b/backend/frontend/school/templates.py new file mode 100644 index 0000000..f6e94b3 --- /dev/null +++ b/backend/frontend/school/templates.py @@ -0,0 +1,186 @@ +""" +School Module - Template Helpers +Reusable components for school pages +""" + +# SVG Icons +ICONS = { + 'dashboard': ''' + + ''', + 'messages': ''' + + ''', + 'calendar': ''' + + ''', + 'attendance': ''' + + ''', + 'grades': ''' + + ''', + 'book': ''' + + ''', + 'users': ''' + + ''', + 'parents': ''' + + ''', + 'check_circle': ''' + + ''', + 'warning': ''' + + ''', + 'mail': ''' + + ''', + 'edit': ''' + + ''', + 'qr': ''' + + ''', + 'check': ''' + + ''', + 'plus': ''' + + ''', + 'download': ''' + + ''', + 'print': ''' + + ''', + 'chevron_left': ''' + + ''', + 'chevron_right': ''' + + ''', +} + + +def render_sidebar(active_page: str = "dashboard") -> str: + """Render the navigation sidebar""" + def nav_item(href: str, icon_key: str, label: str, page_id: str) -> str: + active_class = "active" if active_page == page_id else "" + return f''' + + {ICONS[icon_key]} + {label} + ''' + + return f''' + ''' + + +def render_base_page(title: str, styles: str, content: str, scripts: str = "", + active_page: str = "dashboard", include_sidebar: bool = True) -> str: + """Render a complete HTML page with the BreakPilot school design""" + sidebar_html = render_sidebar(active_page) if include_sidebar else "" + + return f''' + + + + BreakPilot – {title} + + + + + +
    + {sidebar_html} + {content} +
    + {scripts} + +''' + + +# Toast and utility scripts +COMMON_SCRIPTS = """ + +""" diff --git a/backend/frontend/school_styles.py b/backend/frontend/school_styles.py new file mode 100644 index 0000000..2e5174a --- /dev/null +++ b/backend/frontend/school_styles.py @@ -0,0 +1,268 @@ +""" +School Management Module - Shared CSS Styles. + +Enthält die gemeinsamen CSS-Stile für alle Schulverwaltungsseiten. +""" + + +def get_school_base_styles() -> str: + """Base CSS styles for all school pages.""" + return """ +:root { + --bp-primary: #6C1B1B; + --bp-primary-soft: rgba(108, 27, 27, 0.1); + --bp-bg: #F8F8F8; + --bp-surface: #FFFFFF; + --bp-surface-elevated: #FFFFFF; + --bp-border: #E0E0E0; + --bp-border-subtle: rgba(108, 27, 27, 0.15); + --bp-accent: #5ABF60; + --bp-accent-soft: rgba(90, 191, 96, 0.15); + --bp-text: #4A4A4A; + --bp-text-muted: #6B6B6B; + --bp-danger: #ef4444; + --bp-warning: #F1C40F; + --bp-info: #3b82f6; + --bp-gold: #F1C40F; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: 'Manrope', system-ui, -apple-system, sans-serif; + background: var(--bp-bg); + color: var(--bp-text); + min-height: 100vh; +} + +/* Layout */ +.app-container { + display: flex; + min-height: 100vh; +} + +/* Sidebar */ +.sidebar { + width: 260px; + background: var(--bp-surface); + border-right: 1px solid var(--bp-border); + padding: 1.5rem; + display: flex; + flex-direction: column; + flex-shrink: 0; +} + +.logo { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 2rem; +} + +.logo-icon { + width: 36px; + height: 36px; + background: var(--bp-primary); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 700; +} + +.logo-text { + font-size: 1.25rem; + font-weight: 700; + color: var(--bp-primary); +} + +.nav-section { + margin-bottom: 1.5rem; +} + +.nav-section-title { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + color: var(--bp-text-muted); + margin-bottom: 0.75rem; + padding-left: 0.75rem; +} + +.nav-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + border-radius: 8px; + color: var(--bp-text); + text-decoration: none; + transition: all 0.2s ease; + margin-bottom: 0.25rem; +} + +.nav-item:hover { + background: var(--bp-primary-soft); + color: var(--bp-primary); +} + +.nav-item.active { + background: var(--bp-primary); + color: white; +} + +.nav-icon { + width: 20px; + height: 20px; + flex-shrink: 0; +} + +/* Main Content */ +.main-content { + flex: 1; + padding: 2rem; + overflow-y: auto; +} + +.page-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 2rem; +} + +.page-title { + font-size: 1.75rem; + font-weight: 700; + color: var(--bp-text); +} + +.page-subtitle { + color: var(--bp-text-muted); + margin-top: 0.25rem; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + border-radius: 8px; + font-weight: 500; + font-size: 0.875rem; + cursor: pointer; + border: none; + transition: all 0.2s ease; +} + +.btn-primary { + background: var(--bp-primary); + color: white; +} + +.btn-primary:hover { + background: #5a1717; +} + +.btn-secondary { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + color: var(--bp-text); +} + +.btn-secondary:hover { + background: var(--bp-bg); +} + +/* Cards */ +.card { + background: var(--bp-surface); + border-radius: 12px; + border: 1px solid var(--bp-border); + padding: 1.5rem; +} + +.card-title { + font-weight: 600; + margin-bottom: 1rem; +} + +/* Grid */ +.grid { + display: grid; + gap: 1.5rem; +} + +.grid-2 { + grid-template-columns: repeat(2, 1fr); +} + +.grid-3 { + grid-template-columns: repeat(3, 1fr); +} + +.grid-4 { + grid-template-columns: repeat(4, 1fr); +} + +/* Tables */ +.table { + width: 100%; + border-collapse: collapse; +} + +.table th, +.table td { + padding: 0.75rem 1rem; + text-align: left; + border-bottom: 1px solid var(--bp-border); +} + +.table th { + background: var(--bp-bg); + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + color: var(--bp-text-muted); +} + +/* Stats */ +.stat-value { + font-size: 2rem; + font-weight: 700; + color: var(--bp-primary); +} + +.stat-label { + font-size: 0.875rem; + color: var(--bp-text-muted); +} + +/* Status indicators */ +.status { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; +} + +.status-success { + background: var(--bp-accent-soft); + color: #15803d; +} + +.status-warning { + background: rgba(241, 196, 15, 0.15); + color: #b45309; +} + +.status-danger { + background: rgba(239, 68, 68, 0.15); + color: #dc2626; +} +""" diff --git a/backend/frontend/school_templates.py b/backend/frontend/school_templates.py new file mode 100644 index 0000000..d01f2b1 --- /dev/null +++ b/backend/frontend/school_templates.py @@ -0,0 +1,109 @@ +""" +School Management Module - Page Templates. + +Enthält Template-Funktionen für Schulverwaltungsseiten. +""" + +from .school_styles import get_school_base_styles + + +SCHOOL_NAV_ITEMS = [ + {"id": "dashboard", "label": "Dashboard", "href": "/school"}, + {"id": "attendance", "label": "Anwesenheit", "href": "/school/attendance"}, + {"id": "grades", "label": "Noten", "href": "/school/grades"}, + {"id": "timetable", "label": "Stundenplan", "href": "/school/timetable"}, + {"id": "onboarding", "label": "Eltern", "href": "/school/onboarding"}, +] + +SCHOOL_ICONS = { + "home": '', + "users": '', + "calendar": '', + "chart": '', + "clock": '', + "external": '', +} + + +def render_school_sidebar(active_page: str = "dashboard") -> str: + """Render the school sidebar navigation.""" + icon_map = { + "dashboard": "home", + "attendance": "users", + "grades": "chart", + "timetable": "clock", + "onboarding": "calendar", + } + + nav_html = "" + for item in SCHOOL_NAV_ITEMS: + active_class = "active" if item["id"] == active_page else "" + icon = SCHOOL_ICONS.get(icon_map.get(item["id"], "home"), "") + nav_html += f''' + + {icon} + {item['label']} + + ''' + + return f''' + + ''' + + +def render_school_base_page( + title: str, + content: str, + active_page: str = "dashboard", + extra_styles: str = "", + extra_scripts: str = "" +) -> str: + """Render the base page template for school pages.""" + return f''' + + + + + BreakPilot – {title} + + + + + +
    + {render_school_sidebar(active_page)} +
    + {content} +
    +
    + + + + ''' diff --git a/backend/frontend/static/css/customer.css b/backend/frontend/static/css/customer.css new file mode 100644 index 0000000..450b27f --- /dev/null +++ b/backend/frontend/static/css/customer.css @@ -0,0 +1,815 @@ +/** + * BreakPilot Customer Portal - Slim CSS + * + * Light/Dark Theme with Sky Blue/Fuchsia accent colors + * Matching Website Design (Inter font) + */ + +/* CSS Variables - Light Mode (default) */ +:root { + /* Primary Colors */ + --bp-primary: #0ea5e9; + --bp-primary-hover: #0284c7; + --bp-primary-soft: rgba(14, 165, 233, 0.1); + + /* Accent Colors */ + --bp-accent: #d946ef; + --bp-accent-soft: rgba(217, 70, 239, 0.15); + + /* Background */ + --bp-bg: #f8fafc; + --bp-bg-elevated: #ffffff; + --bp-bg-card: #ffffff; + + /* Text */ + --bp-text: #0f172a; + --bp-text-muted: #64748b; + --bp-text-light: #94a3b8; + + /* Borders */ + --bp-border: #e2e8f0; + --bp-border-light: #f1f5f9; + + /* Feedback */ + --bp-success: #10b981; + --bp-danger: #ef4444; + --bp-warning: #f59e0b; + + /* Shadows */ + --bp-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --bp-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --bp-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + + /* Spacing */ + --bp-radius: 8px; + --bp-radius-lg: 12px; + + /* Font */ + --bp-font: 'Inter', system-ui, -apple-system, sans-serif; +} + +/* Dark Mode */ +[data-theme="dark"] { + --bp-primary: #38bdf8; + --bp-primary-hover: #0ea5e9; + --bp-primary-soft: rgba(56, 189, 248, 0.1); + + --bp-accent: #e879f9; + --bp-accent-soft: rgba(232, 121, 249, 0.15); + + --bp-bg: #0f172a; + --bp-bg-elevated: #1e293b; + --bp-bg-card: #1e293b; + + --bp-text: #f1f5f9; + --bp-text-muted: #94a3b8; + --bp-text-light: #64748b; + + --bp-border: #334155; + --bp-border-light: #1e293b; + + --bp-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3); + --bp-shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4); + --bp-shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5); +} + +/* Reset */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* Base */ +html, body { + height: 100%; +} + +body { + font-family: var(--bp-font); + background: var(--bp-bg); + color: var(--bp-text); + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} + +/* App Root */ +.app-root { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* Header */ +.header { + background: var(--bp-bg-elevated); + border-bottom: 1px solid var(--bp-border); + position: sticky; + top: 0; + z-index: 100; +} + +.header-content { + max-width: 1200px; + margin: 0 auto; + padding: 16px 24px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.brand { + display: flex; + align-items: center; + gap: 12px; + text-decoration: none; + color: var(--bp-text); +} + +.brand-logo { + width: 40px; + height: 40px; + background: linear-gradient(135deg, var(--bp-primary), var(--bp-accent)); + border-radius: var(--bp-radius); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + color: white; + font-size: 14px; +} + +.brand-text { + display: flex; + flex-direction: column; +} + +.brand-name { + font-weight: 600; + font-size: 16px; +} + +.brand-sub { + font-size: 12px; + color: var(--bp-text-muted); +} + +.header-nav { + display: flex; + align-items: center; + gap: 8px; +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px 16px; + border-radius: var(--bp-radius); + font-size: 14px; + font-weight: 500; + cursor: pointer; + border: none; + transition: all 0.15s ease; + font-family: inherit; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-primary { + background: var(--bp-primary); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: var(--bp-primary-hover); +} + +.btn-ghost { + background: transparent; + color: var(--bp-text-muted); +} + +.btn-ghost:hover:not(:disabled) { + background: var(--bp-primary-soft); + color: var(--bp-primary); +} + +.btn-danger { + background: var(--bp-danger); + color: white; +} + +.btn-danger:hover:not(:disabled) { + background: #dc2626; +} + +.btn-lg { + padding: 12px 24px; + font-size: 16px; +} + +.btn-full { + width: 100%; +} + +/* User Menu */ +.user-menu { + position: relative; +} + +.user-menu-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: var(--bp-bg-elevated); + border: 1px solid var(--bp-border); + border-radius: var(--bp-radius); + cursor: pointer; + font-family: inherit; + color: var(--bp-text); +} + +.user-menu-btn:hover { + border-color: var(--bp-primary); +} + +.user-avatar { + width: 28px; + height: 28px; + background: linear-gradient(135deg, var(--bp-primary), var(--bp-accent)); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 600; + color: white; +} + +.user-name { + font-size: 14px; + font-weight: 500; +} + +.user-dropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + min-width: 200px; + background: var(--bp-bg-elevated); + border: 1px solid var(--bp-border); + border-radius: var(--bp-radius-lg); + box-shadow: var(--bp-shadow-lg); + display: none; + z-index: 200; +} + +.user-menu.open .user-dropdown { + display: block; +} + +.user-dropdown-header { + padding: 12px 16px; + border-bottom: 1px solid var(--bp-border); +} + +.user-dropdown-name { + display: block; + font-weight: 600; +} + +.user-dropdown-email { + display: block; + font-size: 12px; + color: var(--bp-text-muted); +} + +.user-dropdown hr { + border: none; + border-top: 1px solid var(--bp-border); + margin: 0; +} + +.user-dropdown-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 10px 16px; + background: none; + border: none; + font-size: 14px; + color: var(--bp-text); + cursor: pointer; + text-align: left; + font-family: inherit; +} + +.user-dropdown-item:hover { + background: var(--bp-primary-soft); +} + +.user-dropdown-item.danger { + color: var(--bp-danger); +} + +.user-dropdown-item.danger:hover { + background: rgba(239, 68, 68, 0.1); +} + +/* Main */ +.main { + flex: 1; + max-width: 1200px; + margin: 0 auto; + padding: 32px 24px; + width: 100%; +} + +/* Welcome Section */ +.welcome-section { + display: flex; + align-items: center; + justify-content: center; + min-height: 400px; +} + +.welcome-card { + text-align: center; + max-width: 400px; +} + +.welcome-card h1 { + font-size: 28px; + font-weight: 700; + margin-bottom: 16px; +} + +.welcome-card p { + color: var(--bp-text-muted); + margin-bottom: 24px; +} + +/* Dashboard */ +.dashboard-header { + margin-bottom: 32px; +} + +.dashboard-header h1 { + font-size: 28px; + font-weight: 700; +} + +.dashboard-header p { + color: var(--bp-text-muted); + margin-top: 8px; +} + +.dashboard-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 24px; +} + +.dashboard-card { + background: var(--bp-bg-card); + border: 1px solid var(--bp-border); + border-radius: var(--bp-radius-lg); + padding: 24px; + cursor: pointer; + transition: all 0.2s ease; +} + +.dashboard-card:hover { + border-color: var(--bp-primary); + box-shadow: var(--bp-shadow-md); + transform: translateY(-2px); +} + +.card-icon { + font-size: 32px; + margin-bottom: 16px; +} + +.dashboard-card h2 { + font-size: 18px; + font-weight: 600; + margin-bottom: 8px; +} + +.dashboard-card p { + font-size: 14px; + color: var(--bp-text-muted); + margin-bottom: 16px; +} + +.card-badge { + display: inline-block; + padding: 4px 12px; + background: var(--bp-primary-soft); + color: var(--bp-primary); + border-radius: 20px; + font-size: 12px; + font-weight: 500; +} + +/* Footer */ +.footer { + background: var(--bp-bg-elevated); + border-top: 1px solid var(--bp-border); + padding: 24px; +} + +.footer-content { + max-width: 1200px; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 14px; + color: var(--bp-text-muted); +} + +.footer-nav { + display: flex; + gap: 24px; +} + +.footer-nav a { + color: var(--bp-text-muted); + text-decoration: none; +} + +.footer-nav a:hover { + color: var(--bp-primary); +} + +/* Modal */ +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: none; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal.active { + display: flex; +} + +.modal-backdrop { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); +} + +.modal-content { + position: relative; + background: var(--bp-bg-elevated); + border-radius: var(--bp-radius-lg); + width: 100%; + max-width: 480px; + max-height: 90vh; + overflow-y: auto; + box-shadow: var(--bp-shadow-lg); + margin: 20px; +} + +.modal-lg { + max-width: 720px; +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 20px 24px; + border-bottom: 1px solid var(--bp-border); +} + +.modal-header h2 { + font-size: 18px; + font-weight: 600; +} + +.modal-close { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + font-size: 24px; + color: var(--bp-text-muted); + cursor: pointer; + border-radius: var(--bp-radius); +} + +.modal-close:hover { + background: var(--bp-primary-soft); + color: var(--bp-primary); +} + +.modal-body { + padding: 24px; +} + +/* Auth Tabs */ +.auth-tabs { + display: flex; + gap: 8px; + margin-bottom: 24px; +} + +.auth-tab { + flex: 1; + padding: 12px; + background: none; + border: none; + border-bottom: 2px solid transparent; + font-size: 14px; + font-weight: 500; + color: var(--bp-text-muted); + cursor: pointer; + font-family: inherit; +} + +.auth-tab:hover { + color: var(--bp-text); +} + +.auth-tab.active { + color: var(--bp-primary); + border-bottom-color: var(--bp-primary); +} + +/* Forms */ +.auth-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.form-group label { + font-size: 14px; + font-weight: 500; +} + +.form-group input { + padding: 10px 14px; + border: 1px solid var(--bp-border); + border-radius: var(--bp-radius); + font-size: 14px; + font-family: inherit; + background: var(--bp-bg); + color: var(--bp-text); +} + +.form-group input:focus { + outline: none; + border-color: var(--bp-primary); + box-shadow: 0 0 0 3px var(--bp-primary-soft); +} + +.form-group input:disabled { + background: var(--bp-border-light); + color: var(--bp-text-muted); +} + +.form-hint { + text-align: center; + font-size: 14px; + color: var(--bp-text-muted); +} + +.form-hint a { + color: var(--bp-primary); + text-decoration: none; +} + +.form-hint a:hover { + text-decoration: underline; +} + +.form-message { + padding: 12px; + border-radius: var(--bp-radius); + font-size: 14px; + display: none; +} + +.form-message.success { + display: block; + background: rgba(16, 185, 129, 0.1); + color: var(--bp-success); +} + +.form-message.error { + display: block; + background: rgba(239, 68, 68, 0.1); + color: var(--bp-danger); +} + +/* Consents List */ +.consents-loading { + text-align: center; + padding: 40px; + color: var(--bp-text-muted); +} + +.consents-list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.consent-item { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 16px; + background: var(--bp-bg); + border: 1px solid var(--bp-border); + border-radius: var(--bp-radius); +} + +.consent-info h3 { + font-size: 16px; + font-weight: 600; + margin-bottom: 4px; +} + +.consent-info p { + font-size: 13px; + color: var(--bp-text-muted); +} + +.consent-date { + font-size: 12px; + color: var(--bp-text-light); +} + +.consent-actions { + display: flex; + gap: 8px; +} + +.consents-empty { + text-align: center; + padding: 40px; + color: var(--bp-text-muted); +} + +/* Export */ +.export-info { + background: var(--bp-primary-soft); + padding: 16px; + border-radius: var(--bp-radius); + margin-bottom: 24px; +} + +.export-info p { + font-size: 14px; + margin-bottom: 8px; +} + +.export-info p:last-child { + margin-bottom: 0; +} + +.export-actions { + margin-top: 16px; +} + +.export-status { + margin-top: 24px; + padding-top: 24px; + border-top: 1px solid var(--bp-border); +} + +.export-status h3 { + font-size: 16px; + font-weight: 600; + margin-bottom: 16px; +} + +/* Legal */ +.legal-loading { + text-align: center; + padding: 40px; + color: var(--bp-text-muted); +} + +.legal-content { + font-size: 14px; + line-height: 1.7; +} + +.legal-content h1, .legal-content h2, .legal-content h3 { + margin-top: 24px; + margin-bottom: 12px; +} + +.legal-content p { + margin-bottom: 12px; +} + +.legal-content ul, .legal-content ol { + margin-bottom: 12px; + padding-left: 24px; +} + +/* Settings */ +.settings-section { + margin-bottom: 24px; +} + +.settings-section h3 { + font-size: 16px; + font-weight: 600; + margin-bottom: 16px; +} + +.settings-section form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.settings-section hr { + border: none; + border-top: 1px solid var(--bp-border); + margin: 24px 0; +} + +.danger-zone { + padding: 16px; + background: rgba(239, 68, 68, 0.05); + border: 1px solid rgba(239, 68, 68, 0.2); + border-radius: var(--bp-radius); +} + +.danger-zone h3 { + color: var(--bp-danger); +} + +.danger-zone p { + font-size: 14px; + color: var(--bp-text-muted); + margin-bottom: 16px; +} + +/* Utilities */ +.hidden { + display: none !important; +} + +/* Responsive */ +@media (max-width: 768px) { + .header-content { + padding: 12px 16px; + } + + .brand-text { + display: none; + } + + .user-name { + display: none; + } + + .main { + padding: 24px 16px; + } + + .dashboard-grid { + grid-template-columns: 1fr; + } + + .footer-content { + flex-direction: column; + gap: 16px; + text-align: center; + } +} diff --git a/backend/frontend/static/css/modules/admin/content.css b/backend/frontend/static/css/modules/admin/content.css new file mode 100644 index 0000000..9d09f8c --- /dev/null +++ b/backend/frontend/static/css/modules/admin/content.css @@ -0,0 +1,347 @@ +/* ========================================== + ADMIN PANEL - Content Area + Main content, buttons, cards, status bar + ========================================== */ + +.content { + padding: 14px 16px 16px 16px; + display: flex; + flex-direction: column; + gap: 14px; + overflow-y: auto; + overflow-x: hidden; + min-height: 0; +} + +/* Buttons */ +.btn { + font-size: 13px; + font-weight: 500; + padding: 8px 16px; + border-radius: 8px; + border: 1px solid var(--bp-border-subtle); + background: var(--bp-surface-elevated); + color: var(--bp-text); + cursor: pointer; + transition: all 0.2s ease; +} + +.btn:hover:not(:disabled) { + border-color: var(--bp-primary); + transform: translateY(-1px); +} + +.btn-primary { + border-color: var(--bp-accent); + background: var(--bp-btn-primary-bg); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background: var(--bp-btn-primary-hover); + box-shadow: 0 4px 12px rgba(34,197,94,0.3); +} + +.btn-ghost { + background: transparent; +} + +.btn-ghost:hover:not(:disabled) { + background: var(--bp-surface-elevated); +} + +.btn-sm { + padding: 6px 12px; + font-size: 12px; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Cards Grid */ +.cards-grid { + flex: 1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-auto-rows: 1fr; + gap: 10px; + min-height: 0; + align-items: stretch; +} + +.card { + border-radius: 14px; + border: 1px solid var(--bp-border-subtle); + background: var(--bp-card-bg); + padding: 10px; + display: flex; + flex-direction: column; + overflow: hidden; + transition: all 0.3s ease; +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; +} + +.card-title { + font-size: 13px; + font-weight: 600; +} + +.card-actions { + display: flex; + gap: 4px; +} + +/* Toggle Pills */ +.panel-tools-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; +} + +.card-toggle-bar { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; +} + +.toggle-pill { + font-size: 11px; + padding: 4px 8px; + border-radius: 999px; + border: 1px solid var(--bp-border-subtle); + background: var(--bp-surface-elevated); + color: var(--bp-text-muted); + cursor: pointer; + transition: all 0.2s ease; +} + +.toggle-pill.active { + border-color: var(--bp-accent); + color: var(--bp-accent); + background: var(--bp-accent-soft); +} + +/* Status Bar */ +.status-bar { + position: fixed; + right: 18px; + bottom: 18px; + padding: 8px 12px; + border-radius: 999px; + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border-subtle); + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + min-width: 230px; + transition: all 0.3s ease; +} + +.status-dot { + width: 10px; + height: 10px; + border-radius: 999px; + background: var(--bp-text-muted); +} + +.status-dot.busy { + background: var(--bp-accent); +} + +.status-dot.error { + background: var(--bp-danger); +} + +.status-text-main { + font-size: 12px; +} + +.status-text-sub { + font-size: 11px; + color: var(--bp-text-muted); +} + +/* Footer */ +.footer { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + font-size: 11px; + color: var(--bp-text-muted); + border-top: 1px solid var(--bp-border-subtle); + background: var(--bp-surface-elevated); + transition: background 0.3s ease; +} + +.footer a { + color: var(--bp-text-muted); + text-decoration: none; +} + +.footer a:hover { + text-decoration: underline; +} + +/* Pager */ +.pager { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 2px 0; + font-size: 11px; +} + +.pager button { + width: 24px; + height: 24px; + border-radius: 999px; + border: 1px solid var(--bp-border-subtle); + background: var(--bp-surface-elevated); + color: var(--bp-text); + cursor: pointer; + transition: all 0.2s ease; +} + +.pager button:hover { + border-color: var(--bp-primary); + color: var(--bp-primary); +} + +/* Unit Items */ +.unit-item { + padding: 8px 10px; + border-radius: 6px; + cursor: pointer; + font-size: 12px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + transition: all 0.15s ease; + margin-bottom: 4px; +} + +.unit-item:hover { + background: var(--bp-surface-elevated); +} + +.unit-item.active { + background: var(--bp-surface-elevated); + color: var(--bp-accent); + border: 1px solid var(--bp-accent); + font-weight: 600; +} + +.unit-item-meta { + font-size: 10px; + color: var(--bp-text-muted); + margin-top: 2px; +} + +.btn-unit-add { + align-self: flex-end; +} + +/* Language Selector */ +.language-selector { + margin-right: 8px; +} + +.lang-select { + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border-subtle); + border-radius: 6px; + color: var(--bp-text); + padding: 6px 10px; + font-size: 12px; + cursor: pointer; + transition: all 0.2s ease; +} + +.lang-select:hover { + border-color: var(--bp-primary); +} + +.lang-select:focus { + outline: none; + border-color: var(--bp-primary); +} + +/* Light Mode */ +[data-theme="light"] .btn-primary:hover:not(:disabled) { + box-shadow: 0 4px 12px rgba(14, 165, 233, 0.3); +} + +[data-theme="light"] .btn-ghost { + border-color: var(--bp-primary); + color: var(--bp-primary); +} + +[data-theme="light"] .btn-ghost:hover:not(:disabled) { + background: var(--bp-primary-soft); +} + +[data-theme="light"] .toggle-pill.active { + border-color: var(--bp-primary); + color: var(--bp-primary); + background: var(--bp-primary-soft); +} + +[data-theme="light"] .status-bar { + background: var(--bp-surface); + border: 2px solid var(--bp-primary); + box-shadow: 0 4px 16px rgba(14, 165, 233, 0.15); +} + +[data-theme="light"] .pager button { + border: 2px solid var(--bp-primary); + background: var(--bp-surface); + color: var(--bp-primary); + font-weight: 700; +} + +[data-theme="light"] .pager button:hover { + background: var(--bp-primary); + color: white; +} + +[data-theme="light"] .unit-item:hover { + background: var(--bp-primary-soft); +} + +[data-theme="light"] .unit-item.active { + background: var(--bp-primary-soft); + color: var(--bp-primary); + border: 2px solid var(--bp-primary); +} + +[data-theme="light"] .lang-select { + background: var(--bp-surface); + border: 1px solid var(--bp-primary); + color: var(--bp-text); +} + +[data-theme="light"] .lang-select option { + background: var(--bp-surface); + color: var(--bp-text); +} + +/* RTL Support */ +[dir="rtl"] .card-actions { + flex-direction: row-reverse; +} diff --git a/backend/frontend/static/css/modules/admin/dsms.css b/backend/frontend/static/css/modules/admin/dsms.css new file mode 100644 index 0000000..0a73dd2 --- /dev/null +++ b/backend/frontend/static/css/modules/admin/dsms.css @@ -0,0 +1,183 @@ +/* ========================================== + ADMIN PANEL - DSMS Styles + Decentralized Storage Management System + ========================================== */ + +.dsms-subtab { + padding: 6px 12px; + border: none; + background: transparent; + color: var(--bp-text-muted); + cursor: pointer; + border-radius: 6px; + font-size: 12px; + transition: all 0.2s ease; +} + +.dsms-subtab:hover { + background: var(--bp-border-subtle); + color: var(--bp-text); +} + +.dsms-subtab.active { + background: var(--bp-primary); + color: white; +} + +.dsms-content { + display: none; +} + +.dsms-content.active { + display: block; +} + +.dsms-status-card { + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border); + border-radius: 8px; + padding: 16px; +} + +.dsms-status-card h4 { + margin: 0 0 8px 0; + font-size: 12px; + color: var(--bp-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.dsms-status-card .value { + font-size: 24px; + font-weight: 600; + color: var(--bp-text); +} + +.dsms-status-card .value.online { + color: var(--bp-accent); +} + +.dsms-status-card .value.offline { + color: var(--bp-danger); +} + +.dsms-verify-success { + background: var(--bp-accent-soft); + border: 1px solid var(--bp-accent); + border-radius: 8px; + padding: 16px; + color: var(--bp-accent); +} + +.dsms-verify-error { + background: rgba(239, 68, 68, 0.1); + border: 1px solid var(--bp-danger); + border-radius: 8px; + padding: 16px; + color: var(--bp-danger); +} + +/* DSMS WebUI Styles */ +.dsms-webui-nav { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: none; + background: transparent; + color: var(--bp-text-muted); + font-size: 14px; + border-radius: 6px; + cursor: pointer; + text-align: left; + width: 100%; + transition: all 0.2s; +} + +.dsms-webui-nav:hover { + background: var(--bp-surface-elevated); + color: var(--bp-text); +} + +.dsms-webui-nav.active { + background: var(--bp-primary-soft); + color: var(--bp-primary); + font-weight: 500; +} + +.dsms-webui-section { + display: none; +} + +.dsms-webui-section.active { + display: block; +} + +.dsms-webui-stat-card { + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border); + border-radius: 8px; + padding: 16px; +} + +.dsms-webui-stat-label { + font-size: 12px; + color: var(--bp-text-muted); + margin-bottom: 4px; +} + +.dsms-webui-stat-value { + font-size: 18px; + font-weight: 600; + color: var(--bp-text); +} + +.dsms-webui-stat-sub { + font-size: 11px; + color: var(--bp-text-muted); + margin-top: 4px; +} + +.dsms-webui-upload-zone { + border: 2px dashed var(--bp-border); + border-radius: 12px; + padding: 48px 24px; + background: var(--bp-input-bg); + transition: all 0.2s; +} + +.dsms-webui-upload-zone.dragover { + border-color: var(--bp-primary); + background: var(--bp-primary-soft); +} + +.dsms-webui-file-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border); + border-radius: 8px; + margin-bottom: 8px; +} + +.dsms-webui-file-item .cid { + font-family: monospace; + font-size: 12px; + color: var(--bp-text-muted); + word-break: break-all; +} + +/* Light Mode */ +[data-theme="light"] .dsms-status-card { + background: var(--bp-bg); +} + +[data-theme="light"] .dsms-webui-stat-card { + background: var(--bp-bg); +} + +[data-theme="light"] .dsms-webui-file-item { + background: var(--bp-bg); +} diff --git a/backend/frontend/static/css/modules/admin/learning.css b/backend/frontend/static/css/modules/admin/learning.css new file mode 100644 index 0000000..00ffd6c --- /dev/null +++ b/backend/frontend/static/css/modules/admin/learning.css @@ -0,0 +1,323 @@ +/* ========================================== + ADMIN PANEL - Learning Module Styles + MC Questions, Cloze/Lückentext + ========================================== */ + +/* MC Preview Styles */ +.mc-preview { + margin-top: 8px; + max-height: 300px; + overflow-y: auto; +} + +.mc-question { + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border-subtle); + border-radius: 8px; + padding: 10px; + margin-bottom: 8px; + transition: all 0.3s ease; +} + +.mc-question-text { + font-size: 12px; + font-weight: 500; + margin-bottom: 8px; + color: var(--bp-text); +} + +.mc-options { + display: flex; + flex-direction: column; + gap: 4px; +} + +.mc-option { + font-size: 11px; + padding: 6px 10px; + border-radius: 6px; + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border-subtle); + cursor: pointer; + transition: all 0.15s ease; +} + +.mc-option:hover { + background: var(--bp-accent-soft); + border-color: var(--bp-accent); +} + +.mc-option.selected { + background: var(--bp-accent-soft); + border-color: var(--bp-accent); +} + +.mc-option.correct { + background: rgba(90, 191, 96, 0.2); + border-color: var(--bp-accent); +} + +.mc-option.incorrect { + background: rgba(108, 27, 27, 0.2); + border-color: rgba(239,68,68,0.6); +} + +.mc-option-label { + font-weight: 600; + margin-right: 6px; + text-transform: uppercase; +} + +.mc-feedback { + margin-top: 8px; + font-size: 11px; + padding: 6px 8px; + border-radius: 6px; + background: rgba(34,197,94,0.1); + border: 1px solid rgba(34,197,94,0.3); + color: var(--bp-accent); +} + +.mc-stats { + display: flex; + gap: 12px; + margin-top: 8px; + padding: 8px; + background: var(--bp-surface-elevated); + border-radius: 6px; + font-size: 11px; + transition: all 0.3s ease; +} + +.mc-stats-item { + display: flex; + align-items: center; + gap: 4px; +} + +/* MC Modal */ +.mc-modal { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 50; +} + +.mc-modal.hidden { + display: none; +} + +.mc-modal-content { + background: var(--bp-bg); + border: 1px solid var(--bp-border-subtle); + border-radius: 16px; + padding: 20px; + max-width: 600px; + width: 90%; + max-height: 80vh; + overflow-y: auto; +} + +.mc-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.mc-modal-title { + font-size: 18px; + font-weight: 600; +} + +.mc-modal-close { + font-size: 11px; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--bp-border-subtle); + background: var(--bp-surface-elevated); + color: var(--bp-text); + cursor: pointer; +} + +/* Cloze / Lückentext Styles */ +.cloze-preview { + margin-top: 8px; + max-height: 250px; + overflow-y: auto; +} + +.cloze-item { + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border-subtle); + border-radius: 8px; + padding: 10px; + margin-bottom: 8px; + transition: all 0.3s ease; +} + +.cloze-sentence { + font-size: 13px; + line-height: 1.8; + color: var(--bp-text); +} + +.cloze-gap { + display: inline-block; + min-width: 60px; + border-bottom: 2px solid var(--bp-accent); + margin: 0 2px; + padding: 2px 4px; + text-align: center; + background: var(--bp-accent-soft); + border-radius: 4px 4px 0 0; +} + +.cloze-gap-input { + width: 80px; + padding: 4px 8px; + font-size: 12px; + border: 1px solid var(--bp-border-subtle); + border-radius: 4px; + background: var(--bp-surface-elevated); + color: var(--bp-text); + text-align: center; +} + +.cloze-gap-input:focus { + outline: none; + border-color: var(--bp-primary); +} + +.cloze-gap-input.correct { + border-color: var(--bp-accent); + background: rgba(90, 191, 96, 0.2); +} + +.cloze-gap-input.incorrect { + border-color: var(--bp-danger); + background: rgba(108, 27, 27, 0.2); +} + +.cloze-translation { + margin-top: 8px; + padding: 8px; + background: var(--bp-accent-soft); + border: 1px solid var(--bp-accent); + border-radius: 6px; + font-size: 11px; + color: var(--bp-text-muted); +} + +.cloze-translation-label { + font-size: 10px; + color: var(--bp-text-muted); + margin-bottom: 4px; +} + +.cloze-hint { + font-size: 10px; + color: var(--bp-text-muted); + font-style: italic; + margin-top: 4px; +} + +.cloze-stats { + display: flex; + gap: 12px; + padding: 8px; + background: var(--bp-surface-elevated); + border-radius: 6px; + font-size: 11px; + margin-bottom: 8px; +} + +.cloze-feedback { + margin-top: 6px; + font-size: 11px; + padding: 4px 8px; + border-radius: 4px; +} + +.cloze-feedback.correct { + background: rgba(34,197,94,0.15); + color: var(--bp-accent); +} + +.cloze-feedback.incorrect { + background: rgba(239,68,68,0.15); + color: #ef4444; +} + +/* Light Mode */ +[data-theme="light"] .mc-question { + background: var(--bp-surface); + border: 1px solid var(--bp-primary); + box-shadow: 0 2px 8px rgba(14, 165, 233, 0.08); +} + +[data-theme="light"] .mc-option { + background: var(--bp-bg); + border: 1px solid var(--bp-border); +} + +[data-theme="light"] .mc-option:hover { + background: var(--bp-primary-soft); + border-color: var(--bp-primary); +} + +[data-theme="light"] .mc-option.selected { + background: var(--bp-primary-soft); + border-color: var(--bp-primary); +} + +[data-theme="light"] .mc-stats { + background: var(--bp-bg); + border: 1px solid var(--bp-border); +} + +[data-theme="light"] .mc-modal-content { + background: var(--bp-surface); + border: 2px solid var(--bp-primary); + box-shadow: 0 8px 32px rgba(14, 165, 233, 0.2); +} + +[data-theme="light"] .mc-modal-close { + border: 1px solid var(--bp-primary); + background: var(--bp-surface); + color: var(--bp-primary); +} + +[data-theme="light"] .mc-modal-close:hover { + background: var(--bp-primary); + color: white; +} + +[data-theme="light"] .cloze-item { + background: var(--bp-surface); + border: 1px solid var(--bp-primary); + box-shadow: 0 2px 8px rgba(14, 165, 233, 0.08); +} + +[data-theme="light"] .cloze-gap { + border-bottom-color: var(--bp-primary); + background: var(--bp-primary-soft); +} + +[data-theme="light"] .cloze-gap-input { + background: var(--bp-surface); + border: 1px solid var(--bp-border); +} + +[data-theme="light"] .cloze-translation { + background: var(--bp-primary-soft); + border: 1px solid var(--bp-primary); +} + +[data-theme="light"] .cloze-stats { + background: var(--bp-bg); + border: 1px solid var(--bp-border); +} diff --git a/backend/frontend/static/css/modules/admin/modal.css b/backend/frontend/static/css/modules/admin/modal.css new file mode 100644 index 0000000..6f38d28 --- /dev/null +++ b/backend/frontend/static/css/modules/admin/modal.css @@ -0,0 +1,132 @@ +/* ========================================== + ADMIN PANEL - Modal & Tabs + Basic modal structure and navigation + ========================================== */ + +.admin-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + z-index: 10000; + justify-content: center; + align-items: center; + backdrop-filter: blur(4px); +} + +.admin-modal.active { + display: flex; +} + +.admin-modal-content { + background: var(--bp-surface); + border-radius: 16px; + width: 95%; + max-width: 1000px; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0,0,0,0.4); + border: 1px solid var(--bp-border); +} + +.admin-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid var(--bp-border); +} + +.admin-modal-header h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + display: flex; + align-items: center; + gap: 10px; +} + +.admin-tabs { + display: flex; + gap: 4px; + padding: 12px 24px; + border-bottom: 1px solid var(--bp-border); + background: var(--bp-surface-elevated); +} + +.admin-tab { + padding: 8px 16px; + border: none; + background: transparent; + color: var(--bp-text-muted); + cursor: pointer; + border-radius: 8px; + font-size: 13px; + transition: all 0.2s ease; +} + +.admin-tab:hover { + background: var(--bp-border-subtle); + color: var(--bp-text); +} + +.admin-tab.active { + background: var(--bp-primary); + color: white; +} + +.admin-body { + padding: 24px; + overflow-y: auto; + flex: 1; +} + +.admin-content { + display: none; +} + +.admin-content.active { + display: block; +} + +.admin-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + gap: 12px; +} + +.admin-toolbar-left { + display: flex; + gap: 12px; + align-items: center; +} + +.admin-search { + padding: 8px 12px; + border: 1px solid var(--bp-border); + border-radius: 8px; + background: var(--bp-surface-elevated); + color: var(--bp-text); + width: 250px; +} + +/* Light Mode */ +[data-theme="light"] .admin-modal-content { + background: var(--bp-surface); + border-color: var(--bp-border); +} + +[data-theme="light"] .admin-tabs { + background: var(--bp-bg); +} + +[data-theme="light"] .admin-tab.active { + background: var(--bp-primary); + color: white; +} diff --git a/backend/frontend/static/css/modules/admin/preview.css b/backend/frontend/static/css/modules/admin/preview.css new file mode 100644 index 0000000..616c093 --- /dev/null +++ b/backend/frontend/static/css/modules/admin/preview.css @@ -0,0 +1,202 @@ +/* ========================================== + ADMIN PANEL - Preview & Compare + Image preview, document comparison + ========================================== */ + +.compare-header { + background: var(--bp-surface-elevated); + padding: 6px 10px; + font-size: 12px; + border-bottom: 1px solid var(--bp-border-subtle); + display: flex; + justify-content: space-between; + align-items: center; + gap: 6px; +} + +.compare-header span { + color: var(--bp-text-muted); +} + +.compare-body { + flex: 1; + min-height: 0; + position: relative; + display: flex; + align-items: center; + justify-content: center; + padding: 6px; +} + +.compare-body-inner { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.preview-img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + box-shadow: 0 18px 40px rgba(0,0,0,0.5); + border-radius: 10px; +} + +.clean-frame { + width: 100%; + height: 100%; + border: none; + border-radius: 10px; + background: white; +} + +/* Preview Navigation */ +.preview-nav { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: space-between; + pointer-events: none; + padding: 0 4px; +} + +.preview-nav button { + pointer-events: auto; + width: 28px; + height: 28px; + border-radius: 999px; + border: 1px solid var(--bp-border-subtle); + background: var(--bp-surface-elevated); + color: var(--bp-text); + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.preview-nav button:hover:not(:disabled) { + border-color: var(--bp-primary); + color: var(--bp-primary); +} + +.preview-nav button:disabled { + opacity: 0.35; + cursor: default; +} + +.preview-nav span { + position: absolute; + bottom: 6px; + left: 50%; + transform: translateX(-50%); + font-size: 11px; + padding: 2px 8px; + border-radius: 999px; + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border-subtle); +} + +/* Preview Thumbnails */ +.preview-thumbnails { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px 4px; + overflow-y: auto; + align-items: center; +} + +.preview-thumb { + min-width: 90px; + width: 90px; + height: 70px; + border-radius: 8px; + border: 2px solid rgba(148,163,184,0.25); + background: rgba(15,23,42,0.5); + cursor: pointer; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + position: relative; + flex-shrink: 0; +} + +.preview-thumb:hover { + border-color: var(--bp-accent); +} + +.preview-thumb.active { + border-color: var(--bp-accent); + border-width: 3px; +} + +.preview-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.preview-thumb-label { + position: absolute; + bottom: 0; + left: 0; + right: 0; + font-size: 9px; + padding: 2px; + background: rgba(0,0,0,0.8); + color: white; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Light Mode */ +[data-theme="light"] .compare-header { + background: var(--bp-primary-soft); + border-bottom: 1px solid var(--bp-primary); +} + +[data-theme="light"] .compare-header span { + color: var(--bp-primary); + font-weight: 600; +} + +[data-theme="light"] .preview-img { + box-shadow: 0 8px 24px rgba(14, 165, 233, 0.15); +} + +[data-theme="light"] .preview-nav button { + border: 2px solid var(--bp-primary); + background: var(--bp-surface); + color: var(--bp-primary); + font-weight: 700; +} + +[data-theme="light"] .preview-nav button:hover:not(:disabled) { + background: var(--bp-primary); + color: white; +} + +[data-theme="light"] .preview-nav span { + background: var(--bp-surface); + border: 1px solid var(--bp-primary); + color: var(--bp-primary); +} + +[data-theme="light"] .preview-thumb { + background: var(--bp-bg); + border-color: var(--bp-border); +} + +[data-theme="light"] .preview-thumb:hover, +[data-theme="light"] .preview-thumb.active { + border-color: var(--bp-primary); +} diff --git a/backend/frontend/static/css/modules/admin/sidebar.css b/backend/frontend/static/css/modules/admin/sidebar.css new file mode 100644 index 0000000..8c9047a --- /dev/null +++ b/backend/frontend/static/css/modules/admin/sidebar.css @@ -0,0 +1,267 @@ +/* ========================================== + ADMIN PANEL - Sidebar & Navigation + Main layout sidebar and menu items + ========================================== */ + +.main-layout { + display: grid; + grid-template-columns: 240px minmax(0, 1fr); + height: 100%; + min-height: 0; +} + +.sidebar { + border-right: 1px solid var(--bp-border-subtle); + background: var(--bp-gradient-sidebar); + padding: 14px 10px; + display: flex; + flex-direction: column; + gap: 18px; + min-width: 0; + height: 100%; + max-height: 100vh; + overflow-y: auto; + overflow-x: hidden; + transition: background 0.3s ease, border-color 0.3s ease; +} + +.sidebar-section-title { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--bp-text); + padding: 8px 6px 6px 6px; + margin-top: 12px; +} + +.sidebar-menu { + display: flex; + flex-direction: column; + gap: 4px; +} + +.sidebar-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + border-radius: 9px; + cursor: pointer; + font-size: 13px; + color: var(--bp-text-muted); +} + +.sidebar-item.active { + background: var(--bp-surface-elevated); + color: var(--bp-accent); + border: 1px solid var(--bp-accent-soft); +} + +.sidebar-item-label { + display: flex; + align-items: center; + gap: 7px; +} + +.sidebar-item-badge { + font-size: 10px; + border-radius: 999px; + padding: 2px 7px; + border: 1px solid var(--bp-border-subtle); +} + +/* Sub-Navigation */ +.sidebar-sub-menu { + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px 0 8px 20px; + margin-top: 4px; +} + +.sidebar-sub-item { + font-size: 12px; + color: var(--bp-text-muted); + padding: 6px 10px; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; + position: relative; +} + +.sidebar-sub-item::before { + content: ''; + position: absolute; + left: -12px; + top: 50%; + transform: translateY(-50%); + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--bp-border); +} + +.sidebar-sub-item:hover { + background: rgba(255, 255, 255, 0.05); + color: var(--bp-text); +} + +.sidebar-sub-item.active { + background: rgba(var(--bp-primary), 0.15); + color: var(--bp-primary); +} + +.sidebar-sub-item.active::before { + background: var(--bp-primary); + width: 6px; + height: 6px; +} + +.sidebar-footer { + margin-top: auto; + font-size: 11px; + color: var(--bp-text-muted); + padding: 0 6px; +} + +/* Template Items */ +.template-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + transition: all 0.15s ease; +} + +.template-item:hover { + background: rgba(255, 255, 255, 0.08); +} + +/* Letter Items */ +.letter-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 8px; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; + border-bottom: 1px solid var(--bp-border); +} + +.letter-item:last-child { + border-bottom: none; +} + +.letter-item:hover { + background: rgba(255, 255, 255, 0.05); +} + +.letter-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.letter-title { + font-size: 13px; + font-weight: 500; +} + +.letter-date { + font-size: 11px; + color: var(--bp-text-muted); +} + +.letter-status { + font-size: 10px; + padding: 2px 6px; + border-radius: 4px; +} + +.letter-status.sent { + background: rgba(16, 185, 129, 0.2); + color: #10b981; +} + +.letter-status.draft { + background: rgba(245, 158, 11, 0.2); + color: #f59e0b; +} + +/* Meeting Items */ +.meeting-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.03); + cursor: pointer; +} + +.meeting-item:hover { + background: rgba(255, 255, 255, 0.08); +} + +.meeting-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.meeting-title { + font-size: 12px; + font-weight: 500; +} + +.meeting-time { + font-size: 11px; + color: var(--bp-text-muted); +} + +/* Light Mode */ +[data-theme="light"] .sidebar-item.active { + background: var(--bp-primary-soft); + color: var(--bp-primary); + border: 1px solid var(--bp-primary); +} + +[data-theme="light"] .sidebar-sub-item:hover { + background: rgba(0, 0, 0, 0.05); +} + +[data-theme="light"] .sidebar-sub-item.active { + background: rgba(14, 165, 233, 0.1); + color: #0ea5e9; +} + +[data-theme="light"] .sidebar-sub-item.active::before { + background: #0ea5e9; +} + +[data-theme="light"] .template-item:hover { + background: rgba(0, 0, 0, 0.05); +} + +[data-theme="light"] .meeting-item { + background: rgba(0, 0, 0, 0.02); +} + +[data-theme="light"] .meeting-item:hover { + background: rgba(0, 0, 0, 0.05); +} + +/* RTL Support */ +[dir="rtl"] .sidebar { + border-right: none; + border-left: 1px solid rgba(148,163,184,0.2); +} + +[dir="rtl"] .main-layout { + direction: rtl; +} diff --git a/backend/frontend/static/css/modules/admin/tables.css b/backend/frontend/static/css/modules/admin/tables.css new file mode 100644 index 0000000..0d57a62 --- /dev/null +++ b/backend/frontend/static/css/modules/admin/tables.css @@ -0,0 +1,115 @@ +/* ========================================== + ADMIN PANEL - Tables & Badges + Table styles and status indicators + ========================================== */ + +.admin-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.admin-table th, +.admin-table td { + padding: 12px; + text-align: left; + border-bottom: 1px solid var(--bp-border); +} + +.admin-table th { + background: var(--bp-surface-elevated); + font-weight: 600; + color: var(--bp-text); +} + +.admin-table tr:hover { + background: var(--bp-surface-elevated); +} + +.admin-table td { + color: var(--bp-text-muted); +} + +/* Status Badges */ +.admin-badge { + display: inline-block; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; +} + +.admin-badge-published { + background: rgba(74, 222, 128, 0.2); + color: #4ADE80; +} + +.admin-badge-draft { + background: rgba(251, 191, 36, 0.2); + color: #FBBF24; +} + +.admin-badge-archived { + background: rgba(156, 163, 175, 0.2); + color: #9CA3AF; +} + +.admin-badge-rejected { + background: rgba(239, 68, 68, 0.2); + color: #EF4444; +} + +.admin-badge-review { + background: rgba(147, 51, 234, 0.2); + color: #A855F7; +} + +.admin-badge-approved { + background: rgba(34, 197, 94, 0.2); + color: #22C55E; +} + +.admin-badge-submitted { + background: rgba(59, 130, 246, 0.2); + color: #3B82F6; +} + +.admin-badge-mandatory { + background: rgba(239, 68, 68, 0.2); + color: #EF4444; +} + +.admin-badge-optional { + background: rgba(156, 163, 175, 0.2); + color: #9CA3AF; +} + +.admin-badge-active { + background: rgba(34, 197, 94, 0.2); + color: #22C55E; +} + +.admin-badge-inactive { + background: rgba(156, 163, 175, 0.2); + color: #9CA3AF; +} + +.admin-badge-pending { + background: rgba(251, 191, 36, 0.2); + color: #FBBF24; +} + +.admin-badge-completed { + background: rgba(74, 222, 128, 0.2); + color: #4ADE80; +} + +/* Light Mode */ +[data-theme="light"] .admin-table th { + background: var(--bp-bg); +} + +[data-theme="light"] .admin-table tr:hover { + background: var(--bp-primary-soft); +} diff --git a/backend/frontend/static/css/modules/base/layout.css b/backend/frontend/static/css/modules/base/layout.css new file mode 100644 index 0000000..d692555 --- /dev/null +++ b/backend/frontend/static/css/modules/base/layout.css @@ -0,0 +1,163 @@ +/* ========================================== + Base Layout Styles + Reset, Scrollbar, App Structure, Topbar + ========================================== */ + +* { box-sizing: border-box; } + +/* Custom Scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bp-scrollbar-track); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb { + background: var(--bp-scrollbar-thumb); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--bp-scrollbar-thumb); + opacity: 0.8; +} + +html, body { + height: 100%; +} + +body { + margin: 0; + padding: 0; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif; + background: var(--bp-gradient-bg); + color: var(--bp-text); + min-height: 100vh; + display: flex; + flex-direction: column; + transition: background 0.3s ease, color 0.3s ease; +} + +.app-root { + display: grid; + grid-template-rows: 56px 1fr 32px; + flex: 1; + min-height: 0; +} + +/* ========================================== + Topbar Styles + ========================================== */ +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + border-bottom: 1px solid var(--bp-border-subtle); + backdrop-filter: blur(18px); + background: var(--bp-gradient-topbar); + transition: background 0.3s ease, border-color 0.3s ease; +} + +.brand { + display: flex; + align-items: center; + gap: 10px; +} + +.brand-logo { + width: 28px; + height: 28px; + border-radius: 999px; + border: 2px solid var(--bp-accent); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 14px; + color: var(--bp-accent); +} + +.brand-text-main { + font-weight: 600; + font-size: 16px; +} + +.brand-text-sub { + font-size: 12px; + color: var(--bp-text-muted); +} + +/* ========================================== + Top Navigation + ========================================== */ +.top-nav { + display: flex; + align-items: center; + gap: 18px; + font-size: 13px; +} + +.top-nav-item { + cursor: pointer; + padding: 4px 10px; + border-radius: 999px; + color: var(--bp-text-muted); + border: 1px solid transparent; +} + +.top-nav-item.active { + color: var(--bp-primary); + border-color: var(--bp-accent-soft); + background: var(--bp-surface-elevated); +} + +[data-theme="light"] .top-nav-item.active { + color: var(--bp-primary); + background: var(--bp-primary-soft); + border-color: var(--bp-primary); +} + +.top-actions { + display: flex; + align-items: center; + gap: 10px; +} + +.pill { + font-size: 11px; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--bp-border-subtle); + color: var(--bp-text-muted); +} + +/* ========================================== + Theme Toggle Button + ========================================== */ +.theme-toggle { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--bp-border-subtle); + background: var(--bp-surface-elevated); + color: var(--bp-text-muted); + cursor: pointer; + font-size: 11px; + transition: all 0.2s ease; +} + +.theme-toggle:hover { + border-color: var(--bp-primary); + color: var(--bp-primary); +} + +.theme-toggle-icon { + font-size: 14px; +} diff --git a/backend/frontend/static/css/modules/base/variables.css b/backend/frontend/static/css/modules/base/variables.css new file mode 100644 index 0000000..4fc68a8 --- /dev/null +++ b/backend/frontend/static/css/modules/base/variables.css @@ -0,0 +1,69 @@ +/* ========================================== + CSS Custom Properties (Variables) + Dark Mode (Default) + Light Mode + ========================================== */ + +:root { + --bp-primary: #0f766e; + --bp-primary-soft: #ccfbf1; + --bp-bg: #020617; + --bp-surface: #020617; + --bp-surface-elevated: rgba(15,23,42,0.9); + --bp-border: #1f2937; + --bp-border-subtle: rgba(148,163,184,0.25); + --bp-accent: #22c55e; + --bp-accent-soft: rgba(34,197,94,0.2); + --bp-text: #e5e7eb; + --bp-text-muted: #9ca3af; + --bp-danger: #ef4444; + --bp-gradient-bg: radial-gradient(circle at top left, #1f2937 0, #020617 50%, #000 100%); + --bp-gradient-surface: radial-gradient(circle at top left, rgba(15,23,42,0.9) 0, #020617 50%, #000 100%); + --bp-gradient-sidebar: radial-gradient(circle at top, #020617 0, #020617 40%, #000 100%); + --bp-gradient-topbar: linear-gradient(to right, rgba(15,23,42,0.9), rgba(15,23,42,0.6)); + --bp-btn-primary-bg: linear-gradient(to right, var(--bp-primary), #15803d); + --bp-btn-primary-hover: linear-gradient(to right, #0f766e, #166534); + --bp-card-bg: var(--bp-gradient-surface); + --bp-input-bg: rgba(255,255,255,0.05); + --bp-scrollbar-track: rgba(15,23,42,0.5); + --bp-scrollbar-thumb: rgba(148,163,184,0.5); +} + +/* ========================================== + LIGHT MODE - Website Design + Farben aus BreakPilot Website: + - Primary: Sky Blue #0ea5e9 (Modern, Vertrauen) + - Accent: Fuchsia #d946ef (Kreativ, Energie) + - Text: Slate #0f172a (Klarheit) + - Background: White/Slate-50 (Clean, Professional) + ========================================== */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); + +[data-theme="light"] { + --bp-primary: #0ea5e9; + --bp-primary-soft: rgba(14, 165, 233, 0.1); + --bp-bg: #f8fafc; + --bp-surface: #FFFFFF; + --bp-surface-elevated: #FFFFFF; + --bp-border: #e2e8f0; + --bp-border-subtle: rgba(14, 165, 233, 0.2); + --bp-accent: #d946ef; + --bp-accent-soft: rgba(217, 70, 239, 0.15); + --bp-text: #0f172a; + --bp-text-muted: #64748b; + --bp-danger: #ef4444; + --bp-gradient-bg: linear-gradient(145deg, #f8fafc 0%, #f1f5f9 100%); + --bp-gradient-surface: linear-gradient(145deg, #FFFFFF 0%, #f8fafc 100%); + --bp-gradient-sidebar: linear-gradient(180deg, #FFFFFF 0%, #f1f5f9 100%); + --bp-gradient-topbar: linear-gradient(to right, #FFFFFF, #f8fafc); + --bp-btn-primary-bg: linear-gradient(135deg, #0ea5e9 0%, #d946ef 100%); + --bp-btn-primary-hover: linear-gradient(135deg, #0284c7 0%, #c026d3 100%); + --bp-card-bg: linear-gradient(145deg, #FFFFFF 0%, #f8fafc 100%); + --bp-input-bg: #FFFFFF; + --bp-scrollbar-track: rgba(0,0,0,0.05); + --bp-scrollbar-thumb: rgba(14, 165, 233, 0.3); + --bp-gold: #eab308; +} + +[data-theme="light"] body { + font-family: 'Inter', system-ui, -apple-system, sans-serif; +} diff --git a/backend/frontend/static/css/modules/components/auth-modal.css b/backend/frontend/static/css/modules/components/auth-modal.css new file mode 100644 index 0000000..e8b2f54 --- /dev/null +++ b/backend/frontend/static/css/modules/components/auth-modal.css @@ -0,0 +1,261 @@ +/* ========================================== + AUTH MODAL STYLES + Login, Register, Password Reset + ========================================== */ + +.auth-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + z-index: 10000; + justify-content: center; + align-items: center; + backdrop-filter: blur(4px); +} + +.auth-modal.active { + display: flex; +} + +.auth-modal-content { + background: var(--bp-surface); + border-radius: 16px; + width: 90%; + max-width: 420px; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0,0,0,0.4); + border: 1px solid var(--bp-border); +} + +.auth-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid var(--bp-border); +} + +.auth-modal-header h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + display: flex; + align-items: center; + gap: 10px; +} + +.auth-modal-close { + background: none; + border: none; + font-size: 28px; + cursor: pointer; + color: var(--bp-text-muted); + padding: 0; + line-height: 1; +} + +.auth-modal-close:hover { + color: var(--bp-text); +} + +.auth-body { + padding: 24px; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 16px; +} + +.auth-form-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.auth-form-group label { + font-size: 13px; + color: var(--bp-text); + font-weight: 500; +} + +.auth-form-group input { + padding: 12px 14px; + border: 1px solid var(--bp-border); + border-radius: 8px; + background: var(--bp-surface-elevated); + color: var(--bp-text); + font-size: 14px; + transition: border-color 0.2s ease; +} + +.auth-form-group input:focus { + outline: none; + border-color: var(--bp-primary); +} + +.auth-form-group input::placeholder { + color: var(--bp-text-muted); +} + +.auth-submit-btn { + padding: 12px 20px; + background: var(--bp-btn-primary-bg); + border: none; + border-radius: 8px; + color: white; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + margin-top: 8px; +} + +.auth-submit-btn:hover { + background: var(--bp-btn-primary-hover); +} + +.auth-submit-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.auth-divider { + display: flex; + align-items: center; + gap: 12px; + margin: 8px 0; + color: var(--bp-text-muted); + font-size: 12px; +} + +.auth-divider::before, +.auth-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--bp-border); +} + +.auth-social-btns { + display: flex; + flex-direction: column; + gap: 10px; +} + +.auth-social-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 12px; + border: 1px solid var(--bp-border); + border-radius: 8px; + background: var(--bp-surface-elevated); + color: var(--bp-text); + font-size: 14px; + cursor: pointer; + transition: all 0.2s ease; +} + +.auth-social-btn:hover { + border-color: var(--bp-primary); + background: var(--bp-surface); +} + +.auth-social-btn img { + width: 20px; + height: 20px; +} + +.auth-footer { + padding: 16px 24px; + border-top: 1px solid var(--bp-border); + text-align: center; + font-size: 13px; + color: var(--bp-text-muted); +} + +.auth-footer a { + color: var(--bp-primary); + text-decoration: none; +} + +.auth-footer a:hover { + text-decoration: underline; +} + +.auth-error { + padding: 12px; + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 8px; + color: var(--bp-danger); + font-size: 13px; + display: none; +} + +.auth-error.active { + display: block; +} + +.auth-success { + padding: 12px; + background: rgba(34, 197, 94, 0.1); + border: 1px solid rgba(34, 197, 94, 0.3); + border-radius: 8px; + color: var(--bp-accent); + font-size: 13px; + display: none; +} + +.auth-success.active { + display: block; +} + +/* Password strength indicator */ +.password-strength { + display: flex; + gap: 4px; + margin-top: 8px; +} + +.password-strength-bar { + flex: 1; + height: 4px; + background: var(--bp-border); + border-radius: 2px; + transition: background 0.2s ease; +} + +.password-strength-bar.weak { background: #ef4444; } +.password-strength-bar.fair { background: #f59e0b; } +.password-strength-bar.good { background: #22c55e; } +.password-strength-bar.strong { background: #0f766e; } + +.password-strength-text { + font-size: 11px; + color: var(--bp-text-muted); + margin-top: 4px; +} + +/* Light Mode Overrides */ +[data-theme="light"] .auth-modal-content { + background: var(--bp-surface); + border-color: var(--bp-border); +} + +[data-theme="light"] .auth-form-group input { + background: var(--bp-bg); + border-color: var(--bp-border); +} + +[data-theme="light"] .auth-social-btn { + background: var(--bp-bg); +} diff --git a/backend/frontend/static/css/modules/components/communication.css b/backend/frontend/static/css/modules/components/communication.css new file mode 100644 index 0000000..ceafd78 --- /dev/null +++ b/backend/frontend/static/css/modules/components/communication.css @@ -0,0 +1,155 @@ +/* ========================================== + Communication Panel Styles (Matrix + Jitsi) + Chat, Room List, Messages + ========================================== */ + +/* Room List Styles */ +.room-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 8px; + cursor: pointer; + transition: background 0.2s; +} + +.room-item:hover { + background: var(--bp-surface-elevated); +} + +.room-item.active { + background: var(--bp-primary-soft); + border-left: 3px solid var(--bp-primary); +} + +.room-icon { + font-size: 20px; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bp-surface-elevated); + border-radius: 8px; +} + +.room-info { + flex: 1; + min-width: 0; +} + +.room-name { + font-weight: 500; + font-size: 13px; + color: var(--bp-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.room-preview { + font-size: 11px; + color: var(--bp-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.room-badge { + background: var(--bp-primary); + color: white; + font-size: 10px; + font-weight: 600; + padding: 2px 6px; + border-radius: 10px; + min-width: 18px; + text-align: center; +} + +/* Chat Message Styles */ +.chat-system-msg { + text-align: center; + padding: 8px 16px; + background: var(--bp-surface-elevated); + border-radius: 8px; + font-size: 12px; + color: var(--bp-text-muted); +} + +.chat-msg { + padding: 12px 16px; + border-radius: 12px; + max-width: 85%; +} + +.chat-msg-self { + background: var(--bp-primary-soft); + border: 1px solid var(--bp-border-subtle); + margin-left: auto; +} + +.chat-msg-other { + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border); +} + +.chat-msg-notification { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.chat-msg-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; +} + +.chat-msg-sender { + font-weight: 600; + font-size: 12px; + color: var(--bp-primary); +} + +.chat-msg-time { + font-size: 10px; + color: var(--bp-text-muted); +} + +.chat-msg-content { + font-size: 13px; + line-height: 1.5; + color: var(--bp-text); +} + +.chat-msg-actions { + display: flex; + gap: 6px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--bp-border); +} + +/* Button danger variant (global) */ +.btn-danger { + background: #ef4444 !important; + color: white !important; +} + +.btn-danger:hover { + background: #dc2626 !important; +} + +/* Light Mode Overrides */ +[data-theme="light"] .room-item.active { + background: var(--bp-primary-soft); +} + +[data-theme="light"] .chat-msg-self { + background: var(--bp-primary-soft); +} + +[data-theme="light"] .chat-msg-other { + background: var(--bp-bg); +} diff --git a/backend/frontend/static/css/modules/components/editor.css b/backend/frontend/static/css/modules/components/editor.css new file mode 100644 index 0000000..50ae099 --- /dev/null +++ b/backend/frontend/static/css/modules/components/editor.css @@ -0,0 +1,167 @@ +/* ========================================== + RICH TEXT EDITOR STYLES + WYSIWYG Editor Components + ========================================== */ + +.editor-container { + border: 1px solid var(--bp-border); + border-radius: 8px; + background: var(--bp-surface-elevated); + overflow: hidden; +} + +.editor-toolbar { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 8px 12px; + border-bottom: 1px solid var(--bp-border); + background: var(--bp-surface); +} + +.editor-toolbar-group { + display: flex; + gap: 2px; + padding-right: 8px; + margin-right: 8px; + border-right: 1px solid var(--bp-border); +} + +.editor-toolbar-group:last-child { + border-right: none; + margin-right: 0; + padding-right: 0; +} + +.editor-btn { + padding: 6px 10px; + border: none; + background: transparent; + color: var(--bp-text); + cursor: pointer; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + transition: all 0.2s ease; + min-width: 32px; +} + +.editor-btn:hover { + background: var(--bp-border-subtle); +} + +.editor-btn.active { + background: var(--bp-primary); + color: white; +} + +.editor-btn-upload { + background: var(--bp-primary); + color: white; + padding: 6px 12px; +} + +.editor-btn-upload:hover { + filter: brightness(1.1); +} + +.editor-content { + min-height: 300px; + max-height: 500px; + overflow-y: auto; + padding: 16px; + color: var(--bp-text); + line-height: 1.6; +} + +.editor-content:focus { + outline: none; +} + +.editor-content[contenteditable="true"] { + cursor: text; +} + +.editor-content h1 { + font-size: 24px; + font-weight: 700; + margin: 0 0 16px 0; +} + +.editor-content h2 { + font-size: 20px; + font-weight: 600; + margin: 24px 0 12px 0; +} + +.editor-content h3 { + font-size: 17px; + font-weight: 600; + margin: 20px 0 10px 0; +} + +.editor-content p { + margin: 0 0 12px 0; +} + +.editor-content ul, .editor-content ol { + margin: 0 0 12px 0; + padding-left: 24px; +} + +.editor-content li { + margin-bottom: 4px; +} + +.editor-content strong { + font-weight: 600; +} + +.editor-content em { + font-style: italic; +} + +.editor-content a { + color: var(--bp-primary); + text-decoration: underline; +} + +.editor-content blockquote { + border-left: 3px solid var(--bp-primary); + margin: 16px 0; + padding: 8px 16px; + background: var(--bp-surface); + font-style: italic; +} + +.editor-content hr { + border: none; + border-top: 1px solid var(--bp-border); + margin: 20px 0; +} + +.word-upload-input { + display: none; +} + +.editor-status { + padding: 8px 12px; + font-size: 12px; + color: var(--bp-text-muted); + border-top: 1px solid var(--bp-border); + background: var(--bp-surface); +} + +/* Light Mode Overrides */ +[data-theme="light"] .editor-container { + border-color: var(--bp-border); +} + +[data-theme="light"] .editor-toolbar { + background: var(--bp-bg); + border-color: var(--bp-border); +} + +[data-theme="light"] .editor-content { + background: var(--bp-surface); +} diff --git a/backend/frontend/static/css/modules/components/legal-modal.css b/backend/frontend/static/css/modules/components/legal-modal.css new file mode 100644 index 0000000..4571abe --- /dev/null +++ b/backend/frontend/static/css/modules/components/legal-modal.css @@ -0,0 +1,205 @@ +/* ========================================== + LEGAL MODAL STYLES + Privacy, Terms, Cookie Settings + ========================================== */ + +.legal-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + z-index: 10000; + justify-content: center; + align-items: center; + backdrop-filter: blur(4px); +} + +.legal-modal.active { + display: flex; +} + +.legal-modal-content { + background: var(--bp-surface); + border-radius: 16px; + width: 90%; + max-width: 700px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0,0,0,0.4); + border: 1px solid var(--bp-border); +} + +.legal-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid var(--bp-border); +} + +.legal-modal-header h2 { + margin: 0; + font-size: 18px; + font-weight: 600; +} + +.legal-modal-close { + background: none; + border: none; + font-size: 28px; + cursor: pointer; + color: var(--bp-text-muted); + padding: 0; + line-height: 1; +} + +.legal-modal-close:hover { + color: var(--bp-text); +} + +.legal-tabs { + display: flex; + gap: 4px; + padding: 12px 24px; + border-bottom: 1px solid var(--bp-border); + background: var(--bp-surface-elevated); +} + +.legal-tab { + padding: 8px 16px; + border: none; + background: transparent; + color: var(--bp-text-muted); + cursor: pointer; + border-radius: 8px; + font-size: 13px; + transition: all 0.2s ease; +} + +.legal-tab:hover { + background: var(--bp-border-subtle); + color: var(--bp-text); +} + +.legal-tab.active { + background: var(--bp-primary); + color: white; +} + +.legal-body { + padding: 24px; + overflow-y: auto; + flex: 1; +} + +.legal-content { + display: none; +} + +.legal-content.active { + display: block; +} + +.legal-content h3 { + margin-top: 0; + color: var(--bp-text); +} + +.legal-content p { + line-height: 1.6; + color: var(--bp-text-muted); +} + +.legal-content ul { + color: var(--bp-text-muted); + line-height: 1.8; +} + +/* Cookie Categories */ +.cookie-categories { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 16px; +} + +.cookie-category { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px; + background: var(--bp-surface-elevated); + border-radius: 8px; + border: 1px solid var(--bp-border); + cursor: pointer; +} + +.cookie-category input { + margin-top: 4px; +} + +.cookie-category span { + flex: 1; + font-size: 13px; + color: var(--bp-text); +} + +/* GDPR Actions */ +.gdpr-actions { + display: flex; + flex-direction: column; + gap: 16px; + margin-top: 16px; +} + +.gdpr-action { + padding: 16px; + background: var(--bp-surface-elevated); + border-radius: 12px; + border: 1px solid var(--bp-border); +} + +.gdpr-action h4 { + margin: 0 0 8px 0; + font-size: 14px; + color: var(--bp-text); +} + +.gdpr-action p { + margin: 0 0 12px 0; + font-size: 13px; +} + +.btn-danger { + background: var(--bp-danger) !important; + border-color: var(--bp-danger) !important; +} + +.btn-danger:hover { + filter: brightness(1.1); +} + +/* Light Mode Overrides */ +[data-theme="light"] .legal-modal-content { + background: var(--bp-surface); + border-color: var(--bp-border); +} + +[data-theme="light"] .legal-tabs { + background: var(--bp-bg); +} + +[data-theme="light"] .legal-tab.active { + background: var(--bp-primary); + color: white; +} + +[data-theme="light"] .cookie-category, +[data-theme="light"] .gdpr-action { + background: var(--bp-bg); + border-color: var(--bp-border); +} diff --git a/backend/frontend/static/css/modules/components/notifications.css b/backend/frontend/static/css/modules/components/notifications.css new file mode 100644 index 0000000..f7e8cd6 --- /dev/null +++ b/backend/frontend/static/css/modules/components/notifications.css @@ -0,0 +1,315 @@ +/* ========================================== + NOTIFICATION STYLES + Bell, Panel, Items, Preferences + ========================================== */ + +.notification-bell { + position: relative; + display: none; +} + +.notification-bell.active { + display: flex; +} + +.notification-bell-btn { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border); + border-radius: 8px; + color: var(--bp-text-muted); + cursor: pointer; + font-size: 18px; + transition: all 0.2s ease; +} + +.notification-bell-btn:hover { + border-color: var(--bp-primary); + color: var(--bp-primary); +} + +.notification-badge { + position: absolute; + top: 2px; + right: 2px; + min-width: 18px; + height: 18px; + background: var(--bp-danger); + color: white; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + padding: 0 5px; +} + +.notification-badge.hidden { + display: none; +} + +/* Notification Panel */ +.notification-panel { + display: none; + position: absolute; + top: 100%; + right: 0; + margin-top: 8px; + width: 360px; + max-height: 480px; + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + box-shadow: 0 10px 40px rgba(0,0,0,0.3); + z-index: 1000; + overflow: hidden; +} + +.notification-panel.active { + display: flex; + flex-direction: column; +} + +.notification-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + border-bottom: 1px solid var(--bp-border); +} + +.notification-panel-title { + font-weight: 600; + font-size: 16px; + color: var(--bp-text); +} + +.notification-panel-actions { + display: flex; + gap: 8px; +} + +.notification-panel-action { + padding: 4px 8px; + background: none; + border: 1px solid var(--bp-border); + border-radius: 6px; + color: var(--bp-text-muted); + cursor: pointer; + font-size: 11px; + transition: all 0.2s ease; +} + +.notification-panel-action:hover { + border-color: var(--bp-primary); + color: var(--bp-primary); +} + +/* Notification List */ +.notification-list { + flex: 1; + overflow-y: auto; + max-height: 380px; +} + +.notification-item { + display: flex; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid var(--bp-border); + cursor: pointer; + transition: background 0.2s ease; + position: relative; +} + +.notification-item:hover { + background: var(--bp-surface-elevated); +} + +.notification-item.unread { + background: rgba(15, 118, 110, 0.08); +} + +.notification-item.unread::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--bp-primary); +} + +.notification-icon { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--bp-primary-soft); + color: var(--bp-primary); + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; +} + +.notification-content { + flex: 1; + min-width: 0; +} + +.notification-title { + font-weight: 500; + font-size: 13px; + color: var(--bp-text); + margin-bottom: 4px; + line-height: 1.3; +} + +.notification-body { + font-size: 12px; + color: var(--bp-text-muted); + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.notification-time { + font-size: 11px; + color: var(--bp-text-muted); + margin-top: 6px; +} + +.notification-empty { + padding: 40px 20px; + text-align: center; + color: var(--bp-text-muted); +} + +.notification-empty-icon { + font-size: 36px; + margin-bottom: 12px; + opacity: 0.5; +} + +.notification-footer { + padding: 12px 16px; + border-top: 1px solid var(--bp-border); + text-align: center; +} + +.notification-footer-btn { + background: none; + border: none; + color: var(--bp-primary); + cursor: pointer; + font-size: 13px; + font-weight: 500; +} + +.notification-footer-btn:hover { + text-decoration: underline; +} + +/* Notification Preferences Modal */ +.notification-prefs-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + z-index: 10001; + justify-content: center; + align-items: center; + backdrop-filter: blur(4px); +} + +.notification-prefs-modal.active { + display: flex; +} + +.notification-prefs-content { + background: var(--bp-surface); + border-radius: 16px; + width: 90%; + max-width: 420px; + box-shadow: 0 20px 60px rgba(0,0,0,0.4); + border: 1px solid var(--bp-border); +} + +.notification-pref-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 0; + border-bottom: 1px solid var(--bp-border); +} + +.notification-pref-item:last-child { + border-bottom: none; +} + +.notification-pref-label { + font-size: 14px; + color: var(--bp-text); +} + +.notification-pref-desc { + font-size: 12px; + color: var(--bp-text-muted); + margin-top: 2px; +} + +/* Toggle Switch */ +.toggle-switch { + position: relative; + width: 48px; + height: 26px; + background: var(--bp-border); + border-radius: 999px; + cursor: pointer; + transition: background 0.3s ease; +} + +.toggle-switch.active { + background: var(--bp-primary); +} + +.toggle-switch-handle { + position: absolute; + top: 3px; + left: 3px; + width: 20px; + height: 20px; + background: white; + border-radius: 50%; + transition: transform 0.3s ease; +} + +.toggle-switch.active .toggle-switch-handle { + transform: translateX(22px); +} + +/* Light Mode Overrides */ +[data-theme="light"] .notification-panel { + background: var(--bp-surface); + border-color: var(--bp-border); +} + +[data-theme="light"] .notification-item.unread { + background: rgba(14, 165, 233, 0.05); +} + +[data-theme="light"] .notification-item.unread::before { + background: var(--bp-primary); +} diff --git a/backend/frontend/static/css/modules/components/suspension.css b/backend/frontend/static/css/modules/components/suspension.css new file mode 100644 index 0000000..06c3bd6 --- /dev/null +++ b/backend/frontend/static/css/modules/components/suspension.css @@ -0,0 +1,124 @@ +/* ========================================== + SUSPENSION OVERLAY STYLES + Account suspension/blocking display + ========================================== */ + +.suspension-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.9); + z-index: 20000; + justify-content: center; + align-items: center; + backdrop-filter: blur(8px); +} + +.suspension-overlay.active { + display: flex; +} + +.suspension-content { + background: var(--bp-surface); + border-radius: 20px; + width: 90%; + max-width: 500px; + padding: 40px; + text-align: center; + box-shadow: 0 20px 60px rgba(0,0,0,0.5); + border: 1px solid var(--bp-danger); +} + +.suspension-icon { + font-size: 64px; + margin-bottom: 20px; +} + +.suspension-title { + font-size: 24px; + font-weight: 700; + color: var(--bp-danger); + margin-bottom: 16px; +} + +.suspension-message { + font-size: 15px; + color: var(--bp-text-muted); + line-height: 1.6; + margin-bottom: 24px; +} + +.suspension-reason { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 12px; + padding: 16px; + margin-bottom: 24px; +} + +.suspension-reason-label { + font-size: 12px; + color: var(--bp-text-muted); + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.suspension-reason-text { + font-size: 14px; + color: var(--bp-text); +} + +.suspension-actions { + display: flex; + flex-direction: column; + gap: 12px; +} + +.suspension-btn { + padding: 14px 24px; + border-radius: 10px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + border: none; +} + +.suspension-btn-primary { + background: var(--bp-btn-primary-bg); + color: white; +} + +.suspension-btn-primary:hover { + background: var(--bp-btn-primary-hover); +} + +.suspension-btn-secondary { + background: transparent; + border: 1px solid var(--bp-border); + color: var(--bp-text); +} + +.suspension-btn-secondary:hover { + border-color: var(--bp-primary); + color: var(--bp-primary); +} + +.suspension-countdown { + font-size: 12px; + color: var(--bp-text-muted); + margin-top: 16px; +} + +/* Light Mode Overrides */ +[data-theme="light"] .suspension-content { + background: var(--bp-surface); +} + +[data-theme="light"] .suspension-reason { + background: rgba(239, 68, 68, 0.05); +} diff --git a/backend/frontend/static/css/studio.css b/backend/frontend/static/css/studio.css new file mode 100644 index 0000000..efc7a02 --- /dev/null +++ b/backend/frontend/static/css/studio.css @@ -0,0 +1,52 @@ +/* ========================================== + BreakPilot Studio - Main CSS Entry Point + Modular CSS Architecture + ========================================== */ + +/* Base Styles */ +@import url('./modules/base/variables.css'); +@import url('./modules/base/layout.css'); + +/* UI Components */ +@import url('./modules/components/legal-modal.css'); +@import url('./modules/components/auth-modal.css'); +@import url('./modules/components/notifications.css'); +@import url('./modules/components/suspension.css'); +@import url('./modules/components/editor.css'); +@import url('./modules/components/communication.css'); + +/* Admin Panel */ +@import url('./modules/admin/modal.css'); +@import url('./modules/admin/tables.css'); +@import url('./modules/admin/sidebar.css'); +@import url('./modules/admin/content.css'); +@import url('./modules/admin/dsms.css'); +@import url('./modules/admin/preview.css'); +@import url('./modules/admin/learning.css'); + +/* ========================================== + Module Architecture (Total: 3,710 → 15 modules) + + base/ + ├── variables.css (~65 lines) - CSS Custom Properties + └── layout.css (~145 lines) - App structure, topbar + + components/ + ├── legal-modal.css (~170 lines) - Privacy, terms, cookies + ├── auth-modal.css (~225 lines) - Login, register + ├── notifications.css (~225 lines) - Bell, panel, items + ├── suspension.css (~115 lines) - Account blocking + ├── editor.css (~140 lines) - Rich text editor + └── communication.css (~120 lines) - Chat, Matrix, Jitsi + + admin/ + ├── modal.css (~100 lines) - Admin modal structure + ├── tables.css (~100 lines) - Tables and badges + ├── sidebar.css (~240 lines) - Navigation sidebar + ├── content.css (~260 lines) - Content area, buttons + ├── dsms.css (~160 lines) - Storage management + ├── preview.css (~175 lines) - Preview/compare + └── learning.css (~285 lines) - MC, cloze quizzes + + Original file backed up as: studio_original.css + ========================================== */ diff --git a/backend/frontend/static/css/studio_original.css b/backend/frontend/static/css/studio_original.css new file mode 100644 index 0000000..64cca67 --- /dev/null +++ b/backend/frontend/static/css/studio_original.css @@ -0,0 +1,3711 @@ +/* ========================================== + DARK MODE (Default) - Original Design + ========================================== */ + :root { + --bp-primary: #0f766e; + --bp-primary-soft: #ccfbf1; + --bp-bg: #020617; + --bp-surface: #020617; + --bp-surface-elevated: rgba(15,23,42,0.9); + --bp-border: #1f2937; + --bp-border-subtle: rgba(148,163,184,0.25); + --bp-accent: #22c55e; + --bp-accent-soft: rgba(34,197,94,0.2); + --bp-text: #e5e7eb; + --bp-text-muted: #9ca3af; + --bp-danger: #ef4444; + --bp-gradient-bg: radial-gradient(circle at top left, #1f2937 0, #020617 50%, #000 100%); + --bp-gradient-surface: radial-gradient(circle at top left, rgba(15,23,42,0.9) 0, #020617 50%, #000 100%); + --bp-gradient-sidebar: radial-gradient(circle at top, #020617 0, #020617 40%, #000 100%); + --bp-gradient-topbar: linear-gradient(to right, rgba(15,23,42,0.9), rgba(15,23,42,0.6)); + --bp-btn-primary-bg: linear-gradient(to right, var(--bp-primary), #15803d); + --bp-btn-primary-hover: linear-gradient(to right, #0f766e, #166534); + --bp-card-bg: var(--bp-gradient-surface); + --bp-input-bg: rgba(255,255,255,0.05); + --bp-scrollbar-track: rgba(15,23,42,0.5); + --bp-scrollbar-thumb: rgba(148,163,184,0.5); + } + + /* ========================================== + LIGHT MODE - Website Design + Farben aus BreakPilot Website: + - Primary: Sky Blue #0ea5e9 (Modern, Vertrauen) + - Accent: Fuchsia #d946ef (Kreativ, Energie) + - Text: Slate #0f172a (Klarheit) + - Background: White/Slate-50 (Clean, Professional) + ========================================== */ + @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); + + [data-theme="light"] { + --bp-primary: #0ea5e9; + --bp-primary-soft: rgba(14, 165, 233, 0.1); + --bp-bg: #f8fafc; + --bp-surface: #FFFFFF; + --bp-surface-elevated: #FFFFFF; + --bp-border: #e2e8f0; + --bp-border-subtle: rgba(14, 165, 233, 0.2); + --bp-accent: #d946ef; + --bp-accent-soft: rgba(217, 70, 239, 0.15); + --bp-text: #0f172a; + --bp-text-muted: #64748b; + --bp-danger: #ef4444; + --bp-gradient-bg: linear-gradient(145deg, #f8fafc 0%, #f1f5f9 100%); + --bp-gradient-surface: linear-gradient(145deg, #FFFFFF 0%, #f8fafc 100%); + --bp-gradient-sidebar: linear-gradient(180deg, #FFFFFF 0%, #f1f5f9 100%); + --bp-gradient-topbar: linear-gradient(to right, #FFFFFF, #f8fafc); + --bp-btn-primary-bg: linear-gradient(135deg, #0ea5e9 0%, #d946ef 100%); + --bp-btn-primary-hover: linear-gradient(135deg, #0284c7 0%, #c026d3 100%); + --bp-card-bg: linear-gradient(145deg, #FFFFFF 0%, #f8fafc 100%); + --bp-input-bg: #FFFFFF; + --bp-scrollbar-track: rgba(0,0,0,0.05); + --bp-scrollbar-thumb: rgba(14, 165, 233, 0.3); + --bp-gold: #eab308; + } + + [data-theme="light"] body { + font-family: 'Inter', system-ui, -apple-system, sans-serif; + } + + * { box-sizing: border-box; } + + /* Custom Scrollbar */ + ::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + ::-webkit-scrollbar-track { + background: var(--bp-scrollbar-track); + border-radius: 4px; + } + + ::-webkit-scrollbar-thumb { + background: var(--bp-scrollbar-thumb); + border-radius: 4px; + } + + ::-webkit-scrollbar-thumb:hover { + background: var(--bp-scrollbar-thumb); + opacity: 0.8; + } + + html, body { + height: 100%; + } + + body { + margin: 0; + padding: 0; + font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif; + background: var(--bp-gradient-bg); + color: var(--bp-text); + min-height: 100vh; + display: flex; + flex-direction: column; + transition: background 0.3s ease, color 0.3s ease; + } + + .app-root { + display: grid; + grid-template-rows: 56px 1fr 32px; + flex: 1; + min-height: 0; + } + + .topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 20px; + border-bottom: 1px solid var(--bp-border-subtle); + backdrop-filter: blur(18px); + background: var(--bp-gradient-topbar); + transition: background 0.3s ease, border-color 0.3s ease; + } + + .brand { + display: flex; + align-items: center; + gap: 10px; + } + + .brand-logo { + width: 28px; + height: 28px; + border-radius: 999px; + border: 2px solid var(--bp-accent); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 14px; + color: var(--bp-accent); + } + + .brand-text-main { + font-weight: 600; + font-size: 16px; + } + + .brand-text-sub { + font-size: 12px; + color: var(--bp-text-muted); + } + + .top-nav { + display: flex; + align-items: center; + gap: 18px; + font-size: 13px; + } + + .top-nav-item { + cursor: pointer; + padding: 4px 10px; + border-radius: 999px; + color: var(--bp-text-muted); + border: 1px solid transparent; + } + + .top-nav-item.active { + color: var(--bp-primary); + border-color: var(--bp-accent-soft); + background: var(--bp-surface-elevated); + } + + [data-theme="light"] .top-nav-item.active { + color: var(--bp-primary); + background: var(--bp-primary-soft); + border-color: var(--bp-primary); + } + + .top-actions { + display: flex; + align-items: center; + gap: 10px; + } + + .pill { + font-size: 11px; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--bp-border-subtle); + color: var(--bp-text-muted); + } + + /* Theme Toggle Button */ + .theme-toggle { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--bp-border-subtle); + background: var(--bp-surface-elevated); + color: var(--bp-text-muted); + cursor: pointer; + font-size: 11px; + transition: all 0.2s ease; + } + + .theme-toggle:hover { + border-color: var(--bp-primary); + color: var(--bp-primary); + } + + .theme-toggle-icon { + font-size: 14px; + } + + /* ========================================== + LEGAL MODAL STYLES + ========================================== */ + .legal-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + z-index: 10000; + justify-content: center; + align-items: center; + backdrop-filter: blur(4px); + } + + .legal-modal.active { + display: flex; + } + + .legal-modal-content { + background: var(--bp-surface); + border-radius: 16px; + width: 90%; + max-width: 700px; + max-height: 80vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0,0,0,0.4); + border: 1px solid var(--bp-border); + } + + .legal-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid var(--bp-border); + } + + .legal-modal-header h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + } + + .legal-modal-close { + background: none; + border: none; + font-size: 28px; + cursor: pointer; + color: var(--bp-text-muted); + padding: 0; + line-height: 1; + } + + .legal-modal-close:hover { + color: var(--bp-text); + } + + .legal-tabs { + display: flex; + gap: 4px; + padding: 12px 24px; + border-bottom: 1px solid var(--bp-border); + background: var(--bp-surface-elevated); + } + + .legal-tab { + padding: 8px 16px; + border: none; + background: transparent; + color: var(--bp-text-muted); + cursor: pointer; + border-radius: 8px; + font-size: 13px; + transition: all 0.2s ease; + } + + .legal-tab:hover { + background: var(--bp-border-subtle); + color: var(--bp-text); + } + + .legal-tab.active { + background: var(--bp-primary); + color: white; + } + + .legal-body { + padding: 24px; + overflow-y: auto; + flex: 1; + } + + .legal-content { + display: none; + } + + .legal-content.active { + display: block; + } + + .legal-content h3 { + margin-top: 0; + color: var(--bp-text); + } + + .legal-content p { + line-height: 1.6; + color: var(--bp-text-muted); + } + + .legal-content ul { + color: var(--bp-text-muted); + line-height: 1.8; + } + + .cookie-categories { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 16px; + } + + .cookie-category { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 12px; + background: var(--bp-surface-elevated); + border-radius: 8px; + border: 1px solid var(--bp-border); + cursor: pointer; + } + + .cookie-category input { + margin-top: 4px; + } + + .cookie-category span { + flex: 1; + font-size: 13px; + color: var(--bp-text); + } + + .gdpr-actions { + display: flex; + flex-direction: column; + gap: 16px; + margin-top: 16px; + } + + .gdpr-action { + padding: 16px; + background: var(--bp-surface-elevated); + border-radius: 12px; + border: 1px solid var(--bp-border); + } + + .gdpr-action h4 { + margin: 0 0 8px 0; + font-size: 14px; + color: var(--bp-text); + } + + .gdpr-action p { + margin: 0 0 12px 0; + font-size: 13px; + } + + .btn-danger { + background: var(--bp-danger) !important; + border-color: var(--bp-danger) !important; + } + + .btn-danger:hover { + filter: brightness(1.1); + } + + [data-theme="light"] .legal-modal-content { + background: var(--bp-surface); + border-color: var(--bp-border); + } + + [data-theme="light"] .legal-tabs { + background: var(--bp-bg); + } + + [data-theme="light"] .legal-tab.active { + background: var(--bp-primary); + color: white; + } + + [data-theme="light"] .cookie-category, + [data-theme="light"] .gdpr-action { + background: var(--bp-bg); + border-color: var(--bp-border); + } + + /* ========================================== + AUTH MODAL STYLES + ========================================== */ + .auth-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + z-index: 10000; + justify-content: center; + align-items: center; + backdrop-filter: blur(4px); + } + + .auth-modal.active { + display: flex; + } + + .auth-modal-content { + background: var(--bp-surface); + border-radius: 16px; + width: 90%; + max-width: 420px; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0,0,0,0.4); + border: 1px solid var(--bp-border); + } + + .auth-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid var(--bp-border); + } + + .auth-modal-header h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + display: flex; + align-items: center; + gap: 10px; + } + + .auth-tabs { + display: flex; + gap: 4px; + padding: 12px 24px; + border-bottom: 1px solid var(--bp-border); + background: var(--bp-surface-elevated); + } + + .auth-tab { + flex: 1; + padding: 10px 16px; + border: none; + background: transparent; + color: var(--bp-text-muted); + cursor: pointer; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; + } + + .auth-tab:hover { + background: var(--bp-border-subtle); + color: var(--bp-text); + } + + .auth-tab.active { + background: var(--bp-primary); + color: white; + } + + .auth-body { + padding: 24px; + } + + .auth-content { + display: none; + } + + .auth-content.active { + display: block; + } + + .auth-form-group { + margin-bottom: 16px; + } + + .auth-form-label { + display: block; + margin-bottom: 6px; + font-size: 13px; + font-weight: 500; + color: var(--bp-text); + } + + .auth-form-input { + width: 100%; + padding: 10px 14px; + border: 1px solid var(--bp-border); + border-radius: 8px; + background: var(--bp-surface-elevated); + color: var(--bp-text); + font-size: 14px; + transition: border-color 0.2s ease; + box-sizing: border-box; + } + + .auth-form-input:focus { + outline: none; + border-color: var(--bp-primary); + } + + .auth-form-input::placeholder { + color: var(--bp-text-muted); + } + + .auth-error { + display: none; + padding: 10px 14px; + background: rgba(220, 53, 69, 0.15); + border: 1px solid var(--bp-danger); + border-radius: 8px; + color: var(--bp-danger); + font-size: 13px; + margin-bottom: 16px; + } + + .auth-error.active { + display: block; + } + + .auth-success { + display: none; + padding: 10px 14px; + background: rgba(40, 167, 69, 0.15); + border: 1px solid var(--bp-success); + border-radius: 8px; + color: var(--bp-success); + font-size: 13px; + margin-bottom: 16px; + } + + .auth-success.active { + display: block; + } + + .auth-btn { + width: 100%; + padding: 12px 20px; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + } + + .auth-btn-primary { + background: var(--bp-primary); + color: white; + } + + .auth-btn-primary:hover { + filter: brightness(1.1); + } + + .auth-btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + .auth-link { + text-align: center; + margin-top: 16px; + font-size: 13px; + color: var(--bp-text-muted); + } + + .auth-link a { + color: var(--bp-primary); + text-decoration: none; + cursor: pointer; + } + + .auth-link a:hover { + text-decoration: underline; + } + + .auth-divider { + display: flex; + align-items: center; + text-align: center; + margin: 20px 0; + color: var(--bp-text-muted); + font-size: 12px; + } + + .auth-divider::before, + .auth-divider::after { + content: ''; + flex: 1; + border-bottom: 1px solid var(--bp-border); + } + + .auth-divider::before { + margin-right: 12px; + } + + .auth-divider::after { + margin-left: 12px; + } + + .auth-user-dropdown { + position: relative; + display: none; + } + + .auth-user-dropdown.active { + display: flex; + } + + .auth-user-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border); + border-radius: 8px; + color: var(--bp-text); + cursor: pointer; + font-size: 13px; + } + + .auth-user-btn:hover { + border-color: var(--bp-primary); + } + + .auth-user-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + background: var(--bp-primary); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + } + + .auth-user-menu { + display: none; + position: absolute; + top: 100%; + right: 0; + margin-top: 8px; + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 8px; + box-shadow: 0 10px 30px rgba(0,0,0,0.3); + min-width: 200px; + z-index: 1000; + overflow: hidden; + } + + .auth-user-menu.active { + display: block; + } + + .auth-user-menu-header { + padding: 12px 16px; + border-bottom: 1px solid var(--bp-border); + } + + .auth-user-menu-name { + font-weight: 600; + font-size: 14px; + color: var(--bp-text); + } + + .auth-user-menu-email { + font-size: 12px; + color: var(--bp-text-muted); + margin-top: 2px; + } + + .auth-user-menu-item { + display: block; + width: 100%; + padding: 10px 16px; + border: none; + background: none; + text-align: left; + color: var(--bp-text); + cursor: pointer; + font-size: 13px; + transition: background 0.2s ease; + } + + .auth-user-menu-item:hover { + background: var(--bp-surface-elevated); + } + + .auth-user-menu-item.danger { + color: var(--bp-danger); + } + + [data-theme="light"] .auth-modal-content { + background: var(--bp-surface); + border-color: var(--bp-border); + } + + [data-theme="light"] .auth-tabs { + background: var(--bp-bg); + } + + [data-theme="light"] .auth-form-input { + background: var(--bp-surface); + border-color: var(--bp-border); + } + + [data-theme="light"] .auth-user-menu { + background: var(--bp-surface); + border-color: var(--bp-border); + } + + /* ========================================== + NOTIFICATION STYLES + ========================================== */ + .notification-bell { + position: relative; + display: none; + } + + .notification-bell.active { + display: flex; + } + + .notification-bell-btn { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border); + border-radius: 8px; + color: var(--bp-text-muted); + cursor: pointer; + font-size: 18px; + transition: all 0.2s ease; + } + + .notification-bell-btn:hover { + border-color: var(--bp-primary); + color: var(--bp-primary); + } + + .notification-badge { + position: absolute; + top: 2px; + right: 2px; + min-width: 18px; + height: 18px; + background: var(--bp-danger); + color: white; + border-radius: 999px; + font-size: 11px; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + padding: 0 5px; + } + + .notification-badge.hidden { + display: none; + } + + .notification-panel { + display: none; + position: absolute; + top: 100%; + right: 0; + margin-top: 8px; + width: 360px; + max-height: 480px; + background: var(--bp-surface); + border: 1px solid var(--bp-border); + border-radius: 12px; + box-shadow: 0 10px 40px rgba(0,0,0,0.3); + z-index: 1000; + overflow: hidden; + } + + .notification-panel.active { + display: flex; + flex-direction: column; + } + + .notification-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + border-bottom: 1px solid var(--bp-border); + } + + .notification-panel-title { + font-weight: 600; + font-size: 16px; + color: var(--bp-text); + } + + .notification-panel-actions { + display: flex; + gap: 8px; + } + + .notification-panel-action { + padding: 4px 8px; + background: none; + border: 1px solid var(--bp-border); + border-radius: 6px; + color: var(--bp-text-muted); + cursor: pointer; + font-size: 11px; + transition: all 0.2s ease; + } + + .notification-panel-action:hover { + border-color: var(--bp-primary); + color: var(--bp-primary); + } + + .notification-list { + flex: 1; + overflow-y: auto; + max-height: 380px; + } + + .notification-item { + display: flex; + gap: 12px; + padding: 14px 16px; + border-bottom: 1px solid var(--bp-border); + cursor: pointer; + transition: background 0.2s ease; + } + + .notification-item:hover { + background: var(--bp-surface-elevated); + } + + .notification-item.unread { + background: rgba(15, 118, 110, 0.08); + } + + .notification-item.unread::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background: var(--bp-primary); + } + + .notification-item { + position: relative; + } + + .notification-icon { + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--bp-primary-soft); + color: var(--bp-primary); + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; + } + + .notification-content { + flex: 1; + min-width: 0; + } + + .notification-title { + font-weight: 500; + font-size: 13px; + color: var(--bp-text); + margin-bottom: 4px; + line-height: 1.3; + } + + .notification-body { + font-size: 12px; + color: var(--bp-text-muted); + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + } + + .notification-time { + font-size: 11px; + color: var(--bp-text-muted); + margin-top: 6px; + } + + .notification-empty { + padding: 40px 20px; + text-align: center; + color: var(--bp-text-muted); + } + + .notification-empty-icon { + font-size: 36px; + margin-bottom: 12px; + opacity: 0.5; + } + + .notification-footer { + padding: 12px 16px; + border-top: 1px solid var(--bp-border); + text-align: center; + } + + .notification-footer-btn { + background: none; + border: none; + color: var(--bp-primary); + cursor: pointer; + font-size: 13px; + font-weight: 500; + } + + .notification-footer-btn:hover { + text-decoration: underline; + } + + /* Notification Preferences Modal */ + .notification-prefs-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + z-index: 10001; + justify-content: center; + align-items: center; + backdrop-filter: blur(4px); + } + + .notification-prefs-modal.active { + display: flex; + } + + .notification-prefs-content { + background: var(--bp-surface); + border-radius: 16px; + width: 90%; + max-width: 420px; + box-shadow: 0 20px 60px rgba(0,0,0,0.4); + border: 1px solid var(--bp-border); + } + + .notification-pref-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 0; + border-bottom: 1px solid var(--bp-border); + } + + .notification-pref-item:last-child { + border-bottom: none; + } + + .notification-pref-label { + font-size: 14px; + color: var(--bp-text); + } + + .notification-pref-desc { + font-size: 12px; + color: var(--bp-text-muted); + margin-top: 2px; + } + + .toggle-switch { + position: relative; + width: 48px; + height: 26px; + background: var(--bp-border); + border-radius: 999px; + cursor: pointer; + transition: background 0.3s ease; + } + + .toggle-switch.active { + background: var(--bp-primary); + } + + .toggle-switch-handle { + position: absolute; + top: 3px; + left: 3px; + width: 20px; + height: 20px; + background: white; + border-radius: 50%; + transition: transform 0.3s ease; + } + + .toggle-switch.active .toggle-switch-handle { + transform: translateX(22px); + } + + [data-theme="light"] .notification-panel { + background: var(--bp-surface); + border-color: var(--bp-border); + } + + [data-theme="light"] .notification-item.unread { + background: rgba(14, 165, 233, 0.05); + } + + [data-theme="light"] .notification-item.unread::before { + background: var(--bp-primary); + } + + /* ========================================== + SUSPENSION OVERLAY STYLES + ========================================== */ + .suspension-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.9); + z-index: 20000; + justify-content: center; + align-items: center; + backdrop-filter: blur(8px); + } + + .suspension-overlay.active { + display: flex; + } + + .suspension-content { + background: var(--bp-surface); + border-radius: 20px; + width: 90%; + max-width: 500px; + padding: 40px; + text-align: center; + box-shadow: 0 20px 60px rgba(0,0,0,0.5); + border: 1px solid var(--bp-danger); + } + + .suspension-icon { + font-size: 64px; + margin-bottom: 20px; + } + + .suspension-title { + font-size: 24px; + font-weight: 700; + color: var(--bp-danger); + margin-bottom: 16px; + } + + .suspension-message { + font-size: 15px; + color: var(--bp-text-muted); + margin-bottom: 24px; + line-height: 1.6; + } + + .suspension-docs { + background: var(--bp-surface-elevated); + border-radius: 12px; + padding: 16px; + margin-bottom: 24px; + text-align: left; + } + + .suspension-docs-title { + font-size: 13px; + font-weight: 600; + color: var(--bp-text); + margin-bottom: 12px; + } + + .suspension-doc-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 12px; + background: var(--bp-bg); + border-radius: 8px; + margin-bottom: 8px; + border: 1px solid var(--bp-border); + } + + .suspension-doc-item:last-child { + margin-bottom: 0; + } + + .suspension-doc-name { + font-size: 14px; + color: var(--bp-text); + } + + .suspension-doc-deadline { + font-size: 12px; + color: var(--bp-danger); + font-weight: 500; + } + + .suspension-btn { + padding: 14px 28px; + background: var(--bp-btn-primary-bg); + color: white; + border: none; + border-radius: 10px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + width: 100%; + } + + .suspension-btn:hover { + background: var(--bp-btn-primary-hover); + transform: translateY(-1px); + } + + .suspension-countdown { + font-size: 12px; + color: var(--bp-text-muted); + margin-top: 16px; + } + + /* ========================================== + ADMIN PANEL STYLES + ========================================== */ + .admin-modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.7); + z-index: 10000; + justify-content: center; + align-items: center; + backdrop-filter: blur(4px); + } + + .admin-modal.active { + display: flex; + } + + .admin-modal-content { + background: var(--bp-surface); + border-radius: 16px; + width: 95%; + max-width: 1000px; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0,0,0,0.4); + border: 1px solid var(--bp-border); + } + + .admin-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 1px solid var(--bp-border); + } + + .admin-modal-header h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + display: flex; + align-items: center; + gap: 10px; + } + + .admin-tabs { + display: flex; + gap: 4px; + padding: 12px 24px; + border-bottom: 1px solid var(--bp-border); + background: var(--bp-surface-elevated); + } + + .admin-tab { + padding: 8px 16px; + border: none; + background: transparent; + color: var(--bp-text-muted); + cursor: pointer; + border-radius: 8px; + font-size: 13px; + transition: all 0.2s ease; + } + + .admin-tab:hover { + background: var(--bp-border-subtle); + color: var(--bp-text); + } + + .admin-tab.active { + background: var(--bp-primary); + color: white; + } + + .admin-body { + padding: 24px; + overflow-y: auto; + flex: 1; + } + + .admin-content { + display: none; + } + + .admin-content.active { + display: block; + } + + .admin-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + gap: 12px; + } + + .admin-toolbar-left { + display: flex; + gap: 12px; + align-items: center; + } + + .admin-search { + padding: 8px 12px; + border: 1px solid var(--bp-border); + border-radius: 8px; + background: var(--bp-surface-elevated); + color: var(--bp-text); + width: 250px; + } + + .admin-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + } + + .admin-table th, + .admin-table td { + padding: 12px; + text-align: left; + border-bottom: 1px solid var(--bp-border); + } + + .admin-table th { + background: var(--bp-surface-elevated); + font-weight: 600; + color: var(--bp-text); + } + + .admin-table tr:hover { + background: var(--bp-surface-elevated); + } + + .admin-table td { + color: var(--bp-text-muted); + } + + .admin-badge { + display: inline-block; + padding: 4px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + } + + .admin-badge-published { + background: rgba(74, 222, 128, 0.2); + color: #4ADE80; + } + + .admin-badge-draft { + background: rgba(251, 191, 36, 0.2); + color: #FBBF24; + } + + .admin-badge-archived { + background: rgba(156, 163, 175, 0.2); + color: #9CA3AF; + } + + .admin-badge-rejected { + background: rgba(239, 68, 68, 0.2); + color: #EF4444; + } + + .admin-badge-review { + background: rgba(147, 51, 234, 0.2); + color: #A855F7; + } + + .admin-badge-approved { + background: rgba(34, 197, 94, 0.2); + color: #22C55E; + } + + .admin-badge-submitted { + background: rgba(59, 130, 246, 0.2); + color: #3B82F6; + } + + .admin-badge-rejected { + background: rgba(239, 68, 68, 0.2); + color: #EF4444; + } + + .admin-badge-mandatory { + background: rgba(239, 68, 68, 0.2); + color: #EF4444; + } + + .admin-badge-optional { + background: rgba(96, 165, 250, 0.2); + color: #60A5FA; + } + + .admin-actions { + display: flex; + gap: 8px; + } + + .admin-btn { + padding: 6px 10px; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 12px; + transition: all 0.2s ease; + } + + .admin-btn-edit { + background: var(--bp-primary); + color: white; + } + + .admin-btn-edit:hover { + filter: brightness(1.1); + } + + .admin-btn-delete { + background: rgba(239, 68, 68, 0.2); + color: #EF4444; + } + + .admin-btn-delete:hover { + background: rgba(239, 68, 68, 0.3); + } + + .admin-btn-publish { + background: rgba(74, 222, 128, 0.2); + color: #4ADE80; + } + + .admin-btn-publish:hover { + background: rgba(74, 222, 128, 0.3); + } + + .admin-form { + display: none; + background: var(--bp-surface-elevated); + border-radius: 12px; + padding: 20px; + margin-bottom: 20px; + border: 1px solid var(--bp-border); + } + + .admin-form.active { + display: block; + } + + /* Info Text in Toolbar */ + .admin-info-text { + font-size: 12px; + color: var(--bp-text-muted); + font-style: italic; + } + + /* Dialog Overlay */ + .admin-dialog { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 1000; + align-items: center; + justify-content: center; + } + + .admin-dialog.active { + display: flex; + } + + .admin-dialog-content { + background: var(--bp-surface); + border-radius: 12px; + padding: 24px; + max-width: 500px; + width: 90%; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); + } + + .admin-dialog-content h3 { + margin: 0 0 12px 0; + font-size: 18px; + } + + .admin-dialog-info { + font-size: 13px; + color: var(--bp-text-muted); + margin-bottom: 20px; + line-height: 1.5; + } + + .admin-dialog-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + margin-top: 20px; + } + + /* Scheduled Badge */ + .admin-badge-scheduled { + background: #f59e0b; + color: white; + } + + /* Version Compare Overlay */ + .version-compare-overlay { + display: none; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--bp-bg); + z-index: 2000; + flex-direction: column; + } + + .version-compare-overlay.active { + display: flex; + } + + .version-compare-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + background: var(--bp-surface); + border-bottom: 1px solid var(--bp-border); + } + + .version-compare-header h2 { + margin: 0; + font-size: 18px; + } + + .version-compare-info { + display: flex; + align-items: center; + gap: 12px; + font-size: 14px; + } + + .compare-vs { + color: var(--bp-text-muted); + font-weight: 600; + } + + .version-compare-container { + display: grid; + grid-template-columns: 1fr 1fr; + flex: 1; + overflow: hidden; + gap: 0; + } + + .version-compare-panel { + display: flex; + flex-direction: column; + overflow: hidden; + border-right: 1px solid var(--bp-border); + } + + .version-compare-panel:last-child { + border-right: none; + } + + .version-compare-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 20px; + background: var(--bp-surface); + border-bottom: 1px solid var(--bp-border); + } + + .compare-label { + padding: 4px 12px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + } + + .compare-label-published { + background: rgba(34, 197, 94, 0.15); + color: #22c55e; + } + + .compare-label-draft { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; + } + + .version-compare-content { + flex: 1; + overflow-y: auto; + padding: 24px; + font-size: 14px; + line-height: 1.7; + } + + .version-compare-content h1, + .version-compare-content h2, + .version-compare-content h3 { + margin-top: 24px; + margin-bottom: 12px; + } + + .version-compare-content p { + margin-bottom: 12px; + } + + .version-compare-content ul, + .version-compare-content ol { + margin-bottom: 12px; + padding-left: 24px; + } + + .version-compare-content .no-content { + color: var(--bp-text-muted); + font-style: italic; + text-align: center; + padding: 40px; + } + + .version-compare-footer { + padding: 12px 24px; + background: var(--bp-surface); + border-top: 1px solid var(--bp-border); + max-height: 150px; + overflow-y: auto; + } + + .compare-history-title { + font-size: 13px; + font-weight: 600; + margin-bottom: 8px; + color: var(--bp-text-muted); + } + + .compare-history-item { + font-size: 12px; + padding: 4px 0; + border-bottom: 1px solid var(--bp-border-subtle); + } + + .compare-history-item:last-child { + border-bottom: none; + } + + .compare-history-list { + display: flex; + flex-wrap: wrap; + gap: 4px 8px; + font-size: 12px; + color: var(--bp-text-muted); + } + + .admin-form-title { + margin: 0 0 16px 0; + font-size: 16px; + font-weight: 600; + } + + .admin-form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 16px; + } + + .admin-form-group { + display: flex; + flex-direction: column; + gap: 6px; + } + + .admin-form-group.full-width { + grid-column: 1 / -1; + } + + .admin-form-label { + font-size: 12px; + font-weight: 600; + color: var(--bp-text); + } + + .admin-form-input, + .admin-form-select, + .admin-form-textarea { + padding: 10px 12px; + border: 1px solid var(--bp-border); + border-radius: 8px; + background: var(--bp-surface); + color: var(--bp-text); + font-size: 13px; + } + + .admin-form-textarea { + min-height: 150px; + resize: vertical; + font-family: inherit; + } + + .admin-form-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--bp-border); + } + + .admin-empty { + text-align: center; + padding: 40px; + color: var(--bp-text-muted); + } + + .admin-loading { + text-align: center; + padding: 40px; + color: var(--bp-text-muted); + } + + [data-theme="light"] .admin-modal-content { + background: var(--bp-surface); + border-color: var(--bp-border); + } + + [data-theme="light"] .admin-tabs { + background: var(--bp-bg); + } + + [data-theme="light"] .admin-table th { + background: var(--bp-bg); + } + + [data-theme="light"] .admin-form { + background: var(--bp-bg); + border-color: var(--bp-border); + } + + /* DSMS Styles */ + .dsms-subtab { + padding: 6px 14px; + border: none; + background: transparent; + color: var(--bp-text-muted); + cursor: pointer; + font-size: 13px; + border-radius: 4px; + transition: all 0.2s ease; + } + + .dsms-subtab:hover { + background: var(--bp-border-subtle); + color: var(--bp-text); + } + + .dsms-subtab.active { + background: var(--bp-primary); + color: white; + } + + .dsms-content { + display: none; + } + + .dsms-content.active { + display: block; + } + + .dsms-status-card { + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border); + border-radius: 8px; + padding: 16px; + } + + .dsms-status-card h4 { + margin: 0 0 8px 0; + font-size: 12px; + color: var(--bp-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .dsms-status-card .value { + font-size: 24px; + font-weight: 600; + color: var(--bp-text); + } + + .dsms-status-card .value.online { + color: var(--bp-accent); + } + + .dsms-status-card .value.offline { + color: var(--bp-danger); + } + + .dsms-verify-success { + background: var(--bp-accent-soft); + border: 1px solid var(--bp-accent); + border-radius: 8px; + padding: 16px; + color: var(--bp-accent); + } + + .dsms-verify-error { + background: rgba(239, 68, 68, 0.1); + border: 1px solid var(--bp-danger); + border-radius: 8px; + padding: 16px; + color: var(--bp-danger); + } + + /* DSMS WebUI Styles */ + .dsms-webui-nav { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: none; + background: transparent; + color: var(--bp-text-muted); + font-size: 14px; + border-radius: 6px; + cursor: pointer; + text-align: left; + width: 100%; + transition: all 0.2s; + } + .dsms-webui-nav:hover { + background: var(--bp-surface-elevated); + color: var(--bp-text); + } + .dsms-webui-nav.active { + background: var(--bp-primary-soft); + color: var(--bp-primary); + font-weight: 500; + } + .dsms-webui-section { + display: none; + } + .dsms-webui-section.active { + display: block; + } + .dsms-webui-stat-card { + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border); + border-radius: 8px; + padding: 16px; + } + .dsms-webui-stat-label { + font-size: 12px; + color: var(--bp-text-muted); + margin-bottom: 4px; + } + .dsms-webui-stat-value { + font-size: 18px; + font-weight: 600; + color: var(--bp-text); + } + .dsms-webui-stat-sub { + font-size: 11px; + color: var(--bp-text-muted); + margin-top: 4px; + } + .dsms-webui-upload-zone { + border: 2px dashed var(--bp-border); + border-radius: 12px; + padding: 48px 24px; + background: var(--bp-input-bg); + transition: all 0.2s; + } + .dsms-webui-upload-zone.dragover { + border-color: var(--bp-primary); + background: var(--bp-primary-soft); + } + .dsms-webui-file-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border); + border-radius: 8px; + margin-bottom: 8px; + } + .dsms-webui-file-item .cid { + font-family: monospace; + font-size: 12px; + color: var(--bp-text-muted); + word-break: break-all; + } + + .main-layout { + display: grid; + grid-template-columns: 240px minmax(0, 1fr); + height: 100%; + min-height: 0; + } + + .sidebar { + border-right: 1px solid var(--bp-border-subtle); + background: var(--bp-gradient-sidebar); + padding: 14px 10px; + display: flex; + flex-direction: column; + gap: 18px; + min-width: 0; + height: 100%; + max-height: 100vh; + overflow-y: auto; + overflow-x: hidden; + transition: background 0.3s ease, border-color 0.3s ease; + } + + .sidebar-section-title { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--bp-text); + padding: 8px 6px 6px 6px; + margin-top: 12px; + } + + .sidebar-menu { + display: flex; + flex-direction: column; + gap: 4px; + } + + .sidebar-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + border-radius: 9px; + cursor: pointer; + font-size: 13px; + color: var(--bp-text-muted); + } + + .sidebar-item.active { + background: var(--bp-surface-elevated); + color: var(--bp-accent); + border: 1px solid var(--bp-accent-soft); + } + + [data-theme="light"] .sidebar-item.active { + background: var(--bp-primary-soft); + color: var(--bp-primary); + border: 1px solid var(--bp-primary); + } + + .sidebar-item-label { + display: flex; + align-items: center; + gap: 7px; + } + + .sidebar-item-badge { + font-size: 10px; + border-radius: 999px; + padding: 2px 7px; + border: 1px solid var(--bp-border-subtle); + } + + /* Sub-Navigation für Arbeitsblatt-Studio */ + .sidebar-sub-menu { + display: flex; + flex-direction: column; + gap: 2px; + padding: 4px 0 8px 20px; + margin-top: 4px; + } + + .sidebar-sub-item { + font-size: 12px; + color: var(--bp-text-muted); + padding: 6px 10px; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; + position: relative; + } + + .sidebar-sub-item::before { + content: ''; + position: absolute; + left: -12px; + top: 50%; + transform: translateY(-50%); + width: 4px; + height: 4px; + border-radius: 50%; + background: var(--bp-border); + } + + .sidebar-sub-item:hover { + background: rgba(255, 255, 255, 0.05); + color: var(--bp-text); + } + + .sidebar-sub-item.active { + background: rgba(var(--bp-primary), 0.15); + color: var(--bp-primary); + } + + .sidebar-sub-item.active::before { + background: var(--bp-primary); + width: 6px; + height: 6px; + } + + [data-theme="light"] .sidebar-sub-item:hover { + background: rgba(0, 0, 0, 0.05); + } + + [data-theme="light"] .sidebar-sub-item.active { + background: rgba(14, 165, 233, 0.1); + color: #0ea5e9; + } + + [data-theme="light"] .sidebar-sub-item.active::before { + background: #0ea5e9; + } + + /* Elternbriefe - Template Items */ + .template-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 6px; + cursor: pointer; + font-size: 13px; + transition: all 0.15s ease; + } + + .template-item:hover { + background: rgba(255, 255, 255, 0.08); + } + + [data-theme="light"] .template-item:hover { + background: rgba(0, 0, 0, 0.05); + } + + /* Elternbriefe - Letter Items */ + .letter-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 8px; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; + border-bottom: 1px solid var(--bp-border); + } + + .letter-item:last-child { + border-bottom: none; + } + + .letter-item:hover { + background: rgba(255, 255, 255, 0.05); + } + + .letter-info { + display: flex; + flex-direction: column; + gap: 2px; + } + + .letter-title { + font-size: 13px; + font-weight: 500; + } + + .letter-date { + font-size: 11px; + color: var(--bp-text-muted); + } + + .letter-status { + font-size: 10px; + padding: 2px 6px; + border-radius: 4px; + } + + .letter-status.sent { + background: rgba(16, 185, 129, 0.2); + color: #10b981; + } + + .letter-status.draft { + background: rgba(245, 158, 11, 0.2); + color: #f59e0b; + } + + /* Jitsi Video - Meeting Items */ + .meeting-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.03); + cursor: pointer; + } + + .meeting-item:hover { + background: rgba(255, 255, 255, 0.08); + } + + [data-theme="light"] .meeting-item { + background: rgba(0, 0, 0, 0.02); + } + + [data-theme="light"] .meeting-item:hover { + background: rgba(0, 0, 0, 0.05); + } + + .meeting-info { + display: flex; + flex-direction: column; + gap: 2px; + } + + .meeting-title { + font-size: 12px; + font-weight: 500; + } + + .meeting-time { + font-size: 11px; + color: var(--bp-text-muted); + } + + .sidebar-footer { + margin-top: auto; + font-size: 11px; + color: var(--bp-text-muted); + padding: 0 6px; + } + + .content { + padding: 14px 16px 16px 16px; + display: flex; + flex-direction: column; + gap: 14px; + height: 100%; + min-height: 0; + overflow: hidden; + } + + .panel { + background: var(--bp-gradient-surface); + border-radius: 16px; + border: 1px solid var(--bp-border-subtle); + padding: 12px 14px; + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + flex: 1; + overflow: hidden; + transition: background 0.3s ease, border-color 0.3s ease; + } + + [data-theme="light"] .panel { + box-shadow: 0 2px 12px rgba(0,0,0,0.08); + } + + .panel-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; + } + + .panel-title { + font-size: 18px; + font-weight: 700; + color: var(--bp-text); + margin-bottom: 4px; + } + + .panel-subtitle { + font-size: 12px; + color: var(--bp-text-muted); + line-height: 1.5; + } + + .panel-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 8px; + overflow: auto; + } + + .small-pill { + font-size: 11px; + font-weight: 600; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--bp-border-subtle); + color: var(--bp-text); + background: var(--bp-surface-elevated); + } + + .upload-inline { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px; + background: var(--bp-surface-elevated); + border-radius: 8px; + border: 1px solid var(--bp-border-subtle); + } + + [data-theme="light"] .upload-inline { + background: var(--bp-surface); + box-shadow: inset 0 1px 3px rgba(0,0,0,0.05); + } + + .upload-inline input[type=file] { + font-size: 11px; + padding: 6px; + background: var(--bp-input-bg); + border: 1px solid var(--bp-border-subtle); + border-radius: 6px; + color: var(--bp-text); + cursor: pointer; + } + + .upload-inline input[type=file]::file-selector-button { + background: var(--bp-accent-soft); + border: 1px solid var(--bp-accent); + border-radius: 4px; + padding: 4px 10px; + color: var(--bp-accent); + font-size: 10px; + font-weight: 600; + cursor: pointer; + margin-right: 8px; + } + + [data-theme="light"] .upload-inline input[type=file]::file-selector-button { + background: var(--bp-primary-soft); + border-color: var(--bp-primary); + color: var(--bp-primary); + } + + .upload-inline input[type=file]::file-selector-button:hover { + opacity: 0.8; + } + + .file-list { + list-style: none; + margin: 0; + padding: 0; + max-height: 150px; + overflow-y: auto; + border-radius: 10px; + border: 1px solid var(--bp-border-subtle); + background: var(--bp-surface-elevated); + } + + [data-theme="light"] .file-list { + border-color: var(--bp-primary); + background: var(--bp-surface); + } + + .file-item { + font-size: 12px; + padding: 8px 10px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + transition: background 0.15s ease; + } + + .file-item:nth-child(odd) { + background: var(--bp-surface-elevated); + } + + [data-theme="light"] .file-item:nth-child(odd) { + background: rgba(14, 165, 233, 0.03); + } + + .file-item:hover { + background: var(--bp-accent-soft); + } + + [data-theme="light"] .file-item:hover { + background: var(--bp-primary-soft); + } + + .file-item.active { + background: var(--bp-accent-soft); + } + + .file-item-name { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + flex: 1; + min-width: 0; + } + + .file-item-delete { + font-size: 14px; + color: #f97316; + cursor: pointer; + padding: 4px 6px; + border-radius: 4px; + transition: all 0.15s ease; + flex-shrink: 0; + } + + .file-item-delete:hover { + color: #fb923c; + background: rgba(249,115,22,0.15); + } + + .file-empty { + font-size: 12px; + color: var(--bp-text-muted); + } + + .inline-process { + display: flex; + justify-content: flex-end; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid rgba(148,163,184,0.2); + } + + .preview-container { + flex: 1; + border-radius: 12px; + border: 1px solid var(--bp-border-subtle); + background: var(--bp-gradient-sidebar); + overflow: hidden; + display: flex; + align-items: stretch; + justify-content: center; + position: relative; + min-height: 750px; + transition: all 0.3s ease; + } + + [data-theme="light"] .preview-container { + background: var(--bp-bg); + border: 2px solid var(--bp-primary); + box-shadow: 0 4px 20px rgba(14, 165, 233, 0.1); + } + + .preview-placeholder { + font-size: 13px; + color: var(--bp-text-muted); + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 20px; + } + + .compare-wrapper { + display: grid; + grid-template-columns: minmax(0, 1fr) 110px minmax(0, 1fr); + gap: 8px; + width: 100%; + height: 100%; + position: relative; + } + + .compare-section { + position: relative; + border-radius: 10px; + border: 1px solid var(--bp-border-subtle); + background: var(--bp-gradient-sidebar); + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; + transition: all 0.3s ease; + } + + [data-theme="light"] .compare-section { + border: 2px solid var(--bp-primary); + box-shadow: 0 4px 20px rgba(14, 165, 233, 0.1); + } + + .compare-header { + padding: 6px 10px; + font-size: 12px; + border-bottom: 1px solid var(--bp-border-subtle); + display: flex; + justify-content: space-between; + align-items: center; + gap: 6px; + } + + [data-theme="light"] .compare-header { + background: var(--bp-primary-soft); + border-bottom: 1px solid var(--bp-primary); + } + + .compare-header span { + color: var(--bp-text-muted); + } + + [data-theme="light"] .compare-header span { + color: var(--bp-primary); + font-weight: 600; + } + + .compare-body { + flex: 1; + min-height: 0; + position: relative; + display: flex; + align-items: center; + justify-content: center; + padding: 6px; + } + + .compare-body-inner { + position: relative; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + } + + .preview-img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + box-shadow: 0 18px 40px rgba(0,0,0,0.5); + border-radius: 10px; + } + + [data-theme="light"] .preview-img { + box-shadow: 0 8px 24px rgba(14, 165, 233, 0.15); + } + + .clean-frame { + width: 100%; + height: 100%; + border: none; + border-radius: 10px; + background: white; + } + + .preview-nav { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: space-between; + pointer-events: none; + padding: 0 4px; + } + + .preview-nav button { + pointer-events: auto; + width: 28px; + height: 28px; + border-radius: 999px; + border: 1px solid var(--bp-border-subtle); + background: var(--bp-surface-elevated); + color: var(--bp-text); + cursor: pointer; + font-size: 14px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + } + + [data-theme="light"] .preview-nav button { + border: 2px solid var(--bp-primary); + background: var(--bp-surface); + color: var(--bp-primary); + font-weight: 700; + } + + .preview-nav button:hover:not(:disabled) { + border-color: var(--bp-primary); + color: var(--bp-primary); + } + + [data-theme="light"] .preview-nav button:hover:not(:disabled) { + background: var(--bp-primary); + color: white; + } + + .preview-nav button:disabled { + opacity: 0.35; + cursor: default; + } + + .preview-nav span { + position: absolute; + bottom: 6px; + left: 50%; + transform: translateX(-50%); + font-size: 11px; + padding: 2px 8px; + border-radius: 999px; + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border-subtle); + } + + [data-theme="light"] .preview-nav span { + background: var(--bp-surface); + border: 1px solid var(--bp-primary); + color: var(--bp-primary); + } + + .preview-thumbnails { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px 4px; + overflow-y: auto; + align-items: center; + } + + .preview-thumb { + min-width: 90px; + width: 90px; + height: 70px; + border-radius: 8px; + border: 2px solid rgba(148,163,184,0.25); + background: rgba(15,23,42,0.5); + cursor: pointer; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + position: relative; + flex-shrink: 0; + } + + .preview-thumb:hover { + border-color: var(--bp-accent); + } + + .preview-thumb.active { + border-color: var(--bp-accent); + border-width: 3px; + } + + .preview-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .preview-thumb-label { + position: absolute; + bottom: 0; + left: 0; + right: 0; + font-size: 9px; + padding: 2px; + background: rgba(0,0,0,0.8); + color: white; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + +.pager { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 2px 0; + font-size: 11px; +} + + .pager button { + width: 24px; + height: 24px; + border-radius: 999px; + border: 1px solid var(--bp-border-subtle); + background: var(--bp-surface-elevated); + color: var(--bp-text); + cursor: pointer; + transition: all 0.2s ease; + } + + .pager button:hover { + border-color: var(--bp-primary); + color: var(--bp-primary); + } + + [data-theme="light"] .pager button { + border: 2px solid var(--bp-primary); + background: var(--bp-surface); + color: var(--bp-primary); + font-weight: 700; + } + + [data-theme="light"] .pager button:hover { + background: var(--bp-primary); + color: white; + } + + .status-bar { + position: fixed; + right: 18px; + bottom: 18px; + padding: 8px 12px; + border-radius: 999px; + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border-subtle); + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + min-width: 230px; + transition: all 0.3s ease; + } + + [data-theme="light"] .status-bar { + background: var(--bp-surface); + border: 2px solid var(--bp-primary); + box-shadow: 0 4px 16px rgba(14, 165, 233, 0.15); + } + + .status-dot { + width: 10px; + height: 10px; + border-radius: 999px; + background: var(--bp-text-muted); + } + + .status-dot.busy { + background: var(--bp-accent); + } + + .status-dot.error { + background: var(--bp-danger); + } + + .status-text-main { + font-size: 12px; + } + + .status-text-sub { + font-size: 11px; + color: var(--bp-text-muted); + } + + .footer { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + font-size: 11px; + color: var(--bp-text-muted); + border-top: 1px solid var(--bp-border-subtle); + background: var(--bp-surface-elevated); + transition: background 0.3s ease; + } + + .footer a { + color: var(--bp-text-muted); + text-decoration: none; + } + + .footer a:hover { + text-decoration: underline; + } + + .btn { + font-size: 13px; + font-weight: 500; + padding: 8px 16px; + border-radius: 8px; + border: 1px solid var(--bp-border-subtle); + background: var(--bp-surface-elevated); + color: var(--bp-text); + cursor: pointer; + transition: all 0.2s ease; + } + + .btn:hover:not(:disabled) { + border-color: var(--bp-primary); + transform: translateY(-1px); + } + + .btn-primary { + border-color: var(--bp-accent); + background: var(--bp-btn-primary-bg); + color: white; + } + + .btn-primary:hover:not(:disabled) { + background: var(--bp-btn-primary-hover); + box-shadow: 0 4px 12px rgba(34,197,94,0.3); + } + + [data-theme="light"] .btn-primary:hover:not(:disabled) { + box-shadow: 0 4px 12px rgba(14, 165, 233, 0.3); + } + + .btn-ghost { + background: transparent; + } + + .btn-ghost:hover:not(:disabled) { + background: var(--bp-surface-elevated); + } + + [data-theme="light"] .btn-ghost { + border-color: var(--bp-primary); + color: var(--bp-primary); + } + + [data-theme="light"] .btn-ghost:hover:not(:disabled) { + background: var(--bp-primary-soft); + } + + .btn-sm { + padding: 6px 12px; + font-size: 12px; + } + + .btn:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .panel-tools-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; + } + + .card-toggle-bar { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; + } + + .toggle-pill { + font-size: 11px; + padding: 4px 8px; + border-radius: 999px; + border: 1px solid var(--bp-border-subtle); + background: var(--bp-surface-elevated); + color: var(--bp-text-muted); + cursor: pointer; + transition: all 0.2s ease; + } + + .toggle-pill.active { + border-color: var(--bp-accent); + color: var(--bp-accent); + background: var(--bp-accent-soft); + } + + [data-theme="light"] .toggle-pill.active { + border-color: var(--bp-primary); + color: var(--bp-primary); + background: var(--bp-primary-soft); + } + +.cards-grid { + flex: 1; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-auto-rows: 1fr; + gap: 10px; + min-height: 0; + align-items: stretch; +} + + .card { + border-radius: 14px; + border: 1px solid var(--bp-border-subtle); + background: var(--bp-card-bg); + padding: 10px; + display: flex; + flex-direction: column; + cursor: pointer; + min-height: 0; + transition: all 0.3s ease; + } + + [data-theme="light"] .card { + border: 2px solid var(--bp-primary); + box-shadow: 0 4px 20px rgba(14, 165, 233, 0.1); + } + + [data-theme="light"] .card:hover { + box-shadow: 0 6px 28px rgba(14, 165, 233, 0.2); + transform: translateY(-2px); + } + + .card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 6px; + } + + [data-theme="light"] .card-header { + border-bottom: 1px solid var(--bp-border-subtle); + padding-bottom: 8px; + margin-bottom: 10px; + } + + .card-title { + font-size: 13px; + font-weight: 500; + } + + [data-theme="light"] .card-title { + font-size: 15px; + font-weight: 700; + color: var(--bp-text); + letter-spacing: -0.02em; + } + + .card-badge { + font-size: 10px; + border-radius: 999px; + padding: 2px 7px; + border: 1px solid var(--bp-border-subtle); + color: var(--bp-text-muted); + } + + [data-theme="light"] .card-badge { + background: linear-gradient(135deg, var(--bp-primary) 0%, var(--bp-accent) 100%); + color: white; + border: none; + font-weight: 600; + padding: 4px 10px; + } + + .card-body { + flex: 1; + display: flex; + flex-direction: column; + gap: 5px; + font-size: 11px; + color: var(--bp-text-muted); + } + + [data-theme="light"] .card-body { + font-size: 12px; + line-height: 1.6; + } + + .card-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 8px; + } + + .card-hidden { + display: none; + } + + .card-full { + grid-column: 1 / -1; + min-height: 220px; + } + + /* Vollbild-Overlay für Doppelklick */ + .lightbox { + position: fixed; + inset: 0; + background: rgba(15,23,42,0.96); + display: flex; + align-items: center; + justify-content: center; + z-index: 40; + } + + .lightbox.hidden { + display: none; + } + + .lightbox-inner { + position: relative; + max-width: 96vw; + max-height: 96vh; + } + + .lightbox-img { + max-width: 96vw; + max-height: 96vh; + object-fit: contain; + border-radius: 12px; + box-shadow: 0 20px 45px rgba(0,0,0,0.7); + } + + .lightbox-close { + position: absolute; + top: -32px; + right: 0; + font-size: 11px; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid rgba(148,163,184,0.6); + background: rgba(15,23,42,0.9); + color: var(--bp-text); + cursor: pointer; + } + + .lightbox-caption { + margin-top: 6px; + font-size: 11px; + color: var(--bp-text-muted); + text-align: center; + } + + /* Lerneinheiten-Projektleiste im Sidebar */ + .unit-form { + margin-top: 12px; + padding: 12px 10px; + border-radius: 10px; + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border-subtle); + display: flex; + flex-direction: column; + gap: 8px; + transition: all 0.3s ease; + } + + [data-theme="light"] .unit-form { + background: var(--bp-surface); + border: 2px solid var(--bp-primary); + box-shadow: 0 4px 16px rgba(14, 165, 233, 0.1); + } + + .unit-form-row { + display: flex; + gap: 6px; + } + + .unit-input { + flex: 1; + border-radius: 8px; + border: 1px solid var(--bp-border-subtle); + background: var(--bp-bg); + color: var(--bp-text); + font-size: 12px; + padding: 8px 10px; + transition: border-color 0.2s ease; + } + + [data-theme="light"] .unit-input { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + } + + .unit-input:focus { + outline: none; + border-color: var(--bp-primary); + } + + .unit-input::placeholder { + color: var(--bp-text-muted); + } + + .unit-list { + margin: 8px 0 0 0; + padding: 0 2px; + max-height: 160px; + overflow-y: auto; + list-style: none; + } + + .unit-item { + font-size: 12px; + padding: 8px 10px; + border-radius: 8px; + cursor: pointer; + color: var(--bp-text); + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + transition: all 0.15s ease; + margin-bottom: 4px; + } + + .unit-item:hover { + background: var(--bp-surface-elevated); + } + + [data-theme="light"] .unit-item:hover { + background: var(--bp-primary-soft); + } + + .unit-item.active { + background: var(--bp-surface-elevated); + color: var(--bp-accent); + border: 1px solid var(--bp-accent); + font-weight: 600; + } + + [data-theme="light"] .unit-item.active { + background: var(--bp-primary-soft); + color: var(--bp-primary); + border: 2px solid var(--bp-primary); + } + + .unit-item-meta { + font-size: 10px; + color: var(--bp-text-muted); + margin-top: 2px; + } + + .btn-unit-add { + align-self: flex-end; + } + + /* MC Preview Styles */ + .mc-preview { + margin-top: 8px; + max-height: 300px; + overflow-y: auto; + } + + .mc-question { + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border-subtle); + border-radius: 8px; + padding: 10px; + margin-bottom: 8px; + transition: all 0.3s ease; + } + + [data-theme="light"] .mc-question { + background: var(--bp-surface); + border: 1px solid var(--bp-primary); + box-shadow: 0 2px 8px rgba(14, 165, 233, 0.08); + } + + .mc-question-text { + font-size: 12px; + font-weight: 500; + margin-bottom: 8px; + color: var(--bp-text); + } + + .mc-options { + display: flex; + flex-direction: column; + gap: 4px; + } + + .mc-option { + font-size: 11px; + padding: 6px 10px; + border-radius: 6px; + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border-subtle); + cursor: pointer; + transition: all 0.15s ease; + } + + [data-theme="light"] .mc-option { + background: var(--bp-bg); + border: 1px solid var(--bp-border); + } + + .mc-option:hover { + background: var(--bp-accent-soft); + border-color: var(--bp-accent); + } + + [data-theme="light"] .mc-option:hover { + background: var(--bp-primary-soft); + border-color: var(--bp-primary); + } + + .mc-option.selected { + background: var(--bp-accent-soft); + border-color: var(--bp-accent); + } + + [data-theme="light"] .mc-option.selected { + background: var(--bp-primary-soft); + border-color: var(--bp-primary); + } + + .mc-option.correct { + background: rgba(90, 191, 96, 0.2); + border-color: var(--bp-accent); + } + + .mc-option.incorrect { + background: rgba(108, 27, 27, 0.2); + border-color: rgba(239,68,68,0.6); + } + + .mc-option-label { + font-weight: 600; + margin-right: 6px; + text-transform: uppercase; + } + + .mc-feedback { + margin-top: 8px; + font-size: 11px; + padding: 6px 8px; + border-radius: 6px; + background: rgba(34,197,94,0.1); + border: 1px solid rgba(34,197,94,0.3); + color: var(--bp-accent); + } + + .mc-stats { + display: flex; + gap: 12px; + margin-top: 8px; + padding: 8px; + background: var(--bp-surface-elevated); + border-radius: 6px; + font-size: 11px; + transition: all 0.3s ease; + } + + [data-theme="light"] .mc-stats { + background: var(--bp-bg); + border: 1px solid var(--bp-border); + } + + .mc-stats-item { + display: flex; + align-items: center; + gap: 4px; + } + + .mc-modal { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.85); + display: flex; + align-items: center; + justify-content: center; + z-index: 50; + } + + .mc-modal.hidden { + display: none; + } + + .mc-modal-content { + background: var(--bp-bg); + border: 1px solid var(--bp-border-subtle); + border-radius: 16px; + padding: 20px; + max-width: 600px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + } + + [data-theme="light"] .mc-modal-content { + background: var(--bp-surface); + border: 2px solid var(--bp-primary); + box-shadow: 0 8px 32px rgba(14, 165, 233, 0.2); + } + + .mc-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + } + + .mc-modal-title { + font-size: 18px; + font-weight: 600; + } + + .mc-modal-close { + font-size: 11px; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--bp-border-subtle); + background: var(--bp-surface-elevated); + color: var(--bp-text); + cursor: pointer; + } + + [data-theme="light"] .mc-modal-close { + border: 1px solid var(--bp-primary); + background: var(--bp-surface); + color: var(--bp-primary); + } + + [data-theme="light"] .mc-modal-close:hover { + background: var(--bp-primary); + color: white; + } + + /* Cloze / Lückentext Styles */ + .cloze-preview { + margin-top: 8px; + max-height: 250px; + overflow-y: auto; + } + + .cloze-item { + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border-subtle); + border-radius: 8px; + padding: 10px; + margin-bottom: 8px; + transition: all 0.3s ease; + } + + [data-theme="light"] .cloze-item { + background: var(--bp-surface); + border: 1px solid var(--bp-primary); + box-shadow: 0 2px 8px rgba(14, 165, 233, 0.08); + } + + .cloze-sentence { + font-size: 13px; + line-height: 1.8; + color: var(--bp-text); + } + + .cloze-gap { + display: inline-block; + min-width: 60px; + border-bottom: 2px solid var(--bp-accent); + margin: 0 2px; + padding: 2px 4px; + text-align: center; + background: var(--bp-accent-soft); + border-radius: 4px 4px 0 0; + } + + [data-theme="light"] .cloze-gap { + border-bottom-color: var(--bp-primary); + background: var(--bp-primary-soft); + } + + .cloze-gap-input { + width: 80px; + padding: 4px 8px; + font-size: 12px; + border: 1px solid var(--bp-border-subtle); + border-radius: 4px; + background: var(--bp-surface-elevated); + color: var(--bp-text); + text-align: center; + } + + [data-theme="light"] .cloze-gap-input { + background: var(--bp-surface); + border: 1px solid var(--bp-border); + } + + .cloze-gap-input:focus { + outline: none; + border-color: var(--bp-primary); + } + + .cloze-gap-input.correct { + border-color: var(--bp-accent); + background: rgba(90, 191, 96, 0.2); + } + + .cloze-gap-input.incorrect { + border-color: var(--bp-danger); + background: rgba(108, 27, 27, 0.2); + } + + .cloze-translation { + margin-top: 8px; + padding: 8px; + background: var(--bp-accent-soft); + border: 1px solid var(--bp-accent); + border-radius: 6px; + font-size: 11px; + color: var(--bp-text-muted); + } + + [data-theme="light"] .cloze-translation { + background: var(--bp-primary-soft); + border: 1px solid var(--bp-primary); + } + + .cloze-translation-label { + font-size: 10px; + color: var(--bp-text-muted); + margin-bottom: 4px; + } + + .cloze-hint { + font-size: 10px; + color: var(--bp-text-muted); + font-style: italic; + margin-top: 4px; + } + + .cloze-stats { + display: flex; + gap: 12px; + padding: 8px; + background: var(--bp-surface-elevated); + border-radius: 6px; + font-size: 11px; + margin-bottom: 8px; + } + + [data-theme="light"] .cloze-stats { + background: var(--bp-bg); + border: 1px solid var(--bp-border); + } + + .cloze-feedback { + margin-top: 6px; + font-size: 11px; + padding: 4px 8px; + border-radius: 4px; + } + + .cloze-feedback.correct { + background: rgba(34,197,94,0.15); + color: var(--bp-accent); + } + + .cloze-feedback.incorrect { + background: rgba(239,68,68,0.15); + color: #ef4444; + } + + /* Language Selector */ + .language-selector { + margin-right: 8px; + } + + .lang-select { + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border-subtle); + border-radius: 6px; + color: var(--bp-text); + padding: 6px 10px; + font-size: 12px; + cursor: pointer; + transition: all 0.2s ease; + } + + [data-theme="light"] .lang-select { + background: var(--bp-surface); + border: 1px solid var(--bp-primary); + color: var(--bp-text); + } + + .lang-select:hover { + border-color: var(--bp-primary); + } + + .lang-select:focus { + outline: none; + border-color: var(--bp-primary); + } + + [data-theme="light"] .lang-select option { + background: var(--bp-surface); + color: var(--bp-text); + } + + /* RTL Support for Arabic */ + [dir="rtl"] .sidebar { + border-right: none; + border-left: 1px solid rgba(148,163,184,0.2); + } + + [dir="rtl"] .main-layout { + direction: rtl; + } + + [dir="rtl"] .card-actions { + flex-direction: row-reverse; + } + + /* ========================================== + RICH TEXT EDITOR STYLES + ========================================== */ + .editor-container { + border: 1px solid var(--bp-border); + border-radius: 8px; + background: var(--bp-surface-elevated); + overflow: hidden; + } + + .editor-toolbar { + display: flex; + flex-wrap: wrap; + gap: 4px; + padding: 8px 12px; + border-bottom: 1px solid var(--bp-border); + background: var(--bp-surface); + } + + .editor-toolbar-group { + display: flex; + gap: 2px; + padding-right: 8px; + margin-right: 8px; + border-right: 1px solid var(--bp-border); + } + + .editor-toolbar-group:last-child { + border-right: none; + margin-right: 0; + padding-right: 0; + } + + .editor-btn { + padding: 6px 10px; + border: none; + background: transparent; + color: var(--bp-text); + cursor: pointer; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + transition: all 0.2s ease; + min-width: 32px; + } + + .editor-btn:hover { + background: var(--bp-border-subtle); + } + + .editor-btn.active { + background: var(--bp-primary); + color: white; + } + + .editor-btn-upload { + background: var(--bp-primary); + color: white; + padding: 6px 12px; + } + + .editor-btn-upload:hover { + filter: brightness(1.1); + } + + .editor-content { + min-height: 300px; + max-height: 500px; + overflow-y: auto; + padding: 16px; + color: var(--bp-text); + line-height: 1.6; + } + + .editor-content:focus { + outline: none; + } + + .editor-content[contenteditable="true"] { + cursor: text; + } + + .editor-content h1 { + font-size: 24px; + font-weight: 700; + margin: 0 0 16px 0; + } + + .editor-content h2 { + font-size: 20px; + font-weight: 600; + margin: 24px 0 12px 0; + } + + .editor-content h3 { + font-size: 17px; + font-weight: 600; + margin: 20px 0 10px 0; + } + + .editor-content p { + margin: 0 0 12px 0; + } + + .editor-content ul, .editor-content ol { + margin: 0 0 12px 0; + padding-left: 24px; + } + + .editor-content li { + margin-bottom: 4px; + } + + .editor-content strong { + font-weight: 600; + } + + .editor-content em { + font-style: italic; + } + + .editor-content a { + color: var(--bp-primary); + text-decoration: underline; + } + + .editor-content blockquote { + border-left: 3px solid var(--bp-primary); + margin: 16px 0; + padding: 8px 16px; + background: var(--bp-surface); + font-style: italic; + } + + .editor-content hr { + border: none; + border-top: 1px solid var(--bp-border); + margin: 20px 0; + } + + [data-theme="light"] .editor-container { + border-color: var(--bp-border); + } + + [data-theme="light"] .editor-toolbar { + background: var(--bp-bg); + border-color: var(--bp-border); + } + + [data-theme="light"] .editor-content { + background: var(--bp-surface); + } + + .word-upload-input { + display: none; + } + + .editor-status { + padding: 8px 12px; + font-size: 12px; + color: var(--bp-text-muted); + border-top: 1px solid var(--bp-border); + background: var(--bp-surface); + } + + /* ========================================== + Communication Panel Styles (Matrix + Jitsi) + ========================================== */ + + /* Room List Styles */ + .room-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: 8px; + cursor: pointer; + transition: background 0.2s; + } + + .room-item:hover { + background: var(--bp-surface-elevated); + } + + .room-item.active { + background: var(--bp-primary-soft); + border-left: 3px solid var(--bp-primary); + } + + .room-icon { + font-size: 20px; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: var(--bp-surface-elevated); + border-radius: 8px; + } + + .room-info { + flex: 1; + min-width: 0; + } + + .room-name { + font-weight: 500; + font-size: 13px; + color: var(--bp-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .room-preview { + font-size: 11px; + color: var(--bp-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .room-badge { + background: var(--bp-primary); + color: white; + font-size: 10px; + font-weight: 600; + padding: 2px 6px; + border-radius: 10px; + min-width: 18px; + text-align: center; + } + + /* Chat Message Styles */ + .chat-system-msg { + text-align: center; + padding: 8px 16px; + background: var(--bp-surface-elevated); + border-radius: 8px; + font-size: 12px; + color: var(--bp-text-muted); + } + + .chat-msg { + padding: 12px 16px; + border-radius: 12px; + max-width: 85%; + } + + .chat-msg-self { + background: var(--bp-primary-soft); + border: 1px solid var(--bp-border-subtle); + margin-left: auto; + } + + .chat-msg-other { + background: var(--bp-surface-elevated); + border: 1px solid var(--bp-border); + } + + .chat-msg-notification { + background: rgba(239, 68, 68, 0.1); + border: 1px solid rgba(239, 68, 68, 0.3); + } + + .chat-msg-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; + } + + .chat-msg-sender { + font-weight: 600; + font-size: 12px; + color: var(--bp-primary); + } + + .chat-msg-time { + font-size: 10px; + color: var(--bp-text-muted); + } + + .chat-msg-content { + font-size: 13px; + line-height: 1.5; + color: var(--bp-text); + } + + .chat-msg-actions { + display: flex; + gap: 6px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--bp-border); + } + + /* Button danger variant */ + .btn-danger { + background: #ef4444 !important; + color: white !important; + } + + .btn-danger:hover { + background: #dc2626 !important; + } \ No newline at end of file diff --git a/backend/frontend/static/js/customer.js b/backend/frontend/static/js/customer.js new file mode 100644 index 0000000..3f8a715 --- /dev/null +++ b/backend/frontend/static/js/customer.js @@ -0,0 +1,632 @@ +/** + * BreakPilot Customer Portal - Slim JavaScript + * + * Features: + * - Login/Register + * - My Consents View + * - Data Export Request + * - Legal Documents + * - Theme Toggle + */ + +// API Base URLs +const CONSENT_SERVICE_URL = 'http://localhost:8081'; +const BACKEND_URL = ''; + +// State +let currentUser = null; +let authToken = localStorage.getItem('bp_token'); + +// Initialize on DOM ready +document.addEventListener('DOMContentLoaded', () => { + initTheme(); + initEventListeners(); + checkAuth(); +}); + +// ============================================================ +// Theme +// ============================================================ + +function initTheme() { + const saved = localStorage.getItem('bp_theme') || 'light'; + document.body.setAttribute('data-theme', saved); + updateThemeIcon(saved); +} + +function toggleTheme() { + const current = document.body.getAttribute('data-theme'); + const next = current === 'dark' ? 'light' : 'dark'; + document.body.setAttribute('data-theme', next); + localStorage.setItem('bp_theme', next); + updateThemeIcon(next); +} + +function updateThemeIcon(theme) { + const icon = document.getElementById('theme-icon'); + if (icon) icon.textContent = theme === 'dark' ? '☀️' : '🌙'; +} + +// ============================================================ +// Event Listeners +// ============================================================ + +function initEventListeners() { + // Theme toggle + const themeBtn = document.getElementById('btn-theme'); + if (themeBtn) themeBtn.addEventListener('click', toggleTheme); + + // Login button + const loginBtn = document.getElementById('btn-login'); + if (loginBtn) loginBtn.addEventListener('click', showLoginModal); + + // Legal button + const legalBtn = document.getElementById('btn-legal'); + if (legalBtn) legalBtn.addEventListener('click', () => showLegalDocument('privacy')); + + // User menu toggle + const userMenuBtn = document.getElementById('user-menu-btn'); + if (userMenuBtn) { + userMenuBtn.addEventListener('click', () => { + document.getElementById('user-menu').classList.toggle('open'); + }); + } + + // Close user menu on outside click + document.addEventListener('click', (e) => { + const userMenu = document.getElementById('user-menu'); + if (userMenu && !userMenu.contains(e.target)) { + userMenu.classList.remove('open'); + } + }); + + // Login form + const loginForm = document.getElementById('login-form'); + if (loginForm) loginForm.addEventListener('submit', handleLogin); + + // Register form + const registerForm = document.getElementById('register-form'); + if (registerForm) registerForm.addEventListener('submit', handleRegister); + + // Forgot password form + const forgotForm = document.getElementById('forgot-form'); + if (forgotForm) forgotForm.addEventListener('submit', handleForgotPassword); + + // Profile form + const profileForm = document.getElementById('profile-form'); + if (profileForm) profileForm.addEventListener('submit', handleProfileUpdate); + + // Password form + const passwordForm = document.getElementById('password-form'); + if (passwordForm) passwordForm.addEventListener('submit', handlePasswordChange); +} + +// ============================================================ +// Authentication +// ============================================================ + +async function checkAuth() { + if (!authToken) { + showLoggedOutState(); + return; + } + + try { + const res = await fetch(`${CONSENT_SERVICE_URL}/api/v1/auth/me`, { + headers: { 'Authorization': `Bearer ${authToken}` } + }); + + if (res.ok) { + currentUser = await res.json(); + showLoggedInState(); + } else { + localStorage.removeItem('bp_token'); + authToken = null; + showLoggedOutState(); + } + } catch (err) { + console.error('Auth check failed:', err); + showLoggedOutState(); + } +} + +function showLoggedInState() { + document.getElementById('btn-login')?.classList.add('hidden'); + document.getElementById('user-menu')?.classList.remove('hidden'); + document.getElementById('welcome-section')?.classList.add('hidden'); + document.getElementById('dashboard')?.classList.remove('hidden'); + + if (currentUser) { + const initials = getInitials(currentUser.name || currentUser.email); + document.querySelectorAll('.user-avatar').forEach(el => el.textContent = initials); + document.getElementById('user-name').textContent = currentUser.name || 'Benutzer'; + document.getElementById('dropdown-name').textContent = currentUser.name || 'Benutzer'; + document.getElementById('dropdown-email').textContent = currentUser.email; + } + + loadConsentCount(); +} + +function showLoggedOutState() { + document.getElementById('btn-login')?.classList.remove('hidden'); + document.getElementById('user-menu')?.classList.add('hidden'); + document.getElementById('welcome-section')?.classList.remove('hidden'); + document.getElementById('dashboard')?.classList.add('hidden'); +} + +function getInitials(name) { + if (!name) return 'BP'; + const parts = name.split(' ').filter(p => p.length > 0); + if (parts.length >= 2) { + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + } + return name.substring(0, 2).toUpperCase(); +} + +async function handleLogin(e) { + e.preventDefault(); + const email = document.getElementById('login-email').value; + const password = document.getElementById('login-password').value; + const messageEl = document.getElementById('login-message'); + + try { + const res = await fetch(`${CONSENT_SERVICE_URL}/api/v1/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }) + }); + + const data = await res.json(); + + if (res.ok) { + authToken = data.token; + localStorage.setItem('bp_token', authToken); + currentUser = data.user; + showMessage(messageEl, 'Erfolgreich angemeldet!', 'success'); + setTimeout(() => { + closeAllModals(); + showLoggedInState(); + }, 500); + } else { + showMessage(messageEl, data.error || 'Anmeldung fehlgeschlagen', 'error'); + } + } catch (err) { + showMessage(messageEl, 'Verbindungsfehler', 'error'); + } +} + +async function handleRegister(e) { + e.preventDefault(); + const name = document.getElementById('register-name').value; + const email = document.getElementById('register-email').value; + const password = document.getElementById('register-password').value; + const passwordConfirm = document.getElementById('register-password-confirm').value; + const messageEl = document.getElementById('register-message'); + + if (password !== passwordConfirm) { + showMessage(messageEl, 'Passwörter stimmen nicht überein', 'error'); + return; + } + + try { + const res = await fetch(`${CONSENT_SERVICE_URL}/api/v1/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, email, password }) + }); + + const data = await res.json(); + + if (res.ok) { + showMessage(messageEl, 'Registrierung erfolgreich! Sie können sich jetzt anmelden.', 'success'); + setTimeout(() => switchAuthTab('login'), 1500); + } else { + showMessage(messageEl, data.error || 'Registrierung fehlgeschlagen', 'error'); + } + } catch (err) { + showMessage(messageEl, 'Verbindungsfehler', 'error'); + } +} + +async function handleForgotPassword(e) { + e.preventDefault(); + const email = document.getElementById('forgot-email').value; + const messageEl = document.getElementById('forgot-message'); + + try { + const res = await fetch(`${CONSENT_SERVICE_URL}/api/v1/auth/request-password-reset`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }) + }); + + showMessage(messageEl, 'Wenn ein Konto mit dieser E-Mail existiert, erhalten Sie einen Link zum Zurücksetzen.', 'success'); + } catch (err) { + showMessage(messageEl, 'Verbindungsfehler', 'error'); + } +} + +function logout() { + localStorage.removeItem('bp_token'); + authToken = null; + currentUser = null; + showLoggedOutState(); + document.getElementById('user-menu')?.classList.remove('open'); +} + +// ============================================================ +// Modals +// ============================================================ + +function showLoginModal() { + document.getElementById('login-modal')?.classList.add('active'); + switchAuthTab('login'); +} + +function showMyConsents() { + document.getElementById('consents-modal')?.classList.add('active'); + document.getElementById('user-menu')?.classList.remove('open'); + loadMyConsents(); +} + +function showDataExport() { + document.getElementById('export-modal')?.classList.add('active'); + document.getElementById('user-menu')?.classList.remove('open'); + loadExportRequests(); +} + +function showAccountSettings() { + document.getElementById('settings-modal')?.classList.add('active'); + document.getElementById('user-menu')?.classList.remove('open'); + + if (currentUser) { + document.getElementById('profile-name').value = currentUser.name || ''; + document.getElementById('profile-email').value = currentUser.email || ''; + } +} + +function showLegalDocument(type) { + const modal = document.getElementById('legal-modal'); + const titleEl = document.getElementById('legal-title'); + const loadingEl = document.getElementById('legal-loading'); + const contentEl = document.getElementById('legal-content'); + + const titles = { + privacy: 'Datenschutzerklärung', + terms: 'Allgemeine Geschäftsbedingungen', + imprint: 'Impressum', + cookies: 'Cookie-Richtlinie' + }; + + titleEl.textContent = titles[type] || 'Dokument'; + loadingEl.style.display = 'block'; + contentEl.innerHTML = ''; + modal?.classList.add('active'); + + loadLegalDocument(type).then(html => { + loadingEl.style.display = 'none'; + contentEl.innerHTML = html; + }).catch(() => { + loadingEl.style.display = 'none'; + contentEl.innerHTML = '

    Dokument konnte nicht geladen werden.

    '; + }); +} + +function showForgotPassword() { + document.getElementById('login-form')?.classList.add('hidden'); + document.getElementById('register-form')?.classList.add('hidden'); + document.getElementById('forgot-form')?.classList.remove('hidden'); + document.querySelectorAll('.auth-tab').forEach(t => t.classList.remove('active')); +} + +function closeAllModals() { + document.querySelectorAll('.modal.active').forEach(m => m.classList.remove('active')); +} + +function switchAuthTab(tab) { + document.querySelectorAll('.auth-tab').forEach(t => { + t.classList.toggle('active', t.dataset.tab === tab); + }); + document.getElementById('login-form')?.classList.toggle('hidden', tab !== 'login'); + document.getElementById('register-form')?.classList.toggle('hidden', tab !== 'register'); + document.getElementById('forgot-form')?.classList.add('hidden'); + + // Clear messages + document.querySelectorAll('.form-message').forEach(m => { + m.className = 'form-message'; + m.textContent = ''; + }); +} + +// ============================================================ +// Consents +// ============================================================ + +async function loadConsentCount() { + if (!authToken) return; + + try { + const res = await fetch(`${CONSENT_SERVICE_URL}/api/v1/consents/my`, { + headers: { 'Authorization': `Bearer ${authToken}` } + }); + + if (res.ok) { + const data = await res.json(); + const count = data.consents?.length || 0; + const badge = document.getElementById('consent-count'); + if (badge) badge.textContent = `${count} aktiv`; + } + } catch (err) { + console.error('Failed to load consent count:', err); + } +} + +async function loadMyConsents() { + const loadingEl = document.getElementById('consents-loading'); + const listEl = document.getElementById('consents-list'); + const emptyEl = document.getElementById('consents-empty'); + + loadingEl.style.display = 'block'; + listEl.innerHTML = ''; + emptyEl?.classList.add('hidden'); + + try { + const res = await fetch(`${CONSENT_SERVICE_URL}/api/v1/consents/my`, { + headers: { 'Authorization': `Bearer ${authToken}` } + }); + + if (res.ok) { + const data = await res.json(); + const consents = data.consents || []; + + loadingEl.style.display = 'none'; + + if (consents.length === 0) { + emptyEl?.classList.remove('hidden'); + return; + } + + listEl.innerHTML = consents.map(c => ` + + `).join(''); + } else { + loadingEl.style.display = 'none'; + listEl.innerHTML = '

    Fehler beim Laden der Zustimmungen.

    '; + } + } catch (err) { + loadingEl.style.display = 'none'; + listEl.innerHTML = '

    Verbindungsfehler.

    '; + } +} + +// ============================================================ +// Data Export (GDPR) +// ============================================================ + +async function requestDataExport() { + const messageEl = document.getElementById('export-message'); + const btn = document.getElementById('btn-request-export'); + + btn.disabled = true; + + try { + const res = await fetch(`${BACKEND_URL}/api/gdpr/request-export`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${authToken}`, + 'Content-Type': 'application/json' + } + }); + + if (res.ok) { + showMessage(messageEl, 'Ihre Anfrage wurde erfolgreich eingereicht. Sie erhalten eine E-Mail, sobald der Export bereit ist.', 'success'); + loadExportRequests(); + } else { + const data = await res.json(); + showMessage(messageEl, data.error || 'Anfrage fehlgeschlagen', 'error'); + } + } catch (err) { + showMessage(messageEl, 'Verbindungsfehler', 'error'); + } finally { + btn.disabled = false; + } +} + +async function loadExportRequests() { + const statusEl = document.getElementById('export-status'); + const listEl = document.getElementById('export-requests-list'); + + if (!statusEl || !listEl) return; + + try { + const res = await fetch(`${BACKEND_URL}/api/gdpr/my-requests`, { + headers: { 'Authorization': `Bearer ${authToken}` } + }); + + if (res.ok) { + const data = await res.json(); + const requests = data.requests || []; + + if (requests.length > 0) { + statusEl.classList.remove('hidden'); + listEl.innerHTML = requests.map(r => ` + + `).join(''); + } else { + statusEl.classList.add('hidden'); + } + } + } catch (err) { + console.error('Failed to load export requests:', err); + } +} + +// ============================================================ +// Legal Documents +// ============================================================ + +async function loadLegalDocument(type) { + try { + const res = await fetch(`${CONSENT_SERVICE_URL}/api/v1/documents/type/${type}/published`); + + if (res.ok) { + const data = await res.json(); + return data.content || '

    Dokument ist leer.

    '; + } + + return '

    Dokument nicht gefunden.

    '; + } catch (err) { + return '

    Fehler beim Laden.

    '; + } +} + +// ============================================================ +// Profile & Settings +// ============================================================ + +async function handleProfileUpdate(e) { + e.preventDefault(); + const name = document.getElementById('profile-name').value; + const messageEl = document.getElementById('profile-message'); + + try { + const res = await fetch(`${CONSENT_SERVICE_URL}/api/v1/auth/update-profile`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${authToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ name }) + }); + + if (res.ok) { + currentUser.name = name; + showMessage(messageEl, 'Profil aktualisiert', 'success'); + showLoggedInState(); + } else { + showMessage(messageEl, 'Aktualisierung fehlgeschlagen', 'error'); + } + } catch (err) { + showMessage(messageEl, 'Verbindungsfehler', 'error'); + } +} + +async function handlePasswordChange(e) { + e.preventDefault(); + const currentPassword = document.getElementById('current-password').value; + const newPassword = document.getElementById('new-password').value; + const confirmPassword = document.getElementById('new-password-confirm').value; + const messageEl = document.getElementById('password-message'); + + if (newPassword !== confirmPassword) { + showMessage(messageEl, 'Passwörter stimmen nicht überein', 'error'); + return; + } + + try { + const res = await fetch(`${CONSENT_SERVICE_URL}/api/v1/auth/change-password`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${authToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + current_password: currentPassword, + new_password: newPassword + }) + }); + + if (res.ok) { + showMessage(messageEl, 'Passwort geändert', 'success'); + document.getElementById('password-form').reset(); + } else { + const data = await res.json(); + showMessage(messageEl, data.error || 'Änderung fehlgeschlagen', 'error'); + } + } catch (err) { + showMessage(messageEl, 'Verbindungsfehler', 'error'); + } +} + +function confirmDeleteAccount() { + if (confirm('Sind Sie sicher, dass Sie Ihr Konto löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.')) { + if (confirm('Letzte Warnung: Alle Ihre Daten werden unwiderruflich gelöscht. Fortfahren?')) { + deleteAccount(); + } + } +} + +async function deleteAccount() { + try { + const res = await fetch(`${CONSENT_SERVICE_URL}/api/v1/auth/delete-account`, { + method: 'DELETE', + headers: { 'Authorization': `Bearer ${authToken}` } + }); + + if (res.ok) { + alert('Ihr Konto wurde gelöscht.'); + logout(); + closeAllModals(); + } else { + alert('Kontoloöschung fehlgeschlagen.'); + } + } catch (err) { + alert('Verbindungsfehler'); + } +} + +// ============================================================ +// Utilities +// ============================================================ + +function showMessage(el, text, type) { + if (!el) return; + el.textContent = text; + el.className = `form-message ${type}`; +} + +function escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +function formatDate(dateStr) { + if (!dateStr) return '-'; + const date = new Date(dateStr); + return date.toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }); +} + +// Export for global access +window.showLoginModal = showLoginModal; +window.showMyConsents = showMyConsents; +window.showDataExport = showDataExport; +window.showAccountSettings = showAccountSettings; +window.showLegalDocument = showLegalDocument; +window.showForgotPassword = showForgotPassword; +window.closeAllModals = closeAllModals; +window.switchAuthTab = switchAuthTab; +window.logout = logout; +window.requestDataExport = requestDataExport; +window.confirmDeleteAccount = confirmDeleteAccount; diff --git a/backend/frontend/static/js/modules/README.md b/backend/frontend/static/js/modules/README.md new file mode 100644 index 0000000..ddff599 --- /dev/null +++ b/backend/frontend/static/js/modules/README.md @@ -0,0 +1,154 @@ +# Studio JavaScript Modules + +Das monolithische studio.js (9.787 Zeilen) wurde in modulare ES6-Module aufgeteilt. + +## Modul-Struktur + +``` +backend/frontend/static/js/ +├── studio.js # Original (noch nicht aktualisiert) +└── modules/ + ├── theme.js # Dark/Light Mode (105 Zeilen) + ├── translations.js # Übersetzungen DE/EN (971 Zeilen) + ├── i18n.js # Internationalisierung (250 Zeilen) + ├── lightbox.js # Bildvorschau (234 Zeilen) + ├── api-helpers.js # API-Utilities (360 Zeilen) + ├── file-manager.js # Dateiverwaltung (614 Zeilen) + ├── learning-units-module.js # Lerneinheiten (517 Zeilen) + ├── mc-module.js # Multiple Choice (474 Zeilen) + ├── cloze-module.js # Lückentext (430 Zeilen) + ├── mindmap-module.js # Mindmap (223 Zeilen) + └── qa-leitner-module.js # Q&A / Leitner (444 Zeilen) +``` + +## Module-Übersicht + +### theme.js +- Dark/Light Mode Toggle +- Speichert Präferenz in localStorage +- Exports: `getCurrentTheme()`, `setTheme()`, `initThemeToggle()` + +### translations.js +- Übersetzungswörterbuch für DE/EN +- Export: `translations` Objekt + +### i18n.js +- Internationalisierungsfunktionen +- Exports: `t()`, `applyLanguage()`, `updateUITexts()` + +### lightbox.js +- Bildvorschau-Modal +- Exports: `openLightbox()`, `closeLightbox()` + +### api-helpers.js +- API-Fetch mit Fehlerbehandlung +- Status-Anzeige +- Exports: `apiFetch()`, `setStatus()` + +### file-manager.js +- Arbeitsblatt-Upload und -Verwaltung +- Eingang-Dateien laden +- Exports: `loadEingangFiles()`, `renderEingangList()`, usw. + +### learning-units-module.js +- Lerneinheiten CRUD +- Arbeitsblatt-Zuordnung +- Exports: `loadLearningUnits()`, `addUnitFromForm()`, usw. + +### mc-module.js +- Multiple Choice Generierung +- Quiz-Vorschau und Bewertung +- Exports: `generateMcQuestions()`, `renderMcPreview()`, usw. + +### cloze-module.js +- Lückentext-Generierung +- Interaktive Ausfüllung +- Exports: `generateClozeTexts()`, `renderClozePreview()`, usw. + +### mindmap-module.js +- Mindmap-Generierung +- SVG-Rendering +- Exports: `generateMindmap()`, `renderMindmapPreview()`, usw. + +### qa-leitner-module.js +- Frage-Antwort-Generierung +- Leitner-System Integration +- Exports: `generateQaQuestions()`, `renderQaPreview()`, usw. + +## Verwendung + +```javascript +// Als ES6 Modul importieren +import { getCurrentTheme, setTheme, initThemeToggle } from './modules/theme.js'; +import { t, applyLanguage } from './modules/i18n.js'; +import { openLightbox, closeLightbox } from './modules/lightbox.js'; +// ... + +// Theme initialisieren +initThemeToggle(); + +// Übersetzung abrufen +const label = t('btn_create'); +``` + +## TODO + +Die Haupt-studio.js sollte aktualisiert werden, um diese Module zu importieren: + +```javascript +// In studio.js +import * as Theme from './modules/theme.js'; +import * as I18n from './modules/i18n.js'; +import * as FileManager from './modules/file-manager.js'; +// ... +``` + +## Statistiken + +| Komponente | Zeilen | +|------------|--------| +| theme.js | 105 | +| translations.js | 971 | +| i18n.js | 250 | +| lightbox.js | 234 | +| api-helpers.js | 360 | +| file-manager.js | 614 | +| learning-units-module.js | 517 | +| mc-module.js | 474 | +| cloze-module.js | 430 | +| mindmap-module.js | 223 | +| qa-leitner-module.js | 444 | +| **Gesamt Module** | **4.622** | +| studio.js (Original) | 9.787 | + +## Remaining to Extract (~5,165 lines) + +The following sections remain in studio.js and should be extracted: + +| Section | Lines | Target Module | +|---------|-------|---------------| +| GDPR Functions | ~150 | gdpr-module.js | +| Legal Modal | ~200 | legal-module.js | +| Authentication | ~450 | auth-module.js | +| Notifications | ~400 | notifications-module.js | +| Word Upload | ~140 | upload-module.js | +| Admin Documents | ~940 | admin/documents.js | +| Cookie Categories Admin | ~130 | admin/cookies.js | +| Admin Stats | ~170 | admin/stats.js | +| User Data Export | ~55 | admin/export.js | +| DSR Management | ~450 | admin/dsr.js | +| DSMS Functions | ~520 | dsms-module.js | +| Email Templates | ~400 | admin/email-templates.js | +| Communication Panel | ~2,140 | communication-module.js | + +## Refactoring-Historie + +**03.02.2026**: Refactoring status documented +- Existing modules cover ~47% of original studio.js (4,622 of 9,787 lines) +- Remaining ~5,165 lines identified for future extraction +- Build tooling (Webpack/Vite) recommended for ES6 module bundling + +**19.01.2026**: Module aus studio.js extrahiert: +- Alle funktionalen Bereiche in separate ES6-Module aufgeteilt +- Module verwenden Export/Import-Syntax +- Original studio.js noch nicht aktualisiert (backward compatibility) diff --git a/backend/frontend/static/js/modules/api-helpers.js b/backend/frontend/static/js/modules/api-helpers.js new file mode 100644 index 0000000..971307e --- /dev/null +++ b/backend/frontend/static/js/modules/api-helpers.js @@ -0,0 +1,360 @@ +/** + * BreakPilot Studio - API Helpers Module + * + * Gemeinsame Funktionen für API-Aufrufe und Status-Verwaltung: + * - fetchJSON: Wrapper für fetch mit Error-Handling + * - postJSON: POST-Requests mit JSON-Body + * - setStatus: Status-Leiste aktualisieren + * - showNotification: Toast-Benachrichtigungen + * + * Refactored: 2026-01-19 + */ + +import { t } from './i18n.js'; + +// Status-Bar Element-Referenzen (werden bei init gesetzt) +let statusBar = null; +let statusDot = null; +let statusMain = null; +let statusSub = null; + +/** + * Initialisiert die Status-Bar Referenzen + * Sollte beim DOMContentLoaded aufgerufen werden + */ +export function initStatusBar() { + statusBar = document.getElementById('status-bar'); + statusDot = document.getElementById('status-dot'); + statusMain = document.getElementById('status-main'); + statusSub = document.getElementById('status-sub'); +} + +/** + * Setzt den Status in der Status-Leiste + * @param {string} type - 'ready'|'working'|'success'|'error' + * @param {string} main - Haupttext + * @param {string} [sub] - Optionaler Untertext + */ +export function setStatus(type, main, sub = '') { + if (!statusBar || !statusDot || !statusMain) { + console.log(`[Status ${type}]: ${main}`, sub); + return; + } + + // Alle Status-Klassen entfernen + statusBar.classList.remove('status-ready', 'status-working', 'status-success', 'status-error'); + statusDot.classList.remove('dot-ready', 'dot-working', 'dot-success', 'dot-error'); + + // Neue Status-Klasse setzen + statusBar.classList.add(`status-${type}`); + statusDot.classList.add(`dot-${type}`); + + // Texte setzen + statusMain.textContent = main; + if (statusSub) { + statusSub.textContent = sub; + } +} + +/** + * Setzt den Status auf "Bereit" + */ +export function setStatusReady() { + setStatus('ready', t('status_ready') || 'Bereit', ''); +} + +/** + * Setzt den Status auf "Arbeitet..." + * @param {string} message - Was gerade gemacht wird + */ +export function setStatusWorking(message) { + setStatus('working', message, ''); +} + +/** + * Setzt den Status auf "Erfolg" + * @param {string} message - Erfolgsmeldung + * @param {string} [details] - Optionale Details + */ +export function setStatusSuccess(message, details = '') { + setStatus('success', message, details); +} + +/** + * Setzt den Status auf "Fehler" + * @param {string} message - Fehlermeldung + * @param {string} [details] - Optionale Details + */ +export function setStatusError(message, details = '') { + setStatus('error', message, details); +} + +/** + * Führt einen GET-Request aus und parst JSON + * @param {string} url - Die URL + * @param {Object} [options] - Zusätzliche fetch-Optionen + * @returns {Promise} - Das geparste JSON + * @throws {Error} - Bei Netzwerk- oder Parse-Fehlern + */ +export async function fetchJSON(url, options = {}) { + const response = await fetch(url, { + ...options, + headers: { + 'Accept': 'application/json', + ...options.headers, + }, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + + return response.json(); +} + +/** + * Führt einen POST-Request mit JSON-Body aus + * @param {string} url - Die URL + * @param {Object} data - Die zu sendenden Daten + * @param {Object} [options] - Zusätzliche fetch-Optionen + * @returns {Promise} - Das geparste JSON + * @throws {Error} - Bei Netzwerk- oder Parse-Fehlern + */ +export async function postJSON(url, data = {}, options = {}) { + const response = await fetch(url, { + method: 'POST', + ...options, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + ...options.headers, + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + + return response.json(); +} + +/** + * Führt einen POST-Request ohne Body aus (für Trigger-Endpoints) + * @param {string} url - Die URL + * @param {Object} [options] - Zusätzliche fetch-Optionen + * @returns {Promise} - Das geparste JSON + */ +export async function postTrigger(url, options = {}) { + const response = await fetch(url, { + method: 'POST', + ...options, + headers: { + 'Accept': 'application/json', + ...options.headers, + }, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + + return response.json(); +} + +/** + * Führt einen DELETE-Request aus + * @param {string} url - Die URL + * @param {Object} [options] - Zusätzliche fetch-Optionen + * @returns {Promise} - Das geparste JSON + */ +export async function deleteRequest(url, options = {}) { + const response = await fetch(url, { + method: 'DELETE', + ...options, + headers: { + 'Accept': 'application/json', + ...options.headers, + }, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + + return response.json(); +} + +/** + * Lädt eine Datei hoch + * @param {string} url - Die Upload-URL + * @param {File|FormData} file - Die Datei oder FormData + * @param {function} [onProgress] - Progress-Callback (0-100) + * @returns {Promise} - Das geparste JSON + */ +export async function uploadFile(url, file, onProgress = null) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + xhr.open('POST', url); + + if (onProgress && xhr.upload) { + xhr.upload.addEventListener('progress', (e) => { + if (e.lengthComputable) { + const percent = Math.round((e.loaded / e.total) * 100); + onProgress(percent); + } + }); + } + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + try { + resolve(JSON.parse(xhr.responseText)); + } catch (e) { + resolve({ status: 'OK', message: xhr.responseText }); + } + } else { + reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`)); + } + }; + + xhr.onerror = () => reject(new Error('Network error during upload')); + + // FormData erstellen falls nötig + let formData; + if (file instanceof FormData) { + formData = file; + } else { + formData = new FormData(); + formData.append('file', file); + } + + xhr.send(formData); + }); +} + +/** + * Zeigt eine kurze Benachrichtigung (Toast) + * @param {string} message - Die Nachricht + * @param {string} [type='info'] - 'info'|'success'|'error'|'warning' + * @param {number} [duration=3000] - Anzeigedauer in ms + */ +export function showNotification(message, type = 'info', duration = 3000) { + // Prüfe ob Toast-Container existiert, sonst erstellen + let container = document.getElementById('toast-container'); + if (!container) { + container = document.createElement('div'); + container.id = 'toast-container'; + container.style.cssText = 'position:fixed;top:16px;right:16px;z-index:10000;display:flex;flex-direction:column;gap:8px;'; + document.body.appendChild(container); + } + + // Toast erstellen + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.style.cssText = ` + padding: 12px 16px; + border-radius: 8px; + background: var(--bp-card-bg, #1e293b); + color: var(--bp-text, #e2e8f0); + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + font-size: 13px; + animation: slideIn 0.3s ease; + border-left: 4px solid ${type === 'success' ? '#22c55e' : type === 'error' ? '#ef4444' : type === 'warning' ? '#f59e0b' : '#3b82f6'}; + `; + toast.textContent = message; + + container.appendChild(toast); + + // Nach duration entfernen + setTimeout(() => { + toast.style.animation = 'slideOut 0.3s ease'; + setTimeout(() => toast.remove(), 300); + }, duration); +} + +/** + * Wrapper für API-Aufrufe mit Status-Anzeige und Error-Handling + * @param {function} apiCall - Die async API-Funktion + * @param {Object} options - Optionen + * @param {string} options.workingMessage - Nachricht während des Ladens + * @param {string} options.successMessage - Nachricht bei Erfolg + * @param {string} options.errorMessage - Nachricht bei Fehler + * @returns {Promise} - Das Ergebnis oder null bei Fehler + */ +export async function withStatus(apiCall, options = {}) { + const { + workingMessage = 'Wird geladen...', + successMessage = 'Erfolgreich', + errorMessage = 'Fehler', + } = options; + + setStatusWorking(workingMessage); + + try { + const result = await apiCall(); + setStatusSuccess(successMessage); + return result; + } catch (error) { + console.error(errorMessage, error); + setStatusError(errorMessage, String(error.message || error)); + return null; + } +} + +/** + * Debounce-Funktion für häufige Events + * @param {function} func - Die zu debouncende Funktion + * @param {number} wait - Wartezeit in ms + * @returns {function} - Die gedebouncte Funktion + */ +export function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; +} + +/** + * Throttle-Funktion für Rate-Limiting + * @param {function} func - Die zu throttlende Funktion + * @param {number} limit - Minimaler Abstand in ms + * @returns {function} - Die gethrottlete Funktion + */ +export function throttle(func, limit) { + let inThrottle; + return function(...args) { + if (!inThrottle) { + func.apply(this, args); + inThrottle = true; + setTimeout(() => inThrottle = false, limit); + } + }; +} + +// CSS für Toast-Animationen (einmal injizieren) +if (typeof document !== 'undefined' && !document.getElementById('toast-styles')) { + const style = document.createElement('style'); + style.id = 'toast-styles'; + style.textContent = ` + @keyframes slideIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } + } + @keyframes slideOut { + from { transform: translateX(0); opacity: 1; } + to { transform: translateX(100%); opacity: 0; } + } + `; + document.head.appendChild(style); +} diff --git a/backend/frontend/static/js/modules/cloze-module.js b/backend/frontend/static/js/modules/cloze-module.js new file mode 100644 index 0000000..3e47e81 --- /dev/null +++ b/backend/frontend/static/js/modules/cloze-module.js @@ -0,0 +1,430 @@ +/** + * BreakPilot Studio - Cloze (Lückentext) Module + * + * Lückentext-Funktionalität mit Übersetzung: + * - Generierung von Lückentexten aus Arbeitsblättern + * - Mehrsprachige Übersetzungen (TR, AR, RU, UK, PL, EN) + * - Interaktives Übungsmodul mit Auswertung + * - Druckfunktion (mit/ohne Lösungen) + * + * Refactored: 2026-01-19 + */ + +import { t } from './i18n.js'; +import { setStatus } from './api-helpers.js'; + +// State +let currentClozeData = null; +let clozeAnswers = {}; + +// DOM References +let clozePreview = null; +let clozeBadge = null; +let clozeLanguageSelect = null; +let clozeModal = null; +let clozeModalBody = null; +let clozeModalClose = null; +let btnClozeGenerate = null; +let btnClozeShow = null; +let btnClozePrint = null; + +// Callbacks +let getEingangFilesCallback = null; +let getCurrentIndexCallback = null; + +/** + * Initialisiert das Cloze-Modul + * @param {Object} options - Konfiguration + */ +export function initClozeModule(options = {}) { + getEingangFilesCallback = options.getEingangFiles || (() => []); + getCurrentIndexCallback = options.getCurrentIndex || (() => 0); + + // DOM References + clozePreview = document.getElementById('cloze-preview') || options.previewEl; + clozeBadge = document.getElementById('cloze-badge') || options.badgeEl; + clozeLanguageSelect = document.getElementById('cloze-language') || options.languageSelectEl; + clozeModal = document.getElementById('cloze-modal') || options.modalEl; + clozeModalBody = document.getElementById('cloze-modal-body') || options.modalBodyEl; + clozeModalClose = document.getElementById('cloze-modal-close') || options.modalCloseEl; + btnClozeGenerate = document.getElementById('btn-cloze-generate') || options.generateBtn; + btnClozeShow = document.getElementById('btn-cloze-show') || options.showBtn; + btnClozePrint = document.getElementById('btn-cloze-print') || options.printBtn; + + // Event-Listener + if (btnClozeGenerate) { + btnClozeGenerate.addEventListener('click', generateClozeTexts); + } + + if (btnClozeShow) { + btnClozeShow.addEventListener('click', openClozeModal); + } + + if (btnClozePrint) { + btnClozePrint.addEventListener('click', openClozePrintDialog); + } + + if (clozeModalClose) { + clozeModalClose.addEventListener('click', closeClozeModal); + } + + if (clozeModal) { + clozeModal.addEventListener('click', (ev) => { + if (ev.target === clozeModal) { + closeClozeModal(); + } + }); + } + + // Event für Datei-Wechsel + window.addEventListener('fileSelected', () => { + loadClozePreviewForCurrent(); + }); +} + +/** + * Generiert Lückentexte für alle Arbeitsblätter + */ +export async function generateClozeTexts() { + const targetLang = clozeLanguageSelect ? clozeLanguageSelect.value : 'tr'; + + try { + setStatus(t('cloze_generating') || 'Generiere Lückentexte …', t('ai_working') || 'Bitte warten, KI arbeitet.', 'busy'); + if (clozeBadge) clozeBadge.textContent = t('generating') || 'Generiert...'; + + const resp = await fetch('/api/generate-cloze?target_language=' + targetLang, { method: 'POST' }); + if (!resp.ok) { + throw new Error('HTTP ' + resp.status); + } + + const result = await resp.json(); + if (result.status === 'OK' && result.generated.length > 0) { + setStatus(t('cloze_generated') || 'Lückentexte generiert', result.generated.length + ' ' + (t('files_created') || 'Dateien erstellt')); + if (clozeBadge) clozeBadge.textContent = t('ready') || 'Fertig'; + if (btnClozeShow) btnClozeShow.style.display = 'inline-block'; + if (btnClozePrint) btnClozePrint.style.display = 'inline-block'; + + // Lade Vorschau für aktuelle Datei + await loadClozePreviewForCurrent(); + } else if (result.errors && result.errors.length > 0) { + setStatus(t('cloze_error') || 'Fehler bei Lückentext-Generierung', result.errors[0].error, 'error'); + if (clozeBadge) clozeBadge.textContent = t('error') || 'Fehler'; + } else { + setStatus(t('no_cloze_generated') || 'Keine Lückentexte generiert', t('analysis_missing') || 'Möglicherweise fehlen Analyse-Daten.', 'error'); + if (clozeBadge) clozeBadge.textContent = t('ready') || 'Bereit'; + } + } catch (e) { + console.error('Lückentext-Generierung fehlgeschlagen:', e); + setStatus(t('cloze_error') || 'Fehler bei Lückentext-Generierung', String(e), 'error'); + if (clozeBadge) clozeBadge.textContent = t('error') || 'Fehler'; + } +} + +/** + * Lädt Cloze-Vorschau für die aktuelle Datei + */ +export async function loadClozePreviewForCurrent() { + const eingangFiles = getEingangFilesCallback(); + const currentIndex = getCurrentIndexCallback(); + + if (!eingangFiles.length) { + if (clozePreview) clozePreview.innerHTML = '
    ' + (t('no_worksheets') || 'Keine Arbeitsblätter vorhanden.') + '
    '; + return; + } + + const currentFile = eingangFiles[currentIndex]; + if (!currentFile) return; + + try { + const resp = await fetch('/api/cloze-data/' + encodeURIComponent(currentFile)); + const result = await resp.json(); + + if (result.status === 'OK' && result.data) { + currentClozeData = result.data; + renderClozePreview(result.data); + if (btnClozeShow) btnClozeShow.style.display = 'inline-block'; + if (btnClozePrint) btnClozePrint.style.display = 'inline-block'; + } else { + if (clozePreview) clozePreview.innerHTML = '
    ' + (t('no_cloze_for_worksheet') || 'Noch keine Lückentexte für dieses Arbeitsblatt generiert.') + '
    '; + currentClozeData = null; + if (btnClozeShow) btnClozeShow.style.display = 'none'; + if (btnClozePrint) btnClozePrint.style.display = 'none'; + } + } catch (e) { + console.error('Fehler beim Laden der Lückentext-Daten:', e); + if (clozePreview) clozePreview.innerHTML = ''; + } +} + +/** + * Rendert die Cloze-Vorschau + * @param {Object} clozeData - Cloze-Daten + */ +function renderClozePreview(clozeData) { + if (!clozePreview) return; + if (!clozeData || !clozeData.cloze_items || clozeData.cloze_items.length === 0) { + clozePreview.innerHTML = '
    ' + (t('no_cloze_texts') || 'Keine Lückentexte vorhanden.') + '
    '; + return; + } + + const items = clozeData.cloze_items; + const metadata = clozeData.metadata || {}; + + let html = ''; + + // Statistiken + html += '
    '; + if (metadata.subject) { + html += '
    ' + (t('subject') || 'Fach') + ': ' + escapeHtml(metadata.subject) + '
    '; + } + if (metadata.grade_level) { + html += '
    ' + (t('grade') || 'Stufe') + ': ' + escapeHtml(metadata.grade_level) + '
    '; + } + html += '
    ' + (t('sentences') || 'Sätze') + ': ' + items.length + '
    '; + if (metadata.total_gaps) { + html += '
    ' + (t('gaps') || 'Lücken') + ': ' + metadata.total_gaps + '
    '; + } + html += '
    '; + + // Zeige erste 2 Sätze als Vorschau + const previewItems = items.slice(0, 2); + previewItems.forEach((item, idx) => { + html += '
    '; + html += '
    ' + (idx + 1) + '. ' + item.sentence_with_gaps.replace(/___/g, '___') + '
    '; + + // Übersetzung anzeigen + if (item.translation && item.translation.full_sentence) { + html += '
    '; + html += '
    ' + (item.translation.language_name || t('translation') || 'Übersetzung') + ':
    '; + html += escapeHtml(item.translation.full_sentence); + html += '
    '; + } + html += '
    '; + }); + + if (items.length > 2) { + html += '
    + ' + (items.length - 2) + ' ' + (t('more_sentences') || 'weitere Sätze') + '
    '; + } + + clozePreview.innerHTML = html; +} + +/** + * Öffnet das Cloze-Modal + */ +export function openClozeModal() { + if (!currentClozeData || !currentClozeData.cloze_items) { + alert(t('no_cloze_texts') || 'Keine Lückentexte vorhanden. Bitte zuerst generieren.'); + return; + } + + clozeAnswers = {}; // Reset Antworten + renderClozeModal(currentClozeData); + if (clozeModal) clozeModal.classList.remove('hidden'); +} + +/** + * Schließt das Cloze-Modal + */ +export function closeClozeModal() { + if (clozeModal) clozeModal.classList.add('hidden'); +} + +/** + * Rendert den Modal-Inhalt + * @param {Object} clozeData - Cloze-Daten + */ +function renderClozeModal(clozeData) { + if (!clozeModalBody) return; + + const items = clozeData.cloze_items; + const metadata = clozeData.metadata || {}; + + let html = ''; + + // Header + html += '
    '; + if (metadata.source_title) { + html += '
    ' + (t('worksheet') || 'Arbeitsblatt') + ': ' + escapeHtml(metadata.source_title) + '
    '; + } + if (metadata.total_gaps) { + html += '
    ' + (t('total_gaps') || 'Lücken gesamt') + ': ' + metadata.total_gaps + '
    '; + } + html += '
    '; + + html += '
    ' + (t('cloze_instruction') || 'Fülle die Lücken aus und klicke auf "Prüfen".') + '
    '; + + // Alle Sätze mit Eingabefeldern + items.forEach((item, idx) => { + html += '
    '; + + // Satz mit Eingabefeldern statt ___ + let sentenceHtml = item.sentence_with_gaps; + const gaps = item.gaps || []; + + // Ersetze ___ durch Eingabefelder + let gapIndex = 0; + sentenceHtml = sentenceHtml.replace(/___/g, () => { + const gap = gaps[gapIndex] || { id: 'g' + gapIndex, word: '' }; + const inputId = item.id + '_' + gap.id; + gapIndex++; + return ''; + }); + + html += '
    ' + (idx + 1) + '. ' + sentenceHtml + '
    '; + + // Übersetzung als Hilfe + if (item.translation && item.translation.sentence_with_gaps) { + html += '
    '; + html += '
    ' + (item.translation.language_name || t('translation') || 'Übersetzung') + ' (' + (t('with_gaps') || 'mit Lücken') + '):
    '; + html += escapeHtml(item.translation.sentence_with_gaps); + html += '
    '; + } + + html += '
    '; + }); + + // Buttons + html += '
    '; + html += ''; + html += ''; + html += '
    '; + + clozeModalBody.innerHTML = html; + + // Event-Listener für Prüfen-Button + const btnCheck = document.getElementById('btn-cloze-check'); + if (btnCheck) { + btnCheck.addEventListener('click', checkClozeAnswers); + } + + // Event-Listener für Lösungen zeigen + const btnShowAnswers = document.getElementById('btn-cloze-show-answers'); + if (btnShowAnswers) { + btnShowAnswers.addEventListener('click', showClozeAnswers); + } + + // Enter-Taste zum Prüfen + clozeModalBody.querySelectorAll('.cloze-gap-input').forEach(input => { + input.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + checkClozeAnswers(); + } + }); + }); +} + +/** + * Überprüft die Cloze-Antworten + */ +function checkClozeAnswers() { + if (!clozeModalBody) return; + + let correct = 0; + let total = 0; + + clozeModalBody.querySelectorAll('.cloze-gap-input').forEach(input => { + const userAnswer = input.value.trim().toLowerCase(); + const correctAnswer = input.getAttribute('data-answer').toLowerCase(); + total++; + + // Entferne vorherige Klassen + input.classList.remove('correct', 'incorrect'); + + if (userAnswer === correctAnswer) { + input.classList.add('correct'); + correct++; + } else if (userAnswer !== '') { + input.classList.add('incorrect'); + } + }); + + // Zeige Ergebnis + let existingResult = clozeModalBody.querySelector('.cloze-result'); + if (existingResult) existingResult.remove(); + + const percentage = Math.round(correct / total * 100); + const resultHtml = '
    ' + + '
    ' + correct + ' ' + (t('of') || 'von') + ' ' + total + ' ' + (t('correct_answers') || 'richtig') + '
    ' + + '
    ' + percentage + '% ' + (t('percent_correct') || 'korrekt') + '
    ' + + '
    '; + + const resultDiv = document.createElement('div'); + resultDiv.innerHTML = resultHtml; + clozeModalBody.appendChild(resultDiv.firstChild); +} + +/** + * Zeigt alle Cloze-Lösungen + */ +function showClozeAnswers() { + if (!clozeModalBody) return; + + clozeModalBody.querySelectorAll('.cloze-gap-input').forEach(input => { + const correctAnswer = input.getAttribute('data-answer'); + input.value = correctAnswer; + input.classList.remove('incorrect'); + input.classList.add('correct'); + }); +} + +/** + * Öffnet den Druck-Dialog + */ +export function openClozePrintDialog() { + if (!currentClozeData) { + alert(t('no_cloze_texts') || 'Keine Lückentexte vorhanden.'); + return; + } + + const eingangFiles = getEingangFilesCallback(); + const currentIndex = getCurrentIndexCallback(); + const currentFile = eingangFiles[currentIndex]; + + const confirmMsg = (t('cloze_print_with_answers') || 'Mit Lösungen drucken?') + + '\n\nOK = ' + (t('with_filled_gaps') || 'Mit ausgefüllten Lücken') + + '\n' + (t('cancel') || 'Abbrechen') + ' = ' + (t('exercise_with_wordbank') || 'Übungsblatt mit Wortbank'); + + const choice = confirm(confirmMsg); + const url = '/api/print-cloze/' + encodeURIComponent(currentFile) + '?show_answers=' + choice; + window.open(url, '_blank'); +} + +// === Getter und Setter === + +/** + * Gibt die aktuellen Cloze-Daten zurück + * @returns {Object|null} + */ +export function getClozeData() { + return currentClozeData; +} + +/** + * Setzt die Cloze-Daten + * @param {Object} data + */ +export function setClozeData(data) { + currentClozeData = data; + if (data) { + renderClozePreview(data); + } +} + +/** + * Gibt die aktuelle Zielsprache zurück + * @returns {string} + */ +export function getTargetLanguage() { + return clozeLanguageSelect ? clozeLanguageSelect.value : 'tr'; +} + +/** + * Helper: HTML-Escape + */ +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} diff --git a/backend/frontend/static/js/modules/file-manager.js b/backend/frontend/static/js/modules/file-manager.js new file mode 100644 index 0000000..0752e64 --- /dev/null +++ b/backend/frontend/static/js/modules/file-manager.js @@ -0,0 +1,614 @@ +/** + * BreakPilot Studio - File Manager Module + * + * Datei-Verwaltung für den Arbeitsblatt-Editor: + * - Laden und Rendern der Dateiliste + * - Upload von Dateien + * - Löschen von Dateien + * - Vorschau-Funktionen + * - Navigation zwischen Dateien + * + * Refactored: 2026-01-19 + */ + +import { t } from './i18n.js'; +import { setStatus, setStatusWorking, setStatusError, setStatusSuccess, fetchJSON } from './api-helpers.js'; +import { openLightbox } from './lightbox.js'; + +// State +let allEingangFiles = []; +let eingangFiles = []; +let currentIndex = 0; +let currentSelectedFile = null; +let worksheetPairs = {}; +let allWorksheetPairs = {}; +let showOnlyUnitFiles = false; +let currentUnitId = null; + +// DOM References (werden bei init gesetzt) +let eingangListEl = null; +let eingangCountEl = null; +let previewContainer = null; +let fileInput = null; +let btnUploadInline = null; + +/** + * Initialisiert den File Manager + * @param {Object} options - Konfiguration + */ +export function initFileManager(options = {}) { + eingangListEl = document.getElementById('eingang-list') || options.listEl; + eingangCountEl = document.getElementById('eingang-count') || options.countEl; + previewContainer = document.getElementById('preview-container') || options.previewEl; + fileInput = document.getElementById('file-input') || options.fileInput; + btnUploadInline = document.getElementById('btn-upload-inline') || options.uploadBtn; + + // Upload-Button Event + if (btnUploadInline) { + btnUploadInline.addEventListener('click', handleUpload); + } + + // Initial load + loadEingangFiles(); + loadWorksheetPairs(); +} + +/** + * Setzt die aktuelle Lerneinheit + * @param {string} unitId - Die Unit-ID + */ +export function setCurrentUnit(unitId) { + currentUnitId = unitId; +} + +/** + * Setzt den Filter für Lerneinheit-Dateien + * @param {boolean} show - Nur Unit-Dateien anzeigen + */ +export function setShowOnlyUnitFiles(show) { + showOnlyUnitFiles = show; +} + +/** + * Gibt die aktuelle Dateiliste zurück + * @returns {string[]} - Liste der Dateinamen + */ +export function getFiles() { + return eingangFiles.slice(); +} + +/** + * Gibt den aktuellen Index zurück + * @returns {number} + */ +export function getCurrentIndex() { + return currentIndex; +} + +/** + * Setzt den aktuellen Index + * @param {number} idx + */ +export function setCurrentIndex(idx) { + currentIndex = idx; + renderEingangList(); + renderPreviewForCurrent(); +} + +/** + * Gibt den aktuell ausgewählten Dateinamen zurück + * @returns {string|null} + */ +export function getCurrentFile() { + return eingangFiles[currentIndex] || null; +} + +/** + * Lädt die Dateien aus dem Eingang + */ +export async function loadEingangFiles() { + try { + const data = await fetchJSON('/api/eingang-dateien'); + allEingangFiles = data.eingang || []; + eingangFiles = allEingangFiles.slice(); + currentIndex = 0; + renderEingangList(); + } catch (e) { + console.error('Fehler beim Laden der Dateien:', e); + setStatusError(t('error') || 'Fehler', String(e)); + } +} + +/** + * Lädt die Worksheet-Pairs (Original → Bereinigt) + */ +export async function loadWorksheetPairs() { + try { + const data = await fetchJSON('/api/worksheet-pairs'); + allWorksheetPairs = {}; + (data.pairs || []).forEach((p) => { + allWorksheetPairs[p.original] = { clean_html: p.clean_html, clean_image: p.clean_image }; + }); + worksheetPairs = { ...allWorksheetPairs }; + renderPreviewForCurrent(); + } catch (e) { + console.error('Fehler beim Laden der Neuaufbau-Daten:', e); + setStatusError(t('error') || 'Fehler', String(e)); + } +} + +/** + * Rendert die Dateiliste + */ +export function renderEingangList() { + if (!eingangListEl) return; + + eingangListEl.innerHTML = ''; + + if (!eingangFiles.length) { + const li = document.createElement('li'); + li.className = 'file-empty'; + li.textContent = t('no_files') || 'Noch keine Dateien vorhanden.'; + eingangListEl.appendChild(li); + if (eingangCountEl) { + eingangCountEl.textContent = '0 ' + (t('files') || 'Dateien'); + } + return; + } + + eingangFiles.forEach((filename, idx) => { + const li = document.createElement('li'); + li.className = 'file-item'; + if (idx === currentIndex) { + li.classList.add('active'); + } + + const nameSpan = document.createElement('span'); + nameSpan.className = 'file-item-name'; + nameSpan.textContent = filename; + + const actionsSpan = document.createElement('span'); + actionsSpan.style.display = 'flex'; + actionsSpan.style.gap = '6px'; + + // Button: Aus Lerneinheit entfernen + const removeFromUnitBtn = document.createElement('span'); + removeFromUnitBtn.className = 'file-item-delete'; + removeFromUnitBtn.textContent = '✕'; + removeFromUnitBtn.title = t('remove_from_unit') || 'Aus Lerneinheit entfernen'; + removeFromUnitBtn.addEventListener('click', (ev) => { + ev.stopPropagation(); + if (!currentUnitId) { + alert(t('select_unit_first') || 'Zum Entfernen bitte zuerst eine Lerneinheit auswählen.'); + return; + } + const ok = confirm(t('confirm_remove_from_unit') || 'Dieses Arbeitsblatt aus der aktuellen Lerneinheit entfernen? Die Datei selbst bleibt erhalten.'); + if (!ok) return; + removeWorksheetFromCurrentUnit(eingangFiles[idx]); + }); + + // Button: Datei komplett löschen + const deleteFileBtn = document.createElement('span'); + deleteFileBtn.className = 'file-item-delete'; + deleteFileBtn.textContent = '🗑️'; + deleteFileBtn.title = t('delete_file') || 'Datei komplett löschen'; + deleteFileBtn.style.color = '#ef4444'; + deleteFileBtn.addEventListener('click', async (ev) => { + ev.stopPropagation(); + const ok = confirm(t('confirm_delete_file') || `Datei "${eingangFiles[idx]}" wirklich komplett löschen? Diese Aktion kann nicht rückgängig gemacht werden.`); + if (!ok) return; + await deleteFileCompletely(eingangFiles[idx]); + }); + + actionsSpan.appendChild(removeFromUnitBtn); + actionsSpan.appendChild(deleteFileBtn); + + li.appendChild(nameSpan); + li.appendChild(actionsSpan); + + li.addEventListener('click', () => { + currentIndex = idx; + currentSelectedFile = filename; + renderEingangList(); + renderPreviewForCurrent(); + // Event für andere Module + window.dispatchEvent(new CustomEvent('fileSelected', { + detail: { filename, index: idx } + })); + }); + + eingangListEl.appendChild(li); + }); + + if (eingangCountEl) { + eingangCountEl.textContent = eingangFiles.length + ' ' + (eingangFiles.length === 1 ? (t('file') || 'Datei') : (t('files') || 'Dateien')); + } +} + +/** + * Rendert die Vorschau für die aktuelle Datei + */ +export function renderPreviewForCurrent() { + if (!previewContainer) return; + + if (!eingangFiles.length) { + const message = showOnlyUnitFiles && currentUnitId + ? (t('no_files_in_unit') || 'Dieser Lerneinheit sind noch keine Arbeitsblätter zugeordnet.') + : (t('no_files') || 'Keine Dateien vorhanden.'); + previewContainer.innerHTML = `
    ${message}
    `; + return; + } + + if (currentIndex < 0) currentIndex = 0; + if (currentIndex >= eingangFiles.length) currentIndex = eingangFiles.length - 1; + + const filename = eingangFiles[currentIndex]; + const entry = worksheetPairs[filename] || { clean_html: null, clean_image: null }; + + renderPreview(entry, currentIndex); +} + +/** + * Rendert die Vorschau (Original vs. Bereinigt) + * @param {Object} entry - Die Worksheet-Pair-Daten + * @param {number} index - Der Index + */ +function renderPreview(entry, index) { + if (!previewContainer) return; + + previewContainer.innerHTML = ''; + + const wrapper = document.createElement('div'); + wrapper.className = 'compare-wrapper'; + + // Original-Sektion + const originalSection = createPreviewSection( + t('original_scan') || 'Original-Scan', + t('old_left') || 'Alt (links)', + () => { + const img = document.createElement('img'); + img.className = 'preview-img'; + const imgSrc = '/preview-file/' + encodeURIComponent(eingangFiles[index]); + img.src = imgSrc; + img.alt = 'Original ' + eingangFiles[index]; + img.addEventListener('dblclick', () => openLightbox(imgSrc, eingangFiles[index])); + return img; + } + ); + + // Bereinigt-Sektion + const cleanSection = createPreviewSection( + t('rebuilt_worksheet') || 'Neu aufgebautes Arbeitsblatt', + createPrintButton(), + () => { + if (entry.clean_image) { + const imgClean = document.createElement('img'); + imgClean.className = 'preview-img'; + const cleanSrc = '/preview-clean-file/' + encodeURIComponent(entry.clean_image); + imgClean.src = cleanSrc; + imgClean.alt = 'Neu aufgebaut ' + eingangFiles[index]; + imgClean.addEventListener('dblclick', () => openLightbox(cleanSrc, eingangFiles[index] + ' (neu)')); + return imgClean; + } else if (entry.clean_html) { + const frame = document.createElement('iframe'); + frame.className = 'clean-frame'; + frame.src = '/api/clean-html/' + encodeURIComponent(entry.clean_html); + frame.title = t('rebuilt_worksheet') || 'Neu aufgebautes Arbeitsblatt'; + frame.addEventListener('dblclick', () => { + window.open('/api/clean-html/' + encodeURIComponent(entry.clean_html), '_blank'); + }); + return frame; + } else { + const placeholder = document.createElement('div'); + placeholder.className = 'preview-placeholder'; + placeholder.textContent = t('no_rebuild_data') || 'Noch keine Neuaufbau-Daten vorhanden.'; + return placeholder; + } + } + ); + + // Thumbnails in der Mitte + const thumbsColumn = document.createElement('div'); + thumbsColumn.className = 'preview-thumbnails'; + thumbsColumn.id = 'preview-thumbnails-middle'; + renderThumbnailsInColumn(thumbsColumn); + + wrapper.appendChild(originalSection); + wrapper.appendChild(thumbsColumn); + wrapper.appendChild(cleanSection); + + // Navigation + const navDiv = createNavigationButtons(); + wrapper.appendChild(navDiv); + + previewContainer.appendChild(wrapper); +} + +/** + * Erstellt eine Vorschau-Sektion + */ +function createPreviewSection(title, rightContent, contentFactory) { + const section = document.createElement('div'); + section.className = 'compare-section'; + + const header = document.createElement('div'); + header.className = 'compare-header'; + + const titleSpan = document.createElement('span'); + titleSpan.textContent = title; + + const rightSpan = document.createElement('span'); + rightSpan.style.display = 'flex'; + rightSpan.style.alignItems = 'center'; + rightSpan.style.gap = '8px'; + + if (typeof rightContent === 'string') { + rightSpan.textContent = rightContent; + } else if (rightContent instanceof Node) { + rightSpan.appendChild(rightContent); + } + + header.appendChild(titleSpan); + header.appendChild(rightSpan); + + const body = document.createElement('div'); + body.className = 'compare-body'; + + const inner = document.createElement('div'); + inner.className = 'compare-body-inner'; + + const content = contentFactory(); + inner.appendChild(content); + body.appendChild(inner); + + section.appendChild(header); + section.appendChild(body); + + return section; +} + +/** + * Erstellt den Druck-Button + */ +function createPrintButton() { + const container = document.createElement('span'); + container.style.display = 'flex'; + container.style.alignItems = 'center'; + container.style.gap = '8px'; + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn btn-sm btn-ghost no-print'; + btn.style.padding = '4px 10px'; + btn.style.fontSize = '11px'; + btn.textContent = '🖨️ ' + (t('print') || 'Drucken'); + btn.addEventListener('click', () => { + const currentFile = eingangFiles[currentIndex]; + if (!currentFile) { + alert(t('worksheet_no_data') || 'Keine Arbeitsblatt-Daten vorhanden.'); + return; + } + window.open('/api/print-worksheet/' + encodeURIComponent(currentFile), '_blank'); + }); + + const label = document.createElement('span'); + label.textContent = t('new_right') || 'Neu (rechts)'; + + container.appendChild(btn); + container.appendChild(label); + + return container; +} + +/** + * Rendert Thumbnails in einer Spalte + */ +function renderThumbnailsInColumn(container) { + container.innerHTML = ''; + + if (eingangFiles.length <= 1) return; + + const maxThumbs = 5; + let thumbCount = 0; + + for (let i = 0; i < eingangFiles.length && thumbCount < maxThumbs; i++) { + if (i === currentIndex) continue; + + const filename = eingangFiles[i]; + const thumb = document.createElement('div'); + thumb.className = 'preview-thumb'; + + const img = document.createElement('img'); + img.src = '/preview-file/' + encodeURIComponent(filename); + img.alt = filename; + + const label = document.createElement('div'); + label.className = 'preview-thumb-label'; + label.textContent = `${i + 1}`; + + thumb.appendChild(img); + thumb.appendChild(label); + + thumb.addEventListener('click', () => { + currentIndex = i; + renderEingangList(); + renderPreviewForCurrent(); + }); + + container.appendChild(thumb); + thumbCount++; + } +} + +/** + * Erstellt die Navigations-Buttons + */ +function createNavigationButtons() { + const navDiv = document.createElement('div'); + navDiv.className = 'preview-nav'; + + const prevBtn = document.createElement('button'); + prevBtn.type = 'button'; + prevBtn.textContent = '‹'; + prevBtn.disabled = currentIndex === 0; + prevBtn.addEventListener('click', () => { + if (currentIndex > 0) { + currentIndex--; + renderEingangList(); + renderPreviewForCurrent(); + } + }); + + const nextBtn = document.createElement('button'); + nextBtn.type = 'button'; + nextBtn.textContent = '›'; + nextBtn.disabled = currentIndex >= eingangFiles.length - 1; + nextBtn.addEventListener('click', () => { + if (currentIndex < eingangFiles.length - 1) { + currentIndex++; + renderEingangList(); + renderPreviewForCurrent(); + } + }); + + const positionSpan = document.createElement('span'); + positionSpan.textContent = `${currentIndex + 1} ${t('of') || 'von'} ${eingangFiles.length}`; + + navDiv.appendChild(prevBtn); + navDiv.appendChild(positionSpan); + navDiv.appendChild(nextBtn); + + return navDiv; +} + +/** + * Handle File Upload + */ +async function handleUpload(ev) { + ev.preventDefault(); + ev.stopPropagation(); + + if (!fileInput) return; + + const files = fileInput.files; + if (!files || !files.length) { + alert(t('select_files_first') || 'Bitte erst Dateien auswählen.'); + return; + } + + const formData = new FormData(); + for (const file of files) { + formData.append('files', file); + } + + try { + setStatusWorking(t('uploading') || 'Upload läuft …'); + + const resp = await fetch('/api/upload-multi', { + method: 'POST', + body: formData, + }); + + if (!resp.ok) { + console.error('Upload-Fehler: HTTP', resp.status); + setStatusError(t('upload_error') || 'Fehler beim Upload', 'HTTP ' + resp.status); + return; + } + + setStatusSuccess(t('upload_complete') || 'Upload abgeschlossen'); + fileInput.value = ''; + + // Liste neu laden + await loadEingangFiles(); + await loadWorksheetPairs(); + } catch (e) { + console.error('Netzwerkfehler beim Upload', e); + setStatusError(t('network_error') || 'Netzwerkfehler', String(e)); + } +} + +/** + * Entfernt ein Arbeitsblatt aus der aktuellen Lerneinheit + * @param {string} filename - Der Dateiname + */ +async function removeWorksheetFromCurrentUnit(filename) { + if (!currentUnitId) return; + + try { + setStatusWorking(t('removing_from_unit') || 'Entferne aus Lerneinheit...'); + + const resp = await fetch(`/api/units/${currentUnitId}/worksheets/${encodeURIComponent(filename)}`, { + method: 'DELETE' + }); + + if (!resp.ok) { + throw new Error('HTTP ' + resp.status); + } + + setStatusSuccess(t('removed_from_unit') || 'Aus Lerneinheit entfernt'); + await loadEingangFiles(); + } catch (e) { + console.error('Fehler beim Entfernen:', e); + setStatusError(t('error') || 'Fehler', String(e)); + } +} + +/** + * Löscht eine Datei komplett + * @param {string} filename - Der Dateiname + */ +async function deleteFileCompletely(filename) { + try { + setStatusWorking(t('deleting_file') || 'Lösche Datei...'); + + const resp = await fetch('/api/delete-file/' + encodeURIComponent(filename), { + method: 'DELETE' + }); + + if (!resp.ok) { + throw new Error('HTTP ' + resp.status); + } + + setStatusSuccess(t('file_deleted') || 'Datei gelöscht'); + await loadEingangFiles(); + await loadWorksheetPairs(); + } catch (e) { + console.error('Fehler beim Löschen:', e); + setStatusError(t('error') || 'Fehler', String(e)); + } +} + +/** + * Navigiert zur nächsten Datei + */ +export function nextFile() { + if (currentIndex < eingangFiles.length - 1) { + currentIndex++; + renderEingangList(); + renderPreviewForCurrent(); + } +} + +/** + * Navigiert zur vorherigen Datei + */ +export function prevFile() { + if (currentIndex > 0) { + currentIndex--; + renderEingangList(); + renderPreviewForCurrent(); + } +} + +/** + * Aktualisiert die Worksheet-Pairs für einen bestimmten Dateinamen + * @param {string} filename + * @param {Object} pair + */ +export function updateWorksheetPair(filename, pair) { + worksheetPairs[filename] = pair; + allWorksheetPairs[filename] = pair; + if (eingangFiles[currentIndex] === filename) { + renderPreviewForCurrent(); + } +} diff --git a/backend/frontend/static/js/modules/i18n.js b/backend/frontend/static/js/modules/i18n.js new file mode 100644 index 0000000..f114d31 --- /dev/null +++ b/backend/frontend/static/js/modules/i18n.js @@ -0,0 +1,250 @@ +/** + * BreakPilot Studio - i18n Module + * + * Internationalisierungs-Funktionen: + * - t(key): Übersetzungsfunktion + * - setLanguage(lang): Sprache wechseln + * - applyLanguage(): UI-Texte aktualisieren + * - getCurrentLang(): Aktuelle Sprache abrufen + * + * Refactored: 2026-01-19 + */ + +import { translations, rtlLanguages, defaultLanguage, availableLanguages } from './translations.js'; + +// Aktuelle Sprache (aus localStorage oder Standard) +let currentLang = localStorage.getItem('bp_language') || defaultLanguage; + +/** + * Übersetzungsfunktion + * @param {string} key - Übersetzungsschlüssel + * @returns {string} - Übersetzter Text oder Fallback + */ +export function t(key) { + const lang = translations[currentLang] || translations[defaultLanguage]; + return lang[key] || translations[defaultLanguage][key] || key; +} + +/** + * Aktuelle Sprache abrufen + * @returns {string} - Sprachcode (de, en, tr, etc.) + */ +export function getCurrentLang() { + return currentLang; +} + +/** + * Prüft ob aktuelle Sprache RTL ist + * @returns {boolean} + */ +export function isRTL() { + return rtlLanguages.includes(currentLang); +} + +/** + * Sprache wechseln + * @param {string} lang - Neuer Sprachcode + */ +export function setLanguage(lang) { + if (translations[lang]) { + currentLang = lang; + localStorage.setItem('bp_language', lang); + applyLanguage(); + return true; + } + console.warn(`Language '${lang}' not available`); + return false; +} + +/** + * Wendet die aktuelle Sprache auf alle UI-Elemente an + */ +export function applyLanguage() { + // RTL-Unterstützung + if (isRTL()) { + document.body.classList.add('rtl'); + document.documentElement.setAttribute('dir', 'rtl'); + } else { + document.body.classList.remove('rtl'); + document.documentElement.setAttribute('dir', 'ltr'); + } + + // Alle Elemente mit data-i18n-Attribut aktualisieren + document.querySelectorAll('[data-i18n]').forEach(el => { + const key = el.getAttribute('data-i18n'); + const translated = t(key); + + // Verschiedene Element-Typen behandeln + if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') { + el.placeholder = translated; + } else { + el.textContent = translated; + } + }); + + // Elemente mit data-i18n-title für Tooltips + document.querySelectorAll('[data-i18n-title]').forEach(el => { + const key = el.getAttribute('data-i18n-title'); + el.title = t(key); + }); + + // Elemente mit data-i18n-value für value-Attribute + document.querySelectorAll('[data-i18n-value]').forEach(el => { + const key = el.getAttribute('data-i18n-value'); + el.value = t(key); + }); + + // Custom Event für andere Module + window.dispatchEvent(new CustomEvent('languageChanged', { + detail: { language: currentLang } + })); +} + +/** + * Aktualisiert spezifische UI-Texte (Legacy-Kompatibilität) + * Diese Funktion wird von älterem Code verwendet der direkt UI-IDs referenziert + */ +export function updateUITexts() { + // Sidebar + const sidebarAreas = document.querySelector('.sidebar h4'); + if (sidebarAreas) sidebarAreas.textContent = t('sidebar_areas'); + + // Breadcrumb / Brand + const brandSub = document.querySelector('.brand-sub'); + if (brandSub) brandSub.textContent = t('brand_sub'); + + // Tab Labels + const navCompare = document.getElementById('nav-compare'); + if (navCompare) navCompare.textContent = t('nav_compare'); + + const navTiles = document.getElementById('nav-tiles'); + if (navTiles) navTiles.textContent = t('nav_tiles'); + + // Buttons + const uploadBtn = document.getElementById('uploadBtn'); + if (uploadBtn) { + const textSpan = uploadBtn.querySelector('.btn-text'); + if (textSpan) textSpan.textContent = t('btn_upload'); + } + + const deleteBtn = document.getElementById('deleteBtn'); + if (deleteBtn) { + const textSpan = deleteBtn.querySelector('.btn-text'); + if (textSpan) textSpan.textContent = t('btn_delete'); + } + + // Card Headers + document.querySelectorAll('.card-header').forEach(header => { + const icon = header.querySelector('i'); + const iconHTML = icon ? icon.outerHTML : ''; + + // Original / Cleaned Sections + if (header.closest('.scan-section')?.classList.contains('original-scan')) { + header.innerHTML = iconHTML + ' ' + t('original_scan'); + } else if (header.closest('.scan-section')?.classList.contains('cleaned-scan')) { + header.innerHTML = iconHTML + ' ' + t('cleaned_version'); + } + }); + + // Tiles - MC + const mcTile = document.querySelector('.mc-tile'); + if (mcTile) { + const title = mcTile.querySelector('.tile-content h3'); + if (title) title.textContent = t('mc_title'); + const desc = mcTile.querySelector('.tile-content p'); + if (desc) desc.textContent = t('mc_desc'); + } + + // Tiles - Cloze + const clozeTile = document.querySelector('.cloze-tile'); + if (clozeTile) { + const title = clozeTile.querySelector('.tile-content h3'); + if (title) title.textContent = t('cloze_title'); + const desc = clozeTile.querySelector('.tile-content p'); + if (desc) desc.textContent = t('cloze_desc'); + } + + // Tiles - Q&A + const qaTile = document.querySelector('.qa-tile'); + if (qaTile) { + const title = qaTile.querySelector('.tile-content h3'); + if (title) title.textContent = t('qa_title'); + const desc = qaTile.querySelector('.tile-content p'); + if (desc) desc.textContent = t('qa_desc'); + } + + // Tiles - Mindmap + const mindmapTile = document.querySelector('.mindmap-tile'); + if (mindmapTile) { + const title = mindmapTile.querySelector('.tile-content h3'); + if (title) title.textContent = t('mindmap_title'); + const desc = mindmapTile.querySelector('.tile-content p'); + if (desc) desc.textContent = t('mindmap_desc'); + } + + // Footer + const imprintLink = document.querySelector('footer a[href*="imprint"]'); + if (imprintLink) imprintLink.textContent = t('imprint'); + + const privacyLink = document.querySelector('footer a[href*="privacy"]'); + if (privacyLink) privacyLink.textContent = t('privacy'); + + const contactLink = document.querySelector('footer a[href*="contact"]'); + if (contactLink) contactLink.textContent = t('contact'); + + // Process Button + const fullProcessBtn = document.getElementById('fullProcessBtn'); + if (fullProcessBtn) { + const textSpan = fullProcessBtn.querySelector('.btn-text'); + if (textSpan) textSpan.textContent = t('btn_full_process'); + } + + // Status Bar + const statusText = document.getElementById('statusText'); + if (statusText && statusText.textContent === 'Bereit' || statusText?.textContent === 'Ready') { + statusText.textContent = t('status_ready'); + } +} + +/** + * Initialisiert das Sprachwahl-UI + * @param {string} containerId - ID des Containers für Sprachauswahl + */ +export function initLanguageSelector(containerId = 'language-selector') { + const container = document.getElementById(containerId); + if (!container) return; + + // Dropdown erstellen + container.innerHTML = ` + + `; + + // Event Handler + const select = document.getElementById('language-select'); + if (select) { + select.addEventListener('change', (e) => { + setLanguage(e.target.value); + }); + } +} + +/** + * Formatiert einen Status-Text mit Platzhaltern + * @param {string} key - Übersetzungsschlüssel + * @param {Object} vars - Variablen zum Einsetzen + * @returns {string} - Formatierter Text + */ +export function tFormat(key, vars = {}) { + let text = t(key); + Object.entries(vars).forEach(([k, v]) => { + text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), v); + }); + return text; +} + +// Re-export für Convenience +export { translations, rtlLanguages, defaultLanguage, availableLanguages }; diff --git a/backend/frontend/static/js/modules/learning-units-module.js b/backend/frontend/static/js/modules/learning-units-module.js new file mode 100644 index 0000000..87d6ea4 --- /dev/null +++ b/backend/frontend/static/js/modules/learning-units-module.js @@ -0,0 +1,517 @@ +/** + * BreakPilot Studio - Learning Units Module + * + * Lerneinheiten-Verwaltung: + * - Laden, Erstellen, Löschen von Lerneinheiten + * - Zuordnung von Arbeitsblättern zu Lerneinheiten + * - Filter für Lerneinheiten-spezifische Ansicht + * + * Refactored: 2026-01-19 + */ + +import { t } from './i18n.js'; +import { setStatus } from './api-helpers.js'; + +// State +let units = []; +let currentUnitId = null; +let showOnlyUnitFiles = false; + +// DOM References +let unitListEl = null; +let unitHeading1 = null; +let unitHeading2 = null; +let btnAddUnit = null; +let btnToggleFilter = null; +let btnAttachCurrentToLu = null; + +// Form Inputs +let unitStudentInput = null; +let unitSubjectInput = null; +let unitGradeInput = null; +let unitTitleInput = null; + +// Callbacks für File-Manager Integration +let getEingangFilesCallback = null; +let getAllEingangFilesCallback = null; +let getAllWorksheetPairsCallback = null; +let setFilteredDataCallback = null; +let renderListCallback = null; +let renderPreviewCallback = null; +let getCurrentWorksheetBasenameCallback = null; + +/** + * Initialisiert das Learning Units Modul + * @param {Object} options - Konfiguration + */ +export function initLearningUnitsModule(options = {}) { + // DOM-Referenzen + unitListEl = document.getElementById('unit-list') || options.unitListEl; + unitHeading1 = document.getElementById('unit-heading-1') || options.unitHeading1; + unitHeading2 = document.getElementById('unit-heading-2') || options.unitHeading2; + btnAddUnit = document.getElementById('btn-add-unit') || options.btnAddUnit; + btnToggleFilter = document.getElementById('btn-toggle-filter') || options.btnToggleFilter; + btnAttachCurrentToLu = document.getElementById('btn-attach-current-to-lu') || options.btnAttachCurrentToLu; + + // Form Inputs + unitStudentInput = document.getElementById('unit-student') || options.unitStudentInput; + unitSubjectInput = document.getElementById('unit-subject') || options.unitSubjectInput; + unitGradeInput = document.getElementById('unit-grade') || options.unitGradeInput; + unitTitleInput = document.getElementById('unit-title') || options.unitTitleInput; + + // Callbacks + getEingangFilesCallback = options.getEingangFiles || (() => []); + getAllEingangFilesCallback = options.getAllEingangFiles || (() => []); + getAllWorksheetPairsCallback = options.getAllWorksheetPairs || (() => ({})); + setFilteredDataCallback = options.setFilteredData || (() => {}); + renderListCallback = options.renderList || (() => {}); + renderPreviewCallback = options.renderPreview || (() => {}); + getCurrentWorksheetBasenameCallback = options.getCurrentWorksheetBasename || (() => null); + + // Event-Listener + if (btnAddUnit) { + btnAddUnit.addEventListener('click', (ev) => { + ev.preventDefault(); + addUnitFromForm(); + }); + } + + if (btnAttachCurrentToLu) { + btnAttachCurrentToLu.addEventListener('click', (ev) => { + ev.preventDefault(); + attachCurrentWorksheetToUnit(); + }); + } + + if (btnToggleFilter) { + btnToggleFilter.addEventListener('click', () => { + showOnlyUnitFiles = !showOnlyUnitFiles; + if (showOnlyUnitFiles) { + btnToggleFilter.textContent = t('only_unit') || 'Nur Lerneinheit'; + btnToggleFilter.classList.add('btn-primary'); + } else { + btnToggleFilter.textContent = t('all_files') || 'Alle Dateien'; + btnToggleFilter.classList.remove('btn-primary'); + } + applyUnitFilter(); + }); + } + + // Initial laden + loadLearningUnits(); +} + +/** + * Aktualisiert die Überschrift mit dem Namen der aktuellen Lerneinheit + * @param {Object|null} unit - Lerneinheit oder null + */ +function updateUnitHeading(unit = null) { + if (!unit && currentUnitId && units && units.length) { + unit = units.find((u) => u.id === currentUnitId) || null; + } + + let text = t('no_unit_selected') || 'Keine Lerneinheit ausgewählt'; + if (unit) { + const name = unit.label || unit.title || t('learning_unit') || 'Lerneinheit'; + text = (t('learning_unit') || 'Lerneinheit') + ': ' + name; + } + + if (unitHeading1) unitHeading1.textContent = text; + if (unitHeading2) unitHeading2.textContent = text; +} + +/** + * Wendet den Lerneinheiten-Filter an + * Zeigt nur Dateien der aktuellen Lerneinheit oder alle Dateien + */ +export function applyUnitFilter() { + let unit = null; + if (currentUnitId && units && units.length) { + unit = units.find((u) => u.id === currentUnitId) || null; + } + + const allEingangFiles = getAllEingangFilesCallback(); + const allWorksheetPairs = getAllWorksheetPairsCallback(); + + // Wenn Filter deaktiviert ODER keine Lerneinheit ausgewählt -> alle Dateien anzeigen + if (!showOnlyUnitFiles || !unit || !Array.isArray(unit.worksheet_files) || unit.worksheet_files.length === 0) { + setFilteredDataCallback(allEingangFiles.slice(), { ...allWorksheetPairs }, 0); + renderListCallback(); + renderPreviewCallback(); + updateUnitHeading(unit); + return; + } + + // Filter aktiv: nur Dateien der aktuellen Lerneinheit anzeigen + const allowed = new Set(unit.worksheet_files || []); + const filteredFiles = allEingangFiles.filter((f) => allowed.has(f)); + + const filteredPairs = {}; + Object.keys(allWorksheetPairs).forEach((key) => { + if (allowed.has(key)) { + filteredPairs[key] = allWorksheetPairs[key]; + } + }); + + setFilteredDataCallback(filteredFiles, filteredPairs, 0); + renderListCallback(); + renderPreviewCallback(); + updateUnitHeading(unit); +} + +/** + * Lädt alle Lerneinheiten vom Server + */ +export async function loadLearningUnits() { + try { + const resp = await fetch('/api/learning-units/'); + if (!resp.ok) { + console.error('Fehler beim Laden der Lerneinheiten', resp.status); + return; + } + units = await resp.json(); + if (units.length && !currentUnitId) { + currentUnitId = units[0].id; + } + renderUnits(); + applyUnitFilter(); + } catch (e) { + console.error('Netzwerkfehler beim Laden der Lerneinheiten', e); + } +} + +/** + * Rendert die Lerneinheiten-Liste + */ +export function renderUnits() { + if (!unitListEl) return; + + unitListEl.innerHTML = ''; + + if (!units.length) { + const li = document.createElement('li'); + li.className = 'unit-item'; + li.textContent = t('no_units_yet') || 'Noch keine Lerneinheiten angelegt.'; + unitListEl.appendChild(li); + updateUnitHeading(null); + return; + } + + units.forEach((u) => { + const li = document.createElement('li'); + li.className = 'unit-item'; + if (u.id === currentUnitId) { + li.classList.add('active'); + } + + const contentDiv = document.createElement('div'); + contentDiv.style.flex = '1'; + contentDiv.style.minWidth = '0'; + + const titleEl = document.createElement('div'); + titleEl.textContent = u.label || u.title || t('learning_unit') || 'Lerneinheit'; + + const metaEl = document.createElement('div'); + metaEl.className = 'unit-item-meta'; + + const metaParts = []; + if (u.meta) { + metaParts.push(u.meta); + } + if (Array.isArray(u.worksheet_files)) { + metaParts.push((t('worksheets') || 'Blätter') + ': ' + u.worksheet_files.length); + } + + metaEl.textContent = metaParts.join(' · '); + + contentDiv.appendChild(titleEl); + contentDiv.appendChild(metaEl); + + // Delete-Button + const deleteBtn = document.createElement('span'); + deleteBtn.textContent = '🗑️'; + deleteBtn.style.cursor = 'pointer'; + deleteBtn.style.fontSize = '12px'; + deleteBtn.style.color = '#ef4444'; + deleteBtn.title = t('delete_unit') || 'Lerneinheit löschen'; + deleteBtn.addEventListener('click', async (ev) => { + ev.stopPropagation(); + const confirmMsg = t('confirm_delete_unit') || 'Lerneinheit "{name}" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.'; + const ok = confirm(confirmMsg.replace('{name}', u.label || u.title || '')); + if (!ok) return; + await deleteLearningUnit(u.id); + }); + + li.appendChild(contentDiv); + li.appendChild(deleteBtn); + + li.addEventListener('click', () => { + currentUnitId = u.id; + renderUnits(); + applyUnitFilter(); + + // Event für andere Module + window.dispatchEvent(new CustomEvent('unitSelected', { detail: { unitId: u.id, unit: u } })); + }); + + unitListEl.appendChild(li); + }); +} + +/** + * Erstellt eine neue Lerneinheit aus dem Formular + */ +export async function addUnitFromForm() { + const student = (unitStudentInput && unitStudentInput.value || '').trim(); + const subject = (unitSubjectInput && unitSubjectInput.value || '').trim(); + const grade = (unitGradeInput && unitGradeInput.value || '').trim(); + const title = (unitTitleInput && unitTitleInput.value || '').trim(); + + if (!student && !subject && !title) { + alert(t('unit_form_empty') || 'Bitte mindestens einen Wert (Schüler/in, Fach oder Thema) eintragen.'); + return; + } + + const payload = { + student, + subject, + title, + grade, + }; + + try { + const resp = await fetch('/api/learning-units/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + console.error('Fehler beim Anlegen der Lerneinheit', resp.status); + alert(t('unit_create_error') || 'Lerneinheit konnte nicht angelegt werden.'); + return; + } + + const created = await resp.json(); + units.push(created); + currentUnitId = created.id; + + // Formular leeren + if (unitStudentInput) unitStudentInput.value = ''; + if (unitSubjectInput) unitSubjectInput.value = ''; + if (unitTitleInput) unitTitleInput.value = ''; + if (unitGradeInput) unitGradeInput.value = ''; + + renderUnits(); + applyUnitFilter(); + + // Event für andere Module + window.dispatchEvent(new CustomEvent('unitCreated', { detail: { unit: created } })); + } catch (e) { + console.error('Netzwerkfehler beim Anlegen der Lerneinheit', e); + alert(t('network_error') || 'Netzwerkfehler beim Anlegen der Lerneinheit.'); + } +} + +/** + * Ordnet das aktuelle Arbeitsblatt der aktuellen Lerneinheit zu + */ +export async function attachCurrentWorksheetToUnit() { + if (!currentUnitId) { + alert(t('select_unit_first') || 'Bitte zuerst eine Lerneinheit auswählen oder anlegen.'); + return; + } + + const basename = getCurrentWorksheetBasenameCallback(); + if (!basename) { + alert(t('select_worksheet_first') || 'Bitte zuerst ein Arbeitsblatt im linken Bereich auswählen.'); + return; + } + + const payload = { worksheet_files: [basename] }; + + try { + const resp = await fetch(`/api/learning-units/${currentUnitId}/attach-worksheets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + console.error('Fehler beim Zuordnen des Arbeitsblatts', resp.status); + alert(t('attach_error') || 'Arbeitsblatt konnte nicht zugeordnet werden.'); + return; + } + + const updated = await resp.json(); + const idx = units.findIndex((u) => u.id === updated.id); + if (idx !== -1) { + units[idx] = updated; + } + renderUnits(); + applyUnitFilter(); + + // Event für andere Module + window.dispatchEvent(new CustomEvent('worksheetAttached', { detail: { unitId: currentUnitId, filename: basename } })); + } catch (e) { + console.error('Netzwerkfehler beim Zuordnen des Arbeitsblatts', e); + alert(t('network_error') || 'Netzwerkfehler beim Zuordnen des Arbeitsblatts.'); + } +} + +/** + * Entfernt ein Arbeitsblatt aus der aktuellen Lerneinheit + * @param {string} filename - Dateiname des Arbeitsblatts + */ +export async function removeWorksheetFromCurrentUnit(filename) { + if (!currentUnitId) { + alert(t('select_unit_first') || 'Bitte zuerst eine Lerneinheit auswählen.'); + return; + } + if (!filename) { + alert(t('error_no_filename') || 'Fehler: kein Dateiname übergeben.'); + return; + } + + const payload = { worksheet_file: filename }; + + try { + const resp = await fetch(`/api/learning-units/${currentUnitId}/remove-worksheet`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + console.error('Fehler beim Entfernen des Arbeitsblatts', resp.status); + alert(t('remove_worksheet_error') || 'Arbeitsblatt konnte nicht aus der Lerneinheit entfernt werden.'); + return; + } + + const updated = await resp.json(); + const idx = units.findIndex((u) => u.id === updated.id); + if (idx !== -1) { + units[idx] = updated; + } + renderUnits(); + applyUnitFilter(); + + // Event für andere Module + window.dispatchEvent(new CustomEvent('worksheetRemoved', { detail: { unitId: currentUnitId, filename } })); + } catch (e) { + console.error('Netzwerkfehler beim Entfernen des Arbeitsblatts', e); + alert(t('network_error') || 'Netzwerkfehler beim Entfernen des Arbeitsblatts.'); + } +} + +/** + * Löscht eine Lerneinheit + * @param {string} unitId - ID der Lerneinheit + */ +export async function deleteLearningUnit(unitId) { + if (!unitId) { + alert(t('error_no_unit_id') || 'Fehler: keine Lerneinheit-ID übergeben.'); + return; + } + + try { + setStatus(t('deleting_unit') || 'Lösche Lerneinheit …', '', 'busy'); + + const resp = await fetch(`/api/learning-units/${unitId}`, { + method: 'DELETE', + }); + + if (!resp.ok) { + console.error('Fehler beim Löschen der Lerneinheit', resp.status); + setStatus(t('delete_error') || 'Fehler beim Löschen', '', 'error'); + alert(t('unit_delete_error') || 'Lerneinheit konnte nicht gelöscht werden.'); + return; + } + + const result = await resp.json(); + if (result.status === 'deleted') { + setStatus(t('unit_deleted') || 'Lerneinheit gelöscht', ''); + + // Lerneinheit aus der lokalen Liste entfernen + units = units.filter((u) => u.id !== unitId); + + // Wenn die gelöschte Einheit ausgewählt war, Auswahl zurücksetzen + if (currentUnitId === unitId) { + currentUnitId = units.length > 0 ? units[0].id : null; + } + + renderUnits(); + applyUnitFilter(); + + // Event für andere Module + window.dispatchEvent(new CustomEvent('unitDeleted', { detail: { unitId } })); + } else { + setStatus(t('error') || 'Fehler', t('unknown_error') || 'Unbekannter Fehler', 'error'); + alert(t('unit_delete_error') || 'Fehler beim Löschen der Lerneinheit.'); + } + } catch (e) { + console.error('Netzwerkfehler beim Löschen der Lerneinheit', e); + setStatus(t('network_error') || 'Netzwerkfehler', String(e), 'error'); + alert(t('network_error') || 'Netzwerkfehler beim Löschen der Lerneinheit.'); + } +} + +// === Getter und Setter === + +/** + * Gibt alle Lerneinheiten zurück + * @returns {Array} + */ +export function getUnits() { + return units; +} + +/** + * Gibt die aktuelle Lerneinheit-ID zurück + * @returns {string|null} + */ +export function getCurrentUnitId() { + return currentUnitId; +} + +/** + * Setzt die aktuelle Lerneinheit-ID + * @param {string|null} unitId + */ +export function setCurrentUnitId(unitId) { + currentUnitId = unitId; + renderUnits(); + applyUnitFilter(); +} + +/** + * Gibt zurück, ob der Filter aktiv ist + * @returns {boolean} + */ +export function getShowOnlyUnitFiles() { + return showOnlyUnitFiles; +} + +/** + * Setzt den Filter-Status + * @param {boolean} value + */ +export function setShowOnlyUnitFiles(value) { + showOnlyUnitFiles = value; + if (btnToggleFilter) { + if (showOnlyUnitFiles) { + btnToggleFilter.textContent = t('only_unit') || 'Nur Lerneinheit'; + btnToggleFilter.classList.add('btn-primary'); + } else { + btnToggleFilter.textContent = t('all_files') || 'Alle Dateien'; + btnToggleFilter.classList.remove('btn-primary'); + } + } + applyUnitFilter(); +} + +/** + * Gibt die aktuelle Lerneinheit zurück + * @returns {Object|null} + */ +export function getCurrentUnit() { + if (!currentUnitId || !units.length) return null; + return units.find((u) => u.id === currentUnitId) || null; +} diff --git a/backend/frontend/static/js/modules/lightbox.js b/backend/frontend/static/js/modules/lightbox.js new file mode 100644 index 0000000..c7d9e8f --- /dev/null +++ b/backend/frontend/static/js/modules/lightbox.js @@ -0,0 +1,234 @@ +/** + * BreakPilot Studio - Lightbox Module + * + * Vollbild-Bildvorschau und Modal-Funktionen: + * - Lightbox für Arbeitsblatt-Vorschauen + * - Keyboard-Navigation (Escape zum Schließen) + * - Click-outside zum Schließen + * + * Refactored: 2026-01-19 + */ + +// DOM-Referenzen +let lightboxEl = null; +let lightboxImg = null; +let lightboxCaption = null; +let lightboxClose = null; + +// Callback für Close-Event +let onCloseCallback = null; + +/** + * Initialisiert die Lightbox + * Sucht nach Standard-IDs oder erstellt das DOM + */ +export function initLightbox() { + lightboxEl = document.getElementById('lightbox'); + lightboxImg = document.getElementById('lightbox-img'); + lightboxCaption = document.getElementById('lightbox-caption'); + lightboxClose = document.getElementById('lightbox-close'); + + // Falls keine Lightbox im DOM, erstelle sie + if (!lightboxEl) { + createLightboxDOM(); + } + + // Event-Listener + if (lightboxClose) { + lightboxClose.addEventListener('click', closeLightbox); + } + + if (lightboxEl) { + lightboxEl.addEventListener('click', (ev) => { + // Schließen bei Klick auf Hintergrund + if (ev.target === lightboxEl) { + closeLightbox(); + } + }); + } + + // Escape-Taste + document.addEventListener('keydown', (ev) => { + if (ev.key === 'Escape' && isLightboxOpen()) { + closeLightbox(); + } + }); +} + +/** + * Erstellt das Lightbox-DOM dynamisch + */ +function createLightboxDOM() { + lightboxEl = document.createElement('div'); + lightboxEl.id = 'lightbox'; + lightboxEl.className = 'lightbox hidden'; + lightboxEl.innerHTML = ` + + `; + + document.body.appendChild(lightboxEl); + + // Referenzen aktualisieren + lightboxImg = document.getElementById('lightbox-img'); + lightboxCaption = document.getElementById('lightbox-caption'); + lightboxClose = document.getElementById('lightbox-close'); + + // CSS injizieren falls nicht vorhanden + if (!document.getElementById('lightbox-styles')) { + const style = document.createElement('style'); + style.id = 'lightbox-styles'; + style.textContent = ` + .lightbox { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.9); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + opacity: 1; + transition: opacity 0.3s ease; + } + .lightbox.hidden { + display: none; + opacity: 0; + } + .lightbox-content { + position: relative; + max-width: 90%; + max-height: 90%; + } + .lightbox-img { + max-width: 100%; + max-height: 85vh; + object-fit: contain; + border-radius: 8px; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5); + } + .lightbox-close { + position: absolute; + top: -40px; + right: 0; + background: transparent; + border: none; + color: white; + font-size: 14px; + cursor: pointer; + padding: 8px 16px; + border-radius: 4px; + transition: background 0.2s; + } + .lightbox-close:hover { + background: rgba(255, 255, 255, 0.1); + } + .lightbox-caption { + color: white; + text-align: center; + margin-top: 12px; + font-size: 14px; + } + `; + document.head.appendChild(style); + } +} + +/** + * Öffnet die Lightbox mit einem Bild + * @param {string} src - Bild-URL + * @param {string} [caption] - Optionale Bildunterschrift + */ +export function openLightbox(src, caption = '') { + if (!src) { + console.warn('openLightbox: No image source provided'); + return; + } + + // Lazy-Init falls noch nicht initialisiert + if (!lightboxEl) { + initLightbox(); + } + + if (lightboxImg) { + lightboxImg.src = src; + lightboxImg.alt = caption || 'Vorschau'; + } + + if (lightboxCaption) { + lightboxCaption.textContent = caption; + } + + if (lightboxEl) { + lightboxEl.classList.remove('hidden'); + // Body-Scroll verhindern + document.body.style.overflow = 'hidden'; + } +} + +/** + * Schließt die Lightbox + */ +export function closeLightbox() { + if (lightboxEl) { + lightboxEl.classList.add('hidden'); + // Body-Scroll wiederherstellen + document.body.style.overflow = ''; + } + + if (lightboxImg) { + lightboxImg.src = ''; + } + + // Callback ausführen + if (onCloseCallback) { + onCloseCallback(); + } +} + +/** + * Prüft ob die Lightbox geöffnet ist + * @returns {boolean} + */ +export function isLightboxOpen() { + return lightboxEl && !lightboxEl.classList.contains('hidden'); +} + +/** + * Setzt einen Callback für das Close-Event + * @param {function} callback + */ +export function onClose(callback) { + onCloseCallback = callback; +} + +/** + * Wechselt das Bild in der offenen Lightbox + * @param {string} src - Neue Bild-URL + * @param {string} [caption] - Neue Bildunterschrift + */ +export function changeLightboxImage(src, caption = '') { + if (lightboxImg) { + lightboxImg.src = src; + lightboxImg.alt = caption || 'Vorschau'; + } + + if (lightboxCaption) { + lightboxCaption.textContent = caption; + } +} + +/** + * Setzt den Text des Close-Buttons + * @param {string} text - Der neue Text + */ +export function setCloseButtonText(text) { + if (lightboxClose) { + lightboxClose.textContent = text; + } +} diff --git a/backend/frontend/static/js/modules/mc-module.js b/backend/frontend/static/js/modules/mc-module.js new file mode 100644 index 0000000..cbb260b --- /dev/null +++ b/backend/frontend/static/js/modules/mc-module.js @@ -0,0 +1,474 @@ +/** + * BreakPilot Studio - Multiple Choice Module + * + * Multiple Choice Quiz-Funktionalität: + * - Generierung von MC-Fragen aus Arbeitsblättern + * - Interaktives Quiz mit Auswertung + * - Druckfunktion (mit/ohne Lösungen) + * + * Refactored: 2026-01-19 + */ + +import { t } from './i18n.js'; +import { setStatus } from './api-helpers.js'; + +// State +let currentMcData = null; +let mcAnswers = {}; + +// DOM References +let mcPreview = null; +let mcBadge = null; +let mcModal = null; +let mcModalBody = null; +let mcModalClose = null; +let btnMcGenerate = null; +let btnMcShow = null; +let btnMcPrint = null; + +// Callback für aktuelle Datei +let getCurrentFileCallback = null; +let getEingangFilesCallback = null; +let getCurrentIndexCallback = null; + +/** + * Initialisiert das Multiple Choice Modul + * @param {Object} options - Konfiguration + */ +export function initMcModule(options = {}) { + getCurrentFileCallback = options.getCurrentFile || (() => null); + getEingangFilesCallback = options.getEingangFiles || (() => []); + getCurrentIndexCallback = options.getCurrentIndex || (() => 0); + + // DOM References + mcPreview = document.getElementById('mc-preview') || options.previewEl; + mcBadge = document.getElementById('mc-badge') || options.badgeEl; + mcModal = document.getElementById('mc-modal') || options.modalEl; + mcModalBody = document.getElementById('mc-modal-body') || options.modalBodyEl; + mcModalClose = document.getElementById('mc-modal-close') || options.modalCloseEl; + btnMcGenerate = document.getElementById('btn-mc-generate') || options.generateBtn; + btnMcShow = document.getElementById('btn-mc-show') || options.showBtn; + btnMcPrint = document.getElementById('btn-mc-print') || options.printBtn; + + // Event-Listener + if (btnMcGenerate) { + btnMcGenerate.addEventListener('click', generateMcQuestions); + } + + if (btnMcShow) { + btnMcShow.addEventListener('click', openMcModal); + } + + if (btnMcPrint) { + btnMcPrint.addEventListener('click', openMcPrintDialog); + } + + if (mcModalClose) { + mcModalClose.addEventListener('click', closeMcModal); + } + + if (mcModal) { + mcModal.addEventListener('click', (ev) => { + if (ev.target === mcModal) { + closeMcModal(); + } + }); + } + + // Event für Datei-Wechsel + window.addEventListener('fileSelected', () => { + loadMcPreviewForCurrent(); + }); +} + +/** + * Generiert MC-Fragen für alle Arbeitsblätter + */ +export async function generateMcQuestions() { + try { + setStatus(t('mc_generating') || 'Generiere MC-Fragen …', t('ai_working') || 'Bitte warten, KI arbeitet.', 'busy'); + if (mcBadge) mcBadge.textContent = t('generating') || 'Generiert...'; + + const resp = await fetch('/api/generate-mc', { method: 'POST' }); + if (!resp.ok) { + throw new Error('HTTP ' + resp.status); + } + + const result = await resp.json(); + if (result.status === 'OK' && result.generated.length > 0) { + setStatus(t('mc_generated') || 'MC-Fragen generiert', result.generated.length + ' ' + (t('files_created') || 'Dateien erstellt')); + if (mcBadge) mcBadge.textContent = t('ready') || 'Fertig'; + if (btnMcShow) btnMcShow.style.display = 'inline-block'; + if (btnMcPrint) btnMcPrint.style.display = 'inline-block'; + + // Lade die erste MC-Datei für Vorschau + await loadMcPreviewForCurrent(); + } else if (result.errors && result.errors.length > 0) { + setStatus(t('mc_error') || 'Fehler bei MC-Generierung', result.errors[0].error, 'error'); + if (mcBadge) mcBadge.textContent = t('error') || 'Fehler'; + } else { + setStatus(t('no_mc_generated') || 'Keine MC-Fragen generiert', t('analysis_missing') || 'Möglicherweise fehlen Analyse-Daten.', 'error'); + if (mcBadge) mcBadge.textContent = t('ready') || 'Bereit'; + } + } catch (e) { + console.error('MC-Generierung fehlgeschlagen:', e); + setStatus(t('mc_error') || 'Fehler bei MC-Generierung', String(e), 'error'); + if (mcBadge) mcBadge.textContent = t('error') || 'Fehler'; + } +} + +/** + * Lädt MC-Vorschau für die aktuelle Datei + */ +export async function loadMcPreviewForCurrent() { + const eingangFiles = getEingangFilesCallback(); + const currentIndex = getCurrentIndexCallback(); + + if (!eingangFiles.length) { + if (mcPreview) mcPreview.innerHTML = '
    ' + (t('no_worksheets') || 'Keine Arbeitsblätter vorhanden.') + '
    '; + return; + } + + const currentFile = eingangFiles[currentIndex]; + if (!currentFile) return; + + try { + const resp = await fetch('/api/mc-data/' + encodeURIComponent(currentFile)); + const result = await resp.json(); + + if (result.status === 'OK' && result.data) { + currentMcData = result.data; + renderMcPreview(result.data); + if (btnMcShow) btnMcShow.style.display = 'inline-block'; + if (btnMcPrint) btnMcPrint.style.display = 'inline-block'; + } else { + if (mcPreview) mcPreview.innerHTML = '
    ' + (t('no_mc_for_worksheet') || 'Noch keine MC-Fragen für dieses Arbeitsblatt generiert.') + '
    '; + currentMcData = null; + if (btnMcPrint) btnMcPrint.style.display = 'none'; + } + } catch (e) { + console.error('Fehler beim Laden der MC-Daten:', e); + if (mcPreview) mcPreview.innerHTML = ''; + } +} + +/** + * Rendert die MC-Vorschau + * @param {Object} mcData - MC-Daten + */ +function renderMcPreview(mcData) { + if (!mcPreview) return; + if (!mcData || !mcData.questions || mcData.questions.length === 0) { + mcPreview.innerHTML = '
    ' + (t('no_questions') || 'Keine Fragen vorhanden.') + '
    '; + return; + } + + const questions = mcData.questions; + const metadata = mcData.metadata || {}; + + let html = ''; + + // Zeige Metadaten + if (metadata.grade_level || metadata.subject) { + html += '
    '; + if (metadata.subject) { + html += '
    ' + (t('subject') || 'Fach') + ': ' + escapeHtml(metadata.subject) + '
    '; + } + if (metadata.grade_level) { + html += '
    ' + (t('grade') || 'Stufe') + ': ' + escapeHtml(metadata.grade_level) + '
    '; + } + html += '
    ' + (t('questions') || 'Fragen') + ': ' + questions.length + '
    '; + html += '
    '; + } + + // Zeige erste 2 Fragen als Vorschau + const previewQuestions = questions.slice(0, 2); + previewQuestions.forEach((q, idx) => { + html += '
    '; + html += '
    ' + (idx + 1) + '. ' + escapeHtml(q.question) + '
    '; + html += '
    '; + q.options.forEach(opt => { + html += '
    '; + html += '' + opt.id + ') ' + escapeHtml(opt.text); + html += '
    '; + }); + html += '
    '; + html += '
    '; + }); + + if (questions.length > 2) { + html += '
    + ' + (questions.length - 2) + ' ' + (t('more_questions') || 'weitere Fragen') + '
    '; + } + + mcPreview.innerHTML = html; + + // Event-Listener für Antwort-Auswahl + mcPreview.querySelectorAll('.mc-option').forEach(optEl => { + optEl.addEventListener('click', () => handleMcOptionClick(optEl)); + }); +} + +/** + * Behandelt Klick auf eine MC-Option + * @param {Element} optEl - Angeklicktes Option-Element + */ +function handleMcOptionClick(optEl) { + const qid = optEl.getAttribute('data-qid'); + const optId = optEl.getAttribute('data-opt'); + + if (!currentMcData) return; + + // Finde die Frage + const question = currentMcData.questions.find(q => q.id === qid); + if (!question) return; + + // Markiere alle Optionen dieser Frage + const questionEl = optEl.closest('.mc-question'); + const allOptions = questionEl.querySelectorAll('.mc-option'); + + allOptions.forEach(opt => { + opt.classList.remove('selected', 'correct', 'incorrect'); + const thisOptId = opt.getAttribute('data-opt'); + if (thisOptId === question.correct_answer) { + opt.classList.add('correct'); + } else if (thisOptId === optId) { + opt.classList.add('incorrect'); + } + }); + + // Speichere Antwort + mcAnswers[qid] = optId; + + // Zeige Feedback + const isCorrect = optId === question.correct_answer; + let feedbackEl = questionEl.querySelector('.mc-feedback'); + if (!feedbackEl) { + feedbackEl = document.createElement('div'); + feedbackEl.className = 'mc-feedback'; + questionEl.appendChild(feedbackEl); + } + + if (isCorrect) { + feedbackEl.style.background = 'rgba(34,197,94,0.1)'; + feedbackEl.style.borderColor = 'rgba(34,197,94,0.3)'; + feedbackEl.style.color = 'var(--bp-accent)'; + feedbackEl.textContent = (t('correct') || 'Richtig!') + ' ' + (question.explanation || ''); + } else { + feedbackEl.style.background = 'rgba(239,68,68,0.1)'; + feedbackEl.style.borderColor = 'rgba(239,68,68,0.3)'; + feedbackEl.style.color = '#ef4444'; + feedbackEl.textContent = (t('incorrect') || 'Leider falsch.') + ' ' + (question.explanation || ''); + } +} + +/** + * Öffnet das MC-Quiz-Modal + */ +export function openMcModal() { + if (!currentMcData || !currentMcData.questions) { + alert(t('no_mc_questions') || 'Keine MC-Fragen vorhanden. Bitte zuerst generieren.'); + return; + } + + mcAnswers = {}; // Reset Antworten + renderMcModal(currentMcData); + if (mcModal) mcModal.classList.remove('hidden'); +} + +/** + * Schließt das MC-Modal + */ +export function closeMcModal() { + if (mcModal) mcModal.classList.add('hidden'); +} + +/** + * Rendert den Modal-Inhalt + * @param {Object} mcData - MC-Daten + */ +function renderMcModal(mcData) { + if (!mcModalBody) return; + + const questions = mcData.questions; + const metadata = mcData.metadata || {}; + + let html = ''; + + // Header mit Metadaten + html += '
    '; + if (metadata.source_title) { + html += '
    ' + (t('worksheet') || 'Arbeitsblatt') + ': ' + escapeHtml(metadata.source_title) + '
    '; + } + if (metadata.subject) { + html += '
    ' + (t('subject') || 'Fach') + ': ' + escapeHtml(metadata.subject) + '
    '; + } + if (metadata.grade_level) { + html += '
    ' + (t('grade') || 'Stufe') + ': ' + escapeHtml(metadata.grade_level) + '
    '; + } + html += '
    '; + + // Alle Fragen + questions.forEach((q, idx) => { + html += '
    '; + html += '
    ' + (idx + 1) + '. ' + escapeHtml(q.question) + '
    '; + html += '
    '; + q.options.forEach(opt => { + html += '
    '; + html += '' + opt.id + ') ' + escapeHtml(opt.text); + html += '
    '; + }); + html += '
    '; + html += '
    '; + }); + + // Auswertungs-Button + html += '
    '; + html += ''; + html += '
    '; + + mcModalBody.innerHTML = html; + + // Event-Listener + mcModalBody.querySelectorAll('.mc-option').forEach(optEl => { + optEl.addEventListener('click', () => { + const qid = optEl.getAttribute('data-qid'); + const optId = optEl.getAttribute('data-opt'); + + // Deselektiere andere Optionen der gleichen Frage + const questionEl = optEl.closest('.mc-question'); + questionEl.querySelectorAll('.mc-option').forEach(o => o.classList.remove('selected')); + optEl.classList.add('selected'); + + mcAnswers[qid] = optId; + }); + }); + + const btnEvaluate = document.getElementById('btn-mc-evaluate'); + if (btnEvaluate) { + btnEvaluate.addEventListener('click', evaluateMcQuiz); + } +} + +/** + * Wertet das Quiz aus + */ +function evaluateMcQuiz() { + if (!currentMcData || !mcModalBody) return; + + let correct = 0; + let total = currentMcData.questions.length; + + currentMcData.questions.forEach(q => { + const questionEl = mcModalBody.querySelector('.mc-question[data-qid="' + q.id + '"]'); + if (!questionEl) return; + + const userAnswer = mcAnswers[q.id]; + const allOptions = questionEl.querySelectorAll('.mc-option'); + + allOptions.forEach(opt => { + opt.classList.remove('correct', 'incorrect'); + const optId = opt.getAttribute('data-opt'); + if (optId === q.correct_answer) { + opt.classList.add('correct'); + } else if (optId === userAnswer && userAnswer !== q.correct_answer) { + opt.classList.add('incorrect'); + } + }); + + // Zeige Erklärung + let feedbackEl = questionEl.querySelector('.mc-feedback'); + if (!feedbackEl) { + feedbackEl = document.createElement('div'); + feedbackEl.className = 'mc-feedback'; + questionEl.appendChild(feedbackEl); + } + + if (userAnswer === q.correct_answer) { + correct++; + feedbackEl.style.background = 'rgba(34,197,94,0.1)'; + feedbackEl.style.borderColor = 'rgba(34,197,94,0.3)'; + feedbackEl.style.color = 'var(--bp-accent)'; + feedbackEl.textContent = (t('correct') || 'Richtig!') + ' ' + (q.explanation || ''); + } else if (userAnswer) { + feedbackEl.style.background = 'rgba(239,68,68,0.1)'; + feedbackEl.style.borderColor = 'rgba(239,68,68,0.3)'; + feedbackEl.style.color = '#ef4444'; + feedbackEl.textContent = (t('incorrect') || 'Falsch.') + ' ' + (q.explanation || ''); + } else { + feedbackEl.style.background = 'rgba(148,163,184,0.1)'; + feedbackEl.style.borderColor = 'rgba(148,163,184,0.3)'; + feedbackEl.style.color = 'var(--bp-text-muted)'; + feedbackEl.textContent = (t('not_answered') || 'Nicht beantwortet.') + ' ' + (t('correct_was') || 'Richtig wäre:') + ' ' + q.correct_answer.toUpperCase(); + } + }); + + // Zeige Gesamtergebnis + const percentage = Math.round(correct / total * 100); + const resultHtml = '
    ' + + '
    ' + correct + ' ' + (t('of') || 'von') + ' ' + total + ' ' + (t('correct_answers') || 'richtig') + '
    ' + + '
    ' + percentage + '% ' + (t('percent_correct') || 'korrekt') + '
    ' + + '
    '; + + const existingResult = mcModalBody.querySelector('.mc-result'); + if (existingResult) { + existingResult.remove(); + } + + const resultDiv = document.createElement('div'); + resultDiv.className = 'mc-result'; + resultDiv.innerHTML = resultHtml; + mcModalBody.appendChild(resultDiv); +} + +/** + * Öffnet den Druck-Dialog + */ +export function openMcPrintDialog() { + if (!currentMcData) { + alert(t('no_mc_questions') || 'Keine MC-Fragen vorhanden.'); + return; + } + + const eingangFiles = getEingangFilesCallback(); + const currentIndex = getCurrentIndexCallback(); + const currentFile = eingangFiles[currentIndex]; + + const confirmMsg = (t('mc_print_with_answers') || 'Mit Lösungen drucken?') + + '\n\nOK = ' + (t('solution_sheet') || 'Lösungsblatt mit markierten Antworten') + + '\n' + (t('cancel') || 'Abbrechen') + ' = ' + (t('exercise_sheet') || 'Übungsblatt ohne Lösungen'); + + const choice = confirm(confirmMsg); + const url = '/api/print-mc/' + encodeURIComponent(currentFile) + '?show_answers=' + choice; + window.open(url, '_blank'); +} + +// === Getter und Setter === + +/** + * Gibt die aktuellen MC-Daten zurück + * @returns {Object|null} + */ +export function getMcData() { + return currentMcData; +} + +/** + * Setzt die MC-Daten + * @param {Object} data + */ +export function setMcData(data) { + currentMcData = data; + if (data) { + renderMcPreview(data); + } +} + +/** + * Helper: HTML-Escape + */ +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} diff --git a/backend/frontend/static/js/modules/mindmap-module.js b/backend/frontend/static/js/modules/mindmap-module.js new file mode 100644 index 0000000..6d085ef --- /dev/null +++ b/backend/frontend/static/js/modules/mindmap-module.js @@ -0,0 +1,223 @@ +/** + * BreakPilot Studio - Mindmap Module + * + * Mindmap/Lernplakat-Generierung aus Arbeitsblättern: + * - Generieren von Mindmaps aus analysierten Arbeitsblättern + * - Vorschau und Anzeige + * - Druck-Funktion (A4/A3) + * + * Refactored: 2026-01-19 + */ + +import { t } from './i18n.js'; +import { setStatus, setStatusWorking, setStatusError, setStatusSuccess, fetchJSON } from './api-helpers.js'; + +// State +let currentMindmapData = null; + +// DOM References +let mindmapPreview = null; +let mindmapBadge = null; +let btnMindmapGenerate = null; +let btnMindmapShow = null; +let btnMindmapPrint = null; + +// Callback für aktuelle Datei +let getCurrentFileCallback = null; + +/** + * Initialisiert das Mindmap-Modul + * @param {Object} options - Konfiguration + * @param {Function} options.getCurrentFile - Callback um die aktuelle Datei zu bekommen + */ +export function initMindmapModule(options = {}) { + getCurrentFileCallback = options.getCurrentFile || (() => null); + + mindmapPreview = document.getElementById('mindmap-preview') || options.previewEl; + mindmapBadge = document.getElementById('mindmap-badge') || options.badgeEl; + btnMindmapGenerate = document.getElementById('btn-mindmap-generate') || options.generateBtn; + btnMindmapShow = document.getElementById('btn-mindmap-show') || options.showBtn; + btnMindmapPrint = document.getElementById('btn-mindmap-print') || options.printBtn; + + // Event-Listener + if (btnMindmapGenerate) { + btnMindmapGenerate.addEventListener('click', generateMindmap); + } + + if (btnMindmapShow) { + btnMindmapShow.addEventListener('click', openMindmapView); + } + + if (btnMindmapPrint) { + btnMindmapPrint.addEventListener('click', openMindmapPrint); + } + + // Event für Datei-Wechsel + window.addEventListener('fileSelected', (ev) => { + loadMindmapData(); + }); +} + +/** + * Generiert eine Mindmap für die aktuelle Datei + */ +export async function generateMindmap() { + const currentFile = getCurrentFileCallback(); + if (!currentFile) { + alert(t('select_file_first') || 'Bitte zuerst eine Datei auswählen.'); + return; + } + + try { + setStatusWorking(t('mindmap_generating') || 'Generiere Mindmap...'); + if (mindmapBadge) { + mindmapBadge.textContent = t('generating') || 'Generiert...'; + mindmapBadge.className = 'card-badge'; + } + + const resp = await fetch('/api/generate-mindmap/' + encodeURIComponent(currentFile), { + method: 'POST' + }); + + if (!resp.ok) { + throw new Error('HTTP ' + resp.status); + } + + const data = await resp.json(); + + if (data.status === 'OK') { + if (mindmapBadge) { + mindmapBadge.textContent = t('ready') || 'Fertig'; + mindmapBadge.className = 'card-badge badge-success'; + } + setStatusSuccess(t('mindmap_generated') || 'Mindmap erstellt!'); + + // Lade Mindmap-Daten + await loadMindmapData(); + } else if (data.status === 'NOT_FOUND') { + if (mindmapBadge) { + mindmapBadge.textContent = t('no_analysis') || 'Keine Analyse'; + mindmapBadge.className = 'card-badge badge-error'; + } + setStatusError(t('analyze_first') || 'Bitte zuerst analysieren (Neuaufbau starten)'); + } else { + throw new Error(data.message || 'Fehler bei der Mindmap-Generierung'); + } + } catch (err) { + console.error('Mindmap error:', err); + if (mindmapBadge) { + mindmapBadge.textContent = t('error') || 'Fehler'; + mindmapBadge.className = 'card-badge badge-error'; + } + setStatusError(t('error') || 'Fehler', err.message); + } +} + +/** + * Lädt die Mindmap-Daten für die aktuelle Datei + */ +export async function loadMindmapData() { + const currentFile = getCurrentFileCallback(); + + if (!currentFile) { + if (mindmapPreview) mindmapPreview.innerHTML = ''; + return; + } + + try { + const resp = await fetch('/api/mindmap-data/' + encodeURIComponent(currentFile)); + const data = await resp.json(); + + if (data.status === 'OK' && data.data) { + currentMindmapData = data.data; + renderMindmapPreview(); + if (btnMindmapShow) btnMindmapShow.style.display = 'inline-block'; + if (btnMindmapPrint) btnMindmapPrint.style.display = 'inline-block'; + if (mindmapBadge) { + mindmapBadge.textContent = t('ready') || 'Fertig'; + mindmapBadge.className = 'card-badge badge-success'; + } + } else { + currentMindmapData = null; + if (mindmapPreview) mindmapPreview.innerHTML = ''; + if (btnMindmapShow) btnMindmapShow.style.display = 'none'; + if (btnMindmapPrint) btnMindmapPrint.style.display = 'none'; + if (mindmapBadge) { + mindmapBadge.textContent = t('ready') || 'Bereit'; + mindmapBadge.className = 'card-badge'; + } + } + } catch (err) { + console.error('Error loading mindmap:', err); + } +} + +/** + * Rendert die Mindmap-Vorschau + */ +function renderMindmapPreview() { + if (!mindmapPreview) return; + + if (!currentMindmapData) { + mindmapPreview.innerHTML = ''; + return; + } + + const topic = currentMindmapData.topic || 'Thema'; + const categories = currentMindmapData.categories || []; + const categoryCount = categories.length; + const termCount = categories.reduce((sum, cat) => sum + (cat.terms ? cat.terms.length : 0), 0); + + mindmapPreview.innerHTML = ` +
    +
    ${escapeHtml(topic)}
    +
    + ${categoryCount} ${t('categories') || 'Kategorien'} | ${termCount} ${t('terms') || 'Begriffe'} +
    +
    + `; +} + +/** + * Öffnet die Mindmap-Ansicht (A4) + */ +export function openMindmapView() { + const currentFile = getCurrentFileCallback(); + if (!currentFile) return; + window.open('/api/mindmap-html/' + encodeURIComponent(currentFile) + '?format=a4', '_blank'); +} + +/** + * Öffnet die Mindmap-Druckansicht (A3) + */ +export function openMindmapPrint() { + const currentFile = getCurrentFileCallback(); + if (!currentFile) return; + window.open('/api/mindmap-html/' + encodeURIComponent(currentFile) + '?format=a3', '_blank'); +} + +/** + * Gibt die aktuellen Mindmap-Daten zurück + * @returns {Object|null} + */ +export function getMindmapData() { + return currentMindmapData; +} + +/** + * Setzt die Mindmap-Daten + * @param {Object} data + */ +export function setMindmapData(data) { + currentMindmapData = data; + renderMindmapPreview(); +} + +/** + * Helper: HTML-Escape + */ +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} diff --git a/backend/frontend/static/js/modules/qa-leitner-module.js b/backend/frontend/static/js/modules/qa-leitner-module.js new file mode 100644 index 0000000..adaab38 --- /dev/null +++ b/backend/frontend/static/js/modules/qa-leitner-module.js @@ -0,0 +1,444 @@ +/** + * BreakPilot Studio - Q&A Leitner Module + * + * Frage-Antwort Lernkarten mit Leitner-System: + * - Generieren von Q&A aus analysierten Arbeitsblättern + * - Leitner-Box-System (Neu, Gelernt, Gefestigt) + * - Lern-Session mit Selbstbewertung + * - Fortschrittsspeicherung + * + * Refactored: 2026-01-19 + */ + +import { t } from './i18n.js'; +import { setStatus, setStatusWorking, setStatusError, setStatusSuccess, fetchJSON } from './api-helpers.js'; + +// State +let currentQaData = null; +let currentQaIndex = 0; +let qaSessionStats = { correct: 0, incorrect: 0, total: 0 }; + +// DOM References +let qaPreview = null; +let qaBadge = null; +let qaModal = null; +let qaModalBody = null; +let qaModalClose = null; +let btnQaGenerate = null; +let btnQaLearn = null; +let btnQaPrint = null; + +// Callback für aktuelle Datei +let getCurrentFileCallback = null; +let getFilesCallback = null; +let getCurrentIndexCallback = null; + +/** + * Initialisiert das Q&A Leitner-Modul + * @param {Object} options - Konfiguration + */ +export function initQaModule(options = {}) { + getCurrentFileCallback = options.getCurrentFile || (() => null); + getFilesCallback = options.getFiles || (() => []); + getCurrentIndexCallback = options.getCurrentIndex || (() => 0); + + qaPreview = document.getElementById('qa-preview') || options.previewEl; + qaBadge = document.getElementById('qa-badge') || options.badgeEl; + qaModal = document.getElementById('qa-modal') || options.modalEl; + qaModalBody = document.getElementById('qa-modal-body') || options.modalBodyEl; + qaModalClose = document.getElementById('qa-modal-close') || options.closeBtn; + btnQaGenerate = document.getElementById('btn-qa-generate') || options.generateBtn; + btnQaLearn = document.getElementById('btn-qa-learn') || options.learnBtn; + btnQaPrint = document.getElementById('btn-qa-print') || options.printBtn; + + // Event-Listener + if (btnQaGenerate) { + btnQaGenerate.addEventListener('click', generateQaQuestions); + } + + if (btnQaLearn) { + btnQaLearn.addEventListener('click', openQaModal); + } + + if (btnQaPrint) { + btnQaPrint.addEventListener('click', openQaPrintDialog); + } + + if (qaModalClose) { + qaModalClose.addEventListener('click', closeQaModal); + } + + if (qaModal) { + qaModal.addEventListener('click', (ev) => { + if (ev.target === qaModal) { + closeQaModal(); + } + }); + } + + // Event für Datei-Wechsel + window.addEventListener('fileSelected', () => { + loadQaPreviewForCurrent(); + }); +} + +/** + * Generiert Q&A für alle Dateien + */ +export async function generateQaQuestions() { + try { + setStatusWorking(t('status_generating_qa') || 'Generiere Q&A ...'); + if (qaBadge) qaBadge.textContent = t('mc_generating') || 'Generiert...'; + + const resp = await fetch('/api/generate-qa', { method: 'POST' }); + if (!resp.ok) { + throw new Error('HTTP ' + resp.status); + } + + const result = await resp.json(); + if (result.status === 'OK' && result.generated.length > 0) { + setStatusSuccess( + t('status_qa_generated') || 'Q&A generiert', + result.generated.length + ' ' + (t('status_files_created') || 'Dateien erstellt') + ); + if (qaBadge) qaBadge.textContent = t('mc_done') || 'Fertig'; + if (btnQaLearn) btnQaLearn.style.display = 'inline-block'; + if (btnQaPrint) btnQaPrint.style.display = 'inline-block'; + + await loadQaPreviewForCurrent(); + } else if (result.errors && result.errors.length > 0) { + setStatusError(t('error') || 'Fehler', result.errors[0].error); + if (qaBadge) qaBadge.textContent = t('mc_error') || 'Fehler'; + } else { + setStatusError(t('error') || 'Fehler', 'Keine Q&A generiert.'); + if (qaBadge) qaBadge.textContent = t('mc_ready') || 'Bereit'; + } + } catch (e) { + console.error('Q&A-Generierung fehlgeschlagen:', e); + setStatusError(t('error') || 'Fehler', String(e)); + if (qaBadge) qaBadge.textContent = t('mc_error') || 'Fehler'; + } +} + +/** + * Lädt die Q&A-Vorschau für die aktuelle Datei + */ +export async function loadQaPreviewForCurrent() { + const files = getFilesCallback(); + if (!files.length) { + if (qaPreview) { + qaPreview.innerHTML = `
    ${t('qa_no_questions') || 'Noch keine Q&A vorhanden.'}
    `; + } + return; + } + + const currentFile = getCurrentFileCallback(); + if (!currentFile) return; + + try { + const resp = await fetch('/api/qa-data/' + encodeURIComponent(currentFile)); + const result = await resp.json(); + + if (result.status === 'OK' && result.data) { + currentQaData = result.data; + renderQaPreview(result.data); + if (btnQaLearn) btnQaLearn.style.display = 'inline-block'; + if (btnQaPrint) btnQaPrint.style.display = 'inline-block'; + } else { + if (qaPreview) { + qaPreview.innerHTML = `
    ${t('qa_no_questions') || 'Noch keine Q&A für dieses Arbeitsblatt generiert.'}
    `; + } + currentQaData = null; + if (btnQaLearn) btnQaLearn.style.display = 'none'; + if (btnQaPrint) btnQaPrint.style.display = 'none'; + } + } catch (e) { + console.error('Fehler beim Laden der Q&A-Daten:', e); + if (qaPreview) qaPreview.innerHTML = ''; + } +} + +/** + * Rendert die Q&A-Vorschau + */ +function renderQaPreview(qaData) { + if (!qaPreview) return; + if (!qaData || !qaData.qa_items || qaData.qa_items.length === 0) { + qaPreview.innerHTML = `
    ${t('qa_no_questions') || 'Keine Fragen vorhanden.'}
    `; + return; + } + + const items = qaData.qa_items; + + // Zähle Fragen nach Box + let box0 = 0, box1 = 0, box2 = 0; + items.forEach(item => { + const box = item.leitner ? item.leitner.box : 0; + if (box === 0) box0++; + else if (box === 1) box1++; + else box2++; + }); + + let html = ` +
    +
    +
    ${t('qa_box_new') || 'Neu'}: ${box0}
    +
    ${t('qa_box_learning') || 'Lernt'}: ${box1}
    +
    ${t('qa_box_mastered') || 'Gefestigt'}: ${box2}
    +
    +
    + `; + + // Zeige erste 2 Fragen als Vorschau + const previewItems = items.slice(0, 2); + previewItems.forEach((item, idx) => { + html += ` +
    +
    ${idx + 1}. ${escapeHtml(item.question)}
    +
    → ${escapeHtml(item.answer.substring(0, 60))}${item.answer.length > 60 ? '...' : ''}
    +
    + `; + }); + + if (items.length > 2) { + html += `
    + ${items.length - 2} ${t('questions') || 'weitere Fragen'}
    `; + } + + qaPreview.innerHTML = html; +} + +/** + * Öffnet das Lern-Modal + */ +export function openQaModal() { + if (!currentQaData || !currentQaData.qa_items) { + alert(t('qa_no_questions') || 'Keine Q&A vorhanden. Bitte zuerst generieren.'); + return; + } + + currentQaIndex = 0; + qaSessionStats = { correct: 0, incorrect: 0, total: 0 }; + renderQaLearningCard(); + if (qaModal) qaModal.classList.remove('hidden'); +} + +/** + * Schließt das Lern-Modal + */ +export function closeQaModal() { + if (qaModal) qaModal.classList.add('hidden'); +} + +/** + * Rendert die aktuelle Lernkarte + */ +function renderQaLearningCard() { + const items = currentQaData.qa_items; + + if (currentQaIndex >= items.length) { + renderQaSessionSummary(); + return; + } + + const item = items[currentQaIndex]; + const leitner = item.leitner || { box: 0 }; + const boxNames = [t('qa_box_new') || 'Neu', t('qa_box_learning') || 'Gelernt', t('qa_box_mastered') || 'Gefestigt']; + const boxColors = ['#ef4444', '#f59e0b', '#22c55e']; + + let html = ` + +
    +
    ${t('question') || 'Frage'} ${currentQaIndex + 1} / ${items.length}
    +
    + ${boxNames[leitner.box]} +
    +
    + + +
    +
    ${t('question') || 'Frage'}:
    +
    ${escapeHtml(item.question)}
    +
    + + +
    +
    ${t('qa_your_answer') || 'Deine Antwort'}:
    + +
    + + +
    + +
    + + + + + +
    +
    ${t('qa_session_correct') || 'Richtig'}: ${qaSessionStats.correct}
    +
    ${t('qa_session_incorrect') || 'Falsch'}: ${qaSessionStats.incorrect}
    +
    + `; + + if (qaModalBody) { + qaModalBody.innerHTML = html; + + // Event Listener + document.getElementById('btn-qa-check-answer')?.addEventListener('click', () => { + const userAnswer = document.getElementById('qa-user-answer')?.value.trim() || ''; + document.getElementById('qa-user-answer-text').textContent = userAnswer || (t('qa_no_answer') || '(keine Antwort eingegeben)'); + document.getElementById('qa-input-container').style.display = 'none'; + document.getElementById('qa-check-btn-container').style.display = 'none'; + document.getElementById('qa-comparison-container').style.display = 'block'; + }); + + // Enter zum Prüfen + document.getElementById('qa-user-answer')?.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + document.getElementById('btn-qa-check-answer')?.click(); + } + }); + + // Fokus + setTimeout(() => { + document.getElementById('qa-user-answer')?.focus(); + }, 100); + + document.getElementById('btn-qa-correct')?.addEventListener('click', () => handleQaAnswer(true)); + document.getElementById('btn-qa-incorrect')?.addEventListener('click', () => handleQaAnswer(false)); + } +} + +/** + * Verarbeitet die Antwort + */ +async function handleQaAnswer(correct) { + const item = currentQaData.qa_items[currentQaIndex]; + + qaSessionStats.total++; + if (correct) qaSessionStats.correct++; + else qaSessionStats.incorrect++; + + // Speichere Fortschritt + try { + const currentFile = getCurrentFileCallback(); + if (currentFile) { + await fetch(`/api/qa-progress?filename=${encodeURIComponent(currentFile)}&item_id=${encodeURIComponent(item.id)}&correct=${correct}`, { + method: 'POST' + }); + } + } catch (e) { + console.error('Fehler beim Speichern des Fortschritts:', e); + } + + currentQaIndex++; + renderQaLearningCard(); +} + +/** + * Rendert die Session-Zusammenfassung + */ +function renderQaSessionSummary() { + const percent = qaSessionStats.total > 0 ? Math.round(qaSessionStats.correct / qaSessionStats.total * 100) : 0; + const emoji = percent >= 80 ? '🎉' : percent >= 50 ? '👍' : '💪'; + + let html = ` +
    +
    ${emoji}
    +
    ${t('qa_session_complete') || 'Lernrunde abgeschlossen!'}
    +
    + ${qaSessionStats.correct} / ${qaSessionStats.total} ${t('qa_result_correct') || 'richtig'} (${percent}%) +
    + +
    +
    +
    ${qaSessionStats.correct}
    +
    ${t('qa_correct') || 'Richtig'}
    +
    +
    +
    ${qaSessionStats.incorrect}
    +
    ${t('qa_incorrect') || 'Falsch'}
    +
    +
    + +
    + + +
    +
    + `; + + if (qaModalBody) { + qaModalBody.innerHTML = html; + + document.getElementById('btn-qa-restart')?.addEventListener('click', () => { + currentQaIndex = 0; + qaSessionStats = { correct: 0, incorrect: 0, total: 0 }; + renderQaLearningCard(); + }); + + document.getElementById('btn-qa-close-summary')?.addEventListener('click', closeQaModal); + + // Aktualisiere Preview + loadQaPreviewForCurrent(); + } +} + +/** + * Öffnet den Druck-Dialog + */ +function openQaPrintDialog() { + if (!currentQaData) { + alert(t('qa_no_questions') || 'Keine Q&A vorhanden.'); + return; + } + + const currentFile = getCurrentFileCallback(); + if (!currentFile) return; + + const choice = confirm((t('qa_print_with_answers') || 'Mit Lösungen drucken?') + '\n\nOK = Mit Lösungen\nAbbrechen = Nur Fragen'); + const url = '/api/print-qa/' + encodeURIComponent(currentFile) + '?show_answers=' + choice; + window.open(url, '_blank'); +} + +/** + * Gibt die aktuellen Q&A-Daten zurück + */ +export function getQaData() { + return currentQaData; +} + +/** + * Helper: HTML-Escape + */ +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text || ''; + return div.innerHTML; +} diff --git a/backend/frontend/static/js/modules/theme.js b/backend/frontend/static/js/modules/theme.js new file mode 100644 index 0000000..a4564b4 --- /dev/null +++ b/backend/frontend/static/js/modules/theme.js @@ -0,0 +1,105 @@ +/** + * BreakPilot Studio - Theme Module + * + * Dark/Light Mode Toggle-Funktionalität: + * - Speichert Präferenz in localStorage + * - Unterstützt data-theme Attribut auf + * + * Refactored: 2026-01-19 + */ + +// Initialisiere Theme sofort beim Laden (IIFE) +(function initializeTheme() { + const savedTheme = localStorage.getItem('bp-theme') || 'dark'; + document.documentElement.setAttribute('data-theme', savedTheme); + console.log('Initial theme set to:', savedTheme); +})(); + +/** + * Holt das aktuelle Theme + * @returns {string} - 'dark' oder 'light' + */ +export function getCurrentTheme() { + return document.documentElement.getAttribute('data-theme') || 'dark'; +} + +/** + * Setzt das Theme + * @param {string} theme - 'dark' oder 'light' + */ +export function setTheme(theme) { + if (theme !== 'dark' && theme !== 'light') { + console.warn(`Invalid theme: ${theme}. Use 'dark' or 'light'.`); + return; + } + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem('bp-theme', theme); + + // Custom Event für andere Module + window.dispatchEvent(new CustomEvent('themeChanged', { + detail: { theme } + })); +} + +/** + * Wechselt zwischen Dark und Light Mode + * @returns {string} - Das neue Theme + */ +export function toggleTheme() { + const current = getCurrentTheme(); + const newTheme = current === 'dark' ? 'light' : 'dark'; + setTheme(newTheme); + return newTheme; +} + +/** + * Initialisiert den Theme-Toggle-Button + * Sucht nach Elements mit IDs: theme-toggle, theme-icon, theme-label + */ +export function initThemeToggle() { + const toggle = document.getElementById('theme-toggle'); + const icon = document.getElementById('theme-icon'); + const label = document.getElementById('theme-label'); + + if (!toggle || !icon || !label) { + console.warn('Theme toggle elements not found (theme-toggle, theme-icon, theme-label)'); + return; + } + + function updateToggleUI(theme) { + if (theme === 'light') { + icon.textContent = '☀️'; + label.textContent = 'Light'; + } else { + icon.textContent = '🌙'; + label.textContent = 'Dark'; + } + } + + // Initialize UI based on current theme + updateToggleUI(getCurrentTheme()); + + // Click-Handler + toggle.addEventListener('click', function() { + console.log('Theme toggle clicked'); + const newTheme = toggleTheme(); + console.log('Switched to:', newTheme); + updateToggleUI(newTheme); + }); +} + +/** + * Prüft ob Dark Mode aktiv ist + * @returns {boolean} + */ +export function isDarkMode() { + return getCurrentTheme() === 'dark'; +} + +/** + * Prüft ob Light Mode aktiv ist + * @returns {boolean} + */ +export function isLightMode() { + return getCurrentTheme() === 'light'; +} diff --git a/backend/frontend/static/js/modules/translations.js b/backend/frontend/static/js/modules/translations.js new file mode 100644 index 0000000..9cfc352 --- /dev/null +++ b/backend/frontend/static/js/modules/translations.js @@ -0,0 +1,971 @@ +/** + * BreakPilot Studio - Translations Module + * + * Enthält alle UI-Übersetzungen für 7 Sprachen: + * - de: Deutsch (Standard) + * - en: English + * - tr: Türkisch + * - ar: Arabisch (RTL) + * - ru: Russisch + * - uk: Ukrainisch + * - pl: Polnisch + * + * Refactored: 2026-01-19 + */ + +export const translations = { + de: { + // Navigation & Header + brand_sub: "Studio", + nav_compare: "Arbeitsblätter", + nav_tiles: "Lern-Kacheln", + login: "Login / Anmeldung", + mvp_local: "MVP · Lokal auf deinem Mac", + + // Sidebar + sidebar_areas: "Bereiche", + sidebar_studio: "Arbeitsblatt Studio", + sidebar_active: "aktiv", + sidebar_parents: "Eltern-Kanal", + sidebar_soon: "demnächst", + sidebar_correction: "Korrektur / Noten", + sidebar_units: "Lerneinheiten (lokal)", + input_student: "Schüler/in", + input_subject: "Fach", + input_grade: "Klasse (z.B. 7a)", + input_unit_title: "Lerneinheit / Thema", + btn_create: "Anlegen", + btn_add_current: "Aktuelles Arbeitsblatt hinzufügen", + btn_filter_unit: "Nur Lerneinheit", + btn_filter_all: "Alle Dateien", + + // Screen 1 - Compare + uploaded_worksheets: "Hochgeladene Arbeitsblätter", + files: "Dateien", + btn_upload: "Hochladen", + btn_delete: "Löschen", + original_scan: "Original-Scan", + cleaned_version: "Bereinigt (Handschrift entfernt)", + no_cleaned: "Noch keine bereinigte Version vorhanden.", + process_hint: "Klicke auf 'Verarbeiten', um das Arbeitsblatt zu analysieren und zu bereinigen.", + worksheet_print: "Drucken", + worksheet_no_data: "Keine Arbeitsblatt-Daten vorhanden.", + btn_full_process: "Verarbeiten (Analyse + Bereinigung + HTML)", + btn_original_generate: "Nur Original-HTML generieren", + + // Screen 2 - Tiles + learning_unit: "Lerneinheit", + no_unit_selected: "Keine Lerneinheit ausgewählt", + + // MC Tile + mc_title: "Multiple Choice Test", + mc_ready: "Bereit", + mc_generating: "Generiert...", + mc_done: "Fertig", + mc_error: "Fehler", + mc_desc: "Erzeugt passende MC-Aufgaben zur ursprünglichen Schwierigkeit (z. B. Klasse 7), ohne das Niveau zu verändern.", + mc_generate: "MC generieren", + mc_show: "Fragen anzeigen", + mc_quiz_title: "Multiple Choice Quiz", + mc_evaluate: "Auswerten", + mc_correct: "Richtig!", + mc_incorrect: "Leider falsch.", + mc_not_answered: "Nicht beantwortet. Richtig wäre:", + mc_result: "von", + mc_result_correct: "richtig", + mc_percent: "korrekt", + mc_no_questions: "Noch keine MC-Fragen für dieses Arbeitsblatt generiert.", + mc_print: "Drucken", + mc_print_with_answers: "Mit Lösungen drucken?", + + // Cloze Tile + cloze_title: "Lückentext", + cloze_desc: "Erzeugt Lückentexte mit mehreren sinnvollen Lücken pro Satz. Inkl. Übersetzung für Eltern.", + cloze_translation: "Übersetzung:", + cloze_generate: "Lückentext generieren", + cloze_start: "Übung starten", + cloze_exercise_title: "Lückentext-Übung", + cloze_instruction: "Fülle die Lücken aus und klicke auf 'Prüfen'.", + cloze_check: "Prüfen", + cloze_show_answers: "Lösungen zeigen", + cloze_no_texts: "Noch keine Lückentexte für dieses Arbeitsblatt generiert.", + cloze_sentences: "Sätze", + cloze_gaps: "Lücken", + cloze_gaps_total: "Lücken gesamt", + cloze_with_gaps: "(mit Lücken)", + cloze_print: "Drucken", + cloze_print_with_answers: "Mit Lösungen drucken?", + + // QA Tile + qa_title: "Frage-Antwort-Blatt", + qa_desc: "Frage-Antwort-Paare mit Leitner-Box System. Wiederholung nach Schwierigkeitsgrad.", + qa_generate: "Q&A generieren", + qa_learn: "Lernen starten", + qa_print: "Drucken", + qa_no_questions: "Noch keine Q&A für dieses Arbeitsblatt generiert.", + qa_box_new: "Neu", + qa_box_learning: "Gelernt", + qa_box_mastered: "Gefestigt", + qa_show_answer: "Antwort zeigen", + qa_your_answer: "Deine Antwort", + qa_type_answer: "Schreibe deine Antwort hier...", + qa_check_answer: "Antwort prüfen", + qa_correct_answer: "Richtige Antwort", + qa_self_evaluate: "War deine Antwort richtig?", + qa_no_answer: "(keine Antwort eingegeben)", + qa_correct: "Richtig", + qa_incorrect: "Falsch", + qa_key_terms: "Schlüsselbegriffe", + qa_session_correct: "Richtig", + qa_session_incorrect: "Falsch", + qa_session_complete: "Lernrunde abgeschlossen!", + qa_result_correct: "richtig", + qa_restart: "Nochmal lernen", + qa_print_with_answers: "Mit Lösungen drucken?", + question: "Frage", + answer: "Antwort", + status_generating_qa: "Generiere Q&A …", + status_qa_generated: "Q&A generiert", + + // Common + close: "Schließen", + subject: "Fach", + grade: "Stufe", + questions: "Fragen", + worksheet: "Arbeitsblatt", + loading: "Lädt...", + error: "Fehler", + success: "Erfolgreich", + + // Footer + imprint: "Impressum", + privacy: "Datenschutz", + contact: "Kontakt", + + // Status messages + status_ready: "Bereit", + status_processing: "Verarbeitet...", + status_generating_mc: "Generiere MC-Fragen …", + status_generating_cloze: "Generiere Lückentexte …", + status_please_wait: "Bitte warten, KI arbeitet.", + status_mc_generated: "MC-Fragen generiert", + status_cloze_generated: "Lückentexte generiert", + status_files_created: "Dateien erstellt", + + // Mindmap Tile + mindmap_title: "Mindmap Lernposter", + mindmap_desc: "Erstellt eine kindgerechte Mindmap mit dem Hauptthema in der Mitte und allen Fachbegriffen in farbigen Kategorien.", + mindmap_generate: "Mindmap erstellen", + mindmap_show: "Ansehen", + mindmap_print_a3: "A3 Drucken", + generating_mindmap: "Erstelle Mindmap...", + mindmap_generated: "Mindmap erstellt!", + no_analysis: "Keine Analyse", + analyze_first: "Bitte zuerst analysieren (Verarbeiten starten)", + categories: "Kategorien", + terms: "Begriffe", + }, + + tr: { + brand_sub: "Stüdyo", + nav_compare: "Çalışma Sayfaları", + nav_tiles: "Öğrenme Kartları", + login: "Giriş / Kayıt", + mvp_local: "MVP · Mac'inizde Yerel", + sidebar_areas: "Alanlar", + sidebar_studio: "Çalışma Sayfası Stüdyosu", + sidebar_active: "aktif", + sidebar_parents: "Ebeveyn Kanalı", + sidebar_soon: "yakında", + sidebar_correction: "Düzeltme / Notlar", + sidebar_units: "Öğrenme Birimleri (yerel)", + input_student: "Öğrenci", + input_subject: "Ders", + input_grade: "Sınıf (örn. 7a)", + input_unit_title: "Öğrenme Birimi / Konu", + btn_create: "Oluştur", + btn_add_current: "Mevcut çalışma sayfasını ekle", + btn_filter_unit: "Sadece Birim", + btn_filter_all: "Tüm Dosyalar", + uploaded_worksheets: "Yüklenen Çalışma Sayfaları", + files: "Dosya", + btn_upload: "Yükle", + btn_delete: "Sil", + original_scan: "Orijinal Tarama", + cleaned_version: "Temizlenmiş (El yazısı kaldırıldı)", + no_cleaned: "Henüz temizlenmiş sürüm yok.", + process_hint: "Çalışma sayfasını analiz etmek ve temizlemek için 'İşle'ye tıklayın.", + worksheet_print: "Yazdır", + worksheet_no_data: "Çalışma sayfası verisi yok.", + btn_full_process: "İşle (Analiz + Temizleme + HTML)", + btn_original_generate: "Sadece Orijinal HTML Oluştur", + learning_unit: "Öğrenme Birimi", + no_unit_selected: "Öğrenme birimi seçilmedi", + mc_title: "Çoktan Seçmeli Test", + mc_ready: "Hazır", + mc_generating: "Oluşturuluyor...", + mc_done: "Tamamlandı", + mc_error: "Hata", + mc_desc: "Orijinal zorluğa uygun (örn. 7. sınıf) çoktan seçmeli sorular oluşturur.", + mc_generate: "ÇS Oluştur", + mc_show: "Soruları Göster", + mc_quiz_title: "Çoktan Seçmeli Quiz", + mc_evaluate: "Değerlendir", + mc_correct: "Doğru!", + mc_incorrect: "Maalesef yanlış.", + mc_not_answered: "Cevaplanmadı. Doğru cevap:", + mc_result: "/", + mc_result_correct: "doğru", + mc_percent: "doğru", + mc_no_questions: "Bu çalışma sayfası için henüz ÇS sorusu oluşturulmadı.", + mc_print: "Yazdır", + mc_print_with_answers: "Cevaplarla yazdır?", + cloze_title: "Boşluk Doldurma", + cloze_desc: "Her cümlede birden fazla anlamlı boşluk içeren metinler oluşturur. Ebeveynler için çeviri dahil.", + cloze_translation: "Çeviri:", + cloze_generate: "Boşluk Metni Oluştur", + cloze_start: "Alıştırmayı Başlat", + cloze_exercise_title: "Boşluk Doldurma Alıştırması", + cloze_instruction: "Boşlukları doldurun ve 'Kontrol Et'e tıklayın.", + cloze_check: "Kontrol Et", + cloze_show_answers: "Cevapları Göster", + cloze_no_texts: "Bu çalışma sayfası için henüz boşluk metni oluşturulmadı.", + cloze_sentences: "Cümle", + cloze_gaps: "Boşluk", + cloze_gaps_total: "Toplam boşluk", + cloze_with_gaps: "(boşluklu)", + cloze_print: "Yazdır", + cloze_print_with_answers: "Cevaplarla yazdır?", + qa_title: "Soru-Cevap Sayfası", + qa_desc: "Leitner kutu sistemiyle soru-cevap çiftleri. Zorluk derecesine göre tekrar.", + qa_generate: "S&C Oluştur", + qa_learn: "Öğrenmeye Başla", + qa_print: "Yazdır", + qa_no_questions: "Bu çalışma sayfası için henüz S&C oluşturulmadı.", + qa_box_new: "Yeni", + qa_box_learning: "Öğreniliyor", + qa_box_mastered: "Pekiştirildi", + qa_show_answer: "Cevabı Göster", + qa_your_answer: "Senin Cevabın", + qa_type_answer: "Cevabını buraya yaz...", + qa_check_answer: "Cevabı Kontrol Et", + qa_correct_answer: "Doğru Cevap", + qa_self_evaluate: "Cevabın doğru muydu?", + qa_no_answer: "(cevap girilmedi)", + qa_correct: "Doğru", + qa_incorrect: "Yanlış", + qa_key_terms: "Anahtar Kavramlar", + qa_session_correct: "Doğru", + qa_session_incorrect: "Yanlış", + qa_session_complete: "Öğrenme turu tamamlandı!", + qa_result_correct: "doğru", + qa_restart: "Tekrar Öğren", + qa_print_with_answers: "Cevaplarla yazdır?", + question: "Soru", + answer: "Cevap", + status_generating_qa: "S&C oluşturuluyor…", + status_qa_generated: "S&C oluşturuldu", + close: "Kapat", + subject: "Ders", + grade: "Seviye", + questions: "Soru", + worksheet: "Çalışma Sayfası", + loading: "Yükleniyor...", + error: "Hata", + success: "Başarılı", + imprint: "Künye", + privacy: "Gizlilik", + contact: "İletişim", + status_ready: "Hazır", + status_processing: "İşleniyor...", + status_generating_mc: "ÇS soruları oluşturuluyor…", + status_generating_cloze: "Boşluk metinleri oluşturuluyor…", + status_please_wait: "Lütfen bekleyin, yapay zeka çalışıyor.", + status_mc_generated: "ÇS soruları oluşturuldu", + status_cloze_generated: "Boşluk metinleri oluşturuldu", + status_files_created: "dosya oluşturuldu", + mindmap_title: "Zihin Haritası Poster", + mindmap_desc: "Ana konuyu ortada ve tüm terimleri renkli kategorilerde gösteren çocuk dostu bir zihin haritası oluşturur.", + mindmap_generate: "Zihin Haritası Oluştur", + mindmap_show: "Görüntüle", + mindmap_print_a3: "A3 Yazdır", + generating_mindmap: "Zihin haritası oluşturuluyor...", + mindmap_generated: "Zihin haritası oluşturuldu!", + no_analysis: "Analiz yok", + analyze_first: "Lütfen önce analiz edin (İşle'ye tıklayın)", + categories: "Kategoriler", + terms: "Terimler", + }, + + ar: { + brand_sub: "ستوديو", + nav_compare: "أوراق العمل", + nav_tiles: "بطاقات التعلم", + login: "تسجيل الدخول / التسجيل", + mvp_local: "MVP · محلي على جهازك", + sidebar_areas: "الأقسام", + sidebar_studio: "استوديو أوراق العمل", + sidebar_active: "نشط", + sidebar_parents: "قناة الوالدين", + sidebar_soon: "قريباً", + sidebar_correction: "التصحيح / الدرجات", + sidebar_units: "وحدات التعلم (محلية)", + input_student: "الطالب/ة", + input_subject: "المادة", + input_grade: "الصف (مثل 7أ)", + input_unit_title: "وحدة التعلم / الموضوع", + btn_create: "إنشاء", + btn_add_current: "إضافة ورقة العمل الحالية", + btn_filter_unit: "الوحدة فقط", + btn_filter_all: "جميع الملفات", + uploaded_worksheets: "أوراق العمل المحملة", + files: "ملفات", + btn_upload: "تحميل", + btn_delete: "حذف", + original_scan: "المسح الأصلي", + cleaned_version: "منظف (تم إزالة الكتابة اليدوية)", + no_cleaned: "لا توجد نسخة منظفة بعد.", + process_hint: "انقر على 'معالجة' لتحليل وتنظيف ورقة العمل.", + worksheet_print: "طباعة", + worksheet_no_data: "لا توجد بيانات ورقة العمل.", + btn_full_process: "معالجة (تحليل + تنظيف + HTML)", + btn_original_generate: "إنشاء HTML الأصلي فقط", + learning_unit: "وحدة التعلم", + no_unit_selected: "لم يتم اختيار وحدة تعلم", + mc_title: "اختبار متعدد الخيارات", + mc_ready: "جاهز", + mc_generating: "جاري الإنشاء...", + mc_done: "تم", + mc_error: "خطأ", + mc_desc: "ينشئ أسئلة اختيار من متعدد تناسب مستوى الصعوبة الأصلي (مثل الصف 7).", + mc_generate: "إنشاء أسئلة", + mc_show: "عرض الأسئلة", + mc_quiz_title: "اختبار متعدد الخيارات", + mc_evaluate: "تقييم", + mc_correct: "صحيح!", + mc_incorrect: "للأسف خطأ.", + mc_not_answered: "لم تتم الإجابة. الإجابة الصحيحة:", + mc_result: "من", + mc_result_correct: "صحيح", + mc_percent: "صحيح", + mc_no_questions: "لم يتم إنشاء أسئلة بعد لورقة العمل هذه.", + mc_print: "طباعة", + mc_print_with_answers: "طباعة مع الإجابات؟", + cloze_title: "ملء الفراغات", + cloze_desc: "ينشئ نصوصاً بفراغات متعددة في كل جملة. يشمل الترجمة للوالدين.", + cloze_translation: "الترجمة:", + cloze_generate: "إنشاء نص الفراغات", + cloze_start: "بدء التمرين", + cloze_exercise_title: "تمرين ملء الفراغات", + cloze_instruction: "املأ الفراغات وانقر على 'تحقق'.", + cloze_check: "تحقق", + cloze_show_answers: "عرض الإجابات", + cloze_no_texts: "لم يتم إنشاء نصوص فراغات بعد لورقة العمل هذه.", + cloze_sentences: "جمل", + cloze_gaps: "فراغات", + cloze_gaps_total: "إجمالي الفراغات", + cloze_with_gaps: "(مع فراغات)", + cloze_print: "طباعة", + cloze_print_with_answers: "طباعة مع الإجابات؟", + qa_title: "ورقة الأسئلة والأجوبة", + qa_desc: "أزواج أسئلة وأجوبة مع نظام صندوق لايتنر. التكرار حسب الصعوبة.", + qa_generate: "إنشاء س&ج", + qa_learn: "بدء التعلم", + qa_print: "طباعة", + qa_no_questions: "لم يتم إنشاء س&ج بعد لورقة العمل هذه.", + qa_box_new: "جديد", + qa_box_learning: "قيد التعلم", + qa_box_mastered: "متقن", + qa_show_answer: "عرض الإجابة", + qa_your_answer: "إجابتك", + qa_type_answer: "اكتب إجابتك هنا...", + qa_check_answer: "تحقق من الإجابة", + qa_correct_answer: "الإجابة الصحيحة", + qa_self_evaluate: "هل كانت إجابتك صحيحة؟", + qa_no_answer: "(لم يتم إدخال إجابة)", + qa_correct: "صحيح", + qa_incorrect: "خطأ", + qa_key_terms: "المصطلحات الرئيسية", + qa_session_correct: "صحيح", + qa_session_incorrect: "خطأ", + qa_session_complete: "اكتملت جولة التعلم!", + qa_result_correct: "صحيح", + qa_restart: "تعلم مرة أخرى", + qa_print_with_answers: "طباعة مع الإجابات؟", + question: "سؤال", + answer: "إجابة", + status_generating_qa: "جاري إنشاء س&ج…", + status_qa_generated: "تم إنشاء س&ج", + close: "إغلاق", + subject: "المادة", + grade: "المستوى", + questions: "أسئلة", + worksheet: "ورقة العمل", + loading: "جاري التحميل...", + error: "خطأ", + success: "نجاح", + imprint: "البصمة", + privacy: "الخصوصية", + contact: "اتصل بنا", + status_ready: "جاهز", + status_processing: "جاري المعالجة...", + status_generating_mc: "جاري إنشاء الأسئلة…", + status_generating_cloze: "جاري إنشاء نصوص الفراغات…", + status_please_wait: "يرجى الانتظار، الذكاء الاصطناعي يعمل.", + status_mc_generated: "تم إنشاء الأسئلة", + status_cloze_generated: "تم إنشاء نصوص الفراغات", + status_files_created: "ملفات تم إنشاؤها", + mindmap_title: "ملصق خريطة ذهنية", + mindmap_desc: "ينشئ خريطة ذهنية مناسبة للأطفال مع الموضوع الرئيسي في المنتصف وجميع المصطلحات في فئات ملونة.", + mindmap_generate: "إنشاء خريطة ذهنية", + mindmap_show: "عرض", + mindmap_print_a3: "طباعة A3", + generating_mindmap: "جاري إنشاء الخريطة الذهنية...", + mindmap_generated: "تم إنشاء الخريطة الذهنية!", + no_analysis: "لا يوجد تحليل", + analyze_first: "يرجى التحليل أولاً (انقر على معالجة)", + categories: "الفئات", + terms: "المصطلحات", + }, + + ru: { + brand_sub: "Студия", + nav_compare: "Рабочие листы", + nav_tiles: "Учебные карточки", + login: "Вход / Регистрация", + mvp_local: "MVP · Локально на вашем Mac", + sidebar_areas: "Разделы", + sidebar_studio: "Студия рабочих листов", + sidebar_active: "активно", + sidebar_parents: "Канал для родителей", + sidebar_soon: "скоро", + sidebar_correction: "Проверка / Оценки", + sidebar_units: "Учебные блоки (локально)", + input_student: "Ученик", + input_subject: "Предмет", + input_grade: "Класс (напр. 7а)", + input_unit_title: "Учебный блок / Тема", + btn_create: "Создать", + btn_add_current: "Добавить текущий лист", + btn_filter_unit: "Только блок", + btn_filter_all: "Все файлы", + uploaded_worksheets: "Загруженные рабочие листы", + files: "файлов", + btn_upload: "Загрузить", + btn_delete: "Удалить", + original_scan: "Оригинальный скан", + cleaned_version: "Очищено (рукопись удалена)", + no_cleaned: "Очищенная версия пока недоступна.", + process_hint: "Нажмите 'Обработать' для анализа и очистки листа.", + worksheet_print: "Печать", + worksheet_no_data: "Нет данных рабочего листа.", + btn_full_process: "Обработать (Анализ + Очистка + HTML)", + btn_original_generate: "Только оригинальный HTML", + learning_unit: "Учебный блок", + no_unit_selected: "Блок не выбран", + mc_title: "Тест с выбором ответа", + mc_ready: "Готово", + mc_generating: "Создается...", + mc_done: "Готово", + mc_error: "Ошибка", + mc_desc: "Создает вопросы с выбором ответа соответствующей сложности (напр. 7 класс).", + mc_generate: "Создать тест", + mc_show: "Показать вопросы", + mc_quiz_title: "Тест с выбором ответа", + mc_evaluate: "Оценить", + mc_correct: "Правильно!", + mc_incorrect: "К сожалению, неверно.", + mc_not_answered: "Нет ответа. Правильный ответ:", + mc_result: "из", + mc_result_correct: "правильно", + mc_percent: "верно", + mc_no_questions: "Вопросы для этого листа еще не созданы.", + mc_print: "Печать", + mc_print_with_answers: "Печатать с ответами?", + cloze_title: "Текст с пропусками", + cloze_desc: "Создает тексты с несколькими пропусками в каждом предложении. Включая перевод для родителей.", + cloze_translation: "Перевод:", + cloze_generate: "Создать текст", + cloze_start: "Начать упражнение", + cloze_exercise_title: "Упражнение с пропусками", + cloze_instruction: "Заполните пропуски и нажмите 'Проверить'.", + cloze_check: "Проверить", + cloze_show_answers: "Показать ответы", + cloze_no_texts: "Тексты для этого листа еще не созданы.", + cloze_sentences: "предложений", + cloze_gaps: "пропусков", + cloze_gaps_total: "Всего пропусков", + cloze_with_gaps: "(с пропусками)", + cloze_print: "Печать", + cloze_print_with_answers: "Печатать с ответами?", + qa_title: "Лист вопросов и ответов", + qa_desc: "Пары вопрос-ответ с системой Лейтнера. Повторение по уровню сложности.", + qa_generate: "Создать В&О", + qa_learn: "Начать обучение", + qa_print: "Печать", + qa_no_questions: "В&О для этого листа еще не созданы.", + qa_box_new: "Новый", + qa_box_learning: "Изучается", + qa_box_mastered: "Освоено", + qa_show_answer: "Показать ответ", + qa_your_answer: "Твой ответ", + qa_type_answer: "Напиши свой ответ здесь...", + qa_check_answer: "Проверить ответ", + qa_correct_answer: "Правильный ответ", + qa_self_evaluate: "Твой ответ был правильным?", + qa_no_answer: "(ответ не введён)", + qa_correct: "Правильно", + qa_incorrect: "Неправильно", + qa_key_terms: "Ключевые термины", + qa_session_correct: "Правильно", + qa_session_incorrect: "Неправильно", + qa_session_complete: "Раунд обучения завершен!", + qa_result_correct: "правильно", + qa_restart: "Учить снова", + qa_print_with_answers: "Печатать с ответами?", + question: "Вопрос", + answer: "Ответ", + status_generating_qa: "Создание В&О…", + status_qa_generated: "В&О созданы", + close: "Закрыть", + subject: "Предмет", + grade: "Уровень", + questions: "вопросов", + worksheet: "Рабочий лист", + loading: "Загрузка...", + error: "Ошибка", + success: "Успешно", + imprint: "Импрессум", + privacy: "Конфиденциальность", + contact: "Контакт", + status_ready: "Готово", + status_processing: "Обработка...", + status_generating_mc: "Создание вопросов…", + status_generating_cloze: "Создание текстов…", + status_please_wait: "Пожалуйста, подождите, ИИ работает.", + status_mc_generated: "Вопросы созданы", + status_cloze_generated: "Тексты созданы", + status_files_created: "файлов создано", + mindmap_title: "Плакат Майнд-карта", + mindmap_desc: "Создает детскую ментальную карту с главной темой в центре и всеми терминами в цветных категориях.", + mindmap_generate: "Создать карту", + mindmap_show: "Просмотр", + mindmap_print_a3: "Печать A3", + generating_mindmap: "Создание карты...", + mindmap_generated: "Карта создана!", + no_analysis: "Нет анализа", + analyze_first: "Сначала выполните анализ (нажмите Обработать)", + categories: "Категории", + terms: "Термины", + }, + + uk: { + brand_sub: "Студія", + nav_compare: "Робочі аркуші", + nav_tiles: "Навчальні картки", + login: "Вхід / Реєстрація", + mvp_local: "MVP · Локально на вашому Mac", + sidebar_areas: "Розділи", + sidebar_studio: "Студія робочих аркушів", + sidebar_active: "активно", + sidebar_parents: "Канал для батьків", + sidebar_soon: "незабаром", + sidebar_correction: "Перевірка / Оцінки", + sidebar_units: "Навчальні блоки (локально)", + input_student: "Учень", + input_subject: "Предмет", + input_grade: "Клас (напр. 7а)", + input_unit_title: "Навчальний блок / Тема", + btn_create: "Створити", + btn_add_current: "Додати поточний аркуш", + btn_filter_unit: "Лише блок", + btn_filter_all: "Усі файли", + uploaded_worksheets: "Завантажені робочі аркуші", + files: "файлів", + btn_upload: "Завантажити", + btn_delete: "Видалити", + original_scan: "Оригінальний скан", + cleaned_version: "Очищено (рукопис видалено)", + no_cleaned: "Очищена версія ще недоступна.", + process_hint: "Натисніть 'Обробити' для аналізу та очищення аркуша.", + worksheet_print: "Друк", + worksheet_no_data: "Немає даних робочого аркуша.", + btn_full_process: "Обробити (Аналіз + Очищення + HTML)", + btn_original_generate: "Лише оригінальний HTML", + learning_unit: "Навчальний блок", + no_unit_selected: "Блок не вибрано", + mc_title: "Тест з вибором відповіді", + mc_ready: "Готово", + mc_generating: "Створюється...", + mc_done: "Готово", + mc_error: "Помилка", + mc_desc: "Створює питання з вибором відповіді відповідної складності (напр. 7 клас).", + mc_generate: "Створити тест", + mc_show: "Показати питання", + mc_quiz_title: "Тест з вибором відповіді", + mc_evaluate: "Оцінити", + mc_correct: "Правильно!", + mc_incorrect: "На жаль, неправильно.", + mc_not_answered: "Немає відповіді. Правильна відповідь:", + mc_result: "з", + mc_result_correct: "правильно", + mc_percent: "вірно", + mc_no_questions: "Питання для цього аркуша ще не створені.", + mc_print: "Друк", + mc_print_with_answers: "Друкувати з відповідями?", + cloze_title: "Текст з пропусками", + cloze_desc: "Створює тексти з кількома пропусками в кожному реченні. Включаючи переклад для батьків.", + cloze_translation: "Переклад:", + cloze_generate: "Створити текст", + cloze_start: "Почати вправу", + cloze_exercise_title: "Вправа з пропусками", + cloze_instruction: "Заповніть пропуски та натисніть 'Перевірити'.", + cloze_check: "Перевірити", + cloze_show_answers: "Показати відповіді", + cloze_no_texts: "Тексти для цього аркуша ще не створені.", + cloze_sentences: "речень", + cloze_gaps: "пропусків", + cloze_gaps_total: "Всього пропусків", + cloze_with_gaps: "(з пропусками)", + cloze_print: "Друк", + cloze_print_with_answers: "Друкувати з відповідями?", + qa_title: "Аркуш питань і відповідей", + qa_desc: "Пари питання-відповідь з системою Лейтнера. Повторення за рівнем складності.", + qa_generate: "Створити П&В", + qa_learn: "Почати навчання", + qa_print: "Друк", + qa_no_questions: "П&В для цього аркуша ще не створені.", + qa_box_new: "Новий", + qa_box_learning: "Вивчається", + qa_box_mastered: "Засвоєно", + qa_show_answer: "Показати відповідь", + qa_your_answer: "Твоя відповідь", + qa_type_answer: "Напиши свою відповідь тут...", + qa_check_answer: "Перевірити відповідь", + qa_correct_answer: "Правильна відповідь", + qa_self_evaluate: "Твоя відповідь була правильною?", + qa_no_answer: "(відповідь не введена)", + qa_correct: "Правильно", + qa_incorrect: "Неправильно", + qa_key_terms: "Ключові терміни", + qa_session_correct: "Правильно", + qa_session_incorrect: "Неправильно", + qa_session_complete: "Раунд навчання завершено!", + qa_result_correct: "правильно", + qa_restart: "Вчити знову", + qa_print_with_answers: "Друкувати з відповідями?", + question: "Питання", + answer: "Відповідь", + status_generating_qa: "Створення П&В…", + status_qa_generated: "П&В створені", + close: "Закрити", + subject: "Предмет", + grade: "Рівень", + questions: "питань", + worksheet: "Робочий аркуш", + loading: "Завантаження...", + error: "Помилка", + success: "Успішно", + imprint: "Імпресум", + privacy: "Конфіденційність", + contact: "Контакт", + status_ready: "Готово", + status_processing: "Обробка...", + status_generating_mc: "Створення питань…", + status_generating_cloze: "Створення текстів…", + status_please_wait: "Будь ласка, зачекайте, ШІ працює.", + status_mc_generated: "Питання створені", + status_cloze_generated: "Тексти створені", + status_files_created: "файлів створено", + mindmap_title: "Плакат Інтелект-карта", + mindmap_desc: "Створює дитячу інтелект-карту з головною темою в центрі та всіма термінами в кольорових категоріях.", + mindmap_generate: "Створити карту", + mindmap_show: "Переглянути", + mindmap_print_a3: "Друк A3", + generating_mindmap: "Створення карти...", + mindmap_generated: "Карту створено!", + no_analysis: "Немає аналізу", + analyze_first: "Спочатку виконайте аналіз (натисніть Обробити)", + categories: "Категорії", + terms: "Терміни", + }, + + pl: { + brand_sub: "Studio", + nav_compare: "Karty pracy", + nav_tiles: "Karty nauki", + login: "Logowanie / Rejestracja", + mvp_local: "MVP · Lokalnie na Twoim Mac", + sidebar_areas: "Sekcje", + sidebar_studio: "Studio kart pracy", + sidebar_active: "aktywne", + sidebar_parents: "Kanał dla rodziców", + sidebar_soon: "wkrótce", + sidebar_correction: "Korekta / Oceny", + sidebar_units: "Jednostki nauki (lokalnie)", + input_student: "Uczeń", + input_subject: "Przedmiot", + input_grade: "Klasa (np. 7a)", + input_unit_title: "Jednostka nauki / Temat", + btn_create: "Utwórz", + btn_add_current: "Dodaj bieżącą kartę", + btn_filter_unit: "Tylko jednostka", + btn_filter_all: "Wszystkie pliki", + uploaded_worksheets: "Przesłane karty pracy", + files: "plików", + btn_upload: "Prześlij", + btn_delete: "Usuń", + original_scan: "Oryginalny skan", + cleaned_version: "Oczyszczone (pismo ręczne usunięte)", + no_cleaned: "Oczyszczona wersja jeszcze niedostępna.", + process_hint: "Kliknij 'Przetwórz', aby przeanalizować i oczyścić kartę.", + worksheet_print: "Drukuj", + worksheet_no_data: "Brak danych arkusza.", + btn_full_process: "Przetwórz (Analiza + Czyszczenie + HTML)", + btn_original_generate: "Tylko oryginalny HTML", + learning_unit: "Jednostka nauki", + no_unit_selected: "Nie wybrano jednostki", + mc_title: "Test wielokrotnego wyboru", + mc_ready: "Gotowe", + mc_generating: "Tworzenie...", + mc_done: "Gotowe", + mc_error: "Błąd", + mc_desc: "Tworzy pytania wielokrotnego wyboru o odpowiednim poziomie trudności (np. klasa 7).", + mc_generate: "Utwórz test", + mc_show: "Pokaż pytania", + mc_quiz_title: "Test wielokrotnego wyboru", + mc_evaluate: "Oceń", + mc_correct: "Dobrze!", + mc_incorrect: "Niestety źle.", + mc_not_answered: "Brak odpowiedzi. Poprawna odpowiedź:", + mc_result: "z", + mc_result_correct: "poprawnie", + mc_percent: "poprawnie", + mc_no_questions: "Pytania dla tej karty jeszcze nie zostały utworzone.", + mc_print: "Drukuj", + mc_print_with_answers: "Drukować z odpowiedziami?", + cloze_title: "Tekst z lukami", + cloze_desc: "Tworzy teksty z wieloma lukami w każdym zdaniu. W tym tłumaczenie dla rodziców.", + cloze_translation: "Tłumaczenie:", + cloze_generate: "Utwórz tekst", + cloze_start: "Rozpocznij ćwiczenie", + cloze_exercise_title: "Ćwiczenie z lukami", + cloze_instruction: "Wypełnij luki i kliknij 'Sprawdź'.", + cloze_check: "Sprawdź", + cloze_show_answers: "Pokaż odpowiedzi", + cloze_no_texts: "Teksty dla tej karty jeszcze nie zostały utworzone.", + cloze_sentences: "zdań", + cloze_gaps: "luk", + cloze_gaps_total: "Łącznie luk", + cloze_with_gaps: "(z lukami)", + cloze_print: "Drukuj", + cloze_print_with_answers: "Drukować z odpowiedziami?", + qa_title: "Arkusz pytań i odpowiedzi", + qa_desc: "Pary pytanie-odpowiedź z systemem Leitnera. Powtórki według poziomu trudności.", + qa_generate: "Utwórz P&O", + qa_learn: "Rozpocznij naukę", + qa_print: "Drukuj", + qa_no_questions: "P&O dla tej karty jeszcze nie zostały utworzone.", + qa_box_new: "Nowy", + qa_box_learning: "W nauce", + qa_box_mastered: "Opanowane", + qa_show_answer: "Pokaż odpowiedź", + qa_your_answer: "Twoja odpowiedź", + qa_type_answer: "Napisz swoją odpowiedź tutaj...", + qa_check_answer: "Sprawdź odpowiedź", + qa_correct_answer: "Prawidłowa odpowiedź", + qa_self_evaluate: "Czy twoja odpowiedź była poprawna?", + qa_no_answer: "(nie wprowadzono odpowiedzi)", + qa_correct: "Dobrze", + qa_incorrect: "Źle", + qa_key_terms: "Kluczowe pojęcia", + qa_session_correct: "Dobrze", + qa_session_incorrect: "Źle", + qa_session_complete: "Runda nauki zakończona!", + qa_result_correct: "poprawnie", + qa_restart: "Ucz się ponownie", + qa_print_with_answers: "Drukować z odpowiedziami?", + question: "Pytanie", + answer: "Odpowiedź", + status_generating_qa: "Tworzenie P&O…", + status_qa_generated: "P&O utworzone", + close: "Zamknij", + subject: "Przedmiot", + grade: "Poziom", + questions: "pytań", + worksheet: "Karta pracy", + loading: "Ładowanie...", + error: "Błąd", + success: "Sukces", + imprint: "Impressum", + privacy: "Prywatność", + contact: "Kontakt", + status_ready: "Gotowe", + status_processing: "Przetwarzanie...", + status_generating_mc: "Tworzenie pytań…", + status_generating_cloze: "Tworzenie tekstów…", + status_please_wait: "Proszę czekać, AI pracuje.", + status_mc_generated: "Pytania utworzone", + status_cloze_generated: "Teksty utworzone", + status_files_created: "plików utworzono", + mindmap_title: "Plakat Mapa myśli", + mindmap_desc: "Tworzy przyjazną dla dzieci mapę myśli z głównym tematem w centrum i wszystkimi terminami w kolorowych kategoriach.", + mindmap_generate: "Utwórz mapę", + mindmap_show: "Podgląd", + mindmap_print_a3: "Drukuj A3", + generating_mindmap: "Tworzenie mapy...", + mindmap_generated: "Mapa utworzona!", + no_analysis: "Brak analizy", + analyze_first: "Najpierw wykonaj analizę (kliknij Przetwórz)", + categories: "Kategorie", + terms: "Terminy", + }, + + en: { + brand_sub: "Studio", + nav_compare: "Worksheets", + nav_tiles: "Learning Tiles", + login: "Login / Sign Up", + mvp_local: "MVP · Local on your Mac", + sidebar_areas: "Areas", + sidebar_studio: "Worksheet Studio", + sidebar_active: "active", + sidebar_parents: "Parents Channel", + sidebar_soon: "coming soon", + sidebar_correction: "Correction / Grades", + sidebar_units: "Learning Units (local)", + input_student: "Student", + input_subject: "Subject", + input_grade: "Grade (e.g. 7a)", + input_unit_title: "Learning Unit / Topic", + btn_create: "Create", + btn_add_current: "Add current worksheet", + btn_filter_unit: "Unit only", + btn_filter_all: "All files", + uploaded_worksheets: "Uploaded Worksheets", + files: "files", + btn_upload: "Upload", + btn_delete: "Delete", + original_scan: "Original Scan", + cleaned_version: "Cleaned (handwriting removed)", + no_cleaned: "No cleaned version available yet.", + process_hint: "Click 'Process' to analyze and clean the worksheet.", + worksheet_print: "Print", + worksheet_no_data: "No worksheet data available.", + btn_full_process: "Process (Analysis + Cleaning + HTML)", + btn_original_generate: "Generate Original HTML Only", + learning_unit: "Learning Unit", + no_unit_selected: "No unit selected", + mc_title: "Multiple Choice Test", + mc_ready: "Ready", + mc_generating: "Generating...", + mc_done: "Done", + mc_error: "Error", + mc_desc: "Creates multiple choice questions matching the original difficulty level (e.g. Grade 7).", + mc_generate: "Generate MC", + mc_show: "Show Questions", + mc_quiz_title: "Multiple Choice Quiz", + mc_evaluate: "Evaluate", + mc_correct: "Correct!", + mc_incorrect: "Unfortunately wrong.", + mc_not_answered: "Not answered. Correct answer:", + mc_result: "of", + mc_result_correct: "correct", + mc_percent: "correct", + mc_no_questions: "No MC questions generated yet for this worksheet.", + mc_print: "Print", + mc_print_with_answers: "Print with answers?", + cloze_title: "Fill in the Blanks", + cloze_desc: "Creates texts with multiple meaningful gaps per sentence. Including translation for parents.", + cloze_translation: "Translation:", + cloze_generate: "Generate Cloze Text", + cloze_start: "Start Exercise", + cloze_exercise_title: "Fill in the Blanks Exercise", + cloze_instruction: "Fill in the blanks and click 'Check'.", + cloze_check: "Check", + cloze_show_answers: "Show Answers", + cloze_no_texts: "No cloze texts generated yet for this worksheet.", + cloze_sentences: "sentences", + cloze_gaps: "gaps", + cloze_gaps_total: "Total gaps", + cloze_with_gaps: "(with gaps)", + cloze_print: "Print", + cloze_print_with_answers: "Print with answers?", + qa_title: "Question & Answer Sheet", + qa_desc: "Q&A pairs with Leitner box system. Spaced repetition by difficulty level.", + qa_generate: "Generate Q&A", + qa_learn: "Start Learning", + qa_print: "Print", + qa_no_questions: "No Q&A generated yet for this worksheet.", + qa_box_new: "New", + qa_box_learning: "Learning", + qa_box_mastered: "Mastered", + qa_show_answer: "Show Answer", + qa_your_answer: "Your Answer", + qa_type_answer: "Write your answer here...", + qa_check_answer: "Check Answer", + qa_correct_answer: "Correct Answer", + qa_self_evaluate: "Was your answer correct?", + qa_no_answer: "(no answer entered)", + qa_correct: "Correct", + qa_incorrect: "Incorrect", + qa_key_terms: "Key Terms", + qa_session_correct: "Correct", + qa_session_incorrect: "Incorrect", + qa_session_complete: "Learning session complete!", + qa_result_correct: "correct", + qa_restart: "Learn Again", + qa_print_with_answers: "Print with answers?", + question: "Question", + answer: "Answer", + status_generating_qa: "Generating Q&A…", + status_qa_generated: "Q&A generated", + close: "Close", + subject: "Subject", + grade: "Level", + questions: "questions", + worksheet: "Worksheet", + loading: "Loading...", + error: "Error", + success: "Success", + imprint: "Imprint", + privacy: "Privacy", + contact: "Contact", + status_ready: "Ready", + status_processing: "Processing...", + status_generating_mc: "Generating MC questions…", + status_generating_cloze: "Generating cloze texts…", + status_please_wait: "Please wait, AI is working.", + status_mc_generated: "MC questions generated", + status_cloze_generated: "Cloze texts generated", + status_files_created: "files created", + mindmap_title: "Mindmap Learning Poster", + mindmap_desc: "Creates a child-friendly mindmap with the main topic in the center and all terms in colorful categories.", + mindmap_generate: "Create Mindmap", + mindmap_show: "View", + mindmap_print_a3: "Print A3", + generating_mindmap: "Creating mindmap...", + mindmap_generated: "Mindmap created!", + no_analysis: "No analysis", + analyze_first: "Please analyze first (click Process)", + categories: "Categories", + terms: "Terms", + } +}; + +// RTL-Sprachen (Right-to-Left) +export const rtlLanguages = ['ar']; + +// Standard-Sprache +export const defaultLanguage = 'de'; + +// Verfügbare Sprachen mit Labels +export const availableLanguages = { + de: 'Deutsch', + en: 'English', + tr: 'Türkçe', + ar: 'العربية', + ru: 'Русский', + uk: 'Українська', + pl: 'Polski' +}; diff --git a/backend/frontend/static/js/studio.js b/backend/frontend/static/js/studio.js new file mode 100644 index 0000000..5facd56 --- /dev/null +++ b/backend/frontend/static/js/studio.js @@ -0,0 +1,9788 @@ +console.log('studio.js loading...'); + +// ========================================== + // THEME TOGGLE (Dark/Light Mode) + // ========================================== + (function() { + const savedTheme = localStorage.getItem('bp-theme') || 'dark'; + document.documentElement.setAttribute('data-theme', savedTheme); + console.log('Initial theme set to:', savedTheme); + })(); + + function initThemeToggle() { + const toggle = document.getElementById('theme-toggle'); + const icon = document.getElementById('theme-icon'); + const label = document.getElementById('theme-label'); + + if (!toggle || !icon || !label) { + console.warn('Theme toggle elements not found'); + return; + } + + function updateToggleUI(theme) { + if (theme === 'light') { + icon.textContent = '☀️'; + label.textContent = 'Light'; + } else { + icon.textContent = '🌙'; + label.textContent = 'Dark'; + } + } + + // Initialize UI based on current theme + const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark'; + updateToggleUI(currentTheme); + + toggle.addEventListener('click', function() { + console.log('Theme toggle clicked'); + const current = document.documentElement.getAttribute('data-theme') || 'dark'; + const newTheme = current === 'dark' ? 'light' : 'dark'; + console.log('Switching from', current, 'to', newTheme); + + document.documentElement.setAttribute('data-theme', newTheme); + localStorage.setItem('bp-theme', newTheme); + updateToggleUI(newTheme); + }); + } + + // ========================================== + // INTERNATIONALISIERUNG (i18n) + // ========================================== + const translations = { + de: { + // Navigation & Header + brand_sub: "Studio", + nav_compare: "Arbeitsblätter", + nav_tiles: "Lern-Kacheln", + login: "Login / Anmeldung", + mvp_local: "MVP · Lokal auf deinem Mac", + + // Sidebar + sidebar_areas: "Bereiche", + sidebar_studio: "Arbeitsblatt Studio", + sidebar_active: "aktiv", + sidebar_parents: "Eltern-Kanal", + sidebar_soon: "demnächst", + sidebar_correction: "Korrektur / Noten", + sidebar_units: "Lerneinheiten (lokal)", + input_student: "Schüler/in", + input_subject: "Fach", + input_grade: "Klasse (z.B. 7a)", + input_unit_title: "Lerneinheit / Thema", + btn_create: "Anlegen", + btn_add_current: "Aktuelles Arbeitsblatt hinzufügen", + btn_filter_unit: "Nur Lerneinheit", + btn_filter_all: "Alle Dateien", + + // Screen 1 - Compare + uploaded_worksheets: "Hochgeladene Arbeitsblätter", + files: "Dateien", + btn_upload: "Hochladen", + btn_delete: "Löschen", + original_scan: "Original-Scan", + cleaned_version: "Bereinigt (Handschrift entfernt)", + no_cleaned: "Noch keine bereinigte Version vorhanden.", + process_hint: "Klicke auf 'Verarbeiten', um das Arbeitsblatt zu analysieren und zu bereinigen.", + worksheet_print: "Drucken", + worksheet_no_data: "Keine Arbeitsblatt-Daten vorhanden.", + btn_full_process: "Verarbeiten (Analyse + Bereinigung + HTML)", + btn_original_generate: "Nur Original-HTML generieren", + + // Screen 2 - Tiles + learning_unit: "Lerneinheit", + no_unit_selected: "Keine Lerneinheit ausgewählt", + + // MC Tile + mc_title: "Multiple Choice Test", + mc_ready: "Bereit", + mc_generating: "Generiert...", + mc_done: "Fertig", + mc_error: "Fehler", + mc_desc: "Erzeugt passende MC-Aufgaben zur ursprünglichen Schwierigkeit (z. B. Klasse 7), ohne das Niveau zu verändern.", + mc_generate: "MC generieren", + mc_show: "Fragen anzeigen", + mc_quiz_title: "Multiple Choice Quiz", + mc_evaluate: "Auswerten", + mc_correct: "Richtig!", + mc_incorrect: "Leider falsch.", + mc_not_answered: "Nicht beantwortet. Richtig wäre:", + mc_result: "von", + mc_result_correct: "richtig", + mc_percent: "korrekt", + mc_no_questions: "Noch keine MC-Fragen für dieses Arbeitsblatt generiert.", + mc_print: "Drucken", + mc_print_with_answers: "Mit Lösungen drucken?", + + // Cloze Tile + cloze_title: "Lückentext", + cloze_desc: "Erzeugt Lückentexte mit mehreren sinnvollen Lücken pro Satz. Inkl. Übersetzung für Eltern.", + cloze_translation: "Übersetzung:", + cloze_generate: "Lückentext generieren", + cloze_start: "Übung starten", + cloze_exercise_title: "Lückentext-Übung", + cloze_instruction: "Fülle die Lücken aus und klicke auf 'Prüfen'.", + cloze_check: "Prüfen", + cloze_show_answers: "Lösungen zeigen", + cloze_no_texts: "Noch keine Lückentexte für dieses Arbeitsblatt generiert.", + cloze_sentences: "Sätze", + cloze_gaps: "Lücken", + cloze_gaps_total: "Lücken gesamt", + cloze_with_gaps: "(mit Lücken)", + cloze_print: "Drucken", + cloze_print_with_answers: "Mit Lösungen drucken?", + + // QA Tile + qa_title: "Frage-Antwort-Blatt", + qa_desc: "Frage-Antwort-Paare mit Leitner-Box System. Wiederholung nach Schwierigkeitsgrad.", + qa_generate: "Q&A generieren", + qa_learn: "Lernen starten", + qa_print: "Drucken", + qa_no_questions: "Noch keine Q&A für dieses Arbeitsblatt generiert.", + qa_box_new: "Neu", + qa_box_learning: "Gelernt", + qa_box_mastered: "Gefestigt", + qa_show_answer: "Antwort zeigen", + qa_your_answer: "Deine Antwort", + qa_type_answer: "Schreibe deine Antwort hier...", + qa_check_answer: "Antwort prüfen", + qa_correct_answer: "Richtige Antwort", + qa_self_evaluate: "War deine Antwort richtig?", + qa_no_answer: "(keine Antwort eingegeben)", + qa_correct: "Richtig", + qa_incorrect: "Falsch", + qa_key_terms: "Schlüsselbegriffe", + qa_session_correct: "Richtig", + qa_session_incorrect: "Falsch", + qa_session_complete: "Lernrunde abgeschlossen!", + qa_result_correct: "richtig", + qa_restart: "Nochmal lernen", + qa_print_with_answers: "Mit Lösungen drucken?", + question: "Frage", + answer: "Antwort", + status_generating_qa: "Generiere Q&A …", + status_qa_generated: "Q&A generiert", + + // Common + close: "Schließen", + subject: "Fach", + grade: "Stufe", + questions: "Fragen", + worksheet: "Arbeitsblatt", + loading: "Lädt...", + error: "Fehler", + success: "Erfolgreich", + + // Footer + imprint: "Impressum", + privacy: "Datenschutz", + contact: "Kontakt", + + // Status messages + status_ready: "Bereit", + status_processing: "Verarbeitet...", + status_generating_mc: "Generiere MC-Fragen …", + status_generating_cloze: "Generiere Lückentexte …", + status_please_wait: "Bitte warten, KI arbeitet.", + status_mc_generated: "MC-Fragen generiert", + status_cloze_generated: "Lückentexte generiert", + status_files_created: "Dateien erstellt", + + // Mindmap Tile + mindmap_title: "Mindmap Lernposter", + mindmap_desc: "Erstellt eine kindgerechte Mindmap mit dem Hauptthema in der Mitte und allen Fachbegriffen in farbigen Kategorien.", + mindmap_generate: "Mindmap erstellen", + mindmap_show: "Ansehen", + mindmap_print_a3: "A3 Drucken", + generating_mindmap: "Erstelle Mindmap...", + mindmap_generated: "Mindmap erstellt!", + no_analysis: "Keine Analyse", + analyze_first: "Bitte zuerst analysieren (Verarbeiten starten)", + categories: "Kategorien", + terms: "Begriffe", + }, + + tr: { + brand_sub: "Stüdyo", + nav_compare: "Çalışma Sayfaları", + nav_tiles: "Öğrenme Kartları", + login: "Giriş / Kayıt", + mvp_local: "MVP · Mac'inizde Yerel", + + sidebar_areas: "Alanlar", + sidebar_studio: "Çalışma Sayfası Stüdyosu", + sidebar_active: "aktif", + sidebar_parents: "Ebeveyn Kanalı", + sidebar_soon: "yakında", + sidebar_correction: "Düzeltme / Notlar", + sidebar_units: "Öğrenme Birimleri (yerel)", + input_student: "Öğrenci", + input_subject: "Ders", + input_grade: "Sınıf (örn. 7a)", + input_unit_title: "Öğrenme Birimi / Konu", + btn_create: "Oluştur", + btn_add_current: "Mevcut çalışma sayfasını ekle", + btn_filter_unit: "Sadece Birim", + btn_filter_all: "Tüm Dosyalar", + + uploaded_worksheets: "Yüklenen Çalışma Sayfaları", + files: "Dosya", + btn_upload: "Yükle", + btn_delete: "Sil", + original_scan: "Orijinal Tarama", + cleaned_version: "Temizlenmiş (El yazısı kaldırıldı)", + no_cleaned: "Henüz temizlenmiş sürüm yok.", + process_hint: "Çalışma sayfasını analiz etmek ve temizlemek için 'İşle'ye tıklayın.", + worksheet_print: "Yazdır", + worksheet_no_data: "Çalışma sayfası verisi yok.", + btn_full_process: "İşle (Analiz + Temizleme + HTML)", + btn_original_generate: "Sadece Orijinal HTML Oluştur", + + learning_unit: "Öğrenme Birimi", + no_unit_selected: "Öğrenme birimi seçilmedi", + + mc_title: "Çoktan Seçmeli Test", + mc_ready: "Hazır", + mc_generating: "Oluşturuluyor...", + mc_done: "Tamamlandı", + mc_error: "Hata", + mc_desc: "Orijinal zorluğa uygun (örn. 7. sınıf) çoktan seçmeli sorular oluşturur.", + mc_generate: "ÇS Oluştur", + mc_show: "Soruları Göster", + mc_quiz_title: "Çoktan Seçmeli Quiz", + mc_evaluate: "Değerlendir", + mc_correct: "Doğru!", + mc_incorrect: "Maalesef yanlış.", + mc_not_answered: "Cevaplanmadı. Doğru cevap:", + mc_result: "/", + mc_result_correct: "doğru", + mc_percent: "doğru", + mc_no_questions: "Bu çalışma sayfası için henüz ÇS sorusu oluşturulmadı.", + mc_print: "Yazdır", + mc_print_with_answers: "Cevaplarla yazdır?", + + cloze_title: "Boşluk Doldurma", + cloze_desc: "Her cümlede birden fazla anlamlı boşluk içeren metinler oluşturur. Ebeveynler için çeviri dahil.", + cloze_translation: "Çeviri:", + cloze_generate: "Boşluk Metni Oluştur", + cloze_start: "Alıştırmayı Başlat", + cloze_exercise_title: "Boşluk Doldurma Alıştırması", + cloze_instruction: "Boşlukları doldurun ve 'Kontrol Et'e tıklayın.", + cloze_check: "Kontrol Et", + cloze_show_answers: "Cevapları Göster", + cloze_no_texts: "Bu çalışma sayfası için henüz boşluk metni oluşturulmadı.", + cloze_sentences: "Cümle", + cloze_gaps: "Boşluk", + cloze_gaps_total: "Toplam boşluk", + cloze_with_gaps: "(boşluklu)", + cloze_print: "Yazdır", + cloze_print_with_answers: "Cevaplarla yazdır?", + + qa_title: "Soru-Cevap Sayfası", + qa_desc: "Leitner kutu sistemiyle soru-cevap çiftleri. Zorluk derecesine göre tekrar.", + qa_generate: "S&C Oluştur", + qa_learn: "Öğrenmeye Başla", + qa_print: "Yazdır", + qa_no_questions: "Bu çalışma sayfası için henüz S&C oluşturulmadı.", + qa_box_new: "Yeni", + qa_box_learning: "Öğreniliyor", + qa_box_mastered: "Pekiştirildi", + qa_show_answer: "Cevabı Göster", + qa_your_answer: "Senin Cevabın", + qa_type_answer: "Cevabını buraya yaz...", + qa_check_answer: "Cevabı Kontrol Et", + qa_correct_answer: "Doğru Cevap", + qa_self_evaluate: "Cevabın doğru muydu?", + qa_no_answer: "(cevap girilmedi)", + qa_correct: "Doğru", + qa_incorrect: "Yanlış", + qa_key_terms: "Anahtar Kavramlar", + qa_session_correct: "Doğru", + qa_session_incorrect: "Yanlış", + qa_session_complete: "Öğrenme turu tamamlandı!", + qa_result_correct: "doğru", + qa_restart: "Tekrar Öğren", + qa_print_with_answers: "Cevaplarla yazdır?", + question: "Soru", + answer: "Cevap", + status_generating_qa: "S&C oluşturuluyor…", + status_qa_generated: "S&C oluşturuldu", + + close: "Kapat", + subject: "Ders", + grade: "Seviye", + questions: "Soru", + worksheet: "Çalışma Sayfası", + loading: "Yükleniyor...", + error: "Hata", + success: "Başarılı", + + imprint: "Künye", + privacy: "Gizlilik", + contact: "İletişim", + + status_ready: "Hazır", + status_processing: "İşleniyor...", + status_generating_mc: "ÇS soruları oluşturuluyor…", + status_generating_cloze: "Boşluk metinleri oluşturuluyor…", + status_please_wait: "Lütfen bekleyin, yapay zeka çalışıyor.", + status_mc_generated: "ÇS soruları oluşturuldu", + status_cloze_generated: "Boşluk metinleri oluşturuldu", + status_files_created: "dosya oluşturuldu", + + // Mindmap Tile + mindmap_title: "Zihin Haritası Poster", + mindmap_desc: "Ana konuyu ortada ve tüm terimleri renkli kategorilerde gösteren çocuk dostu bir zihin haritası oluşturur.", + mindmap_generate: "Zihin Haritası Oluştur", + mindmap_show: "Görüntüle", + mindmap_print_a3: "A3 Yazdır", + generating_mindmap: "Zihin haritası oluşturuluyor...", + mindmap_generated: "Zihin haritası oluşturuldu!", + no_analysis: "Analiz yok", + analyze_first: "Lütfen önce analiz edin (İşle'ye tıklayın)", + categories: "Kategoriler", + terms: "Terimler", + }, + + ar: { + brand_sub: "ستوديو", + nav_compare: "أوراق العمل", + nav_tiles: "بطاقات التعلم", + login: "تسجيل الدخول / التسجيل", + mvp_local: "MVP · محلي على جهازك", + + sidebar_areas: "الأقسام", + sidebar_studio: "استوديو أوراق العمل", + sidebar_active: "نشط", + sidebar_parents: "قناة الوالدين", + sidebar_soon: "قريباً", + sidebar_correction: "التصحيح / الدرجات", + sidebar_units: "وحدات التعلم (محلية)", + input_student: "الطالب/ة", + input_subject: "المادة", + input_grade: "الصف (مثل 7أ)", + input_unit_title: "وحدة التعلم / الموضوع", + btn_create: "إنشاء", + btn_add_current: "إضافة ورقة العمل الحالية", + btn_filter_unit: "الوحدة فقط", + btn_filter_all: "جميع الملفات", + + uploaded_worksheets: "أوراق العمل المحملة", + files: "ملفات", + btn_upload: "تحميل", + btn_delete: "حذف", + original_scan: "المسح الأصلي", + cleaned_version: "منظف (تم إزالة الكتابة اليدوية)", + no_cleaned: "لا توجد نسخة منظفة بعد.", + process_hint: "انقر على 'معالجة' لتحليل وتنظيف ورقة العمل.", + worksheet_print: "طباعة", + worksheet_no_data: "لا توجد بيانات ورقة العمل.", + btn_full_process: "معالجة (تحليل + تنظيف + HTML)", + btn_original_generate: "إنشاء HTML الأصلي فقط", + + learning_unit: "وحدة التعلم", + no_unit_selected: "لم يتم اختيار وحدة تعلم", + + mc_title: "اختبار متعدد الخيارات", + mc_ready: "جاهز", + mc_generating: "جاري الإنشاء...", + mc_done: "تم", + mc_error: "خطأ", + mc_desc: "ينشئ أسئلة اختيار من متعدد تناسب مستوى الصعوبة الأصلي (مثل الصف 7).", + mc_generate: "إنشاء أسئلة", + mc_show: "عرض الأسئلة", + mc_quiz_title: "اختبار متعدد الخيارات", + mc_evaluate: "تقييم", + mc_correct: "صحيح!", + mc_incorrect: "للأسف خطأ.", + mc_not_answered: "لم تتم الإجابة. الإجابة الصحيحة:", + mc_result: "من", + mc_result_correct: "صحيح", + mc_percent: "صحيح", + mc_no_questions: "لم يتم إنشاء أسئلة بعد لورقة العمل هذه.", + mc_print: "طباعة", + mc_print_with_answers: "طباعة مع الإجابات؟", + + cloze_title: "ملء الفراغات", + cloze_desc: "ينشئ نصوصاً بفراغات متعددة في كل جملة. يشمل الترجمة للوالدين.", + cloze_translation: "الترجمة:", + cloze_generate: "إنشاء نص الفراغات", + cloze_start: "بدء التمرين", + cloze_exercise_title: "تمرين ملء الفراغات", + cloze_instruction: "املأ الفراغات وانقر على 'تحقق'.", + cloze_check: "تحقق", + cloze_show_answers: "عرض الإجابات", + cloze_no_texts: "لم يتم إنشاء نصوص فراغات بعد لورقة العمل هذه.", + cloze_sentences: "جمل", + cloze_gaps: "فراغات", + cloze_gaps_total: "إجمالي الفراغات", + cloze_with_gaps: "(مع فراغات)", + cloze_print: "طباعة", + cloze_print_with_answers: "طباعة مع الإجابات؟", + + qa_title: "ورقة الأسئلة والأجوبة", + qa_desc: "أزواج أسئلة وأجوبة مع نظام صندوق لايتنر. التكرار حسب الصعوبة.", + qa_generate: "إنشاء س&ج", + qa_learn: "بدء التعلم", + qa_print: "طباعة", + qa_no_questions: "لم يتم إنشاء س&ج بعد لورقة العمل هذه.", + qa_box_new: "جديد", + qa_box_learning: "قيد التعلم", + qa_box_mastered: "متقن", + qa_show_answer: "عرض الإجابة", + qa_your_answer: "إجابتك", + qa_type_answer: "اكتب إجابتك هنا...", + qa_check_answer: "تحقق من الإجابة", + qa_correct_answer: "الإجابة الصحيحة", + qa_self_evaluate: "هل كانت إجابتك صحيحة؟", + qa_no_answer: "(لم يتم إدخال إجابة)", + qa_correct: "صحيح", + qa_incorrect: "خطأ", + qa_key_terms: "المصطلحات الرئيسية", + qa_session_correct: "صحيح", + qa_session_incorrect: "خطأ", + qa_session_complete: "اكتملت جولة التعلم!", + qa_result_correct: "صحيح", + qa_restart: "تعلم مرة أخرى", + qa_print_with_answers: "طباعة مع الإجابات؟", + question: "سؤال", + answer: "إجابة", + status_generating_qa: "جاري إنشاء س&ج…", + status_qa_generated: "تم إنشاء س&ج", + + close: "إغلاق", + subject: "المادة", + grade: "المستوى", + questions: "أسئلة", + worksheet: "ورقة العمل", + loading: "جاري التحميل...", + error: "خطأ", + success: "نجاح", + + imprint: "البصمة", + privacy: "الخصوصية", + contact: "اتصل بنا", + + status_ready: "جاهز", + status_processing: "جاري المعالجة...", + status_generating_mc: "جاري إنشاء الأسئلة…", + status_generating_cloze: "جاري إنشاء نصوص الفراغات…", + status_please_wait: "يرجى الانتظار، الذكاء الاصطناعي يعمل.", + status_mc_generated: "تم إنشاء الأسئلة", + status_cloze_generated: "تم إنشاء نصوص الفراغات", + status_files_created: "ملفات تم إنشاؤها", + + // Mindmap Tile + mindmap_title: "ملصق خريطة ذهنية", + mindmap_desc: "ينشئ خريطة ذهنية مناسبة للأطفال مع الموضوع الرئيسي في المنتصف وجميع المصطلحات في فئات ملونة.", + mindmap_generate: "إنشاء خريطة ذهنية", + mindmap_show: "عرض", + mindmap_print_a3: "طباعة A3", + generating_mindmap: "جاري إنشاء الخريطة الذهنية...", + mindmap_generated: "تم إنشاء الخريطة الذهنية!", + no_analysis: "لا يوجد تحليل", + analyze_first: "يرجى التحليل أولاً (انقر على معالجة)", + categories: "الفئات", + terms: "المصطلحات", + }, + + ru: { + brand_sub: "Студия", + nav_compare: "Рабочие листы", + nav_tiles: "Учебные карточки", + login: "Вход / Регистрация", + mvp_local: "MVP · Локально на вашем Mac", + + sidebar_areas: "Разделы", + sidebar_studio: "Студия рабочих листов", + sidebar_active: "активно", + sidebar_parents: "Канал для родителей", + sidebar_soon: "скоро", + sidebar_correction: "Проверка / Оценки", + sidebar_units: "Учебные блоки (локально)", + input_student: "Ученик", + input_subject: "Предмет", + input_grade: "Класс (напр. 7а)", + input_unit_title: "Учебный блок / Тема", + btn_create: "Создать", + btn_add_current: "Добавить текущий лист", + btn_filter_unit: "Только блок", + btn_filter_all: "Все файлы", + + uploaded_worksheets: "Загруженные рабочие листы", + files: "файлов", + btn_upload: "Загрузить", + btn_delete: "Удалить", + original_scan: "Оригинальный скан", + cleaned_version: "Очищено (рукопись удалена)", + no_cleaned: "Очищенная версия пока недоступна.", + process_hint: "Нажмите 'Обработать' для анализа и очистки листа.", + worksheet_print: "Печать", + worksheet_no_data: "Нет данных рабочего листа.", + btn_full_process: "Обработать (Анализ + Очистка + HTML)", + btn_original_generate: "Только оригинальный HTML", + + learning_unit: "Учебный блок", + no_unit_selected: "Блок не выбран", + + mc_title: "Тест с выбором ответа", + mc_ready: "Готово", + mc_generating: "Создается...", + mc_done: "Готово", + mc_error: "Ошибка", + mc_desc: "Создает вопросы с выбором ответа соответствующей сложности (напр. 7 класс).", + mc_generate: "Создать тест", + mc_show: "Показать вопросы", + mc_quiz_title: "Тест с выбором ответа", + mc_evaluate: "Оценить", + mc_correct: "Правильно!", + mc_incorrect: "К сожалению, неверно.", + mc_not_answered: "Нет ответа. Правильный ответ:", + mc_result: "из", + mc_result_correct: "правильно", + mc_percent: "верно", + mc_no_questions: "Вопросы для этого листа еще не созданы.", + mc_print: "Печать", + mc_print_with_answers: "Печатать с ответами?", + + cloze_title: "Текст с пропусками", + cloze_desc: "Создает тексты с несколькими пропусками в каждом предложении. Включая перевод для родителей.", + cloze_translation: "Перевод:", + cloze_generate: "Создать текст", + cloze_start: "Начать упражнение", + cloze_exercise_title: "Упражнение с пропусками", + cloze_instruction: "Заполните пропуски и нажмите 'Проверить'.", + cloze_check: "Проверить", + cloze_show_answers: "Показать ответы", + cloze_no_texts: "Тексты для этого листа еще не созданы.", + cloze_sentences: "предложений", + cloze_gaps: "пропусков", + cloze_gaps_total: "Всего пропусков", + cloze_with_gaps: "(с пропусками)", + cloze_print: "Печать", + cloze_print_with_answers: "Печатать с ответами?", + + qa_title: "Лист вопросов и ответов", + qa_desc: "Пары вопрос-ответ с системой Лейтнера. Повторение по уровню сложности.", + qa_generate: "Создать В&О", + qa_learn: "Начать обучение", + qa_print: "Печать", + qa_no_questions: "В&О для этого листа еще не созданы.", + qa_box_new: "Новый", + qa_box_learning: "Изучается", + qa_box_mastered: "Освоено", + qa_show_answer: "Показать ответ", + qa_your_answer: "Твой ответ", + qa_type_answer: "Напиши свой ответ здесь...", + qa_check_answer: "Проверить ответ", + qa_correct_answer: "Правильный ответ", + qa_self_evaluate: "Твой ответ был правильным?", + qa_no_answer: "(ответ не введён)", + qa_correct: "Правильно", + qa_incorrect: "Неправильно", + qa_key_terms: "Ключевые термины", + qa_session_correct: "Правильно", + qa_session_incorrect: "Неправильно", + qa_session_complete: "Раунд обучения завершен!", + qa_result_correct: "правильно", + qa_restart: "Учить снова", + qa_print_with_answers: "Печатать с ответами?", + question: "Вопрос", + answer: "Ответ", + status_generating_qa: "Создание В&О…", + status_qa_generated: "В&О созданы", + + close: "Закрыть", + subject: "Предмет", + grade: "Уровень", + questions: "вопросов", + worksheet: "Рабочий лист", + loading: "Загрузка...", + error: "Ошибка", + success: "Успешно", + + imprint: "Импрессум", + privacy: "Конфиденциальность", + contact: "Контакт", + + status_ready: "Готово", + status_processing: "Обработка...", + status_generating_mc: "Создание вопросов…", + status_generating_cloze: "Создание текстов…", + status_please_wait: "Пожалуйста, подождите, ИИ работает.", + status_mc_generated: "Вопросы созданы", + status_cloze_generated: "Тексты созданы", + status_files_created: "файлов создано", + + // Mindmap Tile + mindmap_title: "Плакат Майнд-карта", + mindmap_desc: "Создает детскую ментальную карту с главной темой в центре и всеми терминами в цветных категориях.", + mindmap_generate: "Создать карту", + mindmap_show: "Просмотр", + mindmap_print_a3: "Печать A3", + generating_mindmap: "Создание карты...", + mindmap_generated: "Карта создана!", + no_analysis: "Нет анализа", + analyze_first: "Сначала выполните анализ (нажмите Обработать)", + categories: "Категории", + terms: "Термины", + }, + + uk: { + brand_sub: "Студія", + nav_compare: "Робочі аркуші", + nav_tiles: "Навчальні картки", + login: "Вхід / Реєстрація", + mvp_local: "MVP · Локально на вашому Mac", + + sidebar_areas: "Розділи", + sidebar_studio: "Студія робочих аркушів", + sidebar_active: "активно", + sidebar_parents: "Канал для батьків", + sidebar_soon: "незабаром", + sidebar_correction: "Перевірка / Оцінки", + sidebar_units: "Навчальні блоки (локально)", + input_student: "Учень", + input_subject: "Предмет", + input_grade: "Клас (напр. 7а)", + input_unit_title: "Навчальний блок / Тема", + btn_create: "Створити", + btn_add_current: "Додати поточний аркуш", + btn_filter_unit: "Лише блок", + btn_filter_all: "Усі файли", + + uploaded_worksheets: "Завантажені робочі аркуші", + files: "файлів", + btn_upload: "Завантажити", + btn_delete: "Видалити", + original_scan: "Оригінальний скан", + cleaned_version: "Очищено (рукопис видалено)", + no_cleaned: "Очищена версія ще недоступна.", + process_hint: "Натисніть 'Обробити' для аналізу та очищення аркуша.", + worksheet_print: "Друк", + worksheet_no_data: "Немає даних робочого аркуша.", + btn_full_process: "Обробити (Аналіз + Очищення + HTML)", + btn_original_generate: "Лише оригінальний HTML", + + learning_unit: "Навчальний блок", + no_unit_selected: "Блок не вибрано", + + mc_title: "Тест з вибором відповіді", + mc_ready: "Готово", + mc_generating: "Створюється...", + mc_done: "Готово", + mc_error: "Помилка", + mc_desc: "Створює питання з вибором відповіді відповідної складності (напр. 7 клас).", + mc_generate: "Створити тест", + mc_show: "Показати питання", + mc_quiz_title: "Тест з вибором відповіді", + mc_evaluate: "Оцінити", + mc_correct: "Правильно!", + mc_incorrect: "На жаль, неправильно.", + mc_not_answered: "Немає відповіді. Правильна відповідь:", + mc_result: "з", + mc_result_correct: "правильно", + mc_percent: "вірно", + mc_no_questions: "Питання для цього аркуша ще не створені.", + mc_print: "Друк", + mc_print_with_answers: "Друкувати з відповідями?", + + cloze_title: "Текст з пропусками", + cloze_desc: "Створює тексти з кількома пропусками в кожному реченні. Включаючи переклад для батьків.", + cloze_translation: "Переклад:", + cloze_generate: "Створити текст", + cloze_start: "Почати вправу", + cloze_exercise_title: "Вправа з пропусками", + cloze_instruction: "Заповніть пропуски та натисніть 'Перевірити'.", + cloze_check: "Перевірити", + cloze_show_answers: "Показати відповіді", + cloze_no_texts: "Тексти для цього аркуша ще не створені.", + cloze_sentences: "речень", + cloze_gaps: "пропусків", + cloze_gaps_total: "Всього пропусків", + cloze_with_gaps: "(з пропусками)", + cloze_print: "Друк", + cloze_print_with_answers: "Друкувати з відповідями?", + + qa_title: "Аркуш питань і відповідей", + qa_desc: "Пари питання-відповідь з системою Лейтнера. Повторення за рівнем складності.", + qa_generate: "Створити П&В", + qa_learn: "Почати навчання", + qa_print: "Друк", + qa_no_questions: "П&В для цього аркуша ще не створені.", + qa_box_new: "Новий", + qa_box_learning: "Вивчається", + qa_box_mastered: "Засвоєно", + qa_show_answer: "Показати відповідь", + qa_your_answer: "Твоя відповідь", + qa_type_answer: "Напиши свою відповідь тут...", + qa_check_answer: "Перевірити відповідь", + qa_correct_answer: "Правильна відповідь", + qa_self_evaluate: "Твоя відповідь була правильною?", + qa_no_answer: "(відповідь не введена)", + qa_correct: "Правильно", + qa_incorrect: "Неправильно", + qa_key_terms: "Ключові терміни", + qa_session_correct: "Правильно", + qa_session_incorrect: "Неправильно", + qa_session_complete: "Раунд навчання завершено!", + qa_result_correct: "правильно", + qa_restart: "Вчити знову", + qa_print_with_answers: "Друкувати з відповідями?", + question: "Питання", + answer: "Відповідь", + status_generating_qa: "Створення П&В…", + status_qa_generated: "П&В створені", + + close: "Закрити", + subject: "Предмет", + grade: "Рівень", + questions: "питань", + worksheet: "Робочий аркуш", + loading: "Завантаження...", + error: "Помилка", + success: "Успішно", + + imprint: "Імпресум", + privacy: "Конфіденційність", + contact: "Контакт", + + status_ready: "Готово", + status_processing: "Обробка...", + status_generating_mc: "Створення питань…", + status_generating_cloze: "Створення текстів…", + status_please_wait: "Будь ласка, зачекайте, ШІ працює.", + status_mc_generated: "Питання створені", + status_cloze_generated: "Тексти створені", + status_files_created: "файлів створено", + + // Mindmap Tile + mindmap_title: "Плакат Інтелект-карта", + mindmap_desc: "Створює дитячу інтелект-карту з головною темою в центрі та всіма термінами в кольорових категоріях.", + mindmap_generate: "Створити карту", + mindmap_show: "Переглянути", + mindmap_print_a3: "Друк A3", + generating_mindmap: "Створення карти...", + mindmap_generated: "Карту створено!", + no_analysis: "Немає аналізу", + analyze_first: "Спочатку виконайте аналіз (натисніть Обробити)", + categories: "Категорії", + terms: "Терміни", + }, + + pl: { + brand_sub: "Studio", + nav_compare: "Karty pracy", + nav_tiles: "Karty nauki", + login: "Logowanie / Rejestracja", + mvp_local: "MVP · Lokalnie na Twoim Mac", + + sidebar_areas: "Sekcje", + sidebar_studio: "Studio kart pracy", + sidebar_active: "aktywne", + sidebar_parents: "Kanał dla rodziców", + sidebar_soon: "wkrótce", + sidebar_correction: "Korekta / Oceny", + sidebar_units: "Jednostki nauki (lokalnie)", + input_student: "Uczeń", + input_subject: "Przedmiot", + input_grade: "Klasa (np. 7a)", + input_unit_title: "Jednostka nauki / Temat", + btn_create: "Utwórz", + btn_add_current: "Dodaj bieżącą kartę", + btn_filter_unit: "Tylko jednostka", + btn_filter_all: "Wszystkie pliki", + + uploaded_worksheets: "Przesłane karty pracy", + files: "plików", + btn_upload: "Prześlij", + btn_delete: "Usuń", + original_scan: "Oryginalny skan", + cleaned_version: "Oczyszczone (pismo ręczne usunięte)", + no_cleaned: "Oczyszczona wersja jeszcze niedostępna.", + process_hint: "Kliknij 'Przetwórz', aby przeanalizować i oczyścić kartę.", + worksheet_print: "Drukuj", + worksheet_no_data: "Brak danych arkusza.", + btn_full_process: "Przetwórz (Analiza + Czyszczenie + HTML)", + btn_original_generate: "Tylko oryginalny HTML", + + learning_unit: "Jednostka nauki", + no_unit_selected: "Nie wybrano jednostki", + + mc_title: "Test wielokrotnego wyboru", + mc_ready: "Gotowe", + mc_generating: "Tworzenie...", + mc_done: "Gotowe", + mc_error: "Błąd", + mc_desc: "Tworzy pytania wielokrotnego wyboru o odpowiednim poziomie trudności (np. klasa 7).", + mc_generate: "Utwórz test", + mc_show: "Pokaż pytania", + mc_quiz_title: "Test wielokrotnego wyboru", + mc_evaluate: "Oceń", + mc_correct: "Dobrze!", + mc_incorrect: "Niestety źle.", + mc_not_answered: "Brak odpowiedzi. Poprawna odpowiedź:", + mc_result: "z", + mc_result_correct: "poprawnie", + mc_percent: "poprawnie", + mc_no_questions: "Pytania dla tej karty jeszcze nie zostały utworzone.", + mc_print: "Drukuj", + mc_print_with_answers: "Drukować z odpowiedziami?", + + cloze_title: "Tekst z lukami", + cloze_desc: "Tworzy teksty z wieloma lukami w każdym zdaniu. W tym tłumaczenie dla rodziców.", + cloze_translation: "Tłumaczenie:", + cloze_generate: "Utwórz tekst", + cloze_start: "Rozpocznij ćwiczenie", + cloze_exercise_title: "Ćwiczenie z lukami", + cloze_instruction: "Wypełnij luki i kliknij 'Sprawdź'.", + cloze_check: "Sprawdź", + cloze_show_answers: "Pokaż odpowiedzi", + cloze_no_texts: "Teksty dla tej karty jeszcze nie zostały utworzone.", + cloze_sentences: "zdań", + cloze_gaps: "luk", + cloze_gaps_total: "Łącznie luk", + cloze_with_gaps: "(z lukami)", + cloze_print: "Drukuj", + cloze_print_with_answers: "Drukować z odpowiedziami?", + + qa_title: "Arkusz pytań i odpowiedzi", + qa_desc: "Pary pytanie-odpowiedź z systemem Leitnera. Powtórki według poziomu trudności.", + qa_generate: "Utwórz P&O", + qa_learn: "Rozpocznij naukę", + qa_print: "Drukuj", + qa_no_questions: "P&O dla tej karty jeszcze nie zostały utworzone.", + qa_box_new: "Nowy", + qa_box_learning: "W nauce", + qa_box_mastered: "Opanowane", + qa_show_answer: "Pokaż odpowiedź", + qa_your_answer: "Twoja odpowiedź", + qa_type_answer: "Napisz swoją odpowiedź tutaj...", + qa_check_answer: "Sprawdź odpowiedź", + qa_correct_answer: "Prawidłowa odpowiedź", + qa_self_evaluate: "Czy twoja odpowiedź była poprawna?", + qa_no_answer: "(nie wprowadzono odpowiedzi)", + qa_correct: "Dobrze", + qa_incorrect: "Źle", + qa_key_terms: "Kluczowe pojęcia", + qa_session_correct: "Dobrze", + qa_session_incorrect: "Źle", + qa_session_complete: "Runda nauki zakończona!", + qa_result_correct: "poprawnie", + qa_restart: "Ucz się ponownie", + qa_print_with_answers: "Drukować z odpowiedziami?", + question: "Pytanie", + answer: "Odpowiedź", + status_generating_qa: "Tworzenie P&O…", + status_qa_generated: "P&O utworzone", + + close: "Zamknij", + subject: "Przedmiot", + grade: "Poziom", + questions: "pytań", + worksheet: "Karta pracy", + loading: "Ładowanie...", + error: "Błąd", + success: "Sukces", + + imprint: "Impressum", + privacy: "Prywatność", + contact: "Kontakt", + + status_ready: "Gotowe", + status_processing: "Przetwarzanie...", + status_generating_mc: "Tworzenie pytań…", + status_generating_cloze: "Tworzenie tekstów…", + status_please_wait: "Proszę czekać, AI pracuje.", + status_mc_generated: "Pytania utworzone", + status_cloze_generated: "Teksty utworzone", + status_files_created: "plików utworzono", + + // Mindmap Tile + mindmap_title: "Plakat Mapa myśli", + mindmap_desc: "Tworzy przyjazną dla dzieci mapę myśli z głównym tematem w centrum i wszystkimi terminami w kolorowych kategoriach.", + mindmap_generate: "Utwórz mapę", + mindmap_show: "Podgląd", + mindmap_print_a3: "Drukuj A3", + generating_mindmap: "Tworzenie mapy...", + mindmap_generated: "Mapa utworzona!", + no_analysis: "Brak analizy", + analyze_first: "Najpierw wykonaj analizę (kliknij Przetwórz)", + categories: "Kategorie", + terms: "Terminy", + }, + + en: { + brand_sub: "Studio", + nav_compare: "Worksheets", + nav_tiles: "Learning Tiles", + login: "Login / Sign Up", + mvp_local: "MVP · Local on your Mac", + + sidebar_areas: "Areas", + sidebar_studio: "Worksheet Studio", + sidebar_active: "active", + sidebar_parents: "Parents Channel", + sidebar_soon: "coming soon", + sidebar_correction: "Correction / Grades", + sidebar_units: "Learning Units (local)", + input_student: "Student", + input_subject: "Subject", + input_grade: "Grade (e.g. 7a)", + input_unit_title: "Learning Unit / Topic", + btn_create: "Create", + btn_add_current: "Add current worksheet", + btn_filter_unit: "Unit only", + btn_filter_all: "All files", + + uploaded_worksheets: "Uploaded Worksheets", + files: "files", + btn_upload: "Upload", + btn_delete: "Delete", + original_scan: "Original Scan", + cleaned_version: "Cleaned (handwriting removed)", + no_cleaned: "No cleaned version available yet.", + process_hint: "Click 'Process' to analyze and clean the worksheet.", + worksheet_print: "Print", + worksheet_no_data: "No worksheet data available.", + btn_full_process: "Process (Analysis + Cleaning + HTML)", + btn_original_generate: "Generate Original HTML Only", + + learning_unit: "Learning Unit", + no_unit_selected: "No unit selected", + + mc_title: "Multiple Choice Test", + mc_ready: "Ready", + mc_generating: "Generating...", + mc_done: "Done", + mc_error: "Error", + mc_desc: "Creates multiple choice questions matching the original difficulty level (e.g. Grade 7).", + mc_generate: "Generate MC", + mc_show: "Show Questions", + mc_quiz_title: "Multiple Choice Quiz", + mc_evaluate: "Evaluate", + mc_correct: "Correct!", + mc_incorrect: "Unfortunately wrong.", + mc_not_answered: "Not answered. Correct answer:", + mc_result: "of", + mc_result_correct: "correct", + mc_percent: "correct", + mc_no_questions: "No MC questions generated yet for this worksheet.", + mc_print: "Print", + mc_print_with_answers: "Print with answers?", + + cloze_title: "Fill in the Blanks", + cloze_desc: "Creates texts with multiple meaningful gaps per sentence. Including translation for parents.", + cloze_translation: "Translation:", + cloze_generate: "Generate Cloze Text", + cloze_start: "Start Exercise", + cloze_exercise_title: "Fill in the Blanks Exercise", + cloze_instruction: "Fill in the blanks and click 'Check'.", + cloze_check: "Check", + cloze_show_answers: "Show Answers", + cloze_no_texts: "No cloze texts generated yet for this worksheet.", + cloze_sentences: "sentences", + cloze_gaps: "gaps", + cloze_gaps_total: "Total gaps", + cloze_with_gaps: "(with gaps)", + cloze_print: "Print", + cloze_print_with_answers: "Print with answers?", + + qa_title: "Question & Answer Sheet", + qa_desc: "Q&A pairs with Leitner box system. Spaced repetition by difficulty level.", + qa_generate: "Generate Q&A", + qa_learn: "Start Learning", + qa_print: "Print", + qa_no_questions: "No Q&A generated yet for this worksheet.", + qa_box_new: "New", + qa_box_learning: "Learning", + qa_box_mastered: "Mastered", + qa_show_answer: "Show Answer", + qa_your_answer: "Your Answer", + qa_type_answer: "Write your answer here...", + qa_check_answer: "Check Answer", + qa_correct_answer: "Correct Answer", + qa_self_evaluate: "Was your answer correct?", + qa_no_answer: "(no answer entered)", + qa_correct: "Correct", + qa_incorrect: "Incorrect", + qa_key_terms: "Key Terms", + qa_session_correct: "Correct", + qa_session_incorrect: "Incorrect", + qa_session_complete: "Learning session complete!", + qa_result_correct: "correct", + qa_restart: "Learn Again", + qa_print_with_answers: "Print with answers?", + question: "Question", + answer: "Answer", + status_generating_qa: "Generating Q&A…", + status_qa_generated: "Q&A generated", + + close: "Close", + subject: "Subject", + grade: "Level", + questions: "questions", + worksheet: "Worksheet", + loading: "Loading...", + error: "Error", + success: "Success", + + imprint: "Imprint", + privacy: "Privacy", + contact: "Contact", + + status_ready: "Ready", + status_processing: "Processing...", + status_generating_mc: "Generating MC questions…", + status_generating_cloze: "Generating cloze texts…", + status_please_wait: "Please wait, AI is working.", + status_mc_generated: "MC questions generated", + status_cloze_generated: "Cloze texts generated", + status_files_created: "files created", + + // Mindmap Tile + mindmap_title: "Mindmap Learning Poster", + mindmap_desc: "Creates a child-friendly mindmap with the main topic in the center and all terms in colorful categories.", + mindmap_generate: "Create Mindmap", + mindmap_show: "View", + mindmap_print_a3: "Print A3", + generating_mindmap: "Creating mindmap...", + mindmap_generated: "Mindmap created!", + no_analysis: "No analysis", + analyze_first: "Please analyze first (click Process)", + categories: "Categories", + terms: "Terms", + } + }; + + // Aktuelle Sprache (aus localStorage oder Browser-Sprache) + let currentLang = localStorage.getItem('bp_language') || 'de'; + + // RTL-Sprachen + const rtlLanguages = ['ar']; + + // Übersetzungsfunktion + function t(key) { + const lang = translations[currentLang] || translations['de']; + return lang[key] || translations['de'][key] || key; + } + + // Sprache anwenden + function applyLanguage(lang) { + currentLang = lang; + localStorage.setItem('bp_language', lang); + + // RTL für Arabisch + if (rtlLanguages.includes(lang)) { + document.documentElement.setAttribute('dir', 'rtl'); + } else { + document.documentElement.setAttribute('dir', 'ltr'); + } + + // Alle Elemente mit data-i18n aktualisieren + document.querySelectorAll('[data-i18n]').forEach(el => { + const key = el.getAttribute('data-i18n'); + if (el.tagName === 'INPUT' && el.hasAttribute('placeholder')) { + el.placeholder = t(key); + } else { + el.textContent = t(key); + } + }); + + // Spezielle Elemente manuell aktualisieren + updateUITexts(); + } + + // UI-Texte aktualisieren + function updateUITexts() { + // Header + const brandSub = document.querySelector('.brand-text-sub'); + if (brandSub) brandSub.textContent = t('brand_sub'); + + // Navigation (Text entfernt - wird nicht mehr angezeigt) + // const navItems = document.querySelectorAll('.top-nav-item'); + // if (navItems[0]) navItems[0].textContent = t('nav_compare'); + // if (navItems[1]) navItems[1].textContent = t('nav_tiles'); + + // Sidebar Bereiche + const sidebarTitles = document.querySelectorAll('.sidebar-section-title'); + if (sidebarTitles[0]) sidebarTitles[0].textContent = t('sidebar_areas'); + if (sidebarTitles[1]) sidebarTitles[1].textContent = t('sidebar_units'); + + const sidebarLabels = document.querySelectorAll('.sidebar-item-label span'); + if (sidebarLabels[0]) sidebarLabels[0].textContent = t('sidebar_studio'); + if (sidebarLabels[1]) sidebarLabels[1].textContent = t('sidebar_parents'); + if (sidebarLabels[2]) sidebarLabels[2].textContent = t('sidebar_correction'); + + const sidebarBadges = document.querySelectorAll('.sidebar-item-badge'); + if (sidebarBadges[0]) sidebarBadges[0].textContent = t('sidebar_active'); + if (sidebarBadges[1]) sidebarBadges[1].textContent = t('sidebar_soon'); + if (sidebarBadges[2]) sidebarBadges[2].textContent = t('sidebar_soon'); + + // Input Placeholders + if (unitStudentInput) unitStudentInput.placeholder = t('input_student'); + if (unitSubjectInput) unitSubjectInput.placeholder = t('input_subject'); + if (unitGradeInput) unitGradeInput.placeholder = t('input_grade'); + if (unitTitleInput) unitTitleInput.placeholder = t('input_unit_title'); + + // Buttons + if (btnAddUnit) btnAddUnit.textContent = t('btn_create'); + if (btnAttachCurrentToLu) btnAttachCurrentToLu.textContent = t('btn_add_current'); + if (btnToggleFilter) { + btnToggleFilter.textContent = showOnlyUnitFiles ? t('btn_filter_unit') : t('btn_filter_all'); + } + if (btnFullProcess) btnFullProcess.textContent = t('btn_full_process'); + if (btnOriginalGenerate) btnOriginalGenerate.textContent = t('btn_original_generate'); + + // Titles + const uploadedTitle = document.querySelector('.panel-left > div:first-child'); + if (uploadedTitle) { + uploadedTitle.innerHTML = '' + t('uploaded_worksheets') + ' 0 ' + t('files') + ''; + } + + // MC Tile + const mcTitle = document.querySelector('[data-tile="mc"] .card-title'); + if (mcTitle) mcTitle.textContent = t('mc_title'); + const mcDesc = document.querySelector('[data-tile="mc"] .card-body > div:first-child'); + if (mcDesc) mcDesc.textContent = t('mc_desc'); + if (btnMcGenerate) btnMcGenerate.textContent = t('mc_generate'); + if (btnMcShow) btnMcShow.textContent = t('mc_show'); + + // Cloze Tile + const clozeTitle = document.querySelector('[data-tile="cloze"] .card-title'); + if (clozeTitle) clozeTitle.textContent = t('cloze_title'); + const clozeDesc = document.querySelector('[data-tile="cloze"] .card-body > div:first-child'); + if (clozeDesc) clozeDesc.textContent = t('cloze_desc'); + const clozeLabel = document.querySelector('.cloze-language-select label'); + if (clozeLabel) clozeLabel.textContent = t('cloze_translation'); + if (btnClozeGenerate) btnClozeGenerate.textContent = t('cloze_generate'); + if (btnClozeShow) btnClozeShow.textContent = t('cloze_start'); + + // QA Tile + const qaTitle = document.querySelector('[data-tile="qa"] .card-title'); + if (qaTitle) qaTitle.textContent = t('qa_title'); + const qaDesc = document.querySelector('[data-tile="qa"] .card-body > div:first-child'); + if (qaDesc) qaDesc.textContent = t('qa_desc'); + const qaBadge = document.querySelector('[data-tile="qa"] .card-badge'); + if (qaBadge) qaBadge.textContent = t('qa_soon'); + + // Modal Titles + const mcModalTitle = document.querySelector('#mc-modal .mc-modal-title'); + if (mcModalTitle) mcModalTitle.textContent = t('mc_quiz_title'); + const clozeModalTitle = document.querySelector('#cloze-modal .mc-modal-title'); + if (clozeModalTitle) clozeModalTitle.textContent = t('cloze_exercise_title'); + + // Close Buttons + document.querySelectorAll('.mc-modal-close').forEach(btn => { + btn.textContent = t('close') + ' ✕'; + }); + if (lightboxClose) lightboxClose.textContent = t('close') + ' ✕'; + + // Footer + const footerLinks = document.querySelectorAll('.footer a'); + if (footerLinks[0]) footerLinks[0].textContent = t('imprint'); + if (footerLinks[1]) footerLinks[1].textContent = t('privacy'); + if (footerLinks[2]) footerLinks[2].textContent = t('contact'); + } + + const eingangListEl = document.getElementById('eingang-list'); + const eingangCountEl = document.getElementById('eingang-count'); + const previewContainer = document.getElementById('preview-container'); + const fileInput = document.getElementById('file-input'); + const btnUploadInline = document.getElementById('btn-upload-inline'); + const btnFullProcess = document.getElementById('btn-full-process'); + const btnOriginalGenerate = document.getElementById('btn-original-generate'); + const statusBar = document.getElementById('status-bar'); + const statusDot = document.getElementById('status-dot'); + const statusMain = document.getElementById('status-main'); + const statusSub = document.getElementById('status-sub'); + + const panelCompare = document.getElementById('panel-compare'); + const panelTiles = document.getElementById('panel-tiles'); + const pagerPrev = document.getElementById('pager-prev'); + const pagerNext = document.getElementById('pager-next'); + const pagerLabel = document.getElementById('pager-label'); + + const topNavItems = document.querySelectorAll('.top-nav-item'); + + const lightboxEl = document.getElementById('lightbox'); + const lightboxImg = document.getElementById('lightbox-img'); + const lightboxCaption = document.getElementById('lightbox-caption'); + const lightboxClose = document.getElementById('lightbox-close'); + + const unitStudentInput = document.getElementById('unit-student'); + const unitSubjectInput = document.getElementById('unit-subject'); + const unitGradeInput = document.getElementById('unit-grade'); + const unitTitleInput = document.getElementById('unit-title'); + const unitListEl = document.getElementById('unit-list'); + const btnAddUnit = document.getElementById('btn-add-unit'); + const btnAttachCurrentToLu = document.getElementById('btn-attach-current-to-lu'); + const unitHeading1 = document.getElementById('unit-heading-screen1'); + const unitHeading2 = document.getElementById('unit-heading-screen2'); + const btnToggleFilter = document.getElementById('btn-toggle-filter'); + + let currentSelectedFile = null; + let allEingangFiles = []; // Master-Liste aller Dateien + let eingangFiles = []; // aktuell gefilterte Ansicht + let currentIndex = 0; + let showOnlyUnitFiles = true; // Filter-Modus: true = nur Lerneinheit (Standard), false = alle + + let allWorksheetPairs = {}; // Master-Mapping original -> { clean_html, clean_image } + let worksheetPairs = {}; // aktuell gefiltertes Mapping + + let tileState = { + mindmap: true, + qa: true, + mc: true, + cloze: true, + }; + let currentScreen = 1; + + // Lerneinheiten aus dem Backend + let units = []; + let currentUnitId = null; + + // --- Lightbox / Vollbild --- + function openLightbox(src, caption) { + if (!src) return; + lightboxImg.src = src; + lightboxCaption.textContent = caption || ''; + lightboxEl.classList.remove('hidden'); + } + + function closeLightbox() { + lightboxEl.classList.add('hidden'); + lightboxImg.src = ''; + lightboxCaption.textContent = ''; + } + + lightboxClose.addEventListener('click', closeLightbox); + lightboxEl.addEventListener('click', (ev) => { + if (ev.target === lightboxEl) { + closeLightbox(); + } + }); + document.addEventListener('keydown', (ev) => { + if (ev.key === 'Escape') { + closeLightbox(); + // Close compare view if open + const compareView = document.getElementById('version-compare-view'); + if (compareView && compareView.classList.contains('active')) { + hideCompareView(); + } + } + }); + + // --- Status-Balken --- + function setStatus(main, sub = '', state = 'idle') { + statusMain.textContent = main; + statusSub.textContent = sub; + statusDot.classList.remove('busy', 'error'); + if (state === 'busy') { + statusDot.classList.add('busy'); + } else if (state === 'error') { + statusDot.classList.add('error'); + } + } + + setStatus('Bereit', 'Lade Arbeitsblätter hoch und starte den Neuaufbau.'); + + // --- API-Helfer --- + async function apiFetch(url, options = {}) { + const resp = await fetch(url, options); + if (!resp.ok) { + throw new Error('HTTP ' + resp.status); + } + return resp.json(); + } + + // --- Dateien laden & rendern --- + async function loadEingangFiles() { + try { + const data = await apiFetch('/api/eingang-dateien'); + allEingangFiles = data.eingang || []; + eingangFiles = allEingangFiles.slice(); + currentIndex = 0; + renderEingangList(); + } catch (e) { + console.error(e); + setStatus('Fehler beim Laden der Dateien', String(e), 'error'); + } + } + + function renderEingangList() { + eingangListEl.innerHTML = ''; + + if (!eingangFiles.length) { + const li = document.createElement('li'); + li.className = 'file-empty'; + li.textContent = 'Noch keine Dateien vorhanden.'; + eingangListEl.appendChild(li); + eingangCountEl.textContent = '0 Dateien'; + return; + } + + eingangFiles.forEach((filename, idx) => { + const li = document.createElement('li'); + li.className = 'file-item'; + if (idx === currentIndex) { + li.classList.add('active'); + } + + const nameSpan = document.createElement('span'); + nameSpan.className = 'file-item-name'; + nameSpan.textContent = filename; + + const actionsSpan = document.createElement('span'); + actionsSpan.style.display = 'flex'; + actionsSpan.style.gap = '6px'; + + // Button: Aus Lerneinheit entfernen + const removeFromUnitBtn = document.createElement('span'); + removeFromUnitBtn.className = 'file-item-delete'; + removeFromUnitBtn.textContent = '✕'; + removeFromUnitBtn.title = 'Aus Lerneinheit entfernen'; + removeFromUnitBtn.addEventListener('click', (ev) => { + ev.stopPropagation(); + + if (!currentUnitId) { + alert('Zum Entfernen bitte zuerst eine Lerneinheit auswählen.'); + return; + } + + const ok = confirm('Dieses Arbeitsblatt aus der aktuellen Lerneinheit entfernen? Die Datei selbst bleibt erhalten.'); + if (!ok) return; + + removeWorksheetFromCurrentUnit(eingangFiles[idx]); + }); + + // Button: Datei komplett löschen + const deleteFileBtn = document.createElement('span'); + deleteFileBtn.className = 'file-item-delete'; + deleteFileBtn.textContent = '🗑️'; + deleteFileBtn.title = 'Datei komplett löschen'; + deleteFileBtn.style.color = '#ef4444'; + deleteFileBtn.addEventListener('click', async (ev) => { + ev.stopPropagation(); + + const ok = confirm(`Datei "${eingangFiles[idx]}" wirklich komplett löschen? Diese Aktion kann nicht rückgängig gemacht werden.`); + if (!ok) return; + + await deleteFileCompletely(eingangFiles[idx]); + }); + + actionsSpan.appendChild(removeFromUnitBtn); + actionsSpan.appendChild(deleteFileBtn); + + li.appendChild(nameSpan); + li.appendChild(actionsSpan); + + li.addEventListener('click', () => { + currentIndex = idx; + currentSelectedFile = filename; + renderEingangList(); + renderPreviewForCurrent(); + }); + + eingangListEl.appendChild(li); + }); + + eingangCountEl.textContent = eingangFiles.length + (eingangFiles.length === 1 ? ' Datei' : ' Dateien'); + } + + async function loadWorksheetPairs() { + try { + const data = await apiFetch('/api/worksheet-pairs'); + allWorksheetPairs = {}; + (data.pairs || []).forEach((p) => { + allWorksheetPairs[p.original] = { clean_html: p.clean_html, clean_image: p.clean_image }; + }); + worksheetPairs = { ...allWorksheetPairs }; + renderPreviewForCurrent(); + } catch (e) { + console.error(e); + setStatus('Fehler beim Laden der Neuaufbau-Daten', String(e), 'error'); + } + } + + function renderPreviewForCurrent() { + if (!eingangFiles.length) { + const message = showOnlyUnitFiles && currentUnitId + ? 'Dieser Lerneinheit sind noch keine Arbeitsblätter zugeordnet.' + : 'Keine Dateien vorhanden.'; + previewContainer.innerHTML = `
    ${message}
    `; + return; + } + if (currentIndex < 0) currentIndex = 0; + if (currentIndex >= eingangFiles.length) currentIndex = eingangFiles.length - 1; + + const filename = eingangFiles[currentIndex]; + const entry = worksheetPairs[filename] || { clean_html: null, clean_image: null }; + + renderPreview(entry, currentIndex); + } + + function renderThumbnailsInColumn(container) { + container.innerHTML = ''; + + if (eingangFiles.length <= 1) { + return; // Keine Thumbnails nötig wenn nur 1 oder 0 Dateien + } + + // Zeige bis zu 5 Thumbnails (die nächsten Dateien nach dem aktuellen) + const maxThumbs = 5; + let thumbCount = 0; + + for (let i = 0; i < eingangFiles.length && thumbCount < maxThumbs; i++) { + if (i === currentIndex) continue; // Aktuelles Dokument überspringen + + const filename = eingangFiles[i]; + const thumb = document.createElement('div'); + thumb.className = 'preview-thumb'; + + const img = document.createElement('img'); + img.src = '/preview-file/' + encodeURIComponent(filename); + img.alt = filename; + + const label = document.createElement('div'); + label.className = 'preview-thumb-label'; + label.textContent = `${i + 1}`; + + thumb.appendChild(img); + thumb.appendChild(label); + + thumb.addEventListener('click', () => { + currentIndex = i; + renderEingangList(); + renderPreviewForCurrent(); + }); + + container.appendChild(thumb); + thumbCount++; + } + } + + function renderPreview(entry, index) { + previewContainer.innerHTML = ''; + + const wrapper = document.createElement('div'); + wrapper.className = 'compare-wrapper'; + + // Original + const originalSection = document.createElement('div'); + originalSection.className = 'compare-section'; + const origHeader = document.createElement('div'); + origHeader.className = 'compare-header'; + origHeader.innerHTML = 'Original-ScanAlt (links)'; + const origBody = document.createElement('div'); + origBody.className = 'compare-body'; + const origInner = document.createElement('div'); + origInner.className = 'compare-body-inner'; + + const img = document.createElement('img'); + img.className = 'preview-img'; + const imgSrc = '/preview-file/' + encodeURIComponent(eingangFiles[index]); + img.src = imgSrc; + img.alt = 'Original ' + eingangFiles[index]; + img.addEventListener('dblclick', () => openLightbox(imgSrc, eingangFiles[index])); + + origInner.appendChild(img); + origBody.appendChild(origInner); + originalSection.appendChild(origHeader); + originalSection.appendChild(origBody); + + // Neu aufgebaut + const cleanSection = document.createElement('div'); + cleanSection.className = 'compare-section'; + const cleanHeader = document.createElement('div'); + cleanHeader.className = 'compare-header'; + cleanHeader.innerHTML = 'Neu aufgebautes ArbeitsblattNeu (rechts)'; + const cleanBody = document.createElement('div'); + cleanBody.className = 'compare-body'; + const cleanInner = document.createElement('div'); + cleanInner.className = 'compare-body-inner'; + + // Bevorzuge bereinigtes Bild über HTML (für pixel-genaue Darstellung) + if (entry.clean_image) { + const imgClean = document.createElement('img'); + imgClean.className = 'preview-img'; + const cleanSrc = '/preview-clean-file/' + encodeURIComponent(entry.clean_image); + imgClean.src = cleanSrc; + imgClean.alt = 'Neu aufgebaut ' + eingangFiles[index]; + imgClean.addEventListener('dblclick', () => openLightbox(cleanSrc, eingangFiles[index] + ' (neu)')); + cleanInner.appendChild(imgClean); + } else if (entry.clean_html) { + const frame = document.createElement('iframe'); + frame.className = 'clean-frame'; + frame.src = '/api/clean-html/' + encodeURIComponent(entry.clean_html); + frame.title = 'Neu aufgebautes Arbeitsblatt'; + frame.addEventListener('dblclick', () => { + window.open('/api/clean-html/' + encodeURIComponent(entry.clean_html), '_blank'); + }); + cleanInner.appendChild(frame); + } else { + cleanInner.innerHTML = '
    Noch keine Neuaufbau-Daten vorhanden.
    '; + } + + cleanBody.appendChild(cleanInner); + cleanSection.appendChild(cleanHeader); + cleanSection.appendChild(cleanBody); + + // Print-Button Event-Listener + const printWorksheetBtn = cleanHeader.querySelector('#btn-print-worksheet'); + if (printWorksheetBtn) { + printWorksheetBtn.addEventListener('click', () => { + const currentFile = eingangFiles[currentIndex]; + if (!currentFile) { + alert(t('worksheet_no_data') || 'Keine Arbeitsblatt-Daten vorhanden.'); + return; + } + window.open('/api/print-worksheet/' + encodeURIComponent(currentFile), '_blank'); + }); + } + + // Thumbnails in der Mitte + const thumbsColumn = document.createElement('div'); + thumbsColumn.className = 'preview-thumbnails'; + thumbsColumn.id = 'preview-thumbnails-middle'; + renderThumbnailsInColumn(thumbsColumn); + + wrapper.appendChild(originalSection); + wrapper.appendChild(thumbsColumn); + wrapper.appendChild(cleanSection); + + // Navigation-Buttons hinzufügen + const navDiv = document.createElement('div'); + navDiv.className = 'preview-nav'; + + const prevBtn = document.createElement('button'); + prevBtn.type = 'button'; + prevBtn.textContent = '‹'; + prevBtn.disabled = currentIndex === 0; + prevBtn.addEventListener('click', () => { + if (currentIndex > 0) { + currentIndex--; + renderEingangList(); + renderPreviewForCurrent(); + } + }); + + const nextBtn = document.createElement('button'); + nextBtn.type = 'button'; + nextBtn.textContent = '›'; + nextBtn.disabled = currentIndex >= eingangFiles.length - 1; + nextBtn.addEventListener('click', () => { + if (currentIndex < eingangFiles.length - 1) { + currentIndex++; + renderEingangList(); + renderPreviewForCurrent(); + } + }); + + const positionSpan = document.createElement('span'); + positionSpan.textContent = `${currentIndex + 1} von ${eingangFiles.length}`; + + navDiv.appendChild(prevBtn); + navDiv.appendChild(positionSpan); + navDiv.appendChild(nextBtn); + + wrapper.appendChild(navDiv); + + previewContainer.appendChild(wrapper); + } + + // --- Upload --- + btnUploadInline.addEventListener('click', async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + + const files = fileInput.files; + if (!files || !files.length) { + alert('Bitte erst Dateien auswählen.'); + return; + } + + const formData = new FormData(); + for (const file of files) { + formData.append('files', file); + } + + try { + setStatus('Upload läuft …', 'Dateien werden in den Ordner „Eingang“ geschrieben.', 'busy'); + + const resp = await fetch('/api/upload-multi', { + method: 'POST', + body: formData, + }); + + if (!resp.ok) { + console.error('Upload-Fehler: HTTP', resp.status); + setStatus('Fehler beim Upload', 'Serverantwort: HTTP ' + resp.status, 'error'); + return; + } + + setStatus('Upload abgeschlossen', 'Dateien wurden gespeichert.'); + fileInput.value = ''; + + // Liste neu laden + await loadEingangFiles(); + await loadWorksheetPairs(); + } catch (e) { + console.error('Netzwerkfehler beim Upload', e); + setStatus('Netzwerkfehler beim Upload', String(e), 'error'); + } + }); + + // --- Vollpipeline --- + async function runFullPipeline() { + try { + setStatus('Entferne Handschrift …', 'Bilder werden aufbereitet.', 'busy'); + await apiFetch('/api/remove-handwriting-all', { method: 'POST' }); + + setStatus('Analysiere Arbeitsblätter …', 'Struktur wird erkannt.', 'busy'); + await apiFetch('/api/analyze-all', { method: 'POST' }); + + setStatus('Erzeuge HTML-Arbeitsblätter …', 'Neuaufbau läuft.', 'busy'); + await apiFetch('/api/generate-clean', { method: 'POST' }); + + setStatus('Fertig', 'Alt & Neu können jetzt verglichen werden.'); + await loadWorksheetPairs(); + renderPreviewForCurrent(); + } catch (e) { + console.error(e); + setStatus('Fehler in der Verarbeitung', String(e), 'error'); + } + } + + if (btnFullProcess) btnFullProcess.addEventListener('click', runFullPipeline); + if (btnOriginalGenerate) btnOriginalGenerate.addEventListener('click', runFullPipeline); + + // --- Screen-Navigation (oben + Pager unten) --- + function updateScreen() { + if (currentScreen === 1) { + panelCompare.style.display = 'flex'; + panelTiles.style.display = 'none'; + pagerLabel.textContent = '1 von 2'; + } else { + panelCompare.style.display = 'none'; + panelTiles.style.display = 'flex'; + pagerLabel.textContent = '2 von 2'; + } + + topNavItems.forEach((item) => { + const screen = Number(item.getAttribute('data-screen')); + if (screen === currentScreen) { + item.classList.add('active'); + } else { + item.classList.remove('active'); + } + }); + } + + topNavItems.forEach((item) => { + item.addEventListener('click', () => { + const screen = Number(item.getAttribute('data-screen')); + currentScreen = screen; + updateScreen(); + }); + }); + + pagerPrev.addEventListener('click', () => { + if (currentScreen > 1) { + currentScreen -= 1; + updateScreen(); + } + }); + + pagerNext.addEventListener('click', () => { + if (currentScreen < 2) { + currentScreen += 1; + updateScreen(); + } + }); + + // --- Toggle-Kacheln --- + const tileToggles = document.querySelectorAll('.toggle-pill'); + const cards = document.querySelectorAll('.card'); + + function updateTiles() { + let activeTiles = Object.keys(tileState).filter((k) => tileState[k]); + cards.forEach((card) => { + const key = card.getAttribute('data-tile'); + if (!tileState[key]) { + card.classList.add('card-hidden'); + } else { + card.classList.remove('card-hidden'); + } + card.classList.remove('card-full'); + }); + + if (activeTiles.length === 1) { + const only = activeTiles[0]; + cards.forEach((card) => { + if (card.getAttribute('data-tile') === only) { + card.classList.add('card-full'); + } + }); + } + } + + tileToggles.forEach((btn) => { + btn.addEventListener('click', () => { + const key = btn.getAttribute('data-tile'); + tileState[key] = !tileState[key]; + btn.classList.toggle('active', tileState[key]); + updateTiles(); + }); + }); + + // --- Lerneinheiten-Logik (Backend) --- + function updateUnitHeading(unit = null) { + if (!unit && currentUnitId && units && units.length) { + unit = units.find((u) => u.id === currentUnitId) || null; + } + + let text = 'Keine Lerneinheit ausgewählt'; + if (unit) { + const name = unit.label || unit.title || 'Lerneinheit'; + text = 'Lerneinheit: ' + name; + } + + if (unitHeading1) unitHeading1.textContent = text; + if (unitHeading2) unitHeading2.textContent = text; + } + + function applyUnitFilter() { + let unit = null; + if (currentUnitId && units && units.length) { + unit = units.find((u) => u.id === currentUnitId) || null; + } + + // Wenn Filter deaktiviert ODER keine Lerneinheit ausgewählt -> alle Dateien anzeigen + if (!showOnlyUnitFiles || !unit || !Array.isArray(unit.worksheet_files) || unit.worksheet_files.length === 0) { + eingangFiles = allEingangFiles.slice(); + worksheetPairs = { ...allWorksheetPairs }; + currentIndex = 0; + renderEingangList(); + renderPreviewForCurrent(); + updateUnitHeading(unit); + return; + } + + // Filter aktiv: nur Dateien der aktuellen Lerneinheit anzeigen + const allowed = new Set(unit.worksheet_files || []); + eingangFiles = allEingangFiles.filter((f) => allowed.has(f)); + + const filteredPairs = {}; + Object.keys(allWorksheetPairs).forEach((key) => { + if (allowed.has(key)) { + filteredPairs[key] = allWorksheetPairs[key]; + } + }); + worksheetPairs = filteredPairs; + + currentIndex = 0; + renderEingangList(); + renderPreviewForCurrent(); + updateUnitHeading(unit); + } + + async function loadLearningUnits() { + try { + const resp = await fetch('/api/learning-units/'); + if (!resp.ok) { + console.error('Fehler beim Laden der Lerneinheiten', resp.status); + return; + } + units = await resp.json(); + if (units.length && !currentUnitId) { + currentUnitId = units[0].id; + } + renderUnits(); + applyUnitFilter(); + } catch (e) { + console.error('Netzwerkfehler beim Laden der Lerneinheiten', e); + } + } + + function renderUnits() { + unitListEl.innerHTML = ''; + + if (!units.length) { + const li = document.createElement('li'); + li.className = 'unit-item'; + li.textContent = 'Noch keine Lerneinheiten angelegt.'; + unitListEl.appendChild(li); + updateUnitHeading(null); + return; + } + + units.forEach((u) => { + const li = document.createElement('li'); + li.className = 'unit-item'; + if (u.id === currentUnitId) { + li.classList.add('active'); + } + + const contentDiv = document.createElement('div'); + contentDiv.style.flex = '1'; + contentDiv.style.minWidth = '0'; + + const titleEl = document.createElement('div'); + titleEl.textContent = u.label || u.title || 'Lerneinheit'; + + const metaEl = document.createElement('div'); + metaEl.className = 'unit-item-meta'; + + const metaParts = []; + if (u.meta) { + metaParts.push(u.meta); + } + if (Array.isArray(u.worksheet_files)) { + metaParts.push('Blätter: ' + u.worksheet_files.length); + } + + metaEl.textContent = metaParts.join(' · '); + + contentDiv.appendChild(titleEl); + contentDiv.appendChild(metaEl); + + // Delete-Button + const deleteBtn = document.createElement('span'); + deleteBtn.textContent = '🗑️'; + deleteBtn.style.cursor = 'pointer'; + deleteBtn.style.fontSize = '12px'; + deleteBtn.style.color = '#ef4444'; + deleteBtn.title = 'Lerneinheit löschen'; + deleteBtn.addEventListener('click', async (ev) => { + ev.stopPropagation(); + const ok = confirm(`Lerneinheit "${u.label || u.title}" wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.`); + if (!ok) return; + await deleteLearningUnit(u.id); + }); + + li.appendChild(contentDiv); + li.appendChild(deleteBtn); + + li.addEventListener('click', () => { + currentUnitId = u.id; + renderUnits(); + applyUnitFilter(); + }); + + unitListEl.appendChild(li); + }); + } + + async function addUnitFromForm() { + const student = (unitStudentInput.value || '').trim(); + const subject = (unitSubjectInput.value || '').trim(); + const grade = (unitGradeInput && unitGradeInput.value || '').trim(); + const title = (unitTitleInput.value || '').trim(); + + if (!student && !subject && !title) { + alert('Bitte mindestens einen Wert (Schüler/in, Fach oder Thema) eintragen.'); + return; + } + + const payload = { + student, + subject, + title, + grade, + }; + + try { + const resp = await fetch('/api/learning-units/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + console.error('Fehler beim Anlegen der Lerneinheit', resp.status); + alert('Lerneinheit konnte nicht angelegt werden.'); + return; + } + + const created = await resp.json(); + units.push(created); + currentUnitId = created.id; + + unitStudentInput.value = ''; + unitSubjectInput.value = ''; + unitTitleInput.value = ''; + if (unitGradeInput) unitGradeInput.value = ''; + + renderUnits(); + applyUnitFilter(); + } catch (e) { + console.error('Netzwerkfehler beim Anlegen der Lerneinheit', e); + alert('Netzwerkfehler beim Anlegen der Lerneinheit.'); + } + } + + function getCurrentWorksheetBasename() { + if (!eingangFiles.length) return null; + if (currentIndex < 0 || currentIndex >= eingangFiles.length) return null; + return eingangFiles[currentIndex]; + } + + async function attachCurrentWorksheetToUnit() { + if (!currentUnitId) { + alert('Bitte zuerst eine Lerneinheit auswählen oder anlegen.'); + return; + } + const basename = getCurrentWorksheetBasename(); + if (!basename) { + alert('Bitte zuerst ein Arbeitsblatt im linken Bereich auswählen.'); + return; + } + + const payload = { worksheet_files: [basename] }; + + try { + const resp = await fetch(`/api/learning-units/${currentUnitId}/attach-worksheets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + console.error('Fehler beim Zuordnen des Arbeitsblatts', resp.status); + alert('Arbeitsblatt konnte nicht zugeordnet werden.'); + return; + } + + const updated = await resp.json(); + const idx = units.findIndex((u) => u.id === updated.id); + if (idx !== -1) { + units[idx] = updated; + } + renderUnits(); + applyUnitFilter(); + } catch (e) { + console.error('Netzwerkfehler beim Zuordnen des Arbeitsblatts', e); + alert('Netzwerkfehler beim Zuordnen des Arbeitsblatts.'); + } + } + async function removeWorksheetFromCurrentUnit(filename) { + if (!currentUnitId) { + alert('Bitte zuerst eine Lerneinheit auswählen.'); + return; + } + if (!filename) { + alert('Fehler: kein Dateiname übergeben.'); + return; + } + + const payload = { worksheet_file: filename }; + + try { + const resp = await fetch(`/api/learning-units/${currentUnitId}/remove-worksheet`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + console.error('Fehler beim Entfernen des Arbeitsblatts', resp.status); + alert('Arbeitsblatt konnte nicht aus der Lerneinheit entfernt werden.'); + return; + } + + const updated = await resp.json(); + const idx = units.findIndex((u) => u.id === updated.id); + if (idx !== -1) { + units[idx] = updated; + } + renderUnits(); + applyUnitFilter(); + } catch (e) { + console.error('Netzwerkfehler beim Entfernen des Arbeitsblatts', e); + alert('Netzwerkfehler beim Entfernen des Arbeitsblatts.'); + } + } + + async function deleteFileCompletely(filename) { + if (!filename) { + alert('Fehler: kein Dateiname übergeben.'); + return; + } + + try { + setStatus('Lösche Datei …', filename, 'busy'); + + const resp = await fetch(`/api/eingang-dateien/${encodeURIComponent(filename)}`, { + method: 'DELETE', + }); + + if (!resp.ok) { + console.error('Fehler beim Löschen der Datei', resp.status); + setStatus('Fehler beim Löschen', filename, 'error'); + alert('Datei konnte nicht gelöscht werden.'); + return; + } + + const result = await resp.json(); + if (result.status === 'OK') { + setStatus('Datei gelöscht', filename); + // Dateien neu laden + await loadEingangFiles(); + await loadWorksheetPairs(); + await loadLearningUnits(); + } else { + setStatus('Fehler', result.message, 'error'); + alert(result.message); + } + } catch (e) { + console.error('Netzwerkfehler beim Löschen der Datei', e); + setStatus('Netzwerkfehler', String(e), 'error'); + alert('Netzwerkfehler beim Löschen der Datei.'); + } + } + + async function deleteLearningUnit(unitId) { + if (!unitId) { + alert('Fehler: keine Lerneinheit-ID übergeben.'); + return; + } + + try { + setStatus('Lösche Lerneinheit …', '', 'busy'); + + const resp = await fetch(`/api/learning-units/${unitId}`, { + method: 'DELETE', + }); + + if (!resp.ok) { + console.error('Fehler beim Löschen der Lerneinheit', resp.status); + setStatus('Fehler beim Löschen', '', 'error'); + alert('Lerneinheit konnte nicht gelöscht werden.'); + return; + } + + const result = await resp.json(); + if (result.status === 'deleted') { + setStatus('Lerneinheit gelöscht', ''); + + // Lerneinheit aus der lokalen Liste entfernen + units = units.filter((u) => u.id !== unitId); + + // Wenn die gelöschte Einheit ausgewählt war, Auswahl zurücksetzen + if (currentUnitId === unitId) { + currentUnitId = units.length > 0 ? units[0].id : null; + } + + renderUnits(); + applyUnitFilter(); + } else { + setStatus('Fehler', 'Unbekannter Fehler', 'error'); + alert('Fehler beim Löschen der Lerneinheit.'); + } + } catch (e) { + console.error('Netzwerkfehler beim Löschen der Lerneinheit', e); + setStatus('Netzwerkfehler', String(e), 'error'); + alert('Netzwerkfehler beim Löschen der Lerneinheit.'); + } + } + + if (btnAddUnit) { + btnAddUnit.addEventListener('click', (ev) => { + ev.preventDefault(); + addUnitFromForm(); + }); + } + + if (btnAttachCurrentToLu) { + btnAttachCurrentToLu.addEventListener('click', (ev) => { + ev.preventDefault(); + attachCurrentWorksheetToUnit(); + }); + } + + // --- Filter-Toggle --- + if (btnToggleFilter) { + btnToggleFilter.addEventListener('click', () => { + showOnlyUnitFiles = !showOnlyUnitFiles; + if (showOnlyUnitFiles) { + btnToggleFilter.textContent = 'Nur Lerneinheit'; + btnToggleFilter.classList.add('btn-primary'); + } else { + btnToggleFilter.textContent = 'Alle Dateien'; + btnToggleFilter.classList.remove('btn-primary'); + } + applyUnitFilter(); + }); + } + + // --- Multiple Choice Logik --- + const btnMcGenerate = document.getElementById('btn-mc-generate'); + const btnMcShow = document.getElementById('btn-mc-show'); + const btnMcPrint = document.getElementById('btn-mc-print'); + const mcPreview = document.getElementById('mc-preview'); + const mcBadge = document.getElementById('mc-badge'); + const mcModal = document.getElementById('mc-modal'); + const mcModalBody = document.getElementById('mc-modal-body'); + const mcModalClose = document.getElementById('mc-modal-close'); + + let currentMcData = null; + let mcAnswers = {}; // Speichert Nutzerantworten + + async function generateMcQuestions() { + try { + setStatus('Generiere MC-Fragen …', 'Bitte warten, KI arbeitet.', 'busy'); + if (mcBadge) mcBadge.textContent = 'Generiert...'; + + const resp = await fetch('/api/generate-mc', { method: 'POST' }); + if (!resp.ok) { + throw new Error('HTTP ' + resp.status); + } + + const result = await resp.json(); + if (result.status === 'OK' && result.generated.length > 0) { + setStatus('MC-Fragen generiert', result.generated.length + ' Dateien erstellt.'); + if (mcBadge) mcBadge.textContent = 'Fertig'; + if (btnMcShow) btnMcShow.style.display = 'inline-block'; + if (btnMcPrint) btnMcPrint.style.display = 'inline-block'; + + // Lade die erste MC-Datei für Vorschau + await loadMcPreviewForCurrent(); + } else if (result.errors && result.errors.length > 0) { + setStatus('Fehler bei MC-Generierung', result.errors[0].error, 'error'); + if (mcBadge) mcBadge.textContent = 'Fehler'; + } else { + setStatus('Keine MC-Fragen generiert', 'Möglicherweise fehlen Analyse-Daten.', 'error'); + if (mcBadge) mcBadge.textContent = 'Bereit'; + } + } catch (e) { + console.error('MC-Generierung fehlgeschlagen:', e); + setStatus('Fehler bei MC-Generierung', String(e), 'error'); + if (mcBadge) mcBadge.textContent = 'Fehler'; + } + } + + async function loadMcPreviewForCurrent() { + if (!eingangFiles.length) { + if (mcPreview) mcPreview.innerHTML = '
    Keine Arbeitsblätter vorhanden.
    '; + return; + } + + const currentFile = eingangFiles[currentIndex]; + if (!currentFile) return; + + try { + const resp = await fetch('/api/mc-data/' + encodeURIComponent(currentFile)); + const result = await resp.json(); + + if (result.status === 'OK' && result.data) { + currentMcData = result.data; + renderMcPreview(result.data); + if (btnMcShow) btnMcShow.style.display = 'inline-block'; + if (btnMcPrint) btnMcPrint.style.display = 'inline-block'; + } else { + if (mcPreview) mcPreview.innerHTML = '
    Noch keine MC-Fragen für dieses Arbeitsblatt generiert.
    '; + currentMcData = null; + if (btnMcPrint) btnMcPrint.style.display = 'none'; + } + } catch (e) { + console.error('Fehler beim Laden der MC-Daten:', e); + if (mcPreview) mcPreview.innerHTML = ''; + } + } + + function renderMcPreview(mcData) { + if (!mcPreview) return; + if (!mcData || !mcData.questions || mcData.questions.length === 0) { + mcPreview.innerHTML = '
    Keine Fragen vorhanden.
    '; + return; + } + + const questions = mcData.questions; + const metadata = mcData.metadata || {}; + + let html = ''; + + // Zeige Metadaten + if (metadata.grade_level || metadata.subject) { + html += '
    '; + if (metadata.subject) { + html += '
    Fach: ' + metadata.subject + '
    '; + } + if (metadata.grade_level) { + html += '
    Stufe: ' + metadata.grade_level + '
    '; + } + html += '
    Fragen: ' + questions.length + '
    '; + html += '
    '; + } + + // Zeige erste 2 Fragen als Vorschau + const previewQuestions = questions.slice(0, 2); + previewQuestions.forEach((q, idx) => { + html += '
    '; + html += '
    ' + (idx + 1) + '. ' + q.question + '
    '; + html += '
    '; + q.options.forEach(opt => { + html += '
    '; + html += '' + opt.id + ') ' + opt.text; + html += '
    '; + }); + html += '
    '; + html += '
    '; + }); + + if (questions.length > 2) { + html += '
    + ' + (questions.length - 2) + ' weitere Fragen
    '; + } + + mcPreview.innerHTML = html; + + // Event-Listener für Antwort-Auswahl + mcPreview.querySelectorAll('.mc-option').forEach(optEl => { + optEl.addEventListener('click', () => handleMcOptionClick(optEl)); + }); + } + + function handleMcOptionClick(optEl) { + const qid = optEl.getAttribute('data-qid'); + const optId = optEl.getAttribute('data-opt'); + + if (!currentMcData) return; + + // Finde die Frage + const question = currentMcData.questions.find(q => q.id === qid); + if (!question) return; + + // Markiere alle Optionen dieser Frage + const questionEl = optEl.closest('.mc-question'); + const allOptions = questionEl.querySelectorAll('.mc-option'); + + allOptions.forEach(opt => { + opt.classList.remove('selected', 'correct', 'incorrect'); + const thisOptId = opt.getAttribute('data-opt'); + if (thisOptId === question.correct_answer) { + opt.classList.add('correct'); + } else if (thisOptId === optId) { + opt.classList.add('incorrect'); + } + }); + + // Speichere Antwort + mcAnswers[qid] = optId; + + // Zeige Feedback wenn gewünscht + const isCorrect = optId === question.correct_answer; + let feedbackEl = questionEl.querySelector('.mc-feedback'); + if (!feedbackEl) { + feedbackEl = document.createElement('div'); + feedbackEl.className = 'mc-feedback'; + questionEl.appendChild(feedbackEl); + } + + if (isCorrect) { + feedbackEl.style.background = 'rgba(34,197,94,0.1)'; + feedbackEl.style.borderColor = 'rgba(34,197,94,0.3)'; + feedbackEl.style.color = 'var(--bp-accent)'; + feedbackEl.textContent = 'Richtig! ' + (question.explanation || ''); + } else { + feedbackEl.style.background = 'rgba(239,68,68,0.1)'; + feedbackEl.style.borderColor = 'rgba(239,68,68,0.3)'; + feedbackEl.style.color = '#ef4444'; + feedbackEl.textContent = 'Leider falsch. ' + (question.explanation || ''); + } + } + + function openMcModal() { + if (!currentMcData || !currentMcData.questions) { + alert('Keine MC-Fragen vorhanden. Bitte zuerst generieren.'); + return; + } + + mcAnswers = {}; // Reset Antworten + renderMcModal(currentMcData); + mcModal.classList.remove('hidden'); + } + + function closeMcModal() { + mcModal.classList.add('hidden'); + } + + function renderMcModal(mcData) { + const questions = mcData.questions; + const metadata = mcData.metadata || {}; + + let html = ''; + + // Header mit Metadaten + html += '
    '; + if (metadata.source_title) { + html += '
    Arbeitsblatt: ' + metadata.source_title + '
    '; + } + if (metadata.subject) { + html += '
    Fach: ' + metadata.subject + '
    '; + } + if (metadata.grade_level) { + html += '
    Stufe: ' + metadata.grade_level + '
    '; + } + html += '
    '; + + // Alle Fragen + questions.forEach((q, idx) => { + html += '
    '; + html += '
    ' + (idx + 1) + '. ' + q.question + '
    '; + html += '
    '; + q.options.forEach(opt => { + html += '
    '; + html += '' + opt.id + ') ' + opt.text; + html += '
    '; + }); + html += '
    '; + html += '
    '; + }); + + // Auswertungs-Button + html += '
    '; + html += ''; + html += '
    '; + + mcModalBody.innerHTML = html; + + // Event-Listener + mcModalBody.querySelectorAll('.mc-option').forEach(optEl => { + optEl.addEventListener('click', () => { + const qid = optEl.getAttribute('data-qid'); + const optId = optEl.getAttribute('data-opt'); + + // Deselektiere andere Optionen der gleichen Frage + const questionEl = optEl.closest('.mc-question'); + questionEl.querySelectorAll('.mc-option').forEach(o => o.classList.remove('selected')); + optEl.classList.add('selected'); + + mcAnswers[qid] = optId; + }); + }); + + const btnEvaluate = document.getElementById('btn-mc-evaluate'); + if (btnEvaluate) { + btnEvaluate.addEventListener('click', evaluateMcQuiz); + } + } + + function evaluateMcQuiz() { + if (!currentMcData) return; + + let correct = 0; + let total = currentMcData.questions.length; + + currentMcData.questions.forEach(q => { + const questionEl = mcModalBody.querySelector('.mc-question[data-qid="' + q.id + '"]'); + if (!questionEl) return; + + const userAnswer = mcAnswers[q.id]; + const allOptions = questionEl.querySelectorAll('.mc-option'); + + allOptions.forEach(opt => { + opt.classList.remove('correct', 'incorrect'); + const optId = opt.getAttribute('data-opt'); + if (optId === q.correct_answer) { + opt.classList.add('correct'); + } else if (optId === userAnswer && userAnswer !== q.correct_answer) { + opt.classList.add('incorrect'); + } + }); + + // Zeige Erklärung + let feedbackEl = questionEl.querySelector('.mc-feedback'); + if (!feedbackEl) { + feedbackEl = document.createElement('div'); + feedbackEl.className = 'mc-feedback'; + questionEl.appendChild(feedbackEl); + } + + if (userAnswer === q.correct_answer) { + correct++; + feedbackEl.style.background = 'rgba(34,197,94,0.1)'; + feedbackEl.style.borderColor = 'rgba(34,197,94,0.3)'; + feedbackEl.style.color = 'var(--bp-accent)'; + feedbackEl.textContent = 'Richtig! ' + (q.explanation || ''); + } else if (userAnswer) { + feedbackEl.style.background = 'rgba(239,68,68,0.1)'; + feedbackEl.style.borderColor = 'rgba(239,68,68,0.3)'; + feedbackEl.style.color = '#ef4444'; + feedbackEl.textContent = 'Falsch. ' + (q.explanation || ''); + } else { + feedbackEl.style.background = 'rgba(148,163,184,0.1)'; + feedbackEl.style.borderColor = 'rgba(148,163,184,0.3)'; + feedbackEl.style.color = 'var(--bp-text-muted)'; + feedbackEl.textContent = 'Nicht beantwortet. Richtig wäre: ' + q.correct_answer.toUpperCase(); + } + }); + + // Zeige Gesamtergebnis + const resultHtml = '
    ' + + '
    ' + correct + ' von ' + total + ' richtig
    ' + + '
    ' + Math.round(correct / total * 100) + '% korrekt
    ' + + '
    '; + + const existingResult = mcModalBody.querySelector('.mc-result'); + if (existingResult) { + existingResult.remove(); + } + + const resultDiv = document.createElement('div'); + resultDiv.className = 'mc-result'; + resultDiv.innerHTML = resultHtml; + mcModalBody.appendChild(resultDiv); + } + + function openMcPrintDialog() { + if (!currentMcData) { + alert(t('mc_no_questions') || 'Keine MC-Fragen vorhanden.'); + return; + } + const currentFile = eingangFiles[currentIndex]; + const choice = confirm((t('mc_print_with_answers') || 'Mit Lösungen drucken?') + '\\n\\nOK = Lösungsblatt mit markierten Antworten\\nAbbrechen = Übungsblatt ohne Lösungen'); + const url = '/api/print-mc/' + encodeURIComponent(currentFile) + '?show_answers=' + choice; + window.open(url, '_blank'); + } + + // Event Listener für MC-Buttons + if (btnMcGenerate) { + btnMcGenerate.addEventListener('click', generateMcQuestions); + } + + if (btnMcShow) { + btnMcShow.addEventListener('click', openMcModal); + } + + if (btnMcPrint) { + btnMcPrint.addEventListener('click', openMcPrintDialog); + } + + if (mcModalClose) { + mcModalClose.addEventListener('click', closeMcModal); + } + + if (mcModal) { + mcModal.addEventListener('click', (ev) => { + if (ev.target === mcModal) { + closeMcModal(); + } + }); + } + + // --- Lückentext (Cloze) Logik --- + const btnClozeGenerate = document.getElementById('btn-cloze-generate'); + const btnClozeShow = document.getElementById('btn-cloze-show'); + const btnClozePrint = document.getElementById('btn-cloze-print'); + const clozePreview = document.getElementById('cloze-preview'); + const clozeBadge = document.getElementById('cloze-badge'); + const clozeLanguageSelect = document.getElementById('cloze-language'); + const clozeModal = document.getElementById('cloze-modal'); + const clozeModalBody = document.getElementById('cloze-modal-body'); + const clozeModalClose = document.getElementById('cloze-modal-close'); + + let currentClozeData = null; + let clozeAnswers = {}; // Speichert Nutzerantworten + + async function generateClozeTexts() { + const targetLang = clozeLanguageSelect ? clozeLanguageSelect.value : 'tr'; + + try { + setStatus('Generiere Lückentexte …', 'Bitte warten, KI arbeitet.', 'busy'); + if (clozeBadge) clozeBadge.textContent = 'Generiert...'; + + const resp = await fetch('/api/generate-cloze?target_language=' + targetLang, { method: 'POST' }); + if (!resp.ok) { + throw new Error('HTTP ' + resp.status); + } + + const result = await resp.json(); + if (result.status === 'OK' && result.generated.length > 0) { + setStatus('Lückentexte generiert', result.generated.length + ' Dateien erstellt.'); + if (clozeBadge) clozeBadge.textContent = 'Fertig'; + if (btnClozeShow) btnClozeShow.style.display = 'inline-block'; + if (btnClozePrint) btnClozePrint.style.display = 'inline-block'; + + // Lade Vorschau für aktuelle Datei + await loadClozePreviewForCurrent(); + } else if (result.errors && result.errors.length > 0) { + setStatus('Fehler bei Lückentext-Generierung', result.errors[0].error, 'error'); + if (clozeBadge) clozeBadge.textContent = 'Fehler'; + } else { + setStatus('Keine Lückentexte generiert', 'Möglicherweise fehlen Analyse-Daten.', 'error'); + if (clozeBadge) clozeBadge.textContent = 'Bereit'; + } + } catch (e) { + console.error('Lückentext-Generierung fehlgeschlagen:', e); + setStatus('Fehler bei Lückentext-Generierung', String(e), 'error'); + if (clozeBadge) clozeBadge.textContent = 'Fehler'; + } + } + + async function loadClozePreviewForCurrent() { + if (!eingangFiles.length) { + if (clozePreview) clozePreview.innerHTML = '
    Keine Arbeitsblätter vorhanden.
    '; + return; + } + + const currentFile = eingangFiles[currentIndex]; + if (!currentFile) return; + + try { + const resp = await fetch('/api/cloze-data/' + encodeURIComponent(currentFile)); + const result = await resp.json(); + + if (result.status === 'OK' && result.data) { + currentClozeData = result.data; + renderClozePreview(result.data); + if (btnClozeShow) btnClozeShow.style.display = 'inline-block'; + if (btnClozePrint) btnClozePrint.style.display = 'inline-block'; + } else { + if (clozePreview) clozePreview.innerHTML = '
    Noch keine Lückentexte für dieses Arbeitsblatt generiert.
    '; + currentClozeData = null; + if (btnClozeShow) btnClozeShow.style.display = 'none'; + if (btnClozePrint) btnClozePrint.style.display = 'none'; + } + } catch (e) { + console.error('Fehler beim Laden der Lückentext-Daten:', e); + if (clozePreview) clozePreview.innerHTML = ''; + } + } + + function renderClozePreview(clozeData) { + if (!clozePreview) return; + if (!clozeData || !clozeData.cloze_items || clozeData.cloze_items.length === 0) { + clozePreview.innerHTML = '
    Keine Lückentexte vorhanden.
    '; + return; + } + + const items = clozeData.cloze_items; + const metadata = clozeData.metadata || {}; + + let html = ''; + + // Statistiken + html += '
    '; + if (metadata.subject) { + html += '
    Fach: ' + metadata.subject + '
    '; + } + if (metadata.grade_level) { + html += '
    Stufe: ' + metadata.grade_level + '
    '; + } + html += '
    Sätze: ' + items.length + '
    '; + if (metadata.total_gaps) { + html += '
    Lücken: ' + metadata.total_gaps + '
    '; + } + html += '
    '; + + // Zeige erste 2 Sätze als Vorschau + const previewItems = items.slice(0, 2); + previewItems.forEach((item, idx) => { + html += '
    '; + html += '
    ' + (idx + 1) + '. ' + item.sentence_with_gaps.replace(/___/g, '___') + '
    '; + + // Übersetzung anzeigen + if (item.translation && item.translation.full_sentence) { + html += '
    '; + html += '
    ' + (item.translation.language_name || 'Übersetzung') + ':
    '; + html += item.translation.full_sentence; + html += '
    '; + } + html += '
    '; + }); + + if (items.length > 2) { + html += '
    + ' + (items.length - 2) + ' weitere Sätze
    '; + } + + clozePreview.innerHTML = html; + } + + function openClozeModal() { + if (!currentClozeData || !currentClozeData.cloze_items) { + alert('Keine Lückentexte vorhanden. Bitte zuerst generieren.'); + return; + } + + clozeAnswers = {}; // Reset Antworten + renderClozeModal(currentClozeData); + clozeModal.classList.remove('hidden'); + } + + function closeClozeModal() { + clozeModal.classList.add('hidden'); + } + + function renderClozeModal(clozeData) { + const items = clozeData.cloze_items; + const metadata = clozeData.metadata || {}; + + let html = ''; + + // Header + html += '
    '; + if (metadata.source_title) { + html += '
    Arbeitsblatt: ' + metadata.source_title + '
    '; + } + if (metadata.total_gaps) { + html += '
    Lücken gesamt: ' + metadata.total_gaps + '
    '; + } + html += '
    '; + + html += '
    Fülle die Lücken aus und klicke auf "Prüfen".
    '; + + // Alle Sätze mit Eingabefeldern + items.forEach((item, idx) => { + html += '
    '; + + // Satz mit Eingabefeldern statt ___ + let sentenceHtml = item.sentence_with_gaps; + const gaps = item.gaps || []; + + // Ersetze ___ durch Eingabefelder + let gapIndex = 0; + sentenceHtml = sentenceHtml.replace(/___/g, () => { + const gap = gaps[gapIndex] || { id: 'g' + gapIndex, word: '' }; + const inputId = item.id + '_' + gap.id; + gapIndex++; + return ''; + }); + + html += '
    ' + (idx + 1) + '. ' + sentenceHtml + '
    '; + + // Übersetzung als Hilfe + if (item.translation && item.translation.sentence_with_gaps) { + html += '
    '; + html += '
    ' + (item.translation.language_name || 'Übersetzung') + ' (mit Lücken):
    '; + html += item.translation.sentence_with_gaps; + html += '
    '; + } + + html += '
    '; + }); + + // Buttons + html += '
    '; + html += ''; + html += ''; + html += '
    '; + + clozeModalBody.innerHTML = html; + + // Event-Listener für Prüfen-Button + const btnCheck = document.getElementById('btn-cloze-check'); + if (btnCheck) { + btnCheck.addEventListener('click', checkClozeAnswers); + } + + // Event-Listener für Lösungen zeigen + const btnShowAnswers = document.getElementById('btn-cloze-show-answers'); + if (btnShowAnswers) { + btnShowAnswers.addEventListener('click', showClozeAnswers); + } + + // Enter-Taste zum Prüfen + clozeModalBody.querySelectorAll('.cloze-gap-input').forEach(input => { + input.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + checkClozeAnswers(); + } + }); + }); + } + + function checkClozeAnswers() { + let correct = 0; + let total = 0; + + clozeModalBody.querySelectorAll('.cloze-gap-input').forEach(input => { + const userAnswer = input.value.trim().toLowerCase(); + const correctAnswer = input.getAttribute('data-answer').toLowerCase(); + total++; + + // Entferne vorherige Klassen + input.classList.remove('correct', 'incorrect'); + + if (userAnswer === correctAnswer) { + input.classList.add('correct'); + correct++; + } else if (userAnswer !== '') { + input.classList.add('incorrect'); + } + }); + + // Zeige Ergebnis + let existingResult = clozeModalBody.querySelector('.cloze-result'); + if (existingResult) existingResult.remove(); + + const resultHtml = '
    ' + + '
    ' + correct + ' von ' + total + ' richtig
    ' + + '
    ' + Math.round(correct / total * 100) + '% korrekt
    ' + + '
    '; + + const resultDiv = document.createElement('div'); + resultDiv.innerHTML = resultHtml; + clozeModalBody.appendChild(resultDiv.firstChild); + } + + function showClozeAnswers() { + clozeModalBody.querySelectorAll('.cloze-gap-input').forEach(input => { + const correctAnswer = input.getAttribute('data-answer'); + input.value = correctAnswer; + input.classList.remove('incorrect'); + input.classList.add('correct'); + }); + } + + function openClozePrintDialog() { + if (!currentClozeData) { + alert(t('cloze_no_texts') || 'Keine Lückentexte vorhanden.'); + return; + } + + const currentFile = eingangFiles[currentIndex]; + + // Öffne Druck-Optionen + const choice = confirm((t('cloze_print_with_answers') || 'Mit Lösungen drucken?') + '\\n\\nOK = Mit ausgefüllten Lücken\\nAbbrechen = Übungsblatt mit Wortbank'); + + const url = '/api/print-cloze/' + encodeURIComponent(currentFile) + '?show_answers=' + choice; + window.open(url, '_blank'); + } + + // Event Listener für Cloze-Buttons + if (btnClozeGenerate) { + btnClozeGenerate.addEventListener('click', generateClozeTexts); + } + + if (btnClozeShow) { + btnClozeShow.addEventListener('click', openClozeModal); + } + + if (btnClozePrint) { + btnClozePrint.addEventListener('click', openClozePrintDialog); + } + + if (clozeModalClose) { + clozeModalClose.addEventListener('click', closeClozeModal); + } + + if (clozeModal) { + clozeModal.addEventListener('click', (ev) => { + if (ev.target === clozeModal) { + closeClozeModal(); + } + }); + } + + // --- Mindmap Lernposter Logik --- + const btnMindmapGenerate = document.getElementById('btn-mindmap-generate'); + const btnMindmapShow = document.getElementById('btn-mindmap-show'); + const btnMindmapPrint = document.getElementById('btn-mindmap-print'); + const mindmapPreview = document.getElementById('mindmap-preview'); + const mindmapBadge = document.getElementById('mindmap-badge'); + + let currentMindmapData = null; + + async function generateMindmap() { + const currentFile = eingangFiles[currentIndex]; + if (!currentFile) { + setStatus('error', t('select_file_first') || 'Bitte zuerst eine Datei auswählen'); + return; + } + + if (mindmapBadge) { + mindmapBadge.textContent = t('generating') || 'Generiere...'; + mindmapBadge.className = 'card-badge badge-working'; + } + setStatus('working', t('generating_mindmap') || 'Erstelle Mindmap...'); + + try { + const resp = await fetch('/api/generate-mindmap/' + encodeURIComponent(currentFile), { + method: 'POST' + }); + const data = await resp.json(); + + if (data.status === 'OK') { + if (mindmapBadge) { + mindmapBadge.textContent = t('ready') || 'Fertig'; + mindmapBadge.className = 'card-badge badge-success'; + } + setStatus('ok', t('mindmap_generated') || 'Mindmap erstellt!'); + + // Lade Mindmap-Daten + await loadMindmapData(); + } else if (data.status === 'NOT_FOUND') { + if (mindmapBadge) { + mindmapBadge.textContent = t('no_analysis') || 'Keine Analyse'; + mindmapBadge.className = 'card-badge badge-error'; + } + setStatus('error', t('analyze_first') || 'Bitte zuerst analysieren (Neuaufbau starten)'); + } else { + throw new Error(data.message || 'Fehler bei der Mindmap-Generierung'); + } + } catch (err) { + console.error('Mindmap error:', err); + if (mindmapBadge) { + mindmapBadge.textContent = t('error') || 'Fehler'; + mindmapBadge.className = 'card-badge badge-error'; + } + setStatus('error', err.message); + } + } + + async function loadMindmapData() { + if (!eingangFiles.length) { + if (mindmapPreview) mindmapPreview.innerHTML = ''; + return; + } + + const currentFile = eingangFiles[currentIndex]; + if (!currentFile) return; + + try { + const resp = await fetch('/api/mindmap-data/' + encodeURIComponent(currentFile)); + const data = await resp.json(); + + if (data.status === 'OK' && data.data) { + currentMindmapData = data.data; + renderMindmapPreview(); + if (btnMindmapShow) btnMindmapShow.style.display = 'inline-block'; + if (btnMindmapPrint) btnMindmapPrint.style.display = 'inline-block'; + if (mindmapBadge) { + mindmapBadge.textContent = t('ready') || 'Fertig'; + mindmapBadge.className = 'card-badge badge-success'; + } + } else { + currentMindmapData = null; + if (mindmapPreview) mindmapPreview.innerHTML = ''; + if (btnMindmapShow) btnMindmapShow.style.display = 'none'; + if (btnMindmapPrint) btnMindmapPrint.style.display = 'none'; + if (mindmapBadge) { + mindmapBadge.textContent = t('ready') || 'Bereit'; + mindmapBadge.className = 'card-badge'; + } + } + } catch (err) { + console.error('Error loading mindmap:', err); + } + } + + function renderMindmapPreview() { + if (!mindmapPreview) return; + + if (!currentMindmapData) { + mindmapPreview.innerHTML = ''; + return; + } + + const topic = currentMindmapData.topic || 'Thema'; + const categories = currentMindmapData.categories || []; + const categoryCount = categories.length; + const termCount = categories.reduce((sum, cat) => sum + (cat.terms ? cat.terms.length : 0), 0); + + mindmapPreview.innerHTML = '
    ' + + '
    ' + topic + '
    ' + + '
    ' + categoryCount + ' ' + (t('categories') || 'Kategorien') + ' | ' + termCount + ' ' + (t('terms') || 'Begriffe') + '
    ' + + '
    '; + } + + function openMindmapView() { + const currentFile = eingangFiles[currentIndex]; + if (!currentFile) return; + window.open('/api/mindmap-html/' + encodeURIComponent(currentFile) + '?format=a4', '_blank'); + } + + function openMindmapPrint() { + const currentFile = eingangFiles[currentIndex]; + if (!currentFile) return; + window.open('/api/mindmap-html/' + encodeURIComponent(currentFile) + '?format=a3', '_blank'); + } + + if (btnMindmapGenerate) { + btnMindmapGenerate.addEventListener('click', generateMindmap); + } + + if (btnMindmapShow) { + btnMindmapShow.addEventListener('click', openMindmapView); + } + + if (btnMindmapPrint) { + btnMindmapPrint.addEventListener('click', openMindmapPrint); + } + + // --- Frage-Antwort (Q&A) mit Leitner-System --- + const btnQaGenerate = document.getElementById('btn-qa-generate'); + const btnQaLearn = document.getElementById('btn-qa-learn'); + const btnQaPrint = document.getElementById('btn-qa-print'); + const qaPreview = document.getElementById('qa-preview'); + const qaBadge = document.getElementById('qa-badge'); + const qaModal = document.getElementById('qa-modal'); + const qaModalBody = document.getElementById('qa-modal-body'); + const qaModalClose = document.getElementById('qa-modal-close'); + + let currentQaData = null; + let currentQaIndex = 0; + let qaSessionStats = { correct: 0, incorrect: 0, total: 0 }; + + async function generateQaQuestions() { + try { + setStatus(t('status_generating_qa') || 'Generiere Q&A …', t('status_please_wait'), 'busy'); + if (qaBadge) qaBadge.textContent = t('mc_generating'); + + const resp = await fetch('/api/generate-qa', { method: 'POST' }); + if (!resp.ok) { + throw new Error('HTTP ' + resp.status); + } + + const result = await resp.json(); + if (result.status === 'OK' && result.generated.length > 0) { + setStatus(t('status_qa_generated') || 'Q&A generiert', result.generated.length + ' ' + t('status_files_created')); + if (qaBadge) qaBadge.textContent = t('mc_done'); + if (btnQaLearn) btnQaLearn.style.display = 'inline-block'; + if (btnQaPrint) btnQaPrint.style.display = 'inline-block'; + + await loadQaPreviewForCurrent(); + } else if (result.errors && result.errors.length > 0) { + setStatus(t('error'), result.errors[0].error, 'error'); + if (qaBadge) qaBadge.textContent = t('mc_error'); + } else { + setStatus(t('error'), 'Keine Q&A generiert.', 'error'); + if (qaBadge) qaBadge.textContent = t('mc_ready'); + } + } catch (e) { + console.error('Q&A-Generierung fehlgeschlagen:', e); + setStatus(t('error'), String(e), 'error'); + if (qaBadge) qaBadge.textContent = t('mc_error'); + } + } + + async function loadQaPreviewForCurrent() { + if (!eingangFiles.length) { + if (qaPreview) qaPreview.innerHTML = '
    ' + t('qa_no_questions') + '
    '; + return; + } + + const currentFile = eingangFiles[currentIndex]; + if (!currentFile) return; + + try { + const resp = await fetch('/api/qa-data/' + encodeURIComponent(currentFile)); + const result = await resp.json(); + + if (result.status === 'OK' && result.data) { + currentQaData = result.data; + renderQaPreview(result.data); + if (btnQaLearn) btnQaLearn.style.display = 'inline-block'; + if (btnQaPrint) btnQaPrint.style.display = 'inline-block'; + } else { + if (qaPreview) qaPreview.innerHTML = '
    ' + (t('qa_no_questions') || 'Noch keine Q&A für dieses Arbeitsblatt generiert.') + '
    '; + currentQaData = null; + if (btnQaLearn) btnQaLearn.style.display = 'none'; + if (btnQaPrint) btnQaPrint.style.display = 'none'; + } + } catch (e) { + console.error('Fehler beim Laden der Q&A-Daten:', e); + if (qaPreview) qaPreview.innerHTML = ''; + } + } + + function renderQaPreview(qaData) { + if (!qaPreview) return; + if (!qaData || !qaData.qa_items || qaData.qa_items.length === 0) { + qaPreview.innerHTML = '
    ' + t('qa_no_questions') + '
    '; + return; + } + + const items = qaData.qa_items; + const metadata = qaData.metadata || {}; + + let html = ''; + + // Leitner-Box Statistiken + html += '
    '; + + // Zähle Fragen nach Box + let box0 = 0, box1 = 0, box2 = 0; + items.forEach(item => { + const box = item.leitner ? item.leitner.box : 0; + if (box === 0) box0++; + else if (box === 1) box1++; + else box2++; + }); + + html += '
    '; + html += '
    ' + (t('qa_box_new') || 'Neu') + ': ' + box0 + '
    '; + html += '
    ' + (t('qa_box_learning') || 'Lernt') + ': ' + box1 + '
    '; + html += '
    ' + (t('qa_box_mastered') || 'Gefestigt') + ': ' + box2 + '
    '; + html += '
    '; + html += '
    '; + + // Zeige erste 2 Fragen als Vorschau + const previewItems = items.slice(0, 2); + previewItems.forEach((item, idx) => { + html += '
    '; + html += '
    ' + (idx + 1) + '. ' + item.question + '
    '; + html += '
    → ' + item.answer.substring(0, 60) + (item.answer.length > 60 ? '...' : '') + '
    '; + html += '
    '; + }); + + if (items.length > 2) { + html += '
    + ' + (items.length - 2) + ' ' + (t('questions') || 'weitere Fragen') + '
    '; + } + + qaPreview.innerHTML = html; + } + + function openQaModal() { + if (!currentQaData || !currentQaData.qa_items) { + alert(t('qa_no_questions') || 'Keine Q&A vorhanden. Bitte zuerst generieren.'); + return; + } + + currentQaIndex = 0; + qaSessionStats = { correct: 0, incorrect: 0, total: 0 }; + renderQaLearningCard(); + qaModal.classList.remove('hidden'); + } + + function closeQaModal() { + qaModal.classList.add('hidden'); + } + + function renderQaLearningCard() { + const items = currentQaData.qa_items; + + if (currentQaIndex >= items.length) { + // Alle Fragen durch - Zeige Zusammenfassung + renderQaSessionSummary(); + return; + } + + const item = items[currentQaIndex]; + const leitner = item.leitner || { box: 0 }; + const boxNames = [t('qa_box_new') || 'Neu', t('qa_box_learning') || 'Gelernt', t('qa_box_mastered') || 'Gefestigt']; + const boxColors = ['#ef4444', '#f59e0b', '#22c55e']; + + let html = ''; + + // Fortschrittsanzeige + html += '
    '; + html += '
    ' + (t('question') || 'Frage') + ' ' + (currentQaIndex + 1) + ' / ' + items.length + '
    '; + html += '
    '; + html += '' + boxNames[leitner.box] + ''; + html += '
    '; + html += '
    '; + + // Frage + html += '
    '; + html += '
    ' + (t('question') || 'Frage') + ':
    '; + html += '
    ' + item.question + '
    '; + html += '
    '; + + // Eingabefeld für eigene Antwort + html += '
    '; + html += '
    ' + (t('qa_your_answer') || 'Deine Antwort') + ':
    '; + html += ''; + html += '
    '; + + // Prüfen-Button + html += '
    '; + html += ''; + html += '
    '; + + // Vergleichs-Container (versteckt) + html += ''; // Ende qa-comparison-container + + // Session-Statistik + html += '
    '; + html += '
    ' + (t('qa_session_correct') || 'Richtig') + ': ' + qaSessionStats.correct + '
    '; + html += '
    ' + (t('qa_session_incorrect') || 'Falsch') + ': ' + qaSessionStats.incorrect + '
    '; + html += '
    '; + + qaModalBody.innerHTML = html; + + // Event Listener für Prüfen-Button + document.getElementById('btn-qa-check-answer').addEventListener('click', () => { + const userAnswer = document.getElementById('qa-user-answer').value.trim(); + + // Zeige die eigene Antwort im Vergleich + document.getElementById('qa-user-answer-text').textContent = userAnswer || (t('qa_no_answer') || '(keine Antwort eingegeben)'); + + // Verstecke Eingabe, zeige Vergleich + document.getElementById('qa-input-container').style.display = 'none'; + document.getElementById('qa-check-btn-container').style.display = 'none'; + document.getElementById('qa-comparison-container').style.display = 'block'; + }); + + // Enter-Taste im Textfeld löst Prüfen aus + document.getElementById('qa-user-answer').addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + document.getElementById('btn-qa-check-answer').click(); + } + }); + + // Fokus auf Textfeld setzen + setTimeout(() => { + document.getElementById('qa-user-answer').focus(); + }, 100); + + document.getElementById('btn-qa-correct').addEventListener('click', () => handleQaAnswer(true)); + document.getElementById('btn-qa-incorrect').addEventListener('click', () => handleQaAnswer(false)); + } + + async function handleQaAnswer(correct) { + const item = currentQaData.qa_items[currentQaIndex]; + + // Update Session-Statistik + qaSessionStats.total++; + if (correct) qaSessionStats.correct++; + else qaSessionStats.incorrect++; + + // Update Leitner-Fortschritt auf dem Server + try { + const currentFile = eingangFiles[currentIndex]; + await fetch('/api/qa-progress?filename=' + encodeURIComponent(currentFile) + '&item_id=' + encodeURIComponent(item.id) + '&correct=' + correct, { + method: 'POST' + }); + } catch (e) { + console.error('Fehler beim Speichern des Fortschritts:', e); + } + + // Nächste Frage + currentQaIndex++; + renderQaLearningCard(); + } + + function renderQaSessionSummary() { + const percent = qaSessionStats.total > 0 ? Math.round(qaSessionStats.correct / qaSessionStats.total * 100) : 0; + + let html = ''; + html += '
    '; + html += '
    ' + (percent >= 80 ? '🎉' : percent >= 50 ? '👍' : '💪') + '
    '; + html += '
    ' + (t('qa_session_complete') || 'Lernrunde abgeschlossen!') + '
    '; + html += '
    ' + qaSessionStats.correct + ' / ' + qaSessionStats.total + ' ' + (t('qa_result_correct') || 'richtig') + ' (' + percent + '%)
    '; + + // Statistik + html += '
    '; + html += '
    ' + qaSessionStats.correct + '
    ' + (t('qa_correct') || 'Richtig') + '
    '; + html += '
    ' + qaSessionStats.incorrect + '
    ' + (t('qa_incorrect') || 'Falsch') + '
    '; + html += '
    '; + + html += '
    '; + html += ''; + html += ''; + html += '
    '; + html += '
    '; + + qaModalBody.innerHTML = html; + + document.getElementById('btn-qa-restart').addEventListener('click', () => { + currentQaIndex = 0; + qaSessionStats = { correct: 0, incorrect: 0, total: 0 }; + renderQaLearningCard(); + }); + + document.getElementById('btn-qa-close-summary').addEventListener('click', closeQaModal); + + // Aktualisiere Preview nach Session + loadQaPreviewForCurrent(); + } + + function openQaPrintDialog() { + if (!currentQaData) { + alert(t('qa_no_questions') || 'Keine Q&A vorhanden.'); + return; + } + + const currentFile = eingangFiles[currentIndex]; + const baseName = currentFile.split('.')[0]; + + // Öffne Druck-Optionen + const choice = confirm((t('qa_print_with_answers') || 'Mit Lösungen drucken?') + '\\n\\nOK = Mit Lösungen\\nAbbrechen = Nur Fragen'); + + const url = '/api/print-qa/' + encodeURIComponent(currentFile) + '?show_answers=' + choice; + window.open(url, '_blank'); + } + + // Event Listener für Q&A-Buttons + if (btnQaGenerate) { + btnQaGenerate.addEventListener('click', generateQaQuestions); + } + + if (btnQaLearn) { + btnQaLearn.addEventListener('click', openQaModal); + } + + if (btnQaPrint) { + btnQaPrint.addEventListener('click', openQaPrintDialog); + } + + if (qaModalClose) { + qaModalClose.addEventListener('click', closeQaModal); + } + + if (qaModal) { + qaModal.addEventListener('click', (ev) => { + if (ev.target === qaModal) { + closeQaModal(); + } + }); + } + + // --- Sprachauswahl Event Listener --- + const languageSelect = document.getElementById('language-select'); + if (languageSelect) { + // Setze initiale Auswahl basierend auf gespeicherter Sprache + languageSelect.value = currentLang; + + languageSelect.addEventListener('change', (e) => { + applyLanguage(e.target.value); + }); + } + + // --- Initial --- + async function init() { + // Theme Toggle initialisieren + initThemeToggle(); + + // Sprache anwenden (aus localStorage oder default) + applyLanguage(currentLang); + + updateScreen(); + updateTiles(); + await loadEingangFiles(); + await loadWorksheetPairs(); + await loadLearningUnits(); + // Lade MC-Vorschau für aktuelle Datei + await loadMcPreviewForCurrent(); + // Lade Lückentext-Vorschau + await loadClozePreviewForCurrent(); + // Lade Q&A-Vorschau + await loadQaPreviewForCurrent(); + // Lade Mindmap-Vorschau + await loadMindmapData(); + } + + init(); + +// === SCRIPT BLOCK SEPARATOR === + +// GDPR Actions + async function saveCookiePreferences() { + const functional = document.getElementById('cookie-functional')?.checked || false; + const analytics = document.getElementById('cookie-analytics')?.checked || false; + + // Save to localStorage for now + localStorage.setItem('bp_cookies', JSON.stringify({functional, analytics})); + alert('Cookie-Einstellungen gespeichert!'); + } + + // ========================================== + // GDPR FUNCTIONS (Art. 15-21) + // ========================================== + + // Art. 15 - Auskunftsrecht + async function requestDataExport() { + alert('Ihre Auskunftsanfrage wurde erstellt. Sie erhalten eine E-Mail, sobald Ihre Daten bereit sind (max. 30 Tage).'); + } + + // Art. 16 - Recht auf Berichtigung + async function requestDataCorrection() { + const reason = prompt('Bitte beschreiben Sie, welche Daten korrigiert werden sollen:'); + if (reason) { + alert('Ihre Berichtigungsanfrage wurde erstellt. Wir werden sie innerhalb von 30 Tagen bearbeiten.'); + } + } + + // Art. 17 - Recht auf Löschung + async function requestDataDeletion() { + if (confirm('Sind Sie sicher, dass Sie alle Ihre Daten löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.')) { + alert('Ihre Löschanfrage wurde erstellt. Wir werden sie innerhalb von 30 Tagen bearbeiten.'); + } + } + + // Art. 18 - Einschränkung der Verarbeitung + async function requestProcessingRestriction() { + const reason = prompt('Bitte geben Sie den Grund für die Einschränkung an (z.B. Richtigkeit bestritten, unrechtmäßige Verarbeitung):'); + if (reason) { + alert('Ihre Anfrage auf Einschränkung der Verarbeitung wurde erstellt. Wir werden sie innerhalb von 30 Tagen bearbeiten.'); + } + } + + // Art. 20 - Datenübertragbarkeit + async function requestDataDownload() { + alert('Ihr Datenexport wurde gestartet. Sie erhalten eine E-Mail mit dem Download-Link (JSON-Format).'); + } + + // Art. 21 - Widerspruchsrecht + async function requestProcessingObjection() { + const reason = prompt('Bitte geben Sie den Grund für Ihren Widerspruch an:'); + if (reason) { + alert('Ihr Widerspruch wurde erstellt. Wir werden ihn innerhalb von 30 Tagen prüfen.'); + } + } + + // Consent Manager öffnen + function showConsentManager() { + openLegalModal('cookies'); + } + + // Settings Modal öffnen (leitet zum GDPR-Tab) + function openSettingsModal() { + openLegalModal('gdpr'); + } + + // Load saved cookie preferences + const savedCookies = localStorage.getItem('bp_cookies'); + if (savedCookies) { + const prefs = JSON.parse(savedCookies); + if (document.getElementById('cookie-functional')) { + document.getElementById('cookie-functional').checked = prefs.functional; + } + if (document.getElementById('cookie-analytics')) { + document.getElementById('cookie-analytics').checked = prefs.analytics; + } + } + + // ========================================== + // LEGAL MODAL (now after modal HTML exists) + // ========================================== + const legalModal = document.getElementById('legal-modal'); + const legalModalClose = document.getElementById('legal-modal-close'); + const legalTabs = document.querySelectorAll('.legal-tab'); + const legalContents = document.querySelectorAll('.legal-content'); + const btnLegal = document.getElementById('btn-legal'); + + // Imprint Modal + const imprintModal = document.getElementById('imprint-modal'); + const imprintModalClose = document.getElementById('imprint-modal-close'); + + // Open legal modal from footer + function openLegalModal(tab = 'terms') { + legalModal.classList.add('active'); + // Switch to specified tab + if (tab) { + legalTabs.forEach(t => t.classList.remove('active')); + legalContents.forEach(c => c.classList.remove('active')); + const targetTab = document.querySelector(`.legal-tab[data-tab="${tab}"]`); + if (targetTab) targetTab.classList.add('active'); + document.getElementById(`legal-${tab}`)?.classList.add('active'); + } + loadLegalDocuments(); + } + + // Open imprint modal from footer + function openImprintModal() { + imprintModal.classList.add('active'); + loadImprintContent(); + } + + // Open legal modal + btnLegal?.addEventListener('click', async () => { + openLegalModal(); + }); + + // Close legal modal + legalModalClose?.addEventListener('click', () => { + legalModal.classList.remove('active'); + }); + + // Close imprint modal + imprintModalClose?.addEventListener('click', () => { + imprintModal.classList.remove('active'); + }); + + // Close on background click + legalModal?.addEventListener('click', (e) => { + if (e.target === legalModal) { + legalModal.classList.remove('active'); + } + }); + + imprintModal?.addEventListener('click', (e) => { + if (e.target === imprintModal) { + imprintModal.classList.remove('active'); + } + }); + + // Tab switching + legalTabs.forEach(tab => { + tab.addEventListener('click', () => { + const tabId = tab.dataset.tab; + legalTabs.forEach(t => t.classList.remove('active')); + legalContents.forEach(c => c.classList.remove('active')); + tab.classList.add('active'); + document.getElementById(`legal-${tabId}`)?.classList.add('active'); + + // Load cookie categories when switching to cookies tab + if (tabId === 'cookies') { + loadCookieCategories(); + } + }); + }); + + // Load legal documents from consent service + async function loadLegalDocuments() { + const lang = document.getElementById('language-select')?.value || 'de'; + + // Load all documents in parallel + await Promise.all([ + loadDocumentContent('terms', 'legal-terms-content', getDefaultTerms, lang), + loadDocumentContent('privacy', 'legal-privacy-content', getDefaultPrivacy, lang), + loadDocumentContent('community_guidelines', 'legal-community-content', getDefaultCommunityGuidelines, lang) + ]); + } + + // Load imprint content + async function loadImprintContent() { + const lang = document.getElementById('language-select')?.value || 'de'; + await loadDocumentContent('imprint', 'imprint-content', getDefaultImprint, lang); + } + + // Generic function to load document content + async function loadDocumentContent(docType, containerId, defaultFn, lang) { + const container = document.getElementById(containerId); + if (!container) return; + + try { + const res = await fetch(`/api/consent/documents/${docType}/latest?language=${lang}`); + if (res.ok) { + const data = await res.json(); + if (data.content) { + container.innerHTML = data.content; + return; + } + } + } catch(e) { + console.log(`Could not load ${docType}:`, e); + } + + // Fallback to default + container.innerHTML = defaultFn(lang); + } + + // Load cookie categories for the cookie settings tab + async function loadCookieCategories() { + const container = document.getElementById('cookie-categories-container'); + if (!container) return; + + try { + const res = await fetch('/api/consent/cookies/categories'); + if (res.ok) { + const data = await res.json(); + const categories = data.categories || []; + + if (categories.length === 0) { + container.innerHTML = getDefaultCookieCategories(); + return; + } + + // Get current preferences from localStorage + const savedPrefs = JSON.parse(localStorage.getItem('bp_cookie_consent') || '{}'); + + container.innerHTML = categories.map(cat => ` + + `).join(''); + } else { + container.innerHTML = getDefaultCookieCategories(); + } + } catch(e) { + container.innerHTML = getDefaultCookieCategories(); + } + } + + function getDefaultCookieCategories() { + return ` + + + + `; + } + + function getDefaultTerms(lang) { + const terms = { + de: '

    Allgemeine Geschäftsbedingungen

    Die BreakPilot-Plattform wird von der BreakPilot UG bereitgestellt.

    Nutzung: Die Plattform dient zur Erstellung und Verwaltung von Lernmaterialien für Bildungszwecke.

    Haftung: Die Nutzung erfolgt auf eigene Verantwortung.

    Änderungen: Wir behalten uns vor, diese AGB jederzeit zu ändern.

    ', + en: '

    Terms of Service

    The BreakPilot platform is provided by BreakPilot UG.

    Usage: The platform is designed for creating and managing learning materials for educational purposes.

    Liability: Use at your own risk.

    Changes: We reserve the right to modify these terms at any time.

    ' + }; + return terms[lang] || terms.de; + } + + function getDefaultPrivacy(lang) { + const privacy = { + de: '

    Datenschutzerklärung

    Verantwortlicher: BreakPilot UG

    Erhobene Daten: Bei der Nutzung werden technische Daten (IP-Adresse, Browser-Typ) sowie von Ihnen eingegebene Inhalte verarbeitet.

    Zweck: Die Daten werden zur Bereitstellung der Plattform und zur Verbesserung unserer Dienste genutzt.

    Ihre Rechte (DSGVO):

    • Auskunftsrecht (Art. 15)
    • Recht auf Berichtigung (Art. 16)
    • Recht auf Löschung (Art. 17)
    • Recht auf Datenübertragbarkeit (Art. 20)

    Kontakt: datenschutz@breakpilot.app

    ', + en: '

    Privacy Policy

    Controller: BreakPilot UG

    Data Collected: Technical data (IP address, browser type) and content you provide are processed.

    Purpose: Data is used to provide the platform and improve our services.

    Your Rights (GDPR):

    • Right of access (Art. 15)
    • Right to rectification (Art. 16)
    • Right to erasure (Art. 17)
    • Right to data portability (Art. 20)

    Contact: privacy@breakpilot.app

    ' + }; + return privacy[lang] || privacy.de; + } + + function getDefaultCommunityGuidelines(lang) { + const guidelines = { + de: '

    Community Guidelines

    Willkommen bei BreakPilot! Um eine positive und respektvolle Umgebung zu gewährleisten, bitten wir alle Nutzer, diese Richtlinien zu befolgen.

    Respektvoller Umgang: Behandeln Sie andere Nutzer mit Respekt und Höflichkeit.

    Keine illegalen Inhalte: Das Erstellen oder Teilen von illegalen Inhalten ist streng untersagt.

    Urheberrecht: Respektieren Sie das geistige Eigentum anderer. Verwenden Sie nur Inhalte, für die Sie die Rechte besitzen.

    Datenschutz: Teilen Sie keine persönlichen Daten anderer ohne deren ausdrückliche Zustimmung.

    Qualität: Bemühen Sie sich um qualitativ hochwertige Lerninhalte.

    Verstöße gegen diese Richtlinien können zur Sperrung des Accounts führen.

    ', + en: '

    Community Guidelines

    Welcome to BreakPilot! To ensure a positive and respectful environment, we ask all users to follow these guidelines.

    Respectful Behavior: Treat other users with respect and courtesy.

    No Illegal Content: Creating or sharing illegal content is strictly prohibited.

    Copyright: Respect the intellectual property of others. Only use content you have rights to.

    Privacy: Do not share personal data of others without their explicit consent.

    Quality: Strive for high-quality learning content.

    Violations of these guidelines may result in account suspension.

    ' + }; + return guidelines[lang] || guidelines.de; + } + + function getDefaultImprint(lang) { + const imprint = { + de: '

    Impressum

    Angaben gemäß § 5 TMG:

    BreakPilot UG (haftungsbeschränkt)
    Musterstraße 1
    12345 Musterstadt
    Deutschland

    Vertreten durch:
    Geschäftsführer: Max Mustermann

    Kontakt:
    Telefon: +49 (0) 123 456789
    E-Mail: info@breakpilot.app

    Registereintrag:
    Eintragung im Handelsregister
    Registergericht: Amtsgericht Musterstadt
    Registernummer: HRB 12345

    Umsatzsteuer-ID:
    Umsatzsteuer-Identifikationsnummer gemäß § 27 a UStG: DE123456789

    Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV:
    Max Mustermann
    Musterstraße 1
    12345 Musterstadt

    ', + en: '

    Legal Notice

    Information according to § 5 TMG:

    BreakPilot UG (limited liability)
    Musterstraße 1
    12345 Musterstadt
    Germany

    Represented by:
    Managing Director: Max Mustermann

    Contact:
    Phone: +49 (0) 123 456789
    Email: info@breakpilot.app

    Register entry:
    Entry in the commercial register
    Register court: Amtsgericht Musterstadt
    Register number: HRB 12345

    VAT ID:
    VAT identification number according to § 27 a UStG: DE123456789

    Responsible for content according to § 55 Abs. 2 RStV:
    Max Mustermann
    Musterstraße 1
    12345 Musterstadt

    ' + }; + return imprint[lang] || imprint.de; + } + + // Save cookie preferences + function saveCookiePreferences() { + const prefs = {}; + const checkboxes = document.querySelectorAll('#cookie-categories-container input[type="checkbox"]'); + checkboxes.forEach(cb => { + const name = cb.id.replace('cookie-', ''); + if (name && !cb.disabled) { + prefs[name] = cb.checked; + } + }); + localStorage.setItem('bp_cookie_consent', JSON.stringify(prefs)); + localStorage.setItem('bp_cookie_consent_date', new Date().toISOString()); + + // TODO: Send to consent service if user is logged in + alert('Cookie-Einstellungen gespeichert!'); + } + + // ========================================== + // AUTH MODAL + // ========================================== + const authModal = document.getElementById('auth-modal'); + const authModalClose = document.getElementById('auth-modal-close'); + const authTabs = document.querySelectorAll('.auth-tab'); + const authContents = document.querySelectorAll('.auth-content'); + const btnLogin = document.getElementById('btn-login'); + + // Auth state + let currentUser = null; + let accessToken = localStorage.getItem('bp_access_token'); + let refreshToken = localStorage.getItem('bp_refresh_token'); + + // Update UI based on auth state + function updateAuthUI() { + const loginBtn = document.getElementById('btn-login'); + const userDropdown = document.querySelector('.auth-user-dropdown'); + const notificationBell = document.getElementById('notification-bell'); + + if (currentUser && accessToken) { + // User is logged in - hide login button + if (loginBtn) loginBtn.style.display = 'none'; + + // Show notification bell + if (notificationBell) { + notificationBell.classList.add('active'); + loadNotifications(); // Load notifications on login + startNotificationPolling(); // Start polling for new notifications + checkSuspensionStatus(); // Check if account is suspended + } + + // Show user dropdown if it exists + if (userDropdown) { + userDropdown.classList.add('active'); + const avatar = userDropdown.querySelector('.auth-user-avatar'); + const menuName = userDropdown.querySelector('.auth-user-menu-name'); + const menuEmail = userDropdown.querySelector('.auth-user-menu-email'); + + if (avatar) { + const initials = currentUser.name + ? currentUser.name.substring(0, 2).toUpperCase() + : currentUser.email.substring(0, 2).toUpperCase(); + avatar.textContent = initials; + } + if (menuName) menuName.textContent = currentUser.name || 'Benutzer'; + if (menuEmail) menuEmail.textContent = currentUser.email; + } + } else { + // User is logged out - show login button + if (loginBtn) loginBtn.style.display = 'block'; + if (userDropdown) userDropdown.classList.remove('active'); + if (notificationBell) notificationBell.classList.remove('active'); + stopNotificationPolling(); + } + } + + // Check if user is already logged in + async function checkAuthStatus() { + if (!accessToken) return; + + try { + const response = await fetch('/api/auth/profile', { + headers: { 'Authorization': `Bearer ${accessToken}` } + }); + + if (response.ok) { + currentUser = await response.json(); + updateAuthUI(); + } else if (response.status === 401 && refreshToken) { + // Try to refresh the token + await refreshAccessToken(); + } else { + // Clear invalid tokens + logout(false); + } + } catch (e) { + console.error('Auth check failed:', e); + } + } + + // Refresh access token + async function refreshAccessToken() { + if (!refreshToken) return false; + + try { + const response = await fetch('/api/auth/refresh', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }) + }); + + if (response.ok) { + const data = await response.json(); + accessToken = data.access_token; + refreshToken = data.refresh_token; + currentUser = data.user; + + localStorage.setItem('bp_access_token', accessToken); + localStorage.setItem('bp_refresh_token', refreshToken); + updateAuthUI(); + return true; + } else { + logout(false); + return false; + } + } catch (e) { + console.error('Token refresh failed:', e); + return false; + } + } + + // Logout + function logout(showMessage = true) { + if (refreshToken) { + fetch('/api/auth/logout', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refreshToken }) + }).catch(() => {}); + } + + currentUser = null; + accessToken = null; + refreshToken = null; + localStorage.removeItem('bp_access_token'); + localStorage.removeItem('bp_refresh_token'); + updateAuthUI(); + + if (showMessage) { + alert('Sie wurden erfolgreich abgemeldet.'); + } + } + + // Open auth modal + btnLogin?.addEventListener('click', () => { + authModal.classList.add('active'); + showAuthTab('login'); + clearAuthErrors(); + }); + + // Close auth modal + authModalClose?.addEventListener('click', () => { + authModal.classList.remove('active'); + clearAuthErrors(); + }); + + // Close on background click + authModal?.addEventListener('click', (e) => { + if (e.target === authModal) { + authModal.classList.remove('active'); + clearAuthErrors(); + } + }); + + // Tab switching + authTabs.forEach(tab => { + tab.addEventListener('click', () => { + const tabId = tab.dataset.tab; + showAuthTab(tabId); + }); + }); + + function showAuthTab(tabId) { + authTabs.forEach(t => t.classList.remove('active')); + authContents.forEach(c => c.classList.remove('active')); + + const activeTab = document.querySelector(`.auth-tab[data-tab="${tabId}"]`); + if (activeTab) activeTab.classList.add('active'); + + document.getElementById(`auth-${tabId}`)?.classList.add('active'); + clearAuthErrors(); + } + + function clearAuthErrors() { + document.querySelectorAll('.auth-error, .auth-success').forEach(el => { + el.classList.remove('active'); + el.textContent = ''; + }); + } + + function showAuthError(elementId, message) { + const el = document.getElementById(elementId); + if (el) { + el.textContent = message; + el.classList.add('active'); + } + } + + function showAuthSuccess(elementId, message) { + const el = document.getElementById(elementId); + if (el) { + el.textContent = message; + el.classList.add('active'); + } + } + + // Login form + document.getElementById('auth-login-form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + clearAuthErrors(); + + const email = document.getElementById('login-email').value; + const password = document.getElementById('login-password').value; + const btn = document.getElementById('login-btn'); + + btn.disabled = true; + btn.textContent = 'Anmelden...'; + + try { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }) + }); + + const data = await response.json(); + + if (response.ok) { + accessToken = data.access_token; + refreshToken = data.refresh_token; + currentUser = data.user; + + localStorage.setItem('bp_access_token', accessToken); + localStorage.setItem('bp_refresh_token', refreshToken); + + updateAuthUI(); + authModal.classList.remove('active'); + + // Clear form + document.getElementById('login-email').value = ''; + document.getElementById('login-password').value = ''; + } else { + showAuthError('auth-login-error', data.detail || data.error || 'Anmeldung fehlgeschlagen'); + } + } catch (e) { + showAuthError('auth-login-error', 'Verbindungsfehler. Bitte versuchen Sie es erneut.'); + } + + btn.disabled = false; + btn.textContent = 'Anmelden'; + }); + + // Register form + document.getElementById('auth-register-form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + clearAuthErrors(); + + const name = document.getElementById('register-name').value; + const email = document.getElementById('register-email').value; + const password = document.getElementById('register-password').value; + const passwordConfirm = document.getElementById('register-password-confirm').value; + + if (password !== passwordConfirm) { + showAuthError('auth-register-error', 'Passwörter stimmen nicht überein'); + return; + } + + if (password.length < 8) { + showAuthError('auth-register-error', 'Passwort muss mindestens 8 Zeichen lang sein'); + return; + } + + const btn = document.getElementById('register-btn'); + btn.disabled = true; + btn.textContent = 'Registrieren...'; + + try { + const response = await fetch('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password, name: name || undefined }) + }); + + const data = await response.json(); + + if (response.ok) { + showAuthSuccess('auth-register-success', + 'Registrierung erfolgreich! Bitte überprüfen Sie Ihre E-Mails zur Bestätigung.'); + + // Clear form + document.getElementById('register-name').value = ''; + document.getElementById('register-email').value = ''; + document.getElementById('register-password').value = ''; + document.getElementById('register-password-confirm').value = ''; + } else { + showAuthError('auth-register-error', data.detail || data.error || 'Registrierung fehlgeschlagen'); + } + } catch (e) { + showAuthError('auth-register-error', 'Verbindungsfehler. Bitte versuchen Sie es erneut.'); + } + + btn.disabled = false; + btn.textContent = 'Registrieren'; + }); + + // Forgot password form + document.getElementById('auth-forgot-form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + clearAuthErrors(); + + const email = document.getElementById('forgot-email').value; + const btn = document.getElementById('forgot-btn'); + + btn.disabled = true; + btn.textContent = 'Senden...'; + + try { + const response = await fetch('/api/auth/forgot-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }) + }); + + // Always show success to prevent email enumeration + showAuthSuccess('auth-forgot-success', + 'Falls ein Konto mit dieser E-Mail existiert, wurde ein Link zum Zurücksetzen gesendet.'); + + document.getElementById('forgot-email').value = ''; + } catch (e) { + showAuthError('auth-forgot-error', 'Verbindungsfehler. Bitte versuchen Sie es erneut.'); + } + + btn.disabled = false; + btn.textContent = 'Link senden'; + }); + + // Reset password form + document.getElementById('auth-reset-form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + clearAuthErrors(); + + const password = document.getElementById('reset-password').value; + const passwordConfirm = document.getElementById('reset-password-confirm').value; + const token = document.getElementById('reset-token').value; + + if (password !== passwordConfirm) { + showAuthError('auth-reset-error', 'Passwörter stimmen nicht überein'); + return; + } + + if (password.length < 8) { + showAuthError('auth-reset-error', 'Passwort muss mindestens 8 Zeichen lang sein'); + return; + } + + const btn = document.getElementById('reset-btn'); + btn.disabled = true; + btn.textContent = 'Ändern...'; + + try { + const response = await fetch('/api/auth/reset-password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token, new_password: password }) + }); + + const data = await response.json(); + + if (response.ok) { + showAuthSuccess('auth-reset-success', + 'Passwort erfolgreich geändert! Sie können sich jetzt anmelden.'); + + // Clear URL params + window.history.replaceState({}, document.title, window.location.pathname); + + // Switch to login after 2 seconds + setTimeout(() => showAuthTab('login'), 2000); + } else { + showAuthError('auth-reset-error', data.detail || data.error || 'Passwort zurücksetzen fehlgeschlagen'); + } + } catch (e) { + showAuthError('auth-reset-error', 'Verbindungsfehler. Bitte versuchen Sie es erneut.'); + } + + btn.disabled = false; + btn.textContent = 'Passwort ändern'; + }); + + // Navigation links + document.getElementById('auth-forgot-password')?.addEventListener('click', (e) => { + e.preventDefault(); + showAuthTab('forgot'); + // Hide tabs for forgot password + document.querySelector('.auth-tabs').style.display = 'none'; + }); + + document.getElementById('auth-back-to-login')?.addEventListener('click', (e) => { + e.preventDefault(); + showAuthTab('login'); + document.querySelector('.auth-tabs').style.display = 'flex'; + }); + + document.getElementById('auth-goto-login')?.addEventListener('click', (e) => { + e.preventDefault(); + showAuthTab('login'); + }); + + // Check for URL parameters (email verification, password reset) + function checkAuthUrlParams() { + const urlParams = new URLSearchParams(window.location.search); + const verifyToken = urlParams.get('verify'); + const resetToken = urlParams.get('reset'); + + if (verifyToken) { + authModal.classList.add('active'); + document.querySelector('.auth-tabs').style.display = 'none'; + showAuthTab('verify'); + verifyEmail(verifyToken); + } else if (resetToken) { + authModal.classList.add('active'); + document.querySelector('.auth-tabs').style.display = 'none'; + showAuthTab('reset'); + document.getElementById('reset-token').value = resetToken; + } + } + + async function verifyEmail(token) { + try { + const response = await fetch('/api/auth/verify-email', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }) + }); + + const data = await response.json(); + const loadingEl = document.getElementById('auth-verify-loading'); + + if (response.ok) { + if (loadingEl) loadingEl.style.display = 'none'; + showAuthSuccess('auth-verify-success', 'E-Mail erfolgreich verifiziert! Sie können sich jetzt anmelden.'); + + // Clear URL params + window.history.replaceState({}, document.title, window.location.pathname); + + // Switch to login after 2 seconds + setTimeout(() => { + showAuthTab('login'); + document.querySelector('.auth-tabs').style.display = 'flex'; + }, 2000); + } else { + if (loadingEl) loadingEl.style.display = 'none'; + showAuthError('auth-verify-error', data.detail || data.error || 'Verifizierung fehlgeschlagen. Der Link ist möglicherweise abgelaufen.'); + } + } catch (e) { + document.getElementById('auth-verify-loading').style.display = 'none'; + showAuthError('auth-verify-error', 'Verbindungsfehler. Bitte versuchen Sie es erneut.'); + } + } + + // User dropdown toggle + const authUserBtn = document.getElementById('auth-user-btn'); + const authUserMenu = document.getElementById('auth-user-menu'); + + authUserBtn?.addEventListener('click', (e) => { + e.stopPropagation(); + authUserMenu.classList.toggle('active'); + }); + + // Close dropdown when clicking outside + document.addEventListener('click', () => { + authUserMenu?.classList.remove('active'); + }); + + // Placeholder functions for profile/sessions + function showProfileModal() { + alert('Profil-Einstellungen kommen bald!'); + } + + function showSessionsModal() { + alert('Sitzungsverwaltung kommt bald!'); + } + + // ========================================== + // NOTIFICATION FUNCTIONS + // ========================================== + let notificationPollingInterval = null; + let notificationOffset = 0; + let notificationPrefs = { + email_enabled: true, + push_enabled: false, + in_app_enabled: true + }; + + // Toggle notification panel + document.getElementById('notification-bell-btn')?.addEventListener('click', (e) => { + e.stopPropagation(); + const panel = document.getElementById('notification-panel'); + panel.classList.toggle('active'); + + // Close user menu if open + const userMenu = document.getElementById('auth-user-menu'); + userMenu?.classList.remove('active'); + }); + + // Close notification panel when clicking outside + document.addEventListener('click', (e) => { + const bell = document.getElementById('notification-bell'); + const panel = document.getElementById('notification-panel'); + if (bell && panel && !bell.contains(e.target)) { + panel.classList.remove('active'); + } + }); + + // Load notifications from API + async function loadNotifications(append = false) { + if (!accessToken) return; + + try { + const limit = 10; + const offset = append ? notificationOffset : 0; + + const response = await fetch(`/api/v1/notifications?limit=${limit}&offset=${offset}`, { + headers: { 'Authorization': `Bearer ${accessToken}` } + }); + + if (!response.ok) return; + + const data = await response.json(); + + if (!append) { + notificationOffset = 0; + } + notificationOffset += data.notifications?.length || 0; + + renderNotifications(data.notifications || [], data.total || 0, append); + updateNotificationBadge(); + } catch (e) { + console.error('Failed to load notifications:', e); + } + } + + // Render notifications in the panel + function renderNotifications(notifications, total, append = false) { + const list = document.getElementById('notification-list'); + if (!list) return; + + if (!append) { + list.innerHTML = ''; + } + + if (notifications.length === 0 && !append) { + list.innerHTML = ` +
    +
    🔔
    +
    Keine Benachrichtigungen
    +
    + `; + return; + } + + notifications.forEach(n => { + const item = document.createElement('div'); + item.className = `notification-item ${!n.read_at ? 'unread' : ''}`; + item.onclick = () => markNotificationRead(n.id); + + const icon = getNotificationIcon(n.type); + const timeAgo = formatTimeAgo(new Date(n.created_at)); + + item.innerHTML = ` +
    ${icon}
    +
    +
    ${escapeHtml(n.title)}
    +
    ${escapeHtml(n.body)}
    +
    ${timeAgo}
    +
    + `; + list.appendChild(item); + }); + + // Show/hide load more button + const footer = document.querySelector('.notification-footer'); + if (footer) { + footer.style.display = notificationOffset < total ? 'block' : 'none'; + } + } + + // Get icon for notification type + function getNotificationIcon(type) { + const icons = { + 'consent_required': '📋', + 'consent_reminder': '⏰', + 'version_published': '📢', + 'version_approved': '✅', + 'version_rejected': '❌', + 'account_suspended': '🚫', + 'account_restored': '🔓', + 'general': '🔔' + }; + return icons[type] || '🔔'; + } + + // Format time ago + function formatTimeAgo(date) { + const now = new Date(); + const diff = Math.floor((now - date) / 1000); + + if (diff < 60) return 'Gerade eben'; + if (diff < 3600) return `vor ${Math.floor(diff / 60)} Min.`; + if (diff < 86400) return `vor ${Math.floor(diff / 3600)} Std.`; + if (diff < 604800) return `vor ${Math.floor(diff / 86400)} Tagen`; + return date.toLocaleDateString('de-DE'); + } + + // Escape HTML to prevent XSS + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // Update notification badge + async function updateNotificationBadge() { + if (!accessToken) return; + + try { + const response = await fetch('/api/v1/notifications/unread-count', { + headers: { 'Authorization': `Bearer ${accessToken}` } + }); + + if (!response.ok) return; + + const data = await response.json(); + const badge = document.getElementById('notification-badge'); + + if (badge) { + const count = data.unread_count || 0; + badge.textContent = count > 99 ? '99+' : count; + badge.classList.toggle('hidden', count === 0); + } + } catch (e) { + console.error('Failed to update badge:', e); + } + } + + // Mark notification as read + async function markNotificationRead(id) { + if (!accessToken) return; + + try { + await fetch(`/api/v1/notifications/${id}/read`, { + method: 'PUT', + headers: { 'Authorization': `Bearer ${accessToken}` } + }); + + // Update UI + const item = document.querySelector(`.notification-item[onclick*="${id}"]`); + if (item) item.classList.remove('unread'); + updateNotificationBadge(); + } catch (e) { + console.error('Failed to mark notification as read:', e); + } + } + + // Mark all notifications as read + async function markAllNotificationsRead() { + if (!accessToken) return; + + try { + await fetch('/api/v1/notifications/read-all', { + method: 'PUT', + headers: { 'Authorization': `Bearer ${accessToken}` } + }); + + // Update UI + document.querySelectorAll('.notification-item.unread').forEach(item => { + item.classList.remove('unread'); + }); + updateNotificationBadge(); + } catch (e) { + console.error('Failed to mark all as read:', e); + } + } + + // Load more notifications + function loadMoreNotifications() { + loadNotifications(true); + } + + // Start polling for new notifications + function startNotificationPolling() { + stopNotificationPolling(); + notificationPollingInterval = setInterval(() => { + updateNotificationBadge(); + }, 30000); // Poll every 30 seconds + } + + // Stop polling + function stopNotificationPolling() { + if (notificationPollingInterval) { + clearInterval(notificationPollingInterval); + notificationPollingInterval = null; + } + } + + // Show notification preferences modal + function showNotificationPreferences() { + document.getElementById('notification-panel')?.classList.remove('active'); + document.getElementById('notification-prefs-modal')?.classList.add('active'); + loadNotificationPreferences(); + } + + // Close notification preferences modal + function closeNotificationPreferences() { + document.getElementById('notification-prefs-modal')?.classList.remove('active'); + } + + // Load notification preferences + async function loadNotificationPreferences() { + if (!accessToken) return; + + try { + const response = await fetch('/api/v1/notifications/preferences', { + headers: { 'Authorization': `Bearer ${accessToken}` } + }); + + if (!response.ok) return; + + const prefs = await response.json(); + notificationPrefs = prefs; + + // Update UI toggles + updateToggle('pref-email-toggle', prefs.email_enabled); + updateToggle('pref-inapp-toggle', prefs.in_app_enabled); + updateToggle('pref-push-toggle', prefs.push_enabled); + } catch (e) { + console.error('Failed to load preferences:', e); + } + } + + // Update toggle UI + function updateToggle(id, active) { + const toggle = document.getElementById(id); + if (toggle) { + toggle.classList.toggle('active', active); + } + } + + // Toggle notification preference + function toggleNotificationPref(type) { + const toggleMap = { + 'email': 'pref-email-toggle', + 'inapp': 'pref-inapp-toggle', + 'push': 'pref-push-toggle' + }; + const prefMap = { + 'email': 'email_enabled', + 'inapp': 'in_app_enabled', + 'push': 'push_enabled' + }; + + const toggleId = toggleMap[type]; + const prefKey = prefMap[type]; + const toggle = document.getElementById(toggleId); + + if (toggle) { + const isActive = toggle.classList.toggle('active'); + notificationPrefs[prefKey] = isActive; + } + } + + // Save notification preferences + async function saveNotificationPreferences() { + if (!accessToken) return; + + try { + const response = await fetch('/api/v1/notifications/preferences', { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(notificationPrefs) + }); + + if (response.ok) { + closeNotificationPreferences(); + alert('Einstellungen gespeichert!'); + } else { + alert('Fehler beim Speichern der Einstellungen'); + } + } catch (e) { + console.error('Failed to save preferences:', e); + alert('Fehler beim Speichern der Einstellungen'); + } + } + + // ========================================== + // SUSPENSION CHECK FUNCTIONS + // ========================================== + let isSuspended = false; + + // Check suspension status after login + async function checkSuspensionStatus() { + if (!accessToken) return; + + try { + const response = await fetch('/api/v1/account/suspension-status', { + headers: { 'Authorization': `Bearer ${accessToken}` } + }); + + if (!response.ok) return; + + const data = await response.json(); + + if (data.suspended) { + isSuspended = true; + showSuspensionOverlay(data); + } else { + isSuspended = false; + hideSuspensionOverlay(); + } + } catch (e) { + console.error('Failed to check suspension status:', e); + } + } + + // Show suspension overlay + function showSuspensionOverlay(data) { + const overlay = document.getElementById('suspension-overlay'); + const docList = document.getElementById('suspension-doc-list'); + + if (!overlay || !docList) return; + + // Populate document list + if (data.pending_deadlines && data.pending_deadlines.length > 0) { + docList.innerHTML = data.pending_deadlines.map(d => { + const deadline = new Date(d.deadline_at); + const isOverdue = deadline < new Date(); + return ` +
    + ${escapeHtml(d.document_name)} + ${isOverdue ? 'Überfällig' : deadline.toLocaleDateString('de-DE')} +
    + `; + }).join(''); + } else if (data.details && data.details.documents) { + docList.innerHTML = data.details.documents.map(doc => ` +
    + ${escapeHtml(doc)} + Bestätigung erforderlich +
    + `).join(''); + } + + overlay.classList.add('active'); + } + + // Hide suspension overlay + function hideSuspensionOverlay() { + const overlay = document.getElementById('suspension-overlay'); + if (overlay) { + overlay.classList.remove('active'); + } + } + + // Show consent modal from suspension overlay + function showConsentModal() { + hideSuspensionOverlay(); + // Open legal modal to consent tab + document.getElementById('legal-modal')?.classList.add('active'); + // Switch to appropriate tab + } + + // Initialize auth on page load + checkAuthStatus(); + checkAuthUrlParams(); + + // ========================================== + // RICH TEXT EDITOR FUNCTIONS + // ========================================== + const versionEditor = document.getElementById('admin-version-editor'); + const versionContentHidden = document.getElementById('admin-version-content'); + const editorCharCount = document.getElementById('editor-char-count'); + + // Update hidden field and char count when editor content changes + versionEditor?.addEventListener('input', () => { + versionContentHidden.value = versionEditor.innerHTML; + const textLength = versionEditor.textContent.length; + editorCharCount.textContent = `${textLength} Zeichen`; + }); + + // Format document with execCommand + function formatDoc(cmd, value = null) { + versionEditor.focus(); + document.execCommand(cmd, false, value); + } + + // Format block element + function formatBlock(tag) { + versionEditor.focus(); + document.execCommand('formatBlock', false, `<${tag}>`); + } + + // Insert link + function insertLink() { + const url = prompt('Link-URL eingeben:', 'https://'); + if (url) { + versionEditor.focus(); + document.execCommand('createLink', false, url); + } + } + + // Handle Word document upload + async function handleWordUpload(event) { + const file = event.target.files[0]; + if (!file) return; + + // Show loading indicator + const editor = document.getElementById('admin-version-editor'); + const originalContent = editor.innerHTML; + editor.innerHTML = '

    Word-Dokument wird verarbeitet...

    '; + + const formData = new FormData(); + formData.append('file', file); + + try { + const response = await fetch('/api/consent/admin/versions/upload-word', { + method: 'POST', + body: formData + }); + + if (response.ok) { + const data = await response.json(); + editor.innerHTML = data.html || '

    Konvertierung fehlgeschlagen

    '; + versionContentHidden.value = editor.innerHTML; + + // Update char count + const textLength = editor.textContent.length; + editorCharCount.textContent = `${textLength} Zeichen`; + } else { + const error = await response.json(); + editor.innerHTML = originalContent; + alert('Fehler beim Importieren: ' + (error.detail || 'Unbekannter Fehler')); + } + } catch (e) { + editor.innerHTML = originalContent; + alert('Fehler beim Hochladen: ' + e.message); + } + + // Reset file input + event.target.value = ''; + } + + // Handle paste from Word - clean up the HTML + versionEditor?.addEventListener('paste', (e) => { + // Get pasted data via clipboard API + const clipboardData = e.clipboardData || window.clipboardData; + const pastedData = clipboardData.getData('text/html') || clipboardData.getData('text/plain'); + + if (pastedData && clipboardData.getData('text/html')) { + e.preventDefault(); + + // Clean the HTML + const cleanHtml = cleanWordHtml(pastedData); + document.execCommand('insertHTML', false, cleanHtml); + + // Update hidden field + versionContentHidden.value = versionEditor.innerHTML; + } + }); + + // Clean Word-specific HTML + function cleanWordHtml(html) { + // Create a temporary container + const temp = document.createElement('div'); + temp.innerHTML = html; + + // Remove Word-specific elements and attributes + const elementsToRemove = temp.querySelectorAll('style, script, meta, link, xml'); + elementsToRemove.forEach(el => el.remove()); + + // Get text content from Word spans with specific styling + let cleanedHtml = temp.innerHTML; + + // Remove mso-* styles and other Office-specific CSS + cleanedHtml = cleanedHtml.replace(/\s*mso-[^:]+:[^;]+;?/gi, ''); + cleanedHtml = cleanedHtml.replace(/\s*style="[^"]*"/gi, ''); + cleanedHtml = cleanedHtml.replace(/\s*class="[^"]*"/gi, ''); + cleanedHtml = cleanedHtml.replace(/<\/o:p>/gi, ''); + cleanedHtml = cleanedHtml.replace(/<\/?o:[^>]*>/gi, ''); + cleanedHtml = cleanedHtml.replace(/<\/?w:[^>]*>/gi, ''); + cleanedHtml = cleanedHtml.replace(/<\/?m:[^>]*>/gi, ''); + + // Clean up empty spans + cleanedHtml = cleanedHtml.replace(/]*>\s*<\/span>/gi, ''); + + // Convert Word list markers to proper lists + cleanedHtml = cleanedHtml.replace(/]*>\s*[•·]\s*/gi, '
  • '); + + return cleanedHtml; + } + + // ========================================== + // ADMIN PANEL + // ========================================== + const adminModal = document.getElementById('admin-modal'); + const adminModalClose = document.getElementById('admin-modal-close'); + const adminTabs = document.querySelectorAll('.admin-tab'); + const adminContents = document.querySelectorAll('.admin-content'); + const btnAdmin = document.getElementById('btn-admin'); + + // Admin data cache + let adminDocuments = []; + let adminCookieCategories = []; + + // Open admin modal + btnAdmin?.addEventListener('click', async () => { + adminModal.classList.add('active'); + await loadAdminDocuments(); + await loadAdminCookieCategories(); + populateDocumentSelect(); + }); + + // Close admin modal + adminModalClose?.addEventListener('click', () => { + adminModal.classList.remove('active'); + }); + + // Close on background click + adminModal?.addEventListener('click', (e) => { + if (e.target === adminModal) { + adminModal.classList.remove('active'); + } + }); + + // Admin tab switching + adminTabs.forEach(tab => { + tab.addEventListener('click', () => { + const tabId = tab.dataset.tab; + adminTabs.forEach(t => t.classList.remove('active')); + adminContents.forEach(c => c.classList.remove('active')); + tab.classList.add('active'); + document.getElementById(`admin-${tabId}`)?.classList.add('active'); + + // Load stats when stats tab is clicked + if (tabId === 'stats') { + loadAdminStats(); + } + }); + }); + + // ========================================== + // DOCUMENTS MANAGEMENT + // ========================================== + async function loadAdminDocuments() { + const container = document.getElementById('admin-doc-table-container'); + container.innerHTML = '
    Lade Dokumente...
    '; + + try { + const res = await fetch('/api/consent/admin/documents'); + if (!res.ok) throw new Error('Failed to load'); + const data = await res.json(); + adminDocuments = data.documents || []; + renderDocumentsTable(); + } catch(e) { + container.innerHTML = '
    Fehler beim Laden der Dokumente.
    '; + } + } + + function renderDocumentsTable() { + const container = document.getElementById('admin-doc-table-container'); + + // Alle Dokumente anzeigen + const allDocs = adminDocuments; + + if (allDocs.length === 0) { + container.innerHTML = '
    Keine Dokumente vorhanden. Klicken Sie auf "+ Neues Dokument" um ein Dokument zu erstellen.
    '; + return; + } + + const typeLabels = { + 'terms': 'AGB', + 'privacy': 'Datenschutz', + 'cookies': 'Cookies', + 'community': 'Community', + 'imprint': 'Impressum' + }; + + const html = ` + + + + + + + + + + + + ${allDocs.map(doc => ` + + + + + + + + `).join('')} + +
    TypNameBeschreibungStatusAktionen
    ${typeLabels[doc.type] || doc.type}${doc.name}${doc.description || '-'} + ${doc.is_active ? 'Aktiv' : 'Inaktiv'} + ${doc.is_mandatory ? 'Pflicht' : ''} + + + +
    + `; + container.innerHTML = html; + } + + function goToVersions(docId) { + // Wechsle zum Versionen-Tab und wähle das Dokument aus + const versionsTab = document.querySelector('.admin-tab[data-tab="versions"]'); + if (versionsTab) { + versionsTab.click(); + setTimeout(() => { + const select = document.getElementById('admin-version-doc-select'); + if (select) { + select.value = docId; + loadVersionsForDocument(); + } + }, 100); + } + } + + function showDocumentForm(doc = null) { + const form = document.getElementById('admin-document-form'); + const title = document.getElementById('admin-document-form-title'); + + if (doc) { + title.textContent = 'Dokument bearbeiten'; + document.getElementById('admin-document-id').value = doc.id; + document.getElementById('admin-document-type').value = doc.type; + document.getElementById('admin-document-name').value = doc.name; + document.getElementById('admin-document-description').value = doc.description || ''; + document.getElementById('admin-document-mandatory').checked = doc.is_mandatory; + } else { + title.textContent = 'Neues Dokument erstellen'; + document.getElementById('admin-document-id').value = ''; + document.getElementById('admin-document-type').value = ''; + document.getElementById('admin-document-name').value = ''; + document.getElementById('admin-document-description').value = ''; + document.getElementById('admin-document-mandatory').checked = true; + } + + form.style.display = 'block'; + } + + function hideDocumentForm() { + document.getElementById('admin-document-form').style.display = 'none'; + } + + function editDocument(docId) { + const doc = adminDocuments.find(d => d.id === docId); + if (doc) showDocumentForm(doc); + } + + async function saveDocument() { + const docId = document.getElementById('admin-document-id').value; + const docType = document.getElementById('admin-document-type').value; + const docName = document.getElementById('admin-document-name').value; + + if (!docType || !docName) { + alert('Bitte füllen Sie alle Pflichtfelder aus (Typ und Name).'); + return; + } + + const data = { + type: docType, + name: docName, + description: document.getElementById('admin-document-description').value || null, + is_mandatory: document.getElementById('admin-document-mandatory').checked + }; + + try { + const url = docId ? `/api/consent/admin/documents/${docId}` : '/api/consent/admin/documents'; + const method = docId ? 'PUT' : 'POST'; + + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + if (!res.ok) throw new Error('Failed to save'); + + hideDocumentForm(); + await loadAdminDocuments(); + populateDocumentSelect(); + alert('Dokument gespeichert!'); + } catch(e) { + alert('Fehler beim Speichern: ' + e.message); + } + } + + async function deleteDocument(docId) { + if (!confirm('Dokument wirklich deaktivieren?')) return; + + try { + const res = await fetch(`/api/consent/admin/documents/${docId}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Failed to delete'); + + await loadAdminDocuments(); + populateDocumentSelect(); + alert('Dokument deaktiviert!'); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + // ========================================== + // VERSIONS MANAGEMENT + // ========================================== + function populateDocumentSelect() { + const select = document.getElementById('admin-version-doc-select'); + const uniqueDocs = [...new Map(adminDocuments.map(d => [d.type, d])).values()]; + + select.innerHTML = '' + + adminDocuments.filter(d => d.is_active).map(doc => + `` + ).join(''); + } + + async function loadVersionsForDocument() { + const docId = document.getElementById('admin-version-doc-select').value; + const container = document.getElementById('admin-version-table-container'); + const btnNew = document.getElementById('btn-new-version'); + + if (!docId) { + container.innerHTML = '
    Wählen Sie ein Dokument aus.
    '; + btnNew.disabled = true; + return; + } + + btnNew.disabled = false; + container.innerHTML = '
    Lade Versionen...
    '; + + try { + const res = await fetch(`/api/consent/admin/documents/${docId}/versions`); + if (!res.ok) throw new Error('Failed to load'); + const data = await res.json(); + renderVersionsTable(data.versions || []); + } catch(e) { + container.innerHTML = '
    Fehler beim Laden der Versionen.
    '; + } + } + + function renderVersionsTable(versions) { + const container = document.getElementById('admin-version-table-container'); + if (versions.length === 0) { + container.innerHTML = '
    Keine Versionen vorhanden.
    '; + return; + } + + const getStatusBadge = (status) => { + const statusLabels = { + 'draft': 'Entwurf', + 'review': 'In Prüfung', + 'approved': 'Genehmigt', + 'rejected': 'Abgelehnt', + 'scheduled': 'Geplant', + 'published': 'Veröffentlicht', + 'archived': 'Archiviert' + }; + return statusLabels[status] || status; + }; + + const formatScheduledDate = (isoDate) => { + if (!isoDate) return ''; + const date = new Date(isoDate); + return date.toLocaleString('de-DE', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit' + }); + }; + + const html = ` + + + + + + + + + + + + ${versions.map(v => ` + + + + + + + + `).join('')} + +
    VersionSpracheTitelStatusAktionen
    ${v.version}${v.language.toUpperCase()}${v.title} + ${getStatusBadge(v.status)} + ${v.scheduled_publish_at ? `
    Geplant: ${formatScheduledDate(v.scheduled_publish_at)}` : ''} +
    + ${v.status === 'draft' ? ` + + + + ` : ''} + ${v.status === 'review' ? ` + + + + ` : ''} + ${v.status === 'rejected' ? ` + + + + ` : ''} + ${v.status === 'scheduled' ? ` + + Wartet auf Veröffentlichung + ` : ''} + ${v.status === 'approved' ? ` + + + + ` : ''} + ${v.status === 'published' ? ` + + ` : ''} + +
    + `; + container.innerHTML = html; + } + + function showVersionForm() { + const form = document.getElementById('admin-version-form'); + document.getElementById('admin-version-id').value = ''; + document.getElementById('admin-version-number').value = ''; + document.getElementById('admin-version-lang').value = 'de'; + document.getElementById('admin-version-title').value = ''; + document.getElementById('admin-version-summary').value = ''; + document.getElementById('admin-version-content').value = ''; + // Clear rich text editor + const editor = document.getElementById('admin-version-editor'); + if (editor) { + editor.innerHTML = ''; + document.getElementById('editor-char-count').textContent = '0 Zeichen'; + } + form.classList.add('active'); + } + + function hideVersionForm() { + document.getElementById('admin-version-form').classList.remove('active'); + } + + async function editVersion(versionId) { + // Lade die Version und fülle das Formular + const docId = document.getElementById('admin-version-doc-select').value; + + try { + const res = await fetch(`/api/consent/admin/documents/${docId}/versions`); + if (!res.ok) throw new Error('Failed to load versions'); + const data = await res.json(); + + const version = (data.versions || []).find(v => v.id === versionId); + if (!version) { + alert('Version nicht gefunden'); + return; + } + + // Formular öffnen und Daten einfügen + const form = document.getElementById('admin-version-form'); + document.getElementById('admin-version-id').value = version.id; + document.getElementById('admin-version-number').value = version.version; + document.getElementById('admin-version-lang').value = version.language; + document.getElementById('admin-version-title').value = version.title; + document.getElementById('admin-version-summary').value = version.summary || ''; + + // Rich-Text-Editor mit Inhalt füllen + const editor = document.getElementById('admin-version-editor'); + if (editor) { + editor.innerHTML = version.content || ''; + const charCount = editor.textContent.length; + document.getElementById('editor-char-count').textContent = charCount + ' Zeichen'; + } + document.getElementById('admin-version-content').value = version.content || ''; + + form.classList.add('active'); + } catch(e) { + alert('Fehler beim Laden der Version: ' + e.message); + } + } + + async function saveVersion() { + const docId = document.getElementById('admin-version-doc-select').value; + const versionId = document.getElementById('admin-version-id').value; + + // Get content from rich text editor + const editor = document.getElementById('admin-version-editor'); + const content = editor ? editor.innerHTML : document.getElementById('admin-version-content').value; + + const data = { + document_id: docId, + version: document.getElementById('admin-version-number').value, + language: document.getElementById('admin-version-lang').value, + title: document.getElementById('admin-version-title').value, + summary: document.getElementById('admin-version-summary').value, + content: content + }; + + try { + const url = versionId ? `/api/consent/admin/versions/${versionId}` : '/api/consent/admin/versions'; + const method = versionId ? 'PUT' : 'POST'; + + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + if (!res.ok) throw new Error('Failed to save'); + + hideVersionForm(); + await loadVersionsForDocument(); + alert('Version gespeichert!'); + } catch(e) { + alert('Fehler beim Speichern: ' + e.message); + } + } + + async function publishVersion(versionId) { + if (!confirm('Version wirklich veröffentlichen?')) return; + + try { + const res = await fetch(`/api/consent/admin/versions/${versionId}/publish`, { method: 'POST' }); + if (!res.ok) throw new Error('Failed to publish'); + + await loadVersionsForDocument(); + alert('Version veröffentlicht!'); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + async function archiveVersion(versionId) { + if (!confirm('Version wirklich archivieren?')) return; + + try { + const res = await fetch(`/api/consent/admin/versions/${versionId}/archive`, { method: 'POST' }); + if (!res.ok) throw new Error('Failed to archive'); + + await loadVersionsForDocument(); + alert('Version archiviert!'); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + async function deleteVersion(versionId) { + if (!confirm('Version wirklich dauerhaft löschen?\\n\\nDie Versionsnummer wird wieder frei und kann erneut verwendet werden.\\n\\nDiese Aktion kann nicht rückgängig gemacht werden!')) return; + + try { + const res = await fetch(`/api/consent/admin/versions/${versionId}`, { method: 'DELETE' }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail?.message || err.error || 'Löschen fehlgeschlagen'); + } + + await loadVersionsForDocument(); + alert('Version wurde dauerhaft gelöscht.'); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + // ========================================== + // DSB APPROVAL WORKFLOW + // ========================================== + + async function submitForReview(versionId) { + if (!confirm('Version zur DSB-Prüfung einreichen?')) return; + + try { + const res = await fetch(`/api/consent/admin/versions/${versionId}/submit-review`, { method: 'POST' }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.detail?.error || 'Einreichung fehlgeschlagen'); + } + + await loadVersionsForDocument(); + alert('Version wurde zur Prüfung eingereicht!'); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + // Dialog für Genehmigung mit Veröffentlichungszeitpunkt + let approvalVersionId = null; + + function showApprovalDialog(versionId) { + approvalVersionId = versionId; + const dialog = document.getElementById('approval-dialog'); + + // Setze Minimum-Datum auf morgen + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + document.getElementById('approval-date').min = tomorrow.toISOString().split('T')[0]; + document.getElementById('approval-date').value = ''; + document.getElementById('approval-time').value = '00:00'; + document.getElementById('approval-comment').value = ''; + + dialog.classList.add('active'); + } + + function hideApprovalDialog() { + document.getElementById('approval-dialog').classList.remove('active'); + approvalVersionId = null; + } + + async function submitApproval() { + if (!approvalVersionId) return; + + const dateInput = document.getElementById('approval-date').value; + const timeInput = document.getElementById('approval-time').value; + const comment = document.getElementById('approval-comment').value; + + let scheduledPublishAt = null; + if (dateInput) { + // Kombiniere Datum und Zeit zu ISO 8601 + const datetime = new Date(dateInput + 'T' + (timeInput || '00:00') + ':00'); + scheduledPublishAt = datetime.toISOString(); + } + + try { + const body = { comment: comment || '' }; + if (scheduledPublishAt) { + body.scheduled_publish_at = scheduledPublishAt; + } + + const res = await fetch(`/api/consent/admin/versions/${approvalVersionId}/approve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.detail?.error || data.detail || 'Genehmigung fehlgeschlagen'); + } + + hideApprovalDialog(); + await loadVersionsForDocument(); + + if (scheduledPublishAt) { + const date = new Date(scheduledPublishAt); + alert('Version genehmigt! Geplante Veröffentlichung: ' + date.toLocaleString('de-DE')); + } else { + alert('Version genehmigt! Sie kann jetzt manuell veröffentlicht werden.'); + } + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + // Alte Funktion für Rückwärtskompatibilität + async function approveVersion(versionId) { + showApprovalDialog(versionId); + } + + async function rejectVersion(versionId) { + const comment = prompt('Begründung für Ablehnung (erforderlich):'); + if (!comment) { + alert('Eine Begründung ist erforderlich.'); + return; + } + + try { + const res = await fetch(`/api/consent/admin/versions/${versionId}/reject`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ comment: comment }) + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.detail?.error || data.detail || 'Ablehnung fehlgeschlagen'); + } + + await loadVersionsForDocument(); + alert('Version abgelehnt und zurück in Entwurf-Status versetzt.'); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + // Store current compare version for actions + let currentCompareVersionId = null; + let currentCompareVersionStatus = null; + let currentCompareDocId = null; + + async function showCompareView(versionId) { + try { + const res = await fetch(`/api/consent/admin/versions/${versionId}/compare`); + if (!res.ok) throw new Error('Vergleich konnte nicht geladen werden'); + const data = await res.json(); + + const currentVersion = data.current_version; + const publishedVersion = data.published_version; + const history = data.approval_history || []; + + // Store version info for actions + currentCompareVersionId = versionId; + currentCompareVersionStatus = currentVersion.status; + currentCompareDocId = currentVersion.document_id; + + // Update header info + document.getElementById('compare-published-info').textContent = + publishedVersion ? `${publishedVersion.title} (v${publishedVersion.version})` : 'Keine Version'; + document.getElementById('compare-draft-info').textContent = + `${currentVersion.title} (v${currentVersion.version})`; + document.getElementById('compare-published-version').textContent = + publishedVersion ? `v${publishedVersion.version}` : ''; + document.getElementById('compare-draft-version').textContent = + `v${currentVersion.version} - ${currentVersion.status}`; + + // Populate content panels + const leftPanel = document.getElementById('compare-content-left'); + const rightPanel = document.getElementById('compare-content-right'); + + leftPanel.innerHTML = publishedVersion + ? publishedVersion.content + : '
    Keine veröffentlichte Version vorhanden
    '; + rightPanel.innerHTML = currentVersion.content || '
    Kein Inhalt
    '; + + // Populate history + const historyContainer = document.getElementById('compare-history-container'); + if (history.length > 0) { + historyContainer.innerHTML = ` +
    Genehmigungsverlauf
    +
    + ${history.map(h => ` + + ${h.action} von ${h.approver || 'System'} + (${new Date(h.created_at).toLocaleString('de-DE')}) + ${h.comment ? ': ' + h.comment : ''} + + `).join(' | ')} +
    + `; + } else { + historyContainer.innerHTML = ''; + } + + // Render action buttons based on status + renderCompareActions(currentVersion.status, versionId); + + // Setup synchronized scrolling + setupSyncScroll(leftPanel, rightPanel); + + // Show the overlay + document.getElementById('version-compare-view').classList.add('active'); + document.body.style.overflow = 'hidden'; + } catch(e) { + alert('Fehler beim Laden des Vergleichs: ' + e.message); + } + } + + function renderCompareActions(status, versionId) { + const actionsContainer = document.getElementById('compare-actions-container'); + + let buttons = ''; + + // Edit button - available for draft, review, and rejected + if (status === 'draft' || status === 'review' || status === 'rejected') { + buttons += ``; + } + + // Status-specific actions + if (status === 'draft') { + buttons += ``; + } + + if (status === 'review') { + buttons += ``; + buttons += ``; + } + + if (status === 'approved') { + buttons += ``; + } + + // Delete button for draft/rejected + if (status === 'draft' || status === 'rejected') { + buttons += ``; + } + + actionsContainer.innerHTML = buttons; + } + + async function editVersionFromCompare(versionId) { + // Store the doc ID before closing compare view + const docId = currentCompareDocId; + + // Close compare view + hideCompareView(); + + // Switch to versions tab + const versionsTab = document.querySelector('.admin-tab[data-tab="versions"]'); + if (versionsTab) { + versionsTab.click(); + } + + // Wait a moment for the tab to become active + await new Promise(resolve => setTimeout(resolve, 150)); + + // Ensure document select is populated + populateDocumentSelect(); + + // Set the document select if we have the doc ID + if (docId) { + const select = document.getElementById('admin-version-doc-select'); + if (select) { + select.value = docId; + // Load versions for this document + await loadVersionsForDocument(); + } + } + + // Now load the version data directly and open the form + try { + const res = await fetch(`/api/consent/admin/documents/${docId}/versions`); + if (!res.ok) throw new Error('Failed to load versions'); + const data = await res.json(); + + const version = (data.versions || []).find(v => v.id === versionId); + if (!version) { + alert('Version nicht gefunden'); + return; + } + + // Open the form and fill with version data + const form = document.getElementById('admin-version-form'); + document.getElementById('admin-version-id').value = version.id; + document.getElementById('admin-version-number').value = version.version; + document.getElementById('admin-version-lang').value = version.language; + document.getElementById('admin-version-title').value = version.title; + document.getElementById('admin-version-summary').value = version.summary || ''; + + // Fill rich text editor with content + const editor = document.getElementById('admin-version-editor'); + if (editor) { + editor.innerHTML = version.content || ''; + const charCount = editor.textContent.length; + document.getElementById('editor-char-count').textContent = charCount + ' Zeichen'; + } + document.getElementById('admin-version-content').value = version.content || ''; + + form.classList.add('active'); + } catch(e) { + alert('Fehler beim Laden der Version: ' + e.message); + } + } + + async function submitForReviewFromCompare(versionId) { + await submitForReview(versionId); + hideCompareView(); + await loadVersionsForDocument(); + } + + async function approveVersionFromCompare(versionId) { + const comment = prompt('Kommentar zur Genehmigung (optional):'); + try { + const res = await fetch(`/api/consent/admin/versions/${versionId}/approve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ comment: comment || '' }) + }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail?.error || err.error || 'Genehmigung fehlgeschlagen'); + } + alert('Version genehmigt!'); + hideCompareView(); + await loadVersionsForDocument(); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + async function rejectVersionFromCompare(versionId) { + const comment = prompt('Begründung für die Ablehnung (erforderlich):'); + if (!comment) { + alert('Eine Begründung ist erforderlich.'); + return; + } + try { + const res = await fetch(`/api/consent/admin/versions/${versionId}/reject`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ comment: comment }) + }); + if (!res.ok) throw new Error('Ablehnung fehlgeschlagen'); + alert('Version abgelehnt. Der Autor kann sie überarbeiten.'); + hideCompareView(); + await loadVersionsForDocument(); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + async function publishVersionFromCompare(versionId) { + if (!confirm('Version wirklich veröffentlichen?')) return; + try { + const res = await fetch(`/api/consent/admin/versions/${versionId}/publish`, { method: 'POST' }); + if (!res.ok) throw new Error('Veröffentlichung fehlgeschlagen'); + alert('Version veröffentlicht!'); + hideCompareView(); + await loadVersionsForDocument(); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + async function deleteVersionFromCompare(versionId) { + if (!confirm('Version wirklich dauerhaft löschen?\\n\\nDie Versionsnummer wird wieder frei.')) return; + try { + const res = await fetch(`/api/consent/admin/versions/${versionId}`, { method: 'DELETE' }); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail?.message || err.error || 'Löschen fehlgeschlagen'); + } + alert('Version gelöscht!'); + hideCompareView(); + await loadVersionsForDocument(); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + function hideCompareView() { + document.getElementById('version-compare-view').classList.remove('active'); + document.body.style.overflow = ''; + // Remove scroll listeners + const leftPanel = document.getElementById('compare-content-left'); + const rightPanel = document.getElementById('compare-content-right'); + if (leftPanel) leftPanel.onscroll = null; + if (rightPanel) rightPanel.onscroll = null; + } + + // Synchronized scrolling between two panels + let syncScrollActive = false; + function setupSyncScroll(leftPanel, rightPanel) { + // Remove any existing listeners first + leftPanel.onscroll = null; + rightPanel.onscroll = null; + + // Flag to prevent infinite scroll loops + let isScrolling = false; + + rightPanel.onscroll = function() { + if (isScrolling) return; + isScrolling = true; + + // Calculate scroll percentage + const rightScrollPercent = rightPanel.scrollTop / (rightPanel.scrollHeight - rightPanel.clientHeight); + + // Apply same percentage to left panel + const leftMaxScroll = leftPanel.scrollHeight - leftPanel.clientHeight; + leftPanel.scrollTop = rightScrollPercent * leftMaxScroll; + + setTimeout(() => { isScrolling = false; }, 10); + }; + + leftPanel.onscroll = function() { + if (isScrolling) return; + isScrolling = true; + + // Calculate scroll percentage + const leftScrollPercent = leftPanel.scrollTop / (leftPanel.scrollHeight - leftPanel.clientHeight); + + // Apply same percentage to right panel + const rightMaxScroll = rightPanel.scrollHeight - rightPanel.clientHeight; + rightPanel.scrollTop = leftScrollPercent * rightMaxScroll; + + setTimeout(() => { isScrolling = false; }, 10); + }; + } + + async function showApprovalHistory(versionId) { + try { + const res = await fetch(`/api/consent/admin/versions/${versionId}/approval-history`); + if (!res.ok) throw new Error('Historie konnte nicht geladen werden'); + const data = await res.json(); + const history = data.approval_history || []; + + const content = history.length === 0 + ? '

    Keine Genehmigungshistorie vorhanden.

    ' + : ` + + + + + + + + + + + ${history.map(h => ` + + + + + + + `).join('')} + +
    AktionBenutzerKommentarDatum
    ${h.action}${h.approver || h.name || '-'}${h.comment || '-'}${new Date(h.created_at).toLocaleString('de-DE')}
    + `; + + showCustomModal('Genehmigungsverlauf', content, [ + { text: 'Schließen', onClick: () => hideCustomModal() } + ]); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + // Custom Modal Functions + function showCustomModal(title, content, buttons = []) { + let modal = document.getElementById('custom-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'custom-modal'; + modal.className = 'modal-overlay'; + document.body.appendChild(modal); + } + + modal.innerHTML = ` + + `; + modal.classList.add('active'); + } + + function hideCustomModal() { + const modal = document.getElementById('custom-modal'); + if (modal) modal.classList.remove('active'); + } + + // ========================================== + // COOKIE CATEGORIES MANAGEMENT + // ========================================== + async function loadAdminCookieCategories() { + const container = document.getElementById('admin-cookie-table-container'); + container.innerHTML = '
    Lade Cookie-Kategorien...
    '; + + try { + const res = await fetch('/api/consent/admin/cookies/categories'); + if (!res.ok) throw new Error('Failed to load'); + const data = await res.json(); + adminCookieCategories = data.categories || []; + renderCookieCategoriesTable(); + } catch(e) { + container.innerHTML = '
    Fehler beim Laden der Kategorien.
    '; + } + } + + function renderCookieCategoriesTable() { + const container = document.getElementById('admin-cookie-table-container'); + if (adminCookieCategories.length === 0) { + container.innerHTML = '
    Keine Cookie-Kategorien vorhanden.
    '; + return; + } + + const html = ` + + + + + + + + + + + ${adminCookieCategories.map(cat => ` + + + + + + + `).join('')} + +
    NameAnzeigename (DE)TypAktionen
    ${cat.name}${cat.display_name_de} + ${cat.is_mandatory ? 'Notwendig' : 'Optional'} + + + ${!cat.is_mandatory ? `` : ''} +
    + `; + container.innerHTML = html; + } + + function showCookieForm(cat = null) { + const form = document.getElementById('admin-cookie-form'); + + if (cat) { + document.getElementById('admin-cookie-id').value = cat.id; + document.getElementById('admin-cookie-name').value = cat.name; + document.getElementById('admin-cookie-display-de').value = cat.display_name_de; + document.getElementById('admin-cookie-display-en').value = cat.display_name_en || ''; + document.getElementById('admin-cookie-desc-de').value = cat.description_de || ''; + document.getElementById('admin-cookie-mandatory').checked = cat.is_mandatory; + } else { + document.getElementById('admin-cookie-id').value = ''; + document.getElementById('admin-cookie-name').value = ''; + document.getElementById('admin-cookie-display-de').value = ''; + document.getElementById('admin-cookie-display-en').value = ''; + document.getElementById('admin-cookie-desc-de').value = ''; + document.getElementById('admin-cookie-mandatory').checked = false; + } + + form.classList.add('active'); + } + + function hideCookieForm() { + document.getElementById('admin-cookie-form').classList.remove('active'); + } + + function editCookieCategory(catId) { + const cat = adminCookieCategories.find(c => c.id === catId); + if (cat) showCookieForm(cat); + } + + async function saveCookieCategory() { + const catId = document.getElementById('admin-cookie-id').value; + const data = { + name: document.getElementById('admin-cookie-name').value, + display_name_de: document.getElementById('admin-cookie-display-de').value, + display_name_en: document.getElementById('admin-cookie-display-en').value, + description_de: document.getElementById('admin-cookie-desc-de').value, + is_mandatory: document.getElementById('admin-cookie-mandatory').checked + }; + + try { + const url = catId ? `/api/consent/admin/cookies/categories/${catId}` : '/api/consent/admin/cookies/categories'; + const method = catId ? 'PUT' : 'POST'; + + const res = await fetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + if (!res.ok) throw new Error('Failed to save'); + + hideCookieForm(); + await loadAdminCookieCategories(); + alert('Kategorie gespeichert!'); + } catch(e) { + alert('Fehler beim Speichern: ' + e.message); + } + } + + async function deleteCookieCategory(catId) { + if (!confirm('Kategorie wirklich löschen?')) return; + + try { + const res = await fetch(`/api/consent/admin/cookies/categories/${catId}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('Failed to delete'); + + await loadAdminCookieCategories(); + alert('Kategorie gelöscht!'); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + // ========================================== + // STATISTICS & GDPR EXPORT + // ========================================== + let dataCategories = []; + + async function loadAdminStats() { + const container = document.getElementById('admin-stats-container'); + container.innerHTML = '
    Lade Statistiken & DSGVO-Informationen...
    '; + + try { + // Lade Datenkategorien + const catRes = await fetch('/api/consent/privacy/data-categories'); + if (catRes.ok) { + const catData = await catRes.json(); + dataCategories = catData.categories || []; + } + + renderStatsPanel(); + } catch(e) { + container.innerHTML = '
    Fehler beim Laden: ' + e.message + '
    '; + } + } + + function renderStatsPanel() { + const container = document.getElementById('admin-stats-container'); + + // Kategorisiere Daten + const essential = dataCategories.filter(c => c.is_essential); + const optional = dataCategories.filter(c => !c.is_essential); + + const html = ` +
    + +
    +

    + 📋 DSGVO-Datenauskunft (Art. 15) +

    +

    + Exportieren Sie alle personenbezogenen Daten eines Nutzers als PDF-Dokument. + Dies erfüllt die Anforderungen der DSGVO Art. 15 (Auskunftsrecht). +

    + +
    + + + +
    + +
    +
    + + +
    +

    + 🗄️ Datenkategorien & Löschfristen +

    + +
    +

    + Essentielle Daten (Pflicht für Betrieb) +

    + + + + + + + + + + + ${essential.map(cat => ` + + + + + + + `).join('')} + +
    KategorieBeschreibungLöschfristRechtsgrundlage
    ${cat.name_de}${cat.description_de}${cat.retention_period}${cat.legal_basis}
    +
    + +
    +

    + Optionale Daten (nur bei Einwilligung) +

    + + + + + + + + + + + ${optional.map(cat => ` + + + + + + + `).join('')} + +
    KategorieBeschreibungCookie-KategorieLöschfrist
    ${cat.name_de}${cat.description_de}${cat.cookie_category || '-'}${cat.retention_period}
    +
    +
    + + +
    +
    +
    ${dataCategories.length}
    +
    Datenkategorien
    +
    +
    +
    ${essential.length}
    +
    Essentiell
    +
    +
    +
    ${optional.length}
    +
    Optional (Opt-in)
    +
    +
    +
    + `; + + container.innerHTML = html; + } + + async function exportUserDataPdf() { + const userIdInput = document.getElementById('gdpr-export-user-id'); + const statusDiv = document.getElementById('gdpr-export-status'); + const userId = userIdInput?.value?.trim(); + + statusDiv.innerHTML = 'Generiere PDF...'; + + try { + let url = '/api/consent/privacy/export-pdf'; + + // Wenn eine User-ID angegeben wurde, verwende den Admin-Endpoint + if (userId) { + url = `/api/consent/admin/privacy/export-pdf/${userId}`; + } + + const res = await fetch(url, { method: 'POST' }); + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.detail?.message || error.detail || 'Export fehlgeschlagen'); + } + + // PDF herunterladen + const blob = await res.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = downloadUrl; + a.download = userId ? `datenauskunft_${userId.slice(0,8)}.pdf` : 'breakpilot_datenauskunft.pdf'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(downloadUrl); + + statusDiv.innerHTML = '✓ PDF erfolgreich generiert!'; + } catch(e) { + statusDiv.innerHTML = `Fehler: ${e.message}`; + } + } + + async function previewUserDataHtml() { + const statusDiv = document.getElementById('gdpr-export-status'); + statusDiv.innerHTML = 'Lade Vorschau...'; + + try { + const res = await fetch('/api/consent/privacy/export-html'); + + if (!res.ok) { + throw new Error('Vorschau konnte nicht geladen werden'); + } + + const html = await res.text(); + + // In neuem Tab öffnen + const win = window.open('', '_blank'); + win.document.write(html); + win.document.close(); + + statusDiv.innerHTML = '✓ Vorschau in neuem Tab geöffnet'; + } catch(e) { + statusDiv.innerHTML = `Fehler: ${e.message}`; + } + } + + // ========================================== + // DSR (DATA SUBJECT REQUESTS) FUNCTIONS + // ========================================== + let dsrList = []; + let currentDSR = null; + + const DSR_TYPE_LABELS = { + 'access': 'Art. 15 - Auskunft', + 'rectification': 'Art. 16 - Berichtigung', + 'erasure': 'Art. 17 - Löschung', + 'restriction': 'Art. 18 - Einschränkung', + 'portability': 'Art. 20 - Datenübertragbarkeit' + }; + + const DSR_STATUS_LABELS = { + 'intake': 'Eingang', + 'identity_verification': 'Identitätsprüfung', + 'processing': 'In Bearbeitung', + 'completed': 'Abgeschlossen', + 'rejected': 'Abgelehnt', + 'cancelled': 'Storniert' + }; + + const DSR_STATUS_COLORS = { + 'intake': '#6366f1', + 'identity_verification': '#f59e0b', + 'processing': '#3b82f6', + 'completed': '#22c55e', + 'rejected': '#ef4444', + 'cancelled': '#6b7280' + }; + + async function loadDSRStats() { + const container = document.getElementById('dsr-stats-cards'); + try { + const res = await fetch('/api/v1/admin/dsr/stats'); + if (!res.ok) throw new Error('Failed to load stats'); + const stats = await res.json(); + + container.innerHTML = ` +
    +

    Überfällig

    +
    ${stats.overdue_requests || 0}
    +
    +
    +

    In Bearbeitung

    +
    ${stats.pending_requests || 0}
    +
    +
    +

    Diesen Monat abgeschlossen

    +
    ${stats.completed_this_month || 0}
    +
    +
    +

    Gesamt

    +
    ${stats.total_requests || 0}
    +
    +
    +

    Ø Bearbeitungszeit

    +
    ${(stats.average_processing_days || 0).toFixed(1)} Tage
    +
    + `; + } catch(e) { + container.innerHTML = `
    Fehler beim Laden der Statistiken: ${e.message}
    `; + } + } + + async function loadDSRList() { + const container = document.getElementById('dsr-table-container'); + const status = document.getElementById('dsr-filter-status').value; + const requestType = document.getElementById('dsr-filter-type').value; + const overdueOnly = document.getElementById('dsr-filter-overdue').checked; + + container.innerHTML = '
    Lade Betroffenenanfragen...
    '; + + try { + let url = '/api/v1/admin/dsr?limit=50'; + if (status) url += `&status=${status}`; + if (requestType) url += `&request_type=${requestType}`; + if (overdueOnly) url += `&overdue_only=true`; + + const res = await fetch(url); + if (!res.ok) throw new Error('Failed to load DSRs'); + const data = await res.json(); + dsrList = data.requests || []; + + if (dsrList.length === 0) { + container.innerHTML = ` +
    +

    📋

    +

    Keine Betroffenenanfragen gefunden.

    +
    + `; + return; + } + + container.innerHTML = ` + + + + + + + + + + + + + + + ${dsrList.map(dsr => { + const isOverdue = new Date(dsr.deadline_at) < new Date() && !['completed', 'rejected', 'cancelled'].includes(dsr.status); + const deadlineDate = new Date(dsr.deadline_at).toLocaleDateString('de-DE'); + return ` + + + + + + + + + + + `; + }).join('')} + +
    Nr.TypAntragstellerStatusPrioritätFristErstellt
    ${dsr.request_number}${DSR_TYPE_LABELS[dsr.request_type] || dsr.request_type} +
    ${dsr.requester_email}
    + ${dsr.requester_name ? `
    ${dsr.requester_name}
    ` : ''} +
    + + ${DSR_STATUS_LABELS[dsr.status] || dsr.status} + + + + ${dsr.priority === 'expedited' ? '🔴' : dsr.priority === 'high' ? '🟡' : ''} + ${dsr.priority === 'expedited' ? 'Beschleunigt' : dsr.priority === 'high' ? 'Hoch' : 'Normal'} + + ${deadlineDate}${isOverdue ? ' ⚠️' : ''}${new Date(dsr.created_at).toLocaleDateString('de-DE')} + +
    +
    + ${data.total || dsrList.length} Anfragen gefunden +
    + `; + } catch(e) { + container.innerHTML = `
    Fehler: ${e.message}
    `; + } + } + + function showDSRCreateForm() { + document.getElementById('dsr-create-form').style.display = 'block'; + document.getElementById('dsr-create-type').value = ''; + document.getElementById('dsr-create-priority').value = 'normal'; + document.getElementById('dsr-create-email').value = ''; + document.getElementById('dsr-create-name').value = ''; + document.getElementById('dsr-create-phone').value = ''; + } + + function hideDSRCreateForm() { + document.getElementById('dsr-create-form').style.display = 'none'; + } + + async function createDSR() { + const type = document.getElementById('dsr-create-type').value; + const priority = document.getElementById('dsr-create-priority').value; + const email = document.getElementById('dsr-create-email').value; + const name = document.getElementById('dsr-create-name').value; + const phone = document.getElementById('dsr-create-phone').value; + + if (!type || !email) { + alert('Bitte füllen Sie alle Pflichtfelder aus.'); + return; + } + + try { + const res = await fetch('/api/v1/admin/dsr', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + request_type: type, + priority: priority, + requester_email: email, + requester_name: name || undefined, + requester_phone: phone || undefined, + source: 'admin_panel' + }) + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail?.error || err.detail || 'Fehler beim Erstellen'); + } + + const data = await res.json(); + alert(`Anfrage ${data.request_number} wurde erstellt.`); + hideDSRCreateForm(); + loadDSRStats(); + loadDSRList(); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + async function showDSRDetail(dsrId) { + try { + const res = await fetch(`/api/v1/admin/dsr/${dsrId}`); + if (!res.ok) throw new Error('Failed to load DSR'); + currentDSR = await res.json(); + + // Load history + const historyRes = await fetch(`/api/v1/admin/dsr/${dsrId}/history`); + const historyData = historyRes.ok ? await historyRes.json() : { history: [] }; + + document.getElementById('dsr-table-container').style.display = 'none'; + document.getElementById('dsr-create-form').style.display = 'none'; + document.getElementById('dsr-detail-view').style.display = 'block'; + + const isOverdue = new Date(currentDSR.deadline_at) < new Date() && !['completed', 'rejected', 'cancelled'].includes(currentDSR.status); + + document.getElementById('dsr-detail-content').innerHTML = ` +
    +
    +
    +

    + ${currentDSR.request_number} + + ${DSR_STATUS_LABELS[currentDSR.status] || currentDSR.status} + +

    +
    +
    +
    Anfragetyp
    +
    ${DSR_TYPE_LABELS[currentDSR.request_type] || currentDSR.request_type}
    +
    +
    +
    Priorität
    +
    ${currentDSR.priority === 'expedited' ? '🔴 Beschleunigt' : currentDSR.priority === 'high' ? '🟡 Hoch' : 'Normal'}
    +
    +
    +
    Frist
    +
    ${new Date(currentDSR.deadline_at).toLocaleDateString('de-DE')} ${isOverdue ? '⚠️ ÜBERFÄLLIG' : ''}
    +
    +
    +
    Gesetzliche Frist
    +
    ${currentDSR.legal_deadline_days} Tage
    +
    +
    +
    Identität verifiziert
    +
    ${currentDSR.identity_verified ? '✅ Ja' : '❌ Nein'}
    +
    +
    +
    Quelle
    +
    ${currentDSR.source === 'api' ? 'API' : currentDSR.source === 'admin_panel' ? 'Admin Panel' : currentDSR.source}
    +
    +
    +
    + +
    +

    Antragsteller

    +
    +
    +
    E-Mail
    +
    ${currentDSR.requester_email}
    +
    +
    +
    Name
    +
    ${currentDSR.requester_name || '-'}
    +
    +
    +
    Telefon
    +
    ${currentDSR.requester_phone || '-'}
    +
    +
    +
    + + ${currentDSR.processing_notes ? ` +
    +

    Bearbeitungsnotizen

    +
    ${currentDSR.processing_notes}
    +
    + ` : ''} + + ${currentDSR.result_summary ? ` +
    +

    Ergebnis

    +
    ${currentDSR.result_summary}
    +
    + ` : ''} + + ${currentDSR.rejection_reason ? ` +
    +

    Ablehnung

    +
    Rechtsgrundlage: ${currentDSR.rejection_legal_basis}
    +
    ${currentDSR.rejection_reason}
    +
    + ` : ''} +
    + +
    +
    +

    Verlauf

    +
    + ${(historyData.history || []).map(h => ` +
    +
    ${new Date(h.created_at).toLocaleString('de-DE')}
    +
    + ${h.from_status ? `${DSR_STATUS_LABELS[h.from_status] || h.from_status} → ` : ''} + ${DSR_STATUS_LABELS[h.to_status] || h.to_status} +
    + ${h.comment ? `
    ${h.comment}
    ` : ''} +
    + `).join('') || '
    Kein Verlauf vorhanden
    '} +
    +
    +
    +
    + `; + + // Update button visibility based on status + const canVerify = !currentDSR.identity_verified && ['intake', 'identity_verification'].includes(currentDSR.status); + const canComplete = ['processing'].includes(currentDSR.status); + const canReject = ['intake', 'identity_verification', 'processing'].includes(currentDSR.status); + const canExtend = !['completed', 'rejected', 'cancelled'].includes(currentDSR.status); + + document.getElementById('dsr-btn-verify').style.display = canVerify ? 'inline-flex' : 'none'; + document.getElementById('dsr-btn-complete').style.display = canComplete ? 'inline-flex' : 'none'; + document.getElementById('dsr-btn-reject').style.display = canReject ? 'inline-flex' : 'none'; + document.getElementById('dsr-btn-extend').style.display = canExtend ? 'inline-flex' : 'none'; + + } catch(e) { + alert('Fehler beim Laden: ' + e.message); + } + } + + function hideDSRDetail() { + document.getElementById('dsr-detail-view').style.display = 'none'; + document.getElementById('dsr-table-container').style.display = 'block'; + currentDSR = null; + } + + async function verifyDSRIdentity() { + if (!currentDSR) return; + const method = prompt('Verifizierungsmethode (z.B. id_card, passport, video_call, email):', 'email'); + if (!method) return; + + try { + const res = await fetch(`/api/v1/admin/dsr/${currentDSR.id}/verify-identity`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ method: method }) + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail?.error || 'Fehler'); + } + + alert('Identität wurde verifiziert.'); + showDSRDetail(currentDSR.id); + loadDSRStats(); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + async function showDSRExtendDialog() { + if (!currentDSR) return; + const reason = prompt('Begründung für die Fristverlängerung:'); + if (!reason) return; + + try { + const res = await fetch(`/api/v1/admin/dsr/${currentDSR.id}/extend`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason: reason, days: 60 }) + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail?.error || 'Fehler'); + } + + alert('Frist wurde um 60 Tage verlängert.'); + showDSRDetail(currentDSR.id); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + async function showDSRCompleteDialog() { + if (!currentDSR) return; + const summary = prompt('Zusammenfassung des Ergebnisses:'); + if (!summary) return; + + try { + const res = await fetch(`/api/v1/admin/dsr/${currentDSR.id}/complete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ summary: summary }) + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail?.error || 'Fehler'); + } + + alert('Anfrage wurde abgeschlossen.'); + showDSRDetail(currentDSR.id); + loadDSRStats(); + loadDSRList(); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + async function showDSRRejectDialog() { + if (!currentDSR) return; + const legalBasis = prompt('Rechtsgrundlage für die Ablehnung (z.B. Art. 17(3)a, Art. 12(5)):'); + if (!legalBasis) return; + const reason = prompt('Begründung der Ablehnung:'); + if (!reason) return; + + try { + const res = await fetch(`/api/v1/admin/dsr/${currentDSR.id}/reject`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason: reason, legal_basis: legalBasis }) + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail?.error || 'Fehler'); + } + + alert('Anfrage wurde abgelehnt.'); + showDSRDetail(currentDSR.id); + loadDSRStats(); + loadDSRList(); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + function showDSRAssignDialog() { + // TODO: Implement user selection dialog + alert('Zuweisung noch nicht implementiert. Verwenden Sie die API direkt.'); + } + + function loadDSRData() { + loadDSRStats(); + loadDSRList(); + } + + // Load DSR data when tab is clicked + document.querySelector('.admin-tab[data-tab="dsr"]')?.addEventListener('click', loadDSRData); + + // ========================================== + // DSMS FUNCTIONS + // ========================================== + const DSMS_GATEWAY_URL = 'http://localhost:8082'; + let dsmsArchives = []; + + function switchDsmsTab(tabName) { + document.querySelectorAll('.dsms-subtab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.dsms-content').forEach(c => c.classList.remove('active')); + + document.querySelector(`.dsms-subtab[data-dsms-tab="${tabName}"]`)?.classList.add('active'); + document.getElementById(`dsms-${tabName}`)?.classList.add('active'); + + // Load data for specific tabs + if (tabName === 'settings') { + loadDsmsNodeInfo(); + } + } + + async function loadDsmsData() { + await Promise.all([ + loadDsmsStatus(), + loadDsmsArchives(), + loadDsmsDocumentSelect() + ]); + } + + async function loadDsmsStatus() { + const container = document.getElementById('dsms-status-cards'); + container.innerHTML = '
    Lade DSMS Status...
    '; + + try { + const [healthRes, nodeRes] = await Promise.all([ + fetch(`${DSMS_GATEWAY_URL}/health`).catch(() => null), + fetch(`${DSMS_GATEWAY_URL}/api/v1/node/info`).catch(() => null) + ]); + + const health = healthRes?.ok ? await healthRes.json() : null; + const nodeInfo = nodeRes?.ok ? await nodeRes.json() : null; + + const isOnline = health?.ipfs_connected === true; + const repoSize = nodeInfo?.repo_size ? formatBytes(nodeInfo.repo_size) : '-'; + const storageMax = nodeInfo?.storage_max ? formatBytes(nodeInfo.storage_max) : '-'; + const numObjects = nodeInfo?.num_objects ?? '-'; + + container.innerHTML = ` +
    +

    Status

    +
    ${isOnline ? 'Online' : 'Offline'}
    +
    +
    +

    Speicher verwendet

    +
    ${repoSize}
    +
    +
    +

    Max. Speicher

    +
    ${storageMax}
    +
    +
    +

    Objekte

    +
    ${numObjects}
    +
    + `; + } catch(e) { + container.innerHTML = ` +
    +

    Status

    +
    Nicht erreichbar
    +

    + DSMS Gateway ist nicht verfügbar. Stellen Sie sicher, dass die Container laufen. +

    +
    + `; + } + } + + async function loadDsmsArchives() { + const container = document.getElementById('dsms-archives-table'); + container.innerHTML = '
    Lade archivierte Dokumente...
    '; + + try { + const token = localStorage.getItem('bp_token') || ''; + const res = await fetch(`${DSMS_GATEWAY_URL}/api/v1/documents`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (!res.ok) { + throw new Error('Fehler beim Laden'); + } + + const data = await res.json(); + dsmsArchives = data.documents || []; + + if (dsmsArchives.length === 0) { + container.innerHTML = ` +
    +

    Keine archivierten Dokumente vorhanden.

    +

    + Klicken Sie auf "+ Dokument archivieren" um ein Legal Document im DSMS zu speichern. +

    +
    + `; + return; + } + + container.innerHTML = ` + + + + + + + + + + + + ${dsmsArchives.map(doc => ` + + + + + + + + `).join('')} + +
    CIDDokumentVersionArchiviert amAktionen
    + + ${doc.cid.substring(0, 12)}... + + ${doc.metadata?.document_id || doc.filename || '-'}${doc.metadata?.version || '-'}${doc.metadata?.created_at ? new Date(doc.metadata.created_at).toLocaleString('de-DE') : '-'} + + + + ↗ + +
    + `; + } catch(e) { + container.innerHTML = `
    Fehler: ${e.message}
    `; + } + } + + async function loadDsmsDocumentSelect() { + const select = document.getElementById('dsms-archive-doc-select'); + if (!select) return; + + try { + const res = await fetch('/api/consent/admin/documents'); + if (!res.ok) return; + + const data = await res.json(); + const docs = data.documents || []; + + select.innerHTML = '' + + docs.map(d => ``).join(''); + } catch(e) { + console.error('Error loading documents:', e); + } + } + + async function loadDsmsVersionSelect() { + const docSelect = document.getElementById('dsms-archive-doc-select'); + const versionSelect = document.getElementById('dsms-archive-version-select'); + const docId = docSelect?.value; + + if (!docId) { + versionSelect.innerHTML = ''; + versionSelect.disabled = true; + return; + } + + versionSelect.disabled = false; + versionSelect.innerHTML = ''; + + try { + const res = await fetch(`/api/consent/admin/documents/${docId}/versions`); + if (!res.ok) throw new Error('Fehler'); + + const data = await res.json(); + const versions = data.versions || []; + + if (versions.length === 0) { + versionSelect.innerHTML = ''; + return; + } + + versionSelect.innerHTML = '' + + versions.map(v => ``).join(''); + } catch(e) { + versionSelect.innerHTML = ''; + } + } + + // Attach event listener for doc select change + document.getElementById('dsms-archive-doc-select')?.addEventListener('change', loadDsmsVersionSelect); + + function showArchiveForm() { + document.getElementById('dsms-archive-form').style.display = 'block'; + loadDsmsDocumentSelect(); + } + + function hideArchiveForm() { + document.getElementById('dsms-archive-form').style.display = 'none'; + } + + async function archiveDocumentToDsms() { + const docSelect = document.getElementById('dsms-archive-doc-select'); + const versionSelect = document.getElementById('dsms-archive-version-select'); + const selectedOption = versionSelect.options[versionSelect.selectedIndex]; + + if (!docSelect.value || !versionSelect.value) { + alert('Bitte Dokument und Version auswählen'); + return; + } + + const content = decodeURIComponent(selectedOption.dataset.content || ''); + const version = selectedOption.dataset.version; + const docId = docSelect.value; + + if (!content) { + alert('Die ausgewählte Version hat keinen Inhalt'); + return; + } + + try { + const token = localStorage.getItem('bp_token') || ''; + const params = new URLSearchParams({ + document_id: docId, + version: version, + content: content, + language: 'de' + }); + + const res = await fetch(`${DSMS_GATEWAY_URL}/api/v1/legal-documents/archive?${params}`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (!res.ok) { + const err = await res.json(); + throw new Error(err.detail || 'Archivierung fehlgeschlagen'); + } + + const result = await res.json(); + alert(`Dokument erfolgreich archiviert!\\n\\nCID: ${result.cid}\\nChecksum: ${result.checksum}`); + hideArchiveForm(); + loadDsmsArchives(); + } catch(e) { + alert('Fehler: ' + e.message); + } + } + + async function verifyDsmsDocument() { + const cidInput = document.getElementById('dsms-verify-cid'); + const resultDiv = document.getElementById('dsms-verify-result'); + const cid = cidInput?.value?.trim(); + + if (!cid) { + alert('Bitte CID eingeben'); + return; + } + + await verifyDsmsDocumentByCid(cid); + } + + async function verifyDsmsDocumentByCid(cid) { + const resultDiv = document.getElementById('dsms-verify-result'); + resultDiv.style.display = 'block'; + resultDiv.innerHTML = '
    Verifiziere...
    '; + + // Switch to verify tab + switchDsmsTab('verify'); + document.getElementById('dsms-verify-cid').value = cid; + + try { + const res = await fetch(`${DSMS_GATEWAY_URL}/api/v1/verify/${cid}`); + const data = await res.json(); + + if (data.exists && data.integrity_valid) { + resultDiv.innerHTML = ` +
    +

    ✓ Dokument verifiziert

    +
    +
    CID: ${cid}
    +
    Integrität: Gültig
    +
    Typ: ${data.metadata?.document_type || '-'}
    +
    Dokument-ID: ${data.metadata?.document_id || '-'}
    +
    Version: ${data.metadata?.version || '-'}
    +
    Erstellt: ${data.metadata?.created_at ? new Date(data.metadata.created_at).toLocaleString('de-DE') : '-'}
    +
    Checksum: ${data.stored_checksum || '-'}
    +
    +
    + `; + } else if (data.exists && !data.integrity_valid) { + resultDiv.innerHTML = ` +
    +

    ⚠ Integritätsfehler

    +

    Das Dokument existiert, aber die Prüfsumme stimmt nicht überein.

    +

    Gespeichert: ${data.stored_checksum}

    +

    Berechnet: ${data.calculated_checksum}

    +
    + `; + } else { + resultDiv.innerHTML = ` +
    +

    ✗ Nicht gefunden

    +

    Kein Dokument mit diesem CID gefunden.

    + ${data.error ? `

    ${data.error}

    ` : ''} +
    + `; + } + } catch(e) { + resultDiv.innerHTML = ` +
    +

    Fehler

    +

    ${e.message}

    +
    + `; + } + } + + async function loadDsmsNodeInfo() { + const container = document.getElementById('dsms-node-info'); + container.innerHTML = '
    Lade Node-Info...
    '; + + try { + const res = await fetch(`${DSMS_GATEWAY_URL}/api/v1/node/info`); + if (!res.ok) throw new Error('Nicht erreichbar'); + + const info = await res.json(); + + container.innerHTML = ` +
    +
    Node ID: ${info.node_id || '-'}
    +
    Agent: ${info.agent_version || '-'}
    +
    Repo-Größe: ${info.repo_size ? formatBytes(info.repo_size) : '-'}
    +
    Max. Speicher: ${info.storage_max ? formatBytes(info.storage_max) : '-'}
    +
    Objekte: ${info.num_objects ?? '-'}
    +
    + Adressen: +
      + ${(info.addresses || []).map(a => `
    • ${a}
    • `).join('')} +
    +
    +
    + `; + } catch(e) { + container.innerHTML = `
    DSMS nicht erreichbar: ${e.message}
    `; + } + } + + function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + } + + function copyToClipboard(text) { + navigator.clipboard.writeText(text).then(() => { + // Optional: Show toast + }).catch(err => { + console.error('Copy failed:', err); + }); + } + + // ========================================== + // DSMS WEBUI FUNCTIONS + // ========================================== + function openDsmsWebUI() { + document.getElementById('dsms-webui-modal').style.display = 'flex'; + loadDsmsWebUIData(); + } + + function closeDsmsWebUI() { + document.getElementById('dsms-webui-modal').style.display = 'none'; + } + + function switchDsmsWebUISection(section) { + // Update nav buttons + document.querySelectorAll('.dsms-webui-nav').forEach(btn => { + btn.classList.toggle('active', btn.dataset.section === section); + }); + // Update sections + document.querySelectorAll('.dsms-webui-section').forEach(sec => { + sec.classList.remove('active'); + sec.style.display = 'none'; + }); + const activeSection = document.getElementById('dsms-webui-' + section); + if (activeSection) { + activeSection.classList.add('active'); + activeSection.style.display = 'block'; + } + // Load section-specific data + if (section === 'peers') loadDsmsPeers(); + } + + async function loadDsmsWebUIData() { + try { + // Load node info + const infoRes = await fetch(DSMS_GATEWAY_URL + '/api/v1/node/info'); + const info = await infoRes.json(); + + document.getElementById('webui-status').innerHTML = 'Online'; + document.getElementById('webui-node-id').textContent = info.node_id || '--'; + document.getElementById('webui-protocol').textContent = info.protocol_version || '--'; + document.getElementById('webui-agent').textContent = info.agent_version || '--'; + document.getElementById('webui-repo-size').textContent = formatBytes(info.repo_size || 0); + document.getElementById('webui-storage-info').textContent = 'Max: ' + formatBytes(info.storage_max || 0); + document.getElementById('webui-num-objects').textContent = (info.num_objects || 0).toLocaleString(); + + // Addresses + const addresses = info.addresses || []; + document.getElementById('webui-addresses').innerHTML = addresses.length > 0 + ? addresses.map(a => '
    ' + a + '
    ').join('') + : 'Keine Adressen verfügbar'; + + // Load pinned count + const token = localStorage.getItem('bp_token') || ''; + const docsRes = await fetch(DSMS_GATEWAY_URL + '/api/v1/documents', { + headers: { 'Authorization': 'Bearer ' + token } + }); + if (docsRes.ok) { + const docs = await docsRes.json(); + document.getElementById('webui-pinned-count').textContent = docs.total || 0; + } + } catch (e) { + console.error('Failed to load WebUI data:', e); + document.getElementById('webui-status').innerHTML = 'Offline'; + } + } + + async function loadDsmsPeers() { + const container = document.getElementById('webui-peers-list'); + try { + // IPFS peers endpoint via proxy would need direct IPFS API access + // For now, show info that private network has no peers + container.innerHTML = ` +
    +
    🔒
    +

    Privates Netzwerk

    +

    + DSMS läuft als isolierter Node. Keine externen Peers verbunden. +

    +
    + `; + } catch (e) { + container.innerHTML = '
    Fehler beim Laden der Peers
    '; + } + } + + // File upload handlers + function handleDsmsDragOver(e) { + e.preventDefault(); + e.stopPropagation(); + document.getElementById('dsms-upload-zone').classList.add('dragover'); + } + + function handleDsmsDragLeave(e) { + e.preventDefault(); + e.stopPropagation(); + document.getElementById('dsms-upload-zone').classList.remove('dragover'); + } + + async function handleDsmsFileDrop(e) { + e.preventDefault(); + e.stopPropagation(); + document.getElementById('dsms-upload-zone').classList.remove('dragover'); + const files = e.dataTransfer.files; + if (files.length > 0) { + await uploadDsmsFiles(files); + } + } + + async function handleDsmsFileSelect(e) { + const files = e.target.files; + if (files.length > 0) { + await uploadDsmsFiles(files); + } + } + + async function uploadDsmsFiles(files) { + const token = localStorage.getItem('bp_token') || ''; + const progressDiv = document.getElementById('dsms-upload-progress'); + const statusDiv = document.getElementById('dsms-upload-status'); + const barDiv = document.getElementById('dsms-upload-bar'); + const resultsDiv = document.getElementById('dsms-upload-results'); + + progressDiv.style.display = 'block'; + resultsDiv.innerHTML = ''; + + const results = []; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + statusDiv.textContent = 'Lade hoch: ' + file.name + ' (' + (i+1) + '/' + files.length + ')'; + barDiv.style.width = ((i / files.length) * 100) + '%'; + + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('document_type', 'legal_document'); + + const res = await fetch(DSMS_GATEWAY_URL + '/api/v1/documents', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + token }, + body: formData + }); + + if (res.ok) { + const data = await res.json(); + results.push({ file: file.name, cid: data.cid, success: true }); + } else { + results.push({ file: file.name, error: 'Upload fehlgeschlagen', success: false }); + } + } catch (e) { + results.push({ file: file.name, error: e.message, success: false }); + } + } + + barDiv.style.width = '100%'; + statusDiv.textContent = 'Upload abgeschlossen!'; + + // Show results + resultsDiv.innerHTML = '

    Ergebnisse

    ' + + results.map(r => ` +
    +
    +
    ${r.file}
    + ${r.success + ? '
    CID: ' + r.cid + '
    ' + : '
    ' + r.error + '
    ' + } +
    + ${r.success ? `` : ''} +
    + `).join(''); + + setTimeout(() => { + progressDiv.style.display = 'none'; + barDiv.style.width = '0%'; + }, 2000); + } + + async function exploreDsmsCid() { + const cid = document.getElementById('webui-explore-cid').value.trim(); + if (!cid) return; + + const resultDiv = document.getElementById('dsms-explore-result'); + const contentDiv = document.getElementById('dsms-explore-content'); + + resultDiv.style.display = 'block'; + contentDiv.innerHTML = '
    Lade...
    '; + + try { + const res = await fetch(DSMS_GATEWAY_URL + '/api/v1/verify/' + cid); + const data = await res.json(); + + if (data.exists) { + contentDiv.innerHTML = ` +
    + + ${data.integrity_valid ? '✓' : '✗'} + + + ${data.integrity_valid ? 'Dokument verifiziert' : 'Integritätsfehler'} + +
    + + + + + + + + + + + + + + + + + +
    CID${cid}
    Typ${data.metadata?.document_type || '--'}
    Erstellt${data.metadata?.created_at ? new Date(data.metadata.created_at).toLocaleString('de-DE') : '--'}
    Checksum${data.stored_checksum || '--'}
    + + `; + } else { + contentDiv.innerHTML = ` +
    + Nicht gefunden
    + CID existiert nicht im DSMS: ${cid} +
    + `; + } + } catch (e) { + contentDiv.innerHTML = ` +
    + Fehler
    + ${e.message} +
    + `; + } + } + + async function runDsmsGarbageCollection() { + if (!confirm('Garbage Collection ausführen? Dies entfernt nicht gepinnte Objekte.')) return; + + try { + // Note: Direct GC requires IPFS API access - show info for now + alert('Garbage Collection wird im Hintergrund ausgeführt. Dies kann einige Minuten dauern.'); + } catch (e) { + alert('Fehler: ' + e.message); + } + } + + // Close modal on escape key + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeDsmsWebUI(); + } + }); + + // Close modal on backdrop click + document.getElementById('dsms-webui-modal')?.addEventListener('click', (e) => { + if (e.target.id === 'dsms-webui-modal') { + closeDsmsWebUI(); + } + }); + + // Load DSMS data when tab is clicked + document.querySelector('.admin-tab[data-tab="dsms"]')?.addEventListener('click', loadDsmsData); + + // ========================================== + // E-MAIL TEMPLATE MANAGEMENT + // ========================================== + + let emailTemplates = []; + let emailTemplateVersions = []; + let currentEmailTemplateId = null; + let currentEmailVersionId = null; + + // E-Mail-Template-Typen mit deutschen Namen + const emailTypeNames = { + 'welcome': 'Willkommens-E-Mail', + 'email_verification': 'E-Mail-Verifizierung', + 'password_reset': 'Passwort zurücksetzen', + 'password_changed': 'Passwort geändert', + '2fa_enabled': '2FA aktiviert', + '2fa_disabled': '2FA deaktiviert', + 'new_device_login': 'Neues Gerät Login', + 'suspicious_activity': 'Verdächtige Aktivität', + 'account_locked': 'Account gesperrt', + 'account_unlocked': 'Account entsperrt', + 'deletion_requested': 'Löschung angefordert', + 'deletion_confirmed': 'Löschung bestätigt', + 'data_export_ready': 'Datenexport bereit', + 'email_changed': 'E-Mail geändert', + 'new_version_published': 'Neue Version veröffentlicht', + 'consent_reminder': 'Consent Erinnerung', + 'consent_deadline_warning': 'Consent Frist Warnung', + 'account_suspended': 'Account suspendiert' + }; + + // Load E-Mail Templates when tab is clicked + document.querySelector('.admin-tab[data-tab="emails"]')?.addEventListener('click', loadEmailTemplates); + + async function loadEmailTemplates() { + try { + const res = await fetch('/api/consent/admin/email-templates'); + if (!res.ok) throw new Error('Fehler beim Laden der Templates'); + const data = await res.json(); + emailTemplates = data.templates || []; + populateEmailTemplateSelect(); + } catch (e) { + console.error('Error loading email templates:', e); + showToast('Fehler beim Laden der E-Mail-Templates', 'error'); + } + } + + function populateEmailTemplateSelect() { + const select = document.getElementById('email-template-select'); + select.innerHTML = ''; + + emailTemplates.forEach(item => { + const template = item.template; // API liefert verschachtelte Struktur + const opt = document.createElement('option'); + opt.value = template.id; + opt.textContent = emailTypeNames[template.type] || template.name; + select.appendChild(opt); + }); + } + + async function loadEmailTemplateVersions() { + const select = document.getElementById('email-template-select'); + const templateId = select.value; + const newVersionBtn = document.getElementById('btn-new-email-version'); + const infoCard = document.getElementById('email-template-info'); + const container = document.getElementById('email-version-table-container'); + + if (!templateId) { + newVersionBtn.disabled = true; + infoCard.style.display = 'none'; + container.innerHTML = '
    Wählen Sie eine E-Mail-Vorlage aus, um deren Versionen anzuzeigen.
    '; + currentEmailTemplateId = null; + return; + } + + currentEmailTemplateId = templateId; + newVersionBtn.disabled = false; + + // Finde das Template (API liefert verschachtelte Struktur) + const templateItem = emailTemplates.find(t => t.template.id === templateId); + const template = templateItem?.template; + if (template) { + infoCard.style.display = 'block'; + document.getElementById('email-template-name').textContent = emailTypeNames[template.type] || template.name; + document.getElementById('email-template-description').textContent = template.description || 'Keine Beschreibung'; + document.getElementById('email-template-type-badge').textContent = template.type; + + // Variablen anzeigen (wird aus dem Default-Inhalt ermittelt) + try { + const defaultRes = await fetch(`/api/consent/admin/email-templates/default/${template.type}`); + if (defaultRes.ok) { + const defaultData = await defaultRes.json(); + const variables = extractVariables(defaultData.body_html || ''); + document.getElementById('email-template-variables').textContent = variables.join(', ') || 'Keine'; + } + } catch (e) { + document.getElementById('email-template-variables').textContent = '-'; + } + } + + // Lade Versionen + container.innerHTML = '
    Lade Versionen...
    '; + try { + const res = await fetch(`/api/consent/admin/email-templates/${templateId}/versions`); + if (!res.ok) throw new Error('Fehler beim Laden'); + const data = await res.json(); + emailTemplateVersions = data.versions || []; + renderEmailVersionsTable(); + } catch (e) { + container.innerHTML = '
    Fehler beim Laden der Versionen.
    '; + } + } + + function extractVariables(content) { + const matches = content.match(/\\{\\{([^}]+)\\}\\}/g) || []; + return [...new Set(matches.map(m => m.replace(/[{}]/g, '')))]; + } + + function renderEmailVersionsTable() { + const container = document.getElementById('email-version-table-container'); + + if (emailTemplateVersions.length === 0) { + container.innerHTML = '
    Keine Versionen vorhanden. Erstellen Sie eine neue Version.
    '; + return; + } + + const statusColors = { + 'draft': 'draft', + 'review': 'review', + 'approved': 'approved', + 'published': 'published', + 'archived': 'archived' + }; + + const statusNames = { + 'draft': 'Entwurf', + 'review': 'In Prüfung', + 'approved': 'Genehmigt', + 'published': 'Veröffentlicht', + 'archived': 'Archiviert' + }; + + container.innerHTML = ` + + + + + + + + + + + + + ${emailTemplateVersions.map(v => ` + + + + + + + + + `).join('')} + +
    VersionSpracheBetreffStatusAktualisiertAktionen
    ${v.version}${v.language === 'de' ? '🇩🇪 DE' : '🇬🇧 EN'}${v.subject}${statusNames[v.status] || v.status}${new Date(v.updated_at).toLocaleDateString('de-DE')} + + ${v.status === 'draft' ? ` + + + + ` : ''} + ${v.status === 'review' ? ` + + + ` : ''} + ${v.status === 'approved' ? ` + + ` : ''} +
    + `; + } + + function showEmailVersionForm() { + document.getElementById('email-version-form').style.display = 'block'; + document.getElementById('email-version-form-title').textContent = 'Neue E-Mail-Version erstellen'; + document.getElementById('email-version-id').value = ''; + document.getElementById('email-version-number').value = ''; + document.getElementById('email-version-subject').value = ''; + document.getElementById('email-version-editor').innerHTML = ''; + document.getElementById('email-version-text').value = ''; + + // Lade Default-Inhalt (API liefert verschachtelte Struktur) + const templateItem = emailTemplates.find(t => t.template.id === currentEmailTemplateId); + if (templateItem?.template) { + loadDefaultEmailContent(templateItem.template.type); + } + } + + async function loadDefaultEmailContent(templateType) { + try { + const res = await fetch(`/api/consent/admin/email-templates/default/${templateType}`); + if (res.ok) { + const data = await res.json(); + document.getElementById('email-version-subject').value = data.subject || ''; + document.getElementById('email-version-editor').innerHTML = data.body_html || ''; + document.getElementById('email-version-text').value = data.body_text || ''; + } + } catch (e) { + console.error('Error loading default content:', e); + } + } + + function hideEmailVersionForm() { + document.getElementById('email-version-form').style.display = 'none'; + } + + async function saveEmailVersion() { + const versionId = document.getElementById('email-version-id').value; + const templateId = currentEmailTemplateId; + const version = document.getElementById('email-version-number').value.trim(); + const language = document.getElementById('email-version-lang').value; + const subject = document.getElementById('email-version-subject').value.trim(); + const bodyHtml = document.getElementById('email-version-editor').innerHTML; + const bodyText = document.getElementById('email-version-text').value.trim(); + + if (!version || !subject || !bodyHtml) { + showToast('Bitte füllen Sie alle Pflichtfelder aus', 'error'); + return; + } + + const data = { + template_id: templateId, + version: version, + language: language, + subject: subject, + body_html: bodyHtml, + body_text: bodyText || stripHtml(bodyHtml) + }; + + try { + let res; + if (versionId) { + // Update existing version + res = await fetch(`/api/consent/admin/email-template-versions/${versionId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + } else { + // Create new version + res = await fetch('/api/consent/admin/email-template-versions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + } + + if (!res.ok) { + const error = await res.json(); + throw new Error(error.detail || 'Fehler beim Speichern'); + } + + showToast('E-Mail-Version gespeichert!', 'success'); + hideEmailVersionForm(); + loadEmailTemplateVersions(); + } catch (e) { + showToast('Fehler: ' + e.message, 'error'); + } + } + + function stripHtml(html) { + const div = document.createElement('div'); + div.innerHTML = html; + return div.textContent || div.innerText || ''; + } + + async function editEmailVersion(versionId) { + try { + const res = await fetch(`/api/consent/admin/email-template-versions/${versionId}`); + if (!res.ok) throw new Error('Version nicht gefunden'); + const version = await res.json(); + + document.getElementById('email-version-form').style.display = 'block'; + document.getElementById('email-version-form-title').textContent = 'E-Mail-Version bearbeiten'; + document.getElementById('email-version-id').value = versionId; + document.getElementById('email-version-number').value = version.version; + document.getElementById('email-version-lang').value = version.language; + document.getElementById('email-version-subject').value = version.subject; + document.getElementById('email-version-editor').innerHTML = version.body_html; + document.getElementById('email-version-text').value = version.body_text || ''; + } catch (e) { + showToast('Fehler beim Laden der Version', 'error'); + } + } + + async function deleteEmailVersion(versionId) { + if (!confirm('Möchten Sie diese Version wirklich löschen?')) return; + + try { + const res = await fetch(`/api/consent/admin/email-template-versions/${versionId}`, { + method: 'DELETE' + }); + if (!res.ok) throw new Error('Fehler beim Löschen'); + showToast('Version gelöscht', 'success'); + loadEmailTemplateVersions(); + } catch (e) { + showToast('Fehler beim Löschen', 'error'); + } + } + + async function submitEmailForReview(versionId) { + try { + const res = await fetch(`/api/consent/admin/email-template-versions/${versionId}/submit`, { + method: 'POST' + }); + if (!res.ok) throw new Error('Fehler'); + showToast('Zur Prüfung eingereicht', 'success'); + loadEmailTemplateVersions(); + } catch (e) { + showToast('Fehler beim Einreichen', 'error'); + } + } + + function showEmailApprovalDialogFor(versionId) { + currentEmailVersionId = versionId; + document.getElementById('email-approval-dialog').style.display = 'flex'; + document.getElementById('email-approval-comment').value = ''; + } + + function hideEmailApprovalDialog() { + document.getElementById('email-approval-dialog').style.display = 'none'; + currentEmailVersionId = null; + } + + async function submitEmailApproval() { + if (!currentEmailVersionId) return; + + const comment = document.getElementById('email-approval-comment').value.trim(); + + try { + const res = await fetch(`/api/consent/admin/email-template-versions/${currentEmailVersionId}/approve`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ comment: comment }) + }); + if (!res.ok) throw new Error('Fehler'); + showToast('Version genehmigt', 'success'); + hideEmailApprovalDialog(); + loadEmailTemplateVersions(); + } catch (e) { + showToast('Fehler bei der Genehmigung', 'error'); + } + } + + async function rejectEmailVersion(versionId) { + const reason = prompt('Ablehnungsgrund:'); + if (!reason) return; + + try { + const res = await fetch(`/api/consent/admin/email-template-versions/${versionId}/reject`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ reason: reason }) + }); + if (!res.ok) throw new Error('Fehler'); + showToast('Version abgelehnt', 'success'); + loadEmailTemplateVersions(); + } catch (e) { + showToast('Fehler bei der Ablehnung', 'error'); + } + } + + async function publishEmailVersion(versionId) { + if (!confirm('Möchten Sie diese Version veröffentlichen? Die vorherige Version wird archiviert.')) return; + + try { + const res = await fetch(`/api/consent/admin/email-template-versions/${versionId}/publish`, { + method: 'POST' + }); + if (!res.ok) throw new Error('Fehler'); + showToast('Version veröffentlicht!', 'success'); + loadEmailTemplateVersions(); + } catch (e) { + showToast('Fehler beim Veröffentlichen', 'error'); + } + } + + async function previewEmailVersionById(versionId) { + try { + const res = await fetch(`/api/consent/admin/email-template-versions/${versionId}/preview`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + if (!res.ok) throw new Error('Fehler'); + const data = await res.json(); + + document.getElementById('email-preview-subject').textContent = data.subject; + document.getElementById('email-preview-content').innerHTML = data.body_html; + document.getElementById('email-preview-dialog').style.display = 'flex'; + currentEmailVersionId = versionId; + } catch (e) { + showToast('Fehler bei der Vorschau', 'error'); + } + } + + function previewEmailVersion() { + const subject = document.getElementById('email-version-subject').value; + const bodyHtml = document.getElementById('email-version-editor').innerHTML; + + document.getElementById('email-preview-subject').textContent = subject; + document.getElementById('email-preview-content').innerHTML = bodyHtml; + document.getElementById('email-preview-dialog').style.display = 'flex'; + } + + function hideEmailPreview() { + document.getElementById('email-preview-dialog').style.display = 'none'; + } + + async function sendTestEmail() { + const email = document.getElementById('email-test-address').value.trim(); + if (!email) { + showToast('Bitte geben Sie eine E-Mail-Adresse ein', 'error'); + return; + } + + if (!currentEmailVersionId) { + showToast('Keine Version ausgewählt', 'error'); + return; + } + + try { + const res = await fetch(`/api/consent/admin/email-template-versions/${currentEmailVersionId}/send-test`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email }) + }); + if (!res.ok) throw new Error('Fehler'); + showToast('Test-E-Mail gesendet!', 'success'); + } catch (e) { + showToast('Fehler beim Senden der Test-E-Mail', 'error'); + } + } + + async function initializeEmailTemplates() { + if (!confirm('Möchten Sie alle Standard-E-Mail-Templates initialisieren?')) return; + + try { + const res = await fetch('/api/consent/admin/email-templates/initialize', { + method: 'POST' + }); + if (!res.ok) throw new Error('Fehler'); + showToast('Templates initialisiert!', 'success'); + loadEmailTemplates(); + } catch (e) { + showToast('Fehler bei der Initialisierung', 'error'); + } + } + + // E-Mail Editor Helpers + function formatEmailDoc(command) { + document.execCommand(command, false, null); + document.getElementById('email-version-editor').focus(); + } + + function formatEmailBlock(tag) { + document.execCommand('formatBlock', false, '<' + tag + '>'); + document.getElementById('email-version-editor').focus(); + } + + function insertEmailVariable() { + const variable = prompt('Variablenname eingeben (z.B. user_name, reset_link):'); + if (variable) { + document.execCommand('insertText', false, '{{' + variable + '}}'); + } + } + + function insertEmailLink() { + const url = prompt('Link-URL:'); + if (url) { + const text = prompt('Link-Text:', url); + document.execCommand('insertHTML', false, `${text}`); + } + } + + function insertEmailButton() { + const url = prompt('Button-Link:'); + if (url) { + const text = prompt('Button-Text:', 'Klicken'); + const buttonHtml = `
    ${text}
    `; + document.execCommand('insertHTML', false, buttonHtml); + } + } + + // ========================================== + // INITIALIZATION - DOMContentLoaded + // ========================================== + document.addEventListener('DOMContentLoaded', function() { + // Theme Toggle + initThemeToggle(); + + // Language initialization + if (typeof initLanguage === 'function') { + initLanguage(); + } + + // Vast Control + if (typeof initVastControl === 'function') { + initVastControl(); + } + + // Legal Modal Close Button + const legalCloseBtn = document.querySelector('.legal-modal-close'); + if (legalCloseBtn) { + legalCloseBtn.addEventListener('click', function() { + document.getElementById('legal-modal').classList.remove('active'); + }); + } + + // Auth Modal Close Button + const authCloseBtn = document.querySelector('.auth-modal-close'); + if (authCloseBtn) { + authCloseBtn.addEventListener('click', function() { + document.getElementById('auth-modal').classList.remove('active'); + }); + } + + // Admin Modal Close Button + const adminCloseBtn = document.querySelector('.admin-modal-close'); + if (adminCloseBtn) { + adminCloseBtn.addEventListener('click', function() { + document.getElementById('admin-modal').classList.remove('active'); + }); + } + + // Legal Button (Footer) + const legalBtn = document.querySelector('[onclick*="showLegalModal"]'); + if (legalBtn) { + legalBtn.removeAttribute('onclick'); + legalBtn.addEventListener('click', function() { + document.getElementById('legal-modal').classList.add('active'); + }); + } + + // Consent Button (Footer) + const consentBtn = document.querySelector('[onclick*="showConsentModal"]'); + if (consentBtn) { + consentBtn.removeAttribute('onclick'); + consentBtn.addEventListener('click', function() { + document.getElementById('legal-modal').classList.add('active'); + // Switch to consent tab + document.querySelectorAll('.legal-tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.legal-content').forEach(c => c.classList.remove('active')); + const consentTab = document.querySelector('.legal-tab[data-tab="consent"]'); + const consentContent = document.getElementById('legal-content-consent'); + if (consentTab) consentTab.classList.add('active'); + if (consentContent) consentContent.classList.add('active'); + }); + } + + // Legal Tabs + document.querySelectorAll('.legal-tab').forEach(tab => { + tab.addEventListener('click', function() { + const tabName = this.dataset.tab; + document.querySelectorAll('.legal-tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.legal-content').forEach(c => c.classList.remove('active')); + this.classList.add('active'); + const content = document.getElementById('legal-content-' + tabName); + if (content) content.classList.add('active'); + }); + }); + + // Auth Tabs + document.querySelectorAll('.auth-tab').forEach(tab => { + tab.addEventListener('click', function() { + const tabName = this.dataset.tab; + document.querySelectorAll('.auth-tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.auth-form').forEach(f => f.classList.remove('active')); + this.classList.add('active'); + const form = document.getElementById('auth-form-' + tabName); + if (form) form.classList.add('active'); + }); + }); + + // Admin Tabs + document.querySelectorAll('.admin-tab').forEach(tab => { + tab.addEventListener('click', function() { + const tabName = this.dataset.tab; + document.querySelectorAll('.admin-tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.admin-content').forEach(c => c.classList.remove('active')); + this.classList.add('active'); + const content = document.getElementById('admin-content-' + tabName); + if (content) content.classList.add('active'); + }); + }); + + // Login Button (TopBar) + const loginBtn = document.getElementById('btn-login'); + if (loginBtn) { + loginBtn.addEventListener('click', function() { + document.getElementById('auth-modal').classList.add('active'); + }); + } + + // Admin Button (TopBar) + const adminBtn = document.getElementById('btn-admin'); + if (adminBtn) { + adminBtn.addEventListener('click', function() { + document.getElementById('admin-modal').classList.add('active'); + }); + } + + // Legal Button (TopBar) + const legalTopBtn = document.getElementById('btn-legal'); + if (legalTopBtn) { + legalTopBtn.addEventListener('click', function() { + document.getElementById('legal-modal').classList.add('active'); + }); + } + + // Modal Close Buttons (by specific IDs) + document.getElementById('legal-modal-close')?.addEventListener('click', function() { + document.getElementById('legal-modal').classList.remove('active'); + }); + document.getElementById('auth-modal-close')?.addEventListener('click', function() { + document.getElementById('auth-modal').classList.remove('active'); + }); + document.getElementById('admin-modal-close')?.addEventListener('click', function() { + document.getElementById('admin-modal').classList.remove('active'); + }); + document.getElementById('imprint-modal-close')?.addEventListener('click', function() { + document.getElementById('imprint-modal').classList.remove('active'); + }); + + // Language selector + const langSelect = document.getElementById('language-select'); + if (langSelect && typeof setLanguage === 'function') { + langSelect.addEventListener('change', function() { + setLanguage(this.value); + }); + } + + console.log('BreakPilot Studio initialized'); + }); + +// ========================================== +// Communication Panel Functions (Matrix + Jitsi) +// ========================================== + +// API Base URL for communication endpoints +const COMM_API_BASE = '/consent/api/v1/communication'; +const JITSI_BASE_URL = 'http://localhost:8443'; + +// Current state +let currentRoom = null; +let jitsiApi = null; + +// Show Messenger Panel (Matrix) +function showMessengerPanel() { + console.log('showMessengerPanel called'); + hideAllPanels(); + hideStudioSubMenu(); + const messengerPanel = document.getElementById('panel-messenger'); + if (messengerPanel) { + messengerPanel.style.display = 'flex'; + console.log('Messenger panel shown'); + } else { + console.error('panel-messenger not found'); + } + + updateSidebarActive('sidebar-messenger'); + + // Check service status + checkCommunicationStatus(); +} + +// Show Video Panel (Jitsi) +function showVideoPanel() { + console.log('showVideoPanel called'); + hideAllPanels(); + hideStudioSubMenu(); + const videoPanel = document.getElementById('panel-video'); + if (videoPanel) { + videoPanel.style.display = 'flex'; + console.log('Video panel shown'); + } else { + console.error('panel-video not found'); + } + + updateSidebarActive('sidebar-video'); +} + +// Legacy alias for backward compatibility +function showCommunicationPanel() { + showMessengerPanel(); +} + +// Show Studio Panel (original) - zeigt Arbeitsblatt-Tab als Standard +function showStudioPanel() { + console.log('showStudioPanel called'); + hideAllPanels(); + // Zeige Sub-Navigation + const subMenu = document.getElementById('studio-sub-menu'); + if (subMenu) { + subMenu.style.display = 'flex'; + } + // Zeige Arbeitsblätter als Standard-Tab + showWorksheetTab(); + updateSidebarActive('sidebar-studio'); +} + +// Tab: Arbeitsblätter anzeigen +function showWorksheetTab() { + console.log('showWorksheetTab called'); + // Verstecke beide Studio-Panels + const panelCompare = document.getElementById('panel-compare'); + const panelTiles = document.getElementById('panel-tiles'); + + if (panelCompare) panelCompare.style.display = 'flex'; + if (panelTiles) panelTiles.style.display = 'none'; + + // Update Sub-Navigation active state + updateSubNavActive('sub-worksheets'); +} + +// Tab: Lernkacheln anzeigen +function showTilesTab() { + console.log('showTilesTab called'); + // Verstecke beide Studio-Panels + const panelCompare = document.getElementById('panel-compare'); + const panelTiles = document.getElementById('panel-tiles'); + + if (panelCompare) panelCompare.style.display = 'none'; + if (panelTiles) panelTiles.style.display = 'flex'; + + // Update Sub-Navigation active state + updateSubNavActive('sub-tiles'); +} + +// Helper: Update Sub-Navigation active state +function updateSubNavActive(activeSubId) { + document.querySelectorAll('.sidebar-sub-item').forEach(item => { + item.classList.remove('active'); + }); + const activeItem = document.getElementById(activeSubId); + if (activeItem) { + activeItem.classList.add('active'); + } +} + +// Helper: Hide Studio Sub-Menu (when switching to other panels) +function hideStudioSubMenu() { + const subMenu = document.getElementById('studio-sub-menu'); + if (subMenu) { + subMenu.style.display = 'none'; + } +} + +// Show Correction Panel (Klausur-Korrektur) +function showCorrectionPanel() { + console.log('showCorrectionPanel called'); + hideAllPanels(); + hideStudioSubMenu(); + const correctionPanel = document.getElementById('panel-correction'); + if (correctionPanel) { + correctionPanel.style.display = 'flex'; + console.log('Correction panel shown'); + } else { + console.error('panel-correction not found'); + } + + updateSidebarActive('sidebar-correction'); +} + +// Show Letters Panel (Elternbriefe) +function showLettersPanel() { + console.log('showLettersPanel called'); + hideAllPanels(); + hideStudioSubMenu(); + const lettersPanel = document.getElementById('panel-letters'); + if (lettersPanel) { + lettersPanel.style.display = 'flex'; + console.log('Letters panel shown'); + } else { + console.error('panel-letters not found'); + } + + updateSidebarActive('sidebar-letters'); +} + +// ======================================== +// School Service Panel Functions +// ======================================== + +// School Service API Base URL +const SCHOOL_SERVICE_URL = '/api/school'; + +// Show Exams Panel (Klausuren & Tests) +function showExamsPanel() { + console.log('showExamsPanel called'); + hideAllPanels(); + hideStudioSubMenu(); + const panel = document.getElementById('panel-exams'); + if (panel) { + panel.style.display = 'flex'; + loadClassesForSelect('exams-class-select'); + loadSubjectsForSelect('exams-subject-select'); + loadExams(); + } + updateSidebarActive('sidebar-exams'); +} + +// Show Grades Panel (Notenspiegel) +function showGradesPanel() { + console.log('showGradesPanel called'); + hideAllPanels(); + hideStudioSubMenu(); + const panel = document.getElementById('panel-grades'); + if (panel) { + panel.style.display = 'flex'; + loadClassesForSelect('grades-class-select'); + } + updateSidebarActive('sidebar-grades'); +} + +// Show Gradebook Panel (Klassenbuch) +function showGradebookPanel() { + console.log('showGradebookPanel called'); + hideAllPanels(); + hideStudioSubMenu(); + const panel = document.getElementById('panel-gradebook'); + if (panel) { + panel.style.display = 'flex'; + loadClassesForSelect('gradebook-class-select'); + document.getElementById('gradebook-date').valueAsDate = new Date(); + } + updateSidebarActive('sidebar-gradebook'); +} + +// Show Certificates Panel (Zeugnisse) +function showCertificatesPanel() { + console.log('showCertificatesPanel called'); + hideAllPanels(); + hideStudioSubMenu(); + const panel = document.getElementById('panel-certificates'); + if (panel) { + panel.style.display = 'flex'; + loadClassesForSelect('cert-class-select'); + loadCertificateTemplates(); + } + updateSidebarActive('sidebar-certificates'); +} + +// Show Classes Panel (Klassen & Schüler) +function showClassesPanel() { + console.log('showClassesPanel called'); + hideAllPanels(); + hideStudioSubMenu(); + const panel = document.getElementById('panel-classes'); + if (panel) { + panel.style.display = 'flex'; + loadSchoolYears(); + loadClasses(); + } + updateSidebarActive('sidebar-classes'); +} + +// Show Subjects Panel (Fächer) +function showSubjectsPanel() { + console.log('showSubjectsPanel called'); + hideAllPanels(); + hideStudioSubMenu(); + const panel = document.getElementById('panel-subjects'); + if (panel) { + panel.style.display = 'flex'; + loadSubjects(); + } + updateSidebarActive('sidebar-subjects'); +} + +// Helper: Load classes for dropdown +async function loadClassesForSelect(selectId) { + const select = document.getElementById(selectId); + if (!select) return; + + try { + const response = await fetch(`${SCHOOL_SERVICE_URL}/classes`, { + headers: { 'Authorization': `Bearer ${localStorage.getItem('jwt')}` } + }); + if (response.ok) { + const classes = await response.json(); + select.innerHTML = ''; + (classes || []).forEach(c => { + select.innerHTML += ``; + }); + } + } catch (e) { + console.log('Could not load classes:', e); + } +} + +// Helper: Load subjects for dropdown +async function loadSubjectsForSelect(selectId) { + const select = document.getElementById(selectId); + if (!select) return; + + try { + const response = await fetch(`${SCHOOL_SERVICE_URL}/subjects`, { + headers: { 'Authorization': `Bearer ${localStorage.getItem('jwt')}` } + }); + if (response.ok) { + const subjects = await response.json(); + select.innerHTML = ''; + (subjects || []).forEach(s => { + select.innerHTML += ``; + }); + } + } catch (e) { + console.log('Could not load subjects:', e); + } +} + +// Load school years +async function loadSchoolYears() { + const select = document.getElementById('classes-year-select'); + if (!select) return; + + try { + const response = await fetch(`${SCHOOL_SERVICE_URL}/years`, { + headers: { 'Authorization': `Bearer ${localStorage.getItem('jwt')}` } + }); + if (response.ok) { + const years = await response.json(); + select.innerHTML = ''; + (years || []).forEach(y => { + select.innerHTML += ``; + }); + } + } catch (e) { + console.log('Could not load school years:', e); + } +} + +// ============================================ +// SCHOOL SERVICE CRUD FUNCTIONS +// ============================================ + +const SCHOOL_API_BASE = '/api/school'; + +// Get auth headers for school service requests +function getSchoolAuthHeaders() { + const token = localStorage.getItem('token'); + return { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }; +} + +// Proxy request through backend to school-service +async function schoolServiceRequest(endpoint, options = {}) { + const token = localStorage.getItem('token'); + const url = `${SCHOOL_API_BASE}${endpoint}`; + const response = await fetch(url, { + ...options, + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + ...options.headers + } + }); + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Request failed' })); + throw new Error(error.message || `HTTP ${response.status}`); + } + return response.json(); +} + +// ===== CLASSES ===== +async function loadClasses() { + try { + const data = await schoolServiceRequest('/classes'); + const container = document.getElementById('classes-list'); + if (!container) return; + + if (!data.classes || data.classes.length === 0) { + container.innerHTML = '
    Keine Klassen vorhanden. Erstellen Sie eine neue Klasse.
    '; + return; + } + + container.innerHTML = data.classes.map(c => ` +
    +
    +

    ${c.name}

    + ${c.school_type || 'Gymnasium'} +
    +
    + Klasse ${c.grade_level} + ${c.student_count || 0} Schüler +
    +
    + + +
    +
    + `).join(''); + } catch (e) { + console.error('Error loading classes:', e); + showToast('Fehler beim Laden der Klassen', 'error'); + } +} + +async function showClassStudents(classId, className) { + try { + const data = await schoolServiceRequest(`/classes/${classId}/students`); + const students = data.students || []; + + const modal = document.createElement('div'); + modal.className = 'modal-overlay'; + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + } catch (e) { + console.error('Error loading students:', e); + showToast('Fehler beim Laden der Schüler', 'error'); + } +} + +function showCreateClassModal() { + const modal = document.createElement('div'); + modal.className = 'modal-overlay'; + modal.innerHTML = ` + + `; + document.body.appendChild(modal); +} + +async function createClass(event) { + event.preventDefault(); + const form = event.target; + const formData = new FormData(form); + + try { + await schoolServiceRequest('/classes', { + method: 'POST', + body: JSON.stringify({ + name: formData.get('name'), + grade_level: parseInt(formData.get('grade_level')), + school_type: formData.get('school_type'), + federal_state: formData.get('federal_state') + }) + }); + form.closest('.modal-overlay').remove(); + showToast('Klasse erfolgreich erstellt', 'success'); + loadClasses(); + } catch (e) { + showToast('Fehler: ' + e.message, 'error'); + } +} + +async function deleteClass(classId) { + if (!confirm('Klasse wirklich löschen? Alle Schüler werden ebenfalls gelöscht.')) return; + try { + await schoolServiceRequest(`/classes/${classId}`, { method: 'DELETE' }); + showToast('Klasse gelöscht', 'success'); + loadClasses(); + } catch (e) { + showToast('Fehler: ' + e.message, 'error'); + } +} + +function showAddStudentModal(classId) { + document.querySelector('.modal-overlay')?.remove(); + const modal = document.createElement('div'); + modal.className = 'modal-overlay'; + modal.innerHTML = ` + + `; + document.body.appendChild(modal); +} + +async function addStudent(event, classId) { + event.preventDefault(); + const form = event.target; + const formData = new FormData(form); + + try { + await schoolServiceRequest(`/classes/${classId}/students`, { + method: 'POST', + body: JSON.stringify({ + first_name: formData.get('first_name'), + last_name: formData.get('last_name'), + birth_date: formData.get('birth_date') || null, + student_number: formData.get('student_number') || null + }) + }); + form.closest('.modal-overlay').remove(); + showToast('Schüler hinzugefügt', 'success'); + } catch (e) { + showToast('Fehler: ' + e.message, 'error'); + } +} + +async function deleteStudent(classId, studentId) { + if (!confirm('Schüler wirklich entfernen?')) return; + try { + await schoolServiceRequest(`/classes/${classId}/students/${studentId}`, { method: 'DELETE' }); + showToast('Schüler entfernt', 'success'); + document.querySelector('.modal-overlay')?.remove(); + } catch (e) { + showToast('Fehler: ' + e.message, 'error'); + } +} + +function showImportStudentsModal(classId) { + document.querySelector('.modal-overlay')?.remove(); + const modal = document.createElement('div'); + modal.className = 'modal-overlay'; + modal.innerHTML = ` + + `; + document.body.appendChild(modal); +} + +async function importStudents(event, classId) { + event.preventDefault(); + const form = event.target; + const fileInput = form.querySelector('input[type="file"]'); + const file = fileInput.files[0]; + if (!file) return; + + const formData = new FormData(); + formData.append('file', file); + + try { + const token = localStorage.getItem('token'); + const response = await fetch(`${SCHOOL_API_BASE}/classes/${classId}/students/import`, { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: formData + }); + const data = await response.json(); + form.closest('.modal-overlay').remove(); + showToast(`${data.imported || 0} Schüler importiert`, 'success'); + } catch (e) { + showToast('Import fehlgeschlagen: ' + e.message, 'error'); + } +} + +// ===== SUBJECTS ===== +async function loadSubjects() { + try { + const data = await schoolServiceRequest('/subjects'); + const container = document.getElementById('subjects-list'); + if (!container) return; + + if (!data.subjects || data.subjects.length === 0) { + container.innerHTML = '
    Keine Fächer vorhanden
    '; + return; + } + + container.innerHTML = data.subjects.map(s => ` +
    + ${s.name} + ${s.short_name || ''} + ${s.is_main_subject ? 'Hauptfach' : 'Nebenfach'} + +
    + `).join(''); + } catch (e) { + console.error('Error loading subjects:', e); + showToast('Fehler beim Laden der Fächer', 'error'); + } +} + +function showCreateSubjectModal() { + const modal = document.createElement('div'); + modal.className = 'modal-overlay'; + modal.innerHTML = ` + + `; + document.body.appendChild(modal); +} + +async function createSubject(event) { + event.preventDefault(); + const form = event.target; + const formData = new FormData(form); + + try { + await schoolServiceRequest('/subjects', { + method: 'POST', + body: JSON.stringify({ + name: formData.get('name'), + short_name: formData.get('short_name') || null, + is_main_subject: form.querySelector('[name="is_main_subject"]').checked + }) + }); + form.closest('.modal-overlay').remove(); + showToast('Fach erstellt', 'success'); + loadSubjects(); + } catch (e) { + showToast('Fehler: ' + e.message, 'error'); + } +} + +async function deleteSubject(subjectId) { + if (!confirm('Fach wirklich löschen?')) return; + try { + await schoolServiceRequest(`/subjects/${subjectId}`, { method: 'DELETE' }); + showToast('Fach gelöscht', 'success'); + loadSubjects(); + } catch (e) { + showToast('Fehler: ' + e.message, 'error'); + } +} + +// ===== EXAMS ===== +async function loadExams() { + try { + const classSelect = document.getElementById('exams-class-select'); + const classId = classSelect?.value; + const url = classId ? `/exams?class_id=${classId}` : '/exams'; + const data = await schoolServiceRequest(url); + const container = document.getElementById('exams-list'); + if (!container) return; + + if (!data.exams || data.exams.length === 0) { + container.innerHTML = '
    Keine Klausuren vorhanden
    '; + return; + } + + container.innerHTML = data.exams.map(e => ` +
    +
    +

    ${e.title}

    + ${e.status} +
    +
    + ${e.exam_type} + ${e.exam_date || 'Kein Datum'} + ${e.max_points} Punkte +
    +
    + + + +
    +
    + `).join(''); + } catch (e) { + console.error('Error loading exams:', e); + showToast('Fehler beim Laden der Klausuren', 'error'); + } +} + +function showCreateExamModal() { + const modal = document.createElement('div'); + modal.className = 'modal-overlay'; + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + loadClassesForSelect('exam-class-select'); + loadSubjectsForSelect('exam-subject-select'); +} + +async function createExam(event) { + event.preventDefault(); + const form = event.target; + const formData = new FormData(form); + + try { + await schoolServiceRequest('/exams', { + method: 'POST', + body: JSON.stringify({ + title: formData.get('title'), + exam_type: formData.get('exam_type'), + class_id: formData.get('class_id'), + subject_id: formData.get('subject_id'), + exam_date: formData.get('exam_date') || null, + duration_minutes: parseInt(formData.get('duration_minutes')) || 45, + max_points: parseFloat(formData.get('max_points')) || 50, + topic: formData.get('topic') || null, + content: formData.get('content') || null + }) + }); + form.closest('.modal-overlay').remove(); + showToast('Klausur erstellt', 'success'); + loadExams(); + } catch (e) { + showToast('Fehler: ' + e.message, 'error'); + } +} + +async function deleteExam(examId) { + if (!confirm('Klausur wirklich löschen?')) return; + try { + await schoolServiceRequest(`/exams/${examId}`, { method: 'DELETE' }); + showToast('Klausur gelöscht', 'success'); + loadExams(); + } catch (e) { + showToast('Fehler: ' + e.message, 'error'); + } +} + +async function showExamResultsModal(examId) { + try { + const exam = await schoolServiceRequest(`/exams/${examId}`); + const results = await schoolServiceRequest(`/exams/${examId}/results`); + + const modal = document.createElement('div'); + modal.className = 'modal-overlay'; + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + } catch (e) { + showToast('Fehler: ' + e.message, 'error'); + } +} + +async function saveExamResults(examId, maxPoints) { + const inputs = document.querySelectorAll('.result-points'); + const results = []; + inputs.forEach(input => { + const points = parseFloat(input.value); + if (!isNaN(points)) { + results.push({ + student_id: input.dataset.student, + points_achieved: points + }); + } + }); + + try { + await schoolServiceRequest(`/exams/${examId}/results`, { + method: 'POST', + body: JSON.stringify({ results }) + }); + showToast('Ergebnisse gespeichert', 'success'); + document.querySelector('.modal-overlay')?.remove(); + } catch (e) { + showToast('Fehler: ' + e.message, 'error'); + } +} + +async function approveResult(examId, studentId) { + try { + await schoolServiceRequest(`/exams/${examId}/results/${studentId}/approve`, { method: 'PUT' }); + showToast('Freigegeben', 'success'); + } catch (e) { + showToast('Fehler: ' + e.message, 'error'); + } +} + +async function generateNachschreiber(examId) { + if (!confirm('Nachschreiber-Version mit KI generieren?')) return; + try { + showToast('Generiere Nachschreiber...', 'info'); + await schoolServiceRequest(`/exams/${examId}/generate-variant`, { + method: 'POST', + body: JSON.stringify({ variation_type: 'rewrite' }) + }); + showToast('Nachschreiber erstellt', 'success'); + loadExams(); + } catch (e) { + showToast('Fehler: ' + e.message, 'error'); + } +} + +function editExam(examId) { + showToast('Bearbeitungsfunktion in Entwicklung', 'info'); +} + +// ===== GRADES ===== +async function loadGrades() { + try { + const classSelect = document.getElementById('grades-class-select'); + const classId = classSelect?.value; + if (!classId) return; + + const data = await schoolServiceRequest(`/grades/${classId}`); + const container = document.getElementById('grades-table'); + if (!container) return; + + if (!data.grades || data.grades.length === 0) { + container.innerHTML = '
    Keine Noten vorhanden
    '; + return; + } + + // Get all unique subjects + const subjects = new Set(); + data.grades.forEach(g => g.subjects?.forEach(s => subjects.add(s.subject_name))); + const subjectList = Array.from(subjects); + + container.innerHTML = ` + + + + + ${subjectList.map(s => ``).join('')} + + + + + ${data.grades.map(g => { + const avg = g.subjects?.reduce((sum, s) => sum + (s.final_grade || 0), 0) / (g.subjects?.length || 1); + return ` + + + ${subjectList.map(subj => { + const s = g.subjects?.find(x => x.subject_name === subj); + return ``; + }).join('')} + + + `; + }).join('')} + +
    Schüler${s}Durchschnitt
    ${g.student_name}${s?.final_grade?.toFixed(1) || '-'}${avg.toFixed(2)}
    + `; + } catch (e) { + console.error('Error loading grades:', e); + showToast('Fehler beim Laden der Noten', 'error'); + } +} + +async function calculateFinalGrades() { + const classSelect = document.getElementById('grades-class-select'); + const classId = classSelect?.value; + if (!classId) { + showToast('Bitte Klasse auswählen', 'warning'); + return; + } + + try { + await schoolServiceRequest('/grades/calculate', { + method: 'POST', + body: JSON.stringify({ class_id: classId, semester: 1 }) + }); + showToast('Endnoten berechnet', 'success'); + loadGrades(); + } catch (e) { + showToast('Fehler: ' + e.message, 'error'); + } +} + +// ===== ATTENDANCE / GRADEBOOK ===== +async function loadGradebook() { + try { + const classSelect = document.getElementById('gradebook-class-select'); + const classId = classSelect?.value; + if (!classId) return; + + const attendance = await schoolServiceRequest(`/attendance/${classId}`); + const entries = await schoolServiceRequest(`/gradebook/${classId}`); + + const container = document.getElementById('gradebook-content'); + if (!container) return; + + container.innerHTML = ` +
    +

    Fehlzeiten

    + + + + + + ${(attendance.attendance || []).slice(0, 20).map(a => ` + + + + + + + `).join('')} + +
    SchülerDatumStatusStunden
    ${a.student_name}${a.date}${a.status === 'absent_excused' ? 'entschuldigt' : a.status === 'absent_unexcused' ? 'unentschuldigt' : a.status}${a.periods}
    +
    +
    +

    Einträge

    + ${(entries.entries || []).slice(0, 10).map(e => ` +
    + ${e.date} + ${e.entry_type} + ${e.content} +
    + `).join('')} +
    + `; + } catch (e) { + console.error('Error loading gradebook:', e); + } +} + +function loadGradebookEntries() { loadGradebook(); } + +function showAttendanceModal() { + const classSelect = document.getElementById('gradebook-class-select'); + const classId = classSelect?.value; + if (!classId) { + showToast('Bitte Klasse auswählen', 'warning'); + return; + } + + const modal = document.createElement('div'); + modal.className = 'modal-overlay'; + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + loadStudentsForSelect(classId, 'attendance-student-select'); +} + +async function createAttendance(event, classId) { + event.preventDefault(); + const form = event.target; + const formData = new FormData(form); + + try { + await schoolServiceRequest('/attendance', { + method: 'POST', + body: JSON.stringify({ + student_id: formData.get('student_id'), + date: formData.get('date'), + status: formData.get('status'), + periods: parseInt(formData.get('periods')), + reason: formData.get('reason') || null + }) + }); + form.closest('.modal-overlay').remove(); + showToast('Fehlzeit eingetragen', 'success'); + loadGradebook(); + } catch (e) { + showToast('Fehler: ' + e.message, 'error'); + } +} + +function showGradebookEntryModal() { + const classSelect = document.getElementById('gradebook-class-select'); + const classId = classSelect?.value; + if (!classId) { + showToast('Bitte Klasse auswählen', 'warning'); + return; + } + + const modal = document.createElement('div'); + modal.className = 'modal-overlay'; + modal.innerHTML = ` + + `; + document.body.appendChild(modal); + loadStudentsForSelect(classId, 'entry-student-select'); +} + +async function createGradebookEntry(event, classId) { + event.preventDefault(); + const form = event.target; + const formData = new FormData(form); + + try { + await schoolServiceRequest('/gradebook', { + method: 'POST', + body: JSON.stringify({ + class_id: classId, + student_id: formData.get('student_id') || null, + date: formData.get('date'), + entry_type: formData.get('entry_type'), + content: formData.get('content') + }) + }); + form.closest('.modal-overlay').remove(); + showToast('Eintrag erstellt', 'success'); + loadGradebook(); + } catch (e) { + showToast('Fehler: ' + e.message, 'error'); + } +} + +// ===== CERTIFICATES ===== +async function loadCertificates() { + try { + const classSelect = document.getElementById('certificates-class-select'); + const classId = classSelect?.value; + if (!classId) return; + + const data = await schoolServiceRequest(`/certificates/class/${classId}`); + const container = document.getElementById('certificates-list'); + if (!container) return; + + if (!data.certificates || data.certificates.length === 0) { + container.innerHTML = '
    Keine Zeugnisse vorhanden
    '; + return; + } + + container.innerHTML = data.certificates.map(c => ` +
    + ${c.student_name} + ${c.status} +
    + ${c.status === 'draft' ? `` : ''} + +
    +
    + `).join(''); + } catch (e) { + console.error('Error loading certificates:', e); + } +} + +async function loadCertificateTemplates() { + try { + const data = await schoolServiceRequest('/certificates/templates'); + const select = document.getElementById('certificate-template-select'); + if (!select) return; + + select.innerHTML = (data.templates || []).map(t => + `` + ).join(''); + } catch (e) { + console.error('Error loading templates:', e); + } +} + +async function generateAllCertificates() { + const classSelect = document.getElementById('certificates-class-select'); + const templateSelect = document.getElementById('certificate-template-select'); + const classId = classSelect?.value; + const template = templateSelect?.value; + + if (!classId || !template) { + showToast('Bitte Klasse und Vorlage auswählen', 'warning'); + return; + } + + try { + showToast('Generiere Zeugnisse...', 'info'); + await schoolServiceRequest('/certificates/generate-bulk', { + method: 'POST', + body: JSON.stringify({ + class_id: classId, + semester: 1, + certificate_type: 'halbjahr', + template_name: template + }) + }); + showToast('Zeugnisse generiert', 'success'); + loadCertificates(); + } catch (e) { + showToast('Fehler: ' + e.message, 'error'); + } +} + +async function finalizeCertificate(certId) { + if (!confirm('Zeugnis finalisieren? Dies kann nicht rückgängig gemacht werden.')) return; + try { + await schoolServiceRequest(`/certificates/detail/${certId}/finalize`, { method: 'PUT' }); + showToast('Zeugnis finalisiert', 'success'); + loadCertificates(); + } catch (e) { + showToast('Fehler: ' + e.message, 'error'); + } +} + +async function downloadCertificatePDF(certId) { + try { + const token = localStorage.getItem('token'); + const response = await fetch(`${SCHOOL_API_BASE}/certificates/detail/${certId}/pdf`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + if (!response.ok) throw new Error('PDF download failed'); + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `zeugnis_${certId}.pdf`; + a.click(); + window.URL.revokeObjectURL(url); + } catch (e) { + showToast('Fehler beim PDF-Download', 'error'); + } +} + +// ===== SCHOOL YEARS ===== +function showCreateSchoolYearModal() { + const modal = document.createElement('div'); + modal.className = 'modal-overlay'; + modal.innerHTML = ` + + `; + document.body.appendChild(modal); +} + +async function createSchoolYear(event) { + event.preventDefault(); + const form = event.target; + const formData = new FormData(form); + + try { + await schoolServiceRequest('/years', { + method: 'POST', + body: JSON.stringify({ + name: formData.get('name'), + start_date: formData.get('start_date'), + end_date: formData.get('end_date'), + is_current: form.querySelector('[name="is_current"]').checked + }) + }); + form.closest('.modal-overlay').remove(); + showToast('Schuljahr erstellt', 'success'); + loadSchoolYears(); + } catch (e) { + showToast('Fehler: ' + e.message, 'error'); + } +} + +// ===== HELPER: Load students for select ===== +async function loadStudentsForSelect(classId, selectId) { + try { + const data = await schoolServiceRequest(`/classes/${classId}/students`); + const select = document.getElementById(selectId); + if (!select) return; + + const currentOptions = select.innerHTML; + select.innerHTML = currentOptions + (data.students || []).map(s => + `` + ).join(''); + } catch (e) { + console.error('Error loading students for select:', e); + } +} + +// Helper: Hide all panels +function hideAllPanels() { + const panels = [ + 'panel-compare', + 'panel-tiles', + 'panel-correction', + 'panel-letters', + 'panel-messenger', + 'panel-video', + 'panel-exams', + 'panel-grades', + 'panel-gradebook', + 'panel-certificates', + 'panel-classes', + 'panel-subjects' + ]; + panels.forEach(panelId => { + const panel = document.getElementById(panelId); + if (panel) { + panel.style.display = 'none'; + } + }); +} + +// Helper: Update sidebar active state +function updateSidebarActive(activeSidebarId) { + document.querySelectorAll('.sidebar-item').forEach(item => { + item.classList.remove('active'); + }); + const activeItem = document.getElementById(activeSidebarId); + if (activeItem) { + activeItem.classList.add('active'); + } +} + +// ============================================ +// JITSI VIDEOKONFERENZ MODULE +// ============================================ + +let currentJitsiMeetingUrl = null; +let jitsiMicMuted = false; +let jitsiVideoOff = false; + +// Start instant meeting +async function startInstantMeeting() { + console.log('Starting instant meeting...'); + const meetingName = document.getElementById('meeting-name')?.value || ''; + + try { + // Generate a unique room name + const roomId = 'bp-' + Date.now().toString(36) + Math.random().toString(36).substr(2, 5); + const displayName = meetingName || 'BreakPilot Meeting'; + + // Create Jitsi meeting URL + const jitsiDomain = 'meet.jit.si'; + currentJitsiMeetingUrl = `https://${jitsiDomain}/${roomId}`; + + // Show the Jitsi iframe + const placeholder = document.getElementById('jitsi-placeholder'); + const iframeContainer = document.getElementById('jitsi-iframe-container'); + const controls = document.getElementById('jitsi-controls'); + + if (placeholder) placeholder.style.display = 'none'; + if (iframeContainer) { + iframeContainer.style.display = 'flex'; + iframeContainer.innerHTML = ` + + `; + } + if (controls) controls.style.display = 'flex'; + + // Update status + const statusPill = document.getElementById('jitsi-connection-status'); + if (statusPill) { + statusPill.textContent = 'Live'; + statusPill.style.background = '#ef4444'; + } + + // Update participants list + const participantsList = document.getElementById('jitsi-participants'); + if (participantsList) { + participantsList.innerHTML = ` +
    +
    L
    +
    +
    Sie (Lehrer)
    +
    Moderator
    +
    +
    + `; + } + + console.log('Meeting started:', currentJitsiMeetingUrl); + } catch (error) { + console.error('Error starting meeting:', error); + alert('Fehler beim Starten des Meetings: ' + error.message); + } +} + +// Join a scheduled meeting +function joinScheduledMeeting(meetingId) { + console.log('Joining scheduled meeting:', meetingId); + // For demo, just start a meeting with the ID as room name + document.getElementById('meeting-name').value = meetingId; + startInstantMeeting(); +} + +// Schedule a meeting +function scheduleMeeting() { + const title = document.getElementById('schedule-title')?.value; + const datetime = document.getElementById('schedule-datetime')?.value; + const participants = document.getElementById('schedule-participants')?.value; + + if (!title || !datetime) { + alert('Bitte Titel und Datum/Uhrzeit eingeben'); + return; + } + + console.log('Scheduling meeting:', { title, datetime, participants }); + + // Add to scheduled meetings list (demo) + const scheduledList = document.getElementById('scheduled-meetings'); + if (scheduledList) { + const dateObj = new Date(datetime); + const formattedTime = dateObj.toLocaleString('de-DE', { + weekday: 'short', + hour: '2-digit', + minute: '2-digit' + }); + + const meetingItem = document.createElement('div'); + meetingItem.className = 'meeting-item'; + meetingItem.onclick = () => joinScheduledMeeting('m-' + Date.now()); + meetingItem.innerHTML = ` +
    +
    ${title}
    +
    ${formattedTime}
    +
    + + `; + scheduledList.appendChild(meetingItem); + } + + // Clear form + document.getElementById('schedule-title').value = ''; + document.getElementById('schedule-datetime').value = ''; + document.getElementById('schedule-participants').value = ''; + + alert('Meeting geplant: ' + title); +} + +// Toggle mute +function toggleJitsiMute() { + jitsiMicMuted = !jitsiMicMuted; + console.log('Mic muted:', jitsiMicMuted); + // Note: With iframe approach, we can't control the Jitsi directly + // User should use Jitsi's built-in controls +} + +// Toggle video +function toggleJitsiVideo() { + jitsiVideoOff = !jitsiVideoOff; + console.log('Video off:', jitsiVideoOff); +} + +// Share screen +function shareJitsiScreen() { + console.log('Screen share requested - use Jitsi controls'); + alert('Bitte nutzen Sie die Bildschirmfreigabe-Funktion in der Jitsi-Oberfläche'); +} + +// Copy meeting link +function copyMeetingLink() { + if (currentJitsiMeetingUrl) { + navigator.clipboard.writeText(currentJitsiMeetingUrl).then(() => { + alert('Meeting-Link kopiert: ' + currentJitsiMeetingUrl); + }).catch(err => { + console.error('Failed to copy:', err); + prompt('Meeting-Link:', currentJitsiMeetingUrl); + }); + } else { + alert('Kein aktives Meeting'); + } +} + +// Leave meeting +function leaveJitsiMeeting() { + console.log('Leaving meeting...'); + + const placeholder = document.getElementById('jitsi-placeholder'); + const iframeContainer = document.getElementById('jitsi-iframe-container'); + const controls = document.getElementById('jitsi-controls'); + + if (iframeContainer) { + iframeContainer.innerHTML = ''; + iframeContainer.style.display = 'none'; + } + if (placeholder) placeholder.style.display = 'flex'; + if (controls) controls.style.display = 'none'; + + // Reset status + const statusPill = document.getElementById('jitsi-connection-status'); + if (statusPill) { + statusPill.textContent = 'Bereit'; + statusPill.style.background = '#10b981'; + } + + // Reset participants + const participantsList = document.getElementById('jitsi-participants'); + if (participantsList) { + participantsList.innerHTML = 'Keine aktive Konferenz'; + } + + currentJitsiMeetingUrl = null; +} + +// ============================================ +// MATRIX MESSENGER MODULE (Stubs) +// ============================================ + +// Quick meeting from messenger panel +function startQuickMeeting() { + showVideoPanel(); + setTimeout(() => startInstantMeeting(), 100); +} + +// Create class room +function createClassRoom() { + console.log('Creating class room...'); + alert('Klassen-Info-Raum wird erstellt...\n\nDiese Funktion wird mit der Matrix-Integration verfügbar sein.'); +} + +// Schedule parent meeting +function scheduleParentMeeting() { + showVideoPanel(); + // Focus on the schedule form + setTimeout(() => { + document.getElementById('schedule-title')?.focus(); + }, 100); +} + +// Select a room +function selectRoom(roomId) { + console.log('Selecting room:', roomId); + // Remove active from all rooms + document.querySelectorAll('.room-item').forEach(item => { + item.classList.remove('active'); + }); + // Add active to clicked room + event.currentTarget.classList.add('active'); +} + +// Send message (from messenger panel) +function sendMessage() { + const input = document.getElementById('chat-message-input'); + if (input && input.value.trim()) { + console.log('Sending message:', input.value); + // Add message to chat (demo) + const chatContainer = document.querySelector('#panel-messenger .chat-messages-container'); + if (chatContainer) { + const msgDiv = document.createElement('div'); + msgDiv.className = 'chat-message sent'; + msgDiv.innerHTML = ` +
    ${input.value}
    +
    ${new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
    + `; + chatContainer.appendChild(msgDiv); + chatContainer.scrollTop = chatContainer.scrollHeight; + } + input.value = ''; + } +} + +// ============================================ +// KLAUSUR-KORREKTUR (Exam Correction) Module +// ============================================ + +let currentExamJob = null; +let examUploadedFiles = []; + +// Handle exam file upload +function handleExamUpload(event) { + const files = event.target.files; + if (!files.length) return; + + examUploadedFiles = Array.from(files); + console.log(`${examUploadedFiles.length} Dateien für Klausur-Korrektur ausgewählt`); + + // Update UI + const uploadArea = document.querySelector('.correction-upload-area'); + if (uploadArea) { + uploadArea.innerHTML = ` +
    +

    ${examUploadedFiles.length} Datei(en) ausgewählt

    +

    ${examUploadedFiles.map(f => f.name).join(', ')}

    + + `; + } + + // Enable start button + const startBtn = document.getElementById('start-correction-btn'); + if (startBtn) { + startBtn.disabled = false; + } +} + +// Start correction job +async function startCorrectionJob() { + if (!examUploadedFiles.length) { + alert('Bitte wähle zuerst Dateien aus.'); + return; + } + + const subject = document.getElementById('exam-subject')?.value || 'unbekannt'; + const className = document.getElementById('exam-class')?.value || ''; + const title = document.getElementById('exam-title')?.value || 'Klausur'; + + console.log('Starte Korrektur-Job:', { subject, className, title, files: examUploadedFiles.length }); + + // Show pipeline progress + updatePipelineStep('upload', 'active'); + + // TODO: Implement actual API call to exam-service + // For now, simulate progress + simulateCorrectionPipeline(); +} + +// Simulate correction pipeline (placeholder) +function simulateCorrectionPipeline() { + const steps = ['upload', 'preprocess', 'ocr', 'segment', 'grade', 'review']; + let currentStep = 0; + + const interval = setInterval(() => { + if (currentStep > 0) { + updatePipelineStep(steps[currentStep - 1], 'completed'); + } + if (currentStep < steps.length) { + updatePipelineStep(steps[currentStep], 'active'); + currentStep++; + } else { + clearInterval(interval); + showCorrectionResults(); + } + }, 1500); +} + +// Update pipeline step visualization +function updatePipelineStep(stepId, status) { + const step = document.querySelector(`[data-step="${stepId}"]`); + if (!step) return; + + step.classList.remove('active', 'completed'); + if (status === 'active') { + step.classList.add('active'); + step.style.background = '#3b82f6'; + step.style.color = 'white'; + } else if (status === 'completed') { + step.classList.add('completed'); + step.style.background = '#10b981'; + step.style.color = 'white'; + } +} + +// Show correction results (placeholder) +function showCorrectionResults() { + const resultsPanel = document.querySelector('.correction-results'); + if (resultsPanel) { + resultsPanel.innerHTML = ` +
    +
    +

    Korrektur abgeschlossen

    +

    + Die OCR-Erkennung und automatische Bewertung wurde durchgeführt. +

    +

    + Bitte überprüfe die Ergebnisse im Review-Bereich. +

    + +
    + `; + } +} + +// Show review interface (placeholder) +function showReviewInterface() { + console.log('Review-Interface wird geladen...'); + // TODO: Implement actual review interface +} + +// Export correction results +function exportCorrectionResults(format) { + console.log(`Exportiere Ergebnisse als ${format}...`); + // TODO: Implement export functionality + alert(`Export als ${format} wird in Phase 2 implementiert.`); +} + +// ============================================ +// LERNMATERIAL (Learning Material) Module +// ============================================ + +let learningSourceDocument = null; + +// Generate learning material +async function generateLearningMaterial(type) { + console.log(`Generiere Lernmaterial: ${type}`); + // TODO: Implement actual generation + alert(`${type} wird in einer späteren Phase implementiert.`); +} + +// ============================================ +// ELTERNBRIEFE (Parent Letters) Module +// ============================================ + +// Generate parent letter +async function generateParentLetter(template) { + console.log(`Generiere Elternbrief mit Template: ${template}`); + // TODO: Implement actual generation + alert('Elternbriefe werden in einer späteren Phase implementiert.'); +} + +// Check Matrix and Jitsi service status +async function checkCommunicationStatus() { + try { + const response = await fetch(`${COMM_API_BASE}/status`); + const status = await response.json(); + + // Update Matrix status + const matrixStatus = document.getElementById('matrix-status'); + if (status.matrix && status.matrix.healthy) { + matrixStatus.innerHTML = '● Online'; + matrixStatus.style.color = '#10b981'; + } else { + matrixStatus.innerHTML = '● Offline'; + matrixStatus.style.color = '#ef4444'; + } + + // Update Jitsi status + const jitsiStatus = document.getElementById('jitsi-status'); + if (status.jitsi && status.jitsi.healthy) { + jitsiStatus.innerHTML = '● Bereit'; + jitsiStatus.style.color = '#10b981'; + } else { + jitsiStatus.innerHTML = '● Offline'; + jitsiStatus.style.color = '#ef4444'; + } + + // Update main status pill + const statusPill = document.getElementById('comm-status-pill'); + if ((status.matrix && status.matrix.healthy) || (status.jitsi && status.jitsi.healthy)) { + statusPill.innerHTML = 'Verbunden'; + statusPill.style.background = '#10b981'; + } else { + statusPill.innerHTML = 'Offline'; + statusPill.style.background = '#ef4444'; + } + } catch (error) { + console.error('Failed to check communication status:', error); + document.getElementById('matrix-status').innerHTML = '● Fehler'; + document.getElementById('jitsi-status').innerHTML = '● Fehler'; + } +} + +// Room Selection +function selectRoom(roomId) { + currentRoom = roomId; + + // Update active state in room list + document.querySelectorAll('.room-item').forEach(item => { + item.classList.remove('active'); + }); + event.currentTarget.classList.add('active'); + + // Update room header + const roomName = event.currentTarget.querySelector('.room-name').innerText; + document.getElementById('current-room-name').innerText = roomName; + + // TODO: Load room messages from Matrix + console.log('Selected room:', roomId); +} + +// Start Quick Video Meeting +async function startQuickMeeting() { + try { + const displayName = 'Lehrer'; // TODO: Get from logged in user + + const response = await fetch(`${COMM_API_BASE}/meetings`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token') || ''}` + }, + body: JSON.stringify({ + type: 'quick', + display_name: displayName + }) + }); + + if (!response.ok) { + // Fallback: Open Jitsi directly + const roomName = 'breakpilot-' + Math.random().toString(36).substring(7); + openJitsiMeeting(roomName, displayName); + return; + } + + const meeting = await response.json(); + openJitsiMeeting(meeting.room_name, displayName); + } catch (error) { + console.error('Failed to create meeting:', error); + // Fallback + const roomName = 'breakpilot-' + Math.random().toString(36).substring(7); + openJitsiMeeting(roomName, 'Lehrer'); + } +} + +// Open Jitsi Meeting in embedded view +function openJitsiMeeting(roomName, displayName) { + // Show video panel + document.getElementById('video-panel').style.display = 'flex'; + document.getElementById('info-panel').style.display = 'none'; + + const container = document.getElementById('jitsi-container'); + container.innerHTML = ''; + + // Create iframe + const iframe = document.createElement('iframe'); + iframe.src = `${JITSI_BASE_URL}/${roomName}#userInfo.displayName="${encodeURIComponent(displayName)}"&config.startWithAudioMuted=false&config.startWithVideoMuted=false`; + iframe.style.width = '100%'; + iframe.style.height = '100%'; + iframe.style.border = 'none'; + iframe.allow = 'camera; microphone; fullscreen; display-capture; autoplay'; + + container.appendChild(iframe); + + console.log('Opened Jitsi meeting:', roomName); +} + +// Close Video +function closeVideo() { + document.getElementById('video-panel').style.display = 'none'; + document.getElementById('info-panel').style.display = 'block'; + document.getElementById('jitsi-container').innerHTML = ''; +} + +// Start Video Call from room +function startVideoCall() { + if (currentRoom) { + openJitsiMeeting('elternkanal-' + currentRoom, 'Lehrer'); + } else { + startQuickMeeting(); + } +} + +// Toggle Mute (placeholder) +function toggleMute() { + console.log('Toggle mute'); +} + +// Toggle Video (placeholder) +function toggleVideo() { + console.log('Toggle video'); +} + +// Leave Call +function leaveCall() { + closeVideo(); +} + +// Create Class Info Room +async function createClassRoom() { + const className = prompt('Klassenname eingeben (z.B. 7a):'); + if (!className) return; + + try { + const response = await fetch(`${COMM_API_BASE}/rooms`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token') || ''}` + }, + body: JSON.stringify({ + type: 'class_info', + class_name: className, + school_name: 'BreakPilot Schule', + teacher_ids: [] + }) + }); + + if (response.ok) { + const room = await response.json(); + alert(`Klassen-Info-Raum erstellt!\nRoom ID: ${room.room_id}`); + // TODO: Refresh room list + } else { + alert('Raum konnte nicht erstellt werden. Ist Matrix konfiguriert?'); + } + } catch (error) { + console.error('Failed to create room:', error); + alert('Fehler beim Erstellen des Raums'); + } +} + +// Schedule Parent Meeting +function scheduleParentMeeting() { + const studentName = prompt('Name des Schülers:'); + if (!studentName) return; + + const parentName = prompt('Name der Eltern:'); + if (!parentName) return; + + // For now, just create a quick meeting + const roomName = `elterngespraech-${studentName.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}`; + const meetingUrl = `${JITSI_BASE_URL}/${roomName}`; + + alert(`Elterngespräch geplant!\n\nSchüler: ${studentName}\nEltern: ${parentName}\n\nMeeting-Link:\n${meetingUrl}`); +} + +// Send Message +function sendMessage() { + const input = document.getElementById('chat-message-input'); + const message = input.value.trim(); + + if (!message || !currentRoom) { + return; + } + + // TODO: Send via Matrix API + console.log('Sending message:', message, 'to room:', currentRoom); + + // Add to UI (demo) + const chatMessages = document.getElementById('chat-messages'); + const msgDiv = document.createElement('div'); + msgDiv.className = 'chat-msg chat-msg-self'; + msgDiv.innerHTML = ` +
    + Sie + Jetzt +
    +
    ${message}
    + `; + chatMessages.appendChild(msgDiv); + chatMessages.scrollTop = chatMessages.scrollHeight; + + input.value = ''; +} + +// Attach File (placeholder) +function attachFile() { + alert('Datei-Upload wird in einer zukünftigen Version unterstützt.'); +} + +// Show Room Info (placeholder) +function showRoomInfo() { + alert(`Raum: ${currentRoom || 'Nicht ausgewählt'}\n\nWeitere Raum-Informationen folgen.`); +} + +// Open Notification Dialog +function openNotificationDialog() { + const type = document.getElementById('notification-type').value; + + if (type === 'announcement') { + const title = prompt('Titel der Ankündigung:'); + if (!title) return; + const content = prompt('Inhalt:'); + if (!content) return; + alert(`Ankündigung gesendet:\n\n${title}\n${content}`); + } else if (type === 'absence') { + const student = prompt('Name des Schülers:'); + if (!student) return; + const lesson = prompt('Unterrichtsstunde (1-10):'); + alert(`Abwesenheitsmeldung für ${student} in Stunde ${lesson} gesendet.`); + } else if (type === 'grade') { + const student = prompt('Name des Schülers:'); + if (!student) return; + const subject = prompt('Fach:'); + const grade = prompt('Note:'); + alert(`Notenbenachrichtigung für ${student}: ${subject} = ${grade} gesendet.`); + } +} + +// Initialize sidebar click handlers +document.addEventListener('DOMContentLoaded', function() { + // Studio panel click handler + const sidebarStudio = document.getElementById('sidebar-studio'); + if (sidebarStudio) { + sidebarStudio.addEventListener('click', function(e) { + e.preventDefault(); + showStudioPanel(); + }); + } + + // Communication panel click handler + const sidebarComm = document.getElementById('sidebar-communication'); + if (sidebarComm) { + sidebarComm.addEventListener('click', function(e) { + e.preventDefault(); + showCommunicationPanel(); + }); + } + + // Enter key in chat input + const chatInput = document.getElementById('chat-message-input'); + if (chatInput) { + chatInput.addEventListener('keypress', function(e) { + if (e.key === 'Enter') { + sendMessage(); + } + }); + } +}); \ No newline at end of file diff --git a/backend/frontend/static/manifest.json b/backend/frontend/static/manifest.json new file mode 100644 index 0000000..44f2e4e --- /dev/null +++ b/backend/frontend/static/manifest.json @@ -0,0 +1,124 @@ +{ + "name": "BreakPilot - Digitaler Lehrerassistent", + "short_name": "BreakPilot", + "description": "DSGVO-konforme KI-Unterstuetzung fuer Lehrkraefte", + "start_url": "/app", + "display": "standalone", + "background_color": "#0a0a0a", + "theme_color": "#6366f1", + "orientation": "any", + "scope": "/", + "lang": "de", + "categories": ["education", "productivity"], + "icons": [ + { + "src": "/static/icons/icon-72.png", + "sizes": "72x72", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-96.png", + "sizes": "96x96", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-128.png", + "sizes": "128x128", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-144.png", + "sizes": "144x144", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-152.png", + "sizes": "152x152", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-384.png", + "sizes": "384x384", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "screenshots": [ + { + "src": "/static/screenshots/dashboard.png", + "sizes": "1280x720", + "type": "image/png", + "form_factor": "wide", + "label": "Dashboard mit Moduluebersicht" + }, + { + "src": "/static/screenshots/klausur.png", + "sizes": "1280x720", + "type": "image/png", + "form_factor": "wide", + "label": "KI-gestuetzte Klausurkorrektur" + } + ], + "shortcuts": [ + { + "name": "Neue Korrektur", + "short_name": "Korrektur", + "description": "Starte eine neue Klausurkorrektur", + "url": "/app?module=klausur-korrektur", + "icons": [{ "src": "/static/icons/shortcut-correction.png", "sizes": "96x96" }] + }, + { + "name": "Unterricht starten", + "short_name": "Unterricht", + "description": "Starte den Unterrichtsbegleiter", + "url": "/app?module=companion", + "icons": [{ "src": "/static/icons/shortcut-lesson.png", "sizes": "96x96" }] + } + ], + "share_target": { + "action": "/app/share", + "method": "POST", + "enctype": "multipart/form-data", + "params": { + "title": "title", + "text": "text", + "url": "url", + "files": [ + { + "name": "files", + "accept": ["image/*", "application/pdf"] + } + ] + } + }, + "file_handlers": [ + { + "action": "/app/open", + "accept": { + "application/pdf": [".pdf"], + "image/*": [".png", ".jpg", ".jpeg"] + } + } + ], + "handle_links": "preferred", + "launch_handler": { + "client_mode": "navigate-existing" + } +} diff --git a/backend/frontend/static/service-worker.js b/backend/frontend/static/service-worker.js new file mode 100644 index 0000000..4a6804e --- /dev/null +++ b/backend/frontend/static/service-worker.js @@ -0,0 +1,343 @@ +/** + * BreakPilot PWA Service Worker + * + * Funktionen: + * 1. Cacht statische Assets fuer Offline-Nutzung + * 2. Cacht LLM-Modell fuer lokale Header-Extraktion + * 3. Background Sync fuer Korrektur-Uploads + */ + +const APP_CACHE = 'breakpilot-app-v1'; +const LLM_CACHE = 'breakpilot-llm-v1'; +const DATA_CACHE = 'breakpilot-data-v1'; + +// Statische Assets zum Cachen beim Install +const STATIC_ASSETS = [ + '/', + '/app', + '/static/css/main.css', + '/static/js/main.js' +]; + +// LLM/OCR Assets (groessere Dateien, separater Cache) +const LLM_ASSETS = [ + // Tesseract.js (OCR) + 'https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js', + 'https://cdn.jsdelivr.net/npm/tesseract.js-core@5.0.0/tesseract-core.wasm.js', + + // Tesseract Deutsch Sprachpaket + 'https://tessdata.projectnaptha.com/4.0.0/deu.traineddata.gz', + + // Transformers.js (fuer zukuenftige Vision-Modelle) + 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.1/dist/transformers.min.js' +]; + +// ============================================================ +// INSTALL EVENT +// ============================================================ + +self.addEventListener('install', (event) => { + console.log('[ServiceWorker] Installing...'); + + event.waitUntil( + Promise.all([ + // App Cache + caches.open(APP_CACHE).then((cache) => { + console.log('[ServiceWorker] Caching app shell'); + return cache.addAll(STATIC_ASSETS); + }), + + // LLM Cache (optional, bei Fehler ignorieren) + caches.open(LLM_CACHE).then((cache) => { + console.log('[ServiceWorker] Pre-caching LLM assets'); + return Promise.allSettled( + LLM_ASSETS.map(url => + cache.add(url).catch(err => { + console.warn('[ServiceWorker] Failed to cache:', url, err); + }) + ) + ); + }) + ]).then(() => { + console.log('[ServiceWorker] Install complete'); + return self.skipWaiting(); + }) + ); +}); + +// ============================================================ +// ACTIVATE EVENT +// ============================================================ + +self.addEventListener('activate', (event) => { + console.log('[ServiceWorker] Activating...'); + + event.waitUntil( + // Alte Caches aufräumen + caches.keys().then((cacheNames) => { + return Promise.all( + cacheNames + .filter(name => { + // Nur alte Versionen loeschen + return name.startsWith('breakpilot-') && + ![APP_CACHE, LLM_CACHE, DATA_CACHE].includes(name); + }) + .map(name => { + console.log('[ServiceWorker] Deleting old cache:', name); + return caches.delete(name); + }) + ); + }).then(() => { + console.log('[ServiceWorker] Claiming clients'); + return self.clients.claim(); + }) + ); +}); + +// ============================================================ +// FETCH EVENT +// ============================================================ + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url); + + // API-Requests: Network-First (immer frische Daten) + if (url.pathname.startsWith('/api/')) { + event.respondWith(networkFirstStrategy(event.request)); + return; + } + + // LLM/OCR Assets: Cache-First (grosse Dateien) + if (isLLMAsset(url)) { + event.respondWith(cacheFirstStrategy(event.request, LLM_CACHE)); + return; + } + + // Statische Assets: Cache-First mit Network-Fallback + if (isStaticAsset(url)) { + event.respondWith(cacheFirstStrategy(event.request, APP_CACHE)); + return; + } + + // Alles andere: Network-First + event.respondWith(networkFirstStrategy(event.request)); +}); + +// ============================================================ +// CACHING STRATEGIES +// ============================================================ + +/** + * Cache-First Strategy: Versucht Cache, dann Network. + * Gut fuer statische Assets und LLM-Modelle. + */ +async function cacheFirstStrategy(request, cacheName) { + const cache = await caches.open(cacheName); + const cachedResponse = await cache.match(request); + + if (cachedResponse) { + // Im Hintergrund aktualisieren (Stale-While-Revalidate) + fetchAndCache(request, cache); + return cachedResponse; + } + + // Nicht im Cache: Von Network holen und cachen + try { + const networkResponse = await fetch(request); + if (networkResponse.ok) { + cache.put(request, networkResponse.clone()); + } + return networkResponse; + } catch (error) { + console.error('[ServiceWorker] Fetch failed:', error); + return new Response('Offline - Bitte Internetverbindung pruefen', { + status: 503, + statusText: 'Service Unavailable' + }); + } +} + +/** + * Network-First Strategy: Versucht Network, dann Cache. + * Gut fuer API-Calls und dynamische Inhalte. + */ +async function networkFirstStrategy(request) { + const cache = await caches.open(DATA_CACHE); + + try { + const networkResponse = await fetch(request); + + // Erfolgreiche GET-Requests cachen + if (networkResponse.ok && request.method === 'GET') { + cache.put(request, networkResponse.clone()); + } + + return networkResponse; + + } catch (error) { + // Network failed: Versuche Cache + const cachedResponse = await cache.match(request); + if (cachedResponse) { + return cachedResponse; + } + + // Kein Cache: Offline-Fehler + return new Response(JSON.stringify({ + error: 'offline', + message: 'Keine Internetverbindung' + }), { + status: 503, + headers: { 'Content-Type': 'application/json' } + }); + } +} + +/** + * Holt Resource von Network und cached sie (im Hintergrund). + */ +async function fetchAndCache(request, cache) { + try { + const response = await fetch(request); + if (response.ok) { + cache.put(request, response.clone()); + } + } catch (error) { + // Ignorieren - Cache ist noch gueltig + } +} + +// ============================================================ +// HELPERS +// ============================================================ + +function isStaticAsset(url) { + return url.pathname.startsWith('/static/') || + url.pathname.endsWith('.css') || + url.pathname.endsWith('.js') || + url.pathname.endsWith('.png') || + url.pathname.endsWith('.jpg') || + url.pathname.endsWith('.svg') || + url.pathname.endsWith('.woff2'); +} + +function isLLMAsset(url) { + const llmDomains = [ + 'cdn.jsdelivr.net', + 'huggingface.co', + 'tessdata.projectnaptha.com' + ]; + + const llmExtensions = [ + '.onnx', + '.wasm', + '.traineddata', + '.traineddata.gz', + '.bin' + ]; + + return llmDomains.some(d => url.hostname.includes(d)) || + llmExtensions.some(e => url.pathname.endsWith(e)); +} + +// ============================================================ +// BACKGROUND SYNC (fuer Offline-Uploads) +// ============================================================ + +self.addEventListener('sync', (event) => { + console.log('[ServiceWorker] Sync event:', event.tag); + + if (event.tag === 'upload-exams') { + event.waitUntil(uploadPendingExams()); + } +}); + +async function uploadPendingExams() { + // Hole ausstehende Uploads aus IndexedDB + const db = await openDB('breakpilot-pending', 1); + const pendingUploads = await db.getAll('uploads'); + + for (const upload of pendingUploads) { + try { + const response = await fetch(upload.url, { + method: 'POST', + body: upload.formData + }); + + if (response.ok) { + await db.delete('uploads', upload.id); + + // Benachrichtigung an Client + self.clients.matchAll().then(clients => { + clients.forEach(client => { + client.postMessage({ + type: 'upload-complete', + id: upload.id + }); + }); + }); + } + } catch (error) { + console.error('[ServiceWorker] Upload failed:', error); + } + } +} + +// Simple IndexedDB wrapper +function openDB(name, version) { + return new Promise((resolve, reject) => { + const request = indexedDB.open(name, version); + request.onerror = () => reject(request.error); + request.onsuccess = () => resolve(request.result); + request.onupgradeneeded = (event) => { + const db = event.target.result; + if (!db.objectStoreNames.contains('uploads')) { + db.createObjectStore('uploads', { keyPath: 'id', autoIncrement: true }); + } + }; + }); +} + +// ============================================================ +// PUSH NOTIFICATIONS (fuer Korrektur-Fortschritt) +// ============================================================ + +self.addEventListener('push', (event) => { + const data = event.data?.json() || {}; + + const options = { + body: data.body || 'Neue Benachrichtigung', + icon: '/static/icons/icon-192.png', + badge: '/static/icons/badge-72.png', + tag: data.tag || 'default', + data: data.url || '/', + actions: data.actions || [] + }; + + event.waitUntil( + self.registration.showNotification( + data.title || 'BreakPilot', + options + ) + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + + event.waitUntil( + clients.matchAll({ type: 'window' }).then(clientList => { + // Existierendes Fenster fokussieren + for (const client of clientList) { + if (client.url.includes('/app') && 'focus' in client) { + return client.focus(); + } + } + // Neues Fenster oeffnen + if (clients.openWindow) { + return clients.openWindow(event.notification.data || '/app'); + } + }) + ); +}); + +console.log('[ServiceWorker] Loaded'); diff --git a/backend/frontend/studio.py b/backend/frontend/studio.py new file mode 100644 index 0000000..4403808 --- /dev/null +++ b/backend/frontend/studio.py @@ -0,0 +1,39 @@ +""" +BreakPilot Studio Frontend + +Dieses Modul serviert das Studio-Frontend aus der modularen Architektur. + +Refactored: 2024-12-18 +- Modulare Architektur aus frontend/modules/ +- Dashboard mit Kachel-Navigation als Startansicht +- Module: Dashboard, Worksheets, Correction, Jitsi, Letters, Messenger +""" + +from fastapi import APIRouter +from fastapi.responses import HTMLResponse + +# Import der modularen Architektur +from .studio_modular import get_studio_html + +router = APIRouter() + + +@router.get("/app", response_class=HTMLResponse) +@router.get("/studio", response_class=HTMLResponse) +def app_ui(): + """ + Rendert das BreakPilot Studio Frontend. + + Das HTML wird dynamisch aus den Modulen generiert: + - BaseLayoutModule: TopBar, Sidebar, Theme Toggle + - DashboardModule: Start-Ansicht mit Kacheln + - WorksheetsModule: Arbeitsblaetter Studio + - CorrectionModule: Klausurkorrektur + - JitsiModule: Videokonferenzen + - LettersModule: Elternbriefe + - MessengerModule: Matrix Messenger + - SchoolModule: Schulverwaltung + - ContentCreatorModule: Content Creator (H5P Integration) + - ContentFeedModule: Content Discovery & Rating + """ + return get_studio_html() diff --git a/backend/frontend/studio.py.backup b/backend/frontend/studio.py.backup new file mode 100644 index 0000000..7081b01 --- /dev/null +++ b/backend/frontend/studio.py.backup @@ -0,0 +1,11703 @@ +from fastapi import APIRouter +from fastapi.responses import HTMLResponse + +router = APIRouter() + + +@router.get("/app", response_class=HTMLResponse) +def app_ui(): + return """ + + + + + BreakPilot – Arbeitsblatt Studio + + + + +
    +
    +
    + +
    +
    BreakPilot
    +
    Studio
    +
    +
    + +
    +
    + +
    + + + + + +
    + + +
    +
    + Benachrichtigungen +
    + + +
    +
    +
    +
    +
    🔔
    +
    Keine Benachrichtigungen
    +
    +
    + +
    +
    + +
    + +
    +
    +
    Benutzer
    +
    user@example.com
    +
    + + + +
    +
    +
    MVP · Lokal auf deinem Mac
    +
    +
    + +
    + + +
    + +
    +
    +
    +
    Arbeitsblätter & Vergleich
    +
    Links Scan · Rechts neu aufgebautes Arbeitsblatt
    +
    Keine Lerneinheit ausgewählt
    +
    +
    + + 0 Dateien +
    +
    + +
    +
      + +
      + +
      + +
      +
      + Lade Arbeitsblätter hoch und klicke auf „Arbeitsblätter neu aufbauen".
      + Dann kannst du mit den Pfeilen durch die Scans und die bereinigten Versionen blättern.
      + Mit Doppelklick öffnest du das Dokument im Vollbild. +
      +
      +
      +
      + + + +
      + +
      + + 1 von 2 + +
      + +
      +
      +
      +
      +
      +
      +
      +
      + + + + + + + + + + + + + + + + + + +
      +
      +
      +

      ⚙️ Consent Admin Panel

      + +
      +
      + + + + + + +
      +
      + +
      +
      +
      + +
      + +
      + + + + +
      +
      Lade Dokumente...
      +
      +
      + + +
      +
      +
      + +
      + +
      + +
      +

      Neue Version erstellen

      + +
      +
      + + +
      +
      + + +
      +
      +
      +
      + + +
      +
      +
      +
      + + +
      +
      +
      +
      + +
      +
      +
      + + + +
      +
      + + + + +
      +
      + + +
      +
      + + + +
      +
      + + +
      +
      +
      +
      + 0 Zeichen | + Tipp: Sie können direkt aus Word kopieren und einfügen! +
      +
      + +
      +
      +
      + + +
      +
      + +
      +
      Wählen Sie ein Dokument aus, um dessen Versionen anzuzeigen.
      +
      + + +
      +
      +

      Version genehmigen

      +

      + Legen Sie einen Veröffentlichungszeitpunkt fest. Die Version wird automatisch zum gewählten Zeitpunkt veröffentlicht. +

      +
      +
      + + +
      +
      + + +
      +
      +
      +
      + + +
      +
      +
      + + +
      +
      +
      +
      + + +
      +
      +

      Versionsvergleich

      +
      + + vs + +
      + +
      +
      +
      +
      + Veröffentlichte Version + +
      +
      +
      +
      +
      + Neue Version + +
      +
      +
      +
      + +
      + + +
      +
      +
      + Cookie-Kategorien verwalten +
      + +
      + + + + +
      + + +
      +
      +
      Lade Statistiken...
      +
      +
      + + +
      +
      +
      + +
      + + +
      + + + + + + + + +
      +
      Wählen Sie eine E-Mail-Vorlage aus, um deren Versionen anzuzeigen.
      +
      + + + + + + +
      + + +
      +
      +
      + Dezentrales Speichersystem (IPFS) +
      +
      + + +
      +
      + + +
      +
      Lade DSMS Status...
      +
      + + +
      + + + +
      + + +
      +
      +
      + +
      + +
      + + + + +
      +
      Lade archivierte Dokumente...
      +
      +
      + + + + + + +
      +
      +
      +
      + + + + + + + + + + + +
      +
      +
      +

      🔐 Anmeldung

      + +
      +
      + + +
      +
      + +
      +
      +
      +
      +
      + + +
      +
      + + +
      + +
      + +
      + + +
      +
      +
      +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      + +
      + +
      + + +
      +
      +
      +

      + Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen einen Link zum Zurücksetzen Ihres Passworts. +

      +
      +
      + + +
      + +
      + +
      + + +
      +
      +
      +
      +
      + + +
      +
      + + +
      + + +
      +
      + + +
      +
      +
      +
      +
      + E-Mail wird verifiziert... +
      +
      +
      +
      +
      +
      + + +
      +
      +
      +

      Benachrichtigungseinstellungen

      + +
      +
      +
      +
      +
      E-Mail-Benachrichtigungen
      +
      Wichtige Updates per E-Mail erhalten
      +
      +
      +
      +
      +
      +
      +
      +
      In-App-Benachrichtigungen
      +
      Benachrichtigungen in der App anzeigen
      +
      +
      +
      +
      +
      +
      +
      +
      Push-Benachrichtigungen
      +
      Browser-Push-Benachrichtigungen aktivieren
      +
      +
      +
      +
      +
      +
      + +
      +
      +
      +
      + + +
      +
      +
      🚫
      +
      Account vorübergehend gesperrt
      +
      + Ihr Account wurde gesperrt, da ausstehende Zustimmungen nicht innerhalb der Frist erteilt wurden. + Bitte bestätigen Sie die folgenden Dokumente, um Ihren Account wiederherzustellen. +
      +
      +
      Ausstehende Dokumente:
      +
      + +
      +
      + +
      +
      +
      + + +
      + + + """ + diff --git a/backend/frontend/studio.py.monolithic.bak b/backend/frontend/studio.py.monolithic.bak new file mode 100644 index 0000000..d00bfd0 --- /dev/null +++ b/backend/frontend/studio.py.monolithic.bak @@ -0,0 +1,12524 @@ +from fastapi import APIRouter +from fastapi.responses import HTMLResponse + +router = APIRouter() + + +@router.get("/app", response_class=HTMLResponse) +def app_ui(): + return """ + + + + + BreakPilot – Arbeitsblatt Studio + + + + +
      +
      +
      + +
      +
      BreakPilot
      +
      Studio
      +
      +
      + +
      +
      + +
      + + + + + +
      + + +
      +
      + Benachrichtigungen +
      + + +
      +
      +
      +
      +
      🔔
      +
      Keine Benachrichtigungen
      +
      +
      + +
      +
      + +
      + +
      +
      +
      Benutzer
      +
      user@example.com
      +
      + + + +
      +
      +
      MVP · Lokal auf deinem Mac
      +
      +
      + +
      + + +
      + +
      +
      +
      +
      Arbeitsblätter & Vergleich
      +
      Links Scan · Rechts neu aufgebautes Arbeitsblatt
      +
      Keine Lerneinheit ausgewählt
      +
      +
      + + 0 Dateien +
      +
      + +
      +
        + +
        + +
        + +
        +
        + Lade Arbeitsblätter hoch und klicke auf „Arbeitsblätter neu aufbauen".
        + Dann kannst du mit den Pfeilen durch die Scans und die bereinigten Versionen blättern.
        + Mit Doppelklick öffnest du das Dokument im Vollbild. +
        +
        +
        +
        + + + +
        + +
        + + 1 von 2 + +
        + +
        +
        +
        +
        +
        +
        +
        +
        + + + + + + + + + + + + + + + + + + +
        +
        +
        +

        ⚙️ Consent Admin Panel

        + +
        +
        + + + + + + + +
        +
        + +
        +
        +
        + +
        + +
        + + + + +
        +
        Lade Dokumente...
        +
        +
        + + +
        +
        +
        + +
        + +
        + +
        +

        Neue Version erstellen

        + +
        +
        + + +
        +
        + + +
        +
        +
        +
        + + +
        +
        +
        +
        + + +
        +
        +
        +
        + +
        +
        +
        + + + +
        +
        + + + + +
        +
        + + +
        +
        + + + +
        +
        + + +
        +
        +
        +
        + 0 Zeichen | + Tipp: Sie können direkt aus Word kopieren und einfügen! +
        +
        + +
        +
        +
        + + +
        +
        + +
        +
        Wählen Sie ein Dokument aus, um dessen Versionen anzuzeigen.
        +
        + + +
        +
        +

        Version genehmigen

        +

        + Legen Sie einen Veröffentlichungszeitpunkt fest. Die Version wird automatisch zum gewählten Zeitpunkt veröffentlicht. +

        +
        +
        + + +
        +
        + + +
        +
        +
        +
        + + +
        +
        +
        + + +
        +
        +
        +
        + + +
        +
        +

        Versionsvergleich

        +
        + + vs + +
        + +
        +
        +
        +
        + Veröffentlichte Version + +
        +
        +
        +
        +
        + Neue Version + +
        +
        +
        +
        + +
        + + +
        +
        +
        + Cookie-Kategorien verwalten +
        + +
        + + + + +
        + + +
        +
        +
        Lade Statistiken...
        +
        +
        + + +
        +
        +
        + +
        + + +
        + + + + + + + + +
        +
        Wählen Sie eine E-Mail-Vorlage aus, um deren Versionen anzuzeigen.
        +
        + + + + + + +
        + + +
        +
        +
        + + + +
        + +
        + + +
        +
        Lade Statistiken...
        +
        + + + + + +
        +
        Lade Betroffenenanfragen...
        +
        + + + +
        + + +
        +
        +
        + Dezentrales Speichersystem (IPFS) +
        +
        + + +
        +
        + + +
        +
        Lade DSMS Status...
        +
        + + +
        + + + +
        + + +
        +
        +
        + +
        + +
        + + + + +
        +
        Lade archivierte Dokumente...
        +
        +
        + + + + + + +
        +
        +
        +
        + + + + + + + + + + + +
        +
        +
        +

        🔐 Anmeldung

        + +
        +
        + + +
        +
        + +
        +
        +
        +
        +
        + + +
        +
        + + +
        + +
        + +
        + + +
        +
        +
        +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        + + +
        + +
        + +
        + + +
        +
        +
        +

        + Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen einen Link zum Zurücksetzen Ihres Passworts. +

        +
        +
        + + +
        + +
        + +
        + + +
        +
        +
        +
        +
        + + +
        +
        + + +
        + + +
        +
        + + +
        +
        +
        +
        +
        + E-Mail wird verifiziert... +
        +
        +
        +
        +
        +
        + + +
        +
        +
        +

        Benachrichtigungseinstellungen

        + +
        +
        +
        +
        +
        E-Mail-Benachrichtigungen
        +
        Wichtige Updates per E-Mail erhalten
        +
        +
        +
        +
        +
        +
        +
        +
        In-App-Benachrichtigungen
        +
        Benachrichtigungen in der App anzeigen
        +
        +
        +
        +
        +
        +
        +
        +
        Push-Benachrichtigungen
        +
        Browser-Push-Benachrichtigungen aktivieren
        +
        +
        +
        +
        +
        +
        + +
        +
        +
        +
        + + +
        +
        +
        🚫
        +
        Account vorübergehend gesperrt
        +
        + Ihr Account wurde gesperrt, da ausstehende Zustimmungen nicht innerhalb der Frist erteilt wurden. + Bitte bestätigen Sie die folgenden Dokumente, um Ihren Account wiederherzustellen. +
        +
        +
        Ausstehende Dokumente:
        +
        + +
        +
        + +
        +
        +
        + + +
        + + + """ + diff --git a/backend/frontend/studio_modular.py b/backend/frontend/studio_modular.py new file mode 100644 index 0000000..0582456 --- /dev/null +++ b/backend/frontend/studio_modular.py @@ -0,0 +1,153 @@ +""" +BreakPilot Studio - Modulare Frontend-Architektur + +Diese Datei ist der zentrale Einstiegspunkt fuer das Studio-Frontend. +Sie laedt alle Module und kombiniert sie zu einer vollstaendigen Seite. + +Architektur: +- Jedes Modul (base, jitsi, letters, worksheets, correction) liefert CSS, HTML und JS +- Diese werden hier zusammengefuegt +- Das Ergebnis ist eine vollstaendige HTML-Seite + +Module: +- BaseLayoutModule: TopBar, Sidebar, Footer, Theme Toggle, Login +- JitsiModule: Videokonferenzen (Elterngespraeche, Schulungen) +- LettersModule: Elternbriefe mit rechtssicherer Sprache +- WorksheetsModule: Lerneinheiten und Arbeitsblaetter +- CorrectionModule: Klausurkorrektur mit OCR und AI +""" + +from .modules import ( + BaseLayoutModule, + DashboardModule, + JitsiModule, + LettersModule, + WorksheetsModule, + CorrectionModule, + MessengerModule, + SchoolModule, + ContentCreatorModule, + ContentFeedModule, + KlausurKorrekturModule, + AbiturDocsAdminModule, + RbacAdminModule, + MailInboxModule, + # SecurityModule entfernt - jetzt in /dev-admin verfuegbar +) + + +def get_studio_html() -> str: + """ + Generiert die vollstaendige Studio-HTML-Seite aus allen Modulen. + + Returns: + str: Vollstaendige HTML-Seite mit allen Modulen + """ + # CSS von allen Modulen sammeln + all_css = "\n".join([ + BaseLayoutModule.get_css(), + DashboardModule.get_css(), + JitsiModule.get_css(), + LettersModule.get_css(), + WorksheetsModule.get_css(), + CorrectionModule.get_css(), + MessengerModule.get_css(), + SchoolModule.get_css(), + ContentCreatorModule.get_css(), + ContentFeedModule.get_css(), + KlausurKorrekturModule.get_css(), + AbiturDocsAdminModule.get_css(), + RbacAdminModule.get_css(), + MailInboxModule.get_css(), + # SecurityModule entfernt - jetzt in /dev-admin + ]) + + # HTML von allen Modulen sammeln (Dashboard zuerst!) + all_module_html = "\n".join([ + DashboardModule.get_html(), + JitsiModule.get_html(), + LettersModule.get_html(), + WorksheetsModule.get_html(), + CorrectionModule.get_html(), + MessengerModule.get_html(), + SchoolModule.get_html(), + ContentCreatorModule.get_html(), + ContentFeedModule.get_html(), + KlausurKorrekturModule.get_html(), + AbiturDocsAdminModule.get_html(), + RbacAdminModule.get_html(), + MailInboxModule.get_html(), + # SecurityModule entfernt - jetzt in /dev-admin + ]) + + # JavaScript von allen Modulen sammeln + all_js = "\n".join([ + BaseLayoutModule.get_js(), + DashboardModule.get_js(), + JitsiModule.get_js(), + LettersModule.get_js(), + WorksheetsModule.get_js(), + CorrectionModule.get_js(), + MessengerModule.get_js(), + SchoolModule.get_js(), + ContentCreatorModule.get_js(), + ContentFeedModule.get_js(), + KlausurKorrekturModule.get_js(), + AbiturDocsAdminModule.get_js(), + RbacAdminModule.get_js(), + MailInboxModule.get_js(), + # SecurityModule entfernt - jetzt in /dev-admin + ]) + + # Base HTML Struktur mit allen eingefuegten Modulen + base_html = BaseLayoutModule.get_html() + + # Module HTML in den main-content Bereich einfuegen + # Suche nach dem Platzhalter und ersetze ihn + if "" in base_html: + base_html = base_html.replace("", all_module_html) + else: + # Fallback: Fuege Module vor dem schliessenden main-content ein + base_html = base_html.replace( + "
      • ", + f"{all_module_html}\n
        " + ) + + # Vollstaendige HTML-Seite zusammenbauen + html = f""" + + + + + BreakPilot Studio + + + + +{base_html} + + +""" + + return html + + +def get_studio_page(): + """ + Kompatibilitaets-Wrapper fuer bestehende API. + + Returns: + str: Vollstaendige HTML-Seite + """ + return get_studio_html() + + +# Fuer direkten Import +studio_html = get_studio_html diff --git a/backend/frontend/studio_new.py b/backend/frontend/studio_new.py new file mode 100644 index 0000000..1b7cda1 --- /dev/null +++ b/backend/frontend/studio_new.py @@ -0,0 +1,145 @@ +from fastapi import APIRouter +from fastapi.responses import HTMLResponse + +from .components import ( + base, + legal_modal, + auth_modal, + admin_panel, + admin_email, + admin_dsms, + admin_stats, +) + +router = APIRouter() + + +# Include all remaining CSS that's not in components (buttons, forms, notifications, etc.) +def get_shared_css() -> str: + """CSS für gemeinsam genutzte Styles (Buttons, Forms, Inputs, Notifications, etc.)""" + # This would be extracted from the original file - keeping styles for: + # - Buttons (.btn, .btn-primary, etc.) + # - Forms (input, select, textarea) + # - Notifications + # - User Dropdown + # - Sidebar + # - Content area + # - Cards + # - Lightbox + # - etc. + + # For now, returning a placeholder - in production, this would include + # all the CSS from lines ~500-1200 of the original file + return """ + /* Shared CSS will be extracted here */ + /* This includes buttons, forms, sidebar, content area, cards, etc. */ + """ + + +# Include all remaining HTML that's not in modals (sidebar, content area, etc.) +def get_main_html() -> str: + """HTML für Haupt-Anwendungsbereich (Sidebar, Content, etc.)""" + # This would be extracted from the original file + # For now, returning a placeholder + return """ +
        +
        + +
        + +
        + + +
        + +
        +
        +
        + """ + + +# Include all remaining JavaScript (i18n, initialization, etc.) +def get_shared_js() -> str: + """JavaScript für gemeinsam genutzte Funktionen (i18n, init, etc.)""" + return """ + /* Shared JavaScript will be extracted here */ + /* This includes i18n, notifications, initialization, etc. */ + """ + + +@router.get("/app", response_class=HTMLResponse) +def app_ui(): + """ + Refactored Studio UI - Modulare Komponenten-Architektur + + Die monolithische studio.py (11.703 Zeilen) wurde in 7 Komponenten aufgeteilt: + + - components/base.py: CSS Variables, Base Styles, Theme Toggle (~300 Zeilen) + - components/legal_modal.py: Legal/Consent Modal (~1.200 Zeilen) + - components/auth_modal.py: Auth/Login/Register Modal (~1.500 Zeilen) + - components/admin_panel.py: Admin Panel Core (~3.000 Zeilen) + - components/admin_email.py: E-Mail Template Management (~1.000 Zeilen) + - components/admin_dsms.py: DSMS/IPFS WebUI (~1.500 Zeilen) + - components/admin_stats.py: Statistics & GDPR Export (~700 Zeilen) + + Diese Datei orchestriert die Komponenten und fügt sie zusammen. + """ + + return f""" + + + + + BreakPilot – Arbeitsblatt Studio + + + + + {get_main_html()} + + + {legal_modal.get_legal_modal_html()} + + + {auth_modal.get_auth_modal_html()} + + + {admin_panel.get_admin_panel_html()} + + + {admin_dsms.get_admin_dsms_html()} + + + + + """ diff --git a/backend/frontend/studio_refactored_demo.py b/backend/frontend/studio_refactored_demo.py new file mode 100644 index 0000000..6e8e5b4 --- /dev/null +++ b/backend/frontend/studio_refactored_demo.py @@ -0,0 +1,184 @@ +from fastapi import APIRouter +from fastapi.responses import HTMLResponse + +from .components import ( + base, + legal_modal, + auth_modal, + admin_panel, + admin_email, + admin_dsms, + admin_stats, +) + +router = APIRouter() + + +@router.get("/app", response_class=HTMLResponse) +def app_ui(): + """ + Refactored Studio UI - Modulare Komponenten-Architektur + + REFACTORING ERFOLGREICH ABGESCHLOSSEN: + + Die monolithische studio.py (11.703 Zeilen) wurde in 7 Komponenten aufgeteilt: + + ✓ components/base.py - CSS Variables, Base Styles, Theme Toggle + ✓ components/legal_modal.py - Legal/Consent Modal (AGB, Datenschutz, etc.) + ✓ components/auth_modal.py - Auth/Login/Register Modal + ✓ components/admin_panel.py - Admin Panel Core (Documents, Versions) + ✓ components/admin_email.py - E-Mail Template Management + ✓ components/admin_dsms.py - DSMS/IPFS WebUI, Archive Management + ✓ components/admin_stats.py - Statistics & GDPR Export + + NÄCHSTE SCHRITTE ZUR VOLLSTÄNDIGEN INTEGRATION: + + 1. Kopiere studio.py.backup zu studio.py + 2. Ersetze den Header (Zeilen 1-7) durch die neuen Imports (siehe unten) + 3. Ändere return """ zu return f""" + 4. Ersetze die extrahierten Bereiche durch Komponenten-Aufrufe: + + CSS-Bereiche (im + + + + + +
        +
        +
        + +
        +
        BreakPilot
        +
        Studio (Refactored)
        +
        +
        +
        + + + + +
        +
        + +
        + + +
        + +

        Refactoring erfolgreich!

        +

        Die Komponenten wurden erfolgreich extrahiert.

        +
        +
        + + +
        + + + {legal_modal.get_legal_modal_html()} + + + {auth_modal.get_auth_modal_html()} + + + {admin_panel.get_admin_panel_html()} + + + {admin_dsms.get_admin_dsms_html()} + + + + + """ diff --git a/backend/frontend/teacher_units.py b/backend/frontend/teacher_units.py new file mode 100644 index 0000000..ada813c --- /dev/null +++ b/backend/frontend/teacher_units.py @@ -0,0 +1,1234 @@ +""" +Teacher Units Dashboard Frontend Module +Unit-Zuweisung und Fortschritts-Tracking fuer Lehrer +""" +from fastapi import APIRouter +from fastapi.responses import HTMLResponse + +router = APIRouter() + + +@router.get("/teacher-units", response_class=HTMLResponse) +def teacher_units_dashboard(): + """Teacher Units Dashboard - Unit Assignment and Analytics""" + return """ + + + + + BreakPilot Drive - Lehrer Dashboard + + + + + +
        + + + + +
        + +
        + + +
        +
        +
        Aktive Zuweisungen
        +
        -
        +
        +
        +
        Schueler gestartet
        +
        -
        +
        +
        +
        Abgeschlossen
        +
        -
        +
        +
        +
        Durchschn. Lernzuwachs
        +
        -
        +
        +
        + +
        +
        +

        Aktuelle Zuweisungen

        + +
        +
        +
        +
        + Lade Zuweisungen... +
        +
        +
        +
        + + +
        + + +
        +
        +
        +
        + Lade Zuweisungen... +
        +
        +
        +
        + + +
        + + +
        +
        +
        + Lade verfuegbare Units... +
        +
        +
        + + +
        + + +
        + 💡 +
        Waehlen Sie eine Zuweisung aus der Liste, um den Fortschritt einzelner Schueler zu sehen.
        +
        + +
        +
        +

        Zuweisung auswaehlen

        +
        + +
        + + +
        + + +
        + + +
        + +
        Diese Daten werden aus Pre/Post-Check Antworten und Interaktions-Telemetrie aggregiert.
        +
        + +
        +

        + Noch keine Misskonzepte erkannt. Sobald Schueler Units abschliessen, erscheinen hier haeufige Fehlvorstellungen. +

        +
        +
        + + +
        + + +
        +
        +
        + Lade Ressourcen... +
        +
        +
        + + +
        + + +
        +
        +
        + Lade H5P-Inhalte... +
        +
        +
        +
        +
        + + + + + + + + """ diff --git a/backend/frontend/templates/customer.html b/backend/frontend/templates/customer.html new file mode 100644 index 0000000..9f28d50 --- /dev/null +++ b/backend/frontend/templates/customer.html @@ -0,0 +1,317 @@ + + + + + BreakPilot - Mein Konto + + + + + + + +
        + +
        +
        + + +
        + BreakPilot + Mein Konto +
        +
        + +
        +
        + + +
        + +
        +
        +

        Willkommen bei BreakPilot

        +

        Melden Sie sich an, um Ihre Zustimmungen zu verwalten und Ihre Daten zu exportieren.

        + +
        +
        + + + +
        + + + +
        + + + + + + + + + + + + + + + + + + + diff --git a/backend/frontend/templates/studio.html b/backend/frontend/templates/studio.html new file mode 100644 index 0000000..a73b6da --- /dev/null +++ b/backend/frontend/templates/studio.html @@ -0,0 +1,2372 @@ + + + + + + BreakPilot – Arbeitsblatt Studio + + + + +
        +
        +
        + +
        +
        BreakPilot
        +
        Studio
        +
        +
        + +
        +
        + +
        + + + + +
        + + +
        +
        + Benachrichtigungen +
        + + +
        +
        +
        +
        +
        🔔
        +
        Keine Benachrichtigungen
        +
        +
        + +
        +
        + +
        + +
        +
        +
        Benutzer
        +
        user@example.com
        +
        + + + +
        +
        +
        MVP · Lokal auf deinem Mac
        +
        +
        + +
        + + +
        + +
        +
        +
        +
        Arbeitsblätter & Vergleich
        +
        Links Scan · Rechts neu aufgebautes Arbeitsblatt
        +
        Keine Lerneinheit ausgewählt
        +
        +
        + + 0 Dateien +
        +
        + +
        +
          + +
          + +
          + +
          +
          + Lade Arbeitsblätter hoch und klicke auf „Arbeitsblätter neu aufbauen".
          + Dann kannst du mit den Pfeilen durch die Scans und die bereinigten Versionen blättern.
          + Mit Doppelklick öffnest du das Dokument im Vollbild. +
          +
          +
          +
          + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
          + +
          + + 1 von 2 + +
          + +
          +
          +
          +
          +
          +
          +
          +
          + + + + + + + + + + + + + + + + + + +
          +
          +
          +

          ⚙️ Consent Admin Panel

          + +
          +
          + + + + + + + +
          +
          + +
          +
          +
          + +
          + +
          + + + + +
          +
          Lade Dokumente...
          +
          +
          + + +
          +
          +
          + +
          + +
          + +
          +

          Neue Version erstellen

          + +
          +
          + + +
          +
          + + +
          +
          +
          +
          + + +
          +
          +
          +
          + + +
          +
          +
          +
          + +
          +
          +
          + + + +
          +
          + + + + +
          +
          + + +
          +
          + + + +
          +
          + + +
          +
          +
          +
          + 0 Zeichen | + Tipp: Sie können direkt aus Word kopieren und einfügen! +
          +
          + +
          +
          +
          + + +
          +
          + +
          +
          Wählen Sie ein Dokument aus, um dessen Versionen anzuzeigen.
          +
          + + +
          +
          +

          Version genehmigen

          +

          + Legen Sie einen Veröffentlichungszeitpunkt fest. Die Version wird automatisch zum gewählten Zeitpunkt veröffentlicht. +

          +
          +
          + + +
          +
          + + +
          +
          +
          +
          + + +
          +
          +
          + + +
          +
          +
          +
          + + +
          +
          +

          Versionsvergleich

          +
          + + vs + +
          + +
          +
          +
          +
          + Veröffentlichte Version + +
          +
          +
          +
          +
          + Neue Version + +
          +
          +
          +
          + +
          + + +
          +
          +
          + Cookie-Kategorien verwalten +
          + +
          + + + + +
          + + +
          +
          +
          Lade Statistiken...
          +
          +
          + + +
          +
          +
          + +
          + + +
          + + + + + + + + +
          +
          Wählen Sie eine E-Mail-Vorlage aus, um deren Versionen anzuzeigen.
          +
          + + + + + + +
          + + +
          +
          +
          + + + +
          + +
          + + +
          +
          Lade Statistiken...
          +
          + + + + + +
          +
          Lade Betroffenenanfragen...
          +
          + + + +
          + + +
          +
          +
          + Dezentrales Speichersystem (IPFS) +
          +
          + + +
          +
          + + +
          +
          Lade DSMS Status...
          +
          + + +
          + + + +
          + + +
          +
          +
          + +
          + +
          + + + + +
          +
          Lade archivierte Dokumente...
          +
          +
          + + + + + + +
          +
          +
          +
          + + + + + + + + + + + +
          +
          +
          +

          🔐 Anmeldung

          + +
          +
          + + +
          +
          + +
          +
          +
          +
          +
          + + +
          +
          + + +
          + +
          + +
          + + +
          +
          +
          +
          +
          + + +
          +
          + + +
          +
          + + +
          +
          + + +
          + +
          + +
          + + +
          +
          +
          +

          + Geben Sie Ihre E-Mail-Adresse ein und wir senden Ihnen einen Link zum Zurücksetzen Ihres Passworts. +

          +
          +
          + + +
          + +
          + +
          + + +
          +
          +
          +
          +
          + + +
          +
          + + +
          + + +
          +
          + + +
          +
          +
          +
          +
          + E-Mail wird verifiziert... +
          +
          +
          +
          +
          +
          + + +
          +
          +
          +

          Benachrichtigungseinstellungen

          + +
          +
          +
          +
          +
          E-Mail-Benachrichtigungen
          +
          Wichtige Updates per E-Mail erhalten
          +
          +
          +
          +
          +
          +
          +
          +
          In-App-Benachrichtigungen
          +
          Benachrichtigungen in der App anzeigen
          +
          +
          +
          +
          +
          +
          +
          +
          Push-Benachrichtigungen
          +
          Browser-Push-Benachrichtigungen aktivieren
          +
          +
          +
          +
          +
          +
          + +
          +
          +
          +
          + + +
          +
          +
          🚫
          +
          Account vorübergehend gesperrt
          +
          + Ihr Account wurde gesperrt, da ausstehende Zustimmungen nicht innerhalb der Frist erteilt wurden. + Bitte bestätigen Sie die folgenden Dokumente, um Ihren Account wiederherzustellen. +
          +
          +
          Ausstehende Dokumente:
          +
          + +
          +
          + +
          +
          +
          + +
          + + + \ No newline at end of file diff --git a/backend/frontend/tests/studio-panels.test.js b/backend/frontend/tests/studio-panels.test.js new file mode 100644 index 0000000..ede5c85 --- /dev/null +++ b/backend/frontend/tests/studio-panels.test.js @@ -0,0 +1,849 @@ +/** + * Unit Tests for Studio Panel Navigation + * + * These tests verify the panel navigation functions in studio.js + * Run with: npm test (requires Jest and jsdom) + */ + +// Mock DOM elements +const mockElements = {}; + +function createMockElement(id, display = 'none') { + return { + id, + style: { display }, + classList: { + _classes: new Set(), + add(cls) { this._classes.add(cls); }, + remove(cls) { this._classes.delete(cls); }, + contains(cls) { return this._classes.has(cls); } + } + }; +} + +// Setup mock DOM before tests +function setupMockDOM() { + mockElements['panel-compare'] = createMockElement('panel-compare', 'flex'); + mockElements['panel-tiles'] = createMockElement('panel-tiles'); + mockElements['panel-messenger'] = createMockElement('panel-messenger'); + mockElements['panel-video'] = createMockElement('panel-video'); + mockElements['panel-correction'] = createMockElement('panel-correction'); + mockElements['panel-letters'] = createMockElement('panel-letters'); + mockElements['studio-sub-menu'] = createMockElement('studio-sub-menu', 'flex'); + mockElements['sub-worksheets'] = createMockElement('sub-worksheets'); + mockElements['sub-tiles'] = createMockElement('sub-tiles'); + mockElements['sidebar-studio'] = createMockElement('sidebar-studio'); + mockElements['sidebar-correction'] = createMockElement('sidebar-correction'); + mockElements['sidebar-messenger'] = createMockElement('sidebar-messenger'); + mockElements['sidebar-video'] = createMockElement('sidebar-video'); + mockElements['sidebar-letters'] = createMockElement('sidebar-letters'); + + global.document = { + getElementById: (id) => mockElements[id] || null, + querySelectorAll: (selector) => { + if (selector === '.sidebar-item') { + return Object.values(mockElements).filter(el => el.id.startsWith('sidebar-')); + } + if (selector === '.sidebar-sub-item') { + return Object.values(mockElements).filter(el => el.id.startsWith('sub-')); + } + return []; + } + }; + + global.console = { log: jest.fn(), error: jest.fn() }; +} + +// Import functions (in real setup, these would be imported from studio.js) +function hideAllPanels() { + const panels = [ + 'panel-compare', + 'panel-tiles', + 'panel-correction', + 'panel-letters', + 'panel-messenger', + 'panel-video' + ]; + panels.forEach(panelId => { + const panel = document.getElementById(panelId); + if (panel) { + panel.style.display = 'none'; + } + }); +} + +function hideStudioSubMenu() { + const subMenu = document.getElementById('studio-sub-menu'); + if (subMenu) { + subMenu.style.display = 'none'; + } +} + +function updateSidebarActive(activeSidebarId) { + document.querySelectorAll('.sidebar-item').forEach(item => { + item.classList.remove('active'); + }); + const activeItem = document.getElementById(activeSidebarId); + if (activeItem) { + activeItem.classList.add('active'); + } +} + +function updateSubNavActive(activeSubId) { + document.querySelectorAll('.sidebar-sub-item').forEach(item => { + item.classList.remove('active'); + }); + const activeItem = document.getElementById(activeSubId); + if (activeItem) { + activeItem.classList.add('active'); + } +} + +function showWorksheetTab() { + const panelCompare = document.getElementById('panel-compare'); + const panelTiles = document.getElementById('panel-tiles'); + + if (panelCompare) panelCompare.style.display = 'flex'; + if (panelTiles) panelTiles.style.display = 'none'; + + updateSubNavActive('sub-worksheets'); +} + +function showTilesTab() { + const panelCompare = document.getElementById('panel-compare'); + const panelTiles = document.getElementById('panel-tiles'); + + if (panelCompare) panelCompare.style.display = 'none'; + if (panelTiles) panelTiles.style.display = 'flex'; + + updateSubNavActive('sub-tiles'); +} + +function showStudioPanel() { + hideAllPanels(); + const subMenu = document.getElementById('studio-sub-menu'); + if (subMenu) { + subMenu.style.display = 'flex'; + } + showWorksheetTab(); + updateSidebarActive('sidebar-studio'); +} + +function showCorrectionPanel() { + hideAllPanels(); + hideStudioSubMenu(); + const correctionPanel = document.getElementById('panel-correction'); + if (correctionPanel) { + correctionPanel.style.display = 'flex'; + } + updateSidebarActive('sidebar-correction'); +} + +function showMessengerPanel() { + hideAllPanels(); + hideStudioSubMenu(); + const messengerPanel = document.getElementById('panel-messenger'); + if (messengerPanel) { + messengerPanel.style.display = 'flex'; + } + updateSidebarActive('sidebar-messenger'); +} + +function showVideoPanel() { + hideAllPanels(); + hideStudioSubMenu(); + const videoPanel = document.getElementById('panel-video'); + if (videoPanel) { + videoPanel.style.display = 'flex'; + } + updateSidebarActive('sidebar-video'); +} + +// Legacy alias +function showCommunicationPanel() { + showMessengerPanel(); +} + +function showLettersPanel() { + hideAllPanels(); + hideStudioSubMenu(); + const lettersPanel = document.getElementById('panel-letters'); + if (lettersPanel) { + lettersPanel.style.display = 'flex'; + } + updateSidebarActive('sidebar-letters'); +} + +// Tests +describe('Panel Navigation Functions', () => { + beforeEach(() => { + setupMockDOM(); + }); + + describe('hideAllPanels', () => { + test('should hide all panels', () => { + // Set some panels to visible + mockElements['panel-compare'].style.display = 'flex'; + mockElements['panel-tiles'].style.display = 'flex'; + + hideAllPanels(); + + expect(mockElements['panel-compare'].style.display).toBe('none'); + expect(mockElements['panel-tiles'].style.display).toBe('none'); + expect(mockElements['panel-messenger'].style.display).toBe('none'); + expect(mockElements['panel-video'].style.display).toBe('none'); + expect(mockElements['panel-correction'].style.display).toBe('none'); + expect(mockElements['panel-letters'].style.display).toBe('none'); + }); + }); + + describe('hideStudioSubMenu', () => { + test('should hide studio sub-menu', () => { + mockElements['studio-sub-menu'].style.display = 'flex'; + + hideStudioSubMenu(); + + expect(mockElements['studio-sub-menu'].style.display).toBe('none'); + }); + }); + + describe('showWorksheetTab', () => { + test('should show panel-compare and hide panel-tiles', () => { + mockElements['panel-tiles'].style.display = 'flex'; + + showWorksheetTab(); + + expect(mockElements['panel-compare'].style.display).toBe('flex'); + expect(mockElements['panel-tiles'].style.display).toBe('none'); + }); + + test('should activate sub-worksheets in sub-navigation', () => { + showWorksheetTab(); + + expect(mockElements['sub-worksheets'].classList.contains('active')).toBe(true); + expect(mockElements['sub-tiles'].classList.contains('active')).toBe(false); + }); + }); + + describe('showTilesTab', () => { + test('should show panel-tiles and hide panel-compare', () => { + mockElements['panel-compare'].style.display = 'flex'; + + showTilesTab(); + + expect(mockElements['panel-compare'].style.display).toBe('none'); + expect(mockElements['panel-tiles'].style.display).toBe('flex'); + }); + + test('should activate sub-tiles in sub-navigation', () => { + showTilesTab(); + + expect(mockElements['sub-tiles'].classList.contains('active')).toBe(true); + expect(mockElements['sub-worksheets'].classList.contains('active')).toBe(false); + }); + }); + + describe('showStudioPanel', () => { + test('should hide all panels first', () => { + mockElements['panel-correction'].style.display = 'flex'; + + showStudioPanel(); + + expect(mockElements['panel-correction'].style.display).toBe('none'); + }); + + test('should show sub-menu', () => { + mockElements['studio-sub-menu'].style.display = 'none'; + + showStudioPanel(); + + expect(mockElements['studio-sub-menu'].style.display).toBe('flex'); + }); + + test('should show worksheet tab by default', () => { + showStudioPanel(); + + expect(mockElements['panel-compare'].style.display).toBe('flex'); + expect(mockElements['panel-tiles'].style.display).toBe('none'); + }); + + test('should activate sidebar-studio', () => { + showStudioPanel(); + + expect(mockElements['sidebar-studio'].classList.contains('active')).toBe(true); + }); + }); + + describe('showCorrectionPanel', () => { + test('should hide all panels and show correction panel', () => { + mockElements['panel-compare'].style.display = 'flex'; + + showCorrectionPanel(); + + expect(mockElements['panel-compare'].style.display).toBe('none'); + expect(mockElements['panel-correction'].style.display).toBe('flex'); + }); + + test('should hide studio sub-menu', () => { + mockElements['studio-sub-menu'].style.display = 'flex'; + + showCorrectionPanel(); + + expect(mockElements['studio-sub-menu'].style.display).toBe('none'); + }); + + test('should activate sidebar-correction', () => { + showCorrectionPanel(); + + expect(mockElements['sidebar-correction'].classList.contains('active')).toBe(true); + }); + }); + + describe('showMessengerPanel', () => { + test('should hide all panels and show messenger panel', () => { + mockElements['panel-compare'].style.display = 'flex'; + + showMessengerPanel(); + + expect(mockElements['panel-compare'].style.display).toBe('none'); + expect(mockElements['panel-messenger'].style.display).toBe('flex'); + }); + + test('should hide studio sub-menu', () => { + mockElements['studio-sub-menu'].style.display = 'flex'; + + showMessengerPanel(); + + expect(mockElements['studio-sub-menu'].style.display).toBe('none'); + }); + + test('should activate sidebar-messenger', () => { + showMessengerPanel(); + + expect(mockElements['sidebar-messenger'].classList.contains('active')).toBe(true); + }); + }); + + describe('showVideoPanel', () => { + test('should hide all panels and show video panel', () => { + mockElements['panel-compare'].style.display = 'flex'; + + showVideoPanel(); + + expect(mockElements['panel-compare'].style.display).toBe('none'); + expect(mockElements['panel-video'].style.display).toBe('flex'); + }); + + test('should hide studio sub-menu', () => { + mockElements['studio-sub-menu'].style.display = 'flex'; + + showVideoPanel(); + + expect(mockElements['studio-sub-menu'].style.display).toBe('none'); + }); + + test('should activate sidebar-video', () => { + showVideoPanel(); + + expect(mockElements['sidebar-video'].classList.contains('active')).toBe(true); + }); + }); + + describe('showLettersPanel', () => { + test('should hide all panels and show letters panel', () => { + mockElements['panel-compare'].style.display = 'flex'; + + showLettersPanel(); + + expect(mockElements['panel-compare'].style.display).toBe('none'); + expect(mockElements['panel-letters'].style.display).toBe('flex'); + }); + + test('should hide studio sub-menu', () => { + mockElements['studio-sub-menu'].style.display = 'flex'; + + showLettersPanel(); + + expect(mockElements['studio-sub-menu'].style.display).toBe('none'); + }); + }); +}); + +describe('Sidebar Active State', () => { + beforeEach(() => { + setupMockDOM(); + }); + + test('updateSidebarActive should remove active from all items', () => { + mockElements['sidebar-studio'].classList.add('active'); + mockElements['sidebar-correction'].classList.add('active'); + + updateSidebarActive('sidebar-letters'); + + expect(mockElements['sidebar-studio'].classList.contains('active')).toBe(false); + expect(mockElements['sidebar-correction'].classList.contains('active')).toBe(false); + expect(mockElements['sidebar-letters'].classList.contains('active')).toBe(true); + }); +}); + +describe('Sub-Navigation Active State', () => { + beforeEach(() => { + setupMockDOM(); + }); + + test('updateSubNavActive should toggle active state correctly', () => { + mockElements['sub-worksheets'].classList.add('active'); + + updateSubNavActive('sub-tiles'); + + expect(mockElements['sub-worksheets'].classList.contains('active')).toBe(false); + expect(mockElements['sub-tiles'].classList.contains('active')).toBe(true); + }); +}); + +// ============================================ +// JITSI VIDEOKONFERENZ MODULE TESTS +// ============================================ + +// Mock additional elements for Jitsi tests +function setupJitsiMockDOM() { + setupMockDOM(); + + // Jitsi-specific elements + mockElements['meeting-name'] = { + id: 'meeting-name', + value: 'Test Meeting', + style: { display: 'block' } + }; + mockElements['jitsi-container'] = createMockElement('jitsi-container'); + mockElements['jitsi-container'].innerHTML = ''; + mockElements['jitsi-placeholder'] = createMockElement('jitsi-placeholder', 'flex'); + mockElements['jitsi-controls'] = createMockElement('jitsi-controls'); + mockElements['btn-mute'] = createMockElement('btn-mute'); + mockElements['btn-mute'].textContent = '🎤 Stumm'; + mockElements['btn-video'] = createMockElement('btn-video'); + mockElements['btn-video'].textContent = '📹 Video aus'; + mockElements['meeting-link-display'] = createMockElement('meeting-link-display'); + mockElements['meeting-url'] = { + id: 'meeting-url', + textContent: '', + style: { display: 'block' } + }; + + // Override document methods for extended elements + global.document.getElementById = (id) => mockElements[id] || null; + global.document.createElement = (tag) => { + return { + tagName: tag.toUpperCase(), + style: {}, + setAttribute: jest.fn(), + appendChild: jest.fn() + }; + }; + + // Mock clipboard API + global.navigator = { + clipboard: { + writeText: jest.fn().mockResolvedValue(undefined) + } + }; + + // Mock alert + global.alert = jest.fn(); +} + +// Jitsi module state +let currentJitsiMeetingUrl = null; +let jitsiMicMuted = false; +let jitsiVideoOff = false; + +// Jitsi functions (copied from studio.js for testing) +async function startInstantMeeting() { + console.log('Starting instant meeting...'); + const meetingNameEl = document.getElementById('meeting-name'); + const meetingName = meetingNameEl?.value || ''; + + try { + const roomId = 'bp-' + Date.now().toString(36) + Math.random().toString(36).substr(2, 5); + const jitsiDomain = 'meet.jit.si'; + currentJitsiMeetingUrl = `https://${jitsiDomain}/${roomId}`; + + const container = document.getElementById('jitsi-container'); + const placeholder = document.getElementById('jitsi-placeholder'); + const controls = document.getElementById('jitsi-controls'); + const linkDisplay = document.getElementById('meeting-link-display'); + const urlDisplay = document.getElementById('meeting-url'); + + if (placeholder) placeholder.style.display = 'none'; + if (controls) controls.style.display = 'flex'; + if (linkDisplay) linkDisplay.style.display = 'flex'; + if (urlDisplay) urlDisplay.textContent = currentJitsiMeetingUrl; + + if (container) { + const iframe = document.createElement('iframe'); + iframe.setAttribute('src', `${currentJitsiMeetingUrl}#config.prejoinPageEnabled=false`); + iframe.setAttribute('allow', 'camera; microphone; fullscreen; display-capture'); + container.appendChild(iframe); + } + + return { success: true, url: currentJitsiMeetingUrl }; + } catch (error) { + console.error('Error starting meeting:', error); + return { success: false, error }; + } +} + +function leaveJitsiMeeting() { + const container = document.getElementById('jitsi-container'); + const placeholder = document.getElementById('jitsi-placeholder'); + const controls = document.getElementById('jitsi-controls'); + const linkDisplay = document.getElementById('meeting-link-display'); + + if (container) container.innerHTML = ''; + if (placeholder) placeholder.style.display = 'flex'; + if (controls) controls.style.display = 'none'; + if (linkDisplay) linkDisplay.style.display = 'none'; + + currentJitsiMeetingUrl = null; + jitsiMicMuted = false; + jitsiVideoOff = false; +} + +function toggleJitsiMute() { + jitsiMicMuted = !jitsiMicMuted; + const btn = document.getElementById('btn-mute'); + if (btn) { + btn.textContent = jitsiMicMuted ? '🔇 Unmute' : '🎤 Stumm'; + } + return jitsiMicMuted; +} + +function toggleJitsiVideo() { + jitsiVideoOff = !jitsiVideoOff; + const btn = document.getElementById('btn-video'); + if (btn) { + btn.textContent = jitsiVideoOff ? '📷 Video an' : '📹 Video aus'; + } + return jitsiVideoOff; +} + +async function copyMeetingLink() { + if (currentJitsiMeetingUrl) { + await navigator.clipboard.writeText(currentJitsiMeetingUrl); + alert('Meeting-Link wurde kopiert!'); + return true; + } + return false; +} + +function joinScheduledMeeting(meetingId) { + console.log('Joining scheduled meeting:', meetingId); + const jitsiDomain = 'meet.jit.si'; + currentJitsiMeetingUrl = `https://${jitsiDomain}/${meetingId}`; + + const container = document.getElementById('jitsi-container'); + const placeholder = document.getElementById('jitsi-placeholder'); + const controls = document.getElementById('jitsi-controls'); + + if (placeholder) placeholder.style.display = 'none'; + if (controls) controls.style.display = 'flex'; + + if (container) { + const iframe = document.createElement('iframe'); + iframe.setAttribute('src', `${currentJitsiMeetingUrl}#config.prejoinPageEnabled=false`); + container.appendChild(iframe); + } + + return currentJitsiMeetingUrl; +} + +describe('Jitsi Videokonferenz Module', () => { + beforeEach(() => { + setupJitsiMockDOM(); + currentJitsiMeetingUrl = null; + jitsiMicMuted = false; + jitsiVideoOff = false; + }); + + describe('startInstantMeeting', () => { + test('should create a meeting URL with bp- prefix', async () => { + const result = await startInstantMeeting(); + + expect(result.success).toBe(true); + expect(result.url).toMatch(/^https:\/\/meet\.jit\.si\/bp-/); + expect(currentJitsiMeetingUrl).toBe(result.url); + }); + + test('should hide placeholder and show controls', async () => { + await startInstantMeeting(); + + expect(mockElements['jitsi-placeholder'].style.display).toBe('none'); + expect(mockElements['jitsi-controls'].style.display).toBe('flex'); + expect(mockElements['meeting-link-display'].style.display).toBe('flex'); + }); + + test('should update meeting URL display', async () => { + await startInstantMeeting(); + + expect(mockElements['meeting-url'].textContent).toMatch(/^https:\/\/meet\.jit\.si\/bp-/); + }); + }); + + describe('leaveJitsiMeeting', () => { + test('should reset all meeting state', async () => { + await startInstantMeeting(); + expect(currentJitsiMeetingUrl).not.toBeNull(); + + leaveJitsiMeeting(); + + expect(currentJitsiMeetingUrl).toBeNull(); + expect(jitsiMicMuted).toBe(false); + expect(jitsiVideoOff).toBe(false); + }); + + test('should show placeholder and hide controls', async () => { + await startInstantMeeting(); + + leaveJitsiMeeting(); + + expect(mockElements['jitsi-placeholder'].style.display).toBe('flex'); + expect(mockElements['jitsi-controls'].style.display).toBe('none'); + expect(mockElements['meeting-link-display'].style.display).toBe('none'); + }); + }); + + describe('toggleJitsiMute', () => { + test('should toggle mute state', () => { + expect(jitsiMicMuted).toBe(false); + + const result1 = toggleJitsiMute(); + expect(result1).toBe(true); + expect(jitsiMicMuted).toBe(true); + + const result2 = toggleJitsiMute(); + expect(result2).toBe(false); + expect(jitsiMicMuted).toBe(false); + }); + + test('should update button text', () => { + toggleJitsiMute(); + expect(mockElements['btn-mute'].textContent).toBe('🔇 Unmute'); + + toggleJitsiMute(); + expect(mockElements['btn-mute'].textContent).toBe('🎤 Stumm'); + }); + }); + + describe('toggleJitsiVideo', () => { + test('should toggle video state', () => { + expect(jitsiVideoOff).toBe(false); + + const result1 = toggleJitsiVideo(); + expect(result1).toBe(true); + expect(jitsiVideoOff).toBe(true); + + const result2 = toggleJitsiVideo(); + expect(result2).toBe(false); + expect(jitsiVideoOff).toBe(false); + }); + + test('should update button text', () => { + toggleJitsiVideo(); + expect(mockElements['btn-video'].textContent).toBe('📷 Video an'); + + toggleJitsiVideo(); + expect(mockElements['btn-video'].textContent).toBe('📹 Video aus'); + }); + }); + + describe('copyMeetingLink', () => { + test('should copy meeting URL to clipboard when active', async () => { + await startInstantMeeting(); + + const result = await copyMeetingLink(); + + expect(result).toBe(true); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(currentJitsiMeetingUrl); + expect(alert).toHaveBeenCalledWith('Meeting-Link wurde kopiert!'); + }); + + test('should return false when no active meeting', async () => { + const result = await copyMeetingLink(); + + expect(result).toBe(false); + expect(navigator.clipboard.writeText).not.toHaveBeenCalled(); + }); + }); + + describe('joinScheduledMeeting', () => { + test('should create meeting URL with provided ID', () => { + const meetingId = 'scheduled-meeting-123'; + + const url = joinScheduledMeeting(meetingId); + + expect(url).toBe(`https://meet.jit.si/${meetingId}`); + expect(currentJitsiMeetingUrl).toBe(url); + }); + + test('should hide placeholder and show controls', () => { + joinScheduledMeeting('test-meeting'); + + expect(mockElements['jitsi-placeholder'].style.display).toBe('none'); + expect(mockElements['jitsi-controls'].style.display).toBe('flex'); + }); + }); +}); + +// ============================================ +// MATRIX MESSENGER MODULE TESTS +// ============================================ + +// Messenger stub functions (copied from studio.js) +function startQuickMeeting() { + showVideoPanel(); + // In real implementation: setTimeout(() => startInstantMeeting(), 100); + return 'video-panel-shown'; +} + +function createClassRoom() { + console.log('Creating class room...'); + alert('Klassenraum-Erstellung wird in Phase 2 implementiert.'); + return 'stub'; +} + +function scheduleParentMeeting() { + console.log('Scheduling parent meeting...'); + alert('Elterngespräch-Planung wird in Phase 2 implementiert.'); + return 'stub'; +} + +function selectRoom(roomId) { + console.log('Selecting room:', roomId); + // Stub: Would load room messages + return roomId; +} + +function sendMessage() { + const input = document.getElementById('messenger-input'); + const message = input?.value?.trim(); + if (!message) { + console.log('Empty message, not sending'); + return false; + } + console.log('Sending message:', message); + // Stub: Would send via Matrix API + if (input) input.value = ''; + return true; +} + +// Setup Messenger mock DOM +function setupMessengerMockDOM() { + setupMockDOM(); + + mockElements['messenger-input'] = { + id: 'messenger-input', + value: '', + style: { display: 'block' } + }; +} + +describe('Matrix Messenger Module', () => { + beforeEach(() => { + setupMessengerMockDOM(); + }); + + describe('startQuickMeeting', () => { + test('should show video panel', () => { + const result = startQuickMeeting(); + + expect(result).toBe('video-panel-shown'); + expect(mockElements['panel-video'].style.display).toBe('flex'); + }); + }); + + describe('createClassRoom', () => { + test('should return stub indicator', () => { + const result = createClassRoom(); + + expect(result).toBe('stub'); + expect(alert).toHaveBeenCalledWith('Klassenraum-Erstellung wird in Phase 2 implementiert.'); + }); + }); + + describe('scheduleParentMeeting', () => { + test('should return stub indicator', () => { + const result = scheduleParentMeeting(); + + expect(result).toBe('stub'); + expect(alert).toHaveBeenCalledWith('Elterngespräch-Planung wird in Phase 2 implementiert.'); + }); + }); + + describe('selectRoom', () => { + test('should return room ID', () => { + const roomId = 'room-123'; + + const result = selectRoom(roomId); + + expect(result).toBe(roomId); + }); + }); + + describe('sendMessage', () => { + test('should return false for empty message', () => { + mockElements['messenger-input'].value = ''; + + const result = sendMessage(); + + expect(result).toBe(false); + }); + + test('should return false for whitespace-only message', () => { + mockElements['messenger-input'].value = ' '; + + const result = sendMessage(); + + expect(result).toBe(false); + }); + + test('should return true and clear input for valid message', () => { + mockElements['messenger-input'].value = 'Hello World'; + + const result = sendMessage(); + + expect(result).toBe(true); + expect(mockElements['messenger-input'].value).toBe(''); + }); + }); +}); + +// ============================================ +// INTEGRATION TESTS +// ============================================ + +describe('Panel Integration', () => { + beforeEach(() => { + setupJitsiMockDOM(); + }); + + test('switching from messenger to video should preserve panel state', () => { + showMessengerPanel(); + expect(mockElements['panel-messenger'].style.display).toBe('flex'); + expect(mockElements['sidebar-messenger'].classList.contains('active')).toBe(true); + + showVideoPanel(); + expect(mockElements['panel-messenger'].style.display).toBe('none'); + expect(mockElements['panel-video'].style.display).toBe('flex'); + expect(mockElements['sidebar-messenger'].classList.contains('active')).toBe(false); + expect(mockElements['sidebar-video'].classList.contains('active')).toBe(true); + }); + + test('video panel should be accessible via startQuickMeeting', () => { + showMessengerPanel(); + + startQuickMeeting(); + + expect(mockElements['panel-video'].style.display).toBe('flex'); + expect(mockElements['sidebar-video'].classList.contains('active')).toBe(true); + }); +}); diff --git a/backend/frontend_app.py.save b/backend/frontend_app.py.save new file mode 100644 index 0000000..6e46e95 --- /dev/null +++ b/backend/frontend_app.py.save @@ -0,0 +1,1344 @@ + pathlib import Path + +from fastapi import FastAPI +from fastapi.responses import HTMLResponse, FileResponse + +from main import app as backend_app # Backend unter /api + +BASE_DIR = Path.home() / "Arbeitsblaetter" +EINGANG_DIR = BASE_DIR / "Eingang" +BEREINIGT_DIR = BASE_DIR / "Bereinigt" + +app = FastAPI(title="BreakPilot Frontend") + +# Backend unter /api einhängen +app.mount("/api", backend_app) + + +@app.get("/", response_class=HTMLResponse) +def root(): + return """ + + + + + BreakPilot – Start + + +

          BreakPilot – Lokale App

          +

          Die App läuft.

          +

          Moderne Oberfläche: /app

          +

          Backend-API-Doku: /api/docs

          + + + """ + + +@app.get("/preview-file/{filename}") +def preview_file(filename: str): + path = EINGANG_DIR / filename + if not path.exists(): + return {"error": "Datei nicht gefunden"} + if path.suffix.lower() not in {".jpg", ".jpeg", ".png"}: + return {"error": "Vorschau nur für JPG/PNG möglich"} + return FileResponse(str(path)) + + +@app.get("/preview-clean-file/{filename}") +def preview_clean_file(filename: str): + path = BEREINIGT_DIR / filename + if not path.exists(): + return {"error": "Datei nicht gefunden"} + if path.suffix.lower() not in {".jpg", ".jpeg", ".png"}: + return {"error": "Vorschau nur für JPG/PNG möglich"} + return FileResponse(str(path)) + + +@app.get("/app", response_class=HTMLResponse) +def app_ui(): + return """ + + + + + BreakPilot – Arbeitsblatt Studio + + + + +
          +
          +
          + +
          +
          BreakPilot Studio
          +
          Arbeitsblätter · Eltern · KI
          +
          +
          + +
          +
          MVP · Lokal auf deinem Mac
          +
          +
          + +
          + + +
          + +
          +
          +
          +
          Arbeitsblätter & Vergleich
          +
          Links Scan · Rechts neu aufgebautes Arbeitsblatt
          +
          + 0 Dateien +
          + +
          +
          + + +
          + +
            + +
            + +
            + +
            +
            + Lade Arbeitsblätter hoch und klicke auf „Arbeitsblätter neu aufbauen“.
            + Dann kannst du mit den Pfeilen durch die Scans und die bereinigten Versionen blättern. +
            +
            +
            +
            + + +
            +
            +
            +
            Aufbereitungs-Tools
            +
            Kacheln für den Lernflow aktivieren/deaktivieren
            +
            +
            + +
            +
            + + + + +
            + +
            + +
            +
            +
            Original-Arbeitsblatt
            +
            Neuaufbau
            +
            +
            +
            Erzeugt bereinigte Versionen deiner Arbeitsblätter (ohne Handschrift) und baut saubere HTML-Arbeitsblätter, die im Vergleich rechts angezeigt werden.
            +
            + +
            +
            +
            + + +
            +
            +
            Frage–Antwort-Blatt
            +
            Kommen bald
            +
            +
            +
            Aus dem Original-Arbeitsblatt entsteht ein Frage–Antwort-Blatt. Elternmodus mit Übersetzung & Aussprache-Button wird hier andocken.
            +
            + +
            +
            +
            + + +
            +
            +
            Multiple Choice Test
            +
            Kommen bald
            +
            +
            +
            Erzeugt passende MC-Aufgaben zur ursprünglichen Schwierigkeit (z. B. Klasse 7), ohne das Niveau zu verändern.
            +
            + +
            +
            +
            + + +
            +
            +
            Lückentext
            +
            Kommen bald
            +
            +
            +
            Erzeugt oder rekonstruiert Lückentexte mit sinnvoll aufgeteilten Lücken (z. B. „habe“ + „gemacht“ getrennt).
            +
            + +
            +
            +
            + +
            +
            +
            +
            + +
            + + 1 von 2 + +
            + +
            +
            +
            +
            +
            +
            +
            +
            + + + + + + + """ diff --git a/backend/frontend_paths.py.save b/backend/frontend_paths.py.save new file mode 100644 index 0000000..733ca15 --- /dev/null +++ b/backend/frontend_paths.py.save @@ -0,0 +1,7 @@ +from pathlib import Path + +BASE_DIR = Path.home() / "Arbeitsblaetter" +EINGANG_DIR = BASE_DIR / "Eingang" +BEREINIGT_DIR = BASE_DIR / "Bereinigt" + + diff --git a/backend/game/__init__.py b/backend/game/__init__.py new file mode 100644 index 0000000..f09f850 --- /dev/null +++ b/backend/game/__init__.py @@ -0,0 +1,63 @@ +# ============================================== +# Breakpilot Drive - Game Module +# ============================================== +# Database, quiz generation, and learning rules for the game. + +from .database import ( + GameDatabase, + StudentLearningState, + GameSessionRecord, + GameQuizAnswer, + LearningLevel, + Achievement, + ACHIEVEMENTS, + get_game_db, +) + +from .quiz_generator import ( + QuizGenerator, + GeneratedQuestion, + Subject, + QuizMode, + get_quiz_generator, +) + +from .learning_rules import ( + LearningRuleEngine, + LearningRule, + LearningContext, + Suggestion, + ActionType, + RulePriority, + get_rule_engine, + calculate_level_adjustment, + get_subject_focus_recommendation, +) + +__all__ = [ + # Database + "GameDatabase", + "StudentLearningState", + "GameSessionRecord", + "GameQuizAnswer", + "LearningLevel", + "Achievement", + "ACHIEVEMENTS", + "get_game_db", + # Quiz Generator + "QuizGenerator", + "GeneratedQuestion", + "Subject", + "QuizMode", + "get_quiz_generator", + # Learning Rules + "LearningRuleEngine", + "LearningRule", + "LearningContext", + "Suggestion", + "ActionType", + "RulePriority", + "get_rule_engine", + "calculate_level_adjustment", + "get_subject_focus_recommendation", +] diff --git a/backend/game/database.py b/backend/game/database.py new file mode 100644 index 0000000..0eb9a3b --- /dev/null +++ b/backend/game/database.py @@ -0,0 +1,785 @@ +# ============================================== +# Breakpilot Drive - Game Database +# ============================================== +# Async PostgreSQL database access for game sessions +# and student learning state. + +import os +import json +import logging +from datetime import datetime, timezone +from typing import Optional, List, Dict, Any +from dataclasses import dataclass, field +from enum import IntEnum + +logger = logging.getLogger(__name__) + +# Database URL from environment +GAME_DB_URL = os.getenv( + "DATABASE_URL", + "postgresql://breakpilot:breakpilot123@localhost:5432/breakpilot" +) + + +class LearningLevel(IntEnum): + """Learning level enum mapping to grade ranges.""" + BEGINNER = 1 # Klasse 2-3 + ELEMENTARY = 2 # Klasse 3-4 + INTERMEDIATE = 3 # Klasse 4-5 + ADVANCED = 4 # Klasse 5-6 + EXPERT = 5 # Klasse 6+ + + +@dataclass +class StudentLearningState: + """Student learning state data model.""" + id: Optional[str] = None + student_id: str = "" + overall_level: int = 3 + math_level: float = 3.0 + german_level: float = 3.0 + english_level: float = 3.0 + total_play_time_minutes: int = 0 + total_sessions: int = 0 + questions_answered: int = 0 + questions_correct: int = 0 + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary.""" + return { + "id": self.id, + "student_id": self.student_id, + "overall_level": self.overall_level, + "math_level": self.math_level, + "german_level": self.german_level, + "english_level": self.english_level, + "total_play_time_minutes": self.total_play_time_minutes, + "total_sessions": self.total_sessions, + "questions_answered": self.questions_answered, + "questions_correct": self.questions_correct, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + } + + @property + def accuracy(self) -> float: + """Calculate overall accuracy percentage.""" + if self.questions_answered == 0: + return 0.0 + return self.questions_correct / self.questions_answered + + +@dataclass +class GameSessionRecord: + """Game session record for database storage.""" + id: Optional[str] = None + student_id: str = "" + game_mode: str = "video" + duration_seconds: int = 0 + distance_traveled: float = 0.0 + score: int = 0 + questions_answered: int = 0 + questions_correct: int = 0 + difficulty_level: int = 3 + started_at: Optional[datetime] = None + ended_at: Optional[datetime] = None + metadata: Optional[Dict[str, Any]] = None + + +@dataclass +class GameQuizAnswer: + """Individual quiz answer record.""" + id: Optional[str] = None + session_id: Optional[str] = None + question_id: str = "" + subject: str = "" + difficulty: int = 3 + is_correct: bool = False + answer_time_ms: int = 0 + created_at: Optional[datetime] = None + + +@dataclass +class Achievement: + """Achievement definition and unlock status.""" + id: str + name: str + description: str + icon: str = "star" + category: str = "general" # general, streak, accuracy, time, score + threshold: int = 1 + unlocked: bool = False + unlocked_at: Optional[datetime] = None + progress: int = 0 + + +# Achievement definitions (static, not in DB) +ACHIEVEMENTS = [ + # Erste Schritte + Achievement(id="first_game", name="Erste Fahrt", description="Spiele dein erstes Spiel", icon="rocket", category="general", threshold=1), + Achievement(id="five_games", name="Regelmaessiger Fahrer", description="Spiele 5 Spiele", icon="car", category="general", threshold=5), + Achievement(id="twenty_games", name="Erfahrener Pilot", description="Spiele 20 Spiele", icon="trophy", category="general", threshold=20), + + # Serien + Achievement(id="streak_3", name="Guter Start", description="3 richtige Antworten hintereinander", icon="fire", category="streak", threshold=3), + Achievement(id="streak_5", name="Auf Feuer", description="5 richtige Antworten hintereinander", icon="fire", category="streak", threshold=5), + Achievement(id="streak_10", name="Unaufhaltsam", description="10 richtige Antworten hintereinander", icon="fire", category="streak", threshold=10), + + # Genauigkeit + Achievement(id="perfect_game", name="Perfektes Spiel", description="100% richtig in einem Spiel (min. 5 Fragen)", icon="star", category="accuracy", threshold=100), + Achievement(id="accuracy_80", name="Scharfschuetze", description="80% Gesamtgenauigkeit (min. 50 Fragen)", icon="target", category="accuracy", threshold=80), + + # Zeit + Achievement(id="play_30min", name="Ausdauer", description="30 Minuten Gesamtspielzeit", icon="clock", category="time", threshold=30), + Achievement(id="play_60min", name="Marathon", description="60 Minuten Gesamtspielzeit", icon="clock", category="time", threshold=60), + + # Score + Achievement(id="score_5000", name="Punktejaeger", description="5.000 Punkte in einem Spiel", icon="gem", category="score", threshold=5000), + Achievement(id="score_10000", name="Highscore Hero", description="10.000 Punkte in einem Spiel", icon="crown", category="score", threshold=10000), + + # Level + Achievement(id="level_up", name="Aufsteiger", description="Erreiche Level 2", icon="arrow-up", category="level", threshold=2), + Achievement(id="master", name="Meister", description="Erreiche Level 5", icon="medal", category="level", threshold=5), +] + + +class GameDatabase: + """ + Async database access for Breakpilot Drive game data. + + Uses asyncpg for PostgreSQL access with connection pooling. + """ + + def __init__(self, database_url: Optional[str] = None): + self.database_url = database_url or GAME_DB_URL + self._pool = None + self._connected = False + + async def connect(self): + """Initialize connection pool.""" + if self._connected: + return + + try: + import asyncpg + self._pool = await asyncpg.create_pool( + self.database_url, + min_size=2, + max_size=10, + ) + self._connected = True + logger.info("Game database connected") + except ImportError: + logger.warning("asyncpg not installed, database features disabled") + except Exception as e: + logger.error(f"Game database connection failed: {e}") + + async def close(self): + """Close connection pool.""" + if self._pool: + await self._pool.close() + self._connected = False + + async def _ensure_connected(self): + """Ensure database is connected.""" + if not self._connected: + await self.connect() + + # ============================================== + # Learning State Methods + # ============================================== + + async def get_learning_state(self, student_id: str) -> Optional[StudentLearningState]: + """Get learning state for a student.""" + await self._ensure_connected() + if not self._pool: + return None + + try: + async with self._pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT id, student_id, overall_level, math_level, german_level, + english_level, total_play_time_minutes, total_sessions, + questions_answered, questions_correct, created_at, updated_at + FROM student_learning_state + WHERE student_id = $1 + """, + student_id + ) + + if row: + return StudentLearningState( + id=str(row["id"]), + student_id=str(row["student_id"]), + overall_level=row["overall_level"], + math_level=float(row["math_level"]), + german_level=float(row["german_level"]), + english_level=float(row["english_level"]), + total_play_time_minutes=row["total_play_time_minutes"], + total_sessions=row["total_sessions"], + questions_answered=row["questions_answered"] or 0, + questions_correct=row["questions_correct"] or 0, + created_at=row["created_at"], + updated_at=row["updated_at"], + ) + except Exception as e: + logger.error(f"Failed to get learning state: {e}") + + return None + + async def create_or_update_learning_state( + self, + student_id: str, + overall_level: int = 3, + math_level: float = 3.0, + german_level: float = 3.0, + english_level: float = 3.0, + ) -> Optional[StudentLearningState]: + """Create or update learning state for a student.""" + await self._ensure_connected() + if not self._pool: + return None + + try: + async with self._pool.acquire() as conn: + row = await conn.fetchrow( + """ + INSERT INTO student_learning_state ( + student_id, overall_level, math_level, german_level, english_level + ) VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (student_id) DO UPDATE SET + overall_level = EXCLUDED.overall_level, + math_level = EXCLUDED.math_level, + german_level = EXCLUDED.german_level, + english_level = EXCLUDED.english_level, + updated_at = NOW() + RETURNING id, student_id, overall_level, math_level, german_level, + english_level, total_play_time_minutes, total_sessions, + questions_answered, questions_correct, created_at, updated_at + """, + student_id, overall_level, math_level, german_level, english_level + ) + + if row: + return StudentLearningState( + id=str(row["id"]), + student_id=str(row["student_id"]), + overall_level=row["overall_level"], + math_level=float(row["math_level"]), + german_level=float(row["german_level"]), + english_level=float(row["english_level"]), + total_play_time_minutes=row["total_play_time_minutes"], + total_sessions=row["total_sessions"], + questions_answered=row["questions_answered"] or 0, + questions_correct=row["questions_correct"] or 0, + created_at=row["created_at"], + updated_at=row["updated_at"], + ) + except Exception as e: + logger.error(f"Failed to create/update learning state: {e}") + + return None + + async def update_learning_stats( + self, + student_id: str, + duration_minutes: int, + questions_answered: int, + questions_correct: int, + new_level: Optional[int] = None, + ) -> bool: + """Update learning stats after a game session.""" + await self._ensure_connected() + if not self._pool: + return False + + try: + async with self._pool.acquire() as conn: + if new_level is not None: + await conn.execute( + """ + UPDATE student_learning_state SET + total_play_time_minutes = total_play_time_minutes + $2, + total_sessions = total_sessions + 1, + questions_answered = COALESCE(questions_answered, 0) + $3, + questions_correct = COALESCE(questions_correct, 0) + $4, + overall_level = $5, + updated_at = NOW() + WHERE student_id = $1 + """, + student_id, duration_minutes, questions_answered, + questions_correct, new_level + ) + else: + await conn.execute( + """ + UPDATE student_learning_state SET + total_play_time_minutes = total_play_time_minutes + $2, + total_sessions = total_sessions + 1, + questions_answered = COALESCE(questions_answered, 0) + $3, + questions_correct = COALESCE(questions_correct, 0) + $4, + updated_at = NOW() + WHERE student_id = $1 + """, + student_id, duration_minutes, questions_answered, questions_correct + ) + return True + except Exception as e: + logger.error(f"Failed to update learning stats: {e}") + + return False + + # ============================================== + # Game Session Methods + # ============================================== + + async def save_game_session( + self, + student_id: str, + game_mode: str, + duration_seconds: int, + distance_traveled: float, + score: int, + questions_answered: int, + questions_correct: int, + difficulty_level: int, + metadata: Optional[Dict[str, Any]] = None, + ) -> Optional[str]: + """Save a game session and return the session ID.""" + await self._ensure_connected() + if not self._pool: + return None + + try: + async with self._pool.acquire() as conn: + row = await conn.fetchrow( + """ + INSERT INTO game_sessions ( + student_id, game_mode, duration_seconds, distance_traveled, + score, questions_answered, questions_correct, difficulty_level, + started_at, ended_at, metadata + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, + NOW() - make_interval(secs => $3), NOW(), $9) + RETURNING id + """, + student_id, game_mode, duration_seconds, distance_traveled, + score, questions_answered, questions_correct, difficulty_level, + json.dumps(metadata) if metadata else None + ) + + if row: + return str(row["id"]) + except Exception as e: + logger.error(f"Failed to save game session: {e}") + + return None + + async def get_user_sessions( + self, + student_id: str, + limit: int = 10 + ) -> List[Dict[str, Any]]: + """Get recent game sessions for a user.""" + await self._ensure_connected() + if not self._pool: + return [] + + try: + async with self._pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT id, student_id, game_mode, duration_seconds, distance_traveled, + score, questions_answered, questions_correct, difficulty_level, + started_at, ended_at, metadata + FROM game_sessions + WHERE student_id = $1 + ORDER BY ended_at DESC + LIMIT $2 + """, + student_id, limit + ) + + return [ + { + "session_id": str(row["id"]), + "user_id": str(row["student_id"]), + "game_mode": row["game_mode"], + "duration_seconds": row["duration_seconds"], + "distance_traveled": float(row["distance_traveled"]) if row["distance_traveled"] else 0.0, + "score": row["score"], + "questions_answered": row["questions_answered"], + "questions_correct": row["questions_correct"], + "difficulty_level": row["difficulty_level"], + "started_at": row["started_at"].isoformat() if row["started_at"] else None, + "ended_at": row["ended_at"].isoformat() if row["ended_at"] else None, + } + for row in rows + ] + except Exception as e: + logger.error(f"Failed to get user sessions: {e}") + + return [] + + async def get_leaderboard( + self, + timeframe: str = "day", + limit: int = 10 + ) -> List[Dict[str, Any]]: + """Get leaderboard data.""" + await self._ensure_connected() + if not self._pool: + return [] + + # Timeframe filter + timeframe_sql = { + "day": "ended_at > NOW() - INTERVAL '1 day'", + "week": "ended_at > NOW() - INTERVAL '7 days'", + "month": "ended_at > NOW() - INTERVAL '30 days'", + "all": "1=1", + }.get(timeframe, "1=1") + + try: + async with self._pool.acquire() as conn: + rows = await conn.fetch( + f""" + SELECT student_id, SUM(score) as total_score + FROM game_sessions + WHERE {timeframe_sql} + GROUP BY student_id + ORDER BY total_score DESC + LIMIT $1 + """, + limit + ) + + return [ + { + "rank": i + 1, + "user_id": str(row["student_id"]), + "total_score": int(row["total_score"]), + } + for i, row in enumerate(rows) + ] + except Exception as e: + logger.error(f"Failed to get leaderboard: {e}") + + return [] + + # ============================================== + # Quiz Answer Methods + # ============================================== + + async def save_quiz_answer( + self, + session_id: str, + question_id: str, + subject: str, + difficulty: int, + is_correct: bool, + answer_time_ms: int, + ) -> bool: + """Save an individual quiz answer.""" + await self._ensure_connected() + if not self._pool: + return False + + try: + async with self._pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO game_quiz_answers ( + session_id, question_id, subject, difficulty, + is_correct, answer_time_ms + ) VALUES ($1, $2, $3, $4, $5, $6) + """, + session_id, question_id, subject, difficulty, + is_correct, answer_time_ms + ) + return True + except Exception as e: + logger.error(f"Failed to save quiz answer: {e}") + + return False + + async def get_subject_stats( + self, + student_id: str + ) -> Dict[str, Dict[str, Any]]: + """Get per-subject statistics for a student.""" + await self._ensure_connected() + if not self._pool: + return {} + + try: + async with self._pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT + qa.subject, + COUNT(*) as total, + SUM(CASE WHEN qa.is_correct THEN 1 ELSE 0 END) as correct, + AVG(qa.answer_time_ms) as avg_time_ms + FROM game_quiz_answers qa + JOIN game_sessions gs ON qa.session_id = gs.id + WHERE gs.student_id = $1 + GROUP BY qa.subject + """, + student_id + ) + + return { + row["subject"]: { + "total": row["total"], + "correct": row["correct"], + "accuracy": row["correct"] / row["total"] if row["total"] > 0 else 0.0, + "avg_time_ms": int(row["avg_time_ms"]) if row["avg_time_ms"] else 0, + } + for row in rows + } + except Exception as e: + logger.error(f"Failed to get subject stats: {e}") + + return {} + + # ============================================== + # Extended Leaderboard Methods + # ============================================== + + async def get_class_leaderboard( + self, + class_id: str, + timeframe: str = "week", + limit: int = 10 + ) -> List[Dict[str, Any]]: + """ + Get leaderboard filtered by class. + + Note: Requires class_id to be stored in user metadata or + a separate class_memberships table. For now, this is a + placeholder that can be extended. + """ + # For now, fall back to regular leaderboard + # TODO: Join with class_memberships table when available + return await self.get_leaderboard(timeframe, limit) + + async def get_leaderboard_with_names( + self, + timeframe: str = "day", + limit: int = 10, + anonymize: bool = True + ) -> List[Dict[str, Any]]: + """Get leaderboard with anonymized display names.""" + leaderboard = await self.get_leaderboard(timeframe, limit) + + # Anonymize names for privacy (e.g., "Spieler 1", "Spieler 2") + if anonymize: + for entry in leaderboard: + entry["display_name"] = f"Spieler {entry['rank']}" + else: + # In production: Join with users table to get real names + for entry in leaderboard: + entry["display_name"] = f"Spieler {entry['rank']}" + + return leaderboard + + # ============================================== + # Parent Dashboard Methods + # ============================================== + + async def get_children_stats( + self, + children_ids: List[str] + ) -> List[Dict[str, Any]]: + """Get stats for multiple children (parent dashboard).""" + if not children_ids: + return [] + + results = [] + for child_id in children_ids: + state = await self.get_learning_state(child_id) + sessions = await self.get_user_sessions(child_id, limit=5) + + results.append({ + "student_id": child_id, + "learning_state": state.to_dict() if state else None, + "recent_sessions": sessions, + "has_played": state is not None and state.total_sessions > 0, + }) + + return results + + async def get_progress_over_time( + self, + student_id: str, + days: int = 30 + ) -> List[Dict[str, Any]]: + """Get learning progress over time for charts.""" + await self._ensure_connected() + if not self._pool: + return [] + + try: + async with self._pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT + DATE(ended_at) as date, + COUNT(*) as sessions, + SUM(score) as total_score, + SUM(questions_answered) as questions, + SUM(questions_correct) as correct, + AVG(difficulty_level) as avg_difficulty + FROM game_sessions + WHERE student_id = $1 + AND ended_at > NOW() - make_interval(days => $2) + GROUP BY DATE(ended_at) + ORDER BY date ASC + """, + student_id, days + ) + + return [ + { + "date": row["date"].isoformat(), + "sessions": row["sessions"], + "total_score": int(row["total_score"]), + "questions": row["questions"], + "correct": row["correct"], + "accuracy": row["correct"] / row["questions"] if row["questions"] > 0 else 0, + "avg_difficulty": float(row["avg_difficulty"]) if row["avg_difficulty"] else 3.0, + } + for row in rows + ] + except Exception as e: + logger.error(f"Failed to get progress over time: {e}") + + return [] + + # ============================================== + # Achievement Methods + # ============================================== + + async def get_student_achievements( + self, + student_id: str + ) -> List[Achievement]: + """Get achievements with unlock status for a student.""" + await self._ensure_connected() + + # Get student stats for progress calculation + state = await self.get_learning_state(student_id) + + # Calculate progress for each achievement + achievements = [] + for a in ACHIEVEMENTS: + achievement = Achievement( + id=a.id, + name=a.name, + description=a.description, + icon=a.icon, + category=a.category, + threshold=a.threshold, + ) + + # Calculate progress based on category + if state: + if a.category == "general": + achievement.progress = state.total_sessions + achievement.unlocked = state.total_sessions >= a.threshold + elif a.category == "time": + achievement.progress = state.total_play_time_minutes + achievement.unlocked = state.total_play_time_minutes >= a.threshold + elif a.category == "level": + achievement.progress = state.overall_level + achievement.unlocked = state.overall_level >= a.threshold + elif a.category == "accuracy": + if a.id == "accuracy_80" and state.questions_answered >= 50: + achievement.progress = int(state.accuracy * 100) + achievement.unlocked = state.accuracy >= 0.8 + + achievements.append(achievement) + + # Check DB for unlocked achievements (streak, score, perfect game) + if self._pool: + try: + async with self._pool.acquire() as conn: + # Check for score achievements + max_score = await conn.fetchval( + "SELECT MAX(score) FROM game_sessions WHERE student_id = $1", + student_id + ) + if max_score: + for a in achievements: + if a.category == "score": + a.progress = max_score + a.unlocked = max_score >= a.threshold + + # Check for perfect game + perfect = await conn.fetchval( + """ + SELECT COUNT(*) FROM game_sessions + WHERE student_id = $1 + AND questions_answered >= 5 + AND questions_correct = questions_answered + """, + student_id + ) + for a in achievements: + if a.id == "perfect_game": + a.progress = 100 if perfect and perfect > 0 else 0 + a.unlocked = perfect is not None and perfect > 0 + except Exception as e: + logger.error(f"Failed to check achievements: {e}") + + return achievements + + async def check_new_achievements( + self, + student_id: str, + session_score: int, + session_accuracy: float, + streak: int + ) -> List[Achievement]: + """ + Check for newly unlocked achievements after a session. + Returns list of newly unlocked achievements. + """ + all_achievements = await self.get_student_achievements(student_id) + newly_unlocked = [] + + for a in all_achievements: + # Check streak achievements + if a.category == "streak" and streak >= a.threshold and not a.unlocked: + a.unlocked = True + newly_unlocked.append(a) + + # Check score achievements + if a.category == "score" and session_score >= a.threshold and not a.unlocked: + a.unlocked = True + newly_unlocked.append(a) + + # Check perfect game + if a.id == "perfect_game" and session_accuracy == 1.0: + if not a.unlocked: + a.unlocked = True + newly_unlocked.append(a) + + return newly_unlocked + + +# Global database instance +_game_db: Optional[GameDatabase] = None + + +async def get_game_db() -> GameDatabase: + """Get or create the global game database instance.""" + global _game_db + + if _game_db is None: + _game_db = GameDatabase() + await _game_db.connect() + + return _game_db diff --git a/backend/game/learning_rules.py b/backend/game/learning_rules.py new file mode 100644 index 0000000..a9c24b4 --- /dev/null +++ b/backend/game/learning_rules.py @@ -0,0 +1,439 @@ +# ============================================== +# Breakpilot Drive - Learning Rules +# ============================================== +# Adaptive Regeln fuer Lernniveau-Anpassung. +# Integriert mit der bestehenden State Engine. + +import logging +from dataclasses import dataclass +from typing import Optional, List, Dict, Any, Callable +from enum import Enum, auto + +logger = logging.getLogger(__name__) + + +class RulePriority(Enum): + """Priority levels for rule suggestions.""" + LOW = auto() + MEDIUM = auto() + HIGH = auto() + CRITICAL = auto() + + +class ActionType(str, Enum): + """Available actions for learning adjustments.""" + INCREASE_DIFFICULTY = "increase_difficulty" + DECREASE_DIFFICULTY = "decrease_difficulty" + FOCUS_SUBJECT = "focus_subject" + ENCOURAGE = "encourage" + SUGGEST_BREAK = "suggest_break" + CELEBRATE = "celebrate" + REVIEW_TOPIC = "review_topic" + + +@dataclass +class LearningContext: + """Context for rule evaluation.""" + student_id: str + overall_level: int + math_level: float + german_level: float + english_level: float + recent_accuracy: float + recent_questions: int + total_play_time_minutes: int + total_sessions: int + current_streak: int + session_duration_minutes: int + weakest_subject: Optional[str] = None + strongest_subject: Optional[str] = None + + @classmethod + def from_learning_state(cls, state: Any, session_stats: Dict[str, Any] = None): + """Create context from StudentLearningState.""" + session_stats = session_stats or {} + + # Determine weakest and strongest subjects + levels = { + "math": state.math_level, + "german": state.german_level, + "english": state.english_level, + } + weakest = min(levels, key=levels.get) + strongest = max(levels, key=levels.get) + + return cls( + student_id=state.student_id, + overall_level=state.overall_level, + math_level=state.math_level, + german_level=state.german_level, + english_level=state.english_level, + recent_accuracy=session_stats.get("accuracy", state.accuracy), + recent_questions=session_stats.get("questions", state.questions_answered), + total_play_time_minutes=state.total_play_time_minutes, + total_sessions=state.total_sessions, + current_streak=session_stats.get("streak", 0), + session_duration_minutes=session_stats.get("duration_minutes", 0), + weakest_subject=weakest if levels[weakest] < state.overall_level - 0.5 else None, + strongest_subject=strongest if levels[strongest] > state.overall_level + 0.5 else None, + ) + + +@dataclass +class Suggestion: + """A suggestion generated by a rule.""" + title: str + description: str + action: ActionType + priority: RulePriority + metadata: Optional[Dict[str, Any]] = None + + +@dataclass +class LearningRule: + """A rule for adaptive learning adjustments.""" + id: str + name: str + description: str + condition: Callable[[LearningContext], bool] + suggestion_generator: Callable[[LearningContext], Suggestion] + cooldown_minutes: int = 0 # Minimum time between triggers + + +# ============================================== +# Learning Rules Definitions +# ============================================== + +LEARNING_RULES: List[LearningRule] = [ + # ------------------------------------------ + # Difficulty Adjustment Rules + # ------------------------------------------ + LearningRule( + id="level_up_ready", + name="Bereit fuer naechstes Level", + description="Erhoehe Schwierigkeit wenn 80%+ richtig ueber 10 Fragen", + condition=lambda ctx: ( + ctx.recent_accuracy >= 0.8 and + ctx.recent_questions >= 10 and + ctx.overall_level < 5 + ), + suggestion_generator=lambda ctx: Suggestion( + title="Super gemacht!", + description=f"Du hast {int(ctx.recent_accuracy * 100)}% richtig! Zeit fuer schwerere Aufgaben!", + action=ActionType.INCREASE_DIFFICULTY, + priority=RulePriority.HIGH, + metadata={"new_level": ctx.overall_level + 1} + ), + cooldown_minutes=10 + ), + + LearningRule( + id="level_down_needed", + name="Schwierigkeit reduzieren", + description="Verringere Schwierigkeit wenn weniger als 40% richtig", + condition=lambda ctx: ( + ctx.recent_accuracy < 0.4 and + ctx.recent_questions >= 5 and + ctx.overall_level > 1 + ), + suggestion_generator=lambda ctx: Suggestion( + title="Lass uns einfacher anfangen", + description="Kein Problem! Uebung macht den Meister. Wir machen es etwas leichter.", + action=ActionType.DECREASE_DIFFICULTY, + priority=RulePriority.HIGH, + metadata={"new_level": ctx.overall_level - 1} + ), + cooldown_minutes=5 + ), + + # ------------------------------------------ + # Subject Focus Rules + # ------------------------------------------ + LearningRule( + id="weak_subject_detected", + name="Schwaches Fach erkannt", + description="Fokussiere auf Fach mit niedrigstem Level", + condition=lambda ctx: ( + ctx.weakest_subject is not None and + ctx.recent_questions >= 15 + ), + suggestion_generator=lambda ctx: Suggestion( + title=f"Uebe mehr {_subject_name(ctx.weakest_subject)}", + description=f"In {_subject_name(ctx.weakest_subject)} kannst du noch besser werden!", + action=ActionType.FOCUS_SUBJECT, + priority=RulePriority.MEDIUM, + metadata={"subject": ctx.weakest_subject} + ), + cooldown_minutes=30 + ), + + LearningRule( + id="strong_subject_celebration", + name="Starkes Fach feiern", + description="Lobe wenn ein Fach besonders stark ist", + condition=lambda ctx: ( + ctx.strongest_subject is not None and + getattr(ctx, f"{ctx.strongest_subject}_level", 0) >= 4.5 + ), + suggestion_generator=lambda ctx: Suggestion( + title=f"Du bist super in {_subject_name(ctx.strongest_subject)}!", + description="Weiter so! Du bist ein echtes Talent!", + action=ActionType.CELEBRATE, + priority=RulePriority.MEDIUM, + metadata={"subject": ctx.strongest_subject} + ), + cooldown_minutes=60 + ), + + # ------------------------------------------ + # Motivation Rules + # ------------------------------------------ + LearningRule( + id="streak_celebration", + name="Serie feiern", + description="Feiere Erfolgsserien", + condition=lambda ctx: ctx.current_streak >= 5, + suggestion_generator=lambda ctx: Suggestion( + title=f"{ctx.current_streak}x richtig hintereinander!", + description="Unglaublich! Du bist auf Feuer!", + action=ActionType.CELEBRATE, + priority=RulePriority.HIGH, + metadata={"streak": ctx.current_streak} + ), + cooldown_minutes=0 # Can trigger every time + ), + + LearningRule( + id="encourage_after_wrong", + name="Ermutigen nach Fehler", + description="Ermutige nach mehreren falschen Antworten", + condition=lambda ctx: ( + ctx.recent_questions >= 3 and + ctx.recent_accuracy < 0.35 and + ctx.recent_accuracy > 0 # At least some attempts + ), + suggestion_generator=lambda ctx: Suggestion( + title="Nicht aufgeben!", + description="Fehler sind zum Lernen da. Du schaffst das!", + action=ActionType.ENCOURAGE, + priority=RulePriority.MEDIUM, + metadata={} + ), + cooldown_minutes=2 + ), + + LearningRule( + id="first_session_welcome", + name="Erste Session Willkommen", + description="Begruesse neue Spieler", + condition=lambda ctx: ctx.total_sessions == 0, + suggestion_generator=lambda ctx: Suggestion( + title="Willkommen bei Breakpilot Drive!", + description="Los geht's! Sammle Punkte und lerne dabei!", + action=ActionType.ENCOURAGE, + priority=RulePriority.HIGH, + metadata={"is_first_session": True} + ), + cooldown_minutes=0 + ), + + # ------------------------------------------ + # Break Suggestion Rules + # ------------------------------------------ + LearningRule( + id="suggest_break_long_session", + name="Pause vorschlagen (lange Session)", + description="Schlage Pause nach 30 Minuten vor", + condition=lambda ctx: ctx.session_duration_minutes >= 30, + suggestion_generator=lambda ctx: Suggestion( + title="Zeit fuer eine Pause?", + description="Du spielst schon lange. Eine kurze Pause tut gut!", + action=ActionType.SUGGEST_BREAK, + priority=RulePriority.LOW, + metadata={"minutes_played": ctx.session_duration_minutes} + ), + cooldown_minutes=30 + ), + + LearningRule( + id="suggest_break_declining_performance", + name="Pause vorschlagen (sinkende Leistung)", + description="Schlage Pause vor wenn Leistung nachlässt", + condition=lambda ctx: ( + ctx.session_duration_minutes >= 15 and + ctx.recent_accuracy < 0.5 and + ctx.recent_questions >= 10 + ), + suggestion_generator=lambda ctx: Suggestion( + title="Kurze Pause?", + description="Eine kleine Pause kann helfen, wieder fit zu werden!", + action=ActionType.SUGGEST_BREAK, + priority=RulePriority.MEDIUM, + metadata={} + ), + cooldown_minutes=15 + ), + + # ------------------------------------------ + # Review Topic Rules + # ------------------------------------------ + LearningRule( + id="review_failed_topic", + name="Thema wiederholen", + description="Schlage Wiederholung vor bei wiederholten Fehlern", + condition=lambda ctx: ( + ctx.recent_accuracy < 0.3 and + ctx.recent_questions >= 5 + ), + suggestion_generator=lambda ctx: Suggestion( + title="Nochmal ueben?", + description="Lass uns das Thema nochmal gemeinsam anschauen.", + action=ActionType.REVIEW_TOPIC, + priority=RulePriority.MEDIUM, + metadata={} + ), + cooldown_minutes=10 + ), +] + + +def _subject_name(subject: str) -> str: + """Get German display name for subject.""" + names = { + "math": "Mathe", + "german": "Deutsch", + "english": "Englisch", + "general": "Allgemeinwissen" + } + return names.get(subject, subject) + + +class LearningRuleEngine: + """ + Evaluates learning rules against context. + + Tracks cooldowns and returns applicable suggestions. + """ + + def __init__(self): + self._cooldowns: Dict[str, float] = {} # rule_id -> last_triggered_timestamp + + def evaluate( + self, + context: LearningContext, + current_time: float = None + ) -> List[Suggestion]: + """ + Evaluate all rules and return applicable suggestions. + + Returns suggestions sorted by priority (highest first). + """ + import time + current_time = current_time or time.time() + + suggestions = [] + + for rule in LEARNING_RULES: + # Check cooldown + last_triggered = self._cooldowns.get(rule.id, 0) + cooldown_seconds = rule.cooldown_minutes * 60 + + if current_time - last_triggered < cooldown_seconds: + continue + + # Evaluate condition + try: + if rule.condition(context): + suggestion = rule.suggestion_generator(context) + suggestions.append(suggestion) + self._cooldowns[rule.id] = current_time + except Exception as e: + logger.warning(f"Rule {rule.id} evaluation failed: {e}") + + # Sort by priority (highest first) + priority_order = { + RulePriority.CRITICAL: 0, + RulePriority.HIGH: 1, + RulePriority.MEDIUM: 2, + RulePriority.LOW: 3, + } + suggestions.sort(key=lambda s: priority_order.get(s.priority, 99)) + + return suggestions + + def get_top_suggestion(self, context: LearningContext) -> Optional[Suggestion]: + """Get the highest priority suggestion.""" + suggestions = self.evaluate(context) + return suggestions[0] if suggestions else None + + def reset_cooldowns(self): + """Reset all cooldowns (e.g., for new session).""" + self._cooldowns.clear() + + +# Global instance +_rule_engine: Optional[LearningRuleEngine] = None + + +def get_rule_engine() -> LearningRuleEngine: + """Get the global rule engine instance.""" + global _rule_engine + + if _rule_engine is None: + _rule_engine = LearningRuleEngine() + + return _rule_engine + + +# ============================================== +# Helper Functions +# ============================================== + +def calculate_level_adjustment( + recent_accuracy: float, + recent_questions: int, + current_level: int +) -> int: + """ + Calculate recommended level adjustment. + + Returns: -1 (decrease), 0 (keep), 1 (increase) + """ + if recent_questions < 5: + return 0 # Not enough data + + if recent_accuracy >= 0.8 and current_level < 5: + return 1 # Increase + + if recent_accuracy < 0.4 and current_level > 1: + return -1 # Decrease + + return 0 # Keep + + +def get_subject_focus_recommendation( + math_level: float, + german_level: float, + english_level: float, + overall_level: int +) -> Optional[str]: + """ + Get recommendation for which subject to focus on. + + Returns subject name or None if all balanced. + """ + levels = { + "math": math_level, + "german": german_level, + "english": english_level, + } + + # Find subject most below overall level + min_subject = min(levels, key=levels.get) + min_level = levels[min_subject] + + # Only recommend if significantly below overall + if min_level < overall_level - 0.5: + return min_subject + + return None diff --git a/backend/game/quiz_generator.py b/backend/game/quiz_generator.py new file mode 100644 index 0000000..135f9ff --- /dev/null +++ b/backend/game/quiz_generator.py @@ -0,0 +1,439 @@ +# ============================================== +# Breakpilot Drive - Quiz Generator Service +# ============================================== +# Generiert Quiz-Fragen dynamisch via LLM Gateway. +# Unterstuetzt Caching via Valkey fuer Performance. + +import os +import json +import logging +from typing import Optional, List, Dict, Any +from dataclasses import dataclass +from enum import Enum + +logger = logging.getLogger(__name__) + +# Configuration +LLM_MODEL = os.getenv("GAME_LLM_MODEL", "llama-3.1-8b") +LLM_FALLBACK_MODEL = os.getenv("GAME_LLM_FALLBACK_MODEL", "claude-3-haiku") +CACHE_TTL = int(os.getenv("GAME_QUESTION_CACHE_TTL", "3600")) # 1 hour + + +class Subject(str, Enum): + """Available subjects for quiz questions.""" + MATH = "math" + GERMAN = "german" + ENGLISH = "english" + GENERAL = "general" + + +class QuizMode(str, Enum): + """Quiz modes with different time constraints.""" + QUICK = "quick" # 2-3 options, 3-5 seconds + PAUSE = "pause" # 4 options, unlimited time + + +@dataclass +class GeneratedQuestion: + """Generated question from LLM.""" + question_text: str + options: List[str] + correct_index: int + explanation: Optional[str] = None + difficulty: int = 3 + subject: str = "general" + grade_level: int = 4 + quiz_mode: str = "quick" + visual_trigger: Optional[str] = None + time_limit_seconds: Optional[float] = None + + +# ============================================== +# Prompt Templates +# ============================================== + +QUICK_QUESTION_PROMPT = """Du bist ein Lehrer fuer Grundschulkinder (Klasse {grade}). +Erstelle eine SCHNELLE Quiz-Frage zum Thema "{subject}" mit Schwierigkeit {difficulty}/5. + +Kontext: Das Kind faehrt in einem Autorennen-Spiel und sieht gerade ein(e) {visual_trigger}. +Die Frage soll zum visuellen Element passen und in 3-5 Sekunden beantwortbar sein. + +Regeln: +- NUR 2-3 kurze Antwortoptionen +- Frage muss sehr kurz sein (max 10 Woerter) +- Antworten muessen eindeutig richtig/falsch sein +- Kindgerecht und motivierend + +Antworte NUR im JSON-Format: +{{ + "question_text": "Kurze Frage?", + "options": ["Antwort1", "Antwort2"], + "correct_index": 0, + "explanation": "Kurze Erklaerung" +}}""" + +PAUSE_QUESTION_PROMPT = """Du bist ein Lehrer fuer Grundschulkinder (Klasse {grade}). +Erstelle eine DENKAUFGABE zum Thema "{subject}" mit Schwierigkeit {difficulty}/5. + +Das Kind hat Zeit zum Nachdenken (Spiel ist pausiert). +Die Frage darf komplexer sein und Textverstaendnis erfordern. + +Regeln: +- 4 Antwortoptionen +- Frage kann laenger sein (Textaufgabe erlaubt) +- Eine Option ist eindeutig richtig +- Kindgerecht formulieren + +Antworte NUR im JSON-Format: +{{ + "question_text": "Die vollstaendige Frage oder Aufgabe?", + "options": ["Option A", "Option B", "Option C", "Option D"], + "correct_index": 0, + "explanation": "Erklaerung warum diese Antwort richtig ist" +}}""" + +SUBJECT_CONTEXTS = { + "math": { + "quick": ["Kopfrechnen", "Einmaleins", "Plus/Minus"], + "pause": ["Textaufgaben", "Geometrie", "Brueche", "Prozent"] + }, + "german": { + "quick": ["Rechtschreibung", "Artikel"], + "pause": ["Grammatik", "Wortarten", "Satzglieder", "Zeitformen"] + }, + "english": { + "quick": ["Vokabeln", "Farben", "Zahlen", "Tiere"], + "pause": ["Grammatik", "Saetze bilden", "Uebersetzung"] + }, + "general": { + "quick": ["Allgemeinwissen"], + "pause": ["Sachkunde", "Natur", "Geographie"] + } +} + +VISUAL_TRIGGER_THEMES = { + "bridge": { + "math": "Wie lang ist die Bruecke? Wie viele Autos passen drauf?", + "german": "Wie schreibt man Bruecke? Was reimt sich?", + "english": "What is this? Bridge vocabulary" + }, + "tree": { + "math": "Wie viele Blaetter? Wie hoch ist der Baum?", + "german": "Nomen oder Verb? Einzahl/Mehrzahl", + "english": "Tree, leaf, branch vocabulary" + }, + "house": { + "math": "Fenster zaehlen, Stockwerke", + "german": "Wortfamilie Haus", + "english": "House, room vocabulary" + }, + "car": { + "math": "Raeder zaehlen, Geschwindigkeit", + "german": "Fahrzeug-Woerter", + "english": "Car, vehicle vocabulary" + }, + "mountain": { + "math": "Hoehe, Entfernung", + "german": "Landschafts-Begriffe", + "english": "Mountain, hill vocabulary" + }, + "river": { + "math": "Laenge, Breite", + "german": "Wasser-Woerter", + "english": "River, water vocabulary" + } +} + + +class QuizGenerator: + """ + Generates quiz questions using LLM Gateway. + + Supports caching via Valkey for performance. + Falls back to static questions if LLM unavailable. + """ + + def __init__(self): + self._llm_client = None + self._valkey_client = None + self._llm_available = False + self._cache_available = False + + async def connect(self): + """Initialize LLM and cache connections.""" + await self._connect_llm() + await self._connect_cache() + + async def _connect_llm(self): + """Connect to LLM Gateway.""" + try: + # Try to import LLM client from existing gateway + from llm_gateway.services.inference import InferenceService + self._llm_client = InferenceService() + self._llm_available = True + logger.info("Quiz Generator connected to LLM Gateway") + except ImportError: + logger.warning("LLM Gateway not available, using static questions") + self._llm_available = False + except Exception as e: + logger.warning(f"LLM connection failed: {e}") + self._llm_available = False + + async def _connect_cache(self): + """Connect to Valkey cache.""" + try: + import redis.asyncio as redis + valkey_url = os.getenv("VALKEY_URL", "redis://localhost:6379") + self._valkey_client = redis.from_url( + valkey_url, + encoding="utf-8", + decode_responses=True, + ) + await self._valkey_client.ping() + self._cache_available = True + logger.info("Quiz Generator connected to Valkey cache") + except Exception as e: + logger.warning(f"Valkey cache not available: {e}") + self._cache_available = False + + def _get_cache_key( + self, + difficulty: int, + subject: str, + mode: str, + visual_trigger: Optional[str] = None + ) -> str: + """Generate cache key for questions.""" + if visual_trigger: + return f"quiz:d{difficulty}:s{subject}:m{mode}:v{visual_trigger}" + return f"quiz:d{difficulty}:s{subject}:m{mode}" + + async def get_cached_questions( + self, + difficulty: int, + subject: str, + mode: str, + count: int, + visual_trigger: Optional[str] = None + ) -> List[GeneratedQuestion]: + """Get questions from cache.""" + if not self._cache_available: + return [] + + try: + cache_key = self._get_cache_key(difficulty, subject, mode, visual_trigger) + cached = await self._valkey_client.lrange(cache_key, 0, count - 1) + + questions = [] + for item in cached: + data = json.loads(item) + questions.append(GeneratedQuestion(**data)) + + return questions + except Exception as e: + logger.warning(f"Cache read failed: {e}") + return [] + + async def cache_questions( + self, + questions: List[GeneratedQuestion], + difficulty: int, + subject: str, + mode: str, + visual_trigger: Optional[str] = None + ): + """Store questions in cache.""" + if not self._cache_available: + return + + try: + cache_key = self._get_cache_key(difficulty, subject, mode, visual_trigger) + + for q in questions: + data = { + "question_text": q.question_text, + "options": q.options, + "correct_index": q.correct_index, + "explanation": q.explanation, + "difficulty": q.difficulty, + "subject": q.subject, + "grade_level": q.grade_level, + "quiz_mode": q.quiz_mode, + "visual_trigger": q.visual_trigger, + "time_limit_seconds": q.time_limit_seconds, + } + await self._valkey_client.rpush(cache_key, json.dumps(data)) + + await self._valkey_client.expire(cache_key, CACHE_TTL) + except Exception as e: + logger.warning(f"Cache write failed: {e}") + + async def generate_question( + self, + difficulty: int = 3, + subject: str = "general", + mode: str = "quick", + grade: int = 4, + visual_trigger: Optional[str] = None + ) -> Optional[GeneratedQuestion]: + """ + Generate a single question using LLM. + + Falls back to None if LLM unavailable (caller should use static questions). + """ + if not self._llm_available or not self._llm_client: + return None + + # Select prompt template + if mode == "quick": + prompt = QUICK_QUESTION_PROMPT.format( + grade=grade, + subject=subject, + difficulty=difficulty, + visual_trigger=visual_trigger or "Strasse" + ) + time_limit = 3.0 + (difficulty * 0.5) # 3.5 - 5.5 seconds + else: + prompt = PAUSE_QUESTION_PROMPT.format( + grade=grade, + subject=subject, + difficulty=difficulty + ) + time_limit = None + + try: + # Call LLM Gateway + response = await self._llm_client.chat_completion( + messages=[{"role": "user", "content": prompt}], + model=LLM_MODEL, + temperature=0.7, + max_tokens=500 + ) + + # Parse JSON response + content = response.get("content", "") + + # Extract JSON from response (handle markdown code blocks) + if "```json" in content: + content = content.split("```json")[1].split("```")[0] + elif "```" in content: + content = content.split("```")[1].split("```")[0] + + data = json.loads(content.strip()) + + return GeneratedQuestion( + question_text=data["question_text"], + options=data["options"], + correct_index=data["correct_index"], + explanation=data.get("explanation"), + difficulty=difficulty, + subject=subject, + grade_level=grade, + quiz_mode=mode, + visual_trigger=visual_trigger, + time_limit_seconds=time_limit + ) + + except json.JSONDecodeError as e: + logger.warning(f"Failed to parse LLM response: {e}") + return None + except Exception as e: + logger.error(f"LLM question generation failed: {e}") + return None + + async def generate_questions_batch( + self, + difficulty: int, + subject: str, + mode: str, + count: int, + grade: int = 4, + visual_trigger: Optional[str] = None + ) -> List[GeneratedQuestion]: + """Generate multiple questions.""" + questions = [] + + for _ in range(count): + q = await self.generate_question( + difficulty=difficulty, + subject=subject, + mode=mode, + grade=grade, + visual_trigger=visual_trigger + ) + if q: + questions.append(q) + + return questions + + async def get_questions( + self, + difficulty: int = 3, + subject: str = "general", + mode: str = "quick", + count: int = 5, + grade: int = 4, + visual_trigger: Optional[str] = None + ) -> List[GeneratedQuestion]: + """ + Get questions with caching. + + 1. Check cache first + 2. Generate new if not enough cached + 3. Cache new questions + 4. Return combined result + """ + # Try cache first + cached = await self.get_cached_questions( + difficulty, subject, mode, count, visual_trigger + ) + + if len(cached) >= count: + return cached[:count] + + # Generate more questions + needed = count - len(cached) + new_questions = await self.generate_questions_batch( + difficulty=difficulty, + subject=subject, + mode=mode, + count=needed * 2, # Generate extra for cache + grade=grade, + visual_trigger=visual_trigger + ) + + # Cache new questions + if new_questions: + await self.cache_questions( + new_questions, difficulty, subject, mode, visual_trigger + ) + + # Combine and return + all_questions = cached + new_questions + return all_questions[:count] + + def get_grade_for_difficulty(self, difficulty: int) -> int: + """Map difficulty level to grade level.""" + mapping = { + 1: 2, # Klasse 2 + 2: 3, # Klasse 3 + 3: 4, # Klasse 4 + 4: 5, # Klasse 5 + 5: 6, # Klasse 6 + } + return mapping.get(difficulty, 4) + + +# Global instance +_quiz_generator: Optional[QuizGenerator] = None + + +async def get_quiz_generator() -> QuizGenerator: + """Get or create the global quiz generator instance.""" + global _quiz_generator + + if _quiz_generator is None: + _quiz_generator = QuizGenerator() + await _quiz_generator.connect() + + return _quiz_generator diff --git a/backend/game_api.py b/backend/game_api.py new file mode 100644 index 0000000..cc5496d --- /dev/null +++ b/backend/game_api.py @@ -0,0 +1,1129 @@ +# ============================================== +# Breakpilot Drive - Game API +# ============================================== +# API-Endpunkte fuer das Lernspiel: +# - Lernniveau aus Breakpilot abrufen +# - Quiz-Fragen bereitstellen +# - Spielsessions protokollieren +# - Offline-Sync unterstuetzen +# +# Mit PostgreSQL-Integration fuer persistente Speicherung. +# Fallback auf In-Memory wenn DB nicht verfuegbar. +# +# Auth: Optional via GAME_REQUIRE_AUTH=true + +from fastapi import APIRouter, HTTPException, Query, Depends, Request +from pydantic import BaseModel +from typing import List, Optional, Literal, Dict, Any +from datetime import datetime +import random +import uuid +import os +import logging + +logger = logging.getLogger(__name__) + +# Feature flags +USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true" +REQUIRE_AUTH = os.getenv("GAME_REQUIRE_AUTH", "false").lower() == "true" + +router = APIRouter(prefix="/api/game", tags=["Breakpilot Drive"]) + + +# ============================================== +# Auth Dependency (Optional) +# ============================================== + +async def get_optional_current_user(request: Request) -> Optional[Dict[str, Any]]: + """ + Optional auth dependency for Game API. + + If GAME_REQUIRE_AUTH=true: Requires valid JWT token + If GAME_REQUIRE_AUTH=false: Returns None (anonymous access) + + In development mode without auth, returns demo user. + """ + if not REQUIRE_AUTH: + return None + + try: + from auth import get_current_user + return await get_current_user(request) + except ImportError: + logger.warning("Auth module not available") + return None + except HTTPException: + raise # Re-raise auth errors + except Exception as e: + logger.error(f"Auth error: {e}") + raise HTTPException(status_code=401, detail="Authentication failed") + + +def get_user_id_from_auth( + user: Optional[Dict[str, Any]], + requested_user_id: str +) -> str: + """ + Get the effective user ID, respecting auth when enabled. + + If auth is enabled and user is authenticated: + - Returns user's own ID if requested_user_id matches + - For parents: allows access to child IDs from token + - For teachers: allows access to student IDs (future) + + If auth is disabled: Returns requested_user_id as-is + """ + if not REQUIRE_AUTH or user is None: + return requested_user_id + + user_id = user.get("user_id", "") + + # Same user - always allowed + if requested_user_id == user_id: + return user_id + + # Check for parent accessing child data + children_ids = user.get("raw_claims", {}).get("children_ids", []) + if requested_user_id in children_ids: + return requested_user_id + + # Check for teacher accessing student data (future) + realm_roles = user.get("realm_roles", []) + if "lehrer" in realm_roles or "teacher" in realm_roles: + # Teachers can access any student in their class (implement class check later) + return requested_user_id + + # Admin bypass + if "admin" in realm_roles: + return requested_user_id + + # Not authorized + raise HTTPException( + status_code=403, + detail="Not authorized to access this user's data" + ) + + +# ============================================== +# Pydantic Models +# ============================================== + +class LearningLevel(BaseModel): + """Lernniveau eines Benutzers aus dem Breakpilot-System""" + user_id: str + overall_level: int # 1-5 (1=Anfaenger/Klasse 2, 5=Fortgeschritten/Klasse 6) + math_level: float + german_level: float + english_level: float + last_updated: datetime + + +class GameDifficulty(BaseModel): + """Spielschwierigkeit basierend auf Lernniveau""" + lane_speed: float # Geschwindigkeit in m/s + obstacle_frequency: float # Hindernisse pro Sekunde + power_up_chance: float # Wahrscheinlichkeit fuer Power-Ups (0-1) + question_complexity: int # 1-5 + answer_time: int # Sekunden zum Antworten + hints_enabled: bool + speech_speed: float # Sprechgeschwindigkeit fuer Audio-Version + + +class QuizQuestion(BaseModel): + """Quiz-Frage fuer das Spiel""" + id: str + question_text: str + audio_url: Optional[str] = None + options: List[str] # 2-4 Antwortmoeglichkeiten + correct_index: int # 0-3 + difficulty: int # 1-5 + subject: Literal["math", "german", "english", "general"] + grade_level: Optional[int] = None # 2-6 + # NEU: Quiz-Modus + quiz_mode: Literal["quick", "pause"] = "quick" # quick=waehrend Fahrt, pause=Spiel haelt an + visual_trigger: Optional[str] = None # z.B. "bridge", "house", "tree" - loest Frage aus + time_limit_seconds: Optional[float] = None # Zeit bis Antwort noetig (bei quick) + + +class QuizAnswer(BaseModel): + """Antwort auf eine Quiz-Frage""" + question_id: str + selected_index: int + answer_time_ms: int # Zeit bis zur Antwort in ms + was_correct: bool + + +class GameSession(BaseModel): + """Spielsession-Daten fuer Analytics""" + user_id: str + game_mode: Literal["video", "audio"] + duration_seconds: int + distance_traveled: float + score: int + questions_answered: int + questions_correct: int + difficulty_level: int + quiz_answers: Optional[List[QuizAnswer]] = None + + +class SessionResponse(BaseModel): + """Antwort nach Session-Speicherung""" + session_id: str + status: str + new_level: Optional[int] = None # Falls Lernniveau angepasst wurde + + +# ============================================== +# Schwierigkeits-Mapping +# ============================================== + +DIFFICULTY_MAPPING = { + 1: GameDifficulty( + lane_speed=3.0, + obstacle_frequency=0.3, + power_up_chance=0.4, + question_complexity=1, + answer_time=15, + hints_enabled=True, + speech_speed=0.8 + ), + 2: GameDifficulty( + lane_speed=4.0, + obstacle_frequency=0.4, + power_up_chance=0.35, + question_complexity=2, + answer_time=12, + hints_enabled=True, + speech_speed=0.9 + ), + 3: GameDifficulty( + lane_speed=5.0, + obstacle_frequency=0.5, + power_up_chance=0.3, + question_complexity=3, + answer_time=10, + hints_enabled=True, + speech_speed=1.0 + ), + 4: GameDifficulty( + lane_speed=6.0, + obstacle_frequency=0.6, + power_up_chance=0.25, + question_complexity=4, + answer_time=8, + hints_enabled=False, + speech_speed=1.1 + ), + 5: GameDifficulty( + lane_speed=7.0, + obstacle_frequency=0.7, + power_up_chance=0.2, + question_complexity=5, + answer_time=6, + hints_enabled=False, + speech_speed=1.2 + ), +} + +# ============================================== +# Beispiel Quiz-Fragen (spaeter aus DB laden) +# ============================================== + +SAMPLE_QUESTIONS = [ + # ============================================== + # QUICK QUESTIONS (waehrend der Fahrt, visuell getriggert) + # ============================================== + + # Englisch Vokabeln - Objekte im Spiel (QUICK MODE) + QuizQuestion( + id="vq-bridge", question_text="What is this?", + options=["Bridge", "House"], correct_index=0, + difficulty=1, subject="english", grade_level=3, + quiz_mode="quick", visual_trigger="bridge", time_limit_seconds=3.0 + ), + QuizQuestion( + id="vq-tree", question_text="What is this?", + options=["Tree", "Flower"], correct_index=0, + difficulty=1, subject="english", grade_level=3, + quiz_mode="quick", visual_trigger="tree", time_limit_seconds=3.0 + ), + QuizQuestion( + id="vq-house", question_text="What is this?", + options=["House", "Car"], correct_index=0, + difficulty=1, subject="english", grade_level=3, + quiz_mode="quick", visual_trigger="house", time_limit_seconds=3.0 + ), + QuizQuestion( + id="vq-car", question_text="What is this?", + options=["Car", "Bus"], correct_index=0, + difficulty=1, subject="english", grade_level=3, + quiz_mode="quick", visual_trigger="car", time_limit_seconds=2.5 + ), + QuizQuestion( + id="vq-mountain", question_text="What is this?", + options=["Hill", "Mountain", "Valley"], correct_index=1, + difficulty=2, subject="english", grade_level=4, + quiz_mode="quick", visual_trigger="mountain", time_limit_seconds=3.5 + ), + QuizQuestion( + id="vq-river", question_text="What is this?", + options=["Lake", "River", "Sea"], correct_index=1, + difficulty=2, subject="english", grade_level=4, + quiz_mode="quick", visual_trigger="river", time_limit_seconds=3.5 + ), + + # Schnelle Rechenaufgaben (QUICK MODE) + QuizQuestion( + id="mq-1", question_text="3 + 4 = ?", + options=["6", "7"], correct_index=1, + difficulty=1, subject="math", grade_level=2, + quiz_mode="quick", time_limit_seconds=4.0 + ), + QuizQuestion( + id="mq-2", question_text="5 x 2 = ?", + options=["10", "12"], correct_index=0, + difficulty=1, subject="math", grade_level=2, + quiz_mode="quick", time_limit_seconds=4.0 + ), + QuizQuestion( + id="mq-3", question_text="8 - 3 = ?", + options=["4", "5"], correct_index=1, + difficulty=1, subject="math", grade_level=2, + quiz_mode="quick", time_limit_seconds=3.5 + ), + QuizQuestion( + id="mq-4", question_text="6 x 7 = ?", + options=["42", "48"], correct_index=0, + difficulty=2, subject="math", grade_level=3, + quiz_mode="quick", time_limit_seconds=5.0 + ), + QuizQuestion( + id="mq-5", question_text="9 x 8 = ?", + options=["72", "64"], correct_index=0, + difficulty=3, subject="math", grade_level=4, + quiz_mode="quick", time_limit_seconds=5.0 + ), + + # ============================================== + # PAUSE QUESTIONS (Spiel haelt an, mehr Zeit) + # ============================================== + + # Mathe Level 1-2 (Klasse 2-3) - PAUSE MODE + QuizQuestion( + id="mp1-1", question_text="Anna hat 5 Aepfel. Sie bekommt 3 dazu. Wie viele hat sie jetzt?", + options=["6", "7", "8", "9"], correct_index=2, + difficulty=1, subject="math", grade_level=2, + quiz_mode="pause" + ), + QuizQuestion( + id="mp2-1", question_text="Ein Bus hat 24 Sitze. 18 sind besetzt. Wie viele sind frei?", + options=["4", "5", "6", "7"], correct_index=2, + difficulty=2, subject="math", grade_level=3, + quiz_mode="pause" + ), + QuizQuestion( + id="mp2-2", question_text="Was ist 45 + 27?", + options=["72", "62", "82", "70"], correct_index=0, + difficulty=2, subject="math", grade_level=3, + quiz_mode="pause" + ), + + # Mathe Level 3-4 (Klasse 4-5) - PAUSE MODE + QuizQuestion( + id="mp3-1", question_text="Was ist 7 x 8?", + options=["54", "56", "58", "48"], correct_index=1, + difficulty=3, subject="math", grade_level=4, + quiz_mode="pause" + ), + QuizQuestion( + id="mp3-2", question_text="Ein Rechteck ist 8m lang und 5m breit. Wie gross ist die Flaeche?", + options=["35 m2", "40 m2", "45 m2", "26 m2"], correct_index=1, + difficulty=3, subject="math", grade_level=4, + quiz_mode="pause" + ), + QuizQuestion( + id="mp4-1", question_text="Was ist 15% von 80?", + options=["10", "12", "8", "15"], correct_index=1, + difficulty=4, subject="math", grade_level=5, + quiz_mode="pause" + ), + QuizQuestion( + id="mp4-2", question_text="Was ist 3/4 + 1/2?", + options=["5/4", "4/6", "1", "5/6"], correct_index=0, + difficulty=4, subject="math", grade_level=5, + quiz_mode="pause" + ), + + # Mathe Level 5 (Klasse 6) - PAUSE MODE + QuizQuestion( + id="mp5-1", question_text="Was ist (-5) x (-3)?", + options=["-15", "15", "-8", "8"], correct_index=1, + difficulty=5, subject="math", grade_level=6, + quiz_mode="pause" + ), + QuizQuestion( + id="mp5-2", question_text="Loesung von 2x + 5 = 11?", + options=["2", "3", "4", "6"], correct_index=1, + difficulty=5, subject="math", grade_level=6, + quiz_mode="pause" + ), + + # Deutsch - PAUSE MODE (brauchen Lesezeit) + QuizQuestion( + id="dp1-1", question_text="Welches Wort ist ein Nomen?", + options=["laufen", "schnell", "Hund", "und"], correct_index=2, + difficulty=1, subject="german", grade_level=2, + quiz_mode="pause" + ), + QuizQuestion( + id="dp2-1", question_text="Was ist die Mehrzahl von 'Haus'?", + options=["Haeuse", "Haeuser", "Hausern", "Haus"], correct_index=1, + difficulty=2, subject="german", grade_level=3, + quiz_mode="pause" + ), + QuizQuestion( + id="dp3-1", question_text="Welches Verb steht im Praeteritum?", + options=["geht", "ging", "gegangen", "gehen"], correct_index=1, + difficulty=3, subject="german", grade_level=4, + quiz_mode="pause" + ), + QuizQuestion( + id="dp3-2", question_text="Finde den Rechtschreibfehler: 'Der Hund leuft schnell.'", + options=["Hund", "leuft", "schnell", "Der"], correct_index=1, + difficulty=3, subject="german", grade_level=4, + quiz_mode="pause" + ), + + # Englisch Saetze - PAUSE MODE + QuizQuestion( + id="ep3-1", question_text="How do you say 'Schmetterling'?", + options=["bird", "bee", "butterfly", "beetle"], correct_index=2, + difficulty=3, subject="english", grade_level=4, + quiz_mode="pause" + ), + QuizQuestion( + id="ep4-1", question_text="Choose the correct form: She ___ to school.", + options=["go", "goes", "going", "gone"], correct_index=1, + difficulty=4, subject="english", grade_level=5, + quiz_mode="pause" + ), + QuizQuestion( + id="ep4-2", question_text="What is the past tense of 'run'?", + options=["runned", "ran", "runed", "running"], correct_index=1, + difficulty=4, subject="english", grade_level=5, + quiz_mode="pause" + ), +] + +# In-Memory Session Storage (Fallback wenn DB nicht verfuegbar) +_sessions: dict[str, GameSession] = {} +_user_levels: dict[str, LearningLevel] = {} + +# Database integration +_game_db = None + +async def get_game_database(): + """Get game database instance with lazy initialization.""" + global _game_db + if not USE_DATABASE: + return None + if _game_db is None: + try: + from game.database import get_game_db + _game_db = await get_game_db() + logger.info("Game database initialized") + except Exception as e: + logger.warning(f"Game database not available, using in-memory: {e}") + return _game_db + + +# ============================================== +# API Endpunkte +# ============================================== + +@router.get("/learning-level/{user_id}", response_model=LearningLevel) +async def get_learning_level( + user_id: str, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> LearningLevel: + """ + Holt das aktuelle Lernniveau eines Benutzers aus Breakpilot. + + - Wird beim Spielstart aufgerufen um Schwierigkeit anzupassen + - Gibt Level 1-5 zurueck (1=Anfaenger, 5=Fortgeschritten) + - Cached Werte fuer schnellen Zugriff + - Speichert in PostgreSQL wenn verfuegbar + - Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten + """ + # Verify access rights + user_id = get_user_id_from_auth(user, user_id) + + # Try database first + db = await get_game_database() + if db: + state = await db.get_learning_state(user_id) + if state: + return LearningLevel( + user_id=user_id, + overall_level=state.overall_level, + math_level=state.math_level, + german_level=state.german_level, + english_level=state.english_level, + last_updated=state.updated_at or datetime.now() + ) + + # Create new state in database + new_state = await db.create_or_update_learning_state( + student_id=user_id, + overall_level=3, + math_level=3.0, + german_level=3.0, + english_level=3.0 + ) + if new_state: + return LearningLevel( + user_id=user_id, + overall_level=new_state.overall_level, + math_level=new_state.math_level, + german_level=new_state.german_level, + english_level=new_state.english_level, + last_updated=new_state.updated_at or datetime.now() + ) + + # Fallback to in-memory + if user_id in _user_levels: + return _user_levels[user_id] + + # Standard-Level fuer neue Benutzer + default_level = LearningLevel( + user_id=user_id, + overall_level=3, # Mittleres Level als Default + math_level=3.0, + german_level=3.0, + english_level=3.0, + last_updated=datetime.now() + ) + _user_levels[user_id] = default_level + return default_level + + +@router.get("/difficulty/{level}", response_model=GameDifficulty) +async def get_game_difficulty(level: int) -> GameDifficulty: + """ + Gibt Spielparameter basierend auf Lernniveau zurueck. + + Level 1-5 werden auf Spielgeschwindigkeit, Hindernisfrequenz, + Fragen-Schwierigkeit etc. gemappt. + """ + if level < 1 or level > 5: + raise HTTPException(status_code=400, detail="Level muss zwischen 1 und 5 sein") + + return DIFFICULTY_MAPPING[level] + + +@router.get("/quiz/questions", response_model=List[QuizQuestion]) +async def get_quiz_questions( + difficulty: int = Query(3, ge=1, le=5, description="Schwierigkeitsgrad 1-5"), + count: int = Query(10, ge=1, le=50, description="Anzahl der Fragen"), + subject: Optional[str] = Query(None, description="Fach: math, german, english, oder None fuer gemischt"), + mode: Optional[str] = Query(None, description="Quiz-Modus: quick (waehrend Fahrt), pause (Spiel pausiert), oder None fuer beide") +) -> List[QuizQuestion]: + """ + Holt Quiz-Fragen fuer das Spiel. + + - Filtert nach Schwierigkeitsgrad (+/- 1 Level) + - Optional nach Fach filterbar + - Optional nach Modus: "quick" (visuelle Fragen waehrend Fahrt) oder "pause" (Denkaufgaben) + - Gibt zufaellige Auswahl zurueck + """ + # Fragen nach Schwierigkeit filtern (+/- 1 Level Toleranz) + filtered = [ + q for q in SAMPLE_QUESTIONS + if abs(q.difficulty - difficulty) <= 1 + and (subject is None or q.subject == subject) + and (mode is None or q.quiz_mode == mode) + ] + + if not filtered: + # Fallback: Alle Fragen wenn keine passenden gefunden + filtered = [q for q in SAMPLE_QUESTIONS if mode is None or q.quiz_mode == mode] + + # Zufaellige Auswahl + selected = random.sample(filtered, min(count, len(filtered))) + return selected + + +@router.get("/quiz/visual-triggers") +async def get_visual_triggers() -> List[dict]: + """ + Gibt alle verfuegbaren visuellen Trigger zurueck. + + Unity verwendet diese Liste um zu wissen, welche Objekte + im Spiel Quiz-Fragen ausloesen koennen. + """ + triggers = {} + for q in SAMPLE_QUESTIONS: + if q.visual_trigger and q.quiz_mode == "quick": + if q.visual_trigger not in triggers: + triggers[q.visual_trigger] = { + "trigger": q.visual_trigger, + "question_count": 0, + "difficulties": set(), + "subjects": set() + } + triggers[q.visual_trigger]["question_count"] += 1 + triggers[q.visual_trigger]["difficulties"].add(q.difficulty) + triggers[q.visual_trigger]["subjects"].add(q.subject) + + # Sets zu Listen konvertieren fuer JSON + return [ + { + "trigger": t["trigger"], + "question_count": t["question_count"], + "difficulties": list(t["difficulties"]), + "subjects": list(t["subjects"]) + } + for t in triggers.values() + ] + + +@router.post("/quiz/answer") +async def submit_quiz_answer(answer: QuizAnswer) -> dict: + """ + Verarbeitet eine Quiz-Antwort (fuer Echtzeit-Feedback). + + In der finalen Version: Speichert in Session, updated Analytics. + """ + return { + "question_id": answer.question_id, + "was_correct": answer.was_correct, + "points": 500 if answer.was_correct else -100, + "message": "Richtig! Weiter so!" if answer.was_correct else "Nicht ganz, versuch es nochmal!" + } + + +@router.post("/session", response_model=SessionResponse) +async def save_game_session( + session: GameSession, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> SessionResponse: + """ + Speichert eine komplette Spielsession. + + - Protokolliert Score, Distanz, Fragen-Performance + - Aktualisiert Lernniveau bei genuegend Daten + - Wird am Ende jedes Spiels aufgerufen + - Speichert in PostgreSQL wenn verfuegbar + - Bei GAME_REQUIRE_AUTH=true: User-ID aus Token + """ + # If auth is enabled, use user_id from token (ignore session.user_id) + effective_user_id = session.user_id + if REQUIRE_AUTH and user: + effective_user_id = user.get("user_id", session.user_id) + + session_id = str(uuid.uuid4()) + + # Lernniveau-Anpassung basierend auf Performance + new_level = None + old_level = 3 # Default + + # Try to get current level first + db = await get_game_database() + if db: + state = await db.get_learning_state(effective_user_id) + if state: + old_level = state.overall_level + else: + # Create initial state if not exists + await db.create_or_update_learning_state(effective_user_id) + old_level = 3 + elif effective_user_id in _user_levels: + old_level = _user_levels[effective_user_id].overall_level + + # Calculate level adjustment + if session.questions_answered >= 5: + accuracy = session.questions_correct / session.questions_answered + + # Anpassung: Wenn >80% korrekt und max nicht erreicht → Level up + if accuracy >= 0.8 and old_level < 5: + new_level = old_level + 1 + # Wenn <40% korrekt und min nicht erreicht → Level down + elif accuracy < 0.4 and old_level > 1: + new_level = old_level - 1 + + # Save to database + if db: + # Save session + db_session_id = await db.save_game_session( + student_id=effective_user_id, + game_mode=session.game_mode, + duration_seconds=session.duration_seconds, + distance_traveled=session.distance_traveled, + score=session.score, + questions_answered=session.questions_answered, + questions_correct=session.questions_correct, + difficulty_level=session.difficulty_level, + ) + if db_session_id: + session_id = db_session_id + + # Save individual quiz answers if provided + if session.quiz_answers: + for answer in session.quiz_answers: + await db.save_quiz_answer( + session_id=session_id, + question_id=answer.question_id, + subject="general", # Could be enhanced to track actual subject + difficulty=session.difficulty_level, + is_correct=answer.was_correct, + answer_time_ms=answer.answer_time_ms, + ) + + # Update learning stats + duration_minutes = session.duration_seconds // 60 + await db.update_learning_stats( + student_id=effective_user_id, + duration_minutes=duration_minutes, + questions_answered=session.questions_answered, + questions_correct=session.questions_correct, + new_level=new_level, + ) + else: + # Fallback to in-memory + _sessions[session_id] = session + + if new_level: + _user_levels[effective_user_id] = LearningLevel( + user_id=effective_user_id, + overall_level=new_level, + math_level=float(new_level), + german_level=float(new_level), + english_level=float(new_level), + last_updated=datetime.now() + ) + + return SessionResponse( + session_id=session_id, + status="saved", + new_level=new_level + ) + + +@router.get("/sessions/{user_id}") +async def get_user_sessions( + user_id: str, + limit: int = Query(10, ge=1, le=100), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> List[dict]: + """ + Holt die letzten Spielsessions eines Benutzers. + + Fuer Statistiken und Fortschrittsanzeige. + Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten. + """ + # Verify access rights + user_id = get_user_id_from_auth(user, user_id) + + # Try database first + db = await get_game_database() + if db: + sessions = await db.get_user_sessions(user_id, limit) + if sessions: + return sessions + + # Fallback to in-memory + user_sessions = [ + {"session_id": sid, **s.model_dump()} + for sid, s in _sessions.items() + if s.user_id == user_id + ] + return user_sessions[:limit] + + +@router.get("/leaderboard") +async def get_leaderboard( + timeframe: str = Query("day", description="day, week, month, all"), + limit: int = Query(10, ge=1, le=100) +) -> List[dict]: + """ + Gibt Highscore-Liste zurueck. + + - Sortiert nach Punktzahl + - Optional nach Zeitraum filterbar + """ + # Try database first + db = await get_game_database() + if db: + leaderboard = await db.get_leaderboard(timeframe, limit) + if leaderboard: + return leaderboard + + # Fallback to in-memory + # Aggregiere Scores pro User + user_scores: dict[str, int] = {} + for session in _sessions.values(): + if session.user_id not in user_scores: + user_scores[session.user_id] = 0 + user_scores[session.user_id] += session.score + + # Sortieren und limitieren + leaderboard = [ + {"rank": i + 1, "user_id": uid, "total_score": score} + for i, (uid, score) in enumerate( + sorted(user_scores.items(), key=lambda x: x[1], reverse=True)[:limit] + ) + ] + + return leaderboard + + +@router.get("/stats/{user_id}") +async def get_user_stats( + user_id: str, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> dict: + """ + Gibt detaillierte Statistiken fuer einen Benutzer zurueck. + + - Gesamtstatistiken + - Fach-spezifische Statistiken + - Lernniveau-Verlauf + - Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten + """ + # Verify access rights + user_id = get_user_id_from_auth(user, user_id) + + db = await get_game_database() + if db: + state = await db.get_learning_state(user_id) + subject_stats = await db.get_subject_stats(user_id) + + if state: + return { + "user_id": user_id, + "overall_level": state.overall_level, + "math_level": state.math_level, + "german_level": state.german_level, + "english_level": state.english_level, + "total_play_time_minutes": state.total_play_time_minutes, + "total_sessions": state.total_sessions, + "questions_answered": state.questions_answered, + "questions_correct": state.questions_correct, + "accuracy": state.accuracy, + "subjects": subject_stats, + } + + # Fallback - return defaults + return { + "user_id": user_id, + "overall_level": 3, + "math_level": 3.0, + "german_level": 3.0, + "english_level": 3.0, + "total_play_time_minutes": 0, + "total_sessions": 0, + "questions_answered": 0, + "questions_correct": 0, + "accuracy": 0.0, + "subjects": {}, + } + + +@router.get("/suggestions/{user_id}") +async def get_learning_suggestions( + user_id: str, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> dict: + """ + Gibt adaptive Lernvorschlaege fuer einen Benutzer zurueck. + + Basierend auf aktueller Performance und Lernhistorie. + Bei GAME_REQUIRE_AUTH=true: Nur eigene oder Kind-Daten. + """ + # Verify access rights + user_id = get_user_id_from_auth(user, user_id) + + db = await get_game_database() + if not db: + return {"suggestions": [], "message": "Database not available"} + + state = await db.get_learning_state(user_id) + if not state: + return {"suggestions": [], "message": "No learning state found"} + + try: + from game.learning_rules import ( + LearningContext, + get_rule_engine, + ) + + # Create context from state + context = LearningContext.from_learning_state(state) + + # Get suggestions from rule engine + engine = get_rule_engine() + suggestions = engine.evaluate(context) + + return { + "user_id": user_id, + "overall_level": state.overall_level, + "suggestions": [ + { + "title": s.title, + "description": s.description, + "action": s.action.value, + "priority": s.priority.name.lower(), + "metadata": s.metadata or {}, + } + for s in suggestions[:3] # Top 3 suggestions + ] + } + except ImportError: + return {"suggestions": [], "message": "Learning rules not available"} + except Exception as e: + logger.warning(f"Failed to get suggestions: {e}") + return {"suggestions": [], "message": str(e)} + + +@router.get("/quiz/generate") +async def generate_quiz_questions( + difficulty: int = Query(3, ge=1, le=5, description="Schwierigkeitsgrad 1-5"), + count: int = Query(5, ge=1, le=20, description="Anzahl der Fragen"), + subject: Optional[str] = Query(None, description="Fach: math, german, english"), + mode: str = Query("quick", description="Quiz-Modus: quick oder pause"), + visual_trigger: Optional[str] = Query(None, description="Visueller Trigger: bridge, tree, house, etc.") +) -> List[dict]: + """ + Generiert Quiz-Fragen dynamisch via LLM. + + Fallback auf statische Fragen wenn LLM nicht verfuegbar. + """ + try: + from game.quiz_generator import get_quiz_generator + + generator = await get_quiz_generator() + questions = await generator.get_questions( + difficulty=difficulty, + subject=subject or "general", + mode=mode, + count=count, + visual_trigger=visual_trigger + ) + + if questions: + return [ + { + "id": f"gen-{i}", + "question_text": q.question_text, + "options": q.options, + "correct_index": q.correct_index, + "difficulty": q.difficulty, + "subject": q.subject, + "grade_level": q.grade_level, + "quiz_mode": q.quiz_mode, + "visual_trigger": q.visual_trigger, + "time_limit_seconds": q.time_limit_seconds, + } + for i, q in enumerate(questions) + ] + except ImportError: + logger.info("Quiz generator not available, using static questions") + except Exception as e: + logger.warning(f"Quiz generation failed: {e}") + + # Fallback to static questions + return await get_quiz_questions(difficulty, count, subject, mode) + + +@router.get("/health") +async def health_check() -> dict: + """Health-Check fuer das Spiel-Backend.""" + db = await get_game_database() + db_status = "connected" if db and db._connected else "disconnected" + + # Check LLM availability + llm_status = "disabled" + try: + from game.quiz_generator import get_quiz_generator + generator = await get_quiz_generator() + llm_status = "connected" if generator._llm_available else "disconnected" + except: + pass + + return { + "status": "healthy", + "service": "breakpilot-drive", + "database": db_status, + "llm_generator": llm_status, + "auth_required": REQUIRE_AUTH, + "questions_available": len(SAMPLE_QUESTIONS), + "active_sessions": len(_sessions) + } + + +# ============================================== +# Phase 5: Erweiterte Features +# ============================================== + +@router.get("/achievements/{user_id}") +async def get_achievements( + user_id: str, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> dict: + """ + Gibt Achievements mit Fortschritt fuer einen Benutzer zurueck. + + Achievements werden basierend auf Spielstatistiken berechnet. + """ + # Verify access rights + user_id = get_user_id_from_auth(user, user_id) + + db = await get_game_database() + if not db: + return {"achievements": [], "message": "Database not available"} + + try: + achievements = await db.get_student_achievements(user_id) + + unlocked = [a for a in achievements if a.unlocked] + locked = [a for a in achievements if not a.unlocked] + + return { + "user_id": user_id, + "total": len(achievements), + "unlocked_count": len(unlocked), + "achievements": [ + { + "id": a.id, + "name": a.name, + "description": a.description, + "icon": a.icon, + "category": a.category, + "threshold": a.threshold, + "progress": a.progress, + "unlocked": a.unlocked, + } + for a in achievements + ] + } + except Exception as e: + logger.error(f"Failed to get achievements: {e}") + return {"achievements": [], "message": str(e)} + + +@router.get("/progress/{user_id}") +async def get_progress( + user_id: str, + days: int = Query(30, ge=7, le=90, description="Anzahl Tage zurueck"), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> dict: + """ + Gibt Lernfortschritt ueber Zeit zurueck (fuer Charts). + + - Taegliche Statistiken + - Fuer Eltern-Dashboard und Fortschrittsanzeige + """ + # Verify access rights + user_id = get_user_id_from_auth(user, user_id) + + db = await get_game_database() + if not db: + return {"progress": [], "message": "Database not available"} + + try: + progress = await db.get_progress_over_time(user_id, days) + return { + "user_id": user_id, + "days": days, + "data_points": len(progress), + "progress": progress, + } + except Exception as e: + logger.error(f"Failed to get progress: {e}") + return {"progress": [], "message": str(e)} + + +@router.get("/parent/children") +async def get_children_dashboard( + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> dict: + """ + Eltern-Dashboard: Statistiken fuer alle Kinder. + + Erfordert Auth mit Eltern-Rolle und children_ids Claim. + """ + if not REQUIRE_AUTH or user is None: + return { + "message": "Auth required for parent dashboard", + "children": [] + } + + # Get children IDs from token + children_ids = user.get("raw_claims", {}).get("children_ids", []) + + if not children_ids: + return { + "message": "No children associated with this account", + "children": [] + } + + db = await get_game_database() + if not db: + return {"children": [], "message": "Database not available"} + + try: + children_stats = await db.get_children_stats(children_ids) + return { + "parent_id": user.get("user_id"), + "children_count": len(children_ids), + "children": children_stats, + } + except Exception as e: + logger.error(f"Failed to get children stats: {e}") + return {"children": [], "message": str(e)} + + +@router.get("/leaderboard/class/{class_id}") +async def get_class_leaderboard( + class_id: str, + timeframe: str = Query("week", description="day, week, month, all"), + limit: int = Query(10, ge=1, le=50), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> List[dict]: + """ + Klassenspezifische Rangliste. + + Nur fuer Lehrer oder Schueler der Klasse sichtbar. + """ + db = await get_game_database() + if not db: + return [] + + try: + leaderboard = await db.get_class_leaderboard(class_id, timeframe, limit) + return leaderboard + except Exception as e: + logger.error(f"Failed to get class leaderboard: {e}") + return [] + + +@router.get("/leaderboard/display") +async def get_display_leaderboard( + timeframe: str = Query("day", description="day, week, month, all"), + limit: int = Query(10, ge=1, le=100), + anonymize: bool = Query(True, description="Namen anonymisieren") +) -> List[dict]: + """ + Oeffentliche Rangliste mit Anzeigenamen. + + Standardmaessig anonymisiert fuer Datenschutz. + """ + db = await get_game_database() + if not db: + return [] + + try: + return await db.get_leaderboard_with_names(timeframe, limit, anonymize) + except Exception as e: + logger.error(f"Failed to get display leaderboard: {e}") + return [] diff --git a/backend/gdpr_api.py b/backend/gdpr_api.py new file mode 100644 index 0000000..fa0a373 --- /dev/null +++ b/backend/gdpr_api.py @@ -0,0 +1,363 @@ +""" +GDPR API Endpoints für BreakPilot +Stellt DSGVO-konforme Funktionen für Datenauskunft und -löschung bereit + +Endpoints: +- POST /privacy/export-pdf - PDF-Datenauskunft herunterladen +- GET /privacy/export-html - HTML-Preview der Datenauskunft +- GET /privacy/data-categories - Datenkategorien mit Löschfristen +- POST /privacy/request-deletion - Löschanfrage einreichen +""" + +from fastapi import APIRouter, HTTPException, Header, Query +from fastapi.responses import Response, HTMLResponse +from typing import Optional +from pydantic import BaseModel +import os + +from gdpr_export_service import ( + GDPRExportService, + get_data_retention_policies, + get_essential_data_categories, + get_optional_data_categories, + WEASYPRINT_AVAILABLE +) +from consent_client import generate_jwt_token + +# Consent Service URL +CONSENT_SERVICE_URL = os.getenv("CONSENT_SERVICE_URL", "http://localhost:8081") + +# Admin User UUID +ADMIN_USER_UUID = "a0000000-0000-0000-0000-000000000001" + +router = APIRouter(prefix="/consent/privacy", tags=["gdpr-privacy"]) + +# Service-Instanz +gdpr_service = GDPRExportService() + + +# Request Models +class DeletionRequest(BaseModel): + reason: Optional[str] = None + confirm: bool = False + + +# Helper für Token +def get_user_token(authorization: Optional[str]) -> str: + """ + Extrahiert Token aus Authorization Header oder generiert einen für Dev. + """ + if authorization: + parts = authorization.split(" ") + if len(parts) == 2 and parts[0] == "Bearer": + return parts[1] + + # Für Entwicklung: Generiere einen Demo-Token + return generate_jwt_token( + user_id="demo-user-12345", + email="demo@breakpilot.app", + role="user" + ) + + +# ========================================== +# DSGVO Art. 15: Auskunftsrecht +# ========================================== + +@router.post("/export-pdf", response_class=Response) +async def export_user_data_pdf(authorization: Optional[str] = Header(None)): + """ + Generiert eine PDF-Datenauskunft gemäß DSGVO Art. 15. + + Returns: + PDF-Dokument mit allen gespeicherten Nutzerdaten + """ + if not WEASYPRINT_AVAILABLE: + raise HTTPException( + status_code=501, + detail={ + "error": "PDF-Export nicht verfügbar", + "message": "WeasyPrint ist nicht installiert. Bitte nutzen Sie den HTML-Export.", + "alternative": "/consent/privacy/export-html" + } + ) + + token = get_user_token(authorization) + + try: + pdf_bytes = await gdpr_service.generate_user_data_pdf(token) + + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={ + "Content-Disposition": "attachment; filename=breakpilot_datenauskunft.pdf", + "Cache-Control": "no-store, no-cache, must-revalidate", + "Pragma": "no-cache" + } + ) + except Exception as e: + raise HTTPException( + status_code=500, + detail={ + "error": "PDF-Generierung fehlgeschlagen", + "message": str(e) + } + ) + + +@router.get("/export-html", response_class=HTMLResponse) +async def export_user_data_html(authorization: Optional[str] = Header(None)): + """ + Generiert eine HTML-Datenauskunft (Preview oder Alternative zu PDF). + + Returns: + HTML-Dokument mit allen gespeicherten Nutzerdaten + """ + token = get_user_token(authorization) + + try: + html_content = await gdpr_service.generate_user_data_html(token) + return HTMLResponse( + content=html_content, + headers={ + "Cache-Control": "no-store, no-cache, must-revalidate", + "Pragma": "no-cache" + } + ) + except Exception as e: + raise HTTPException( + status_code=500, + detail={ + "error": "HTML-Generierung fehlgeschlagen", + "message": str(e) + } + ) + + +# ========================================== +# Datenkategorien & Löschfristen +# ========================================== + +@router.get("/data-categories") +async def get_data_categories( + filter: Optional[str] = Query(None, description="Filter: 'essential', 'optional', oder leer für alle") +): + """ + Gibt alle Datenkategorien mit ihren Löschfristen zurück. + + Diese Information wird auch im PDF-Export angezeigt und gibt Nutzern + Transparenz darüber, welche Daten wie lange gespeichert werden. + + Query Parameters: + filter: 'essential' für Pflicht-Daten, 'optional' für Opt-in Daten + """ + if filter == "essential": + categories = get_essential_data_categories() + elif filter == "optional": + categories = get_optional_data_categories() + else: + categories = get_data_retention_policies() + + return { + "categories": categories, + "total_count": len(categories), + "filter_applied": filter, + "info": { + "essential": "Diese Daten sind für den Betrieb des Dienstes erforderlich", + "optional": "Diese Daten werden nur bei expliziter Zustimmung erhoben" + } + } + + +@router.get("/data-categories/{category}") +async def get_data_category_details(category: str): + """ + Gibt Details zu einer spezifischen Datenkategorie zurück. + """ + all_categories = get_data_retention_policies() + for cat in all_categories: + if cat["category"] == category: + return cat + + raise HTTPException( + status_code=404, + detail=f"Datenkategorie '{category}' nicht gefunden" + ) + + +# ========================================== +# DSGVO Art. 17: Recht auf Löschung +# ========================================== + +@router.post("/request-deletion") +async def request_data_deletion( + request: DeletionRequest, + authorization: Optional[str] = Header(None) +): + """ + Reicht einen Antrag auf Datenlöschung ein (DSGVO Art. 17). + + Der Antrag wird protokolliert und innerhalb von 30 Tagen bearbeitet. + Bestimmte Daten müssen aufgrund gesetzlicher Aufbewahrungsfristen + möglicherweise länger gespeichert werden. + + Body: + reason: Optionaler Grund für die Löschung + confirm: Muss true sein zur Bestätigung + """ + if not request.confirm: + raise HTTPException( + status_code=400, + detail={ + "error": "Bestätigung erforderlich", + "message": "Bitte bestätigen Sie den Löschantrag mit 'confirm: true'", + "warning": "Die Löschung Ihrer Daten kann nicht rückgängig gemacht werden!" + } + ) + + token = get_user_token(authorization) + + # TODO: Hier sollte der Löschantrag an den Go-Service weitergeleitet werden + # Für jetzt: Platzhalter-Response + + return { + "status": "pending", + "message": "Ihr Löschantrag wurde eingereicht", + "details": { + "request_date": "2024-01-15T10:30:00Z", + "expected_completion": "Innerhalb von 30 Tagen", + "reason_provided": request.reason is not None, + "exceptions": [ + "Consent-Nachweise (3 Jahre Aufbewahrungspflicht)", + "Anonymisierte Audit-Logs (Compliance)" + ] + }, + "next_steps": [ + "Sie erhalten eine Bestätigungs-E-Mail", + "Die Löschung wird nach Prüfung durchgeführt", + "Bei vollständiger Löschung wird Ihr Account deaktiviert" + ] + } + + +# ========================================== +# Admin Endpoints für GDPR +# ========================================== + +admin_router = APIRouter(prefix="/consent/admin/privacy", tags=["gdpr-admin"]) + + +def get_admin_token(authorization: Optional[str]) -> str: + """Extrahiert Admin-Token aus Authorization Header oder generiert einen für Dev.""" + if authorization: + parts = authorization.split(" ") + if len(parts) == 2 and parts[0] == "Bearer": + return parts[1] + + return generate_jwt_token( + user_id=ADMIN_USER_UUID, + email="admin@breakpilot.app", + role="admin" + ) + + +@admin_router.get("/export-pdf/{user_id}", response_class=Response) +async def admin_export_user_data_pdf( + user_id: str, + authorization: Optional[str] = Header(None) +): + """ + [Admin] Generiert PDF-Datenauskunft für einen beliebigen Nutzer. + + Nur für Admins: Ermöglicht Export von Nutzerdaten für Support-Anfragen + oder Behördenanfragen. + """ + if not WEASYPRINT_AVAILABLE: + raise HTTPException( + status_code=501, + detail="PDF-Export nicht verfügbar. WeasyPrint fehlt." + ) + + # Für Admin-Export: Generiere Token für den angegebenen Nutzer + # In Produktion sollte hier eine echte Nutzer-Abfrage stattfinden + token = generate_jwt_token( + user_id=user_id, + email=f"user-{user_id[:8]}@breakpilot.app", + role="user" + ) + + try: + pdf_bytes = await gdpr_service.generate_user_data_pdf(token) + + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={ + "Content-Disposition": f"attachment; filename=datenauskunft_{user_id[:8]}.pdf", + "Cache-Control": "no-store" + } + ) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@admin_router.get("/deletion-requests") +async def admin_get_deletion_requests( + status: Optional[str] = Query(None, description="Filter: pending, processing, completed"), + page: int = Query(1, ge=1), + per_page: int = Query(20, ge=1, le=100), + authorization: Optional[str] = Header(None) +): + """ + [Admin] Gibt alle Löschanträge zurück. + """ + # TODO: Implementierung mit echtem Backend + return { + "requests": [], + "total": 0, + "page": page, + "per_page": per_page, + "status_filter": status, + "message": "Löschanträge-Verwaltung noch nicht implementiert" + } + + +@admin_router.post("/deletion-requests/{request_id}/process") +async def admin_process_deletion_request( + request_id: str, + authorization: Optional[str] = Header(None) +): + """ + [Admin] Bearbeitet einen Löschantrag. + """ + # TODO: Implementierung mit echtem Backend + return { + "request_id": request_id, + "status": "processing", + "message": "Löschantrag wird bearbeitet" + } + + +@admin_router.get("/retention-stats") +async def admin_get_retention_stats(authorization: Optional[str] = Header(None)): + """ + [Admin] Gibt Statistiken über Daten und Löschfristen zurück. + """ + # TODO: Implementierung mit echtem Backend + categories = get_data_retention_policies() + + return { + "total_categories": len(categories), + "essential_categories": len(get_essential_data_categories()), + "optional_categories": len(get_optional_data_categories()), + "categories": [ + { + "name": cat["name_de"], + "retention_days": cat.get("retention_days"), + "is_essential": cat.get("is_essential", True) + } + for cat in categories + ], + "message": "Detaillierte Statistiken noch nicht implementiert" + } diff --git a/backend/gdpr_export_service.py b/backend/gdpr_export_service.py new file mode 100644 index 0000000..44ab7be --- /dev/null +++ b/backend/gdpr_export_service.py @@ -0,0 +1,460 @@ +""" +GDPR Export Service für BreakPilot +Generiert PDF-Datenauskunft gemäß DSGVO Art. 15 + +Datenkategorien mit Löschfristen: +- Stammdaten: Account-Löschung + 30 Tage +- Einwilligungen: 3 Jahre nach Widerruf/Ablauf +- IP-Adressen: 4 Wochen +- Session-Daten: Nach Sitzungsende +- Audit-Log (personenbezogen): 3 Jahre +- Analytics (Opt-in): 26 Monate +- Marketing (Opt-in): 12 Monate +""" + +import os +import io +import uuid +import httpx +from datetime import datetime +from typing import Optional, Dict, Any, List +from pathlib import Path +from jinja2 import Environment, FileSystemLoader, select_autoescape + +# WeasyPrint für PDF-Generierung +# WeasyPrint benötigt System-Libraries (GTK/Pango/Cairo) +# Falls nicht verfügbar, wird nur HTML-Export unterstützt +WEASYPRINT_AVAILABLE = False +HTML = None +CSS = None + +try: + from weasyprint import HTML, CSS + WEASYPRINT_AVAILABLE = True +except (ImportError, OSError) as e: + # ImportError: weasyprint nicht installiert + # OSError: System-Libraries fehlen (libgobject, etc.) + print(f"WeasyPrint nicht verfügbar: {e}") + print("PDF-Export deaktiviert. HTML-Export ist weiterhin möglich.") + WEASYPRINT_AVAILABLE = False + +# Consent Service URL +CONSENT_SERVICE_URL = os.getenv("CONSENT_SERVICE_URL", "http://localhost:8081") + + +class GDPRExportService: + """Service für DSGVO-konforme Datenexporte als PDF""" + + def __init__(self, template_dir: str = None): + """ + Initialisiert den Export Service. + + Args: + template_dir: Pfad zum Templates-Verzeichnis + """ + if template_dir is None: + template_dir = str(Path(__file__).parent / "templates" / "gdpr") + + self.template_dir = template_dir + self.jinja_env = Environment( + loader=FileSystemLoader(template_dir), + autoescape=select_autoescape(['html', 'xml']) + ) + + # Custom Filter registrieren + self.jinja_env.filters['format_datetime'] = self._format_datetime + self.jinja_env.filters['translate_action'] = self._translate_action + + @staticmethod + def _format_datetime(value: Optional[str]) -> str: + """Formatiert ISO-Datetime für Anzeige""" + if not value: + return "-" + try: + if isinstance(value, str): + # ISO Format: 2024-01-15T10:30:00Z + dt = datetime.fromisoformat(value.replace('Z', '+00:00')) + else: + dt = value + return dt.strftime("%d.%m.%Y %H:%M") + except (ValueError, AttributeError): + return str(value) if value else "-" + + @staticmethod + def _translate_action(action: str) -> str: + """Übersetzt Audit-Log Aktionen ins Deutsche""" + translations = { + "login": "Anmeldung", + "logout": "Abmeldung", + "register": "Registrierung", + "consent_given": "Einwilligung erteilt", + "consent_withdrawn": "Einwilligung widerrufen", + "cookie_consent_updated": "Cookie-Präferenzen aktualisiert", + "password_changed": "Passwort geändert", + "password_reset_requested": "Passwort-Reset angefordert", + "password_reset_completed": "Passwort zurückgesetzt", + "email_verified": "E-Mail verifiziert", + "profile_updated": "Profil aktualisiert", + "data_export_requested": "Datenexport angefordert", + "data_deletion_requested": "Datenlöschung angefordert", + "session_created": "Sitzung gestartet", + "session_revoked": "Sitzung beendet", + "version_published": "Version veröffentlicht", + "version_approved": "Version genehmigt", + "version_rejected": "Version abgelehnt", + } + return translations.get(action, action) + + async def get_user_data(self, token: str) -> Dict[str, Any]: + """ + Holt alle Nutzerdaten vom Consent Service. + + Args: + token: JWT Token des Nutzers + + Returns: + Dictionary mit allen Nutzerdaten + """ + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + + user_data = { + "user": {}, + "consents": [], + "cookie_consents": [], + "audit_logs": [], + "sessions": [] + } + + async with httpx.AsyncClient() as client: + # Profildaten + try: + profile_resp = await client.get( + f"{CONSENT_SERVICE_URL}/api/v1/profile", + headers=headers, + timeout=10.0 + ) + if profile_resp.status_code == 200: + user_data["user"] = profile_resp.json() + except Exception as e: + print(f"Error fetching profile: {e}") + + # Einwilligungen + try: + consents_resp = await client.get( + f"{CONSENT_SERVICE_URL}/api/v1/consent/my", + headers=headers, + timeout=10.0 + ) + if consents_resp.status_code == 200: + data = consents_resp.json() + user_data["consents"] = data.get("consents", data if isinstance(data, list) else []) + except Exception as e: + print(f"Error fetching consents: {e}") + + # Cookie-Präferenzen + try: + cookies_resp = await client.get( + f"{CONSENT_SERVICE_URL}/api/v1/cookies/consent/my", + headers=headers, + timeout=10.0 + ) + if cookies_resp.status_code == 200: + data = cookies_resp.json() + user_data["cookie_consents"] = data.get("consents", data if isinstance(data, list) else []) + except Exception as e: + print(f"Error fetching cookie consents: {e}") + + # Meine Daten (GDPR endpoint) + try: + my_data_resp = await client.get( + f"{CONSENT_SERVICE_URL}/api/v1/privacy/my-data", + headers=headers, + timeout=10.0 + ) + if my_data_resp.status_code == 200: + my_data = my_data_resp.json() + # Merge additional data + if "audit_log" in my_data: + user_data["audit_logs"] = my_data["audit_log"] + if "sessions" in my_data: + user_data["sessions"] = my_data["sessions"] + if "user" in my_data and not user_data["user"]: + user_data["user"] = my_data["user"] + except Exception as e: + print(f"Error fetching my-data: {e}") + + # Aktive Sessions + try: + sessions_resp = await client.get( + f"{CONSENT_SERVICE_URL}/api/v1/profile/sessions", + headers=headers, + timeout=10.0 + ) + if sessions_resp.status_code == 200: + data = sessions_resp.json() + if not user_data["sessions"]: + user_data["sessions"] = data.get("sessions", data if isinstance(data, list) else []) + except Exception as e: + print(f"Error fetching sessions: {e}") + + return user_data + + def render_html(self, data: Dict[str, Any]) -> str: + """ + Rendert das HTML-Template mit den Nutzerdaten. + + Args: + data: Nutzerdaten Dictionary + + Returns: + Gerendertes HTML + """ + template = self.jinja_env.get_template("gdpr_export.html") + + # Kontext für das Template vorbereiten + context = { + "export_date": datetime.now().strftime("%d.%m.%Y %H:%M"), + "document_id": f"GDPR-{uuid.uuid4().hex[:8].upper()}", + "user": data.get("user", {}), + "consents": data.get("consents", []), + "cookie_consents": data.get("cookie_consents", []), + "audit_logs": data.get("audit_logs", []), + + # Company info (kann später aus Config kommen) + "company_name": "BreakPilot GmbH", + "company_address": "Musterstraße 1", + "company_city": "12345 Musterstadt", + "dpo_name": "Datenschutzbeauftragter", + "dpo_email": "datenschutz@breakpilot.app" + } + + return template.render(**context) + + def generate_pdf(self, html_content: str) -> bytes: + """ + Konvertiert HTML zu PDF mit WeasyPrint. + + Args: + html_content: Gerendertes HTML + + Returns: + PDF als Bytes + + Raises: + RuntimeError: Wenn WeasyPrint nicht verfügbar ist + """ + if not WEASYPRINT_AVAILABLE: + raise RuntimeError( + "WeasyPrint ist nicht installiert. " + "Bitte installieren Sie: pip install weasyprint" + ) + + # PDF generieren + html = HTML(string=html_content, base_url=self.template_dir) + pdf_buffer = io.BytesIO() + html.write_pdf(pdf_buffer) + + return pdf_buffer.getvalue() + + async def generate_user_data_pdf(self, token: str) -> bytes: + """ + Komplette Pipeline: Daten holen, HTML rendern, PDF generieren. + + Args: + token: JWT Token des Nutzers + + Returns: + PDF als Bytes + """ + # 1. Nutzerdaten abrufen + user_data = await self.get_user_data(token) + + # 2. HTML rendern + html_content = self.render_html(user_data) + + # 3. PDF generieren + pdf_bytes = self.generate_pdf(html_content) + + return pdf_bytes + + async def generate_user_data_html(self, token: str) -> str: + """ + Generiert nur HTML (für Preview oder wenn PDF nicht verfügbar). + + Args: + token: JWT Token des Nutzers + + Returns: + Gerendertes HTML + """ + user_data = await self.get_user_data(token) + return self.render_html(user_data) + + +# Datenkategorien und Löschfristen (für API-Response) +DATA_RETENTION_POLICIES = [ + { + "category": "stammdaten", + "name_de": "Stammdaten", + "name_en": "Master Data", + "description_de": "Name, E-Mail-Adresse, Kontoinformationen", + "description_en": "Name, email address, account information", + "retention_period": "Account-Löschung + 30 Tage", + "retention_days": None, # Abhängig von Account-Löschung + "legal_basis": "Vertragserfüllung (Art. 6 Abs. 1 lit. b DSGVO)", + "is_essential": True + }, + { + "category": "consent_records", + "name_de": "Einwilligungen", + "name_en": "Consent Records", + "description_de": "Consent-Entscheidungen, Dokumentversionen", + "description_en": "Consent decisions, document versions", + "retention_period": "3 Jahre nach Widerruf/Ablauf", + "retention_days": 1095, # 3 Jahre + "legal_basis": "Gesetzliche Nachweispflicht (§ 7a UWG)", + "is_essential": True + }, + { + "category": "ip_addresses", + "name_de": "IP-Adressen", + "name_en": "IP Addresses", + "description_de": "Technische Protokollierung bei Aktionen", + "description_en": "Technical logging during actions", + "retention_period": "4 Wochen", + "retention_days": 28, + "legal_basis": "Berechtigtes Interesse (Art. 6 Abs. 1 lit. f DSGVO)", + "is_essential": True + }, + { + "category": "session_data", + "name_de": "Session-Daten", + "name_en": "Session Data", + "description_de": "Login-Tokens, Sitzungsinformationen", + "description_en": "Login tokens, session information", + "retention_period": "Nach Sitzungsende oder 24h Inaktivität", + "retention_days": 1, + "legal_basis": "Vertragserfüllung (Art. 6 Abs. 1 lit. b DSGVO)", + "is_essential": True + }, + { + "category": "audit_log", + "name_de": "Audit-Log", + "name_en": "Audit Log", + "description_de": "Protokoll aller datenschutzrelevanten Aktionen", + "description_en": "Log of all privacy-relevant actions", + "retention_period": "3 Jahre (personenbezogen)", + "retention_days": 1095, + "legal_basis": "Berechtigtes Interesse / Compliance", + "is_essential": True + }, + { + "category": "password_reset_tokens", + "name_de": "Passwort-Reset-Tokens", + "name_en": "Password Reset Tokens", + "description_de": "Temporäre Tokens für Passwort-Zurücksetzung", + "description_en": "Temporary tokens for password reset", + "retention_period": "24 Stunden oder nach Nutzung", + "retention_days": 1, + "legal_basis": "Vertragserfüllung", + "is_essential": True + }, + { + "category": "email_verification_tokens", + "name_de": "E-Mail-Verifikations-Tokens", + "name_en": "Email Verification Tokens", + "description_de": "Tokens für E-Mail-Bestätigung", + "description_en": "Tokens for email confirmation", + "retention_period": "7 Tage oder nach Nutzung", + "retention_days": 7, + "legal_basis": "Vertragserfüllung", + "is_essential": True + }, + { + "category": "analytics", + "name_de": "Analytics-Daten", + "name_en": "Analytics Data", + "description_de": "Nutzungsstatistiken (nur bei Zustimmung)", + "description_en": "Usage statistics (only with consent)", + "retention_period": "26 Monate", + "retention_days": 790, + "legal_basis": "Einwilligung (Art. 6 Abs. 1 lit. a DSGVO)", + "is_essential": False, + "cookie_category": "analytics" + }, + { + "category": "marketing", + "name_de": "Marketing-Daten", + "name_en": "Marketing Data", + "description_de": "Werbe-Identifier (nur bei Zustimmung)", + "description_en": "Advertising identifiers (only with consent)", + "retention_period": "12 Monate", + "retention_days": 365, + "legal_basis": "Einwilligung (Art. 6 Abs. 1 lit. a DSGVO)", + "is_essential": False, + "cookie_category": "marketing" + }, + { + "category": "functional", + "name_de": "Funktionale Daten", + "name_en": "Functional Data", + "description_de": "Personalisierung, Präferenzen (bei Zustimmung)", + "description_en": "Personalization, preferences (with consent)", + "retention_period": "6 Monate", + "retention_days": 180, + "legal_basis": "Einwilligung (Art. 6 Abs. 1 lit. a DSGVO)", + "is_essential": False, + "cookie_category": "functional" + }, + { + "category": "export_requests", + "name_de": "Export-Anfragen", + "name_en": "Export Requests", + "description_de": "Anträge auf Datenauskunft", + "description_en": "Data access requests", + "retention_period": "30 Tage nach Abschluss", + "retention_days": 30, + "legal_basis": "Vertragserfüllung / DSGVO Art. 15", + "is_essential": True + }, + { + "category": "deletion_requests", + "name_de": "Lösch-Anfragen", + "name_en": "Deletion Requests", + "description_de": "Anträge auf Datenlöschung (anonymisiert)", + "description_en": "Data deletion requests (anonymized)", + "retention_period": "3 Jahre (anonymisiert)", + "retention_days": 1095, + "legal_basis": "Nachweis der Löschung", + "is_essential": True + }, + { + "category": "notifications", + "name_de": "Benachrichtigungen", + "name_en": "Notifications", + "description_de": "System-Benachrichtigungen an den Nutzer", + "description_en": "System notifications to user", + "retention_period": "90 Tage nach Lesen", + "retention_days": 90, + "legal_basis": "Vertragserfüllung", + "is_essential": True + } +] + + +def get_data_retention_policies() -> List[Dict[str, Any]]: + """Gibt alle Datenkategorien mit Löschfristen zurück""" + return DATA_RETENTION_POLICIES + + +def get_essential_data_categories() -> List[Dict[str, Any]]: + """Gibt nur essenzielle Datenkategorien zurück""" + return [p for p in DATA_RETENTION_POLICIES if p.get("is_essential", True)] + + +def get_optional_data_categories() -> List[Dict[str, Any]]: + """Gibt optionale (opt-in) Datenkategorien zurück""" + return [p for p in DATA_RETENTION_POLICIES if not p.get("is_essential", True)] diff --git a/backend/generators/__init__.py b/backend/generators/__init__.py new file mode 100644 index 0000000..68c727a --- /dev/null +++ b/backend/generators/__init__.py @@ -0,0 +1,14 @@ +# Worksheet Generators Module +# AI-powered generators for educational content + +from .mc_generator import MultipleChoiceGenerator +from .cloze_generator import ClozeGenerator +from .mindmap_generator import MindmapGenerator +from .quiz_generator import QuizGenerator + +__all__ = [ + "MultipleChoiceGenerator", + "ClozeGenerator", + "MindmapGenerator", + "QuizGenerator" +] diff --git a/backend/generators/cloze_generator.py b/backend/generators/cloze_generator.py new file mode 100644 index 0000000..60fbcaf --- /dev/null +++ b/backend/generators/cloze_generator.py @@ -0,0 +1,380 @@ +""" +Cloze Generator - Erstellt Lückentexte aus Quelltexten. + +Generiert: +- Lückentexte mit ausgeblendeten Schlüsselwörtern +- Verschiedene Schwierigkeitsgrade +- Hinweise und Erklärungen +""" + +import logging +import json +import re +from typing import List, Dict, Any, Optional +from dataclasses import dataclass +from enum import Enum + +logger = logging.getLogger(__name__) + + +class ClozeType(str, Enum): + """Typen von Lückentexten.""" + FILL_IN = "fill_in" # Freies Ausfüllen + DRAG_DROP = "drag_drop" # Drag & Drop + DROPDOWN = "dropdown" # Dropdown-Auswahl + + +@dataclass +class ClozeGap: + """Eine Lücke im Text.""" + position: int # Position im Text (0-basiert) + answer: str # Korrekte Antwort + alternatives: List[str] # Alternative korrekte Antworten + hint: Optional[str] # Hinweis + distractors: List[str] # Falsche Optionen (für Dropdown/Drag-Drop) + + +@dataclass +class ClozeText: + """Ein kompletter Lückentext.""" + text_with_gaps: str # Text mit Platzhaltern + original_text: str # Originaltext + gaps: List[ClozeGap] # Liste der Lücken + cloze_type: ClozeType # Typ des Lückentexts + topic: Optional[str] # Thema + difficulty: str # easy, medium, hard + + +class ClozeGenerator: + """ + Generiert Lückentexte aus Quelltexten. + + Unterstützt verschiedene Modi: + - Automatische Erkennung wichtiger Begriffe + - LLM-basierte intelligente Auswahl + - Manuelle Vorgabe von Lücken + """ + + def __init__(self, llm_client=None): + """ + Initialisiert den Generator. + + Args: + llm_client: Optional - LLM-Client für intelligente Generierung + """ + self.llm_client = llm_client + logger.info("ClozeGenerator initialized") + + # Wortarten, die oft als Lücken geeignet sind + self._important_pos = {"NOUN", "VERB", "ADJ"} # Substantive, Verben, Adjektive + + def generate( + self, + source_text: str, + num_gaps: int = 5, + difficulty: str = "medium", + cloze_type: ClozeType = ClozeType.FILL_IN, + topic: Optional[str] = None + ) -> ClozeText: + """ + Generiert einen Lückentext aus einem Quelltext. + + Args: + source_text: Der Ausgangstext + num_gaps: Anzahl der Lücken + difficulty: Schwierigkeitsgrad (easy, medium, hard) + cloze_type: Art des Lückentexts + topic: Optionales Thema + + Returns: + ClozeText-Objekt + """ + logger.info(f"Generating cloze text with {num_gaps} gaps (difficulty: {difficulty})") + + if not source_text or len(source_text.strip()) < 50: + logger.warning("Source text too short") + return self._empty_cloze(source_text, cloze_type) + + if self.llm_client: + return self._generate_with_llm( + source_text, num_gaps, difficulty, cloze_type, topic + ) + else: + return self._generate_automatic( + source_text, num_gaps, difficulty, cloze_type, topic + ) + + def _generate_with_llm( + self, + source_text: str, + num_gaps: int, + difficulty: str, + cloze_type: ClozeType, + topic: Optional[str] + ) -> ClozeText: + """Generiert Lückentext mit LLM.""" + prompt = f""" +Erstelle einen Lückentext auf Deutsch basierend auf folgendem Text. +Ersetze {num_gaps} wichtige Begriffe durch Lücken. +Schwierigkeitsgrad: {difficulty} +{f'Thema: {topic}' if topic else ''} + +Originaltext: +{source_text} + +Wähle {num_gaps} wichtige Begriffe (Substantive, Verben, Fachbegriffe) aus. +Für jeden Begriff gib an: +- Das Wort, das ausgeblendet wird +- Alternative Schreibweisen (falls vorhanden) +- Einen Hinweis +- 3 ähnliche aber falsche Wörter (Distraktoren) + +Antworte im JSON-Format: +{{ + "gaps": [ + {{ + "word": "Photosynthese", + "alternatives": ["Fotosynthese"], + "hint": "Prozess bei dem Pflanzen Licht nutzen", + "distractors": ["Zellatmung", "Osmose", "Diffusion"] + }} + ] +}} +""" + + try: + response = self.llm_client.generate(prompt) + data = json.loads(response) + return self._create_cloze_from_llm( + source_text, data, difficulty, cloze_type, topic + ) + except Exception as e: + logger.error(f"Error generating with LLM: {e}") + return self._generate_automatic( + source_text, num_gaps, difficulty, cloze_type, topic + ) + + def _generate_automatic( + self, + source_text: str, + num_gaps: int, + difficulty: str, + cloze_type: ClozeType, + topic: Optional[str] + ) -> ClozeText: + """Generiert Lückentext automatisch ohne LLM.""" + # Finde wichtige Wörter + words = self._find_important_words(source_text) + + # Wähle Wörter basierend auf Schwierigkeit + selected = self._select_words_by_difficulty(words, num_gaps, difficulty) + + # Erstelle Lücken + gaps = [] + text_with_gaps = source_text + + for i, (word, pos) in enumerate(selected): + # Position im aktuellen Text finden + match = re.search(r'\b' + re.escape(word) + r'\b', text_with_gaps) + if match: + # Ersetze durch Platzhalter + placeholder = f"[_{i+1}_]" + text_with_gaps = text_with_gaps[:match.start()] + placeholder + text_with_gaps[match.end():] + + gap = ClozeGap( + position=i, + answer=word, + alternatives=[word.lower(), word.upper()], + hint=self._generate_hint(word, source_text), + distractors=self._generate_distractors(word, words) + ) + gaps.append(gap) + + return ClozeText( + text_with_gaps=text_with_gaps, + original_text=source_text, + gaps=gaps, + cloze_type=cloze_type, + topic=topic, + difficulty=difficulty + ) + + def _find_important_words(self, text: str) -> List[tuple]: + """Findet wichtige Wörter im Text.""" + # Einfache Heuristik: Längere Wörter sind oft wichtiger + words = re.findall(r'\b[A-Za-zäöüÄÖÜß]{4,}\b', text) + + # Zähle Häufigkeit + word_count = {} + for word in words: + word_lower = word.lower() + word_count[word_lower] = word_count.get(word_lower, 0) + 1 + + # Sortiere nach Länge und Häufigkeit + unique_words = list(set(words)) + scored = [] + for word in unique_words: + score = len(word) + word_count[word.lower()] * 2 + # Bevorzuge Wörter mit Großbuchstaben (Substantive) + if word[0].isupper(): + score += 3 + scored.append((word, score)) + + scored.sort(key=lambda x: x[1], reverse=True) + return [(w, s) for w, s in scored] + + def _select_words_by_difficulty( + self, + words: List[tuple], + num_gaps: int, + difficulty: str + ) -> List[tuple]: + """Wählt Wörter basierend auf Schwierigkeit.""" + if difficulty == "easy": + # Einfach: Häufige, wichtige Wörter + return words[:num_gaps] + elif difficulty == "hard": + # Schwer: Weniger häufige Wörter + return words[num_gaps:num_gaps*2] if len(words) > num_gaps else words[:num_gaps] + else: + # Medium: Mischung + return words[:num_gaps] + + def _generate_hint(self, word: str, text: str) -> str: + """Generiert einen Hinweis für ein Wort.""" + # Einfacher Hinweis basierend auf Kontext + sentences = text.split('.') + for sentence in sentences: + if word in sentence: + # Extrahiere Kontext + words_in_sentence = sentence.split() + if len(words_in_sentence) > 5: + return f"Beginnt mit '{word[0]}' ({len(word)} Buchstaben)" + return f"Beginnt mit '{word[0]}'" + + def _generate_distractors(self, word: str, all_words: List[tuple]) -> List[str]: + """Generiert Distraktoren (falsche Optionen).""" + distractors = [] + word_len = len(word) + + # Finde ähnlich lange Wörter + for w, _ in all_words: + if w.lower() != word.lower(): + if abs(len(w) - word_len) <= 2: + distractors.append(w) + if len(distractors) >= 3: + break + + # Falls nicht genug, füge generische hinzu + while len(distractors) < 3: + distractors.append(f"[Option {len(distractors)+1}]") + + return distractors[:3] + + def _create_cloze_from_llm( + self, + source_text: str, + data: Dict[str, Any], + difficulty: str, + cloze_type: ClozeType, + topic: Optional[str] + ) -> ClozeText: + """Erstellt ClozeText aus LLM-Antwort.""" + text_with_gaps = source_text + gaps = [] + + for i, gap_data in enumerate(data.get("gaps", [])): + word = gap_data.get("word", "") + if word: + # Ersetze im Text + pattern = r'\b' + re.escape(word) + r'\b' + placeholder = f"[_{i+1}_]" + text_with_gaps = re.sub(pattern, placeholder, text_with_gaps, count=1) + + gap = ClozeGap( + position=i, + answer=word, + alternatives=gap_data.get("alternatives", []), + hint=gap_data.get("hint"), + distractors=gap_data.get("distractors", []) + ) + gaps.append(gap) + + return ClozeText( + text_with_gaps=text_with_gaps, + original_text=source_text, + gaps=gaps, + cloze_type=cloze_type, + topic=topic, + difficulty=difficulty + ) + + def _empty_cloze(self, text: str, cloze_type: ClozeType) -> ClozeText: + """Erstellt leeren ClozeText bei Fehler.""" + return ClozeText( + text_with_gaps=text, + original_text=text, + gaps=[], + cloze_type=cloze_type, + topic=None, + difficulty="medium" + ) + + def to_h5p_format(self, cloze: ClozeText) -> Dict[str, Any]: + """ + Konvertiert Lückentext ins H5P-Format. + + Args: + cloze: ClozeText-Objekt + + Returns: + H5P-kompatibles Dict + """ + # H5P Fill in the Blanks Format + h5p_text = cloze.text_with_gaps + + # Ersetze Platzhalter durch H5P-Format + for i, gap in enumerate(cloze.gaps): + placeholder = f"[_{i+1}_]" + answers = [gap.answer] + gap.alternatives + h5p_answer = "/".join(answers) + + if cloze.cloze_type == ClozeType.DROPDOWN: + # Mit Distraktoren + all_options = answers + gap.distractors + h5p_answer = "/".join(all_options) + + h5p_text = h5p_text.replace(placeholder, f"*{h5p_answer}*") + + return { + "library": "H5P.Blanks", + "params": { + "text": h5p_text, + "behaviour": { + "enableRetry": True, + "enableSolutionsButton": True, + "caseSensitive": False, + "showSolutionsRequiresInput": True + } + } + } + + def to_dict(self, cloze: ClozeText) -> Dict[str, Any]: + """Konvertiert ClozeText zu Dictionary-Format.""" + return { + "text_with_gaps": cloze.text_with_gaps, + "original_text": cloze.original_text, + "gaps": [ + { + "position": gap.position, + "answer": gap.answer, + "alternatives": gap.alternatives, + "hint": gap.hint, + "distractors": gap.distractors + } + for gap in cloze.gaps + ], + "cloze_type": cloze.cloze_type.value, + "topic": cloze.topic, + "difficulty": cloze.difficulty + } diff --git a/backend/generators/mc_generator.py b/backend/generators/mc_generator.py new file mode 100644 index 0000000..6e31538 --- /dev/null +++ b/backend/generators/mc_generator.py @@ -0,0 +1,277 @@ +""" +Multiple Choice Generator - Erstellt MC-Fragen aus Quelltexten. + +Verwendet LLM (Claude/Ollama) zur Generierung von: +- Multiple-Choice-Fragen mit 4 Antwortmöglichkeiten +- Unterschiedliche Schwierigkeitsgrade +- Erklärungen für falsche Antworten +""" + +import logging +import json +from typing import List, Dict, Any, Optional +from dataclasses import dataclass +from enum import Enum + +logger = logging.getLogger(__name__) + + +class Difficulty(str, Enum): + """Schwierigkeitsgrade.""" + EASY = "easy" + MEDIUM = "medium" + HARD = "hard" + + +@dataclass +class MCOption: + """Eine Antwortmöglichkeit.""" + text: str + is_correct: bool + explanation: Optional[str] = None + + +@dataclass +class MCQuestion: + """Eine Multiple-Choice-Frage.""" + question: str + options: List[MCOption] + difficulty: Difficulty + topic: Optional[str] = None + hint: Optional[str] = None + + +class MultipleChoiceGenerator: + """ + Generiert Multiple-Choice-Fragen aus Quelltexten. + + Verwendet ein LLM zur intelligenten Fragengenerierung. + """ + + def __init__(self, llm_client=None): + """ + Initialisiert den Generator. + + Args: + llm_client: Optional - LLM-Client für Generierung. + Falls nicht angegeben, wird ein Mock verwendet. + """ + self.llm_client = llm_client + logger.info("MultipleChoiceGenerator initialized") + + def generate( + self, + source_text: str, + num_questions: int = 5, + difficulty: Difficulty = Difficulty.MEDIUM, + subject: Optional[str] = None, + grade_level: Optional[str] = None + ) -> List[MCQuestion]: + """ + Generiert Multiple-Choice-Fragen aus einem Quelltext. + + Args: + source_text: Der Text, aus dem Fragen generiert werden + num_questions: Anzahl der zu generierenden Fragen + difficulty: Schwierigkeitsgrad + subject: Fach (z.B. "Biologie", "Geschichte") + grade_level: Klassenstufe (z.B. "7") + + Returns: + Liste von MCQuestion-Objekten + """ + logger.info(f"Generating {num_questions} MC questions (difficulty: {difficulty})") + + if not source_text or len(source_text.strip()) < 50: + logger.warning("Source text too short for meaningful questions") + return [] + + if self.llm_client: + return self._generate_with_llm( + source_text, num_questions, difficulty, subject, grade_level + ) + else: + return self._generate_mock( + source_text, num_questions, difficulty + ) + + def _generate_with_llm( + self, + source_text: str, + num_questions: int, + difficulty: Difficulty, + subject: Optional[str], + grade_level: Optional[str] + ) -> List[MCQuestion]: + """Generiert Fragen mit einem LLM.""" + difficulty_desc = { + Difficulty.EASY: "einfach (Faktenwissen)", + Difficulty.MEDIUM: "mittel (Verständnis)", + Difficulty.HARD: "schwer (Anwendung und Analyse)" + } + + prompt = f""" +Erstelle {num_questions} Multiple-Choice-Fragen auf Deutsch basierend auf folgendem Text. +Schwierigkeitsgrad: {difficulty_desc[difficulty]} +{f'Fach: {subject}' if subject else ''} +{f'Klassenstufe: {grade_level}' if grade_level else ''} + +Text: +{source_text} + +Erstelle für jede Frage: +- Eine klare Frage +- 4 Antwortmöglichkeiten (genau eine richtig) +- Eine kurze Erklärung, warum die richtigen Antwort richtig ist +- Einen optionalen Hinweis + +Antworte im folgenden JSON-Format: +{{ + "questions": [ + {{ + "question": "Die Frage...", + "options": [ + {{"text": "Antwort A", "is_correct": false, "explanation": "Warum falsch"}}, + {{"text": "Antwort B", "is_correct": true, "explanation": "Warum richtig"}}, + {{"text": "Antwort C", "is_correct": false, "explanation": "Warum falsch"}}, + {{"text": "Antwort D", "is_correct": false, "explanation": "Warum falsch"}} + ], + "topic": "Thema der Frage", + "hint": "Optionaler Hinweis" + }} + ] +}} +""" + + try: + response = self.llm_client.generate(prompt) + data = json.loads(response) + return self._parse_llm_response(data, difficulty) + except Exception as e: + logger.error(f"Error generating with LLM: {e}") + return self._generate_mock(source_text, num_questions, difficulty) + + def _parse_llm_response( + self, + data: Dict[str, Any], + difficulty: Difficulty + ) -> List[MCQuestion]: + """Parst die LLM-Antwort zu MCQuestion-Objekten.""" + questions = [] + + for q_data in data.get("questions", []): + options = [ + MCOption( + text=opt["text"], + is_correct=opt.get("is_correct", False), + explanation=opt.get("explanation") + ) + for opt in q_data.get("options", []) + ] + + question = MCQuestion( + question=q_data.get("question", ""), + options=options, + difficulty=difficulty, + topic=q_data.get("topic"), + hint=q_data.get("hint") + ) + questions.append(question) + + return questions + + def _generate_mock( + self, + source_text: str, + num_questions: int, + difficulty: Difficulty + ) -> List[MCQuestion]: + """Generiert Mock-Fragen für Tests/Demo.""" + logger.info("Using mock generator (no LLM client)") + + # Extrahiere einige Schlüsselwörter aus dem Text + words = source_text.split() + keywords = [w for w in words if len(w) > 5][:10] + + questions = [] + for i in range(min(num_questions, 5)): + keyword = keywords[i] if i < len(keywords) else f"Begriff {i+1}" + + question = MCQuestion( + question=f"Was beschreibt '{keyword}' im Kontext des Textes am besten?", + options=[ + MCOption(text=f"Definition A von {keyword}", is_correct=True, + explanation="Dies ist die korrekte Definition."), + MCOption(text=f"Falsche Definition B", is_correct=False, + explanation="Diese Definition passt nicht."), + MCOption(text=f"Falsche Definition C", is_correct=False, + explanation="Diese Definition ist unvollständig."), + MCOption(text=f"Falsche Definition D", is_correct=False, + explanation="Diese Definition ist irreführend."), + ], + difficulty=difficulty, + topic="Allgemein", + hint=f"Denke an die Bedeutung von '{keyword}'." + ) + questions.append(question) + + return questions + + def to_h5p_format(self, questions: List[MCQuestion]) -> Dict[str, Any]: + """ + Konvertiert Fragen ins H5P-Format für Multiple Choice. + + Args: + questions: Liste von MCQuestion-Objekten + + Returns: + H5P-kompatibles Dict + """ + h5p_questions = [] + + for q in questions: + answers = [] + for opt in q.options: + answers.append({ + "text": opt.text, + "correct": opt.is_correct, + "tpiMessage": opt.explanation or "" + }) + + h5p_questions.append({ + "question": q.question, + "answers": answers, + "tip": q.hint or "" + }) + + return { + "library": "H5P.MultiChoice", + "params": { + "questions": h5p_questions, + "behaviour": { + "enableRetry": True, + "enableSolutionsButton": True, + "singleAnswer": True + } + } + } + + def to_dict(self, questions: List[MCQuestion]) -> List[Dict[str, Any]]: + """Konvertiert Fragen zu Dictionary-Format.""" + return [ + { + "question": q.question, + "options": [ + { + "text": opt.text, + "is_correct": opt.is_correct, + "explanation": opt.explanation + } + for opt in q.options + ], + "difficulty": q.difficulty.value, + "topic": q.topic, + "hint": q.hint + } + for q in questions + ] diff --git a/backend/generators/mindmap_generator.py b/backend/generators/mindmap_generator.py new file mode 100644 index 0000000..2f2fb47 --- /dev/null +++ b/backend/generators/mindmap_generator.py @@ -0,0 +1,380 @@ +""" +Mindmap Generator - Erstellt Mindmaps aus Quelltexten. + +Generiert: +- Hierarchische Struktur aus Text +- Hauptthema mit Unterthemen +- Verbindungen und Beziehungen +""" + +import logging +import json +import re +from typing import List, Dict, Any, Optional +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + + +@dataclass +class MindmapNode: + """Ein Knoten in der Mindmap.""" + id: str + label: str + level: int = 0 + children: List['MindmapNode'] = field(default_factory=list) + color: Optional[str] = None + icon: Optional[str] = None + notes: Optional[str] = None + + +@dataclass +class Mindmap: + """Eine komplette Mindmap.""" + root: MindmapNode + title: str + topic: Optional[str] = None + total_nodes: int = 0 + + +class MindmapGenerator: + """ + Generiert Mindmaps aus Quelltexten. + + Extrahiert: + - Hauptthema als Zentrum + - Unterthemen als Äste + - Details als Blätter + """ + + def __init__(self, llm_client=None): + """ + Initialisiert den Generator. + + Args: + llm_client: Optional - LLM-Client für intelligente Generierung + """ + self.llm_client = llm_client + logger.info("MindmapGenerator initialized") + + # Farben für verschiedene Ebenen + self.level_colors = [ + "#6C1B1B", # Weinrot (Zentrum) + "#3b82f6", # Blau + "#22c55e", # Grün + "#f59e0b", # Orange + "#8b5cf6", # Violett + ] + + def generate( + self, + source_text: str, + title: Optional[str] = None, + max_depth: int = 3, + topic: Optional[str] = None + ) -> Mindmap: + """ + Generiert eine Mindmap aus einem Quelltext. + + Args: + source_text: Der Ausgangstext + title: Optionaler Titel (sonst automatisch ermittelt) + max_depth: Maximale Tiefe der Hierarchie + topic: Optionales Thema + + Returns: + Mindmap-Objekt + """ + logger.info(f"Generating mindmap (max_depth: {max_depth})") + + if not source_text or len(source_text.strip()) < 50: + logger.warning("Source text too short") + return self._empty_mindmap(title or "Mindmap") + + if self.llm_client: + return self._generate_with_llm(source_text, title, max_depth, topic) + else: + return self._generate_automatic(source_text, title, max_depth, topic) + + def _generate_with_llm( + self, + source_text: str, + title: Optional[str], + max_depth: int, + topic: Optional[str] + ) -> Mindmap: + """Generiert Mindmap mit LLM.""" + prompt = f""" +Erstelle eine Mindmap-Struktur auf Deutsch basierend auf folgendem Text. +{f'Titel: {title}' if title else 'Ermittle einen passenden Titel.'} +Maximale Tiefe: {max_depth} Ebenen +{f'Thema: {topic}' if topic else ''} + +Text: +{source_text} + +Erstelle eine hierarchische Struktur mit: +- Einem zentralen Hauptthema +- 3-5 Hauptästen (Unterthemen) +- Jeweils 2-4 Details pro Ast + +Antworte im JSON-Format: +{{ + "title": "Hauptthema", + "branches": [ + {{ + "label": "Unterthema 1", + "children": [ + {{"label": "Detail 1.1"}}, + {{"label": "Detail 1.2"}} + ] + }}, + {{ + "label": "Unterthema 2", + "children": [ + {{"label": "Detail 2.1"}} + ] + }} + ] +}} +""" + + try: + response = self.llm_client.generate(prompt) + data = json.loads(response) + return self._create_mindmap_from_llm(data, topic) + except Exception as e: + logger.error(f"Error generating with LLM: {e}") + return self._generate_automatic(source_text, title, max_depth, topic) + + def _generate_automatic( + self, + source_text: str, + title: Optional[str], + max_depth: int, + topic: Optional[str] + ) -> Mindmap: + """Generiert Mindmap automatisch ohne LLM.""" + # Extrahiere Struktur aus Text + sections = self._extract_sections(source_text) + + # Bestimme Titel + if not title: + # Erste Zeile oder erstes Substantiv + first_line = source_text.split('\n')[0].strip() + title = first_line[:50] if first_line else "Mindmap" + + # Erstelle Root-Knoten + node_counter = [0] + root = self._create_node(title, 0, node_counter) + + # Füge Hauptäste hinzu + for section_title, section_content in sections[:5]: # Max 5 Hauptäste + branch = self._create_node(section_title, 1, node_counter) + branch.color = self.level_colors[1 % len(self.level_colors)] + + # Füge Details hinzu + details = self._extract_details(section_content) + for detail in details[:4]: # Max 4 Details pro Ast + if max_depth >= 2: + leaf = self._create_node(detail, 2, node_counter) + leaf.color = self.level_colors[2 % len(self.level_colors)] + branch.children.append(leaf) + + root.children.append(branch) + + return Mindmap( + root=root, + title=title, + topic=topic, + total_nodes=node_counter[0] + ) + + def _extract_sections(self, text: str) -> List[tuple]: + """Extrahiert Abschnitte aus dem Text.""" + sections = [] + + # Versuche Überschriften zu finden + lines = text.split('\n') + current_section = None + current_content = [] + + for line in lines: + line = line.strip() + if not line: + continue + + # Erkenne potenzielle Überschriften + if (line.endswith(':') or + line.isupper() or + len(line) < 50 and line[0].isupper() and '.' not in line): + # Speichere vorherige Section + if current_section: + sections.append((current_section, '\n'.join(current_content))) + current_section = line.rstrip(':') + current_content = [] + else: + current_content.append(line) + + # Letzte Section + if current_section: + sections.append((current_section, '\n'.join(current_content))) + + # Falls keine Sections gefunden, erstelle aus Sätzen + if not sections: + sentences = re.split(r'[.!?]+', text) + for i, sentence in enumerate(sentences[:5]): + sentence = sentence.strip() + if len(sentence) > 10: + # Kürze auf max 30 Zeichen für Label + label = sentence[:30] + '...' if len(sentence) > 30 else sentence + sections.append((label, sentence)) + + return sections + + def _extract_details(self, content: str) -> List[str]: + """Extrahiert Details aus Abschnittsinhalt.""" + details = [] + + # Aufzählungen + bullet_pattern = r'[-•*]\s*(.+)' + bullets = re.findall(bullet_pattern, content) + details.extend(bullets) + + # Nummerierte Listen + num_pattern = r'\d+[.)]\s*(.+)' + numbered = re.findall(num_pattern, content) + details.extend(numbered) + + # Falls keine Listen, nehme Sätze + if not details: + sentences = re.split(r'[.!?]+', content) + for sentence in sentences: + sentence = sentence.strip() + if len(sentence) > 5: + label = sentence[:40] + '...' if len(sentence) > 40 else sentence + details.append(label) + + return details + + def _create_node( + self, + label: str, + level: int, + counter: List[int] + ) -> MindmapNode: + """Erstellt einen neuen Knoten.""" + counter[0] += 1 + return MindmapNode( + id=f"node_{counter[0]}", + label=label, + level=level, + children=[], + color=self.level_colors[level % len(self.level_colors)] + ) + + def _create_mindmap_from_llm( + self, + data: Dict[str, Any], + topic: Optional[str] + ) -> Mindmap: + """Erstellt Mindmap aus LLM-Antwort.""" + node_counter = [0] + title = data.get("title", "Mindmap") + + root = self._create_node(title, 0, node_counter) + + for branch_data in data.get("branches", []): + branch = self._create_node(branch_data.get("label", ""), 1, node_counter) + branch.color = self.level_colors[1 % len(self.level_colors)] + + for child_data in branch_data.get("children", []): + child = self._create_node(child_data.get("label", ""), 2, node_counter) + child.color = self.level_colors[2 % len(self.level_colors)] + branch.children.append(child) + + root.children.append(branch) + + return Mindmap( + root=root, + title=title, + topic=topic, + total_nodes=node_counter[0] + ) + + def _empty_mindmap(self, title: str) -> Mindmap: + """Erstellt leere Mindmap bei Fehler.""" + root = MindmapNode( + id="root", + label=title, + level=0, + color=self.level_colors[0] + ) + return Mindmap(root=root, title=title, total_nodes=1) + + def to_dict(self, mindmap: Mindmap) -> Dict[str, Any]: + """Konvertiert Mindmap zu Dictionary-Format.""" + def node_to_dict(node: MindmapNode) -> Dict[str, Any]: + return { + "id": node.id, + "label": node.label, + "level": node.level, + "color": node.color, + "icon": node.icon, + "notes": node.notes, + "children": [node_to_dict(child) for child in node.children] + } + + return { + "title": mindmap.title, + "topic": mindmap.topic, + "total_nodes": mindmap.total_nodes, + "root": node_to_dict(mindmap.root) + } + + def to_mermaid(self, mindmap: Mindmap) -> str: + """ + Konvertiert Mindmap zu Mermaid-Format für Visualisierung. + + Args: + mindmap: Mindmap-Objekt + + Returns: + Mermaid-Diagramm als String + """ + lines = ["mindmap"] + lines.append(f" root(({mindmap.title}))") + + def add_node(node: MindmapNode, indent: int): + for child in node.children: + prefix = " " * (indent + 1) + if child.children: + lines.append(f"{prefix}{child.label}") + else: + lines.append(f"{prefix}){child.label}(") + add_node(child, indent + 1) + + add_node(mindmap.root, 1) + return "\n".join(lines) + + def to_json_tree(self, mindmap: Mindmap) -> Dict[str, Any]: + """ + Konvertiert Mindmap zu JSON-Tree-Format für JS-Bibliotheken. + + Args: + mindmap: Mindmap-Objekt + + Returns: + JSON-Tree-Format für d3.js, vis.js etc. + """ + def node_to_tree(node: MindmapNode) -> Dict[str, Any]: + result = { + "name": node.label, + "id": node.id, + "color": node.color + } + if node.children: + result["children"] = [node_to_tree(c) for c in node.children] + return result + + return node_to_tree(mindmap.root) diff --git a/backend/generators/quiz_generator.py b/backend/generators/quiz_generator.py new file mode 100644 index 0000000..3f4b6e5 --- /dev/null +++ b/backend/generators/quiz_generator.py @@ -0,0 +1,594 @@ +""" +Quiz Generator - Erstellt verschiedene Quiz-Typen aus Quelltexten. + +Generiert: +- True/False Fragen +- Zuordnungsaufgaben (Matching) +- Sortieraufgaben +- Offene Fragen mit Musterlösungen +""" + +import logging +import json +import re +from typing import List, Dict, Any, Optional, Tuple +from dataclasses import dataclass +from enum import Enum + +logger = logging.getLogger(__name__) + + +class QuizType(str, Enum): + """Typen von Quiz-Aufgaben.""" + TRUE_FALSE = "true_false" + MATCHING = "matching" + SORTING = "sorting" + OPEN_ENDED = "open_ended" + + +@dataclass +class TrueFalseQuestion: + """Eine Wahr/Falsch-Frage.""" + statement: str + is_true: bool + explanation: str + source_reference: Optional[str] = None + + +@dataclass +class MatchingPair: + """Ein Zuordnungspaar.""" + left: str + right: str + hint: Optional[str] = None + + +@dataclass +class SortingItem: + """Ein Element zum Sortieren.""" + text: str + correct_position: int + category: Optional[str] = None + + +@dataclass +class OpenQuestion: + """Eine offene Frage.""" + question: str + model_answer: str + keywords: List[str] + points: int = 1 + + +@dataclass +class Quiz: + """Ein komplettes Quiz.""" + quiz_type: QuizType + title: str + questions: List[Any] # Je nach Typ unterschiedlich + topic: Optional[str] = None + difficulty: str = "medium" + + +class QuizGenerator: + """ + Generiert verschiedene Quiz-Typen aus Quelltexten. + """ + + def __init__(self, llm_client=None): + """ + Initialisiert den Generator. + + Args: + llm_client: Optional - LLM-Client für intelligente Generierung + """ + self.llm_client = llm_client + logger.info("QuizGenerator initialized") + + def generate( + self, + source_text: str, + quiz_type: QuizType, + num_questions: int = 5, + title: Optional[str] = None, + topic: Optional[str] = None, + difficulty: str = "medium" + ) -> Quiz: + """ + Generiert ein Quiz aus einem Quelltext. + + Args: + source_text: Der Ausgangstext + quiz_type: Art des Quiz + num_questions: Anzahl der Fragen/Aufgaben + title: Optionaler Titel + topic: Optionales Thema + difficulty: Schwierigkeitsgrad + + Returns: + Quiz-Objekt + """ + logger.info(f"Generating {quiz_type} quiz with {num_questions} questions") + + if not source_text or len(source_text.strip()) < 50: + logger.warning("Source text too short") + return self._empty_quiz(quiz_type, title or "Quiz") + + generators = { + QuizType.TRUE_FALSE: self._generate_true_false, + QuizType.MATCHING: self._generate_matching, + QuizType.SORTING: self._generate_sorting, + QuizType.OPEN_ENDED: self._generate_open_ended, + } + + generator = generators.get(quiz_type) + if not generator: + raise ValueError(f"Unbekannter Quiz-Typ: {quiz_type}") + + questions = generator(source_text, num_questions, difficulty) + + return Quiz( + quiz_type=quiz_type, + title=title or f"{quiz_type.value.replace('_', ' ').title()} Quiz", + questions=questions, + topic=topic, + difficulty=difficulty + ) + + def _generate_true_false( + self, + source_text: str, + num_questions: int, + difficulty: str + ) -> List[TrueFalseQuestion]: + """Generiert Wahr/Falsch-Fragen.""" + if self.llm_client: + return self._generate_true_false_llm(source_text, num_questions, difficulty) + + # Automatische Generierung + sentences = self._extract_factual_sentences(source_text) + questions = [] + + for i, sentence in enumerate(sentences[:num_questions]): + # Abwechselnd wahre und falsche Aussagen + if i % 2 == 0: + # Wahre Aussage + questions.append(TrueFalseQuestion( + statement=sentence, + is_true=True, + explanation="Diese Aussage entspricht dem Text.", + source_reference=sentence[:50] + )) + else: + # Falsche Aussage (Negation) + false_statement = self._negate_sentence(sentence) + questions.append(TrueFalseQuestion( + statement=false_statement, + is_true=False, + explanation=f"Richtig wäre: {sentence}", + source_reference=sentence[:50] + )) + + return questions + + def _generate_true_false_llm( + self, + source_text: str, + num_questions: int, + difficulty: str + ) -> List[TrueFalseQuestion]: + """Generiert Wahr/Falsch-Fragen mit LLM.""" + prompt = f""" +Erstelle {num_questions} Wahr/Falsch-Aussagen auf Deutsch basierend auf folgendem Text. +Schwierigkeit: {difficulty} +Erstelle etwa gleich viele wahre und falsche Aussagen. + +Text: +{source_text} + +Antworte im JSON-Format: +{{ + "questions": [ + {{ + "statement": "Die Aussage...", + "is_true": true, + "explanation": "Erklärung warum wahr/falsch" + }} + ] +}} +""" + try: + response = self.llm_client.generate(prompt) + data = json.loads(response) + return [ + TrueFalseQuestion( + statement=q["statement"], + is_true=q["is_true"], + explanation=q["explanation"] + ) + for q in data.get("questions", []) + ] + except Exception as e: + logger.error(f"LLM error: {e}") + return self._generate_true_false(source_text, num_questions, difficulty) + + def _generate_matching( + self, + source_text: str, + num_pairs: int, + difficulty: str + ) -> List[MatchingPair]: + """Generiert Zuordnungsaufgaben.""" + if self.llm_client: + return self._generate_matching_llm(source_text, num_pairs, difficulty) + + # Automatische Generierung: Begriff -> Definition + pairs = [] + definitions = self._extract_definitions(source_text) + + for term, definition in definitions[:num_pairs]: + pairs.append(MatchingPair( + left=term, + right=definition, + hint=f"Beginnt mit '{definition[0]}'" + )) + + return pairs + + def _generate_matching_llm( + self, + source_text: str, + num_pairs: int, + difficulty: str + ) -> List[MatchingPair]: + """Generiert Zuordnungen mit LLM.""" + prompt = f""" +Erstelle {num_pairs} Zuordnungspaare auf Deutsch basierend auf folgendem Text. +Jedes Paar besteht aus einem Begriff und seiner Definition/Erklärung. +Schwierigkeit: {difficulty} + +Text: +{source_text} + +Antworte im JSON-Format: +{{ + "pairs": [ + {{ + "term": "Begriff", + "definition": "Definition des Begriffs", + "hint": "Optionaler Hinweis" + }} + ] +}} +""" + try: + response = self.llm_client.generate(prompt) + data = json.loads(response) + return [ + MatchingPair( + left=p["term"], + right=p["definition"], + hint=p.get("hint") + ) + for p in data.get("pairs", []) + ] + except Exception as e: + logger.error(f"LLM error: {e}") + return self._generate_matching(source_text, num_pairs, difficulty) + + def _generate_sorting( + self, + source_text: str, + num_items: int, + difficulty: str + ) -> List[SortingItem]: + """Generiert Sortieraufgaben.""" + if self.llm_client: + return self._generate_sorting_llm(source_text, num_items, difficulty) + + # Automatische Generierung: Chronologische Reihenfolge + items = [] + steps = self._extract_sequence(source_text) + + for i, step in enumerate(steps[:num_items]): + items.append(SortingItem( + text=step, + correct_position=i + 1 + )) + + return items + + def _generate_sorting_llm( + self, + source_text: str, + num_items: int, + difficulty: str + ) -> List[SortingItem]: + """Generiert Sortierung mit LLM.""" + prompt = f""" +Erstelle eine Sortieraufgabe auf Deutsch basierend auf folgendem Text. +Finde {num_items} Elemente, die in eine logische Reihenfolge gebracht werden müssen. +(z.B. chronologisch, nach Wichtigkeit, nach Größe, etc.) +Schwierigkeit: {difficulty} + +Text: +{source_text} + +Antworte im JSON-Format: +{{ + "category": "chronologisch/nach Größe/etc.", + "items": [ + {{"text": "Erstes Element", "position": 1}}, + {{"text": "Zweites Element", "position": 2}} + ] +}} +""" + try: + response = self.llm_client.generate(prompt) + data = json.loads(response) + category = data.get("category") + return [ + SortingItem( + text=item["text"], + correct_position=item["position"], + category=category + ) + for item in data.get("items", []) + ] + except Exception as e: + logger.error(f"LLM error: {e}") + return self._generate_sorting(source_text, num_items, difficulty) + + def _generate_open_ended( + self, + source_text: str, + num_questions: int, + difficulty: str + ) -> List[OpenQuestion]: + """Generiert offene Fragen.""" + if self.llm_client: + return self._generate_open_ended_llm(source_text, num_questions, difficulty) + + # Automatische Generierung + questions = [] + sentences = self._extract_factual_sentences(source_text) + + question_starters = [ + "Was bedeutet", + "Erkläre", + "Warum", + "Wie funktioniert", + "Nenne die Hauptmerkmale von" + ] + + for i, sentence in enumerate(sentences[:num_questions]): + # Extrahiere Schlüsselwort + keywords = self._extract_keywords(sentence) + if keywords: + keyword = keywords[0] + starter = question_starters[i % len(question_starters)] + question = f"{starter} '{keyword}'?" + + questions.append(OpenQuestion( + question=question, + model_answer=sentence, + keywords=keywords, + points=1 + )) + + return questions + + def _generate_open_ended_llm( + self, + source_text: str, + num_questions: int, + difficulty: str + ) -> List[OpenQuestion]: + """Generiert offene Fragen mit LLM.""" + prompt = f""" +Erstelle {num_questions} offene Fragen auf Deutsch basierend auf folgendem Text. +Jede Frage sollte eine ausführliche Antwort erfordern. +Schwierigkeit: {difficulty} + +Text: +{source_text} + +Antworte im JSON-Format: +{{ + "questions": [ + {{ + "question": "Die Frage...", + "model_answer": "Eine vollständige Musterantwort", + "keywords": ["Schlüsselwort1", "Schlüsselwort2"], + "points": 2 + }} + ] +}} +""" + try: + response = self.llm_client.generate(prompt) + data = json.loads(response) + return [ + OpenQuestion( + question=q["question"], + model_answer=q["model_answer"], + keywords=q.get("keywords", []), + points=q.get("points", 1) + ) + for q in data.get("questions", []) + ] + except Exception as e: + logger.error(f"LLM error: {e}") + return self._generate_open_ended(source_text, num_questions, difficulty) + + # Hilfsmethoden + + def _extract_factual_sentences(self, text: str) -> List[str]: + """Extrahiert Fakten-Sätze aus dem Text.""" + sentences = re.split(r'[.!?]+', text) + factual = [] + + for sentence in sentences: + sentence = sentence.strip() + # Filtere zu kurze oder fragende Sätze + if len(sentence) > 20 and '?' not in sentence: + factual.append(sentence) + + return factual + + def _negate_sentence(self, sentence: str) -> str: + """Negiert eine Aussage einfach.""" + # Einfache Negation durch Einfügen von "nicht" + words = sentence.split() + if len(words) > 2: + # Nach erstem Verb "nicht" einfügen + for i, word in enumerate(words): + if word.endswith(('t', 'en', 'st')) and i > 0: + words.insert(i + 1, 'nicht') + break + return ' '.join(words) + + def _extract_definitions(self, text: str) -> List[Tuple[str, str]]: + """Extrahiert Begriff-Definition-Paare.""" + definitions = [] + + # Suche nach Mustern wie "X ist Y" oder "X bezeichnet Y" + patterns = [ + r'(\w+)\s+ist\s+(.+?)[.]', + r'(\w+)\s+bezeichnet\s+(.+?)[.]', + r'(\w+)\s+bedeutet\s+(.+?)[.]', + r'(\w+):\s+(.+?)[.]', + ] + + for pattern in patterns: + matches = re.findall(pattern, text) + for term, definition in matches: + if len(definition) > 10: + definitions.append((term, definition.strip())) + + return definitions + + def _extract_sequence(self, text: str) -> List[str]: + """Extrahiert eine Sequenz von Schritten.""" + steps = [] + + # Suche nach nummerierten Schritten + numbered = re.findall(r'\d+[.)]\s*([^.]+)', text) + steps.extend(numbered) + + # Suche nach Signalwörtern + signal_words = ['zuerst', 'dann', 'danach', 'anschließend', 'schließlich'] + for word in signal_words: + pattern = rf'{word}\s+([^.]+)' + matches = re.findall(pattern, text, re.IGNORECASE) + steps.extend(matches) + + return steps + + def _extract_keywords(self, text: str) -> List[str]: + """Extrahiert Schlüsselwörter.""" + # Längere Wörter mit Großbuchstaben (meist Substantive) + words = re.findall(r'\b[A-ZÄÖÜ][a-zäöüß]+\b', text) + return list(set(words))[:5] + + def _empty_quiz(self, quiz_type: QuizType, title: str) -> Quiz: + """Erstellt leeres Quiz bei Fehler.""" + return Quiz( + quiz_type=quiz_type, + title=title, + questions=[], + difficulty="medium" + ) + + def to_dict(self, quiz: Quiz) -> Dict[str, Any]: + """Konvertiert Quiz zu Dictionary-Format.""" + questions_data = [] + + for q in quiz.questions: + if isinstance(q, TrueFalseQuestion): + questions_data.append({ + "type": "true_false", + "statement": q.statement, + "is_true": q.is_true, + "explanation": q.explanation + }) + elif isinstance(q, MatchingPair): + questions_data.append({ + "type": "matching", + "left": q.left, + "right": q.right, + "hint": q.hint + }) + elif isinstance(q, SortingItem): + questions_data.append({ + "type": "sorting", + "text": q.text, + "correct_position": q.correct_position, + "category": q.category + }) + elif isinstance(q, OpenQuestion): + questions_data.append({ + "type": "open_ended", + "question": q.question, + "model_answer": q.model_answer, + "keywords": q.keywords, + "points": q.points + }) + + return { + "quiz_type": quiz.quiz_type.value, + "title": quiz.title, + "topic": quiz.topic, + "difficulty": quiz.difficulty, + "questions": questions_data + } + + def to_h5p_format(self, quiz: Quiz) -> Dict[str, Any]: + """Konvertiert Quiz ins H5P-Format.""" + if quiz.quiz_type == QuizType.TRUE_FALSE: + return self._true_false_to_h5p(quiz) + elif quiz.quiz_type == QuizType.MATCHING: + return self._matching_to_h5p(quiz) + # Weitere Typen... + return {} + + def _true_false_to_h5p(self, quiz: Quiz) -> Dict[str, Any]: + """Konvertiert True/False zu H5P.""" + statements = [] + for q in quiz.questions: + statements.append({ + "text": q.statement, + "correct": q.is_true, + "feedback": q.explanation + }) + + return { + "library": "H5P.TrueFalse", + "params": { + "statements": statements, + "behaviour": { + "enableRetry": True, + "enableSolutionsButton": True + } + } + } + + def _matching_to_h5p(self, quiz: Quiz) -> Dict[str, Any]: + """Konvertiert Matching zu H5P.""" + pairs = [] + for q in quiz.questions: + pairs.append({ + "question": q.left, + "answer": q.right + }) + + return { + "library": "H5P.DragText", + "params": { + "pairs": pairs, + "behaviour": { + "enableRetry": True, + "enableSolutionsButton": True + } + } + } diff --git a/backend/gpu_test_api.py b/backend/gpu_test_api.py new file mode 100644 index 0000000..bb701cf --- /dev/null +++ b/backend/gpu_test_api.py @@ -0,0 +1,455 @@ +""" +GPU Infrastructure Test API - Test Runner fuer CUDA/ROCm GPU Management +Endpoint: /api/admin/gpu-tests +""" + +from fastapi import APIRouter +from pydantic import BaseModel +from typing import List, Optional, Literal +import httpx +import asyncio +import time +import os +import subprocess + +router = APIRouter(prefix="/api/admin/gpu-tests", tags=["GPU Tests"]) + +# ============================================== +# Models +# ============================================== + +class TestResult(BaseModel): + name: str + description: str + expected: str + actual: str + status: Literal["passed", "failed", "pending", "skipped"] + duration_ms: float + error_message: Optional[str] = None + + +class TestCategoryResult(BaseModel): + category: str + display_name: str + description: str + tests: List[TestResult] + passed: int + failed: int + total: int + + +class FullTestResults(BaseModel): + categories: List[TestCategoryResult] + total_passed: int + total_failed: int + total_tests: int + duration_ms: float + + +# ============================================== +# Configuration +# ============================================== + +BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000") +VAST_API_KEY = os.getenv("VAST_API_KEY", "") + + +# ============================================== +# Test Implementations +# ============================================== + +async def test_nvidia_smi() -> TestResult: + """Test NVIDIA GPU Detection via nvidia-smi""" + start = time.time() + try: + result = subprocess.run( + ["nvidia-smi", "--query-gpu=name,memory.total,driver_version", "--format=csv,noheader"], + capture_output=True, + text=True, + timeout=10 + ) + duration = (time.time() - start) * 1000 + + if result.returncode == 0 and result.stdout.strip(): + gpu_info = result.stdout.strip().split('\n')[0] + return TestResult( + name="NVIDIA GPU Erkennung", + description="Prueft ob NVIDIA GPUs via nvidia-smi erkannt werden", + expected="GPU-Informationen verfuegbar", + actual=f"GPU: {gpu_info}", + status="passed", + duration_ms=duration + ) + else: + return TestResult( + name="NVIDIA GPU Erkennung", + description="Prueft ob NVIDIA GPUs via nvidia-smi erkannt werden", + expected="GPU-Informationen verfuegbar", + actual="Keine NVIDIA GPU gefunden", + status="skipped", + duration_ms=duration, + error_message="nvidia-smi nicht verfuegbar oder keine GPU" + ) + except FileNotFoundError: + return TestResult( + name="NVIDIA GPU Erkennung", + description="Prueft ob NVIDIA GPUs via nvidia-smi erkannt werden", + expected="GPU-Informationen verfuegbar", + actual="nvidia-smi nicht installiert", + status="skipped", + duration_ms=(time.time() - start) * 1000, + error_message="nvidia-smi Binary nicht gefunden" + ) + except Exception as e: + return TestResult( + name="NVIDIA GPU Erkennung", + description="Prueft ob NVIDIA GPUs via nvidia-smi erkannt werden", + expected="GPU-Informationen verfuegbar", + actual=f"Fehler: {str(e)}", + status="failed", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +async def test_rocm_smi() -> TestResult: + """Test AMD GPU Detection via rocm-smi""" + start = time.time() + try: + result = subprocess.run( + ["rocm-smi", "--showproductname"], + capture_output=True, + text=True, + timeout=10 + ) + duration = (time.time() - start) * 1000 + + if result.returncode == 0 and result.stdout.strip(): + return TestResult( + name="AMD ROCm GPU Erkennung", + description="Prueft ob AMD GPUs via rocm-smi erkannt werden", + expected="GPU-Informationen verfuegbar", + actual=f"ROCm GPU erkannt", + status="passed", + duration_ms=duration + ) + else: + return TestResult( + name="AMD ROCm GPU Erkennung", + description="Prueft ob AMD GPUs via rocm-smi erkannt werden", + expected="GPU-Informationen verfuegbar", + actual="Keine AMD GPU gefunden", + status="skipped", + duration_ms=duration, + error_message="rocm-smi nicht verfuegbar oder keine GPU" + ) + except FileNotFoundError: + return TestResult( + name="AMD ROCm GPU Erkennung", + description="Prueft ob AMD GPUs via rocm-smi erkannt werden", + expected="GPU-Informationen verfuegbar", + actual="rocm-smi nicht installiert", + status="skipped", + duration_ms=(time.time() - start) * 1000, + error_message="rocm-smi Binary nicht gefunden" + ) + except Exception as e: + return TestResult( + name="AMD ROCm GPU Erkennung", + description="Prueft ob AMD GPUs via rocm-smi erkannt werden", + expected="GPU-Informationen verfuegbar", + actual=f"Fehler: {str(e)}", + status="failed", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +async def test_cuda_available() -> TestResult: + """Test CUDA Availability via Python""" + start = time.time() + try: + # Try to import torch and check CUDA + result = subprocess.run( + ["python", "-c", "import torch; print(f'CUDA: {torch.cuda.is_available()}, Devices: {torch.cuda.device_count()}')"], + capture_output=True, + text=True, + timeout=30 + ) + duration = (time.time() - start) * 1000 + + if result.returncode == 0: + output = result.stdout.strip() + if "True" in output: + return TestResult( + name="PyTorch CUDA Support", + description="Prueft ob PyTorch CUDA-Unterstuetzung hat", + expected="CUDA verfuegbar", + actual=output, + status="passed", + duration_ms=duration + ) + else: + return TestResult( + name="PyTorch CUDA Support", + description="Prueft ob PyTorch CUDA-Unterstuetzung hat", + expected="CUDA verfuegbar", + actual=output, + status="skipped", + duration_ms=duration, + error_message="CUDA nicht verfuegbar in PyTorch" + ) + else: + return TestResult( + name="PyTorch CUDA Support", + description="Prueft ob PyTorch CUDA-Unterstuetzung hat", + expected="CUDA verfuegbar", + actual="PyTorch nicht installiert", + status="skipped", + duration_ms=duration, + error_message=result.stderr[:200] if result.stderr else "PyTorch fehlt" + ) + except Exception as e: + return TestResult( + name="PyTorch CUDA Support", + description="Prueft ob PyTorch CUDA-Unterstuetzung hat", + expected="CUDA verfuegbar", + actual=f"Fehler: {str(e)}", + status="skipped", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +async def test_vast_ai_api() -> TestResult: + """Test vast.ai API Connection""" + start = time.time() + + if not VAST_API_KEY: + return TestResult( + name="vast.ai API Verbindung", + description="Prueft ob die vast.ai Cloud-GPU API konfiguriert ist", + expected="API Key konfiguriert", + actual="VAST_API_KEY nicht gesetzt", + status="skipped", + duration_ms=(time.time() - start) * 1000, + error_message="Umgebungsvariable VAST_API_KEY fehlt" + ) + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get( + "https://console.vast.ai/api/v0/users/current", + headers={"Authorization": f"Bearer {VAST_API_KEY}"} + ) + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + data = response.json() + balance = data.get("credit", 0) + return TestResult( + name="vast.ai API Verbindung", + description="Prueft ob die vast.ai Cloud-GPU API konfiguriert ist", + expected="API erreichbar mit Guthaben", + actual=f"Verbunden, Guthaben: ${balance:.2f}", + status="passed", + duration_ms=duration + ) + else: + return TestResult( + name="vast.ai API Verbindung", + description="Prueft ob die vast.ai Cloud-GPU API konfiguriert ist", + expected="API erreichbar mit Guthaben", + actual=f"HTTP {response.status_code}", + status="failed", + duration_ms=duration, + error_message="API-Authentifizierung fehlgeschlagen" + ) + except Exception as e: + return TestResult( + name="vast.ai API Verbindung", + description="Prueft ob die vast.ai Cloud-GPU API konfiguriert ist", + expected="API erreichbar mit Guthaben", + actual=f"Fehler: {str(e)}", + status="failed", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +async def test_gpu_api_endpoint() -> TestResult: + """Test GPU Admin API Endpoint""" + start = time.time() + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(f"{BACKEND_URL}/api/gpu/status") + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + return TestResult( + name="GPU Admin API", + description="Prueft ob die GPU-Verwaltungs-API verfuegbar ist", + expected="HTTP 200 mit GPU-Status", + actual="API verfuegbar", + status="passed", + duration_ms=duration + ) + elif response.status_code == 404: + return TestResult( + name="GPU Admin API", + description="Prueft ob die GPU-Verwaltungs-API verfuegbar ist", + expected="HTTP 200 mit GPU-Status", + actual="Endpoint nicht implementiert", + status="skipped", + duration_ms=duration, + error_message="GPU API nicht aktiviert" + ) + else: + return TestResult( + name="GPU Admin API", + description="Prueft ob die GPU-Verwaltungs-API verfuegbar ist", + expected="HTTP 200 mit GPU-Status", + actual=f"HTTP {response.status_code}", + status="failed", + duration_ms=duration, + error_message=f"Unerwarteter Status: {response.status_code}" + ) + except Exception as e: + return TestResult( + name="GPU Admin API", + description="Prueft ob die GPU-Verwaltungs-API verfuegbar ist", + expected="HTTP 200 mit GPU-Status", + actual=f"Fehler: {str(e)}", + status="failed", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +# ============================================== +# Category Runners +# ============================================== + +async def run_detection_tests() -> TestCategoryResult: + """Run GPU detection tests""" + tests = await asyncio.gather( + test_nvidia_smi(), + test_rocm_smi(), + test_cuda_available(), + ) + + passed = sum(1 for t in tests if t.status == "passed") + failed = sum(1 for t in tests if t.status == "failed") + + return TestCategoryResult( + category="detection", + display_name="GPU Erkennung", + description="Tests zur Hardware-Erkennung", + tests=list(tests), + passed=passed, + failed=failed, + total=len(tests) + ) + + +async def run_cloud_tests() -> TestCategoryResult: + """Run cloud GPU tests""" + tests = await asyncio.gather( + test_vast_ai_api(), + ) + + passed = sum(1 for t in tests if t.status == "passed") + failed = sum(1 for t in tests if t.status == "failed") + + return TestCategoryResult( + category="cloud", + display_name="Cloud GPU (vast.ai)", + description="Tests fuer Cloud-GPU-Dienste", + tests=list(tests), + passed=passed, + failed=failed, + total=len(tests) + ) + + +async def run_api_tests() -> TestCategoryResult: + """Run GPU API tests""" + tests = await asyncio.gather( + test_gpu_api_endpoint(), + ) + + passed = sum(1 for t in tests if t.status == "passed") + failed = sum(1 for t in tests if t.status == "failed") + + return TestCategoryResult( + category="api-health", + display_name="GPU Admin API", + description="Tests fuer die GPU-Verwaltungs-Endpunkte", + tests=list(tests), + passed=passed, + failed=failed, + total=len(tests) + ) + + +# ============================================== +# API Endpoints +# ============================================== + +@router.post("/{category}", response_model=TestCategoryResult) +async def run_category_tests(category: str): + """Run tests for a specific category""" + runners = { + "api-health": run_api_tests, + "detection": run_detection_tests, + "cloud": run_cloud_tests, + } + + if category not in runners: + return TestCategoryResult( + category=category, + display_name=f"Unbekannt: {category}", + description="Kategorie nicht gefunden", + tests=[], + passed=0, + failed=0, + total=0 + ) + + return await runners[category]() + + +@router.post("/run-all", response_model=FullTestResults) +async def run_all_tests(): + """Run all GPU tests""" + start = time.time() + + categories = await asyncio.gather( + run_api_tests(), + run_detection_tests(), + run_cloud_tests(), + ) + + total_passed = sum(c.passed for c in categories) + total_failed = sum(c.failed for c in categories) + total_tests = sum(c.total for c in categories) + + return FullTestResults( + categories=list(categories), + total_passed=total_passed, + total_failed=total_failed, + total_tests=total_tests, + duration_ms=(time.time() - start) * 1000 + ) + + +@router.get("/categories") +async def get_categories(): + """Get available test categories""" + return { + "categories": [ + {"id": "api-health", "name": "GPU Admin API", "description": "Backend API Tests"}, + {"id": "detection", "name": "GPU Erkennung", "description": "Hardware Detection"}, + {"id": "cloud", "name": "Cloud GPU", "description": "vast.ai Integration"}, + ] + } diff --git a/backend/image_cleaner.py b/backend/image_cleaner.py new file mode 100644 index 0000000..f0a04e6 --- /dev/null +++ b/backend/image_cleaner.py @@ -0,0 +1,345 @@ +""" +Image Cleaning Module - Stage 2 of Worksheet Cleaning System + +Removes handwriting and markings from worksheet scans while preserving +printed text and diagrams using computer vision techniques. +""" + +import cv2 +import numpy as np +from pathlib import Path +from typing import Dict, List, Optional +import logging + +logger = logging.getLogger(__name__) + + +class WorksheetCleaner: + """ + Removes handwriting from worksheet scans while preserving printed content. + + Multi-strategy approach: + 1. Color-based filtering (blue ink detection) + 2. AI-guided region masking (using bounding boxes from analysis) + 3. Stroke thickness analysis (thin handwriting vs thick print) + 4. Diagram preservation (copy from original) + """ + + def __init__(self, debug_mode: bool = False): + """ + Initialize the worksheet cleaner. + + Args: + debug_mode: If True, saves intermediate images for debugging + """ + self.debug_mode = debug_mode + + # Tunable parameters (optimiert für bessere Handschrift-Entfernung) + self.blue_hue_range = (90, 130) # HSV hue range for blue ink + self.inpaint_radius = 10 # Erhöht von 3 auf 10 für besseres Inpainting + self.min_stroke_thickness = 2 + self.handwriting_area_threshold = 50 + self.sharpen_amount = 1.5 + self.mask_dilation_kernel_size = 5 # Vergrößert Masken um Handschrift vollständig zu erfassen + + def clean_worksheet( + self, + image_path: Path, + analysis_data: Dict, + output_path: Path + ) -> Path: + """ + Main cleaning pipeline. + + Args: + image_path: Path to input worksheet scan + analysis_data: JSON from Stage 1 analysis (with layout/handwriting_regions) + output_path: Where to save cleaned image + + Returns: + Path to cleaned image + + Raises: + ValueError: If image cannot be loaded + RuntimeError: If cleaning fails + """ + logger.info(f"Starting cleaning for {image_path.name}") + + # Load image + img = cv2.imread(str(image_path)) + if img is None: + raise ValueError(f"Cannot load image: {image_path}") + + original = img.copy() + cleaned = img.copy() + + try: + # Strategy 1: Color-based filtering + if self._has_blue_ink_annotations(analysis_data): + logger.info("Applying blue ink removal") + cleaned = self._remove_blue_ink(cleaned) + + # Strategy 2: AI-guided region masking + hw_regions = analysis_data.get('handwriting_regions', []) + if hw_regions: + logger.info(f"Masking {len(hw_regions)} handwriting regions") + cleaned = self._mask_handwriting_regions(cleaned, hw_regions) + + # Strategy 3: Stroke thickness analysis + logger.info("Removing thin strokes") + cleaned = self._remove_thin_strokes(cleaned, img) + + # Post-processing: enhance printed text + logger.info("Enhancing printed text") + cleaned = self._enhance_printed_text(cleaned) + + # Preserve diagrams + diagram_elements = analysis_data.get('layout', {}).get('diagram_elements', []) + if diagram_elements: + logger.info(f"Preserving {len(diagram_elements)} diagram elements") + cleaned = self._preserve_diagrams(cleaned, original, diagram_elements) + + # Save result + cv2.imwrite(str(output_path), cleaned) + logger.info(f"Cleaned image saved to {output_path.name}") + + return output_path + + except Exception as e: + logger.error(f"Cleaning failed for {image_path.name}: {e}") + raise RuntimeError(f"Cleaning failed: {e}") from e + + def _has_blue_ink_annotations(self, analysis_data: Dict) -> bool: + """Check if analysis detected blue ink handwriting""" + hw_regions = analysis_data.get('handwriting_regions', []) + return any(r.get('color_hint') == 'blue' for r in hw_regions) + + def _remove_blue_ink(self, img: np.ndarray) -> np.ndarray: + """ + Remove blue pen marks (common for student answers). + + Strategy: Blue ink has high Blue channel, lower Red/Green. + Convert to HSV, isolate blue hue range, create mask, inpaint. + + Args: + img: Input image (BGR) + + Returns: + Image with blue ink removed + """ + hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) + + # Blue hue range: 90-130 in OpenCV HSV (Hue is 0-180) + lower_blue = np.array([self.blue_hue_range[0], 50, 50]) + upper_blue = np.array([self.blue_hue_range[1], 255, 255]) + + # Create mask for blue pixels + blue_mask = cv2.inRange(hsv, lower_blue, upper_blue) + + # Morphological operations to clean up mask + kernel = np.ones((3, 3), np.uint8) + blue_mask = cv2.morphologyEx(blue_mask, cv2.MORPH_CLOSE, kernel) + blue_mask = cv2.morphologyEx(blue_mask, cv2.MORPH_OPEN, kernel) + + # Inpaint blue regions with surrounding colors + cleaned = cv2.inpaint(img, blue_mask, inpaintRadius=self.inpaint_radius, + flags=cv2.INPAINT_TELEA) + + return cleaned + + def _mask_handwriting_regions( + self, + img: np.ndarray, + hw_regions: List[Dict] + ) -> np.ndarray: + """ + Mask out handwriting regions identified by AI. + + Uses bounding boxes from analysis_data to selectively clean areas. + OPTIMIZED: Verwendet starkes Inpainting mit vergrößerten Masken. + + Args: + img: Input image + hw_regions: List of handwriting region dicts with bounding_box + + Returns: + Image with handwriting regions cleaned + """ + cleaned = img.copy() + img_h, img_w = img.shape[:2] + + # Erstelle globale Maske für alle Handschrift-Regionen + mask = np.zeros((img_h, img_w), dtype=np.uint8) + + for region in hw_regions: + if region.get('type') in ['student_answer', 'correction', 'note', 'drawing']: + bbox = region.get('bounding_box', {}) + x = bbox.get('x', 0) + y = bbox.get('y', 0) + w = bbox.get('width', 0) + h = bbox.get('height', 0) + + # Validate and clip bounding box + if w > 0 and h > 0 and x >= 0 and y >= 0: + x = max(0, min(x, img_w - 1)) + y = max(0, min(y, img_h - 1)) + w = min(w, img_w - x) + h = min(h, img_h - y) + + if w > 0 and h > 0: + # Vergrößere Bounding Box um 10 Pixel in jede Richtung + padding = 10 + x_pad = max(0, x - padding) + y_pad = max(0, y - padding) + w_pad = min(img_w - x_pad, w + 2 * padding) + h_pad = min(img_h - y_pad, h + 2 * padding) + + # Zeichne gefülltes Rechteck in Maske + cv2.rectangle(mask, (x_pad, y_pad), (x_pad + w_pad, y_pad + h_pad), 255, -1) + + # Vergrößere Maske mit Morphological Dilation + if self.mask_dilation_kernel_size > 0: + kernel = np.ones((self.mask_dilation_kernel_size, self.mask_dilation_kernel_size), np.uint8) + mask = cv2.dilate(mask, kernel, iterations=2) + + # Inpaint mit größerem Radius + if np.any(mask > 0): + cleaned = cv2.inpaint(cleaned, mask, inpaintRadius=self.inpaint_radius, + flags=cv2.INPAINT_TELEA) + logger.info(f"Inpainted {np.sum(mask > 0)} pixels with radius {self.inpaint_radius}") + + return cleaned + + def _clean_text_region(self, region: np.ndarray) -> np.ndarray: + """ + Clean a specific text region, removing handwriting but keeping print. + + Heuristic: Printed text is usually darker and more uniform thickness. + Handwriting varies in pressure, lighter or inconsistent. + + Args: + region: Image region to clean + + Returns: + Cleaned region + """ + gray = cv2.cvtColor(region, cv2.COLOR_BGR2GRAY) + + # Adaptive threshold to separate foreground + binary = cv2.adaptiveThreshold( + gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, + cv2.THRESH_BINARY_INV, 11, 2 + ) + + # Find connected components + num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats( + binary, connectivity=8 + ) + + # Create mask for components to remove (small/thin = handwriting) + mask = np.zeros(binary.shape, dtype=np.uint8) + for i in range(1, num_labels): # Skip background (0) + area = stats[i, cv2.CC_STAT_AREA] + width = stats[i, cv2.CC_STAT_WIDTH] + height = stats[i, cv2.CC_STAT_HEIGHT] + + # Heuristic: handwriting is often thinner, smaller area + # This needs tuning based on real samples + if area < self.handwriting_area_threshold or max(width, height) < 10: + mask[labels == i] = 255 + + # Inpaint masked regions + cleaned = cv2.inpaint(region, mask, inpaintRadius=self.inpaint_radius, + flags=cv2.INPAINT_TELEA) + + return cleaned + + def _remove_thin_strokes(self, cleaned: np.ndarray, original: np.ndarray) -> np.ndarray: + """ + Remove thin pen strokes (handwriting) while keeping thicker printed text. + + Uses morphological operations to identify stroke thickness. + + Args: + cleaned: Current cleaned image + original: Original image + + Returns: + Image with thin strokes removed + """ + gray_clean = cv2.cvtColor(cleaned, cv2.COLOR_BGR2GRAY) + + # Detect edges + edges_clean = cv2.Canny(gray_clean, 50, 150) + + # Morphological closing to connect nearby edges + kernel = np.ones((2, 2), np.uint8) + thick_strokes = cv2.morphologyEx(edges_clean, cv2.MORPH_CLOSE, kernel, iterations=2) + + # Thin strokes detection (erosion reveals thin lines) + thin_strokes = cv2.erode(edges_clean, kernel, iterations=1) + thin_only = cv2.subtract(thin_strokes, thick_strokes) + + # Inpaint thin strokes + result = cv2.inpaint(cleaned, thin_only, inpaintRadius=2, flags=cv2.INPAINT_TELEA) + + return result + + def _enhance_printed_text(self, img: np.ndarray) -> np.ndarray: + """ + Sharpen and enhance printed text after cleaning. + + Uses unsharp masking technique. + + Args: + img: Input image + + Returns: + Enhanced image + """ + # Unsharp masking + gaussian = cv2.GaussianBlur(img, (0, 0), 2.0) + sharpened = cv2.addWeighted(img, self.sharpen_amount, gaussian, -0.5, 0) + + return sharpened + + def _preserve_diagrams( + self, + cleaned: np.ndarray, + original: np.ndarray, + diagram_elements: List[Dict] + ) -> np.ndarray: + """ + Preserve diagram/illustration areas by copying from original. + + Args: + cleaned: Current cleaned image + original: Original scan + diagram_elements: List of diagram bounding boxes from AI analysis + + Returns: + Image with diagrams preserved + """ + result = cleaned.copy() + + for diagram in diagram_elements: + if diagram.get('preserve', True): + bbox = diagram.get('bounding_box', {}) + x = bbox.get('x', 0) + y = bbox.get('y', 0) + w = bbox.get('width', 0) + h = bbox.get('height', 0) + + # Validate and clip bounding box + if w > 0 and h > 0 and x >= 0 and y >= 0: + img_h, img_w = original.shape[:2] + x = min(x, img_w - 1) + y = min(y, img_h - 1) + w = min(w, img_w - x) + h = min(h, img_h - y) + + if w > 0 and h > 0: + # Copy diagram region from original + result[y:y+h, x:x+w] = original[y:y+h, x:x+w] + + return result diff --git a/backend/infra/__init__.py b/backend/infra/__init__.py new file mode 100644 index 0000000..b6b4e19 --- /dev/null +++ b/backend/infra/__init__.py @@ -0,0 +1,10 @@ +""" +Infrastructure management module. + +Provides control plane for external GPU resources (vast.ai). +""" + +from .vast_client import VastAIClient +from .vast_power import router as vast_router + +__all__ = ["VastAIClient", "vast_router"] diff --git a/backend/infra/vast_client.py b/backend/infra/vast_client.py new file mode 100644 index 0000000..4732ac1 --- /dev/null +++ b/backend/infra/vast_client.py @@ -0,0 +1,419 @@ +""" +Vast.ai REST API Client. + +Verwendet die offizielle vast.ai API statt CLI fuer mehr Stabilitaet. +API Dokumentation: https://docs.vast.ai/api +""" + +import asyncio +import logging +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum +from typing import Optional, Dict, Any, List + +import httpx + +logger = logging.getLogger(__name__) + + +class InstanceStatus(Enum): + """Vast.ai Instance Status.""" + RUNNING = "running" + STOPPED = "stopped" + EXITED = "exited" + LOADING = "loading" + SCHEDULING = "scheduling" + CREATING = "creating" + UNKNOWN = "unknown" + + +@dataclass +class AccountInfo: + """Informationen ueber den vast.ai Account.""" + credit: float # Aktuelles Guthaben in USD + balance: float # Balance (meist 0) + total_spend: float # Gesamtausgaben + username: str + email: str + has_billing: bool + + @classmethod + def from_api_response(cls, data: Dict[str, Any]) -> "AccountInfo": + """Erstellt AccountInfo aus API Response.""" + return cls( + credit=data.get("credit", 0.0), + balance=data.get("balance", 0.0), + total_spend=abs(data.get("total_spend", 0.0)), # API gibt negativ zurück + username=data.get("username", ""), + email=data.get("email", ""), + has_billing=data.get("has_billing", False), + ) + + def to_dict(self) -> Dict[str, Any]: + """Serialisiert zu Dictionary.""" + return { + "credit": self.credit, + "balance": self.balance, + "total_spend": self.total_spend, + "username": self.username, + "email": self.email, + "has_billing": self.has_billing, + } + + +@dataclass +class InstanceInfo: + """Informationen ueber eine vast.ai Instanz.""" + id: int + status: InstanceStatus + machine_id: Optional[int] = None + gpu_name: Optional[str] = None + num_gpus: int = 1 + gpu_ram: Optional[float] = None # GB + cpu_ram: Optional[float] = None # GB + disk_space: Optional[float] = None # GB + dph_total: Optional[float] = None # $/hour + public_ipaddr: Optional[str] = None + ports: Dict[str, Any] = field(default_factory=dict) + label: Optional[str] = None + image_uuid: Optional[str] = None + started_at: Optional[datetime] = None + + @classmethod + def from_api_response(cls, data: Dict[str, Any]) -> "InstanceInfo": + """Erstellt InstanceInfo aus API Response.""" + status_map = { + "running": InstanceStatus.RUNNING, + "exited": InstanceStatus.EXITED, + "loading": InstanceStatus.LOADING, + "scheduling": InstanceStatus.SCHEDULING, + "creating": InstanceStatus.CREATING, + } + + actual_status = data.get("actual_status", "unknown") + status = status_map.get(actual_status, InstanceStatus.UNKNOWN) + + # Parse ports mapping + ports = {} + if "ports" in data and data["ports"]: + ports = data["ports"] + + # Parse started_at + started_at = None + if "start_date" in data and data["start_date"]: + try: + started_at = datetime.fromtimestamp(data["start_date"], tz=timezone.utc) + except (ValueError, TypeError): + pass + + return cls( + id=data.get("id", 0), + status=status, + machine_id=data.get("machine_id"), + gpu_name=data.get("gpu_name"), + num_gpus=data.get("num_gpus", 1), + gpu_ram=data.get("gpu_ram"), + cpu_ram=data.get("cpu_ram"), + disk_space=data.get("disk_space"), + dph_total=data.get("dph_total"), + public_ipaddr=data.get("public_ipaddr"), + ports=ports, + label=data.get("label"), + image_uuid=data.get("image_uuid"), + started_at=started_at, + ) + + def get_endpoint_url(self, internal_port: int = 8001) -> Optional[str]: + """Berechnet die externe URL fuer einen internen Port.""" + if not self.public_ipaddr: + return None + + # vast.ai mapped interne Ports auf externe Ports + # Format: {"8001/tcp": [{"HostIp": "0.0.0.0", "HostPort": "12345"}]} + port_key = f"{internal_port}/tcp" + if port_key in self.ports: + port_info = self.ports[port_key] + if isinstance(port_info, list) and port_info: + host_port = port_info[0].get("HostPort") + if host_port: + return f"http://{self.public_ipaddr}:{host_port}" + + # Fallback: Direkter Port + return f"http://{self.public_ipaddr}:{internal_port}" + + def to_dict(self) -> Dict[str, Any]: + """Serialisiert zu Dictionary.""" + return { + "id": self.id, + "status": self.status.value, + "machine_id": self.machine_id, + "gpu_name": self.gpu_name, + "num_gpus": self.num_gpus, + "gpu_ram": self.gpu_ram, + "cpu_ram": self.cpu_ram, + "disk_space": self.disk_space, + "dph_total": self.dph_total, + "public_ipaddr": self.public_ipaddr, + "ports": self.ports, + "label": self.label, + "started_at": self.started_at.isoformat() if self.started_at else None, + } + + +class VastAIClient: + """ + Async Client fuer vast.ai REST API. + + Verwendet die offizielle API unter https://console.vast.ai/api/v0/ + """ + + BASE_URL = "https://console.vast.ai/api/v0" + + def __init__(self, api_key: str, timeout: float = 30.0): + self.api_key = api_key + self.timeout = timeout + self._client: Optional[httpx.AsyncClient] = None + + async def _get_client(self) -> httpx.AsyncClient: + """Lazy Client-Erstellung.""" + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient( + timeout=self.timeout, + headers={ + "Accept": "application/json", + }, + ) + return self._client + + async def close(self) -> None: + """Schliesst den HTTP Client.""" + if self._client and not self._client.is_closed: + await self._client.aclose() + self._client = None + + def _build_url(self, endpoint: str) -> str: + """Baut vollstaendige URL mit API Key.""" + sep = "&" if "?" in endpoint else "?" + return f"{self.BASE_URL}{endpoint}{sep}api_key={self.api_key}" + + async def list_instances(self) -> List[InstanceInfo]: + """Listet alle Instanzen auf.""" + client = await self._get_client() + url = self._build_url("/instances/") + + try: + response = await client.get(url) + response.raise_for_status() + data = response.json() + + instances = [] + if "instances" in data: + for inst_data in data["instances"]: + instances.append(InstanceInfo.from_api_response(inst_data)) + + return instances + + except httpx.HTTPStatusError as e: + logger.error(f"vast.ai API error listing instances: {e}") + raise + + async def get_instance(self, instance_id: int) -> Optional[InstanceInfo]: + """Holt Details einer spezifischen Instanz.""" + client = await self._get_client() + url = self._build_url(f"/instances/{instance_id}/") + + try: + response = await client.get(url) + response.raise_for_status() + data = response.json() + + if "instances" in data: + instances = data["instances"] + # API gibt bei einzelner Instanz ein dict zurück, bei Liste eine Liste + if isinstance(instances, list) and instances: + return InstanceInfo.from_api_response(instances[0]) + elif isinstance(instances, dict): + # Füge ID hinzu falls nicht vorhanden + if "id" not in instances: + instances["id"] = instance_id + return InstanceInfo.from_api_response(instances) + elif isinstance(data, dict) and "id" in data: + return InstanceInfo.from_api_response(data) + + return None + + except httpx.HTTPStatusError as e: + if e.response.status_code == 404: + return None + logger.error(f"vast.ai API error getting instance {instance_id}: {e}") + raise + + async def start_instance(self, instance_id: int) -> bool: + """Startet eine gestoppte Instanz.""" + client = await self._get_client() + url = self._build_url(f"/instances/{instance_id}/") + + try: + response = await client.put( + url, + json={"state": "running"}, + ) + response.raise_for_status() + logger.info(f"vast.ai instance {instance_id} start requested") + return True + + except httpx.HTTPStatusError as e: + logger.error(f"vast.ai API error starting instance {instance_id}: {e}") + return False + + async def stop_instance(self, instance_id: int) -> bool: + """Stoppt eine laufende Instanz (haelt Disk).""" + client = await self._get_client() + url = self._build_url(f"/instances/{instance_id}/") + + try: + response = await client.put( + url, + json={"state": "stopped"}, + ) + response.raise_for_status() + logger.info(f"vast.ai instance {instance_id} stop requested") + return True + + except httpx.HTTPStatusError as e: + logger.error(f"vast.ai API error stopping instance {instance_id}: {e}") + return False + + async def destroy_instance(self, instance_id: int) -> bool: + """Loescht eine Instanz komplett (Disk weg!).""" + client = await self._get_client() + url = self._build_url(f"/instances/{instance_id}/") + + try: + response = await client.delete(url) + response.raise_for_status() + logger.info(f"vast.ai instance {instance_id} destroyed") + return True + + except httpx.HTTPStatusError as e: + logger.error(f"vast.ai API error destroying instance {instance_id}: {e}") + return False + + async def set_label(self, instance_id: int, label: str) -> bool: + """Setzt ein Label fuer eine Instanz.""" + client = await self._get_client() + url = self._build_url(f"/instances/{instance_id}/") + + try: + response = await client.put( + url, + json={"label": label}, + ) + response.raise_for_status() + return True + + except httpx.HTTPStatusError as e: + logger.error(f"vast.ai API error setting label on instance {instance_id}: {e}") + return False + + async def wait_for_status( + self, + instance_id: int, + target_status: InstanceStatus, + timeout_seconds: int = 300, + poll_interval: float = 5.0, + ) -> Optional[InstanceInfo]: + """ + Wartet bis eine Instanz einen bestimmten Status erreicht. + + Returns: + InstanceInfo wenn Status erreicht, None bei Timeout. + """ + deadline = asyncio.get_event_loop().time() + timeout_seconds + + while asyncio.get_event_loop().time() < deadline: + instance = await self.get_instance(instance_id) + + if instance and instance.status == target_status: + return instance + + if instance: + logger.debug( + f"vast.ai instance {instance_id} status: {instance.status.value}, " + f"waiting for {target_status.value}" + ) + + await asyncio.sleep(poll_interval) + + logger.warning( + f"Timeout waiting for instance {instance_id} to reach {target_status.value}" + ) + return None + + async def wait_for_health( + self, + instance: InstanceInfo, + health_path: str = "/health", + internal_port: int = 8001, + timeout_seconds: int = 600, + poll_interval: float = 5.0, + ) -> bool: + """ + Wartet bis der Health-Endpoint erreichbar ist. + + Returns: + True wenn Health OK, False bei Timeout. + """ + endpoint = instance.get_endpoint_url(internal_port) + if not endpoint: + logger.error("No endpoint URL available for health check") + return False + + health_url = f"{endpoint.rstrip('/')}{health_path}" + logger.info(f"Waiting for health at {health_url}") + + deadline = asyncio.get_event_loop().time() + timeout_seconds + health_client = httpx.AsyncClient(timeout=5.0) + + try: + while asyncio.get_event_loop().time() < deadline: + try: + response = await health_client.get(health_url) + if 200 <= response.status_code < 300: + logger.info(f"Health check passed: {health_url}") + return True + except Exception as e: + logger.debug(f"Health check failed: {e}") + + await asyncio.sleep(poll_interval) + + logger.warning(f"Health check timeout: {health_url}") + return False + + finally: + await health_client.aclose() + + async def get_account_info(self) -> Optional[AccountInfo]: + """ + Holt Account-Informationen inkl. Credit/Budget. + + Returns: + AccountInfo oder None bei Fehler. + """ + client = await self._get_client() + url = self._build_url("/users/current/") + + try: + response = await client.get(url) + response.raise_for_status() + data = response.json() + + return AccountInfo.from_api_response(data) + + except httpx.HTTPStatusError as e: + logger.error(f"vast.ai API error getting account info: {e}") + return None + except Exception as e: + logger.error(f"Error getting vast.ai account info: {e}") + return None diff --git a/backend/infra/vast_power.py b/backend/infra/vast_power.py new file mode 100644 index 0000000..fc1d049 --- /dev/null +++ b/backend/infra/vast_power.py @@ -0,0 +1,618 @@ +""" +Vast.ai Power Control API. + +Stellt Endpoints bereit fuer: +- Start/Stop von vast.ai Instanzen +- Status-Abfrage +- Auto-Shutdown bei Inaktivitaet +- Kosten-Tracking + +Sicherheit: Alle Endpoints erfordern CONTROL_API_KEY. +""" + +import asyncio +import json +import logging +import os +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional, Dict, Any, List + +from fastapi import APIRouter, Depends, HTTPException, Header, BackgroundTasks +from pydantic import BaseModel, Field + +from .vast_client import VastAIClient, InstanceInfo, InstanceStatus, AccountInfo + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/infra/vast", tags=["Infrastructure"]) + + +# ------------------------- +# Configuration (ENV) +# ------------------------- +VAST_API_KEY = os.getenv("VAST_API_KEY") +VAST_INSTANCE_ID = os.getenv("VAST_INSTANCE_ID") # Numeric instance ID +CONTROL_API_KEY = os.getenv("CONTROL_API_KEY") # Admin key for these endpoints + +# Health check configuration +VAST_HEALTH_PORT = int(os.getenv("VAST_HEALTH_PORT", "8001")) +VAST_HEALTH_PATH = os.getenv("VAST_HEALTH_PATH", "/health") +VAST_WAIT_TIMEOUT_S = int(os.getenv("VAST_WAIT_TIMEOUT_S", "600")) # 10 min + +# Auto-shutdown configuration +AUTO_SHUTDOWN_ENABLED = os.getenv("VAST_AUTO_SHUTDOWN", "true").lower() == "true" +AUTO_SHUTDOWN_MINUTES = int(os.getenv("VAST_AUTO_SHUTDOWN_MINUTES", "30")) + +# State persistence (in /tmp for container compatibility) +STATE_PATH = Path(os.getenv("VAST_STATE_PATH", "/tmp/vast_state.json")) +AUDIT_PATH = Path(os.getenv("VAST_AUDIT_PATH", "/tmp/vast_audit.log")) + + +# ------------------------- +# State Management +# ------------------------- +class VastState: + """ + Persistenter State fuer vast.ai Kontrolle. + + Speichert: + - Aktueller Endpunkt (weil IP sich aendern kann) + - Letzte Aktivitaet (fuer Auto-Shutdown) + - Kosten-Tracking + """ + + def __init__(self, path: Path = STATE_PATH): + self.path = path + self._state: Dict[str, Any] = self._load() + + def _load(self) -> Dict[str, Any]: + """Laedt State von Disk.""" + if not self.path.exists(): + return { + "desired_state": None, + "endpoint_base_url": None, + "last_activity": None, + "last_start": None, + "last_stop": None, + "total_runtime_seconds": 0, + "total_cost_usd": 0.0, + } + try: + return json.loads(self.path.read_text(encoding="utf-8")) + except Exception: + return {} + + def _save(self) -> None: + """Speichert State auf Disk.""" + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text( + json.dumps(self._state, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + def get(self, key: str, default: Any = None) -> Any: + return self._state.get(key, default) + + def set(self, key: str, value: Any) -> None: + self._state[key] = value + self._save() + + def update(self, data: Dict[str, Any]) -> None: + self._state.update(data) + self._save() + + def record_activity(self) -> None: + """Zeichnet letzte Aktivitaet auf (fuer Auto-Shutdown).""" + self._state["last_activity"] = datetime.now(timezone.utc).isoformat() + self._save() + + def get_last_activity(self) -> Optional[datetime]: + """Gibt letzte Aktivitaet als datetime.""" + ts = self._state.get("last_activity") + if ts: + return datetime.fromisoformat(ts) + return None + + def record_start(self) -> None: + """Zeichnet Start-Zeit auf.""" + self._state["last_start"] = datetime.now(timezone.utc).isoformat() + self._state["desired_state"] = "RUNNING" + self._save() + + def record_stop(self, dph_total: Optional[float] = None) -> None: + """Zeichnet Stop-Zeit auf und berechnet Kosten.""" + now = datetime.now(timezone.utc) + self._state["last_stop"] = now.isoformat() + self._state["desired_state"] = "STOPPED" + + # Berechne Runtime und Kosten + last_start = self._state.get("last_start") + if last_start: + start_dt = datetime.fromisoformat(last_start) + runtime_seconds = (now - start_dt).total_seconds() + self._state["total_runtime_seconds"] = ( + self._state.get("total_runtime_seconds", 0) + runtime_seconds + ) + + if dph_total: + hours = runtime_seconds / 3600 + cost = hours * dph_total + self._state["total_cost_usd"] = ( + self._state.get("total_cost_usd", 0.0) + cost + ) + logger.info( + f"Session cost: ${cost:.3f} ({runtime_seconds/60:.1f} min @ ${dph_total}/h)" + ) + + self._save() + + +# Global state instance +_state = VastState() + + +# ------------------------- +# Audit Logging +# ------------------------- +def audit_log(event: str, actor: str = "system", meta: Optional[Dict[str, Any]] = None) -> None: + """Schreibt Audit-Log Eintrag.""" + meta = meta or {} + line = json.dumps( + { + "ts": datetime.now(timezone.utc).isoformat(), + "event": event, + "actor": actor, + "meta": meta, + }, + ensure_ascii=False, + ) + AUDIT_PATH.parent.mkdir(parents=True, exist_ok=True) + with AUDIT_PATH.open("a", encoding="utf-8") as f: + f.write(line + "\n") + logger.info(f"AUDIT: {event} by {actor}") + + +# ------------------------- +# Request/Response Models +# ------------------------- +class PowerOnRequest(BaseModel): + wait_for_health: bool = Field(default=True, description="Warten bis LLM bereit") + health_path: str = Field(default=VAST_HEALTH_PATH) + health_port: int = Field(default=VAST_HEALTH_PORT) + + +class PowerOnResponse(BaseModel): + status: str + instance_id: Optional[int] = None + endpoint_base_url: Optional[str] = None + health_url: Optional[str] = None + message: Optional[str] = None + + +class PowerOffRequest(BaseModel): + pass # Keine Parameter noetig + + +class PowerOffResponse(BaseModel): + status: str + session_runtime_minutes: Optional[float] = None + session_cost_usd: Optional[float] = None + message: Optional[str] = None + + +class VastStatusResponse(BaseModel): + instance_id: Optional[int] = None + status: str + gpu_name: Optional[str] = None + dph_total: Optional[float] = None + endpoint_base_url: Optional[str] = None + last_activity: Optional[str] = None + auto_shutdown_in_minutes: Optional[int] = None + total_runtime_hours: Optional[float] = None + total_cost_usd: Optional[float] = None + # Budget / Credit Informationen + account_credit: Optional[float] = None # Verbleibendes Guthaben in USD + account_total_spend: Optional[float] = None # Gesamtausgaben auf vast.ai + # Session-Kosten (seit letztem Start) + session_runtime_minutes: Optional[float] = None + session_cost_usd: Optional[float] = None + message: Optional[str] = None + + +class CostStatsResponse(BaseModel): + total_runtime_hours: float + total_cost_usd: float + sessions_count: int + avg_session_minutes: float + + +# ------------------------- +# Security Dependency +# ------------------------- +def require_control_key(x_api_key: Optional[str] = Header(default=None)) -> None: + """ + Admin-Schutz fuer Control-Endpoints. + + Header: X-API-Key: + """ + if not CONTROL_API_KEY: + raise HTTPException( + status_code=500, + detail="CONTROL_API_KEY not configured on server", + ) + if x_api_key != CONTROL_API_KEY: + raise HTTPException(status_code=401, detail="Unauthorized") + + +# ------------------------- +# Auto-Shutdown Background Task +# ------------------------- +_shutdown_task: Optional[asyncio.Task] = None + + +async def auto_shutdown_monitor() -> None: + """ + Hintergrund-Task der bei Inaktivitaet die Instanz stoppt. + + Laeuft permanent wenn Instanz an ist und prueft alle 60s ob + Aktivitaet stattfand. Stoppt Instanz wenn keine Aktivitaet + seit AUTO_SHUTDOWN_MINUTES. + """ + if not VAST_API_KEY or not VAST_INSTANCE_ID: + return + + client = VastAIClient(VAST_API_KEY) + + try: + while True: + await asyncio.sleep(60) # Check every minute + + if not AUTO_SHUTDOWN_ENABLED: + continue + + last_activity = _state.get_last_activity() + if not last_activity: + continue + + # Berechne Inaktivitaet + now = datetime.now(timezone.utc) + inactive_minutes = (now - last_activity).total_seconds() / 60 + + if inactive_minutes >= AUTO_SHUTDOWN_MINUTES: + logger.info( + f"Auto-shutdown triggered: {inactive_minutes:.1f} min inactive" + ) + audit_log( + "auto_shutdown", + actor="system", + meta={"inactive_minutes": inactive_minutes}, + ) + + # Hole aktuelle Instanz-Info fuer Kosten + instance = await client.get_instance(int(VAST_INSTANCE_ID)) + dph = instance.dph_total if instance else None + + # Stop + await client.stop_instance(int(VAST_INSTANCE_ID)) + _state.record_stop(dph_total=dph) + + audit_log("auto_shutdown_complete", actor="system") + + except asyncio.CancelledError: + pass + except Exception as e: + logger.error(f"Auto-shutdown monitor error: {e}") + finally: + await client.close() + + +def start_auto_shutdown_monitor() -> None: + """Startet den Auto-Shutdown Monitor.""" + global _shutdown_task + if _shutdown_task is None or _shutdown_task.done(): + _shutdown_task = asyncio.create_task(auto_shutdown_monitor()) + logger.info("Auto-shutdown monitor started") + + +def stop_auto_shutdown_monitor() -> None: + """Stoppt den Auto-Shutdown Monitor.""" + global _shutdown_task + if _shutdown_task and not _shutdown_task.done(): + _shutdown_task.cancel() + logger.info("Auto-shutdown monitor stopped") + + +# ------------------------- +# API Endpoints +# ------------------------- + +@router.get("/status", response_model=VastStatusResponse, dependencies=[Depends(require_control_key)]) +async def get_status() -> VastStatusResponse: + """ + Gibt Status der vast.ai Instanz zurueck. + + Inkludiert: + - Aktueller Status (running/stopped/etc) + - GPU Info und Kosten pro Stunde + - Endpoint URL + - Auto-Shutdown Timer + - Gesamtkosten + - Account Credit (verbleibendes Budget) + - Session-Kosten (seit letztem Start) + """ + if not VAST_API_KEY or not VAST_INSTANCE_ID: + return VastStatusResponse( + status="unconfigured", + message="VAST_API_KEY or VAST_INSTANCE_ID not set", + ) + + client = VastAIClient(VAST_API_KEY) + try: + instance = await client.get_instance(int(VAST_INSTANCE_ID)) + + if not instance: + return VastStatusResponse( + instance_id=int(VAST_INSTANCE_ID), + status="not_found", + message=f"Instance {VAST_INSTANCE_ID} not found", + ) + + # Hole Account-Info fuer Budget/Credit + account_info = await client.get_account_info() + account_credit = account_info.credit if account_info else None + account_total_spend = account_info.total_spend if account_info else None + + # Update endpoint if running + endpoint = None + if instance.status == InstanceStatus.RUNNING: + endpoint = instance.get_endpoint_url(VAST_HEALTH_PORT) + if endpoint: + _state.set("endpoint_base_url", endpoint) + + # Calculate auto-shutdown timer + auto_shutdown_minutes = None + if AUTO_SHUTDOWN_ENABLED and instance.status == InstanceStatus.RUNNING: + last_activity = _state.get_last_activity() + if last_activity: + inactive = (datetime.now(timezone.utc) - last_activity).total_seconds() / 60 + auto_shutdown_minutes = max(0, int(AUTO_SHUTDOWN_MINUTES - inactive)) + + # Berechne aktuelle Session-Kosten (wenn Instanz laeuft) + session_runtime_minutes = None + session_cost_usd = None + last_start = _state.get("last_start") + + # Falls Instanz laeuft aber kein last_start gesetzt (z.B. nach Container-Neustart), + # nutze start_date aus der vast.ai API falls vorhanden, sonst jetzt + if instance.status == InstanceStatus.RUNNING and not last_start: + if instance.started_at: + _state.set("last_start", instance.started_at.isoformat()) + last_start = instance.started_at.isoformat() + else: + _state.record_start() + last_start = _state.get("last_start") + + if last_start and instance.status == InstanceStatus.RUNNING: + start_dt = datetime.fromisoformat(last_start) + session_runtime_minutes = (datetime.now(timezone.utc) - start_dt).total_seconds() / 60 + if instance.dph_total: + session_cost_usd = (session_runtime_minutes / 60) * instance.dph_total + + return VastStatusResponse( + instance_id=instance.id, + status=instance.status.value, + gpu_name=instance.gpu_name, + dph_total=instance.dph_total, + endpoint_base_url=endpoint or _state.get("endpoint_base_url"), + last_activity=_state.get("last_activity"), + auto_shutdown_in_minutes=auto_shutdown_minutes, + total_runtime_hours=_state.get("total_runtime_seconds", 0) / 3600, + total_cost_usd=_state.get("total_cost_usd", 0.0), + account_credit=account_credit, + account_total_spend=account_total_spend, + session_runtime_minutes=session_runtime_minutes, + session_cost_usd=session_cost_usd, + ) + + finally: + await client.close() + + +@router.post("/power/on", response_model=PowerOnResponse, dependencies=[Depends(require_control_key)]) +async def power_on( + payload: PowerOnRequest, + background_tasks: BackgroundTasks, +) -> PowerOnResponse: + """ + Startet die vast.ai Instanz. + + 1. Startet Instanz via API + 2. Wartet auf Status RUNNING + 3. Optional: Wartet auf Health-Endpoint + 4. Startet Auto-Shutdown Monitor + """ + if not VAST_API_KEY or not VAST_INSTANCE_ID: + raise HTTPException( + status_code=500, + detail="VAST_API_KEY or VAST_INSTANCE_ID not configured", + ) + + instance_id = int(VAST_INSTANCE_ID) + audit_log("power_on_requested", meta={"instance_id": instance_id}) + + client = VastAIClient(VAST_API_KEY) + try: + # Start instance + success = await client.start_instance(instance_id) + if not success: + raise HTTPException(status_code=502, detail="Failed to start instance") + + _state.record_start() + _state.record_activity() + + # Wait for running status + instance = await client.wait_for_status( + instance_id, + InstanceStatus.RUNNING, + timeout_seconds=300, + ) + + if not instance: + return PowerOnResponse( + status="starting", + instance_id=instance_id, + message="Instance start requested but not yet running. Check status.", + ) + + # Get endpoint + endpoint = instance.get_endpoint_url(payload.health_port) + if endpoint: + _state.set("endpoint_base_url", endpoint) + + # Wait for health if requested + if payload.wait_for_health: + health_ok = await client.wait_for_health( + instance, + health_path=payload.health_path, + internal_port=payload.health_port, + timeout_seconds=VAST_WAIT_TIMEOUT_S, + ) + + if not health_ok: + audit_log("power_on_health_timeout", meta={"instance_id": instance_id}) + return PowerOnResponse( + status="running_unhealthy", + instance_id=instance_id, + endpoint_base_url=endpoint, + message=f"Instance running but health check failed at {endpoint}{payload.health_path}", + ) + + # Start auto-shutdown monitor + start_auto_shutdown_monitor() + + audit_log("power_on_complete", meta={ + "instance_id": instance_id, + "endpoint": endpoint, + }) + + return PowerOnResponse( + status="running", + instance_id=instance_id, + endpoint_base_url=endpoint, + health_url=f"{endpoint}{payload.health_path}" if endpoint else None, + message="Instance running and healthy", + ) + + finally: + await client.close() + + +@router.post("/power/off", response_model=PowerOffResponse, dependencies=[Depends(require_control_key)]) +async def power_off(payload: PowerOffRequest) -> PowerOffResponse: + """ + Stoppt die vast.ai Instanz (behaelt Disk). + + Berechnet Session-Kosten und -Laufzeit. + """ + if not VAST_API_KEY or not VAST_INSTANCE_ID: + raise HTTPException( + status_code=500, + detail="VAST_API_KEY or VAST_INSTANCE_ID not configured", + ) + + instance_id = int(VAST_INSTANCE_ID) + audit_log("power_off_requested", meta={"instance_id": instance_id}) + + # Stop auto-shutdown monitor + stop_auto_shutdown_monitor() + + client = VastAIClient(VAST_API_KEY) + try: + # Get current info for cost calculation + instance = await client.get_instance(instance_id) + dph = instance.dph_total if instance else None + + # Calculate session stats before updating state + session_runtime = 0.0 + session_cost = 0.0 + last_start = _state.get("last_start") + if last_start: + start_dt = datetime.fromisoformat(last_start) + session_runtime = (datetime.now(timezone.utc) - start_dt).total_seconds() / 60 + if dph: + session_cost = (session_runtime / 60) * dph + + # Stop instance + success = await client.stop_instance(instance_id) + if not success: + raise HTTPException(status_code=502, detail="Failed to stop instance") + + _state.record_stop(dph_total=dph) + + audit_log("power_off_complete", meta={ + "instance_id": instance_id, + "session_minutes": session_runtime, + "session_cost": session_cost, + }) + + return PowerOffResponse( + status="stopped", + session_runtime_minutes=session_runtime, + session_cost_usd=session_cost, + message=f"Instance stopped. Session: {session_runtime:.1f} min, ${session_cost:.3f}", + ) + + finally: + await client.close() + + +@router.post("/activity", dependencies=[Depends(require_control_key)]) +async def record_activity() -> Dict[str, str]: + """ + Zeichnet Aktivitaet auf (verzoegert Auto-Shutdown). + + Sollte von LLM Gateway aufgerufen werden bei jedem Request. + """ + _state.record_activity() + return {"status": "recorded", "last_activity": _state.get("last_activity")} + + +@router.get("/costs", response_model=CostStatsResponse, dependencies=[Depends(require_control_key)]) +async def get_costs() -> CostStatsResponse: + """ + Gibt Kosten-Statistiken zurueck. + """ + total_seconds = _state.get("total_runtime_seconds", 0) + total_cost = _state.get("total_cost_usd", 0.0) + + # TODO: Sessions count from audit log + sessions = 1 if total_seconds > 0 else 0 + avg_minutes = (total_seconds / 60 / sessions) if sessions > 0 else 0 + + return CostStatsResponse( + total_runtime_hours=total_seconds / 3600, + total_cost_usd=total_cost, + sessions_count=sessions, + avg_session_minutes=avg_minutes, + ) + + +@router.get("/audit", dependencies=[Depends(require_control_key)]) +async def get_audit_log(limit: int = 50) -> List[Dict[str, Any]]: + """ + Gibt letzte Audit-Log Eintraege zurueck. + """ + if not AUDIT_PATH.exists(): + return [] + + lines = AUDIT_PATH.read_text(encoding="utf-8").strip().split("\n") + entries = [] + for line in lines[-limit:]: + try: + entries.append(json.loads(line)) + except json.JSONDecodeError: + continue + + return list(reversed(entries)) # Neueste zuerst diff --git a/backend/jitsi_api.py b/backend/jitsi_api.py new file mode 100644 index 0000000..f1438ce --- /dev/null +++ b/backend/jitsi_api.py @@ -0,0 +1,199 @@ +""" +BreakPilot Jitsi API + +Ermoeglicht das Versenden von Jitsi-Meeting-Einladungen per Email. +""" + +import os +import uuid +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel, Field + +from fastapi import APIRouter, HTTPException + +router = APIRouter(prefix="/api/jitsi", tags=["Jitsi"]) + +# Standard Jitsi Server (kann konfiguriert werden) +JITSI_SERVER = os.getenv("JITSI_SERVER", "https://meet.jit.si") + + +# ========================================== +# PYDANTIC MODELS +# ========================================== + +class JitsiInvitation(BaseModel): + """Model fuer Jitsi-Meeting-Einladung.""" + to_email: str = Field(..., description="Email-Adresse des Teilnehmers") + to_name: str = Field(..., description="Name des Teilnehmers") + organizer_name: str = Field(default="BreakPilot Lehrer", description="Name des Organisators") + meeting_title: str = Field(..., description="Titel des Meetings") + meeting_date: str = Field(..., description="Datum z.B. '20. Dezember 2024'") + meeting_time: str = Field(..., description="Uhrzeit z.B. '14:00 Uhr'") + room_name: Optional[str] = Field(None, description="Raumname (wird generiert wenn leer)") + additional_info: Optional[str] = Field(None, description="Zusaetzliche Informationen") + + +class JitsiInvitationResponse(BaseModel): + """Antwort auf eine Jitsi-Einladung.""" + success: bool + jitsi_url: str + room_name: str + email_sent: bool + email_error: Optional[str] = None + + +class JitsiBulkInvitation(BaseModel): + """Model fuer mehrere Jitsi-Einladungen.""" + recipients: List[dict] = Field(..., description="Liste von {email, name} Objekten") + organizer_name: str = Field(default="BreakPilot Lehrer") + meeting_title: str + meeting_date: str + meeting_time: str + room_name: Optional[str] = None + additional_info: Optional[str] = None + + +class JitsiBulkResponse(BaseModel): + """Antwort auf Bulk-Einladungen.""" + jitsi_url: str + room_name: str + sent: int + failed: int + errors: List[str] + + +# ========================================== +# HELPER FUNCTIONS +# ========================================== + +def generate_room_name() -> str: + """Generiert einen sicheren Raumnamen.""" + # UUID-basiert fuer Sicherheit + unique_id = uuid.uuid4().hex[:12] + return f"BreakPilot-{unique_id}" + + +def build_jitsi_url(room_name: str) -> str: + """Erstellt die vollstaendige Jitsi-URL.""" + return f"{JITSI_SERVER}/{room_name}" + + +# ========================================== +# API ENDPOINTS +# ========================================== + +@router.post("/invite", response_model=JitsiInvitationResponse) +async def send_jitsi_invitation(invitation: JitsiInvitation): + """ + Sendet eine Jitsi-Meeting-Einladung per Email. + + Der Empfaenger kann dem Meeting ueber den Browser beitreten, + ohne Matrix oder andere Software installieren zu muessen. + """ + # Raumname generieren oder verwenden + room_name = invitation.room_name or generate_room_name() + jitsi_url = build_jitsi_url(room_name) + + email_sent = False + email_error = None + + try: + from email_service import email_service + + result = email_service.send_jitsi_invitation( + to_email=invitation.to_email, + to_name=invitation.to_name, + organizer_name=invitation.organizer_name, + meeting_title=invitation.meeting_title, + meeting_date=invitation.meeting_date, + meeting_time=invitation.meeting_time, + jitsi_url=jitsi_url, + additional_info=invitation.additional_info + ) + + email_sent = result.success + if not result.success: + email_error = result.error + + except Exception as e: + email_error = str(e) + + return JitsiInvitationResponse( + success=email_sent, + jitsi_url=jitsi_url, + room_name=room_name, + email_sent=email_sent, + email_error=email_error + ) + + +@router.post("/invite/bulk", response_model=JitsiBulkResponse) +async def send_bulk_jitsi_invitations(bulk: JitsiBulkInvitation): + """ + Sendet Jitsi-Einladungen an mehrere Empfaenger. + + Alle Empfaenger erhalten eine Einladung zum selben Meeting. + """ + # Gemeinsamer Raumname fuer alle + room_name = bulk.room_name or generate_room_name() + jitsi_url = build_jitsi_url(room_name) + + sent = 0 + failed = 0 + errors = [] + + try: + from email_service import email_service + + for recipient in bulk.recipients: + if not recipient.get("email"): + errors.append(f"Fehlende Email fuer {recipient.get('name', 'Unbekannt')}") + failed += 1 + continue + + result = email_service.send_jitsi_invitation( + to_email=recipient["email"], + to_name=recipient.get("name", ""), + organizer_name=bulk.organizer_name, + meeting_title=bulk.meeting_title, + meeting_date=bulk.meeting_date, + meeting_time=bulk.meeting_time, + jitsi_url=jitsi_url, + additional_info=bulk.additional_info + ) + + if result.success: + sent += 1 + else: + failed += 1 + errors.append(f"{recipient.get('email')}: {result.error}") + + except Exception as e: + errors.append(f"Allgemeiner Fehler: {str(e)}") + + return JitsiBulkResponse( + jitsi_url=jitsi_url, + room_name=room_name, + sent=sent, + failed=failed, + errors=errors[:20] # Max 20 Fehler zurueckgeben + ) + + +@router.get("/room") +async def generate_meeting_room(): + """ + Generiert einen neuen Meeting-Raum. + + Gibt die URL zurueck ohne Einladungen zu senden. + """ + room_name = generate_room_name() + jitsi_url = build_jitsi_url(room_name) + + return { + "room_name": room_name, + "jitsi_url": jitsi_url, + "server": JITSI_SERVER, + "created_at": datetime.utcnow().isoformat() + } diff --git a/backend/jitsi_proxy.py b/backend/jitsi_proxy.py new file mode 100644 index 0000000..3a77b34 --- /dev/null +++ b/backend/jitsi_proxy.py @@ -0,0 +1,96 @@ +""" +Jitsi Reverse Proxy + +Leitet Anfragen an den internen Jitsi-Web Container weiter, +sodass Jitsi über Port 8000 erreichbar ist. +""" + +import httpx +from fastapi import APIRouter, Request, Response +from fastapi.responses import StreamingResponse +import os + +router = APIRouter() + +JITSI_INTERNAL_URL = os.getenv("JITSI_INTERNAL_URL", "http://jitsi-web:80") + +# HTTP Client mit längeren Timeouts für Streaming +client = httpx.AsyncClient( + base_url=JITSI_INTERNAL_URL, + timeout=httpx.Timeout(30.0, connect=10.0), + follow_redirects=True +) + + +@router.api_route("/jitsi/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"]) +async def proxy_jitsi(request: Request, path: str): + """ + Proxy all requests to Jitsi Web container. + + This allows accessing Jitsi through the main backend port (8000) + instead of a separate port (8443). + """ + # Build target URL + url = f"/{path}" + if request.query_params: + url += f"?{request.query_params}" + + # Forward headers (except Host) + headers = dict(request.headers) + headers.pop("host", None) + headers.pop("content-length", None) + + # Get request body if present + body = await request.body() if request.method in ["POST", "PUT"] else None + + try: + # Forward request to Jitsi + response = await client.request( + method=request.method, + url=url, + headers=headers, + content=body + ) + + # Build response headers + response_headers = dict(response.headers) + # Remove headers that shouldn't be forwarded + for header in ["content-encoding", "content-length", "transfer-encoding"]: + response_headers.pop(header, None) + + return Response( + content=response.content, + status_code=response.status_code, + headers=response_headers, + media_type=response.headers.get("content-type") + ) + + except httpx.ConnectError: + return Response( + content="Jitsi service not available. Please start with: docker compose up -d jitsi-web", + status_code=503, + media_type="text/plain" + ) + except Exception as e: + return Response( + content=f"Proxy error: {str(e)}", + status_code=502, + media_type="text/plain" + ) + + +@router.get("/jitsi-status") +async def jitsi_status(): + """Check if Jitsi is available.""" + try: + response = await client.get("/") + return { + "status": "available", + "internal_url": JITSI_INTERNAL_URL + } + except Exception as e: + return { + "status": "unavailable", + "error": str(e), + "internal_url": JITSI_INTERNAL_URL + } diff --git a/backend/klausur/__init__.py b/backend/klausur/__init__.py new file mode 100644 index 0000000..1c7bd4f --- /dev/null +++ b/backend/klausur/__init__.py @@ -0,0 +1,54 @@ +""" +Klausurkorrektur Module - Privacy-by-Design Exam Correction. + +DSGVO-compliant exam correction with QR-code based pseudonymization. +No personal data is sent to the LLM. + +Architecture: +- Pseudonymization via doc_token (128-bit UUID) +- Teacher namespace isolation +- Self-hosted LLM at SysEleven +- Zero-knowledge identity mapping (encrypted client-side) +""" + +from .db_models import ( + ExamSession, PseudonymizedDocument, QRBatchJob, + SessionStatus, DocumentStatus, + # Magic Onboarding + OnboardingSession, DetectedStudent, ModuleLink, + OnboardingStatus, ModuleLinkType +) +from .repository import KlausurRepository +from .database import get_db, init_db + +# Services +from .services.roster_parser import RosterParser, get_roster_parser +from .services.school_resolver import SchoolResolver, get_school_resolver +from .services.module_linker import ModuleLinker, get_module_linker + +__all__ = [ + # Models + "ExamSession", + "PseudonymizedDocument", + "QRBatchJob", + "SessionStatus", + "DocumentStatus", + # Magic Onboarding Models + "OnboardingSession", + "DetectedStudent", + "ModuleLink", + "OnboardingStatus", + "ModuleLinkType", + # Repository + "KlausurRepository", + # Database + "get_db", + "init_db", + # Services + "RosterParser", + "get_roster_parser", + "SchoolResolver", + "get_school_resolver", + "ModuleLinker", + "get_module_linker", +] diff --git a/backend/klausur/database.py b/backend/klausur/database.py new file mode 100644 index 0000000..aed6f4d --- /dev/null +++ b/backend/klausur/database.py @@ -0,0 +1,47 @@ +""" +Database Configuration for Klausur Module. + +Uses the same PostgreSQL database as the main backend. +""" +import os +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +# Database URL from environment (uses same DB as Backend) +_raw_url = os.getenv( + "DATABASE_URL", + "postgresql://breakpilot:breakpilot123@localhost:5432/breakpilot" +) +# SQLAlchemy 2.0 requires "postgresql://" instead of "postgres://" +DATABASE_URL = _raw_url.replace("postgres://", "postgresql://", 1) if _raw_url.startswith("postgres://") else _raw_url + +# Engine configuration +engine = create_engine( + DATABASE_URL, + pool_pre_ping=True, + pool_size=5, + max_overflow=10, + echo=os.getenv("SQL_ECHO", "false").lower() == "true" +) + +# Declarative Base +Base = declarative_base() + +# Session factory +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def get_db(): + """Database dependency for FastAPI endpoints.""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db(): + """Creates all tables (for development).""" + from . import db_models # Import models to register them + Base.metadata.create_all(bind=engine) diff --git a/backend/klausur/db_models.py b/backend/klausur/db_models.py new file mode 100644 index 0000000..61113a3 --- /dev/null +++ b/backend/klausur/db_models.py @@ -0,0 +1,377 @@ +""" +SQLAlchemy Database Models for Klausurkorrektur Module. + +Privacy-by-Design: No personal data (student names) is stored in these models. +Only pseudonymized doc_tokens are used to reference exam documents. +""" +from datetime import datetime +from sqlalchemy import ( + Column, String, Integer, DateTime, JSON, + Boolean, Text, Enum as SQLEnum, ForeignKey, LargeBinary +) +from sqlalchemy.orm import relationship +import enum +import uuid + +from .database import Base + + +class SessionStatus(str, enum.Enum): + """Status of an exam correction session.""" + CREATED = "created" # Session created, awaiting uploads + UPLOADING = "uploading" # Documents being uploaded + PROCESSING = "processing" # OCR and AI correction in progress + COMPLETED = "completed" # All documents processed + ARCHIVED = "archived" # Session archived (data retention) + DELETED = "deleted" # Soft delete + + +class OnboardingStatus(str, enum.Enum): + """Status of a magic onboarding session.""" + ANALYZING = "analyzing" # Local LLM extracting headers + CONFIRMING = "confirming" # User confirming detected data + PROCESSING = "processing" # Cloud LLM correcting exams + LINKING = "linking" # Creating module links + COMPLETE = "complete" # Onboarding finished + + +class ModuleLinkType(str, enum.Enum): + """Type of cross-module link.""" + NOTENBUCH = "notenbuch" # Link to grade book + ELTERNABEND = "elternabend" # Link to parent meetings + ZEUGNIS = "zeugnis" # Link to certificates + CALENDAR = "calendar" # Link to calendar events + KLASSENBUCH = "klassenbuch" # Link to class book + + +class DocumentStatus(str, enum.Enum): + """Status of a single pseudonymized document.""" + UPLOADED = "uploaded" # Document uploaded, awaiting OCR + OCR_PROCESSING = "ocr_processing" # OCR in progress + OCR_COMPLETED = "ocr_completed" # OCR done, awaiting AI correction + AI_PROCESSING = "ai_processing" # AI correction in progress + COMPLETED = "completed" # Fully processed + FAILED = "failed" # Processing failed + + +class ExamSession(Base): + """ + Exam Correction Session. + + Groups multiple pseudonymized documents for a single exam correction task. + No personal data is stored - teacher_id is the only identifying info. + """ + __tablename__ = 'klausur_sessions' + + # Primary Key + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + + # Teacher isolation (mandatory) + teacher_id = Column(String(100), nullable=False, index=True) + + # Session metadata + name = Column(String(200), nullable=False) # e.g., "Mathe 10a - Klausur 1" + subject = Column(String(100), default="") + class_name = Column(String(100), default="") # e.g., "10a" + + # Exam configuration + total_points = Column(Integer, default=100) + rubric = Column(Text, default="") # Bewertungskriterien + questions = Column(JSON, default=list) # [{question, points, rubric}] + + # Status + status = Column( + SQLEnum(SessionStatus), + default=SessionStatus.CREATED, + nullable=False, + index=True + ) + + # Statistics (anonymized) + document_count = Column(Integer, default=0) + processed_count = Column(Integer, default=0) + + # Encrypted identity map (only teacher can decrypt) + # This is stored encrypted with teacher's password + encrypted_identity_map = Column(LargeBinary, nullable=True) + identity_map_iv = Column(String(64), nullable=True) # IV for AES decryption + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + completed_at = Column(DateTime, nullable=True) + + # Data retention: auto-delete after this date + retention_until = Column(DateTime, nullable=True) + + # Magic Onboarding: Link to school class (optional) + linked_school_class_id = Column(String(36), nullable=True) + linked_subject_id = Column(String(36), nullable=True) + + # Relationship to documents + documents = relationship( + "PseudonymizedDocument", + back_populates="session", + cascade="all, delete-orphan" + ) + + def __repr__(self): + return f"" + + +class PseudonymizedDocument(Base): + """ + Pseudonymized Exam Document. + + PRIVACY DESIGN: + - doc_token is a 128-bit random UUID, NOT derivable from student identity + - No student name or personal info is stored here + - Identity mapping is stored encrypted in ExamSession.encrypted_identity_map + - The backend CANNOT de-pseudonymize documents + + Only the teacher (with their encryption key) can map doc_token -> student name. + """ + __tablename__ = 'klausur_documents' + + # Primary Key: The pseudonymization token + doc_token = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + + # Session relationship + session_id = Column(String(36), ForeignKey('klausur_sessions.id'), nullable=False, index=True) + + # Processing status + status = Column( + SQLEnum(DocumentStatus), + default=DocumentStatus.UPLOADED, + nullable=False, + index=True + ) + + # Page info + page_number = Column(Integer, default=1) + total_pages = Column(Integer, default=1) + + # OCR result (redacted - no header/name visible) + ocr_text = Column(Text, default="") + ocr_confidence = Column(Integer, default=0) # 0-100 + + # AI correction result (pseudonymized) + ai_feedback = Column(Text, default="") + ai_score = Column(Integer, nullable=True) # Points achieved + ai_grade = Column(String(10), nullable=True) # e.g., "2+" or "B" + ai_details = Column(JSON, default=dict) # Per-question scores + + # Processing metadata + processing_started_at = Column(DateTime, nullable=True) + processing_completed_at = Column(DateTime, nullable=True) + processing_error = Column(Text, nullable=True) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationship + session = relationship("ExamSession", back_populates="documents") + + def __repr__(self): + return f"" + + +class QRBatchJob(Base): + """ + QR Code Generation Batch Job. + + Tracks generation of QR overlay sheets for printing. + The generated PDF contains QR codes with doc_tokens. + """ + __tablename__ = 'klausur_qr_batches' + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + + # Session relationship + session_id = Column(String(36), ForeignKey('klausur_sessions.id'), nullable=False, index=True) + teacher_id = Column(String(100), nullable=False, index=True) + + # Batch info + student_count = Column(Integer, nullable=False) + generated_tokens = Column(JSON, default=list) # List of generated doc_tokens + + # Generated PDF (stored as path reference, not in DB) + pdf_path = Column(String(500), nullable=True) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow) + downloaded_at = Column(DateTime, nullable=True) + + def __repr__(self): + return f"" + + +class OnboardingSession(Base): + """ + Magic Onboarding Session. + + Tracks the automatic class/student detection and setup process. + Temporary data structure - merged into ExamSession after confirmation. + """ + __tablename__ = 'klausur_onboarding_sessions' + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + + # Links + klausur_session_id = Column(String(36), ForeignKey('klausur_sessions.id'), nullable=True) + teacher_id = Column(String(100), nullable=False, index=True) + + # Detected metadata (from local LLM) + detected_class = Column(String(100), nullable=True) + detected_subject = Column(String(100), nullable=True) + detected_date = Column(DateTime, nullable=True) + detected_student_count = Column(Integer, default=0) + detection_confidence = Column(Integer, default=0) # 0-100 + + # Confirmed data (after user review) + confirmed_class = Column(String(100), nullable=True) + confirmed_subject = Column(String(100), nullable=True) + + # Linked school entities (after confirmation) + linked_school_id = Column(String(36), nullable=True) + linked_class_id = Column(String(36), nullable=True) + + # School context + bundesland = Column(String(50), nullable=True) + schulform = Column(String(50), nullable=True) + school_name = Column(String(200), nullable=True) + + # Status + status = Column( + SQLEnum(OnboardingStatus), + default=OnboardingStatus.ANALYZING, + nullable=False, + index=True + ) + + # Progress tracking + analysis_completed_at = Column(DateTime, nullable=True) + confirmation_completed_at = Column(DateTime, nullable=True) + processing_started_at = Column(DateTime, nullable=True) + processing_completed_at = Column(DateTime, nullable=True) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + detected_students = relationship( + "DetectedStudent", + back_populates="onboarding_session", + cascade="all, delete-orphan" + ) + + def __repr__(self): + return f"" + + +class DetectedStudent(Base): + """ + Student detected during Magic Onboarding. + + Temporary storage for detected student data before confirmation. + After confirmation, students are created in the School Service. + """ + __tablename__ = 'klausur_detected_students' + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + + # Onboarding session + onboarding_session_id = Column( + String(36), + ForeignKey('klausur_onboarding_sessions.id'), + nullable=False, + index=True + ) + + # Detected data (from exam header) + detected_first_name = Column(String(100), nullable=True) + detected_last_name_hint = Column(String(100), nullable=True) # Partial, e.g. "M." + + # Confirmed data (after roster matching) + confirmed_first_name = Column(String(100), nullable=True) + confirmed_last_name = Column(String(100), nullable=True) + + # Matched to School Service student + matched_student_id = Column(String(36), nullable=True) + + # Parent contact (extracted from roster) + parent_email = Column(String(200), nullable=True) + parent_phone = Column(String(50), nullable=True) + + # Link to pseudonymized document + doc_token = Column(String(36), nullable=True) + + # Confidence + confidence = Column(Integer, default=0) # 0-100 + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow) + + # Relationship + onboarding_session = relationship("OnboardingSession", back_populates="detected_students") + + def __repr__(self): + name = self.confirmed_first_name or self.detected_first_name or "?" + return f"" + + +class ModuleLink(Base): + """ + Cross-module link from Klausur to other BreakPilot modules. + + Tracks connections to: Notenbuch, Elternabend, Zeugnis, Calendar + """ + __tablename__ = 'klausur_module_links' + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + + # Source + klausur_session_id = Column( + String(36), + ForeignKey('klausur_sessions.id'), + nullable=False, + index=True + ) + + # Link type + link_type = Column( + SQLEnum(ModuleLinkType), + nullable=False, + index=True + ) + + # Target + target_module = Column(String(50), nullable=False) # school, calendar, etc. + target_entity_id = Column(String(36), nullable=True) + target_url = Column(String(500), nullable=True) + + # Link metadata + link_metadata = Column(JSON, default=dict) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow) + + def __repr__(self): + return f" {self.target_module}>" + + +# Export all models +__all__ = [ + "SessionStatus", + "DocumentStatus", + "OnboardingStatus", + "ModuleLinkType", + "ExamSession", + "PseudonymizedDocument", + "QRBatchJob", + "OnboardingSession", + "DetectedStudent", + "ModuleLink", +] diff --git a/backend/klausur/repository.py b/backend/klausur/repository.py new file mode 100644 index 0000000..4a7395a --- /dev/null +++ b/backend/klausur/repository.py @@ -0,0 +1,377 @@ +""" +Repository for Klausurkorrektur Module. + +All queries are filtered by teacher_id to ensure complete namespace isolation. +No cross-teacher data access is possible. +""" +from datetime import datetime, timedelta +from typing import Optional, List +from sqlalchemy.orm import Session +from sqlalchemy import and_, func + +from .db_models import ( + ExamSession, PseudonymizedDocument, QRBatchJob, + SessionStatus, DocumentStatus +) + + +class KlausurRepository: + """ + Repository for exam correction data. + + PRIVACY DESIGN: + - All queries MUST include teacher_id filter + - No method allows access to other teachers' data + - Bulk operations are scoped to teacher namespace + """ + + def __init__(self, db: Session): + self.db = db + + # ==================== Session Operations ==================== + + def create_session( + self, + teacher_id: str, + name: str, + subject: str = "", + class_name: str = "", + total_points: int = 100, + rubric: str = "", + questions: Optional[List[dict]] = None, + retention_days: int = 30 + ) -> ExamSession: + """Create a new exam correction session.""" + session = ExamSession( + teacher_id=teacher_id, + name=name, + subject=subject, + class_name=class_name, + total_points=total_points, + rubric=rubric, + questions=questions or [], + status=SessionStatus.CREATED, + retention_until=datetime.utcnow() + timedelta(days=retention_days) + ) + self.db.add(session) + self.db.commit() + self.db.refresh(session) + return session + + def get_session( + self, + session_id: str, + teacher_id: str + ) -> Optional[ExamSession]: + """Get a session by ID (teacher-scoped).""" + return self.db.query(ExamSession).filter( + and_( + ExamSession.id == session_id, + ExamSession.teacher_id == teacher_id, + ExamSession.status != SessionStatus.DELETED + ) + ).first() + + def list_sessions( + self, + teacher_id: str, + include_archived: bool = False, + limit: int = 50, + offset: int = 0 + ) -> List[ExamSession]: + """List all sessions for a teacher.""" + query = self.db.query(ExamSession).filter( + and_( + ExamSession.teacher_id == teacher_id, + ExamSession.status != SessionStatus.DELETED + ) + ) + if not include_archived: + query = query.filter(ExamSession.status != SessionStatus.ARCHIVED) + + return query.order_by(ExamSession.created_at.desc()).offset(offset).limit(limit).all() + + def update_session_status( + self, + session_id: str, + teacher_id: str, + status: SessionStatus + ) -> Optional[ExamSession]: + """Update session status.""" + session = self.get_session(session_id, teacher_id) + if session: + session.status = status + if status == SessionStatus.COMPLETED: + session.completed_at = datetime.utcnow() + self.db.commit() + self.db.refresh(session) + return session + + def update_session_identity_map( + self, + session_id: str, + teacher_id: str, + encrypted_map: bytes, + iv: str + ) -> Optional[ExamSession]: + """Store encrypted identity map (teacher-scoped).""" + session = self.get_session(session_id, teacher_id) + if session: + session.encrypted_identity_map = encrypted_map + session.identity_map_iv = iv + self.db.commit() + self.db.refresh(session) + return session + + def delete_session( + self, + session_id: str, + teacher_id: str, + hard_delete: bool = False + ) -> bool: + """Delete a session (soft or hard delete).""" + session = self.get_session(session_id, teacher_id) + if not session: + return False + + if hard_delete: + self.db.delete(session) + else: + session.status = SessionStatus.DELETED + self.db.commit() + return True + + # ==================== Document Operations ==================== + + def create_document( + self, + session_id: str, + teacher_id: str, + doc_token: Optional[str] = None, + page_number: int = 1, + total_pages: int = 1 + ) -> Optional[PseudonymizedDocument]: + """Create a new pseudonymized document.""" + # Verify session belongs to teacher + session = self.get_session(session_id, teacher_id) + if not session: + return None + + doc = PseudonymizedDocument( + session_id=session_id, + page_number=page_number, + total_pages=total_pages, + status=DocumentStatus.UPLOADED + ) + if doc_token: + doc.doc_token = doc_token + + self.db.add(doc) + + # Update session document count + session.document_count += 1 + self.db.commit() + self.db.refresh(doc) + return doc + + def get_document( + self, + doc_token: str, + teacher_id: str + ) -> Optional[PseudonymizedDocument]: + """Get a document by token (teacher-scoped via session).""" + return self.db.query(PseudonymizedDocument).join( + ExamSession + ).filter( + and_( + PseudonymizedDocument.doc_token == doc_token, + ExamSession.teacher_id == teacher_id, + ExamSession.status != SessionStatus.DELETED + ) + ).first() + + def list_documents( + self, + session_id: str, + teacher_id: str + ) -> List[PseudonymizedDocument]: + """List all documents in a session (teacher-scoped).""" + # Verify session belongs to teacher + session = self.get_session(session_id, teacher_id) + if not session: + return [] + + return self.db.query(PseudonymizedDocument).filter( + PseudonymizedDocument.session_id == session_id + ).order_by(PseudonymizedDocument.created_at).all() + + def update_document_ocr( + self, + doc_token: str, + teacher_id: str, + ocr_text: str, + confidence: int = 0 + ) -> Optional[PseudonymizedDocument]: + """Update document with OCR results.""" + doc = self.get_document(doc_token, teacher_id) + if doc: + doc.ocr_text = ocr_text + doc.ocr_confidence = confidence + doc.status = DocumentStatus.OCR_COMPLETED + self.db.commit() + self.db.refresh(doc) + return doc + + def update_document_ai_result( + self, + doc_token: str, + teacher_id: str, + feedback: str, + score: Optional[int] = None, + grade: Optional[str] = None, + details: Optional[dict] = None + ) -> Optional[PseudonymizedDocument]: + """Update document with AI correction results.""" + doc = self.get_document(doc_token, teacher_id) + if doc: + doc.ai_feedback = feedback + doc.ai_score = score + doc.ai_grade = grade + doc.ai_details = details or {} + doc.status = DocumentStatus.COMPLETED + doc.processing_completed_at = datetime.utcnow() + + # Update session processed count + session = doc.session + session.processed_count += 1 + + # Check if all documents are processed + if session.processed_count >= session.document_count: + session.status = SessionStatus.COMPLETED + session.completed_at = datetime.utcnow() + + self.db.commit() + self.db.refresh(doc) + return doc + + def update_document_status( + self, + doc_token: str, + teacher_id: str, + status: DocumentStatus, + error: Optional[str] = None + ) -> Optional[PseudonymizedDocument]: + """Update document processing status.""" + doc = self.get_document(doc_token, teacher_id) + if doc: + doc.status = status + if error: + doc.processing_error = error + if status in [DocumentStatus.OCR_PROCESSING, DocumentStatus.AI_PROCESSING]: + doc.processing_started_at = datetime.utcnow() + self.db.commit() + self.db.refresh(doc) + return doc + + # ==================== QR Batch Operations ==================== + + def create_qr_batch( + self, + session_id: str, + teacher_id: str, + student_count: int, + generated_tokens: List[str] + ) -> Optional[QRBatchJob]: + """Create a QR code batch job.""" + # Verify session belongs to teacher + session = self.get_session(session_id, teacher_id) + if not session: + return None + + batch = QRBatchJob( + session_id=session_id, + teacher_id=teacher_id, + student_count=student_count, + generated_tokens=generated_tokens + ) + self.db.add(batch) + self.db.commit() + self.db.refresh(batch) + return batch + + def get_qr_batch( + self, + batch_id: str, + teacher_id: str + ) -> Optional[QRBatchJob]: + """Get a QR batch by ID (teacher-scoped).""" + return self.db.query(QRBatchJob).filter( + and_( + QRBatchJob.id == batch_id, + QRBatchJob.teacher_id == teacher_id + ) + ).first() + + # ==================== Statistics (Anonymized) ==================== + + def get_session_stats( + self, + session_id: str, + teacher_id: str + ) -> dict: + """Get anonymized statistics for a session.""" + session = self.get_session(session_id, teacher_id) + if not session: + return {} + + # Count documents by status + status_counts = self.db.query( + PseudonymizedDocument.status, + func.count(PseudonymizedDocument.doc_token) + ).filter( + PseudonymizedDocument.session_id == session_id + ).group_by(PseudonymizedDocument.status).all() + + # Score statistics (anonymized) + score_stats = self.db.query( + func.avg(PseudonymizedDocument.ai_score), + func.min(PseudonymizedDocument.ai_score), + func.max(PseudonymizedDocument.ai_score) + ).filter( + and_( + PseudonymizedDocument.session_id == session_id, + PseudonymizedDocument.ai_score.isnot(None) + ) + ).first() + + return { + "session_id": session_id, + "total_documents": session.document_count, + "processed_documents": session.processed_count, + "status_breakdown": {s.value: c for s, c in status_counts}, + "score_average": float(score_stats[0]) if score_stats[0] else None, + "score_min": score_stats[1], + "score_max": score_stats[2] + } + + # ==================== Data Retention ==================== + + def cleanup_expired_sessions(self) -> int: + """Delete sessions past their retention date. Returns count deleted.""" + now = datetime.utcnow() + expired = self.db.query(ExamSession).filter( + and_( + ExamSession.retention_until < now, + ExamSession.status != SessionStatus.DELETED + ) + ).all() + + count = len(expired) + for session in expired: + session.status = SessionStatus.DELETED + # Clear sensitive data + session.encrypted_identity_map = None + session.identity_map_iv = None + + self.db.commit() + return count diff --git a/backend/klausur/routes.py b/backend/klausur/routes.py new file mode 100644 index 0000000..500db13 --- /dev/null +++ b/backend/klausur/routes.py @@ -0,0 +1,1970 @@ +""" +Klausurkorrektur API Routes. + +Privacy-by-Design exam correction with QR-code based pseudonymization. +All endpoints are teacher-scoped - no cross-teacher data access possible. + +DSGVO Compliance: +- No student names stored in backend +- Only doc_tokens (pseudonymized IDs) used +- Identity mapping encrypted client-side +- All data auto-deleted after retention period +""" + +import uuid +import logging +import re +import json +from datetime import datetime, timedelta +from typing import Optional, List +from io import BytesIO + +from fastapi import APIRouter, HTTPException, Query, Depends, UploadFile, File, Response, BackgroundTasks +from fastapi.responses import StreamingResponse +from sqlalchemy.orm import Session +from pydantic import BaseModel, Field + +from .database import get_db +from .db_models import ( + ExamSession, PseudonymizedDocument, QRBatchJob, + SessionStatus, DocumentStatus +) +from .repository import KlausurRepository +from .services.pseudonymizer import get_pseudonymizer +from .services.correction_service import get_correction_service, QuestionRubric +from .services.storage_service import get_storage_service +from .services.processing_service import get_processing_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/klausur", tags=["Klausurkorrektur"]) + + +# ============================================================================= +# Pydantic Schemas +# ============================================================================= + +class SessionCreate(BaseModel): + """Request to create a new exam session.""" + name: str = Field(..., min_length=1, max_length=200, description="Session name (e.g., 'Mathe 10a - Klausur 1')") + subject: str = Field(default="", max_length=100) + class_name: str = Field(default="", max_length=100, description="Class name (e.g., '10a')") + total_points: int = Field(default=100, ge=1, le=1000) + rubric: str = Field(default="", description="General grading criteria") + questions: List[dict] = Field(default=[], description="Question definitions with rubrics") + retention_days: int = Field(default=30, ge=1, le=365, description="Auto-delete after N days") + + +class SessionResponse(BaseModel): + """Response for an exam session.""" + id: str + name: str + subject: str + class_name: str + total_points: int + status: str + document_count: int + processed_count: int + created_at: datetime + completed_at: Optional[datetime] = None + retention_until: Optional[datetime] = None + + class Config: + from_attributes = True + + +class SessionListResponse(BaseModel): + """List of exam sessions.""" + sessions: List[SessionResponse] + total: int + + +class DocumentResponse(BaseModel): + """Response for a pseudonymized document.""" + doc_token: str + session_id: str + status: str + page_number: int + total_pages: int + ocr_confidence: int + ai_score: Optional[int] = None + ai_grade: Optional[str] = None + ai_feedback: Optional[str] = None + created_at: datetime + processing_completed_at: Optional[datetime] = None + + class Config: + from_attributes = True + + +class DocumentListResponse(BaseModel): + """List of documents in a session.""" + documents: List[DocumentResponse] + total: int + + +class QRBatchRequest(BaseModel): + """Request to generate QR code batch.""" + student_count: int = Field(..., ge=1, le=100, description="Number of QR codes to generate") + labels: Optional[List[str]] = Field(default=None, description="Optional labels (numbers only, NO names!)") + + +class QRBatchResponse(BaseModel): + """Response with generated QR batch.""" + batch_id: str + session_id: str + student_count: int + generated_tokens: List[str] + + +class IdentityMapUpdate(BaseModel): + """Request to store encrypted identity map.""" + encrypted_data: str = Field(..., description="Base64-encoded encrypted identity map") + iv: str = Field(..., description="Initialization vector for decryption") + + +class ProcessingStats(BaseModel): + """Processing statistics for a session.""" + session_id: str + total_documents: int + processed_documents: int + status_breakdown: dict + score_average: Optional[float] = None + score_min: Optional[int] = None + score_max: Optional[int] = None + + +class CorrectionResultResponse(BaseModel): + """AI correction result (pseudonymized).""" + doc_token: str + total_score: int + max_score: int + grade: str + overall_feedback: str + question_results: List[dict] + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def get_teacher_id(request=None) -> str: + """ + Get teacher ID from request context. + + In production, this should extract the teacher ID from JWT token. + For now, we use a placeholder that should be replaced with actual auth. + """ + # TODO: Implement proper JWT extraction + # return request.state.teacher_id + return "default_teacher" + + +# ============================================================================= +# Session Endpoints +# ============================================================================= + +@router.post("/sessions", response_model=SessionResponse, status_code=201) +async def create_session( + data: SessionCreate, + db: Session = Depends(get_db) +): + """ + Create a new exam correction session. + + This initializes a workspace for pseudonymized exam correction. + No student data is stored at this point. + """ + teacher_id = get_teacher_id() + repo = KlausurRepository(db) + + session = repo.create_session( + teacher_id=teacher_id, + name=data.name, + subject=data.subject, + class_name=data.class_name, + total_points=data.total_points, + rubric=data.rubric, + questions=data.questions, + retention_days=data.retention_days + ) + + return SessionResponse( + id=session.id, + name=session.name, + subject=session.subject, + class_name=session.class_name, + total_points=session.total_points, + status=session.status.value, + document_count=session.document_count, + processed_count=session.processed_count, + created_at=session.created_at, + completed_at=session.completed_at, + retention_until=session.retention_until + ) + + +@router.get("/sessions", response_model=SessionListResponse) +async def list_sessions( + include_archived: bool = Query(False, description="Include archived sessions"), + limit: int = Query(50, ge=1, le=100), + offset: int = Query(0, ge=0), + db: Session = Depends(get_db) +): + """List all exam sessions for the current teacher.""" + teacher_id = get_teacher_id() + repo = KlausurRepository(db) + + sessions = repo.list_sessions( + teacher_id=teacher_id, + include_archived=include_archived, + limit=limit, + offset=offset + ) + + return SessionListResponse( + sessions=[SessionResponse( + id=s.id, + name=s.name, + subject=s.subject, + class_name=s.class_name, + total_points=s.total_points, + status=s.status.value, + document_count=s.document_count, + processed_count=s.processed_count, + created_at=s.created_at, + completed_at=s.completed_at, + retention_until=s.retention_until + ) for s in sessions], + total=len(sessions) + ) + + +@router.get("/sessions/{session_id}", response_model=SessionResponse) +async def get_session( + session_id: str, + db: Session = Depends(get_db) +): + """Get details of a specific session.""" + teacher_id = get_teacher_id() + repo = KlausurRepository(db) + + session = repo.get_session(session_id, teacher_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + return SessionResponse( + id=session.id, + name=session.name, + subject=session.subject, + class_name=session.class_name, + total_points=session.total_points, + status=session.status.value, + document_count=session.document_count, + processed_count=session.processed_count, + created_at=session.created_at, + completed_at=session.completed_at, + retention_until=session.retention_until + ) + + +@router.delete("/sessions/{session_id}", status_code=204) +async def delete_session( + session_id: str, + hard_delete: bool = Query(False, description="Permanently delete (vs soft delete)"), + db: Session = Depends(get_db) +): + """Delete an exam session and all associated documents.""" + teacher_id = get_teacher_id() + repo = KlausurRepository(db) + + success = repo.delete_session(session_id, teacher_id, hard_delete=hard_delete) + if not success: + raise HTTPException(status_code=404, detail="Session not found") + + return Response(status_code=204) + + +# ============================================================================= +# QR Code Generation Endpoints +# ============================================================================= + +@router.post("/sessions/{session_id}/qr-batch", response_model=QRBatchResponse) +async def generate_qr_batch( + session_id: str, + data: QRBatchRequest, + db: Session = Depends(get_db) +): + """ + Generate QR codes for exam pseudonymization. + + Each QR code contains a random doc_token that will be used to + track the exam through the correction process WITHOUT revealing + the student's identity. + + IMPORTANT: Labels should be numbers only (e.g., "Nr. 1", "Nr. 2"), + NOT student names! + """ + teacher_id = get_teacher_id() + repo = KlausurRepository(db) + + session = repo.get_session(session_id, teacher_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + # Generate random tokens + pseudonymizer = get_pseudonymizer() + tokens = pseudonymizer.generate_batch_tokens(data.student_count) + + # Create batch record + batch = repo.create_qr_batch( + session_id=session_id, + teacher_id=teacher_id, + student_count=data.student_count, + generated_tokens=tokens + ) + + return QRBatchResponse( + batch_id=batch.id, + session_id=session_id, + student_count=data.student_count, + generated_tokens=tokens + ) + + +@router.get("/sessions/{session_id}/qr-sheet") +async def download_qr_sheet( + session_id: str, + batch_id: Optional[str] = Query(None), + db: Session = Depends(get_db) +): + """ + Download printable QR code sheet as PNG. + + The sheet contains QR codes with doc_tokens that students + will attach to their exams for pseudonymized tracking. + """ + teacher_id = get_teacher_id() + repo = KlausurRepository(db) + + session = repo.get_session(session_id, teacher_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + # Get the batch (or create one if not specified) + if batch_id: + batch = repo.get_qr_batch(batch_id, teacher_id) + if not batch: + raise HTTPException(status_code=404, detail="QR batch not found") + tokens = batch.generated_tokens + else: + # Get all tokens from documents + docs = repo.list_documents(session_id, teacher_id) + tokens = [d.doc_token for d in docs] + if not tokens: + raise HTTPException(status_code=400, detail="No documents or QR batch found") + + # Generate QR sheet + pseudonymizer = get_pseudonymizer() + try: + sheet_bytes = pseudonymizer.generate_qr_sheet(tokens) + except RuntimeError as e: + raise HTTPException(status_code=500, detail=str(e)) + + return StreamingResponse( + BytesIO(sheet_bytes), + media_type="image/png", + headers={ + "Content-Disposition": f"attachment; filename=qr_sheet_{session_id[:8]}.png" + } + ) + + +# ============================================================================= +# Document Upload & Processing Endpoints +# ============================================================================= + +@router.post("/sessions/{session_id}/upload", response_model=DocumentResponse) +async def upload_document( + session_id: str, + file: UploadFile = File(...), + auto_redact: bool = Query(True, description="Automatically redact header area"), + db: Session = Depends(get_db) +): + """ + Upload a scanned exam page. + + The document will be: + 1. Scanned for QR code to extract doc_token + 2. Header area redacted to remove personal data (if auto_redact=True) + 3. Stored for OCR processing + + PRIVACY: Header redaction removes student name/class before storage. + """ + teacher_id = get_teacher_id() + repo = KlausurRepository(db) + + session = repo.get_session(session_id, teacher_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + # Read file content + content = await file.read() + + pseudonymizer = get_pseudonymizer() + + # Try to detect QR code + qr_result = pseudonymizer.detect_qr_code(content) + doc_token = qr_result.doc_token + + if not doc_token: + # Generate new token if QR not found + doc_token = pseudonymizer.generate_doc_token() + logger.warning(f"No QR code found in upload, generated new token: {doc_token[:8]}") + + # Redact header if requested + if auto_redact: + redaction_result = pseudonymizer.smart_redact_header(content, preserve_qr=True) + if redaction_result.redaction_applied: + content = redaction_result.redacted_image + logger.info(f"Redacted {redaction_result.redacted_height}px header from document") + + # Create document record + doc = repo.create_document( + session_id=session_id, + teacher_id=teacher_id, + doc_token=doc_token + ) + + if not doc: + raise HTTPException(status_code=500, detail="Failed to create document") + + # Store content in MinIO storage + try: + storage = get_storage_service() + file_ext = file.filename.split(".")[-1] if file.filename else "png" + storage.upload_document( + session_id=session_id, + doc_token=doc_token, + file_data=content, + file_extension=file_ext, + is_redacted=auto_redact + ) + logger.info(f"Stored document {doc_token[:8]} in MinIO") + except Exception as e: + logger.warning(f"Failed to store document in MinIO (continuing anyway): {e}") + + return DocumentResponse( + doc_token=doc.doc_token, + session_id=doc.session_id, + status=doc.status.value, + page_number=doc.page_number, + total_pages=doc.total_pages, + ocr_confidence=doc.ocr_confidence, + ai_score=doc.ai_score, + ai_grade=doc.ai_grade, + ai_feedback=doc.ai_feedback, + created_at=doc.created_at, + processing_completed_at=doc.processing_completed_at + ) + + +@router.get("/sessions/{session_id}/documents", response_model=DocumentListResponse) +async def list_documents( + session_id: str, + db: Session = Depends(get_db) +): + """List all documents in a session (pseudonymized).""" + teacher_id = get_teacher_id() + repo = KlausurRepository(db) + + session = repo.get_session(session_id, teacher_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + docs = repo.list_documents(session_id, teacher_id) + + return DocumentListResponse( + documents=[DocumentResponse( + doc_token=d.doc_token, + session_id=d.session_id, + status=d.status.value, + page_number=d.page_number, + total_pages=d.total_pages, + ocr_confidence=d.ocr_confidence, + ai_score=d.ai_score, + ai_grade=d.ai_grade, + ai_feedback=d.ai_feedback, + created_at=d.created_at, + processing_completed_at=d.processing_completed_at + ) for d in docs], + total=len(docs) + ) + + +@router.get("/documents/{doc_token}", response_model=DocumentResponse) +async def get_document( + doc_token: str, + db: Session = Depends(get_db) +): + """Get details of a specific document by token.""" + teacher_id = get_teacher_id() + repo = KlausurRepository(db) + + doc = repo.get_document(doc_token, teacher_id) + if not doc: + raise HTTPException(status_code=404, detail="Document not found") + + return DocumentResponse( + doc_token=doc.doc_token, + session_id=doc.session_id, + status=doc.status.value, + page_number=doc.page_number, + total_pages=doc.total_pages, + ocr_confidence=doc.ocr_confidence, + ai_score=doc.ai_score, + ai_grade=doc.ai_grade, + ai_feedback=doc.ai_feedback, + created_at=doc.created_at, + processing_completed_at=doc.processing_completed_at + ) + + +# ============================================================================= +# Processing & Correction Endpoints +# ============================================================================= + +@router.post("/sessions/{session_id}/process", status_code=202) +async def start_processing( + session_id: str, + background_tasks: BackgroundTasks, + use_ai: bool = Query(default=True, description="Run AI correction (requires LLM)"), + db: Session = Depends(get_db) +): + """ + Start OCR and AI correction for all uploaded documents. + + This triggers background processing: + 1. OCR extraction of student answers (via TrOCR on Mac Mini) + 2. AI-assisted correction using self-hosted LLM + 3. Grade calculation + + PRIVACY: Only pseudonymized text is sent to LLM. + No student names or personal data. + """ + teacher_id = get_teacher_id() + repo = KlausurRepository(db) + + session = repo.get_session(session_id, teacher_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + if session.document_count == 0: + raise HTTPException(status_code=400, detail="No documents to process") + + if session.status == SessionStatus.PROCESSING: + raise HTTPException(status_code=409, detail="Session is already processing") + + # Update session status + repo.update_session_status(session_id, teacher_id, SessionStatus.PROCESSING) + + # Start background processing task + async def run_processing(): + """Background task wrapper.""" + from .database import SessionLocal + db_session = SessionLocal() + try: + service = get_processing_service(db_session) + await service.process_session( + session_id=session_id, + teacher_id=teacher_id, + use_ai_correction=use_ai + ) + except Exception as e: + logger.error(f"Background processing failed: {e}") + # Mark session as failed + try: + repo_err = KlausurRepository(db_session) + repo_err.update_session_status(session_id, teacher_id, SessionStatus.CREATED) + except Exception: + pass + finally: + db_session.close() + + # Add to background tasks + background_tasks.add_task(run_processing) + + logger.info(f"Started background processing for session {session_id} with {session.document_count} documents") + + return { + "status": "processing", + "message": "Background processing started", + "session_id": session_id, + "document_count": session.document_count, + "use_ai_correction": use_ai + } + + +@router.get("/sessions/{session_id}/stats", response_model=ProcessingStats) +async def get_processing_stats( + session_id: str, + db: Session = Depends(get_db) +): + """Get anonymized processing statistics for a session.""" + teacher_id = get_teacher_id() + repo = KlausurRepository(db) + + stats = repo.get_session_stats(session_id, teacher_id) + if not stats: + raise HTTPException(status_code=404, detail="Session not found") + + return ProcessingStats(**stats) + + +@router.get("/sessions/{session_id}/results", response_model=List[CorrectionResultResponse]) +async def get_correction_results( + session_id: str, + db: Session = Depends(get_db) +): + """ + Get AI correction results (pseudonymized). + + Returns doc_token + scores/grades WITHOUT student names. + The teacher's client can rejoin these with the encrypted + identity map to reveal which student each result belongs to. + """ + teacher_id = get_teacher_id() + repo = KlausurRepository(db) + + session = repo.get_session(session_id, teacher_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + docs = repo.list_documents(session_id, teacher_id) + + results = [] + for doc in docs: + if doc.status == DocumentStatus.COMPLETED: + results.append(CorrectionResultResponse( + doc_token=doc.doc_token, + total_score=doc.ai_score or 0, + max_score=session.total_points, + grade=doc.ai_grade or "", + overall_feedback=doc.ai_feedback or "", + question_results=doc.ai_details.get("question_results", []) if doc.ai_details else [] + )) + + return results + + +# ============================================================================= +# Identity Map (Client-Side Encryption) Endpoints +# ============================================================================= + +@router.post("/sessions/{session_id}/identity-map", status_code=204) +async def store_identity_map( + session_id: str, + data: IdentityMapUpdate, + db: Session = Depends(get_db) +): + """ + Store encrypted identity map for a session. + + PRIVACY DESIGN: + - The identity map (doc_token → student name) is encrypted + with the teacher's password BEFORE being sent to server + - Server stores only the encrypted blob + - Server CANNOT decrypt the mapping + - Only the teacher (with their password) can rejoin results + + This is zero-knowledge storage. + """ + teacher_id = get_teacher_id() + repo = KlausurRepository(db) + + import base64 + try: + encrypted_bytes = base64.b64decode(data.encrypted_data) + except Exception: + raise HTTPException(status_code=400, detail="Invalid base64 data") + + result = repo.update_session_identity_map( + session_id=session_id, + teacher_id=teacher_id, + encrypted_map=encrypted_bytes, + iv=data.iv + ) + + if not result: + raise HTTPException(status_code=404, detail="Session not found") + + return Response(status_code=204) + + +@router.get("/sessions/{session_id}/identity-map") +async def get_identity_map( + session_id: str, + db: Session = Depends(get_db) +): + """ + Retrieve encrypted identity map. + + Returns the encrypted blob that the teacher's client + can decrypt locally to rejoin results with student names. + """ + teacher_id = get_teacher_id() + repo = KlausurRepository(db) + + session = repo.get_session(session_id, teacher_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + if not session.encrypted_identity_map: + raise HTTPException(status_code=404, detail="No identity map stored") + + import base64 + return { + "encrypted_data": base64.b64encode(session.encrypted_identity_map).decode(), + "iv": session.identity_map_iv + } + + +# ============================================================================= +# Data Retention Endpoint +# ============================================================================= + +@router.post("/maintenance/cleanup", status_code=200) +async def cleanup_expired_data( + db: Session = Depends(get_db) +): + """ + Clean up expired sessions (data retention). + + This should be called periodically (e.g., daily cron job). + Deletes sessions past their retention_until date. + """ + repo = KlausurRepository(db) + deleted_count = repo.cleanup_expired_sessions() + + return { + "status": "ok", + "deleted_sessions": deleted_count, + "timestamp": datetime.utcnow().isoformat() + } + + +# ============================================================================= +# Magic Onboarding Endpoints +# ============================================================================= + +# Import additional models for Magic Onboarding +from .db_models import OnboardingSession, DetectedStudent, ModuleLink, OnboardingStatus, ModuleLinkType +from .services.roster_parser import get_roster_parser +from .services.school_resolver import get_school_resolver, BUNDESLAENDER, SCHULFORMEN, FAECHER +from .services.module_linker import get_module_linker, CorrectionResult + + +class MagicAnalysisRequest(BaseModel): + """Request for magic header analysis (client-side results).""" + detected_class: Optional[str] = None + detected_subject: Optional[str] = None + detected_date: Optional[str] = None + students: List[dict] = Field(default=[]) # [{firstName, lastNameHint, confidence}] + confidence: float = Field(default=0.0, ge=0.0, le=1.0) + + +class MagicAnalysisResponse(BaseModel): + """Response after magic analysis.""" + onboarding_id: str + detected_class: Optional[str] + detected_subject: Optional[str] + detected_date: Optional[str] + student_count: int + confidence: float + bundeslaender: dict # For school cascade + schulformen: dict + existing_classes: List[dict] # Teacher's existing classes + + +class OnboardingConfirmRequest(BaseModel): + """Request to confirm onboarding data.""" + onboarding_id: str + # School context + bundesland: str + schulform: str + school_name: str + # Class info + class_name: str + subject: str + # Students (confirmed) + students: List[dict] # [{firstName, lastName, parentEmail?, parentPhone?}] + # Options + create_class: bool = Field(default=True) + link_to_existing_class_id: Optional[str] = None + + +class OnboardingConfirmResponse(BaseModel): + """Response after confirmation.""" + session_id: str + onboarding_id: str + class_id: Optional[str] + student_count: int + ready_for_correction: bool + + +class RosterUploadResponse(BaseModel): + """Response after roster upload.""" + parsed_count: int + matched_count: int + entries: List[dict] # [{firstName, lastName, parentEmail?, matched: bool}] + warnings: List[str] + + +class MagicCorrectionRequest(BaseModel): + """Request to start magic correction.""" + onboarding_id: str + rubric: str = Field(default="") + questions: List[dict] = Field(default=[]) + + +class ResultsWithLinksResponse(BaseModel): + """Results with module links.""" + results: List[CorrectionResultResponse] + statistics: dict + module_links: List[dict] + parent_meeting_suggestions: List[dict] + + +class FileExtractionRequest(BaseModel): + """Request to extract info from uploaded exam files.""" + filenames: List[str] = Field(default=[], description="Original filenames for metadata extraction") + use_llm: bool = Field(default=True, description="Use LLM for intelligent extraction") + + +class ExamExtractionResult(BaseModel): + """Extracted information from an exam file.""" + filename: str + detected_student_name: Optional[str] = None + detected_last_name_hint: Optional[str] = None + detected_class: Optional[str] = None + detected_subject: Optional[str] = None + detected_date: Optional[str] = None + detected_grade: Optional[str] = None + detected_score: Optional[int] = None + detected_max_score: Optional[int] = None + is_nachschreiben: bool = False + is_separate_page: bool = False + page_number: Optional[int] = None + question_scores: List[dict] = Field(default=[]) # [{question: 1, score: 5, max: 10}] + raw_text: Optional[str] = None + confidence: float = 0.0 + + +class FileExtractionResponse(BaseModel): + """Response with extracted exam information.""" + results: List[ExamExtractionResult] + detected_class: Optional[str] = None + detected_subject: Optional[str] = None + detected_date: Optional[str] = None + student_count: int = 0 + overall_confidence: float = 0.0 + + +@router.post("/magic-onboarding/extract", response_model=FileExtractionResponse) +async def extract_exam_info( + files: List[UploadFile] = File(...), + db: Session = Depends(get_db) +): + """ + Server-side extraction of exam information using OCR and LLM. + + Extracts: + - Student names from headers + - Class and subject from context + - Grades and scores if already corrected + - Question-level scores + + Uses: + 1. Filename parsing for initial metadata + 2. OCR for text extraction + 3. Ollama/Qwen for intelligent parsing (if available) + """ + import re + import httpx + + results = [] + class_votes = {} + subject_votes = {} + date_votes = {} + + for file in files: + filename = file.filename or "" + content = await file.read() + + # Parse filename for metadata + filename_info = _parse_exam_filename(filename) + + result = ExamExtractionResult( + filename=filename, + detected_class=filename_info.get('class'), + detected_subject=filename_info.get('subject'), + detected_date=filename_info.get('date'), + is_nachschreiben=filename_info.get('nachschreiben', False), + is_separate_page=filename_info.get('separate_page', False), + page_number=filename_info.get('page_number'), + confidence=0.5 # Base confidence from filename + ) + + # Try to extract student name from filename + if filename_info.get('student_name'): + result.detected_student_name = filename_info['student_name'] + result.confidence = 0.7 + + # Vote for class/subject + if result.detected_class: + class_votes[result.detected_class] = class_votes.get(result.detected_class, 0) + 1 + if result.detected_subject: + subject_votes[result.detected_subject] = subject_votes.get(result.detected_subject, 0) + 1 + if result.detected_date: + date_votes[result.detected_date] = date_votes.get(result.detected_date, 0) + 1 + + # Try LLM extraction if Ollama is available + try: + llm_result = await _extract_with_ollama(content, filename) + if llm_result: + result.detected_student_name = llm_result.get('student_name') or result.detected_student_name + result.detected_last_name_hint = llm_result.get('last_name_hint') + result.detected_grade = llm_result.get('grade') + result.detected_score = llm_result.get('score') + result.detected_max_score = llm_result.get('max_score') + result.question_scores = llm_result.get('question_scores', []) + result.raw_text = llm_result.get('raw_text', '')[:500] # Truncate for response + result.confidence = max(result.confidence, llm_result.get('confidence', 0.0)) + except Exception as e: + logger.warning(f"LLM extraction failed for {filename}: {e}") + + results.append(result) + + # Determine overall detected values + detected_class = max(class_votes.items(), key=lambda x: x[1])[0] if class_votes else None + detected_subject = max(subject_votes.items(), key=lambda x: x[1])[0] if subject_votes else None + detected_date = max(date_votes.items(), key=lambda x: x[1])[0] if date_votes else None + overall_confidence = sum(r.confidence for r in results) / len(results) if results else 0.0 + + return FileExtractionResponse( + results=results, + detected_class=detected_class, + detected_subject=detected_subject, + detected_date=detected_date, + student_count=len(results), + overall_confidence=overall_confidence + ) + + +def _parse_exam_filename(filename: str) -> dict: + """ + Parse exam filename for metadata. + + Expected patterns: + - 20260119_103820_Mathe_Klasse_3-1_2026-01-15_085630.pdf + - Mathe_Klasse_3_Nachschreiben_2026-01-15_090901.pdf + - Mathe_Klasse_3-2_Miguel_Seite_2_2026-01-15_090620.pdf + """ + import re + + result = { + 'class': None, + 'subject': None, + 'date': None, + 'nachschreiben': False, + 'separate_page': False, + 'page_number': None, + 'student_name': None + } + + # Remove extension + name = filename.rsplit('.', 1)[0] if '.' in filename else filename + + # Detect subject (common German subjects) + subjects = ['Mathe', 'Mathematik', 'Deutsch', 'Englisch', 'Physik', 'Chemie', 'Bio', 'Biologie', + 'Geschichte', 'Erdkunde', 'Geographie', 'Kunst', 'Musik', 'Sport', 'Informatik', + 'Französisch', 'Latein', 'Spanisch', 'Religion', 'Ethik', 'Politik', 'Wirtschaft'] + for subject in subjects: + if subject.lower() in name.lower(): + result['subject'] = subject + break + + # Detect class (e.g., Klasse_3-1, 3a, 10b, Q1) + class_patterns = [ + r'Klasse[_\s]*(\d+[-a-zA-Z0-9]*)', # Klasse_3-1, Klasse 10a + r'(\d{1,2}[a-zA-Z])', # 3a, 10b + r'(Q[12])', # Q1, Q2 (Oberstufe) + r'(E[PF])', # EP, EF (Einführungsphase) + ] + for pattern in class_patterns: + match = re.search(pattern, name, re.IGNORECASE) + if match: + result['class'] = match.group(1) + break + + # Detect date (YYYY-MM-DD or DD.MM.YYYY) + date_patterns = [ + r'(\d{4}-\d{2}-\d{2})', # 2026-01-15 + r'(\d{2}\.\d{2}\.\d{4})', # 15.01.2026 + ] + for pattern in date_patterns: + match = re.search(pattern, name) + if match: + result['date'] = match.group(1) + break + + # Detect Nachschreiben + if 'nachschreib' in name.lower(): + result['nachschreiben'] = True + + # Detect separate page (Seite_2) + page_match = re.search(r'Seite[_\s]*(\d+)', name, re.IGNORECASE) + if page_match: + result['separate_page'] = True + result['page_number'] = int(page_match.group(1)) + + # Try to extract student name (usually after class, before date) + # Pattern: ...Klasse_3-2_Miguel_Seite... + name_match = re.search(r'Klasse[_\s]*\d+[-a-zA-Z0-9]*[_\s]+([A-Z][a-z]+)(?:[_\s]|$)', name) + if name_match: + potential_name = name_match.group(1) + # Exclude common non-name words + if potential_name not in ['Seite', 'Nachschreiben', 'Teil', 'Aufgabe']: + result['student_name'] = potential_name + + return result + + +async def _extract_with_ollama(content: bytes, filename: str) -> Optional[dict]: + """ + Use Ollama (local or Mac Mini) to extract information from exam content. + + Tries local Ollama first, then Mac Mini if configured. + """ + import httpx + import base64 + + # Ollama endpoints to try + ollama_endpoints = [ + "http://localhost:11434", # Local + "http://192.168.178.163:11434", # Mac Mini + ] + + # Convert PDF first page to image if needed + image_data = None + if filename.lower().endswith('.pdf'): + try: + # Try to extract first page as image + # This requires pdf2image or PyMuPDF + image_data = await _pdf_to_image(content) + except Exception as e: + logger.warning(f"PDF conversion failed: {e}") + return None + elif filename.lower().endswith(('.png', '.jpg', '.jpeg')): + image_data = content + + if not image_data: + return None + + # Create prompt for extraction + prompt = """Analysiere dieses Bild einer Klausur/Klassenarbeit und extrahiere folgende Informationen im JSON-Format: +{ + "student_name": "Vorname des Schülers (falls sichtbar)", + "last_name_hint": "Anfangsbuchstabe des Nachnamens (z.B. 'M.' falls sichtbar)", + "grade": "Note falls eingetragen (z.B. '2+', '3', '5-')", + "score": Punktzahl als Zahl (falls vorhanden), + "max_score": Maximale Punktzahl als Zahl (falls vorhanden), + "question_scores": [{"question": 1, "score": 5, "max": 10}], + "confidence": Konfidenz 0.0-1.0 +} +Antworte NUR mit dem JSON, kein zusätzlicher Text.""" + + # Try each endpoint + for endpoint in ollama_endpoints: + try: + async with httpx.AsyncClient(timeout=30.0) as client: + # Check if vision model is available + response = await client.get(f"{endpoint}/api/tags") + if response.status_code != 200: + continue + + models = response.json().get('models', []) + # Prefer vision models: llava, bakllava, moondream, qwen2-vl + vision_model = None + for m in models: + name = m.get('name', '').lower() + if any(vm in name for vm in ['llava', 'moondream', 'qwen', 'vision']): + vision_model = m['name'] + break + + # Fall back to text model with OCR + model = vision_model or (models[0]['name'] if models else None) + if not model: + continue + + # Call Ollama + request_data = { + "model": model, + "prompt": prompt, + "stream": False + } + + if vision_model and image_data: + request_data["images"] = [base64.b64encode(image_data).decode()] + + response = await client.post( + f"{endpoint}/api/generate", + json=request_data + ) + + if response.status_code == 200: + result_text = response.json().get('response', '') + # Parse JSON from response + import json + try: + # Extract JSON from response + json_match = re.search(r'\{[^}]+\}', result_text, re.DOTALL) + if json_match: + return json.loads(json_match.group()) + except json.JSONDecodeError: + logger.warning(f"Failed to parse LLM response as JSON") + return None + + except Exception as e: + logger.debug(f"Ollama endpoint {endpoint} failed: {e}") + continue + + return None + + +async def _pdf_to_image(content: bytes) -> Optional[bytes]: + """Convert first page of PDF to PNG image.""" + try: + import fitz # PyMuPDF + doc = fitz.open(stream=content, filetype="pdf") + page = doc[0] + pix = page.get_pixmap(dpi=150) + return pix.tobytes("png") + except ImportError: + pass + + try: + from pdf2image import convert_from_bytes + images = convert_from_bytes(content, first_page=1, last_page=1, dpi=150) + if images: + from io import BytesIO + buffer = BytesIO() + images[0].save(buffer, format='PNG') + return buffer.getvalue() + except ImportError: + pass + + return None + + +@router.post("/magic-onboarding/analyze", response_model=MagicAnalysisResponse) +async def magic_analyze( + data: MagicAnalysisRequest, + db: Session = Depends(get_db) +): + """ + Phase 1: Store client-side analysis results and prepare for confirmation. + + The actual header extraction happens client-side using the local LLM. + This endpoint stores the results and provides school cascade data. + """ + teacher_id = get_teacher_id() + repo = KlausurRepository(db) + resolver = get_school_resolver() + + # Create onboarding session + onboarding = OnboardingSession( + teacher_id=teacher_id, + detected_class=data.detected_class, + detected_subject=data.detected_subject, + detected_student_count=len(data.students), + detection_confidence=int(data.confidence * 100), + status=OnboardingStatus.CONFIRMING + ) + onboarding.analysis_completed_at = datetime.utcnow() + db.add(onboarding) + + # Store detected students + for student_data in data.students: + student = DetectedStudent( + onboarding_session_id=onboarding.id, + detected_first_name=student_data.get('firstName'), + detected_last_name_hint=student_data.get('lastNameHint'), + confidence=int(student_data.get('confidence', 0) * 100) + ) + db.add(student) + + db.commit() + db.refresh(onboarding) + + # Get teacher's existing classes + existing_classes = await resolver.get_classes_for_teacher(teacher_id) + + return MagicAnalysisResponse( + onboarding_id=onboarding.id, + detected_class=onboarding.detected_class, + detected_subject=onboarding.detected_subject, + detected_date=data.detected_date, + student_count=onboarding.detected_student_count, + confidence=data.confidence, + bundeslaender=BUNDESLAENDER, + schulformen={k: v['name'] for k, v in SCHULFORMEN.items()}, + existing_classes=[{ + 'id': c.id, + 'name': c.name, + 'grade_level': c.grade_level + } for c in existing_classes] + ) + + +@router.post("/magic-onboarding/upload-roster", response_model=RosterUploadResponse) +async def upload_roster( + onboarding_id: str = Query(...), + file: UploadFile = File(...), + db: Session = Depends(get_db) +): + """ + Phase 2a: Upload Klassenbuch photo or roster file. + + Parses the uploaded file and matches names to detected students. + """ + teacher_id = get_teacher_id() + parser = get_roster_parser() + + # Get onboarding session + onboarding = db.query(OnboardingSession).filter( + OnboardingSession.id == onboarding_id, + OnboardingSession.teacher_id == teacher_id + ).first() + + if not onboarding: + raise HTTPException(status_code=404, detail="Onboarding session not found") + + # Read file + content = await file.read() + filename = file.filename.lower() + + # Parse based on file type + if filename.endswith(('.png', '.jpg', '.jpeg')): + roster = parser.parse_klassenbuch_image(content) + elif filename.endswith('.pdf'): + roster = parser.parse_pdf_roster(content) + elif filename.endswith('.csv'): + roster = parser.parse_csv_roster(content.decode('utf-8')) + else: + raise HTTPException(status_code=400, detail="Unsupported file format") + + # Get detected students + detected_students = db.query(DetectedStudent).filter( + DetectedStudent.onboarding_session_id == onboarding_id + ).all() + + detected_names = [s.detected_first_name for s in detected_students if s.detected_first_name] + + # Match names + matches = parser.match_first_names(detected_names, roster.entries) + + # Update detected students with matched data + matched_count = 0 + for match in matches: + if match.matched_entry and match.confidence > 0.7: + for student in detected_students: + if student.detected_first_name == match.detected_name: + student.confirmed_first_name = match.matched_entry.first_name + student.confirmed_last_name = match.matched_entry.last_name + student.parent_email = match.matched_entry.parent_email + student.parent_phone = match.matched_entry.parent_phone + matched_count += 1 + break + + db.commit() + + return RosterUploadResponse( + parsed_count=len(roster.entries), + matched_count=matched_count, + entries=[{ + 'firstName': e.first_name, + 'lastName': e.last_name, + 'parentEmail': e.parent_email, + 'parentPhone': e.parent_phone, + 'matched': any( + m.matched_entry and m.matched_entry.first_name == e.first_name + for m in matches + ) + } for e in roster.entries], + warnings=roster.warnings + ) + + +@router.post("/magic-onboarding/confirm", response_model=OnboardingConfirmResponse) +async def confirm_onboarding( + data: OnboardingConfirmRequest, + db: Session = Depends(get_db) +): + """ + Phase 2b: Confirm onboarding data and create class/session. + + Creates the school class (if requested) and exam session. + """ + teacher_id = get_teacher_id() + repo = KlausurRepository(db) + resolver = get_school_resolver() + + # Get onboarding session + onboarding = db.query(OnboardingSession).filter( + OnboardingSession.id == data.onboarding_id, + OnboardingSession.teacher_id == teacher_id + ).first() + + if not onboarding: + raise HTTPException(status_code=404, detail="Onboarding session not found") + + # Update school context + onboarding.bundesland = data.bundesland + onboarding.schulform = data.schulform + onboarding.school_name = data.school_name + onboarding.confirmed_class = data.class_name + onboarding.confirmed_subject = data.subject + onboarding.confirmation_completed_at = datetime.utcnow() + + class_id = data.link_to_existing_class_id + + # Create class if requested + if data.create_class and not class_id: + from .services.school_resolver import DetectedClassInfo + + # Get or create school + school = await resolver.get_or_create_school( + teacher_id=teacher_id, + bundesland=data.bundesland, + schulform=data.schulform, + school_name=data.school_name + ) + onboarding.linked_school_id = school.id + + # Create class + class_info = DetectedClassInfo( + class_name=data.class_name, + students=data.students + ) + school_class = await resolver.auto_create_class( + teacher_id=teacher_id, + school_id=school.id, + detected_info=class_info + ) + class_id = school_class.id + onboarding.linked_class_id = class_id + + # Create exam session + session = repo.create_session( + teacher_id=teacher_id, + name=f"{data.subject} - {data.class_name}", + subject=data.subject, + class_name=data.class_name, + total_points=100 + ) + session.linked_school_class_id = class_id + onboarding.klausur_session_id = session.id + onboarding.status = OnboardingStatus.PROCESSING + + # Update detected students with confirmed data + for student_data in data.students: + # Update or create detected student + first_name = student_data.get('firstName') + if first_name: + student = db.query(DetectedStudent).filter( + DetectedStudent.onboarding_session_id == data.onboarding_id, + DetectedStudent.detected_first_name == first_name + ).first() + + if student: + student.confirmed_first_name = first_name + student.confirmed_last_name = student_data.get('lastName', '') + student.parent_email = student_data.get('parentEmail') + student.parent_phone = student_data.get('parentPhone') + + db.commit() + + return OnboardingConfirmResponse( + session_id=session.id, + onboarding_id=onboarding.id, + class_id=class_id, + student_count=len(data.students), + ready_for_correction=True + ) + + +@router.post("/magic-onboarding/start-correction") +async def start_magic_correction( + data: MagicCorrectionRequest, + db: Session = Depends(get_db) +): + """ + Phase 3: Start background correction. + + Triggers the AI correction process for all uploaded documents. + """ + teacher_id = get_teacher_id() + + # Get onboarding session + onboarding = db.query(OnboardingSession).filter( + OnboardingSession.id == data.onboarding_id, + OnboardingSession.teacher_id == teacher_id + ).first() + + if not onboarding: + raise HTTPException(status_code=404, detail="Onboarding session not found") + + if not onboarding.klausur_session_id: + raise HTTPException(status_code=400, detail="Session not confirmed yet") + + onboarding.processing_started_at = datetime.utcnow() + db.commit() + + # The actual correction is triggered via the existing /sessions/{id}/process endpoint + return { + "status": "started", + "session_id": onboarding.klausur_session_id, + "onboarding_id": onboarding.id, + "message": "Korrektur gestartet. Verwende /sessions/{id}/progress-stream fuer Updates." + } + + +@router.get("/sessions/{session_id}/results-with-links", response_model=ResultsWithLinksResponse) +async def get_results_with_links( + session_id: str, + db: Session = Depends(get_db) +): + """ + Phase 4: Get results with module links. + + Returns correction results along with suggestions for module linking. + """ + teacher_id = get_teacher_id() + repo = KlausurRepository(db) + linker = get_module_linker() + + session = repo.get_session(session_id, teacher_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + # Get documents + documents = repo.list_documents(session_id, teacher_id) + completed_docs = [d for d in documents if d.status == DocumentStatus.COMPLETED] + + # Build correction results + results = [] + correction_results = [] # For linker + + for doc in completed_docs: + result = CorrectionResultResponse( + doc_token=doc.doc_token, + total_score=doc.ai_score or 0, + max_score=session.total_points, + grade=doc.ai_grade or "", + overall_feedback=doc.ai_feedback or "", + question_results=doc.ai_details.get('question_results', []) if doc.ai_details else [] + ) + results.append(result) + + correction_results.append(CorrectionResult( + doc_token=doc.doc_token, + score=float(doc.ai_score or 0), + max_score=float(session.total_points), + grade=doc.ai_grade or "", + feedback=doc.ai_feedback or "" + )) + + # Calculate statistics + stats = linker.calculate_grade_statistics(correction_results) + + # Get existing module links + links = db.query(ModuleLink).filter( + ModuleLink.klausur_session_id == session_id + ).all() + + # Generate parent meeting suggestions + meeting_suggestions = linker.suggest_elternabend( + results=correction_results, + subject=session.subject + ) + + return ResultsWithLinksResponse( + results=results, + statistics=stats, + module_links=[{ + 'id': link.id, + 'type': link.link_type.value, + 'module': link.target_module, + 'url': link.target_url + } for link in links], + parent_meeting_suggestions=[{ + 'doc_token': s.doc_token, + 'reason': s.reason, + 'urgency': s.urgency.value, + 'grade': s.grade, + 'topics': s.suggested_topics + } for s in meeting_suggestions] + ) + + +@router.post("/sessions/{session_id}/link-to-module") +async def create_module_link( + session_id: str, + link_type: str = Query(..., description="notenbuch, elternabend, zeugnis, calendar"), + db: Session = Depends(get_db) +): + """ + Phase 4: Create a link to another module. + + Creates the actual connection to Notenbuch, Elternabend, etc. + """ + teacher_id = get_teacher_id() + repo = KlausurRepository(db) + linker = get_module_linker() + + session = repo.get_session(session_id, teacher_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + # Get documents + documents = repo.list_documents(session_id, teacher_id) + completed_docs = [d for d in documents if d.status == DocumentStatus.COMPLETED] + + # Build correction results + correction_results = [ + CorrectionResult( + doc_token=doc.doc_token, + score=float(doc.ai_score or 0), + max_score=float(session.total_points), + grade=doc.ai_grade or "", + feedback=doc.ai_feedback or "" + ) + for doc in completed_docs + ] + + result = None + + if link_type == "notenbuch": + result = await linker.link_to_notenbuch( + session_id=session_id, + class_id=session.linked_school_class_id or "", + subject=session.subject, + results=correction_results, + exam_name=session.name, + exam_date=session.created_at.strftime("%Y-%m-%d") + ) + + elif link_type == "elternabend": + suggestions = linker.suggest_elternabend( + results=correction_results, + subject=session.subject + ) + result = await linker.create_elternabend_link( + session_id=session_id, + suggestions=suggestions, + teacher_id=teacher_id + ) + + elif link_type == "zeugnis": + grades = {r.doc_token: r.grade for r in correction_results} + result = await linker.update_zeugnis( + class_id=session.linked_school_class_id or "", + subject=session.subject, + grades=grades + ) + + elif link_type == "calendar": + suggestions = linker.suggest_elternabend( + results=correction_results, + subject=session.subject + ) + events = await linker.create_calendar_events( + teacher_id=teacher_id, + meetings=suggestions + ) + result = type('obj', (object,), { + 'success': len(events) > 0, + 'message': f"{len(events)} Kalendereintraege erstellt" + })() + + else: + raise HTTPException(status_code=400, detail=f"Unknown link type: {link_type}") + + if result and result.success: + # Store the link + link = ModuleLink( + klausur_session_id=session_id, + link_type=ModuleLinkType(link_type), + target_module=link_type, + target_entity_id=getattr(result, 'link', {}).target_entity_id if hasattr(result, 'link') and result.link else "", + target_url=getattr(result, 'target_url', None) + ) + db.add(link) + db.commit() + + return { + "success": result.success if result else False, + "message": result.message if result else "Unknown error", + "target_url": getattr(result, 'target_url', None) if result else None + } + + +@router.get("/school-data/bundeslaender") +async def get_bundeslaender(): + """Get list of German federal states.""" + return {"bundeslaender": BUNDESLAENDER} + + +@router.get("/school-data/schulformen") +async def get_schulformen(): + """Get list of school types.""" + return {"schulformen": {k: v['name'] for k, v in SCHULFORMEN.items()}} + + +@router.get("/school-data/faecher") +async def get_faecher(): + """Get list of subjects.""" + return {"faecher": {k: v['name'] for k, v in FAECHER.items()}} + + +# ============================================================================= +# TrOCR HANDWRITING RECOGNITION ENDPOINTS +# ============================================================================= + +class TrOCRExtractRequest(BaseModel): + """Request for TrOCR text extraction.""" + detect_lines: bool = Field(default=True, description="Detect and process text lines separately") + + +class TrOCRTrainingRequest(BaseModel): + """Request to add a training example.""" + ground_truth: str = Field(..., min_length=1, description="Correct text for the image") + + +class TrOCRFineTuneRequest(BaseModel): + """Request to start fine-tuning.""" + epochs: int = Field(default=3, ge=1, le=10) + learning_rate: float = Field(default=5e-5, gt=0, lt=1) + + +@router.post("/trocr/extract") +async def trocr_extract( + file: UploadFile = File(...), + detect_lines: bool = Query(default=True), + teacher_id: str = Query(default="teacher_1") +): + """ + Extract handwritten text from an image using TrOCR. + + This endpoint uses Microsoft's TrOCR model optimized for handwriting. + Processing happens on Mac Mini TrOCR service - no cloud, only local network. + + Args: + file: Image file (PNG, JPG) + detect_lines: If True, detect individual text lines + teacher_id: Teacher ID for logging + + Returns: + Extracted text with confidence scores + """ + # Try remote TrOCR client first (Mac Mini) + try: + from .services.trocr_client import get_trocr_client + + client = get_trocr_client() + + if await client.is_available(): + content = await file.read() + result = await client.extract_text( + content, + filename=file.filename or "image.png", + detect_lines=detect_lines + ) + + return { + "text": result.text, + "confidence": result.confidence, + "bounding_boxes": [], + "processing_time_ms": result.processing_time_ms, + "model": "trocr-base-handwritten", + "device": result.device, + "service": "mac-mini" + } + except Exception as e: + logger.warning(f"Remote TrOCR client failed: {e}") + + # Fallback to local TrOCR service + try: + from .services.trocr_service import get_trocr_service + + service = get_trocr_service() + content = await file.read() + result = await service.extract_text(content, detect_lines=detect_lines) + + return { + "text": result.text, + "confidence": result.confidence, + "bounding_boxes": result.bounding_boxes, + "processing_time_ms": result.processing_time_ms, + "model": service.model_name, + "has_lora_adapter": service._lora_adapter is not None, + "service": "local" + } + + except ImportError as e: + logger.error(f"TrOCR not available locally or remotely: {e}") + raise HTTPException( + status_code=503, + detail="TrOCR not available. Mac Mini service unreachable and local dependencies missing." + ) + except Exception as e: + logger.error(f"TrOCR extraction failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/trocr/batch-extract") +async def trocr_batch_extract( + files: List[UploadFile] = File(...), + detect_lines: bool = Query(default=True), + teacher_id: str = Query(default="teacher_1") +): + """ + Extract handwritten text from multiple images. + + Args: + files: List of image files + detect_lines: If True, detect individual text lines + teacher_id: Teacher ID for logging + + Returns: + List of extraction results + """ + try: + from .services.trocr_service import get_trocr_service + + service = get_trocr_service() + + # Read all files + images = [await f.read() for f in files] + + # Extract from all + results = await service.batch_extract(images, detect_lines=detect_lines) + + return { + "results": [ + { + "filename": files[i].filename, + "text": r.text, + "confidence": r.confidence, + "processing_time_ms": r.processing_time_ms + } + for i, r in enumerate(results) + ], + "total_files": len(files), + "model": service.model_name + } + + except ImportError as e: + raise HTTPException(status_code=503, detail=f"TrOCR not available: {e}") + except Exception as e: + logger.error(f"TrOCR batch extraction failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/trocr/training/add") +async def trocr_add_training_example( + file: UploadFile = File(...), + ground_truth: str = Query(..., min_length=1), + teacher_id: str = Query(default="teacher_1") +): + """ + Add a training example for TrOCR fine-tuning. + + When a teacher corrects OCR output, submit the correction here + to improve future recognition accuracy. + + Args: + file: Image file with handwritten text + ground_truth: The correct text (teacher-corrected) + teacher_id: Teacher ID (for tracking) + + Returns: + Example ID + """ + try: + from .services.trocr_service import get_trocr_service + + service = get_trocr_service() + + # Read file + content = await file.read() + + # Add training example + example_id = service.add_training_example( + image_data=content, + ground_truth=ground_truth, + teacher_id=teacher_id + ) + + info = service.get_model_info() + + return { + "example_id": example_id, + "ground_truth": ground_truth, + "teacher_id": teacher_id, + "total_examples": info["training_examples_count"], + "message": "Training example added successfully" + } + + except Exception as e: + logger.error(f"Failed to add training example: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/trocr/training/fine-tune") +async def trocr_fine_tune( + request: TrOCRFineTuneRequest, + teacher_id: str = Query(default=None) +): + """ + Start fine-tuning TrOCR with collected training examples. + + Uses LoRA for efficient fine-tuning. Requires at least 10 training examples. + + Args: + request: Fine-tuning parameters + teacher_id: If provided, only use examples from this teacher + + Returns: + Training results + """ + try: + from .services.trocr_service import get_trocr_service + + service = get_trocr_service() + + # Run fine-tuning + result = await service.fine_tune( + teacher_id=teacher_id, + epochs=request.epochs, + learning_rate=request.learning_rate + ) + + return result + + except ImportError as e: + raise HTTPException( + status_code=503, + detail=f"Fine-tuning dependencies not installed: {e}. Install with: pip install peft" + ) + except Exception as e: + logger.error(f"Fine-tuning failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/trocr/training/examples") +async def trocr_list_training_examples( + teacher_id: str = Query(default=None) +): + """ + List training examples. + + Args: + teacher_id: If provided, filter by teacher + + Returns: + List of training examples + """ + try: + from .services.trocr_service import get_trocr_service + + service = get_trocr_service() + examples = service.get_training_examples(teacher_id) + + return { + "examples": [ + { + "image_path": e.image_path, + "ground_truth": e.ground_truth[:100] + "..." if len(e.ground_truth) > 100 else e.ground_truth, + "teacher_id": e.teacher_id, + "created_at": e.created_at + } + for e in examples + ], + "total": len(examples) + } + + except Exception as e: + logger.error(f"Failed to list training examples: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/trocr/status") +async def trocr_status(): + """ + Get TrOCR model status and info. + + Returns: + Model information including device, adapter status, etc. + """ + result = { + "status": "unavailable", + "services": {} + } + + # Check Mac Mini TrOCR service + try: + from .services.trocr_client import get_trocr_client + + client = get_trocr_client() + if await client.is_available(): + remote_status = await client.get_status() + result["services"]["mac_mini"] = { + "status": "available", + **remote_status + } + result["status"] = "available" + result["primary_service"] = "mac_mini" + except Exception as e: + result["services"]["mac_mini"] = { + "status": "error", + "error": str(e) + } + + # Check local TrOCR service + try: + from .services.trocr_service import get_trocr_service + + service = get_trocr_service() + info = service.get_model_info() + result["services"]["local"] = { + "status": "available", + **info + } + if result["status"] != "available": + result["status"] = "available" + result["primary_service"] = "local" + + except ImportError as e: + result["services"]["local"] = { + "status": "not_installed", + "error": str(e) + } + except Exception as e: + result["services"]["local"] = { + "status": "error", + "error": str(e) + } + + return result diff --git a/backend/klausur/services/__init__.py b/backend/klausur/services/__init__.py new file mode 100644 index 0000000..f51d39b --- /dev/null +++ b/backend/klausur/services/__init__.py @@ -0,0 +1,28 @@ +""" +Services for Klausurkorrektur Module. + +- PseudonymizationService: QR code generation, header redaction +- CorrectionService: LLM integration for AI-assisted grading +- RosterParser: Parse Klassenbuch photos and roster files +- SchoolResolver: School/class selection and auto-creation +- ModuleLinker: Cross-module links (Notenbuch, Elternabend, etc.) +""" + +from .pseudonymizer import PseudonymizationService, get_pseudonymizer +from .correction_service import ExamCorrectionService, get_correction_service +from .roster_parser import RosterParser, get_roster_parser +from .school_resolver import SchoolResolver, get_school_resolver +from .module_linker import ModuleLinker, get_module_linker + +__all__ = [ + "PseudonymizationService", + "get_pseudonymizer", + "ExamCorrectionService", + "get_correction_service", + "RosterParser", + "get_roster_parser", + "SchoolResolver", + "get_school_resolver", + "ModuleLinker", + "get_module_linker", +] diff --git a/backend/klausur/services/correction_service.py b/backend/klausur/services/correction_service.py new file mode 100644 index 0000000..fcd4ec4 --- /dev/null +++ b/backend/klausur/services/correction_service.py @@ -0,0 +1,379 @@ +""" +Exam Correction Service using Self-Hosted LLM. + +PRIVACY BY DESIGN: +- Only pseudonymized text (doc_token + OCR content) is sent to LLM +- No student names or personal data in prompts +- All processing happens on self-hosted infrastructure (SysEleven) +- No data sent to external APIs (unless explicitly configured) + +This service generates AI-assisted corrections and feedback for exam answers. +""" +import logging +from typing import Optional, List +from dataclasses import dataclass + +from llm_gateway.services.inference import get_inference_service, InferenceResult +from llm_gateway.models.chat import ChatCompletionRequest, ChatMessage +from llm_gateway.config import get_config + +logger = logging.getLogger(__name__) + + +@dataclass +class QuestionRubric: + """Rubric for a single exam question.""" + question_number: int + question_text: str + max_points: int + expected_answer: str + grading_criteria: str + + +@dataclass +class QuestionResult: + """AI correction result for a single question.""" + question_number: int + points_awarded: int + max_points: int + feedback: str + strengths: List[str] + improvements: List[str] + + +@dataclass +class CorrectionResult: + """Complete correction result for an exam.""" + doc_token: str # Pseudonymized identifier + total_score: int + max_score: int + grade: str + overall_feedback: str + question_results: List[QuestionResult] + processing_time_ms: int + + +# German grading scale (can be customized) +GERMAN_GRADES = [ + (95, "1+"), # sehr gut plus + (90, "1"), # sehr gut + (85, "1-"), # sehr gut minus + (80, "2+"), # gut plus + (75, "2"), # gut + (70, "2-"), # gut minus + (65, "3+"), # befriedigend plus + (60, "3"), # befriedigend + (55, "3-"), # befriedigend minus + (50, "4+"), # ausreichend plus + (45, "4"), # ausreichend + (40, "4-"), # ausreichend minus + (33, "5+"), # mangelhaft plus + (27, "5"), # mangelhaft + (20, "5-"), # mangelhaft minus + (0, "6"), # ungenuegend +] + + +def calculate_grade(percentage: float) -> str: + """Calculate German grade from percentage.""" + for threshold, grade in GERMAN_GRADES: + if percentage >= threshold: + return grade + return "6" + + +class ExamCorrectionService: + """ + Service for AI-assisted exam correction. + + PRIVACY GUARANTEES: + 1. Prompts contain NO personal data + 2. Only doc_token is used as reference + 3. Processing on self-hosted LLM + 4. Results stored with pseudonymized identifiers + """ + + # System prompt for exam correction (German) + CORRECTION_SYSTEM_PROMPT = """Du bist ein erfahrener Lehrer und korrigierst Schuelerantworten. + +WICHTIGE REGELN: +1. Bewerte NUR den fachlichen Inhalt der Antwort +2. Ignoriere Rechtschreibfehler (ausser bei Deutschklausuren) +3. Gib konstruktives, ermutigzendes Feedback +4. Beziehe dich auf die Bewertungskriterien +5. Sei fair und konsistent + +AUSGABEFORMAT (JSON): +{ + "points": , + "feedback": "", + "strengths": ["", ""], + "improvements": [""] +} + +Antworte NUR mit dem JSON-Objekt, ohne weitere Erklaerungen.""" + + OVERALL_FEEDBACK_PROMPT = """Basierend auf den einzelnen Bewertungen, erstelle eine Gesamtrueckmeldung. + +Einzelbewertungen: +{question_results} + +Gesamtpunktzahl: {total_score}/{max_score} ({percentage}%) +Note: {grade} + +Erstelle eine motivierende Gesamtrueckmeldung (2-3 Saetze), die: +1. Die Staerken hervorhebt +2. Konstruktive Verbesserungsvorschlaege macht +3. Ermutigt und motiviert + +Antworte nur mit dem Feedback-Text, ohne JSON-Formatierung.""" + + def __init__(self, model: Optional[str] = None): + """ + Initialize the correction service. + + Args: + model: LLM model to use (default: qwen2.5:14b from config) + + DATENSCHUTZ/PRIVACY: + Das Modell läuft lokal auf dem Mac Mini via Ollama. + Keine Daten werden an externe Server gesendet. + """ + config = get_config() + # Use configured correction model (default: qwen2.5:14b) + self.model = model or config.correction_model + self.inference = get_inference_service() + logger.info(f"Correction service initialized with model: {self.model}") + + async def correct_question( + self, + student_answer: str, + rubric: QuestionRubric, + subject: str = "Allgemein" + ) -> QuestionResult: + """ + Correct a single question answer. + + Args: + student_answer: The student's OCR-extracted answer (pseudonymized) + rubric: Grading rubric for this question + subject: Subject for context + + Returns: + QuestionResult with points and feedback + """ + # Build prompt with NO personal data + user_prompt = f"""Fach: {subject} +Frage {rubric.question_number}: {rubric.question_text} +Maximale Punktzahl: {rubric.max_points} + +Erwartete Antwort: +{rubric.expected_answer} + +Bewertungskriterien: +{rubric.grading_criteria} + +--- + +Schuelerantwort: +{student_answer} + +--- + +Bewerte diese Antwort nach den Kriterien.""" + + request = ChatCompletionRequest( + model=self.model, + messages=[ + ChatMessage(role="system", content=self.CORRECTION_SYSTEM_PROMPT), + ChatMessage(role="user", content=user_prompt), + ], + temperature=0.3, # Lower temperature for consistent grading + max_tokens=500, + ) + + try: + response = await self.inference.complete(request) + content = response.choices[0].message.content or "{}" + + # Parse JSON response + import json + try: + result = json.loads(content) + except json.JSONDecodeError: + # Fallback parsing + logger.warning(f"Failed to parse LLM response as JSON: {content[:100]}") + result = { + "points": rubric.max_points // 2, + "feedback": content[:200], + "strengths": [], + "improvements": ["Automatische Bewertung fehlgeschlagen - manuelle Pruefung erforderlich"] + } + + points = min(int(result.get("points", 0)), rubric.max_points) + + return QuestionResult( + question_number=rubric.question_number, + points_awarded=points, + max_points=rubric.max_points, + feedback=result.get("feedback", ""), + strengths=result.get("strengths", []), + improvements=result.get("improvements", []), + ) + + except Exception as e: + logger.error(f"Correction failed for question {rubric.question_number}: {e}") + return QuestionResult( + question_number=rubric.question_number, + points_awarded=0, + max_points=rubric.max_points, + feedback=f"Automatische Bewertung fehlgeschlagen: {str(e)}", + strengths=[], + improvements=["Manuelle Korrektur erforderlich"], + ) + + async def correct_exam( + self, + doc_token: str, + ocr_text: str, + rubrics: List[QuestionRubric], + subject: str = "Allgemein" + ) -> CorrectionResult: + """ + Correct a complete exam with multiple questions. + + Args: + doc_token: Pseudonymized document identifier + ocr_text: Full OCR text of the exam (already redacted) + rubrics: List of question rubrics + subject: Subject name + + Returns: + CorrectionResult with all scores and feedback + """ + import time + start_time = time.time() + + # Split OCR text into answers (simple heuristic) + answers = self._extract_answers(ocr_text, len(rubrics)) + + # Correct each question + question_results = [] + for i, rubric in enumerate(rubrics): + answer = answers[i] if i < len(answers) else "" + result = await self.correct_question(answer, rubric, subject) + question_results.append(result) + + # Calculate totals + total_score = sum(r.points_awarded for r in question_results) + max_score = sum(r.max_points for r in question_results) + percentage = (total_score / max_score * 100) if max_score > 0 else 0 + grade = calculate_grade(percentage) + + # Generate overall feedback + overall_feedback = await self._generate_overall_feedback( + question_results, total_score, max_score, percentage, grade + ) + + processing_time_ms = int((time.time() - start_time) * 1000) + + return CorrectionResult( + doc_token=doc_token, + total_score=total_score, + max_score=max_score, + grade=grade, + overall_feedback=overall_feedback, + question_results=question_results, + processing_time_ms=processing_time_ms, + ) + + async def _generate_overall_feedback( + self, + question_results: List[QuestionResult], + total_score: int, + max_score: int, + percentage: float, + grade: str + ) -> str: + """Generate motivating overall feedback.""" + # Summarize question results + results_summary = "\n".join([ + f"Frage {r.question_number}: {r.points_awarded}/{r.max_points} Punkte - {r.feedback[:100]}" + for r in question_results + ]) + + prompt = self.OVERALL_FEEDBACK_PROMPT.format( + question_results=results_summary, + total_score=total_score, + max_score=max_score, + percentage=f"{percentage:.1f}", + grade=grade, + ) + + request = ChatCompletionRequest( + model=self.model, + messages=[ + ChatMessage(role="user", content=prompt), + ], + temperature=0.5, + max_tokens=200, + ) + + try: + response = await self.inference.complete(request) + return response.choices[0].message.content or "Gute Arbeit! Weiter so." + except Exception as e: + logger.error(f"Failed to generate overall feedback: {e}") + return f"Gesamtergebnis: {total_score}/{max_score} Punkte ({grade})" + + def _extract_answers(self, ocr_text: str, num_questions: int) -> List[str]: + """ + Extract individual answers from OCR text. + + Simple heuristic: split by question markers (1., 2., etc.) + More sophisticated extraction can be implemented. + """ + import re + + # Try to find question markers + pattern = r'(?:^|\n)\s*(\d+)[.\)]\s*' + parts = re.split(pattern, ocr_text) + + answers = [] + i = 1 # Skip first empty part + while i < len(parts): + if i + 1 < len(parts): + # parts[i] is the question number, parts[i+1] is the answer + answers.append(parts[i + 1].strip()) + i += 2 + + # Pad with empty answers if needed + while len(answers) < num_questions: + answers.append("") + + return answers[:num_questions] + + +# Singleton instance +_correction_service: Optional[ExamCorrectionService] = None + + +def get_correction_service(model: Optional[str] = None) -> ExamCorrectionService: + """ + Get or create the correction service singleton. + + Args: + model: Optional model override. If None, uses config.correction_model (qwen2.5:14b) + + Returns: + ExamCorrectionService instance + + DATENSCHUTZ: Alle Verarbeitung erfolgt lokal via Ollama - keine Cloud-API. + """ + global _correction_service + if _correction_service is None: + _correction_service = ExamCorrectionService(model=model) + elif model and _correction_service.model != model: + # Only recreate if explicitly requesting different model + _correction_service = ExamCorrectionService(model=model) + return _correction_service diff --git a/backend/klausur/services/module_linker.py b/backend/klausur/services/module_linker.py new file mode 100644 index 0000000..f692698 --- /dev/null +++ b/backend/klausur/services/module_linker.py @@ -0,0 +1,630 @@ +""" +Module Linker Service - Cross-Module Verknuepfungen. + +Verknuepft Klausur-Ergebnisse mit anderen BreakPilot-Modulen: +- Notenbuch (School Service) +- Elternabend (Gespraechsvorschlaege) +- Zeugnisse (Notenuebernahme) +- Kalender (Termine) + +Privacy: +- Verknuepfungen nutzen doc_tokens (pseudonymisiert) +- Deanonymisierung nur Client-seitig moeglich +""" + +import httpx +import os +from dataclasses import dataclass, field +from typing import List, Optional, Dict, Any +from datetime import datetime, timedelta +from enum import Enum + + +# ============================================================================ +# DATA CLASSES +# ============================================================================ + +class LinkType(str, Enum): + """Typ der Modul-Verknuepfung.""" + NOTENBUCH = "notenbuch" + ELTERNABEND = "elternabend" + ZEUGNIS = "zeugnis" + CALENDAR = "calendar" + KLASSENBUCH = "klassenbuch" + + +class MeetingUrgency(str, Enum): + """Dringlichkeit eines Elterngespraechs.""" + LOW = "niedrig" + MEDIUM = "mittel" + HIGH = "hoch" + + +@dataclass +class CorrectionResult: + """Korrektur-Ergebnis (pseudonymisiert).""" + doc_token: str + score: float # Punkte + max_score: float + grade: str # z.B. "2+" + feedback: str + question_results: List[Dict[str, Any]] = field(default_factory=list) + + +@dataclass +class GradeEntry: + """Notenbuch-Eintrag.""" + student_id: str # Im Notenbuch: echte Student-ID + doc_token: str # Aus Klausur: pseudonymisiert + grade: str + points: float + max_points: float + exam_name: str + date: str + + +@dataclass +class ParentMeetingSuggestion: + """Vorschlag fuer ein Elterngespraech.""" + doc_token: str # Pseudonymisiert + reason: str + urgency: MeetingUrgency + grade: str + subject: str + suggested_topics: List[str] = field(default_factory=list) + + +@dataclass +class CalendarEvent: + """Kalender-Eintrag.""" + id: str + title: str + description: str + start_time: datetime + end_time: datetime + event_type: str + linked_doc_tokens: List[str] = field(default_factory=list) + + +@dataclass +class ModuleLink: + """Verknuepfung zu einem anderen Modul.""" + id: str + klausur_session_id: str + link_type: LinkType + target_module: str + target_entity_id: str + target_url: Optional[str] = None + link_metadata: Dict[str, Any] = field(default_factory=dict) + created_at: datetime = field(default_factory=datetime.utcnow) + + +@dataclass +class LinkResult: + """Ergebnis einer Verknuepfungs-Operation.""" + success: bool + link: Optional[ModuleLink] = None + message: str = "" + target_url: Optional[str] = None + + +# ============================================================================ +# MODULE LINKER +# ============================================================================ + +class ModuleLinker: + """ + Verknuepft Klausur-Ergebnisse mit anderen Modulen. + + Beispiel: + linker = ModuleLinker() + + # Noten ins Notenbuch uebertragen + result = await linker.link_to_notenbuch( + session_id="session-123", + class_id="class-456", + results=correction_results + ) + + # Elterngespraeche vorschlagen + suggestions = linker.suggest_elternabend( + results=correction_results, + subject="Mathematik" + ) + """ + + # Notenschwellen fuer Elterngespraeche + GRADE_THRESHOLDS = { + "1+": 0.95, "1": 0.90, "1-": 0.85, + "2+": 0.80, "2": 0.75, "2-": 0.70, + "3+": 0.65, "3": 0.60, "3-": 0.55, + "4+": 0.50, "4": 0.45, "4-": 0.40, + "5+": 0.33, "5": 0.25, "5-": 0.17, + "6": 0.0 + } + + # Noten die Gespraeche erfordern + MEETING_TRIGGER_GRADES = ["4", "4-", "5+", "5", "5-", "6"] + + def __init__(self): + self.school_service_url = os.getenv( + "SCHOOL_SERVICE_URL", + "http://school-service:8084" + ) + self.calendar_service_url = os.getenv( + "CALENDAR_SERVICE_URL", + "http://calendar-service:8085" + ) + + # ========================================================================= + # NOTENBUCH INTEGRATION + # ========================================================================= + + async def link_to_notenbuch( + self, + session_id: str, + class_id: str, + subject: str, + results: List[CorrectionResult], + exam_name: str, + exam_date: str, + identity_map: Optional[Dict[str, str]] = None + ) -> LinkResult: + """ + Uebertraegt Noten ins Notenbuch (School Service). + + Args: + session_id: Klausur-Session-ID + class_id: Klassen-ID im School Service + subject: Fach + results: Liste der Korrektur-Ergebnisse + exam_name: Name der Klausur + exam_date: Datum der Klausur + identity_map: Optional: doc_token -> student_id Mapping + + Note: + Das identity_map wird nur serverseitig genutzt, wenn der + Lehrer explizit die Verknuepfung freigibt. Normalerweise + bleibt das Mapping Client-seitig. + """ + try: + # Noten-Daten aufbereiten + grades_data = [] + for result in results: + grade_entry = { + "doc_token": result.doc_token, + "grade": result.grade, + "points": result.score, + "max_points": result.max_score, + "percentage": result.score / result.max_score if result.max_score > 0 else 0 + } + + # Falls identity_map vorhanden: Student-ID hinzufuegen + if identity_map and result.doc_token in identity_map: + grade_entry["student_id"] = identity_map[result.doc_token] + + grades_data.append(grade_entry) + + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + f"{self.school_service_url}/api/classes/{class_id}/exams", + json={ + "name": exam_name, + "subject": subject, + "date": exam_date, + "max_points": results[0].max_score if results else 100, + "grades": grades_data, + "klausur_session_id": session_id + } + ) + + if response.status_code in (200, 201): + data = response.json() + return LinkResult( + success=True, + link=ModuleLink( + id=data.get('id', ''), + klausur_session_id=session_id, + link_type=LinkType.NOTENBUCH, + target_module="school", + target_entity_id=data.get('id', ''), + target_url=f"/app?module=school&class={class_id}&exam={data.get('id')}" + ), + message=f"Noten erfolgreich uebertragen ({len(results)} Eintraege)", + target_url=f"/app?module=school&class={class_id}" + ) + + return LinkResult( + success=False, + message=f"Fehler beim Uebertragen: {response.status_code}" + ) + + except Exception as e: + return LinkResult( + success=False, + message=f"Verbindungsfehler: {str(e)}" + ) + + # ========================================================================= + # ELTERNABEND VORSCHLAEGE + # ========================================================================= + + def suggest_elternabend( + self, + results: List[CorrectionResult], + subject: str, + threshold_grade: str = "4" + ) -> List[ParentMeetingSuggestion]: + """ + Schlaegt Elterngespraeche fuer schwache Schueler vor. + + Args: + results: Liste der Korrektur-Ergebnisse + subject: Fach + threshold_grade: Ab dieser Note wird ein Gespraech vorgeschlagen + + Returns: + Liste von Gespraechs-Vorschlaegen (pseudonymisiert) + """ + suggestions = [] + threshold_idx = list(self.GRADE_THRESHOLDS.keys()).index(threshold_grade) \ + if threshold_grade in self.GRADE_THRESHOLDS else 9 + + for result in results: + # Pruefe ob Note Gespraech erfordert + if result.grade in self.MEETING_TRIGGER_GRADES: + urgency = self._determine_urgency(result.grade) + topics = self._generate_meeting_topics(result, subject) + + suggestions.append(ParentMeetingSuggestion( + doc_token=result.doc_token, + reason=f"Note {result.grade} in {subject}", + urgency=urgency, + grade=result.grade, + subject=subject, + suggested_topics=topics + )) + + # Nach Dringlichkeit sortieren + urgency_order = { + MeetingUrgency.HIGH: 0, + MeetingUrgency.MEDIUM: 1, + MeetingUrgency.LOW: 2 + } + suggestions.sort(key=lambda s: urgency_order[s.urgency]) + + return suggestions + + def _determine_urgency(self, grade: str) -> MeetingUrgency: + """Bestimmt die Dringlichkeit basierend auf der Note.""" + if grade in ["5-", "6"]: + return MeetingUrgency.HIGH + elif grade in ["5", "5+"]: + return MeetingUrgency.MEDIUM + else: + return MeetingUrgency.LOW + + def _generate_meeting_topics( + self, + result: CorrectionResult, + subject: str + ) -> List[str]: + """Generiert Gespraechsthemen basierend auf den Ergebnissen.""" + topics = [] + + # Allgemeine Themen + topics.append(f"Leistungsstand in {subject}") + + # Basierend auf Feedback + if "Verstaendnis" in result.feedback.lower() or "grundlagen" in result.feedback.lower(): + topics.append("Grundlagenverstaendnis foerdern") + + if "uebung" in result.feedback.lower(): + topics.append("Zusaetzliche Uebungsmoeglichkeiten") + + # Basierend auf Aufgaben-Ergebnissen + if result.question_results: + weak_areas = [] + for qr in result.question_results: + if qr.get('points_awarded', 0) / qr.get('max_points', 1) < 0.5: + weak_areas.append(qr.get('question_text', '')) + + if weak_areas: + topics.append("Gezielte Foerderung in Schwachstellen") + + # Standard-Themen + if not topics or len(topics) < 3: + topics.extend([ + "Lernstrategien besprechen", + "Unterstuetzungsmoeglichkeiten zu Hause", + "Nachhilfe-Optionen" + ]) + + return topics[:5] # Max 5 Themen + + async def create_elternabend_link( + self, + session_id: str, + suggestions: List[ParentMeetingSuggestion], + teacher_id: str + ) -> LinkResult: + """Erstellt Verknuepfungen zum Elternabend-Modul.""" + # TODO: Integration mit Elternabend-Modul + # Vorerst nur Metadaten speichern + + return LinkResult( + success=True, + link=ModuleLink( + id=f"elternabend-{session_id}", + klausur_session_id=session_id, + link_type=LinkType.ELTERNABEND, + target_module="elternabend", + target_entity_id="", + link_metadata={ + "suggestion_count": len(suggestions), + "high_urgency_count": sum( + 1 for s in suggestions if s.urgency == MeetingUrgency.HIGH + ) + } + ), + message=f"{len(suggestions)} Elterngespraeche vorgeschlagen", + target_url="/app?module=elternabend" + ) + + # ========================================================================= + # ZEUGNIS INTEGRATION + # ========================================================================= + + async def update_zeugnis( + self, + class_id: str, + subject: str, + grades: Dict[str, str], + exam_weight: float = 1.0 + ) -> LinkResult: + """ + Aktualisiert Zeugnis-Aggregation mit neuen Noten. + + Args: + class_id: Klassen-ID + subject: Fach + grades: doc_token -> Note Mapping + exam_weight: Gewichtung der Klausur (Standard: 1.0) + """ + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + f"{self.school_service_url}/api/classes/{class_id}/grades/aggregate", + json={ + "subject": subject, + "grades": grades, + "weight": exam_weight, + "type": "klausur" + } + ) + + if response.status_code in (200, 201): + return LinkResult( + success=True, + message="Zeugnis-Daten aktualisiert", + target_url=f"/app?module=school&class={class_id}&tab=certificates" + ) + + return LinkResult( + success=False, + message=f"Fehler: {response.status_code}" + ) + + except Exception as e: + return LinkResult( + success=False, + message=f"Verbindungsfehler: {str(e)}" + ) + + # ========================================================================= + # KALENDER INTEGRATION + # ========================================================================= + + async def create_calendar_events( + self, + teacher_id: str, + suggestions: List[ParentMeetingSuggestion], + default_duration_minutes: int = 30 + ) -> List[CalendarEvent]: + """ + Erstellt Kalender-Eintraege fuer Elterngespraeche. + + Args: + teacher_id: ID des Lehrers + suggestions: Liste der Gespraechs-Vorschlaege + default_duration_minutes: Standard-Dauer pro Gespraech + """ + events = [] + + # Zeitslots generieren (ab naechster Woche, nachmittags) + start_date = datetime.now() + timedelta(days=7 - datetime.now().weekday()) + start_date = start_date.replace(hour=14, minute=0, second=0, microsecond=0) + + slot_index = 0 + for suggestion in suggestions: + # Zeitslot berechnen + event_start = start_date + timedelta(minutes=slot_index * default_duration_minutes) + event_end = event_start + timedelta(minutes=default_duration_minutes) + + # Naechster Tag wenn nach 18 Uhr + if event_start.hour >= 18: + start_date += timedelta(days=1) + start_date = start_date.replace(hour=14) + slot_index = 0 + event_start = start_date + event_end = event_start + timedelta(minutes=default_duration_minutes) + + event = CalendarEvent( + id=f"meeting-{suggestion.doc_token[:8]}", + title=f"Elterngespraech ({suggestion.grade})", + description=f"Anlass: {suggestion.reason}\n\nThemen:\n" + + "\n".join(f"- {t}" for t in suggestion.suggested_topics), + start_time=event_start, + end_time=event_end, + event_type="parent_meeting", + linked_doc_tokens=[suggestion.doc_token] + ) + events.append(event) + slot_index += 1 + + # An Kalender-Service senden + try: + async with httpx.AsyncClient(timeout=10.0) as client: + for event in events: + await client.post( + f"{self.calendar_service_url}/api/events", + json={ + "teacher_id": teacher_id, + "title": event.title, + "description": event.description, + "start": event.start_time.isoformat(), + "end": event.end_time.isoformat(), + "type": event.event_type, + "metadata": { + "doc_tokens": event.linked_doc_tokens + } + } + ) + except Exception as e: + print(f"[ModuleLinker] Calendar service error: {e}") + + return events + + # ========================================================================= + # STATISTIKEN + # ========================================================================= + + def calculate_grade_statistics( + self, + results: List[CorrectionResult] + ) -> Dict[str, Any]: + """ + Berechnet Notenstatistiken. + + Returns: + Dict mit Durchschnitt, Verteilung, Median, etc. + """ + if not results: + return {} + + # Notenwerte (fuer Durchschnitt) + grade_values = { + "1+": 0.7, "1": 1.0, "1-": 1.3, + "2+": 1.7, "2": 2.0, "2-": 2.3, + "3+": 2.7, "3": 3.0, "3-": 3.3, + "4+": 3.7, "4": 4.0, "4-": 4.3, + "5+": 4.7, "5": 5.0, "5-": 5.3, + "6": 6.0 + } + + # Noten sammeln + grades = [r.grade for r in results] + points = [r.score for r in results] + max_points = results[0].max_score if results else 100 + + # Durchschnitt berechnen + numeric_grades = [grade_values.get(g, 4.0) for g in grades] + avg_grade = sum(numeric_grades) / len(numeric_grades) + + # Notenverteilung + distribution = {} + for grade in grades: + distribution[grade] = distribution.get(grade, 0) + 1 + + # Prozent-Verteilung + percent_distribution = { + "sehr gut (1)": sum(1 for g in grades if g.startswith("1")), + "gut (2)": sum(1 for g in grades if g.startswith("2")), + "befriedigend (3)": sum(1 for g in grades if g.startswith("3")), + "ausreichend (4)": sum(1 for g in grades if g.startswith("4")), + "mangelhaft (5)": sum(1 for g in grades if g.startswith("5")), + "ungenuegend (6)": sum(1 for g in grades if g == "6") + } + + return { + "count": len(results), + "average_grade": round(avg_grade, 2), + "average_grade_display": self._numeric_to_grade(avg_grade), + "average_points": round(sum(points) / len(points), 1), + "max_points": max_points, + "average_percent": round((sum(points) / len(points) / max_points) * 100, 1), + "best_grade": min(grades, key=lambda g: grade_values.get(g, 6)), + "worst_grade": max(grades, key=lambda g: grade_values.get(g, 0)), + "median_grade": self._calculate_median_grade(grades), + "distribution": distribution, + "percent_distribution": percent_distribution, + "passing_count": sum(1 for g in grades if not g.startswith("5") and g != "6"), + "failing_count": sum(1 for g in grades if g.startswith("5") or g == "6") + } + + def _numeric_to_grade(self, value: float) -> str: + """Konvertiert Notenwert zu Note.""" + if value <= 1.15: + return "1+" + elif value <= 1.5: + return "1" + elif value <= 1.85: + return "1-" + elif value <= 2.15: + return "2+" + elif value <= 2.5: + return "2" + elif value <= 2.85: + return "2-" + elif value <= 3.15: + return "3+" + elif value <= 3.5: + return "3" + elif value <= 3.85: + return "3-" + elif value <= 4.15: + return "4+" + elif value <= 4.5: + return "4" + elif value <= 4.85: + return "4-" + elif value <= 5.15: + return "5+" + elif value <= 5.5: + return "5" + elif value <= 5.85: + return "5-" + else: + return "6" + + def _calculate_median_grade(self, grades: List[str]) -> str: + """Berechnet die Median-Note.""" + grade_values = { + "1+": 0.7, "1": 1.0, "1-": 1.3, + "2+": 1.7, "2": 2.0, "2-": 2.3, + "3+": 2.7, "3": 3.0, "3-": 3.3, + "4+": 3.7, "4": 4.0, "4-": 4.3, + "5+": 4.7, "5": 5.0, "5-": 5.3, + "6": 6.0 + } + + numeric = sorted([grade_values.get(g, 4.0) for g in grades]) + n = len(numeric) + if n % 2 == 0: + median = (numeric[n // 2 - 1] + numeric[n // 2]) / 2 + else: + median = numeric[n // 2] + + return self._numeric_to_grade(median) + + +# Singleton +_module_linker: Optional[ModuleLinker] = None + + +def get_module_linker() -> ModuleLinker: + """Gibt die Singleton-Instanz des ModuleLinkers zurueck.""" + global _module_linker + if _module_linker is None: + _module_linker = ModuleLinker() + return _module_linker diff --git a/backend/klausur/services/processing_service.py b/backend/klausur/services/processing_service.py new file mode 100644 index 0000000..3cc1a5a --- /dev/null +++ b/backend/klausur/services/processing_service.py @@ -0,0 +1,424 @@ +""" +Background Processing Service for Klausur Correction. + +Orchestrates the complete correction pipeline: +1. Load documents from storage +2. Run TrOCR for text extraction +3. Run AI correction for grading +4. Save results to database + +PRIVACY BY DESIGN: +- Only pseudonymized doc_tokens used throughout +- No student names in processing pipeline +- All data stays on self-hosted infrastructure +""" +import asyncio +import logging +from datetime import datetime +from typing import Optional, List, Callable +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from ..db_models import ( + ExamSession, PseudonymizedDocument, + SessionStatus, DocumentStatus +) +from ..repository import KlausurRepository +from .trocr_client import get_trocr_client, TrOCRClient +from .vision_ocr_service import get_vision_ocr_service, VisionOCRService +from .correction_service import ( + get_correction_service, ExamCorrectionService, + QuestionRubric, CorrectionResult +) +from .storage_service import get_storage_service, KlausurStorageService + +logger = logging.getLogger(__name__) + + +@dataclass +class ProcessingProgress: + """Progress update for SSE streaming.""" + session_id: str + total_documents: int + processed_documents: int + current_document: Optional[str] = None + current_step: str = "idle" # ocr, correction, saving + error: Optional[str] = None + + @property + def percentage(self) -> int: + if self.total_documents == 0: + return 0 + return int(self.processed_documents / self.total_documents * 100) + + +class ProcessingService: + """ + Background service for exam correction processing. + + Usage: + service = ProcessingService(db_session) + await service.process_session(session_id, teacher_id) + """ + + def __init__( + self, + db: Session, + trocr_client: Optional[TrOCRClient] = None, + vision_ocr_service: Optional[VisionOCRService] = None, + correction_service: Optional[ExamCorrectionService] = None, + storage_service: Optional[KlausurStorageService] = None, + prefer_vision_ocr: bool = True # Vision-LLM als Primär für Handschrift + ): + self.db = db + self.repo = KlausurRepository(db) + self.trocr = trocr_client or get_trocr_client() + self.vision_ocr = vision_ocr_service or get_vision_ocr_service() + self.correction = correction_service or get_correction_service() + self.storage = storage_service or get_storage_service() + self.prefer_vision_ocr = prefer_vision_ocr + + # Progress callback for SSE streaming + self._progress_callback: Optional[Callable[[ProcessingProgress], None]] = None + + def set_progress_callback(self, callback: Callable[[ProcessingProgress], None]): + """Set callback for progress updates (SSE streaming).""" + self._progress_callback = callback + + def _notify_progress(self, progress: ProcessingProgress): + """Notify progress to callback if set.""" + if self._progress_callback: + try: + self._progress_callback(progress) + except Exception as e: + logger.warning(f"Progress callback failed: {e}") + + async def process_session( + self, + session_id: str, + teacher_id: str, + use_ai_correction: bool = True + ) -> bool: + """ + Process all documents in a session. + + Args: + session_id: Exam session ID + teacher_id: Teacher ID for isolation + use_ai_correction: Whether to run AI correction (requires LLM) + + Returns: + True if processing completed successfully + """ + # Get session + session = self.repo.get_session(session_id, teacher_id) + if not session: + logger.error(f"Session not found: {session_id}") + return False + + # Get documents + documents = self.repo.list_documents(session_id, teacher_id) + if not documents: + logger.warning(f"No documents in session: {session_id}") + return False + + total = len(documents) + processed = 0 + + logger.info(f"Starting processing for session {session_id}: {total} documents") + + # Check OCR service availability (Vision-LLM preferred for handwriting) + vision_ocr_available = await self.vision_ocr.is_available() + trocr_available = await self.trocr.is_available() + + if vision_ocr_available and self.prefer_vision_ocr: + logger.info("Using Vision-LLM (llama3.2-vision) for OCR - optimal for handwriting") + use_vision_ocr = True + elif trocr_available: + logger.info("Using TrOCR for OCR") + use_vision_ocr = False + elif vision_ocr_available: + logger.info("TrOCR not available, falling back to Vision-LLM") + use_vision_ocr = True + else: + logger.warning("No OCR service available - OCR will be skipped") + use_vision_ocr = False + trocr_available = False + + # Process each document + for doc in documents: + progress = ProcessingProgress( + session_id=session_id, + total_documents=total, + processed_documents=processed, + current_document=doc.doc_token[:8], + current_step="ocr" + ) + self._notify_progress(progress) + + try: + # Step 1: OCR extraction (Vision-LLM or TrOCR) + if (vision_ocr_available or trocr_available) and doc.status == DocumentStatus.UPLOADED: + await self._process_ocr(session_id, doc, teacher_id, use_vision_ocr=use_vision_ocr) + + # Step 2: AI correction + progress.current_step = "correction" + self._notify_progress(progress) + + if use_ai_correction and doc.ocr_text: + await self._process_correction(session, doc, teacher_id) + else: + # Just mark as completed without AI + self._mark_document_completed(doc, teacher_id) + + processed += 1 + + except Exception as e: + logger.error(f"Failed to process document {doc.doc_token}: {e}") + self._mark_document_failed(doc, str(e), teacher_id) + + # Update session status + self.repo.update_session_status(session_id, teacher_id, SessionStatus.COMPLETED) + + # Final progress + progress = ProcessingProgress( + session_id=session_id, + total_documents=total, + processed_documents=processed, + current_step="complete" + ) + self._notify_progress(progress) + + logger.info(f"Completed processing session {session_id}: {processed}/{total} documents") + return True + + async def _process_ocr( + self, + session_id: str, + doc: PseudonymizedDocument, + teacher_id: str, + use_vision_ocr: bool = True + ): + """ + Run OCR on a document. + + Args: + session_id: Session ID + doc: Document to process + teacher_id: Teacher ID + use_vision_ocr: True to use Vision-LLM (llama3.2-vision), False for TrOCR + """ + # Update status + doc.status = DocumentStatus.OCR_PROCESSING + doc.processing_started_at = datetime.utcnow() + self.db.commit() + + # Try to get document from storage (check both redacted and original) + image_data = None + for is_redacted in [True, False]: # Prefer redacted version + for ext in ["png", "jpg", "jpeg", "pdf"]: + image_data = self.storage.get_document( + session_id, doc.doc_token, ext, is_redacted=is_redacted + ) + if image_data: + logger.debug(f"Found document: {doc.doc_token[:8]}.{ext} (redacted={is_redacted})") + break + if image_data: + break + + if not image_data: + logger.warning(f"No image found for document {doc.doc_token}") + # Use placeholder OCR text for testing + doc.ocr_text = "[Kein Bild gefunden - Manuelle Eingabe erforderlich]" + doc.ocr_confidence = 0 + doc.status = DocumentStatus.OCR_COMPLETED + self.db.commit() + return + + # Call OCR service (Vision-LLM or TrOCR) + try: + if use_vision_ocr: + # Use Vision-LLM (llama3.2-vision) - better for handwriting + result = await self.vision_ocr.extract_text( + image_data, + filename=f"{doc.doc_token}.png", + is_handwriting=True # Assume handwriting for exams + ) + ocr_method = "Vision-LLM" + else: + # Use TrOCR + result = await self.trocr.extract_text( + image_data, + filename=f"{doc.doc_token}.png", + detect_lines=True + ) + ocr_method = "TrOCR" + + doc.ocr_text = result.text + doc.ocr_confidence = int(result.confidence * 100) + doc.status = DocumentStatus.OCR_COMPLETED + + logger.info( + f"OCR completed ({ocr_method}) for {doc.doc_token[:8]}: " + f"{len(result.text)} chars, {result.confidence:.0%} confidence" + ) + + except Exception as e: + logger.error(f"OCR failed for {doc.doc_token}: {e}") + doc.ocr_text = f"[OCR Fehler: {str(e)[:100]}]" + doc.ocr_confidence = 0 + doc.status = DocumentStatus.OCR_COMPLETED # Continue to AI anyway + + self.db.commit() + + async def _process_correction( + self, + session: ExamSession, + doc: PseudonymizedDocument, + teacher_id: str + ): + """Run AI correction on a document.""" + doc.status = DocumentStatus.AI_PROCESSING + self.db.commit() + + # Build rubrics from session questions + rubrics = self._build_rubrics(session) + + if not rubrics: + # No rubrics defined - use simple scoring + doc.ai_feedback = "Keine Bewertungskriterien definiert. Manuelle Korrektur empfohlen." + doc.ai_score = None + doc.ai_grade = None + doc.status = DocumentStatus.COMPLETED + doc.processing_completed_at = datetime.utcnow() + self.db.commit() + + # Update session stats + session.processed_count += 1 + self.db.commit() + return + + try: + # Run AI correction + result = await self.correction.correct_exam( + doc_token=doc.doc_token, + ocr_text=doc.ocr_text, + rubrics=rubrics, + subject=session.subject or "Allgemein" + ) + + # Save results + doc.ai_feedback = result.overall_feedback + doc.ai_score = result.total_score + doc.ai_grade = result.grade + doc.ai_details = { + "max_score": result.max_score, + "processing_time_ms": result.processing_time_ms, + "questions": [ + { + "number": q.question_number, + "points": q.points_awarded, + "max_points": q.max_points, + "feedback": q.feedback, + "strengths": q.strengths, + "improvements": q.improvements + } + for q in result.question_results + ] + } + doc.status = DocumentStatus.COMPLETED + doc.processing_completed_at = datetime.utcnow() + + logger.info( + f"Correction completed for {doc.doc_token[:8]}: " + f"{result.total_score}/{result.max_score} ({result.grade})" + ) + + except Exception as e: + logger.error(f"AI correction failed for {doc.doc_token}: {e}") + doc.ai_feedback = f"KI-Korrektur fehlgeschlagen: {str(e)[:200]}" + doc.status = DocumentStatus.COMPLETED # Mark complete anyway + doc.processing_completed_at = datetime.utcnow() + + # Update session stats + session.processed_count += 1 + self.db.commit() + + def _build_rubrics(self, session: ExamSession) -> List[QuestionRubric]: + """Build QuestionRubric list from session questions.""" + rubrics = [] + + if not session.questions: + return rubrics + + for i, q in enumerate(session.questions): + rubric = QuestionRubric( + question_number=q.get("number", i + 1), + question_text=q.get("text", f"Frage {i + 1}"), + max_points=q.get("points", 10), + expected_answer=q.get("expected_answer", ""), + grading_criteria=q.get("rubric", session.rubric or "") + ) + rubrics.append(rubric) + + return rubrics + + def _mark_document_completed( + self, + doc: PseudonymizedDocument, + teacher_id: str + ): + """Mark document as completed without AI correction.""" + doc.status = DocumentStatus.COMPLETED + doc.processing_completed_at = datetime.utcnow() + if not doc.ai_feedback: + doc.ai_feedback = "Verarbeitung abgeschlossen (ohne KI-Korrektur)" + self.db.commit() + + # Update session stats + if doc.session: + doc.session.processed_count += 1 + self.db.commit() + + def _mark_document_failed( + self, + doc: PseudonymizedDocument, + error: str, + teacher_id: str + ): + """Mark document as failed.""" + doc.status = DocumentStatus.FAILED + doc.processing_error = error[:500] + doc.processing_completed_at = datetime.utcnow() + self.db.commit() + + +# Background task function for FastAPI +async def process_session_background( + session_id: str, + teacher_id: str, + db_url: str +): + """ + Background task for session processing. + + This function creates its own DB session for use in background tasks. + """ + from ..database import SessionLocal + + db = SessionLocal() + try: + service = ProcessingService(db) + await service.process_session(session_id, teacher_id) + finally: + db.close() + + +# Singleton for main service +_processing_service: Optional[ProcessingService] = None + + +def get_processing_service(db: Session) -> ProcessingService: + """Get processing service instance.""" + return ProcessingService(db) diff --git a/backend/klausur/services/pseudonymizer.py b/backend/klausur/services/pseudonymizer.py new file mode 100644 index 0000000..31f29c6 --- /dev/null +++ b/backend/klausur/services/pseudonymizer.py @@ -0,0 +1,376 @@ +""" +Pseudonymization Service for Klausurkorrektur. + +Implements privacy-by-design principles: +- QR code generation with random doc_tokens +- Header redaction to remove personal data before OCR +- No student identity data leaves the teacher's device + +DSGVO Art. 4 Nr. 5 Compliance: +The doc_token is a 128-bit random UUID that cannot be used to +identify a student without the encrypted identity map. +""" +import uuid +import io +import logging +from typing import List, Tuple, Optional +from dataclasses import dataclass +from PIL import Image, ImageDraw, ImageFont + +logger = logging.getLogger(__name__) + +# Optional imports (graceful fallback if not installed) +try: + import qrcode + HAS_QRCODE = True +except ImportError: + HAS_QRCODE = False + logger.warning("qrcode not installed - QR generation disabled") + +try: + import cv2 + import numpy as np + HAS_CV2 = True +except ImportError: + HAS_CV2 = False + logger.warning("opencv-python not installed - image processing disabled") + +try: + from pyzbar.pyzbar import decode as pyzbar_decode + HAS_PYZBAR = True +except ImportError: + HAS_PYZBAR = False + logger.warning("pyzbar not installed - QR reading disabled") + + +@dataclass +class RedactionResult: + """Result of header redaction.""" + redacted_image: bytes + original_height: int + redacted_height: int + redaction_applied: bool + + +@dataclass +class QRDetectionResult: + """Result of QR code detection.""" + doc_token: Optional[str] + confidence: float + bbox: Optional[Tuple[int, int, int, int]] # x, y, width, height + + +class PseudonymizationService: + """ + Service for document pseudonymization. + + PRIVACY GUARANTEES: + 1. doc_tokens are cryptographically random (UUID4) + 2. No deterministic relationship between token and student + 3. Header redaction removes visible personal data + 4. Identity mapping is encrypted client-side + """ + + # Default header height to redact (in pixels, assuming 300 DPI scan) + DEFAULT_HEADER_HEIGHT = 300 # ~1 inch / 2.5cm + + @staticmethod + def generate_doc_token() -> str: + """ + Generate a cryptographically random document token. + + Uses UUID4 which provides 122 bits of randomness. + This ensures no correlation between tokens is possible. + """ + return str(uuid.uuid4()) + + @staticmethod + def generate_batch_tokens(count: int) -> List[str]: + """Generate multiple unique doc_tokens.""" + return [PseudonymizationService.generate_doc_token() for _ in range(count)] + + def generate_qr_code( + self, + doc_token: str, + size: int = 200, + border: int = 2 + ) -> bytes: + """ + Generate a QR code image for a doc_token. + + Args: + doc_token: The pseudonymization token + size: Size of the QR code in pixels + border: Border size in QR modules + + Returns: + PNG image as bytes + """ + if not HAS_QRCODE: + raise RuntimeError("qrcode library not installed") + + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_M, + box_size=10, + border=border, + ) + qr.add_data(doc_token) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + img = img.resize((size, size), Image.Resampling.LANCZOS) + + buffer = io.BytesIO() + img.save(buffer, format="PNG") + return buffer.getvalue() + + def generate_qr_sheet( + self, + doc_tokens: List[str], + page_size: Tuple[int, int] = (2480, 3508), # A4 at 300 DPI + qr_size: int = 200, + margin: int = 100, + labels: Optional[List[str]] = None + ) -> bytes: + """ + Generate a printable sheet of QR codes. + + Args: + doc_tokens: List of tokens to generate QR codes for + page_size: Page dimensions (width, height) in pixels + qr_size: Size of each QR code + margin: Page margin + labels: Optional labels (e.g., "Nr. 1", "Nr. 2") - NO student names! + + Returns: + PNG image of the full sheet + """ + if not HAS_QRCODE: + raise RuntimeError("qrcode library not installed") + + width, height = page_size + img = Image.new('RGB', (width, height), 'white') + draw = ImageDraw.Draw(img) + + # Calculate grid + usable_width = width - 2 * margin + usable_height = height - 2 * margin + cell_width = qr_size + 50 + cell_height = qr_size + 80 # Extra space for label + + cols = usable_width // cell_width + rows = usable_height // cell_height + + # Try to load a font (fallback to default) + try: + font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 16) + except (IOError, OSError): + font = ImageFont.load_default() + + # Generate QR codes + for i, token in enumerate(doc_tokens): + if i >= cols * rows: + logger.warning(f"Sheet full, skipping {len(doc_tokens) - i} tokens") + break + + row = i // cols + col = i % cols + + x = margin + col * cell_width + y = margin + row * cell_height + + # Generate QR code + qr_bytes = self.generate_qr_code(token, qr_size) + qr_img = Image.open(io.BytesIO(qr_bytes)) + img.paste(qr_img, (x, y)) + + # Add label (number only, NO names) + label = labels[i] if labels and i < len(labels) else f"Nr. {i + 1}" + draw.text((x, y + qr_size + 5), label, fill="black", font=font) + + # Add truncated token for verification + token_short = token[:8] + "..." + draw.text((x, y + qr_size + 25), token_short, fill="gray", font=font) + + buffer = io.BytesIO() + img.save(buffer, format="PNG") + return buffer.getvalue() + + def detect_qr_code(self, image_bytes: bytes) -> QRDetectionResult: + """ + Detect and decode QR code from an image. + + Args: + image_bytes: Image data (PNG, JPEG, etc.) + + Returns: + QRDetectionResult with doc_token if found + """ + if not HAS_PYZBAR: + return QRDetectionResult( + doc_token=None, + confidence=0.0, + bbox=None + ) + + try: + img = Image.open(io.BytesIO(image_bytes)) + + # Decode QR codes + decoded = pyzbar_decode(img) + + for obj in decoded: + if obj.type == 'QRCODE': + token = obj.data.decode('utf-8') + # Validate it looks like a UUID + try: + uuid.UUID(token) + rect = obj.rect + return QRDetectionResult( + doc_token=token, + confidence=1.0, + bbox=(rect.left, rect.top, rect.width, rect.height) + ) + except ValueError: + continue + + return QRDetectionResult(doc_token=None, confidence=0.0, bbox=None) + + except Exception as e: + logger.error(f"QR detection failed: {e}") + return QRDetectionResult(doc_token=None, confidence=0.0, bbox=None) + + def redact_header( + self, + image_bytes: bytes, + header_height: Optional[int] = None, + fill_color: Tuple[int, int, int] = (255, 255, 255) + ) -> RedactionResult: + """ + Redact the header area of a scanned exam page. + + This removes the area where student name/class/date typically appears. + The redaction is permanent - no original data is preserved. + + Args: + image_bytes: Original scanned image + header_height: Height in pixels to redact (None = auto-detect) + fill_color: RGB color to fill redacted area (default: white) + + Returns: + RedactionResult with redacted image + """ + try: + img = Image.open(io.BytesIO(image_bytes)) + width, height = img.size + + # Determine header height + redact_height = header_height or self.DEFAULT_HEADER_HEIGHT + + # Create a copy and redact header + redacted = img.copy() + draw = ImageDraw.Draw(redacted) + draw.rectangle([(0, 0), (width, redact_height)], fill=fill_color) + + # Save result + buffer = io.BytesIO() + redacted.save(buffer, format="PNG") + + return RedactionResult( + redacted_image=buffer.getvalue(), + original_height=height, + redacted_height=redact_height, + redaction_applied=True + ) + + except Exception as e: + logger.error(f"Header redaction failed: {e}") + return RedactionResult( + redacted_image=image_bytes, + original_height=0, + redacted_height=0, + redaction_applied=False + ) + + def smart_redact_header( + self, + image_bytes: bytes, + preserve_qr: bool = True + ) -> RedactionResult: + """ + Smart header redaction that detects text regions. + + Uses OCR confidence to identify and redact only the header + area containing personal data. + + Args: + image_bytes: Original scanned image + preserve_qr: If True, don't redact QR code areas + + Returns: + RedactionResult with intelligently redacted image + """ + if not HAS_CV2: + # Fallback to simple redaction + return self.redact_header(image_bytes) + + try: + # Convert to OpenCV format + nparr = np.frombuffer(image_bytes, np.uint8) + img = cv2.imdecode(nparr, cv2.IMREAD_COLOR) + height, width = img.shape[:2] + + # Detect QR code position if present + qr_result = self.detect_qr_code(image_bytes) + + # Calculate redaction area (top portion of page) + # Typically header is in top 10-15% of page + header_height = int(height * 0.12) + + # If QR code is in header area, adjust redaction + if preserve_qr and qr_result.bbox: + qr_x, qr_y, qr_w, qr_h = qr_result.bbox + if qr_y < header_height: + # QR is in header - redact around it + # Create mask + mask = np.ones((header_height, width), dtype=np.uint8) * 255 + + # Leave QR area unredacted + mask[max(0, qr_y):min(header_height, qr_y + qr_h), + max(0, qr_x):min(width, qr_x + qr_w)] = 0 + + # Apply white fill where mask is 255 + img[:header_height][mask == 255] = [255, 255, 255] + else: + # QR not in header - simple redaction + img[:header_height] = [255, 255, 255] + else: + # Simple header redaction + img[:header_height] = [255, 255, 255] + + # Encode result + _, buffer = cv2.imencode('.png', img) + + return RedactionResult( + redacted_image=buffer.tobytes(), + original_height=height, + redacted_height=header_height, + redaction_applied=True + ) + + except Exception as e: + logger.error(f"Smart redaction failed: {e}") + return self.redact_header(image_bytes) + + +# Singleton instance +_pseudonymizer: Optional[PseudonymizationService] = None + + +def get_pseudonymizer() -> PseudonymizationService: + """Get or create the pseudonymization service singleton.""" + global _pseudonymizer + if _pseudonymizer is None: + _pseudonymizer = PseudonymizationService() + return _pseudonymizer diff --git a/backend/klausur/services/roster_parser.py b/backend/klausur/services/roster_parser.py new file mode 100644 index 0000000..9d6a13c --- /dev/null +++ b/backend/klausur/services/roster_parser.py @@ -0,0 +1,502 @@ +""" +Roster Parser Service - Klassenbuch und Schuelerlisten parsen. + +Unterstuetzt: +- Klassenbuch-Fotos (OCR mit PaddleOCR) +- PDF-Schuelerlisten (SchILD, ASV, etc.) +- CSV-Dateien +- Manuelle Eingabe + +Privacy-First: +- Alle Verarbeitung serverseitig (kein externer Upload) +- Daten bleiben im Lehrer-Namespace +""" + +import re +import csv +import io +from dataclasses import dataclass, field +from typing import List, Optional, Dict, Tuple +from difflib import SequenceMatcher + +# Optionale Imports +try: + from services.file_processor import get_file_processor, ProcessingResult + HAS_OCR = True +except ImportError: + HAS_OCR = False + +try: + import fitz # PyMuPDF + HAS_PDF = True +except ImportError: + HAS_PDF = False + + +@dataclass +class RosterEntry: + """Eintrag in einer Schuelerliste.""" + first_name: str + last_name: str + student_number: Optional[str] = None + parent_email: Optional[str] = None + parent_phone: Optional[str] = None + birth_date: Optional[str] = None + additional_data: Dict[str, str] = field(default_factory=dict) + + +@dataclass +class ParsedRoster: + """Ergebnis des Roster-Parsings.""" + entries: List[RosterEntry] + source_type: str # klassenbuch, pdf, csv + confidence: float + warnings: List[str] = field(default_factory=list) + raw_text: Optional[str] = None + + +@dataclass +class NameMatch: + """Ergebnis eines Name-Matchings.""" + detected_name: str + matched_entry: Optional[RosterEntry] + confidence: float + match_type: str # exact, first_name, fuzzy, none + + +class RosterParser: + """ + Parst Klassenlisten aus verschiedenen Quellen. + + Beispiel: + parser = RosterParser() + + # Klassenbuch-Foto + roster = parser.parse_klassenbuch_image(image_bytes) + + # PDF-Liste + roster = parser.parse_pdf_roster(pdf_bytes) + + # Namen matchen + matches = parser.match_first_names( + detected=["Max", "Anna", "Tim"], + roster=roster.entries + ) + """ + + # Regex-Patterns fuer Kontaktdaten + EMAIL_PATTERN = re.compile(r'[\w.+-]+@[\w-]+\.[\w.-]+') + PHONE_PATTERN = re.compile(r'(?:\+49|0)[\s.-]?\d{2,4}[\s.-]?\d{3,}[\s.-]?\d{2,}') + DATE_PATTERN = re.compile(r'\b(\d{1,2})\.(\d{1,2})\.(\d{2,4})\b') + + # Deutsche Vornamen (Auszug fuer Validierung) + COMMON_FIRST_NAMES = { + 'max', 'anna', 'tim', 'lena', 'paul', 'marie', 'felix', 'emma', + 'leon', 'sophia', 'lukas', 'mia', 'jonas', 'hannah', 'elias', 'emilia', + 'ben', 'lea', 'noah', 'lina', 'finn', 'amelie', 'luis', 'laura', + 'moritz', 'clara', 'henry', 'julia', 'julian', 'emily', 'david', 'johanna', + 'niklas', 'charlotte', 'simon', 'maja', 'alexander', 'sarah', 'jan', 'lisa', + 'tom', 'nele', 'luca', 'sophie', 'erik', 'alina', 'fabian', 'paula', + 'philipp', 'luisa', 'tobias', 'melina', 'vincent', 'lara', 'maximilian', 'elena' + } + + def __init__(self): + self.file_processor = get_file_processor() if HAS_OCR else None + + # ========================================================================= + # KLASSENBUCH-FOTO PARSING + # ========================================================================= + + def parse_klassenbuch_image(self, image_bytes: bytes) -> ParsedRoster: + """ + Parst ein Klassenbuch-Foto via OCR. + + Args: + image_bytes: Bild als Bytes (PNG, JPG) + + Returns: + ParsedRoster mit extrahierten Schuelerdaten + """ + if not HAS_OCR or not self.file_processor: + return ParsedRoster( + entries=[], + source_type='klassenbuch', + confidence=0.0, + warnings=['OCR nicht verfuegbar (PaddleOCR nicht installiert)'] + ) + + # OCR ausfuehren + result: ProcessingResult = self.file_processor.process_file( + image_bytes, + filename='klassenbuch.png', + processing_mode='ocr_handwriting' + ) + + # Text in Zeilen aufteilen + lines = result.text.split('\n') + entries = [] + warnings = [] + + for line in lines: + line = line.strip() + if not line or len(line) < 3: + continue + + entry = self._parse_roster_line(line) + if entry: + entries.append(entry) + + return ParsedRoster( + entries=entries, + source_type='klassenbuch', + confidence=result.confidence, + warnings=warnings, + raw_text=result.text + ) + + def _parse_roster_line(self, line: str) -> Optional[RosterEntry]: + """Parst eine einzelne Zeile aus dem Klassenbuch.""" + # Bereinigen + line = re.sub(r'\s+', ' ', line).strip() + + # Nummer am Anfang entfernen (z.B. "1. Max Mustermann") + line = re.sub(r'^\d+[\.\)\s]+', '', line) + + # Email extrahieren + email_match = self.EMAIL_PATTERN.search(line) + email = email_match.group() if email_match else None + if email: + line = line.replace(email, '') + + # Telefon extrahieren + phone_match = self.PHONE_PATTERN.search(line) + phone = phone_match.group() if phone_match else None + if phone: + line = line.replace(phone, '') + + # Geburtsdatum extrahieren + date_match = self.DATE_PATTERN.search(line) + birth_date = date_match.group() if date_match else None + if birth_date: + line = line.replace(birth_date, '') + + # Namen parsen (Rest der Zeile) + line = re.sub(r'\s+', ' ', line).strip() + if not line: + return None + + first_name, last_name = self._parse_name(line) + if not first_name: + return None + + return RosterEntry( + first_name=first_name, + last_name=last_name or '', + parent_email=email, + parent_phone=phone, + birth_date=birth_date + ) + + def _parse_name(self, text: str) -> Tuple[Optional[str], Optional[str]]: + """ + Parst einen Namen in Vor- und Nachname. + + Formate: + - "Max Mustermann" + - "Mustermann, Max" + - "Max M." + - "Max" + """ + text = text.strip() + if not text: + return None, None + + # Format: "Nachname, Vorname" + if ',' in text: + parts = text.split(',', 1) + last_name = parts[0].strip() + first_name = parts[1].strip() if len(parts) > 1 else '' + return first_name, last_name + + # Format: "Vorname Nachname" oder "Vorname" + parts = text.split() + if len(parts) == 1: + return parts[0], None + elif len(parts) == 2: + return parts[0], parts[1] + else: + # Erster Teil ist Vorname, Rest ist Nachname + return parts[0], ' '.join(parts[1:]) + + # ========================================================================= + # PDF ROSTER PARSING + # ========================================================================= + + def parse_pdf_roster(self, pdf_bytes: bytes) -> ParsedRoster: + """ + Parst eine PDF-Schuelerliste. + + Unterstuetzt gaengige Schulverwaltungs-Exporte: + - SchILD-NRW + - ASV (Bayern) + - Untis + - Generic CSV-in-PDF + """ + if not HAS_PDF: + return ParsedRoster( + entries=[], + source_type='pdf', + confidence=0.0, + warnings=['PDF-Parsing nicht verfuegbar (PyMuPDF nicht installiert)'] + ) + + entries = [] + warnings = [] + raw_text = '' + + try: + doc = fitz.open(stream=pdf_bytes, filetype='pdf') + + for page in doc: + text = page.get_text() + raw_text += text + '\n' + + # Tabellen extrahieren + tables = page.find_tables() + for table in tables: + df = table.to_pandas() + for _, row in df.iterrows(): + entry = self._parse_table_row(row.to_dict()) + if entry: + entries.append(entry) + + # Falls keine Tabellen: Zeilenweise parsen + if not tables: + for line in text.split('\n'): + entry = self._parse_roster_line(line) + if entry: + entries.append(entry) + + doc.close() + + except Exception as e: + warnings.append(f'PDF-Parsing Fehler: {str(e)}') + + # Duplikate entfernen + entries = self._deduplicate_entries(entries) + + return ParsedRoster( + entries=entries, + source_type='pdf', + confidence=0.9 if entries else 0.0, + warnings=warnings, + raw_text=raw_text + ) + + def _parse_table_row(self, row: Dict) -> Optional[RosterEntry]: + """Parst eine Tabellenzeile in einen RosterEntry.""" + # Spalten-Mappings (verschiedene Formate) + name_columns = ['name', 'schueler', 'schüler', 'student', 'nachname', 'last_name'] + first_name_columns = ['vorname', 'first_name', 'firstname'] + email_columns = ['email', 'e-mail', 'mail', 'eltern_email', 'parent_email'] + phone_columns = ['telefon', 'phone', 'tel', 'handy', 'mobile', 'eltern_tel'] + + first_name = None + last_name = None + email = None + phone = None + + for key, value in row.items(): + if not value or str(value).strip() == '': + continue + + key_lower = str(key).lower() + value_str = str(value).strip() + + if any(col in key_lower for col in first_name_columns): + first_name = value_str + elif any(col in key_lower for col in name_columns): + # Kann "Vorname Nachname" oder nur "Nachname" sein + if first_name: + last_name = value_str + else: + first_name, last_name = self._parse_name(value_str) + elif any(col in key_lower for col in email_columns): + if self.EMAIL_PATTERN.match(value_str): + email = value_str + elif any(col in key_lower for col in phone_columns): + phone = value_str + + if not first_name: + return None + + return RosterEntry( + first_name=first_name, + last_name=last_name or '', + parent_email=email, + parent_phone=phone + ) + + # ========================================================================= + # CSV PARSING + # ========================================================================= + + def parse_csv_roster(self, csv_content: str) -> ParsedRoster: + """ + Parst eine CSV-Schuelerliste. + + Args: + csv_content: CSV als String + + Returns: + ParsedRoster + """ + entries = [] + warnings = [] + + try: + # Delimiter erraten + dialect = csv.Sniffer().sniff(csv_content[:1024]) + reader = csv.DictReader(io.StringIO(csv_content), dialect=dialect) + + for row in reader: + entry = self._parse_table_row(row) + if entry: + entries.append(entry) + + except csv.Error as e: + warnings.append(f'CSV-Parsing Fehler: {str(e)}') + + # Fallback: Zeilenweise parsen + for line in csv_content.split('\n'): + entry = self._parse_roster_line(line) + if entry: + entries.append(entry) + + return ParsedRoster( + entries=entries, + source_type='csv', + confidence=0.95 if entries else 0.0, + warnings=warnings, + raw_text=csv_content + ) + + # ========================================================================= + # NAME MATCHING + # ========================================================================= + + def match_first_names( + self, + detected: List[str], + roster: List[RosterEntry], + threshold: float = 0.7 + ) -> List[NameMatch]: + """ + Matched erkannte Vornamen zu Roster-Eintraegen. + + Args: + detected: Liste erkannter Vornamen (z.B. ["Max", "Anna"]) + roster: Vollstaendige Schuelerliste + threshold: Mindest-Konfidenz fuer Fuzzy-Matching + + Returns: + Liste von NameMatch-Objekten + """ + matches = [] + used_entries = set() + + for name in detected: + name_lower = name.lower().strip() + best_match = None + best_confidence = 0.0 + match_type = 'none' + + for i, entry in enumerate(roster): + if i in used_entries: + continue + + entry_first_lower = entry.first_name.lower().strip() + + # Exakter Match + if name_lower == entry_first_lower: + best_match = entry + best_confidence = 1.0 + match_type = 'exact' + used_entries.add(i) + break + + # Vorname-Anfang Match (z.B. "Max" matched "Maximilian") + if entry_first_lower.startswith(name_lower) or name_lower.startswith(entry_first_lower): + confidence = min(len(name_lower), len(entry_first_lower)) / max(len(name_lower), len(entry_first_lower)) + if confidence > best_confidence and confidence >= threshold: + best_match = entry + best_confidence = confidence + match_type = 'first_name' + + # Fuzzy Match + ratio = SequenceMatcher(None, name_lower, entry_first_lower).ratio() + if ratio > best_confidence and ratio >= threshold: + best_match = entry + best_confidence = ratio + match_type = 'fuzzy' + + if best_match and match_type != 'exact': + # Entry als verwendet markieren + for i, entry in enumerate(roster): + if entry is best_match: + used_entries.add(i) + break + + matches.append(NameMatch( + detected_name=name, + matched_entry=best_match, + confidence=best_confidence, + match_type=match_type + )) + + return matches + + # ========================================================================= + # HELPERS + # ========================================================================= + + def _deduplicate_entries(self, entries: List[RosterEntry]) -> List[RosterEntry]: + """Entfernt Duplikate basierend auf Vor- und Nachname.""" + seen = set() + unique = [] + + for entry in entries: + key = (entry.first_name.lower(), entry.last_name.lower()) + if key not in seen: + seen.add(key) + unique.append(entry) + + return unique + + def validate_entry(self, entry: RosterEntry) -> List[str]: + """Validiert einen RosterEntry und gibt Warnungen zurueck.""" + warnings = [] + + # Vorname pruefen + if not entry.first_name: + warnings.append('Kein Vorname') + elif len(entry.first_name) < 2: + warnings.append('Vorname zu kurz') + + # Email validieren + if entry.parent_email and not self.EMAIL_PATTERN.match(entry.parent_email): + warnings.append('Ungueltige Email-Adresse') + + return warnings + + +# Singleton +_roster_parser: Optional[RosterParser] = None + + +def get_roster_parser() -> RosterParser: + """Gibt die Singleton-Instanz des RosterParsers zurueck.""" + global _roster_parser + if _roster_parser is None: + _roster_parser = RosterParser() + return _roster_parser diff --git a/backend/klausur/services/school_resolver.py b/backend/klausur/services/school_resolver.py new file mode 100644 index 0000000..458b767 --- /dev/null +++ b/backend/klausur/services/school_resolver.py @@ -0,0 +1,613 @@ +""" +School Resolver Service - Schul-Auswahl und Klassen-Erstellung. + +Funktionen: +- Bundesland -> Schulform -> Schule Kaskade +- Auto-Erstellung von Klassen aus erkannten Daten +- Integration mit Go School Service (Port 8084) + +Privacy: +- Schuldaten sind Stammdaten (kein DSGVO-Problem) +- Schueler-Erstellung nur im Lehrer-Namespace +""" + +import httpx +import os +from dataclasses import dataclass, field +from typing import List, Optional, Dict, Any +from enum import Enum + + +# ============================================================================ +# KONSTANTEN +# ============================================================================ + +BUNDESLAENDER = { + "BW": "Baden-Wuerttemberg", + "BY": "Bayern", + "BE": "Berlin", + "BB": "Brandenburg", + "HB": "Bremen", + "HH": "Hamburg", + "HE": "Hessen", + "MV": "Mecklenburg-Vorpommern", + "NI": "Niedersachsen", + "NW": "Nordrhein-Westfalen", + "RP": "Rheinland-Pfalz", + "SL": "Saarland", + "SN": "Sachsen", + "ST": "Sachsen-Anhalt", + "SH": "Schleswig-Holstein", + "TH": "Thueringen" +} + +SCHULFORMEN = { + "grundschule": { + "name": "Grundschule", + "grades": [1, 2, 3, 4], + "short": "GS" + }, + "hauptschule": { + "name": "Hauptschule", + "grades": [5, 6, 7, 8, 9, 10], + "short": "HS" + }, + "realschule": { + "name": "Realschule", + "grades": [5, 6, 7, 8, 9, 10], + "short": "RS" + }, + "gymnasium": { + "name": "Gymnasium", + "grades": [5, 6, 7, 8, 9, 10, 11, 12, 13], + "short": "GYM" + }, + "gesamtschule": { + "name": "Gesamtschule", + "grades": [5, 6, 7, 8, 9, 10, 11, 12, 13], + "short": "IGS" + }, + "oberschule": { + "name": "Oberschule", + "grades": [5, 6, 7, 8, 9, 10], + "short": "OBS" + }, + "sekundarschule": { + "name": "Sekundarschule", + "grades": [5, 6, 7, 8, 9, 10], + "short": "SEK" + }, + "foerderschule": { + "name": "Foerderschule", + "grades": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + "short": "FS" + }, + "berufsschule": { + "name": "Berufsschule", + "grades": [10, 11, 12, 13], + "short": "BS" + }, + "fachoberschule": { + "name": "Fachoberschule", + "grades": [11, 12, 13], + "short": "FOS" + } +} + +# Faecher mit Standardbezeichnungen +FAECHER = { + "mathematik": {"name": "Mathematik", "short": "Ma"}, + "deutsch": {"name": "Deutsch", "short": "De"}, + "englisch": {"name": "Englisch", "short": "En"}, + "franzoesisch": {"name": "Franzoesisch", "short": "Fr"}, + "spanisch": {"name": "Spanisch", "short": "Sp"}, + "latein": {"name": "Latein", "short": "La"}, + "physik": {"name": "Physik", "short": "Ph"}, + "chemie": {"name": "Chemie", "short": "Ch"}, + "biologie": {"name": "Biologie", "short": "Bio"}, + "geschichte": {"name": "Geschichte", "short": "Ge"}, + "erdkunde": {"name": "Erdkunde", "short": "Ek"}, + "politik": {"name": "Politik", "short": "Po"}, + "wirtschaft": {"name": "Wirtschaft", "short": "Wi"}, + "kunst": {"name": "Kunst", "short": "Ku"}, + "musik": {"name": "Musik", "short": "Mu"}, + "sport": {"name": "Sport", "short": "Sp"}, + "religion": {"name": "Religion", "short": "Re"}, + "ethik": {"name": "Ethik", "short": "Et"}, + "informatik": {"name": "Informatik", "short": "If"}, + "sachunterricht": {"name": "Sachunterricht", "short": "SU"} +} + + +# ============================================================================ +# DATA CLASSES +# ============================================================================ + +@dataclass +class School: + """Schule.""" + id: str + name: str + bundesland: str + schulform: str + address: Optional[str] = None + city: Optional[str] = None + + +@dataclass +class SchoolClass: + """Schulklasse.""" + id: str + school_id: str + name: str # z.B. "3a" + grade_level: int # z.B. 3 + school_year: str # z.B. "2025/2026" + teacher_id: str + student_count: int = 0 + + +@dataclass +class Student: + """Schueler (Stammdaten, keine PII im Klausur-Kontext).""" + id: str + class_id: str + first_name: str + last_name: str + student_number: Optional[str] = None + + +@dataclass +class DetectedClassInfo: + """Aus Klausuren erkannte Klasseninformationen.""" + class_name: str # z.B. "3a" + grade_level: Optional[int] = None # z.B. 3 + subject: Optional[str] = None + date: Optional[str] = None + students: List[Dict[str, str]] = field(default_factory=list) + confidence: float = 0.0 + + +@dataclass +class SchoolContext: + """Vollstaendiger Schulkontext fuer einen Lehrer.""" + teacher_id: str + school: Optional[School] = None + classes: List[SchoolClass] = field(default_factory=list) + current_school_year: str = "2025/2026" + + +# ============================================================================ +# SCHOOL RESOLVER +# ============================================================================ + +class SchoolResolver: + """ + Verwaltet Schul- und Klassenkontext. + + Beispiel: + resolver = SchoolResolver() + + # Schul-Kaskade + schools = await resolver.search_schools("Niedersachsen", "Grundschule", "Jever") + + # Klasse auto-erstellen + class_obj = await resolver.auto_create_class( + teacher_id="teacher-123", + school_id="school-456", + detected_info=DetectedClassInfo( + class_name="3a", + students=[{"firstName": "Max"}, {"firstName": "Anna"}] + ) + ) + """ + + def __init__(self): + self.school_service_url = os.getenv( + "SCHOOL_SERVICE_URL", + "http://school-service:8084" + ) + # Fallback auf lokale Daten wenn Service nicht erreichbar + self._local_schools: Dict[str, School] = {} + self._local_classes: Dict[str, SchoolClass] = {} + + # ========================================================================= + # BUNDESLAND / SCHULFORM LOOKUP + # ========================================================================= + + def get_bundeslaender(self) -> Dict[str, str]: + """Gibt alle Bundeslaender zurueck.""" + return BUNDESLAENDER + + def get_schulformen(self) -> Dict[str, Dict]: + """Gibt alle Schulformen zurueck.""" + return SCHULFORMEN + + def get_faecher(self) -> Dict[str, Dict]: + """Gibt alle Faecher zurueck.""" + return FAECHER + + def get_grades_for_schulform(self, schulform: str) -> List[int]: + """Gibt die Klassenstufen fuer eine Schulform zurueck.""" + if schulform in SCHULFORMEN: + return SCHULFORMEN[schulform]["grades"] + return list(range(1, 14)) # Default: alle Stufen + + def detect_grade_from_class_name(self, class_name: str) -> Optional[int]: + """ + Erkennt die Klassenstufe aus dem Klassennamen. + + Beispiele: + - "3a" -> 3 + - "10b" -> 10 + - "Q1" -> 11 + - "EF" -> 10 + """ + import re + + # Standard-Format: Zahl + Buchstabe + match = re.match(r'^(\d{1,2})[a-zA-Z]?$', class_name) + if match: + return int(match.group(1)) + + # Oberstufen-Formate + upper_grades = { + 'ef': 10, 'e': 10, + 'q1': 11, 'q2': 12, + 'k1': 11, 'k2': 12, + '11': 11, '12': 12, '13': 13 + } + + class_lower = class_name.lower() + if class_lower in upper_grades: + return upper_grades[class_lower] + + return None + + def normalize_subject(self, detected_subject: str) -> Optional[str]: + """ + Normalisiert einen erkannten Fachnamen. + + Beispiel: "Mathe" -> "mathematik" + """ + subject_lower = detected_subject.lower().strip() + + # Direkte Matches + if subject_lower in FAECHER: + return subject_lower + + # Abkuerzungen und Varianten + subject_aliases = { + 'mathe': 'mathematik', + 'bio': 'biologie', + 'phy': 'physik', + 'che': 'chemie', + 'geo': 'erdkunde', + 'geographie': 'erdkunde', + 'powi': 'politik', + 'sowi': 'politik', + 'reli': 'religion', + 'info': 'informatik', + 'su': 'sachunterricht' + } + + if subject_lower in subject_aliases: + return subject_aliases[subject_lower] + + # Teilstring-Match + for key in FAECHER: + if key.startswith(subject_lower) or subject_lower.startswith(key[:3]): + return key + + return None + + # ========================================================================= + # SCHOOL SERVICE INTEGRATION + # ========================================================================= + + async def search_schools( + self, + bundesland: Optional[str] = None, + schulform: Optional[str] = None, + name_query: Optional[str] = None, + limit: int = 20 + ) -> List[School]: + """ + Sucht Schulen im School Service. + + Args: + bundesland: Bundesland-Kuerzel (z.B. "NI") + schulform: Schulform-Key (z.B. "grundschule") + name_query: Suchbegriff fuer Schulname + limit: Max. Anzahl Ergebnisse + """ + try: + async with httpx.AsyncClient(timeout=10.0) as client: + params = {} + if bundesland: + params['state'] = bundesland + if schulform: + params['type'] = schulform + if name_query: + params['q'] = name_query + params['limit'] = limit + + response = await client.get( + f"{self.school_service_url}/api/schools", + params=params + ) + + if response.status_code == 200: + data = response.json() + return [ + School( + id=s['id'], + name=s['name'], + bundesland=s.get('state', bundesland or ''), + schulform=s.get('type', schulform or ''), + address=s.get('address'), + city=s.get('city') + ) + for s in data.get('schools', []) + ] + + except Exception as e: + print(f"[SchoolResolver] Service error: {e}") + + # Fallback: Leere Liste (Schule kann manuell angelegt werden) + return [] + + async def get_or_create_school( + self, + teacher_id: str, + bundesland: str, + schulform: str, + school_name: str, + city: Optional[str] = None + ) -> School: + """ + Holt oder erstellt eine Schule. + + Falls die Schule existiert, wird sie zurueckgegeben. + Sonst wird sie neu erstellt. + """ + # Zuerst suchen + existing = await self.search_schools( + bundesland=bundesland, + schulform=schulform, + name_query=school_name, + limit=1 + ) + + if existing: + return existing[0] + + # Neu erstellen + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + f"{self.school_service_url}/api/schools", + json={ + "name": school_name, + "state": bundesland, + "type": schulform, + "city": city, + "created_by": teacher_id + } + ) + + if response.status_code in (200, 201): + data = response.json() + return School( + id=data['id'], + name=school_name, + bundesland=bundesland, + schulform=schulform, + city=city + ) + + except Exception as e: + print(f"[SchoolResolver] Create school error: {e}") + + # Fallback: Lokale Schule erstellen + import uuid + school_id = str(uuid.uuid4()) + school = School( + id=school_id, + name=school_name, + bundesland=bundesland, + schulform=schulform, + city=city + ) + self._local_schools[school_id] = school + return school + + # ========================================================================= + # CLASS MANAGEMENT + # ========================================================================= + + async def get_classes_for_teacher( + self, + teacher_id: str, + school_id: Optional[str] = None + ) -> List[SchoolClass]: + """Holt alle Klassen eines Lehrers.""" + try: + async with httpx.AsyncClient(timeout=10.0) as client: + params = {"teacher_id": teacher_id} + if school_id: + params["school_id"] = school_id + + response = await client.get( + f"{self.school_service_url}/api/classes", + params=params + ) + + if response.status_code == 200: + data = response.json() + return [ + SchoolClass( + id=c['id'], + school_id=c.get('school_id', ''), + name=c['name'], + grade_level=c.get('grade_level', 0), + school_year=c.get('school_year', '2025/2026'), + teacher_id=teacher_id, + student_count=c.get('student_count', 0) + ) + for c in data.get('classes', []) + ] + + except Exception as e: + print(f"[SchoolResolver] Get classes error: {e}") + + return list(self._local_classes.values()) + + async def auto_create_class( + self, + teacher_id: str, + school_id: str, + detected_info: DetectedClassInfo, + school_year: str = "2025/2026" + ) -> SchoolClass: + """ + Erstellt automatisch eine Klasse aus erkannten Daten. + + Args: + teacher_id: ID des Lehrers + school_id: ID der Schule + detected_info: Aus Klausuren erkannte Informationen + school_year: Schuljahr + """ + grade_level = detected_info.grade_level or self.detect_grade_from_class_name( + detected_info.class_name + ) or 0 + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + f"{self.school_service_url}/api/classes", + json={ + "school_id": school_id, + "name": detected_info.class_name, + "grade_level": grade_level, + "school_year": school_year, + "teacher_id": teacher_id + } + ) + + if response.status_code in (200, 201): + data = response.json() + class_id = data['id'] + + # Schueler hinzufuegen + if detected_info.students: + await self._bulk_create_students( + class_id, + detected_info.students + ) + + return SchoolClass( + id=class_id, + school_id=school_id, + name=detected_info.class_name, + grade_level=grade_level, + school_year=school_year, + teacher_id=teacher_id, + student_count=len(detected_info.students) + ) + + except Exception as e: + print(f"[SchoolResolver] Create class error: {e}") + + # Fallback: Lokale Klasse + import uuid + class_id = str(uuid.uuid4()) + school_class = SchoolClass( + id=class_id, + school_id=school_id, + name=detected_info.class_name, + grade_level=grade_level, + school_year=school_year, + teacher_id=teacher_id, + student_count=len(detected_info.students) + ) + self._local_classes[class_id] = school_class + return school_class + + async def _bulk_create_students( + self, + class_id: str, + students: List[Dict[str, str]] + ) -> List[Student]: + """Erstellt mehrere Schueler auf einmal.""" + created = [] + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post( + f"{self.school_service_url}/api/classes/{class_id}/students/bulk", + json={ + "students": [ + { + "first_name": s.get("firstName", s.get("first_name", "")), + "last_name": s.get("lastName", s.get("last_name", "")) + } + for s in students + ] + } + ) + + if response.status_code in (200, 201): + data = response.json() + created = [ + Student( + id=s['id'], + class_id=class_id, + first_name=s['first_name'], + last_name=s.get('last_name', '') + ) + for s in data.get('students', []) + ] + + except Exception as e: + print(f"[SchoolResolver] Bulk create students error: {e}") + + return created + + # ========================================================================= + # CONTEXT MANAGEMENT + # ========================================================================= + + async def get_teacher_context(self, teacher_id: str) -> SchoolContext: + """ + Holt den vollstaendigen Schulkontext eines Lehrers. + + Beinhaltet Schule, Klassen und aktuelles Schuljahr. + """ + context = SchoolContext(teacher_id=teacher_id) + + # Klassen laden + classes = await self.get_classes_for_teacher(teacher_id) + context.classes = classes + + # Schule aus erster Klasse ableiten + if classes and classes[0].school_id: + schools = await self.search_schools() + for school in schools: + if school.id == classes[0].school_id: + context.school = school + break + + return context + + +# Singleton +_school_resolver: Optional[SchoolResolver] = None + + +def get_school_resolver() -> SchoolResolver: + """Gibt die Singleton-Instanz des SchoolResolvers zurueck.""" + global _school_resolver + if _school_resolver is None: + _school_resolver = SchoolResolver() + return _school_resolver diff --git a/backend/klausur/services/storage_service.py b/backend/klausur/services/storage_service.py new file mode 100644 index 0000000..5bacb0f --- /dev/null +++ b/backend/klausur/services/storage_service.py @@ -0,0 +1,197 @@ +""" +Storage Service for Klausur Documents. + +PRIVACY BY DESIGN: +- Documents stored with doc_token as identifier (not student names) +- Organized by session_id/doc_token for teacher isolation +- Auto-cleanup when retention period expires +""" +import os +import io +import logging +from typing import Optional, BinaryIO +from pathlib import Path +from minio import Minio +from minio.error import S3Error + +logger = logging.getLogger(__name__) + + +class KlausurStorageService: + """ + MinIO/S3 Storage Service for exam documents. + + Structure: + klausur-exams/ + {session_id}/ + {doc_token}.{ext} + {doc_token}_redacted.{ext} # After header redaction + """ + + def __init__(self): + self.endpoint = os.getenv("MINIO_ENDPOINT", "minio:9000") + self.access_key = os.getenv("MINIO_ROOT_USER", "breakpilot_dev") + self.secret_key = os.getenv("MINIO_ROOT_PASSWORD", "breakpilot_dev_123") + self.secure = os.getenv("MINIO_SECURE", "false").lower() == "true" + self.bucket_name = os.getenv("KLAUSUR_BUCKET", "klausur-exams") + + self._client: Optional[Minio] = None + + @property + def client(self) -> Minio: + """Lazy-init MinIO client.""" + if self._client is None: + self._client = Minio( + self.endpoint, + access_key=self.access_key, + secret_key=self.secret_key, + secure=self.secure + ) + self._ensure_bucket() + return self._client + + def _ensure_bucket(self): + """Create bucket if it doesn't exist.""" + try: + if not self._client.bucket_exists(self.bucket_name): + self._client.make_bucket(self.bucket_name) + logger.info(f"Created Klausur bucket: {self.bucket_name}") + except S3Error as e: + logger.warning(f"MinIO bucket check failed: {e}") + + def upload_document( + self, + session_id: str, + doc_token: str, + file_data: bytes, + file_extension: str = "png", + is_redacted: bool = False + ) -> str: + """ + Upload exam document to storage. + + Args: + session_id: Exam session ID + doc_token: Pseudonymized document token + file_data: Document binary data + file_extension: File extension (png, jpg, pdf) + is_redacted: Whether this is the redacted version + + Returns: + Object path in storage + """ + suffix = "_redacted" if is_redacted else "" + object_name = f"{session_id}/{doc_token}{suffix}.{file_extension}" + + # Determine content type + content_types = { + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "pdf": "application/pdf", + } + content_type = content_types.get(file_extension.lower(), "application/octet-stream") + + try: + self.client.put_object( + bucket_name=self.bucket_name, + object_name=object_name, + data=io.BytesIO(file_data), + length=len(file_data), + content_type=content_type + ) + logger.info(f"Uploaded document: {object_name}") + return object_name + + except S3Error as e: + logger.error(f"Failed to upload document: {e}") + raise + + def get_document( + self, + session_id: str, + doc_token: str, + file_extension: str = "png", + is_redacted: bool = False + ) -> Optional[bytes]: + """ + Download exam document from storage. + + Args: + session_id: Exam session ID + doc_token: Pseudonymized document token + file_extension: File extension + is_redacted: Whether to get the redacted version + + Returns: + Document binary data or None if not found + """ + suffix = "_redacted" if is_redacted else "" + object_name = f"{session_id}/{doc_token}{suffix}.{file_extension}" + + try: + response = self.client.get_object(self.bucket_name, object_name) + data = response.read() + response.close() + response.release_conn() + return data + + except S3Error as e: + if e.code == "NoSuchKey": + logger.warning(f"Document not found: {object_name}") + return None + logger.error(f"Failed to get document: {e}") + raise + + def delete_session_documents(self, session_id: str) -> int: + """ + Delete all documents for a session. + + Args: + session_id: Exam session ID + + Returns: + Number of deleted objects + """ + deleted_count = 0 + prefix = f"{session_id}/" + + try: + objects = self.client.list_objects(self.bucket_name, prefix=prefix) + for obj in objects: + self.client.remove_object(self.bucket_name, obj.object_name) + deleted_count += 1 + logger.debug(f"Deleted: {obj.object_name}") + + logger.info(f"Deleted {deleted_count} documents for session {session_id}") + return deleted_count + + except S3Error as e: + logger.error(f"Failed to delete session documents: {e}") + raise + + def document_exists( + self, + session_id: str, + doc_token: str, + file_extension: str = "png" + ) -> bool: + """Check if document exists in storage.""" + object_name = f"{session_id}/{doc_token}.{file_extension}" + try: + self.client.stat_object(self.bucket_name, object_name) + return True + except S3Error: + return False + + +# Singleton instance +_storage_service: Optional[KlausurStorageService] = None + + +def get_storage_service() -> KlausurStorageService: + """Get or create the storage service singleton.""" + global _storage_service + if _storage_service is None: + _storage_service = KlausurStorageService() + return _storage_service diff --git a/backend/klausur/services/trocr_client.py b/backend/klausur/services/trocr_client.py new file mode 100644 index 0000000..bc85053 --- /dev/null +++ b/backend/klausur/services/trocr_client.py @@ -0,0 +1,214 @@ +""" +TrOCR Client - Connects to external TrOCR service (Mac Mini). + +This client forwards OCR requests to the TrOCR service running on +the Mac Mini, enabling handwriting recognition without requiring +local GPU/ML dependencies. + +Privacy: Images are sent over the local network only - no cloud. +""" +import os +import httpx +import logging +from typing import Optional, List, Dict +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + +# Mac Mini TrOCR Service URL +TROCR_SERVICE_URL = os.environ.get( + "TROCR_SERVICE_URL", + "http://192.168.178.163:8084" +) + + +@dataclass +class OCRResult: + """Result from TrOCR extraction.""" + text: str + confidence: float + processing_time_ms: int + device: str = "remote" + + +class TrOCRClient: + """ + Client for external TrOCR service. + + Usage: + client = TrOCRClient() + + # Check if service is available + if await client.is_available(): + result = await client.extract_text(image_bytes) + print(result.text) + """ + + def __init__(self, base_url: Optional[str] = None): + self.base_url = base_url or TROCR_SERVICE_URL + self._client: Optional[httpx.AsyncClient] = None + + async def _get_client(self) -> httpx.AsyncClient: + """Get or create HTTP client.""" + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient( + base_url=self.base_url, + timeout=300.0 # 5 min timeout for model loading + ) + return self._client + + async def close(self): + """Close the HTTP client.""" + if self._client and not self._client.is_closed: + await self._client.aclose() + + async def is_available(self) -> bool: + """Check if TrOCR service is available.""" + try: + client = await self._get_client() + response = await client.get("/health", timeout=5.0) + return response.status_code == 200 + except Exception as e: + logger.warning(f"TrOCR service not available: {e}") + return False + + async def get_status(self) -> Dict: + """Get TrOCR service status.""" + try: + client = await self._get_client() + response = await client.get("/api/v1/status") + response.raise_for_status() + return response.json() + except Exception as e: + logger.error(f"Failed to get TrOCR status: {e}") + return { + "status": "unavailable", + "error": str(e) + } + + async def extract_text( + self, + image_data: bytes, + filename: str = "image.png", + detect_lines: bool = True + ) -> OCRResult: + """ + Extract text from an image using TrOCR. + + Args: + image_data: Raw image bytes + filename: Original filename + detect_lines: Whether to detect individual lines + + Returns: + OCRResult with extracted text + """ + try: + client = await self._get_client() + + files = {"file": (filename, image_data, "image/png")} + params = {"detect_lines": str(detect_lines).lower()} + + response = await client.post( + "/api/v1/extract", + files=files, + params=params + ) + response.raise_for_status() + + data = response.json() + + return OCRResult( + text=data.get("text", ""), + confidence=data.get("confidence", 0.0), + processing_time_ms=data.get("processing_time_ms", 0), + device=data.get("device", "remote") + ) + + except httpx.TimeoutException: + logger.error("TrOCR request timed out (model may be loading)") + raise + except Exception as e: + logger.error(f"TrOCR extraction failed: {e}") + raise + + async def batch_extract( + self, + images: List[bytes], + filenames: Optional[List[str]] = None, + detect_lines: bool = True + ) -> List[OCRResult]: + """ + Extract text from multiple images. + + Args: + images: List of image bytes + filenames: Optional list of filenames + detect_lines: Whether to detect individual lines + + Returns: + List of OCRResult + """ + if filenames is None: + filenames = [f"image_{i}.png" for i in range(len(images))] + + try: + client = await self._get_client() + + files = [ + ("files", (fn, img, "image/png")) + for fn, img in zip(filenames, images) + ] + + response = await client.post( + "/api/v1/batch-extract", + files=files + ) + response.raise_for_status() + + data = response.json() + results = [] + + for item in data.get("results", []): + results.append(OCRResult( + text=item.get("text", ""), + confidence=item.get("confidence", 0.85), + processing_time_ms=0, + device="remote" + )) + + return results + + except Exception as e: + logger.error(f"TrOCR batch extraction failed: {e}") + raise + + +# Singleton instance +_trocr_client: Optional[TrOCRClient] = None + + +def get_trocr_client() -> TrOCRClient: + """Get the TrOCR client singleton.""" + global _trocr_client + if _trocr_client is None: + _trocr_client = TrOCRClient() + return _trocr_client + + +async def extract_text_from_image( + image_data: bytes, + filename: str = "image.png" +) -> OCRResult: + """ + Convenience function to extract text from an image. + + Args: + image_data: Raw image bytes + filename: Original filename + + Returns: + OCRResult with extracted text + """ + client = get_trocr_client() + return await client.extract_text(image_data, filename) diff --git a/backend/klausur/services/trocr_service.py b/backend/klausur/services/trocr_service.py new file mode 100644 index 0000000..01aa8ab --- /dev/null +++ b/backend/klausur/services/trocr_service.py @@ -0,0 +1,577 @@ +""" +TrOCR Service for Handwriting Recognition. + +Uses Microsoft's TrOCR model for extracting handwritten text from exam images. +Supports fine-tuning with teacher corrections via LoRA adapters. + +PRIVACY BY DESIGN: +- All processing happens locally +- No data sent to external services +- Fine-tuning data stays on-premise +""" +import logging +import os +from pathlib import Path +from typing import Optional, List, Dict, Tuple +from dataclasses import dataclass +from io import BytesIO +import json + +logger = logging.getLogger(__name__) + +# Model paths +MODEL_CACHE_DIR = Path(os.environ.get("TROCR_CACHE_DIR", "/app/models/trocr")) +LORA_ADAPTERS_DIR = Path(os.environ.get("TROCR_LORA_DIR", "/app/models/trocr/lora")) +TRAINING_DATA_DIR = Path(os.environ.get("TROCR_TRAINING_DIR", "/app/data/trocr_training")) + + +@dataclass +class OCRResult: + """Result from TrOCR extraction.""" + text: str + confidence: float + bounding_boxes: List[Dict] # [{"x": 0, "y": 0, "w": 100, "h": 20, "text": "..."}] + processing_time_ms: int + + +@dataclass +class TrainingExample: + """A single training example for fine-tuning.""" + image_path: str + ground_truth: str + teacher_id: str + created_at: str + + +class TrOCRService: + """ + Handwriting recognition service using TrOCR. + + Features: + - Line-by-line handwriting extraction + - Confidence scoring + - LoRA fine-tuning support + - Batch processing + """ + + # Available models (from smallest to largest) + MODELS = { + "trocr-small": "microsoft/trocr-small-handwritten", + "trocr-base": "microsoft/trocr-base-handwritten", # Recommended + "trocr-large": "microsoft/trocr-large-handwritten", + } + + def __init__(self, model_name: str = "trocr-base", device: str = "auto"): + """ + Initialize TrOCR service. + + Args: + model_name: One of "trocr-small", "trocr-base", "trocr-large" + device: "cpu", "cuda", "mps" (Apple Silicon), or "auto" + """ + self.model_name = model_name + self.model_id = self.MODELS.get(model_name, self.MODELS["trocr-base"]) + self.device = self._get_device(device) + + self._processor = None + self._model = None + self._lora_adapter = None + + # Create directories + MODEL_CACHE_DIR.mkdir(parents=True, exist_ok=True) + LORA_ADAPTERS_DIR.mkdir(parents=True, exist_ok=True) + TRAINING_DATA_DIR.mkdir(parents=True, exist_ok=True) + + logger.info(f"TrOCR Service initialized: model={model_name}, device={self.device}") + + def _get_device(self, device: str) -> str: + """Determine the best device for inference.""" + if device != "auto": + return device + + try: + import torch + if torch.cuda.is_available(): + return "cuda" + elif torch.backends.mps.is_available(): + return "mps" + return "cpu" + except ImportError: + return "cpu" + + def _load_model(self): + """Lazy-load the TrOCR model.""" + if self._model is not None: + return + + try: + from transformers import TrOCRProcessor, VisionEncoderDecoderModel + import torch + + logger.info(f"Loading TrOCR model: {self.model_id}") + + self._processor = TrOCRProcessor.from_pretrained( + self.model_id, + cache_dir=str(MODEL_CACHE_DIR) + ) + + self._model = VisionEncoderDecoderModel.from_pretrained( + self.model_id, + cache_dir=str(MODEL_CACHE_DIR) + ) + + # Move to device + if self.device == "cuda": + self._model = self._model.cuda() + elif self.device == "mps": + self._model = self._model.to("mps") + + # Load LoRA adapter if exists + adapter_path = LORA_ADAPTERS_DIR / f"{self.model_name}_adapter" + if adapter_path.exists(): + self._load_lora_adapter(adapter_path) + + logger.info(f"TrOCR model loaded successfully on {self.device}") + + except ImportError as e: + logger.error(f"Missing dependencies: {e}") + logger.error("Install with: pip install transformers torch pillow") + raise + except Exception as e: + logger.error(f"Failed to load TrOCR model: {e}") + raise + + def _load_lora_adapter(self, adapter_path: Path): + """Load a LoRA adapter for fine-tuned model.""" + try: + from peft import PeftModel + + logger.info(f"Loading LoRA adapter from {adapter_path}") + self._model = PeftModel.from_pretrained(self._model, str(adapter_path)) + self._lora_adapter = str(adapter_path) + logger.info("LoRA adapter loaded successfully") + + except ImportError: + logger.warning("peft not installed, skipping LoRA adapter") + except Exception as e: + logger.warning(f"Failed to load LoRA adapter: {e}") + + async def extract_text( + self, + image_data: bytes, + detect_lines: bool = True + ) -> OCRResult: + """ + Extract handwritten text from an image. + + Args: + image_data: Raw image bytes (PNG, JPG, etc.) + detect_lines: If True, detect and process individual lines + + Returns: + OCRResult with extracted text and confidence + """ + import time + start_time = time.time() + + self._load_model() + + try: + from PIL import Image + import torch + + # Load image + image = Image.open(BytesIO(image_data)).convert("RGB") + + if detect_lines: + # Detect text lines and process each + lines, bboxes = await self._detect_and_extract_lines(image) + text = "\n".join(lines) + confidence = 0.85 # Average confidence estimate + else: + # Process whole image + text, confidence = await self._extract_single(image) + bboxes = [] + + processing_time_ms = int((time.time() - start_time) * 1000) + + return OCRResult( + text=text, + confidence=confidence, + bounding_boxes=bboxes, + processing_time_ms=processing_time_ms + ) + + except Exception as e: + logger.error(f"OCR extraction failed: {e}") + return OCRResult( + text="", + confidence=0.0, + bounding_boxes=[], + processing_time_ms=int((time.time() - start_time) * 1000) + ) + + async def _extract_single(self, image) -> Tuple[str, float]: + """Extract text from a single image (no line detection).""" + import torch + + # Preprocess + pixel_values = self._processor( + images=image, + return_tensors="pt" + ).pixel_values + + if self.device == "cuda": + pixel_values = pixel_values.cuda() + elif self.device == "mps": + pixel_values = pixel_values.to("mps") + + # Generate + with torch.no_grad(): + generated_ids = self._model.generate( + pixel_values, + max_length=128, + num_beams=4, + return_dict_in_generate=True, + output_scores=True + ) + + # Decode + text = self._processor.batch_decode( + generated_ids.sequences, + skip_special_tokens=True + )[0] + + # Estimate confidence from generation scores + confidence = self._estimate_confidence(generated_ids) + + return text.strip(), confidence + + async def _detect_and_extract_lines(self, image) -> Tuple[List[str], List[Dict]]: + """Detect text lines and extract each separately.""" + from PIL import Image + import numpy as np + + # Convert to numpy for line detection + img_array = np.array(image.convert("L")) # Grayscale + + # Simple horizontal projection for line detection + lines_y = self._detect_line_positions(img_array) + + if not lines_y: + # Fallback: process whole image + text, _ = await self._extract_single(image) + return [text], [] + + # Extract each line + results = [] + bboxes = [] + width = image.width + + for i, (y_start, y_end) in enumerate(lines_y): + # Crop line + line_img = image.crop((0, y_start, width, y_end)) + + # Ensure minimum height + if line_img.height < 20: + continue + + # Extract text + text, conf = await self._extract_single(line_img) + + if text.strip(): + results.append(text) + bboxes.append({ + "x": 0, + "y": y_start, + "w": width, + "h": y_end - y_start, + "text": text, + "confidence": conf + }) + + return results, bboxes + + def _detect_line_positions(self, img_array) -> List[Tuple[int, int]]: + """Detect horizontal text line positions using projection profile.""" + import numpy as np + + # Horizontal projection (sum of pixels per row) + projection = np.sum(255 - img_array, axis=1) + + # Threshold to find text rows + threshold = np.max(projection) * 0.1 + text_rows = projection > threshold + + # Find line boundaries + lines = [] + in_line = False + line_start = 0 + + for i, is_text in enumerate(text_rows): + if is_text and not in_line: + in_line = True + line_start = max(0, i - 5) # Add padding + elif not is_text and in_line: + in_line = False + line_end = min(len(text_rows) - 1, i + 5) # Add padding + if line_end - line_start > 15: # Minimum line height + lines.append((line_start, line_end)) + + # Handle last line + if in_line: + lines.append((line_start, len(text_rows) - 1)) + + return lines + + def _estimate_confidence(self, generated_output) -> float: + """Estimate confidence from generation scores.""" + try: + import torch + + if hasattr(generated_output, 'scores') and generated_output.scores: + # Average probability of selected tokens + probs = [] + for score in generated_output.scores: + prob = torch.softmax(score, dim=-1).max().item() + probs.append(prob) + return sum(probs) / len(probs) if probs else 0.5 + return 0.75 # Default confidence + except Exception: + return 0.75 + + async def batch_extract( + self, + images: List[bytes], + detect_lines: bool = True + ) -> List[OCRResult]: + """ + Extract text from multiple images. + + Args: + images: List of image bytes + detect_lines: If True, detect lines in each image + + Returns: + List of OCRResult + """ + results = [] + for img_data in images: + result = await self.extract_text(img_data, detect_lines) + results.append(result) + return results + + # ========================================== + # FINE-TUNING SUPPORT + # ========================================== + + def add_training_example( + self, + image_data: bytes, + ground_truth: str, + teacher_id: str + ) -> str: + """ + Add a training example for fine-tuning. + + Args: + image_data: Image bytes + ground_truth: Correct text (teacher-provided) + teacher_id: ID of the teacher providing correction + + Returns: + Example ID + """ + import uuid + from datetime import datetime + + example_id = str(uuid.uuid4()) + + # Save image + image_path = TRAINING_DATA_DIR / f"{example_id}.png" + with open(image_path, "wb") as f: + f.write(image_data) + + # Save metadata + example = TrainingExample( + image_path=str(image_path), + ground_truth=ground_truth, + teacher_id=teacher_id, + created_at=datetime.utcnow().isoformat() + ) + + meta_path = TRAINING_DATA_DIR / f"{example_id}.json" + with open(meta_path, "w") as f: + json.dump(example.__dict__, f, indent=2) + + logger.info(f"Training example added: {example_id}") + return example_id + + def get_training_examples(self, teacher_id: Optional[str] = None) -> List[TrainingExample]: + """Get all training examples, optionally filtered by teacher.""" + examples = [] + + for meta_file in TRAINING_DATA_DIR.glob("*.json"): + with open(meta_file) as f: + data = json.load(f) + example = TrainingExample(**data) + + if teacher_id is None or example.teacher_id == teacher_id: + examples.append(example) + + return examples + + async def fine_tune( + self, + teacher_id: Optional[str] = None, + epochs: int = 3, + learning_rate: float = 5e-5 + ) -> Dict: + """ + Fine-tune the model with collected training examples. + + Uses LoRA for efficient fine-tuning. + + Args: + teacher_id: If provided, only use examples from this teacher + epochs: Number of training epochs + learning_rate: Learning rate for fine-tuning + + Returns: + Training statistics + """ + examples = self.get_training_examples(teacher_id) + + if len(examples) < 10: + return { + "status": "error", + "message": f"Need at least 10 examples, have {len(examples)}" + } + + try: + from peft import LoraConfig, get_peft_model, TaskType + from transformers import Trainer, TrainingArguments + from PIL import Image + import torch + + self._load_model() + + logger.info(f"Starting fine-tuning with {len(examples)} examples") + + # Configure LoRA + lora_config = LoraConfig( + task_type=TaskType.SEQ_2_SEQ_LM, + r=16, # LoRA rank + lora_alpha=32, + lora_dropout=0.1, + target_modules=["q_proj", "v_proj"] # Attention layers + ) + + # Apply LoRA + model = get_peft_model(self._model, lora_config) + + # Prepare dataset + class OCRDataset(torch.utils.data.Dataset): + def __init__(self, examples, processor): + self.examples = examples + self.processor = processor + + def __len__(self): + return len(self.examples) + + def __getitem__(self, idx): + ex = self.examples[idx] + image = Image.open(ex.image_path).convert("RGB") + + pixel_values = self.processor( + images=image, return_tensors="pt" + ).pixel_values.squeeze() + + labels = self.processor.tokenizer( + ex.ground_truth, + return_tensors="pt", + padding="max_length", + max_length=128 + ).input_ids.squeeze() + + return { + "pixel_values": pixel_values, + "labels": labels + } + + dataset = OCRDataset(examples, self._processor) + + # Training arguments + output_dir = LORA_ADAPTERS_DIR / f"{self.model_name}_adapter" + training_args = TrainingArguments( + output_dir=str(output_dir), + num_train_epochs=epochs, + per_device_train_batch_size=4, + learning_rate=learning_rate, + save_strategy="epoch", + logging_steps=10, + remove_unused_columns=False, + ) + + # Train + trainer = Trainer( + model=model, + args=training_args, + train_dataset=dataset, + ) + + train_result = trainer.train() + + # Save adapter + model.save_pretrained(str(output_dir)) + + # Reload model with new adapter + self._model = None + self._load_model() + + return { + "status": "success", + "examples_used": len(examples), + "epochs": epochs, + "adapter_path": str(output_dir), + "train_loss": train_result.training_loss + } + + except ImportError as e: + logger.error(f"Missing dependencies for fine-tuning: {e}") + return { + "status": "error", + "message": f"Missing dependencies: {e}. Install with: pip install peft" + } + except Exception as e: + logger.error(f"Fine-tuning failed: {e}") + return { + "status": "error", + "message": str(e) + } + + def get_model_info(self) -> Dict: + """Get information about the loaded model.""" + adapter_path = LORA_ADAPTERS_DIR / f"{self.model_name}_adapter" + + return { + "model_name": self.model_name, + "model_id": self.model_id, + "device": self.device, + "is_loaded": self._model is not None, + "has_lora_adapter": adapter_path.exists(), + "lora_adapter_path": str(adapter_path) if adapter_path.exists() else None, + "training_examples_count": len(list(TRAINING_DATA_DIR.glob("*.json"))), + } + + +# Singleton instance +_trocr_service: Optional[TrOCRService] = None + + +def get_trocr_service(model_name: str = "trocr-base") -> TrOCRService: + """Get or create the TrOCR service singleton.""" + global _trocr_service + if _trocr_service is None or _trocr_service.model_name != model_name: + _trocr_service = TrOCRService(model_name=model_name) + return _trocr_service diff --git a/backend/klausur/services/vision_ocr_service.py b/backend/klausur/services/vision_ocr_service.py new file mode 100644 index 0000000..2c7d00f --- /dev/null +++ b/backend/klausur/services/vision_ocr_service.py @@ -0,0 +1,309 @@ +""" +Vision-OCR Service - Handschrifterkennung mit Llama 3.2 Vision. + +DATENSCHUTZ/PRIVACY BY DESIGN: +- Alle Verarbeitung erfolgt lokal auf dem Mac Mini +- Keine Daten verlassen das lokale Netzwerk +- Keine Cloud-APIs beteiligt +- Perfekt für DSGVO-konforme Schulumgebungen + +Verwendet llama3.2-vision:11b über Ollama für OCR/Handschrifterkennung. +Dies ist eine Alternative zu TrOCR mit besserer Handschrifterkennung. +""" +import os +import base64 +import httpx +import logging +import time +from typing import Optional +from dataclasses import dataclass + +from llm_gateway.config import get_config + +logger = logging.getLogger(__name__) + + +@dataclass +class VisionOCRResult: + """Result from Vision-LLM OCR extraction.""" + text: str + confidence: float + processing_time_ms: int + model: str = "llama3.2-vision:11b" + device: str = "local-ollama" + + +# OCR System Prompt für optimale Handschrifterkennung +HANDWRITING_OCR_PROMPT = """Du bist ein Experte für Handschrifterkennung (OCR). + +AUFGABE: Extrahiere den handschriftlichen Text aus dem Bild so genau wie möglich. + +WICHTIGE REGELN: +1. Transkribiere NUR den sichtbaren Text - erfinde nichts dazu +2. Behalte die Zeilenstruktur bei (jede Zeile auf einer neuen Zeile) +3. Bei unleserlichen Stellen: [unleserlich] oder [?] verwenden +4. Ignoriere Linien, Kästchen und andere Formatierungen +5. Korrigiere KEINE Rechtschreibfehler - transkribiere exakt was da steht +6. Bei Aufzählungen: Nummern/Punkte beibehalten (1., 2., a), b), etc.) + +AUSGABE: Nur der transkribierte Text, keine Erklärungen oder Kommentare.""" + +# Alternative Prompt für gedruckten Text +PRINTED_OCR_PROMPT = """Extrahiere den gesamten Text aus diesem Bild. +Behalte die Struktur bei (Absätze, Listen, etc.). +Gib nur den extrahierten Text zurück, ohne Kommentare.""" + + +class VisionOCRService: + """ + OCR Service mit Llama 3.2 Vision über Ollama. + + Läuft komplett lokal auf dem Mac Mini - keine Cloud-Verbindung nötig. + Ideal für datenschutzkonforme Klausurkorrektur in Schulen. + + Usage: + service = VisionOCRService() + + if await service.is_available(): + result = await service.extract_text(image_bytes) + print(result.text) + """ + + def __init__(self, ollama_url: Optional[str] = None, model: Optional[str] = None): + """ + Initialize Vision OCR Service. + + Args: + ollama_url: Ollama API URL (default: from config) + model: Vision model to use (default: llama3.2-vision:11b) + """ + config = get_config() + self.ollama_url = ollama_url or (config.ollama.base_url if config.ollama else "http://localhost:11434") + self.model = model or config.vision_model + self._client: Optional[httpx.AsyncClient] = None + + async def _get_client(self) -> httpx.AsyncClient: + """Get or create HTTP client.""" + if self._client is None or self._client.is_closed: + self._client = httpx.AsyncClient( + timeout=300.0 # 5 min timeout für große Bilder + ) + return self._client + + async def close(self): + """Close the HTTP client.""" + if self._client and not self._client.is_closed: + await self._client.aclose() + + async def is_available(self) -> bool: + """Check if Ollama with vision model is available.""" + try: + client = await self._get_client() + + # Check Ollama health + response = await client.get( + f"{self.ollama_url}/api/tags", + timeout=5.0 + ) + + if response.status_code != 200: + return False + + # Check if vision model is installed + data = response.json() + models = [m.get("name", "") for m in data.get("models", [])] + + # Check for any vision model + has_vision = any( + "vision" in m.lower() or "llava" in m.lower() + for m in models + ) + + if not has_vision: + logger.warning(f"No vision model found. Available: {models}") + return False + + return True + + except Exception as e: + logger.warning(f"Vision OCR service not available: {e}") + return False + + async def get_status(self) -> dict: + """Get service status.""" + try: + client = await self._get_client() + response = await client.get(f"{self.ollama_url}/api/tags") + + if response.status_code == 200: + data = response.json() + models = data.get("models", []) + vision_models = [ + m for m in models + if "vision" in m.get("name", "").lower() or "llava" in m.get("name", "").lower() + ] + + return { + "status": "available", + "ollama_url": self.ollama_url, + "configured_model": self.model, + "vision_models": [m.get("name") for m in vision_models], + "total_models": len(models) + } + else: + return { + "status": "unavailable", + "error": f"HTTP {response.status_code}" + } + + except Exception as e: + return { + "status": "unavailable", + "error": str(e) + } + + async def extract_text( + self, + image_data: bytes, + filename: str = "image.png", + is_handwriting: bool = True + ) -> VisionOCRResult: + """ + Extract text from an image using Vision LLM. + + Args: + image_data: Raw image bytes (PNG, JPG, etc.) + filename: Original filename (for logging) + is_handwriting: True for handwriting, False for printed text + + Returns: + VisionOCRResult with extracted text + """ + start_time = time.time() + + try: + client = await self._get_client() + + # Encode image as base64 + image_base64 = base64.b64encode(image_data).decode("utf-8") + + # Select appropriate prompt + prompt = HANDWRITING_OCR_PROMPT if is_handwriting else PRINTED_OCR_PROMPT + + # Ollama Vision API request + payload = { + "model": self.model, + "messages": [ + { + "role": "user", + "content": prompt, + "images": [image_base64] + } + ], + "stream": False, + "options": { + "temperature": 0.1, # Low temperature for consistent OCR + "num_predict": 2048, # Max tokens for extracted text + } + } + + logger.info(f"Sending image to Vision OCR: {filename} ({len(image_data)} bytes)") + + response = await client.post( + f"{self.ollama_url}/api/chat", + json=payload, + timeout=180.0 # 3 min timeout + ) + response.raise_for_status() + + data = response.json() + + extracted_text = data.get("message", {}).get("content", "") + + processing_time_ms = int((time.time() - start_time) * 1000) + + # Estimate confidence based on response quality + confidence = self._estimate_confidence(extracted_text) + + logger.info( + f"Vision OCR completed for {filename}: " + f"{len(extracted_text)} chars in {processing_time_ms}ms" + ) + + return VisionOCRResult( + text=extracted_text.strip(), + confidence=confidence, + processing_time_ms=processing_time_ms, + model=self.model, + device="local-ollama" + ) + + except httpx.TimeoutException: + logger.error(f"Vision OCR timed out for {filename}") + raise + except Exception as e: + logger.error(f"Vision OCR failed for {filename}: {e}") + raise + + def _estimate_confidence(self, text: str) -> float: + """ + Estimate OCR confidence based on text quality. + + This is a heuristic - real confidence would need model output. + """ + if not text: + return 0.0 + + # Count uncertain markers + uncertain_markers = text.count("[unleserlich]") + text.count("[?]") + + # Count reasonable text vs markers + text_length = len(text.replace("[unleserlich]", "").replace("[?]", "")) + + if text_length == 0: + return 0.1 + + # Base confidence + confidence = 0.85 + + # Reduce for uncertain markers + confidence -= min(uncertain_markers * 0.05, 0.3) + + # Very short text might be incomplete + if text_length < 20: + confidence -= 0.1 + + return max(confidence, 0.1) + + +# Singleton instance +_vision_ocr_service: Optional[VisionOCRService] = None + + +def get_vision_ocr_service() -> VisionOCRService: + """Get the Vision OCR service singleton.""" + global _vision_ocr_service + if _vision_ocr_service is None: + _vision_ocr_service = VisionOCRService() + return _vision_ocr_service + + +async def extract_handwriting( + image_data: bytes, + filename: str = "image.png" +) -> VisionOCRResult: + """ + Convenience function to extract handwriting from an image. + + Uses Llama 3.2 Vision locally via Ollama. + All processing happens on the local Mac Mini - DSGVO-konform. + + Args: + image_data: Raw image bytes + filename: Original filename + + Returns: + VisionOCRResult with extracted text + """ + service = get_vision_ocr_service() + return await service.extract_text(image_data, filename, is_handwriting=True) diff --git a/backend/klausur/tests/__init__.py b/backend/klausur/tests/__init__.py new file mode 100644 index 0000000..253b0a0 --- /dev/null +++ b/backend/klausur/tests/__init__.py @@ -0,0 +1,9 @@ +""" +Tests for Klausurkorrektur Module. + +Tests cover: +- Database models and repository +- Pseudonymization service +- API routes +- Privacy guarantees +""" diff --git a/backend/klausur/tests/test_magic_onboarding.py b/backend/klausur/tests/test_magic_onboarding.py new file mode 100644 index 0000000..b6a57f7 --- /dev/null +++ b/backend/klausur/tests/test_magic_onboarding.py @@ -0,0 +1,455 @@ +""" +Tests for Magic Onboarding functionality. + +Tests cover: +- OnboardingSession lifecycle +- Student detection and confirmation +- Roster parsing +- School resolution +- Module linking +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from datetime import datetime + +# Import models +from klausur.db_models import ( + OnboardingSession, DetectedStudent, ModuleLink, + OnboardingStatus, ModuleLinkType +) + +# Import services +from klausur.services.roster_parser import RosterParser, RosterEntry, NameMatch +from klausur.services.school_resolver import SchoolResolver, BUNDESLAENDER, SCHULFORMEN +from klausur.services.module_linker import ( + ModuleLinker, CorrectionResult, MeetingUrgency, ParentMeetingSuggestion +) + + +# ============================================================================= +# ROSTER PARSER TESTS +# ============================================================================= + +class TestRosterParser: + """Tests for RosterParser service.""" + + def test_match_first_names_exact_match(self): + """Test exact name matching.""" + parser = RosterParser() + + roster = [ + RosterEntry(first_name="Max", last_name="Mueller"), + RosterEntry(first_name="Anna", last_name="Schmidt"), + RosterEntry(first_name="Tim", last_name="Weber"), + ] + + detected = ["Max", "Anna", "Tim"] + matches = parser.match_first_names(detected, roster) + + # Check all names matched + assert len(matches) == 3 + + # Find Max match + max_match = next(m for m in matches if m.detected_name == "Max") + assert max_match.matched_entry is not None + assert max_match.matched_entry.last_name == "Mueller" + assert max_match.match_type == "exact" + assert max_match.confidence == 1.0 + + def test_match_first_names_fuzzy_match(self): + """Test fuzzy matching for similar names.""" + parser = RosterParser() + + roster = [ + RosterEntry(first_name="Maximilian", last_name="Mueller"), + RosterEntry(first_name="Anna-Lena", last_name="Schmidt"), + ] + + # "Max" should fuzzy-match "Maximilian" (starts with) + detected = ["Max"] + matches = parser.match_first_names(detected, roster) + + assert len(matches) == 1 + max_match = matches[0] + # Should match to Maximilian via first_name matching + if max_match.matched_entry is not None: + assert max_match.match_type in ["first_name", "fuzzy"] + + def test_match_first_names_no_match(self): + """Test handling of unmatched names.""" + parser = RosterParser() + + roster = [ + RosterEntry(first_name="Max", last_name="Mueller"), + ] + + detected = ["Sophie", "Lisa"] + matches = parser.match_first_names(detected, roster) + + # Both should be unmatched + assert len(matches) == 2 + for match in matches: + assert match.matched_entry is None + assert match.match_type == "none" + + def test_roster_entry_creation(self): + """Test RosterEntry dataclass creation.""" + entry = RosterEntry( + first_name="Max", + last_name="Mueller", + student_number="12345", + parent_email="eltern@example.com", + parent_phone="+49123456789" + ) + + assert entry.first_name == "Max" + assert entry.last_name == "Mueller" + assert entry.parent_email == "eltern@example.com" + + def test_name_match_dataclass(self): + """Test NameMatch dataclass creation.""" + entry = RosterEntry(first_name="Max", last_name="Mueller") + match = NameMatch( + detected_name="Max", + matched_entry=entry, + confidence=1.0, + match_type="exact" + ) + + assert match.detected_name == "Max" + assert match.matched_entry.last_name == "Mueller" + assert match.confidence == 1.0 + + +# ============================================================================= +# SCHOOL RESOLVER TESTS +# ============================================================================= + +class TestSchoolResolver: + """Tests for SchoolResolver service.""" + + def test_bundeslaender_completeness(self): + """Test that all 16 German states are included.""" + assert len(BUNDESLAENDER) == 16 + # BUNDESLAENDER is a dict with codes as keys + assert "NI" in BUNDESLAENDER # Niedersachsen + assert "BY" in BUNDESLAENDER # Bayern + assert "BE" in BUNDESLAENDER # Berlin + # Check values too + assert BUNDESLAENDER["NI"] == "Niedersachsen" + + def test_schulformen_have_grades(self): + """Test that each Schulform has grade ranges.""" + for schulform, info in SCHULFORMEN.items(): + assert "grades" in info + assert isinstance(info["grades"], list) + assert len(info["grades"]) > 0 + + def test_detect_grade_from_class_name(self): + """Test grade detection from class names.""" + resolver = SchoolResolver() + + # Test various formats + assert resolver.detect_grade_from_class_name("3a") == 3 + assert resolver.detect_grade_from_class_name("10b") == 10 + assert resolver.detect_grade_from_class_name("Q1") == 11 + assert resolver.detect_grade_from_class_name("Q2") == 12 + assert resolver.detect_grade_from_class_name("12") == 12 + + def test_detect_grade_returns_none_for_invalid(self): + """Test grade detection returns None for invalid input.""" + resolver = SchoolResolver() + + assert resolver.detect_grade_from_class_name("abc") is None + assert resolver.detect_grade_from_class_name("") is None + + def test_local_storage_initialization(self): + """Test that local storage starts empty.""" + resolver = SchoolResolver() + assert resolver._local_schools == {} + assert resolver._local_classes == {} + + +# ============================================================================= +# MODULE LINKER TESTS +# ============================================================================= + +class TestModuleLinker: + """Tests for ModuleLinker service.""" + + def test_suggest_elternabend_for_weak_students(self): + """Test parent meeting suggestions for failing grades.""" + linker = ModuleLinker() + + results = [ + CorrectionResult( + doc_token="token1", score=25, max_score=100, + grade="5", feedback="" + ), + CorrectionResult( + doc_token="token2", score=85, max_score=100, + grade="2", feedback="" + ), + CorrectionResult( + doc_token="token3", score=30, max_score=100, + grade="5-", feedback="" + ), + CorrectionResult( + doc_token="token4", score=20, max_score=100, + grade="6", feedback="" + ), + ] + + suggestions = linker.suggest_elternabend( + results, subject="Mathematik", threshold_grade="4" + ) + + # Should suggest meetings for students with grades 4 or worse + # Grades 5, 5-, and 6 should trigger meetings + assert len(suggestions) == 3 + + # Verify suggestions use doc_tokens (privacy) + for suggestion in suggestions: + assert suggestion.doc_token in ["token1", "token3", "token4"] + + def test_suggest_elternabend_empty_for_good_class(self): + """Test no suggestions for good performers.""" + linker = ModuleLinker() + + results = [ + CorrectionResult( + doc_token="token1", score=95, max_score=100, + grade="1", feedback="" + ), + CorrectionResult( + doc_token="token2", score=85, max_score=100, + grade="2", feedback="" + ), + CorrectionResult( + doc_token="token3", score=78, max_score=100, + grade="3", feedback="" + ), + ] + + suggestions = linker.suggest_elternabend( + results, subject="Deutsch", threshold_grade="4" + ) + + assert len(suggestions) == 0 + + def test_calculate_grade_statistics(self): + """Test grade distribution calculation.""" + linker = ModuleLinker() + + results = [ + CorrectionResult(doc_token="t1", score=95, max_score=100, grade="1", feedback=""), + CorrectionResult(doc_token="t2", score=85, max_score=100, grade="2", feedback=""), + CorrectionResult(doc_token="t3", score=85, max_score=100, grade="2", feedback=""), + CorrectionResult(doc_token="t4", score=75, max_score=100, grade="3", feedback=""), + CorrectionResult(doc_token="t5", score=55, max_score=100, grade="4", feedback=""), + CorrectionResult(doc_token="t6", score=25, max_score=100, grade="5", feedback=""), + ] + + stats = linker.calculate_grade_statistics(results) + + assert isinstance(stats, dict) + assert stats["count"] == 6 + + # Check grade distribution + assert stats["distribution"].get("1", 0) == 1 + assert stats["distribution"].get("2", 0) == 2 + assert stats["distribution"].get("3", 0) == 1 + + # Check passing/failing counts + assert stats["passing_count"] == 5 # Grades 1-4 pass + assert stats["failing_count"] == 1 # Grade 5 fails + + def test_calculate_statistics_empty_results(self): + """Test statistics with no results.""" + linker = ModuleLinker() + + stats = linker.calculate_grade_statistics([]) + + assert stats == {} + + def test_correction_result_creation(self): + """Test CorrectionResult dataclass.""" + result = CorrectionResult( + doc_token="abc-123", + score=87, + max_score=100, + grade="2+", + feedback="Gut geloest", + question_results=[{"aufgabe": 1, "punkte": 10}] + ) + + assert result.doc_token == "abc-123" + assert result.score == 87 + assert result.grade == "2+" + + +# ============================================================================= +# DB MODEL TESTS +# ============================================================================= + +class TestOnboardingModels: + """Tests for Magic Onboarding database models.""" + + def test_onboarding_status_enum_values(self): + """Test OnboardingStatus enum has all required values.""" + assert OnboardingStatus.ANALYZING.value == "analyzing" + assert OnboardingStatus.CONFIRMING.value == "confirming" + assert OnboardingStatus.PROCESSING.value == "processing" + assert OnboardingStatus.LINKING.value == "linking" + assert OnboardingStatus.COMPLETE.value == "complete" + + def test_module_link_type_enum_values(self): + """Test ModuleLinkType enum has all required values.""" + assert ModuleLinkType.NOTENBUCH.value == "notenbuch" + assert ModuleLinkType.ELTERNABEND.value == "elternabend" + assert ModuleLinkType.ZEUGNIS.value == "zeugnis" + assert ModuleLinkType.CALENDAR.value == "calendar" + assert ModuleLinkType.KLASSENBUCH.value == "klassenbuch" + + def test_onboarding_session_repr(self): + """Test OnboardingSession string representation.""" + session = OnboardingSession( + id="12345678-1234-1234-1234-123456789abc", + teacher_id="teacher-1", + detected_class="3a", + status=OnboardingStatus.ANALYZING + ) + + repr_str = repr(session) + assert "12345678" in repr_str + assert "3a" in repr_str + assert "analyzing" in repr_str + + def test_detected_student_repr(self): + """Test DetectedStudent string representation.""" + student = DetectedStudent( + id="12345678-1234-1234-1234-123456789abc", + detected_first_name="Max" + ) + + repr_str = repr(student) + assert "Max" in repr_str + + def test_module_link_repr(self): + """Test ModuleLink string representation.""" + link = ModuleLink( + id="12345678-1234-1234-1234-123456789abc", + klausur_session_id="session-1", + link_type=ModuleLinkType.NOTENBUCH, + target_module="school" + ) + + repr_str = repr(link) + assert "notenbuch" in repr_str + assert "school" in repr_str + + +# ============================================================================= +# PRIVACY TESTS +# ============================================================================= + +class TestPrivacyInMagicOnboarding: + """Tests ensuring privacy is maintained in Magic Onboarding.""" + + def test_detected_student_no_full_last_name_in_detection(self): + """Test that detection only captures hints, not full last names.""" + student = DetectedStudent( + id="12345678-1234-1234-1234-123456789abc", + detected_first_name="Max", + detected_last_name_hint="M." # Only initial/hint, not full name + ) + + # The detection phase should only have hints + assert student.detected_last_name_hint == "M." + # Full name is only set after teacher confirmation + assert student.confirmed_last_name is None + + def test_module_link_uses_doc_tokens_not_names(self): + """Test that module links use pseudonymized tokens.""" + linker = ModuleLinker() + + # Results should only contain doc_tokens, not student names + results = [ + CorrectionResult( + doc_token="uuid-token-1", score=45, max_score=100, + grade="4", feedback="" + ), + ] + + suggestions = linker.suggest_elternabend( + results, subject="Deutsch", threshold_grade="4" + ) + + # Suggestions reference doc_tokens, not names + for suggestion in suggestions: + assert hasattr(suggestion, 'doc_token') + # Verify doc_token is the pseudonymized one + assert suggestion.doc_token == "uuid-token-1" + + +# ============================================================================= +# INTEGRATION FLOW TESTS +# ============================================================================= + +class TestMagicOnboardingFlow: + """Tests for the complete Magic Onboarding flow.""" + + def test_onboarding_status_progression(self): + """Test that status progresses correctly through the flow.""" + statuses = list(OnboardingStatus) + + # Verify correct order + assert statuses[0] == OnboardingStatus.ANALYZING + assert statuses[1] == OnboardingStatus.CONFIRMING + assert statuses[2] == OnboardingStatus.PROCESSING + assert statuses[3] == OnboardingStatus.LINKING + assert statuses[4] == OnboardingStatus.COMPLETE + + def test_grade_conversion_german_scale(self): + """Test that German grading scale (1-6) is used correctly.""" + linker = ModuleLinker() + + # Test the internal grade checking + # Grades 1-4 are passing, 5-6 are failing + results = [ + CorrectionResult(doc_token="t1", score=95, max_score=100, grade="1", feedback=""), + CorrectionResult(doc_token="t2", score=80, max_score=100, grade="2", feedback=""), + CorrectionResult(doc_token="t3", score=65, max_score=100, grade="3", feedback=""), + CorrectionResult(doc_token="t4", score=50, max_score=100, grade="4", feedback=""), + CorrectionResult(doc_token="t5", score=30, max_score=100, grade="5", feedback=""), + CorrectionResult(doc_token="t6", score=15, max_score=100, grade="6", feedback=""), + ] + + stats = linker.calculate_grade_statistics(results) + + # 4 passing (grades 1-4), 2 failing (grades 5, 6) + assert stats["passing_count"] == 4 + assert stats["failing_count"] == 2 + + def test_meeting_urgency_levels(self): + """Test meeting urgency assignment based on grades.""" + linker = ModuleLinker() + + results = [ + CorrectionResult(doc_token="t1", score=55, max_score=100, grade="4", feedback=""), + CorrectionResult(doc_token="t2", score=30, max_score=100, grade="5", feedback=""), + CorrectionResult(doc_token="t3", score=15, max_score=100, grade="6", feedback=""), + ] + + suggestions = linker.suggest_elternabend( + results, subject="Mathe", threshold_grade="4" + ) + + # Verify urgency levels exist and are meaningful + urgencies = [s.urgency for s in suggestions] + assert len(urgencies) == 3 + + # Grade 6 should be high urgency + grade_6_suggestion = next(s for s in suggestions if s.grade == "6") + assert grade_6_suggestion.urgency == MeetingUrgency.HIGH diff --git a/backend/klausur/tests/test_pseudonymizer.py b/backend/klausur/tests/test_pseudonymizer.py new file mode 100644 index 0000000..9e80ee8 --- /dev/null +++ b/backend/klausur/tests/test_pseudonymizer.py @@ -0,0 +1,209 @@ +""" +Tests for PseudonymizationService. + +Verifies that: +- doc_tokens are cryptographically random +- QR codes are generated correctly +- Header redaction works as expected +- No personal data leaks through pseudonymization +""" +import pytest +import uuid +from unittest.mock import patch, MagicMock + +from klausur.services.pseudonymizer import ( + PseudonymizationService, + get_pseudonymizer, + RedactionResult, + QRDetectionResult, +) + + +class TestDocTokenGeneration: + """Tests for doc_token generation.""" + + def test_generate_doc_token_returns_valid_uuid(self): + """doc_token should be a valid UUID4.""" + service = PseudonymizationService() + token = service.generate_doc_token() + + # Should be a valid UUID + parsed = uuid.UUID(token) + assert parsed.version == 4 + + def test_generate_doc_token_is_unique(self): + """Each generated token should be unique.""" + service = PseudonymizationService() + tokens = [service.generate_doc_token() for _ in range(1000)] + + # All tokens should be unique + assert len(set(tokens)) == 1000 + + def test_generate_batch_tokens_correct_count(self): + """Batch generation should return correct number of tokens.""" + service = PseudonymizationService() + tokens = service.generate_batch_tokens(25) + + assert len(tokens) == 25 + assert len(set(tokens)) == 25 # All unique + + def test_token_no_correlation_to_index(self): + """Tokens should not correlate to their generation order.""" + service = PseudonymizationService() + + # Generate multiple batches + batch1 = service.generate_batch_tokens(10) + batch2 = service.generate_batch_tokens(10) + + # No overlap between batches + assert not set(batch1).intersection(set(batch2)) + + +class TestQRCodeGeneration: + """Tests for QR code generation.""" + + def test_generate_qr_code_returns_bytes(self): + """QR code generation should return PNG bytes.""" + service = PseudonymizationService() + token = service.generate_doc_token() + + try: + qr_bytes = service.generate_qr_code(token) + assert isinstance(qr_bytes, bytes) + # PNG magic bytes + assert qr_bytes[:8] == b'\x89PNG\r\n\x1a\n' + except RuntimeError: + pytest.skip("qrcode library not installed") + + def test_generate_qr_code_custom_size(self): + """QR code should respect custom size.""" + service = PseudonymizationService() + token = service.generate_doc_token() + + try: + # Generate with different sizes + small = service.generate_qr_code(token, size=100) + large = service.generate_qr_code(token, size=400) + + # Both should be valid PNG + assert small[:8] == b'\x89PNG\r\n\x1a\n' + assert large[:8] == b'\x89PNG\r\n\x1a\n' + + # Large should be bigger + assert len(large) > len(small) + except RuntimeError: + pytest.skip("qrcode library not installed") + + +class TestHeaderRedaction: + """Tests for header redaction.""" + + def test_redact_header_returns_redaction_result(self): + """Redaction should return proper RedactionResult.""" + service = PseudonymizationService() + + # Create a simple test image (1x1 white pixel PNG) + # This is a minimal valid PNG + test_png = ( + b'\x89PNG\r\n\x1a\n' # PNG signature + b'\x00\x00\x00\rIHDR' # IHDR chunk + b'\x00\x00\x00\x01' # Width: 1 + b'\x00\x00\x00\x01' # Height: 1 + b'\x08\x02' # Bit depth: 8, Color type: RGB + b'\x00\x00\x00' # Compression, Filter, Interlace + b'\x90wS\xde' # CRC + b'\x00\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05\x18\xd8N' # IDAT + b'\x00\x00\x00\x00IEND\xaeB`\x82' # IEND + ) + + result = service.redact_header(test_png) + + assert isinstance(result, RedactionResult) + assert isinstance(result.redacted_image, bytes) + + def test_redact_header_with_invalid_image_returns_original(self): + """Invalid images should return original bytes with redaction_applied=False.""" + service = PseudonymizationService() + + invalid_data = b'not an image' + result = service.redact_header(invalid_data) + + assert result.redacted_image == invalid_data + assert result.redaction_applied is False + + +class TestQRDetection: + """Tests for QR code detection.""" + + def test_detect_qr_code_no_qr_returns_none(self): + """Image without QR should return None token.""" + service = PseudonymizationService() + + # Empty/invalid image + result = service.detect_qr_code(b'not an image with qr') + + assert result.doc_token is None + assert result.confidence == 0.0 + + +class TestSingleton: + """Tests for singleton pattern.""" + + def test_get_pseudonymizer_returns_same_instance(self): + """Singleton should return same instance.""" + instance1 = get_pseudonymizer() + instance2 = get_pseudonymizer() + + assert instance1 is instance2 + + def test_pseudonymizer_is_service_instance(self): + """Singleton should be PseudonymizationService.""" + instance = get_pseudonymizer() + assert isinstance(instance, PseudonymizationService) + + +class TestPrivacyGuarantees: + """Tests verifying privacy guarantees.""" + + def test_token_cannot_be_reversed_to_name(self): + """Tokens should have no mathematical relationship to any input.""" + service = PseudonymizationService() + + # Generate tokens for "students" + student_names = ["Max Mustermann", "Anna Schmidt", "Tim Mueller"] + tokens = service.generate_batch_tokens(len(student_names)) + + # Tokens should not contain any part of names + for token in tokens: + for name in student_names: + assert name.lower() not in token.lower() + for part in name.split(): + assert part.lower() not in token.lower() + + def test_token_generation_is_not_deterministic(self): + """Same input should not produce same token.""" + service = PseudonymizationService() + + # Even with "same student count", tokens should differ + batch1 = service.generate_batch_tokens(5) + batch2 = service.generate_batch_tokens(5) + + # No tokens should match + assert not set(batch1).intersection(set(batch2)) + + def test_token_entropy(self): + """Tokens should have sufficient entropy.""" + service = PseudonymizationService() + tokens = service.generate_batch_tokens(100) + + # Each token should be 36 chars (UUID format: 8-4-4-4-12) + for token in tokens: + assert len(token) == 36 + assert token.count('-') == 4 + + # Check character distribution (rough entropy check) + all_chars = ''.join(t.replace('-', '') for t in tokens) + unique_chars = set(all_chars) + + # Should use all hex digits (0-9, a-f) + assert len(unique_chars) >= 10 diff --git a/backend/klausur/tests/test_repository.py b/backend/klausur/tests/test_repository.py new file mode 100644 index 0000000..b00d8bd --- /dev/null +++ b/backend/klausur/tests/test_repository.py @@ -0,0 +1,248 @@ +""" +Tests for KlausurRepository. + +Verifies: +- Teacher isolation (critical for privacy) +- CRUD operations +- Data retention cleanup +""" +import pytest +from datetime import datetime, timedelta +from unittest.mock import MagicMock, patch +from sqlalchemy.orm import Session + +from klausur.repository import KlausurRepository +from klausur.db_models import ( + ExamSession, PseudonymizedDocument, QRBatchJob, + SessionStatus, DocumentStatus +) + + +@pytest.fixture +def mock_db(): + """Create a mock database session.""" + return MagicMock(spec=Session) + + +@pytest.fixture +def repo(mock_db): + """Create a repository with mock DB.""" + return KlausurRepository(mock_db) + + +class TestTeacherIsolation: + """Tests for teacher namespace isolation (CRITICAL for privacy).""" + + def test_get_session_requires_teacher_id(self, repo, mock_db): + """Getting a session must require teacher_id.""" + # Setup mock + mock_query = MagicMock() + mock_db.query.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.first.return_value = None + + # Attempt to get session + result = repo.get_session("session-123", "teacher-A") + + # Verify filter was called (teacher isolation) + mock_db.query.assert_called_with(ExamSession) + mock_query.filter.assert_called() + + def test_list_sessions_only_returns_teacher_sessions(self, repo, mock_db): + """Listing sessions must filter by teacher_id.""" + mock_query = MagicMock() + mock_db.query.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.order_by.return_value = mock_query + mock_query.offset.return_value = mock_query + mock_query.limit.return_value = mock_query + mock_query.all.return_value = [] + + result = repo.list_sessions("teacher-A") + + # Verify query chain + mock_db.query.assert_called_with(ExamSession) + + def test_get_document_verifies_teacher_ownership(self, repo, mock_db): + """Getting a document must verify teacher owns the session.""" + mock_query = MagicMock() + mock_db.query.return_value = mock_query + mock_query.join.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.first.return_value = None + + result = repo.get_document("doc-token-123", "teacher-A") + + # Must join with ExamSession to verify teacher_id + mock_query.join.assert_called() + + def test_different_teachers_cannot_see_each_others_sessions(self, repo, mock_db): + """Teacher A cannot access Teacher B's sessions.""" + # Create mock session owned by teacher-B + session_b = MagicMock(spec=ExamSession) + session_b.teacher_id = "teacher-B" + session_b.id = "session-123" + + mock_query = MagicMock() + mock_db.query.return_value = mock_query + mock_query.filter.return_value = mock_query + # Return None because filter should exclude teacher-B's session + mock_query.first.return_value = None + + # Teacher A tries to access + result = repo.get_session("session-123", "teacher-A") + + assert result is None + + +class TestSessionOperations: + """Tests for session CRUD operations.""" + + def test_create_session_sets_teacher_id(self, repo, mock_db): + """Creating a session must set the teacher_id.""" + repo.create_session( + teacher_id="teacher-123", + name="Mathe Klausur", + subject="Mathematik" + ) + + # Verify session was added with teacher_id + mock_db.add.assert_called_once() + added_session = mock_db.add.call_args[0][0] + assert added_session.teacher_id == "teacher-123" + assert added_session.name == "Mathe Klausur" + + def test_create_session_sets_retention_date(self, repo, mock_db): + """Sessions must have a retention date for auto-deletion.""" + repo.create_session( + teacher_id="teacher-123", + name="Test", + retention_days=30 + ) + + added_session = mock_db.add.call_args[0][0] + assert added_session.retention_until is not None + + # Should be approximately 30 days in the future + expected = datetime.utcnow() + timedelta(days=30) + diff = abs((added_session.retention_until - expected).total_seconds()) + assert diff < 60 # Within 1 minute + + def test_delete_session_soft_delete_by_default(self, repo, mock_db): + """Deleting should soft-delete by default.""" + mock_session = MagicMock(spec=ExamSession) + mock_session.status = SessionStatus.CREATED + + mock_query = MagicMock() + mock_db.query.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.first.return_value = mock_session + + result = repo.delete_session("session-123", "teacher-A") + + # Should set status to DELETED, not actually delete + assert mock_session.status == SessionStatus.DELETED + mock_db.delete.assert_not_called() + + def test_delete_session_hard_delete_when_requested(self, repo, mock_db): + """Hard delete should actually delete the record.""" + mock_session = MagicMock(spec=ExamSession) + + mock_query = MagicMock() + mock_db.query.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.first.return_value = mock_session + + result = repo.delete_session("session-123", "teacher-A", hard_delete=True) + + mock_db.delete.assert_called_once_with(mock_session) + + +class TestDocumentOperations: + """Tests for document CRUD operations.""" + + def test_create_document_requires_valid_session(self, repo, mock_db): + """Creating a document requires a valid session owned by teacher.""" + # Session not found (wrong teacher or doesn't exist) + mock_query = MagicMock() + mock_db.query.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.first.return_value = None + + result = repo.create_document( + session_id="session-123", + teacher_id="teacher-A" + ) + + assert result is None + + def test_update_document_ocr_changes_status(self, repo, mock_db): + """Updating OCR results should update document status.""" + mock_doc = MagicMock(spec=PseudonymizedDocument) + mock_doc.status = DocumentStatus.UPLOADED + + # Mock get_document + with patch.object(repo, 'get_document', return_value=mock_doc): + result = repo.update_document_ocr( + doc_token="doc-123", + teacher_id="teacher-A", + ocr_text="Student answer text", + confidence=95 + ) + + assert mock_doc.ocr_text == "Student answer text" + assert mock_doc.ocr_confidence == 95 + assert mock_doc.status == DocumentStatus.OCR_COMPLETED + + +class TestDataRetention: + """Tests for data retention and cleanup.""" + + def test_cleanup_expired_sessions(self, repo, mock_db): + """Cleanup should mark expired sessions as deleted.""" + # Create expired session + expired_session = MagicMock(spec=ExamSession) + expired_session.retention_until = datetime.utcnow() - timedelta(days=1) + expired_session.status = SessionStatus.COMPLETED + expired_session.encrypted_identity_map = b"encrypted_data" + + mock_query = MagicMock() + mock_db.query.return_value = mock_query + mock_query.filter.return_value = mock_query + mock_query.all.return_value = [expired_session] + + count = repo.cleanup_expired_sessions() + + assert count == 1 + assert expired_session.status == SessionStatus.DELETED + # Identity map should be cleared + assert expired_session.encrypted_identity_map is None + + +class TestStatistics: + """Tests for anonymized statistics.""" + + def test_get_session_stats_returns_anonymized_data(self, repo, mock_db): + """Statistics should not contain any PII.""" + mock_session = MagicMock(spec=ExamSession) + mock_session.document_count = 25 + mock_session.processed_count = 20 + + mock_query = MagicMock() + mock_db.query.return_value = mock_query + mock_query.filter.return_value = mock_query + # first() is called twice: once for status counts and once for score stats + # Return a tuple for score_stats that can be subscripted + mock_query.first.return_value = (85.0, 60, 100) # avg, min, max scores + mock_query.group_by.return_value = mock_query + mock_query.all.return_value = [] + + with patch.object(repo, 'get_session', return_value=mock_session): + stats = repo.get_session_stats("session-123", "teacher-A") + + # Stats should contain only aggregate data, no PII + assert "session_id" in stats + assert "total_documents" in stats + # Should NOT contain student names or tokens + assert "student_names" not in stats + assert "doc_tokens" not in stats diff --git a/backend/klausur/tests/test_routes.py b/backend/klausur/tests/test_routes.py new file mode 100644 index 0000000..abe5d00 --- /dev/null +++ b/backend/klausur/tests/test_routes.py @@ -0,0 +1,346 @@ +""" +Tests for Klausur API Routes. + +Verifies: +- API endpoint behavior +- Request validation +- Response format +- Privacy guarantees at API level +""" +import pytest +from unittest.mock import MagicMock, patch, AsyncMock +from fastapi.testclient import TestClient +from fastapi import FastAPI + +from klausur.routes import router +from klausur.db_models import SessionStatus, DocumentStatus + + +@pytest.fixture +def app(): + """Create test FastAPI app.""" + app = FastAPI() + app.include_router(router, prefix="/api") + return app + + +@pytest.fixture +def client(app): + """Create test client.""" + return TestClient(app) + + +class TestSessionEndpoints: + """Tests for session-related endpoints.""" + + @patch('klausur.routes.KlausurRepository') + @patch('klausur.routes.get_db') + def test_create_session_returns_201(self, mock_get_db, mock_repo_class, client): + """Creating a session should return 201.""" + # Setup mocks + mock_db = MagicMock() + mock_get_db.return_value = iter([mock_db]) + + mock_repo = MagicMock() + mock_repo_class.return_value = mock_repo + + mock_session = MagicMock() + mock_session.id = "session-123" + mock_session.name = "Test Klausur" + mock_session.subject = "Mathe" + mock_session.class_name = "10a" + mock_session.total_points = 100 + mock_session.status = SessionStatus.CREATED + mock_session.document_count = 0 + mock_session.processed_count = 0 + mock_session.created_at = "2024-01-15T10:00:00" + mock_session.completed_at = None + mock_session.retention_until = "2024-02-15T10:00:00" + + mock_repo.create_session.return_value = mock_session + + response = client.post("/api/klausur/sessions", json={ + "name": "Test Klausur", + "subject": "Mathe", + "class_name": "10a" + }) + + assert response.status_code == 201 + data = response.json() + assert data["name"] == "Test Klausur" + assert data["status"] == "created" + + @patch('klausur.routes.KlausurRepository') + @patch('klausur.routes.get_db') + def test_create_session_validates_name(self, mock_get_db, mock_repo_class, client): + """Session name is required and must not be empty.""" + response = client.post("/api/klausur/sessions", json={ + "name": "", # Empty name + "subject": "Mathe" + }) + + assert response.status_code == 422 # Validation error + + @patch('klausur.routes.KlausurRepository') + @patch('klausur.routes.get_db') + def test_list_sessions_returns_array(self, mock_get_db, mock_repo_class, client): + """Listing sessions should return an array.""" + mock_db = MagicMock() + mock_get_db.return_value = iter([mock_db]) + + mock_repo = MagicMock() + mock_repo_class.return_value = mock_repo + mock_repo.list_sessions.return_value = [] + + response = client.get("/api/klausur/sessions") + + assert response.status_code == 200 + data = response.json() + assert "sessions" in data + assert isinstance(data["sessions"], list) + + @patch('klausur.routes.KlausurRepository') + @patch('klausur.routes.get_db') + def test_get_session_404_when_not_found(self, mock_get_db, mock_repo_class, client): + """Getting non-existent session should return 404.""" + mock_db = MagicMock() + mock_get_db.return_value = iter([mock_db]) + + mock_repo = MagicMock() + mock_repo_class.return_value = mock_repo + mock_repo.get_session.return_value = None + + response = client.get("/api/klausur/sessions/nonexistent-123") + + assert response.status_code == 404 + + +class TestQREndpoints: + """Tests for QR code generation endpoints.""" + + @patch('klausur.routes.KlausurRepository') + @patch('klausur.routes.get_pseudonymizer') + @patch('klausur.routes.get_db') + def test_generate_qr_batch_creates_tokens( + self, mock_get_db, mock_get_pseudonymizer, mock_repo_class, client + ): + """QR batch generation should create correct number of tokens.""" + mock_db = MagicMock() + mock_get_db.return_value = iter([mock_db]) + + mock_repo = MagicMock() + mock_repo_class.return_value = mock_repo + + mock_session = MagicMock() + mock_repo.get_session.return_value = mock_session + + mock_batch = MagicMock() + mock_batch.id = "batch-123" + mock_batch.student_count = 5 + mock_repo.create_qr_batch.return_value = mock_batch + + mock_pseudonymizer = MagicMock() + mock_pseudonymizer.generate_batch_tokens.return_value = [ + "token-1", "token-2", "token-3", "token-4", "token-5" + ] + mock_get_pseudonymizer.return_value = mock_pseudonymizer + + response = client.post("/api/klausur/sessions/session-123/qr-batch", json={ + "student_count": 5 + }) + + assert response.status_code == 200 + data = response.json() + assert len(data["generated_tokens"]) == 5 + + @patch('klausur.routes.KlausurRepository') + @patch('klausur.routes.get_db') + def test_qr_batch_validates_student_count(self, mock_get_db, mock_repo_class, client): + """Student count must be within valid range.""" + # Too many students + response = client.post("/api/klausur/sessions/session-123/qr-batch", json={ + "student_count": 200 # Max is 100 + }) + + assert response.status_code == 422 + + +class TestUploadEndpoints: + """Tests for document upload endpoints.""" + + @patch('klausur.routes.KlausurRepository') + @patch('klausur.routes.get_pseudonymizer') + @patch('klausur.routes.get_db') + def test_upload_applies_redaction_by_default( + self, mock_get_db, mock_get_pseudonymizer, mock_repo_class, client + ): + """Upload should apply header redaction by default.""" + mock_db = MagicMock() + mock_get_db.return_value = iter([mock_db]) + + mock_repo = MagicMock() + mock_repo_class.return_value = mock_repo + + mock_session = MagicMock() + mock_repo.get_session.return_value = mock_session + + mock_doc = MagicMock() + mock_doc.doc_token = "doc-token-123" + mock_doc.session_id = "session-123" + mock_doc.status = DocumentStatus.UPLOADED + mock_doc.page_number = 1 + mock_doc.total_pages = 1 + mock_doc.ocr_confidence = 0 + mock_doc.ai_score = None + mock_doc.ai_grade = None + mock_doc.ai_feedback = None + mock_doc.created_at = "2024-01-15T10:00:00" + mock_doc.processing_completed_at = None + mock_repo.create_document.return_value = mock_doc + + mock_pseudonymizer = MagicMock() + mock_pseudonymizer.detect_qr_code.return_value = MagicMock(doc_token=None) + mock_pseudonymizer.generate_doc_token.return_value = "doc-token-123" + mock_pseudonymizer.smart_redact_header.return_value = MagicMock( + redaction_applied=True, + redacted_image=b"redacted", + redacted_height=300 + ) + mock_get_pseudonymizer.return_value = mock_pseudonymizer + + # Create a minimal file upload + response = client.post( + "/api/klausur/sessions/session-123/upload", + files={"file": ("test.png", b"fake image data", "image/png")} + ) + + # Verify redaction was called + mock_pseudonymizer.smart_redact_header.assert_called_once() + + +class TestResultsEndpoints: + """Tests for results endpoints.""" + + @patch('klausur.routes.KlausurRepository') + @patch('klausur.routes.get_db') + def test_results_only_return_pseudonymized_data( + self, mock_get_db, mock_repo_class, client + ): + """Results should only contain doc_tokens, not names.""" + mock_db = MagicMock() + mock_get_db.return_value = iter([mock_db]) + + mock_repo = MagicMock() + mock_repo_class.return_value = mock_repo + + mock_session = MagicMock() + mock_session.total_points = 100 + mock_repo.get_session.return_value = mock_session + + mock_doc = MagicMock() + mock_doc.doc_token = "anonymous-token-123" + mock_doc.status = DocumentStatus.COMPLETED + mock_doc.ai_score = 85 + mock_doc.ai_grade = "2+" + mock_doc.ai_feedback = "Good work" + mock_doc.ai_details = {} + mock_repo.list_documents.return_value = [mock_doc] + + response = client.get("/api/klausur/sessions/session-123/results") + + assert response.status_code == 200 + data = response.json() + + # Results should use doc_token, not student name + assert len(data) == 1 + assert "doc_token" in data[0] + assert "student_name" not in data[0] + assert "name" not in data[0] + + +class TestIdentityMapEndpoints: + """Tests for identity map (vault) endpoints.""" + + @patch('klausur.routes.KlausurRepository') + @patch('klausur.routes.get_db') + def test_store_identity_map_accepts_encrypted_data( + self, mock_get_db, mock_repo_class, client + ): + """Identity map endpoint should accept encrypted data.""" + mock_db = MagicMock() + mock_get_db.return_value = iter([mock_db]) + + mock_repo = MagicMock() + mock_repo_class.return_value = mock_repo + + mock_session = MagicMock() + mock_repo.update_session_identity_map.return_value = mock_session + + # Base64 encoded "encrypted" data + import base64 + encrypted = base64.b64encode(b"encrypted identity map").decode() + + response = client.post("/api/klausur/sessions/session-123/identity-map", json={ + "encrypted_data": encrypted, + "iv": "base64iv==" + }) + + assert response.status_code == 204 + + @patch('klausur.routes.KlausurRepository') + @patch('klausur.routes.get_db') + def test_get_identity_map_returns_encrypted_blob( + self, mock_get_db, mock_repo_class, client + ): + """Getting identity map should return encrypted blob.""" + mock_db = MagicMock() + mock_get_db.return_value = iter([mock_db]) + + mock_repo = MagicMock() + mock_repo_class.return_value = mock_repo + + mock_session = MagicMock() + mock_session.encrypted_identity_map = b"encrypted data" + mock_session.identity_map_iv = "ivvalue" + mock_repo.get_session.return_value = mock_session + + response = client.get("/api/klausur/sessions/session-123/identity-map") + + assert response.status_code == 200 + data = response.json() + assert "encrypted_data" in data + assert "iv" in data + + +class TestPrivacyAtAPILevel: + """Tests verifying privacy guarantees at API level.""" + + def test_no_student_names_in_any_response_schema(self): + """Verify response schemas don't include student names.""" + from klausur.routes import ( + SessionResponse, DocumentResponse, CorrectionResultResponse + ) + + # Check all response model fields + session_fields = SessionResponse.model_fields.keys() + doc_fields = DocumentResponse.model_fields.keys() + result_fields = CorrectionResultResponse.model_fields.keys() + + all_fields = list(session_fields) + list(doc_fields) + list(result_fields) + + # Should not contain student-name-related fields + # Note: "name" alone is allowed (e.g., session/exam name like "Mathe Klausur") + forbidden = ["student_name", "schueler_name", "student", "pupil", "schueler"] + for field in all_fields: + assert field.lower() not in forbidden, f"Field '{field}' may contain PII" + + def test_identity_map_request_requires_encryption(self): + """Identity map must be encrypted before storage.""" + from klausur.routes import IdentityMapUpdate + + # Check that schema requires encrypted_data, not plain names + fields = IdentityMapUpdate.model_fields.keys() + + assert "encrypted_data" in fields + assert "names" not in fields + assert "student_names" not in fields diff --git a/backend/klausur_korrektur_api.py b/backend/klausur_korrektur_api.py new file mode 100644 index 0000000..3ce34b5 --- /dev/null +++ b/backend/klausur_korrektur_api.py @@ -0,0 +1,1859 @@ +""" +Klausur-Korrektur API - REST API für Abitur-Klausur-Korrektur. + +Zwei Modi: +- Modus A: Landes-Abitur Niedersachsen (NiBiS-Aufgaben, rechtlich geklärt) +- Modus B: Vorabitur (Lehrer-erstellte Klausuren mit Rights-Gate) + +Features: +- Multi-Kriterien-Bewertung (Rechtschreibung, Grammatik, Inhalt, Struktur, Stil) +- Rights-Gate für Textquellen-Verifikation +- KI-generierter Erwartungshorizont +- Gutachten-Generierung +- 15-Punkte-Notensystem (Abitur) +- Erst-/Zweitprüfer-Workflow +- Fairness-Vergleich +""" + +import logging +import uuid +import os +from datetime import datetime +from typing import List, Dict, Any, Optional +from enum import Enum +from pathlib import Path +from dataclasses import dataclass, field, asdict + +from fastapi import APIRouter, HTTPException, UploadFile, File, Form, BackgroundTasks +from fastapi.responses import Response, FileResponse +from pydantic import BaseModel, Field + +# FileProcessor requires OpenCV with libGL - make optional for CI +try: + from services.file_processor import FileProcessor + _ocr_available = True +except (ImportError, OSError): + FileProcessor = None # type: ignore + _ocr_available = False + +# PDF service requires WeasyPrint with system libraries - make optional for CI +try: + from services.pdf_service import PDFService + _pdf_available = True +except (ImportError, OSError): + PDFService = None # type: ignore + _pdf_available = False + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/klausur-korrektur", + tags=["klausur-korrektur"], +) + +# Upload directory +UPLOAD_DIR = Path("/tmp/klausur-korrektur") +UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + + +# ============================================================================ +# Enums +# ============================================================================ + +class KlausurModus(str, Enum): + """Klausur-Modus.""" + LANDES_ABITUR = "landes_abitur" # NiBiS-Aufgaben + VORABITUR = "vorabitur" # Lehrer-erstellt + + +class TextSourceType(str, Enum): + """Art der Textquelle.""" + NIBIS = "nibis" # Offizielle NiBiS-Aufgabe + EIGENTEXT = "eigentext" # Eigener Text + FREMDTEXT = "fremdtext" # Fremder Text (erfordert Rights-Gate) + + +class TextSourceStatus(str, Enum): + """Status der Textquellen-Verifikation.""" + PENDING = "pending" + VERIFIED = "verified" + REJECTED = "rejected" + + +class StudentKlausurStatus(str, Enum): + """Status einer Schüler-Klausur.""" + UPLOADED = "uploaded" + OCR_PROCESSING = "ocr_processing" + OCR_COMPLETE = "ocr_complete" + ANALYZING = "analyzing" + FIRST_EXAMINER = "first_examiner" + SECOND_EXAMINER = "second_examiner" + COMPLETED = "completed" + ERROR = "error" + + +class KlausurStatus(str, Enum): + """Status einer Klausur.""" + DRAFT = "draft" + TEXT_SOURCE = "text_source" + RIGHTS_GATE = "rights_gate" + ERWARTUNGSHORIZONT = "erwartungshorizont" + COLLECTING = "collecting" + CORRECTING = "correcting" + COMPLETED = "completed" + + +class Anforderungsbereich(int, Enum): + """Anforderungsbereich nach KMK.""" + I = 1 # Reproduktion + II = 2 # Reorganisation und Transfer + III = 3 # Reflexion und Problemlösung + + +# ============================================================================ +# 15-Punkte-Notenschlüssel +# ============================================================================ + +GRADE_THRESHOLDS = { + 15: 95, # 1+ + 14: 90, # 1 + 13: 85, # 1- + 12: 80, # 2+ + 11: 75, # 2 + 10: 70, # 2- + 9: 65, # 3+ + 8: 60, # 3 + 7: 55, # 3- + 6: 50, # 4+ + 5: 45, # 4 + 4: 40, # 4- + 3: 33, # 5+ + 2: 27, # 5 + 1: 20, # 5- + 0: 0, # 6 +} + +GRADE_LABELS = { + 15: "1+ (sehr gut plus)", + 14: "1 (sehr gut)", + 13: "1- (sehr gut minus)", + 12: "2+ (gut plus)", + 11: "2 (gut)", + 10: "2- (gut minus)", + 9: "3+ (befriedigend plus)", + 8: "3 (befriedigend)", + 7: "3- (befriedigend minus)", + 6: "4+ (ausreichend plus)", + 5: "4 (ausreichend)", + 4: "4- (ausreichend minus)", + 3: "5+ (mangelhaft plus)", + 2: "5 (mangelhaft)", + 1: "5- (mangelhaft minus)", + 0: "6 (ungenügend)", +} + +# Standard-Bewertungskriterien +DEFAULT_CRITERIA = { + "rechtschreibung": {"weight": 0.15, "max": 100, "label": "Rechtschreibung"}, + "grammatik": {"weight": 0.15, "max": 100, "label": "Grammatik"}, + "inhalt": {"weight": 0.40, "max": 100, "label": "Inhalt"}, + "struktur": {"weight": 0.15, "max": 100, "label": "Struktur"}, + "stil": {"weight": 0.15, "max": 100, "label": "Stil"}, +} + +# Operatoren nach Niedersachsen KC +OPERATORS = { + "I": [ + {"name": "nennen", "description": "Informationen ohne Erläuterung wiedergeben"}, + {"name": "beschreiben", "description": "Sachverhalte in eigenen Worten wiedergeben"}, + {"name": "wiedergeben", "description": "Inhalte in eigenen Worten darstellen"}, + {"name": "zusammenfassen", "description": "Wesentliche Aspekte komprimiert darstellen"}, + ], + "II": [ + {"name": "analysieren", "description": "Materialien systematisch untersuchen"}, + {"name": "erklären", "description": "Sachverhalte verständlich machen"}, + {"name": "erläutern", "description": "Sachverhalte mit Beispielen veranschaulichen"}, + {"name": "vergleichen", "description": "Gemeinsamkeiten und Unterschiede herausarbeiten"}, + {"name": "einordnen", "description": "In einen Zusammenhang stellen"}, + {"name": "charakterisieren", "description": "Wesenszüge herausarbeiten"}, + ], + "III": [ + {"name": "beurteilen", "description": "Aussagen an Kriterien messen und ein Urteil fällen"}, + {"name": "bewerten", "description": "Eine eigene Position mit Begründung einnehmen"}, + {"name": "erörtern", "description": "Ein Thema multiperspektivisch diskutieren"}, + {"name": "Stellung nehmen", "description": "Eigene Position argumentativ vertreten"}, + {"name": "gestalten", "description": "Kreativ-produktive Texte erstellen"}, + {"name": "entwerfen", "description": "Konzepte entwickeln"}, + ], +} + + +# ============================================================================ +# Pydantic Models für API Requests/Responses +# ============================================================================ + +class TextSourceCreate(BaseModel): + """Request zum Erstellen einer Textquelle.""" + source_type: TextSourceType + title: str + author: str = "" + content: str + nibis_id: Optional[str] = None # Für NiBiS-Aufgaben + license_info: Optional[Dict[str, Any]] = None + + +class TextSourceResponse(BaseModel): + """Response für eine Textquelle.""" + id: str + source_type: TextSourceType + title: str + author: str + content: str + nibis_id: Optional[str] + license_status: TextSourceStatus + license_info: Optional[Dict[str, Any]] + created_at: datetime + + +class AufgabeCreate(BaseModel): + """Request zum Erstellen einer Aufgabe.""" + nummer: str + text: str + operator: str + anforderungsbereich: int = Field(ge=1, le=3) + erwartete_leistungen: List[str] = [] + punkte: int = Field(ge=0) + + +class AufgabeResponse(BaseModel): + """Response für eine Aufgabe.""" + id: str + nummer: str + text: str + operator: str + anforderungsbereich: int + erwartete_leistungen: List[str] + punkte: int + + +class ErwartungshorizontCreate(BaseModel): + """Request zum Erstellen/Aktualisieren eines Erwartungshorizonts.""" + aufgaben: List[AufgabeCreate] + max_points: int = Field(ge=0) + hinweise: str = "" + + +class ErwartungshorizontResponse(BaseModel): + """Response für einen Erwartungshorizont.""" + id: str + aufgaben: List[AufgabeResponse] + max_points: int + hinweise: str + generated: bool + created_at: datetime + + +class CriterionScoreUpdate(BaseModel): + """Update für einen Kriterien-Score.""" + score: int = Field(ge=0, le=100) + annotations: List[str] = [] + comment: str = "" + + +class AnnotationCreate(BaseModel): + """Request zum Erstellen einer Annotation.""" + page: int + x: float + y: float + width: float + height: float + text: str + type: str = "comment" # comment, correction, highlight + color: str = "#FF0000" + + +class AnnotationResponse(BaseModel): + """Response für eine Annotation.""" + id: str + page: int + x: float + y: float + width: float + height: float + text: str + type: str + color: str + created_at: datetime + + +class GutachtenCreate(BaseModel): + """Request zum Erstellen/Aktualisieren eines Gutachtens.""" + einleitung: str + hauptteil: str + fazit: str + staerken: List[str] = [] + schwaechen: List[str] = [] + + +class GutachtenResponse(BaseModel): + """Response für ein Gutachten.""" + id: str + einleitung: str + hauptteil: str + fazit: str + staerken: List[str] + schwaechen: List[str] + generated: bool + edited: bool + created_at: datetime + + +class ExaminerResultCreate(BaseModel): + """Request für Prüfer-Ergebnis.""" + examiner_id: str + examiner_name: str + grade_points: int = Field(ge=0, le=15) + comment: str = "" + + +class ExaminerResultResponse(BaseModel): + """Response für Prüfer-Ergebnis.""" + examiner_id: str + examiner_name: str + grade_points: int + comment: str + submitted_at: datetime + + +class StudentKlausurCreate(BaseModel): + """Request zum Erstellen einer Schüler-Klausur.""" + student_name: str + student_id: Optional[str] = None + + +class StudentKlausurResponse(BaseModel): + """Response für eine Schüler-Klausur.""" + id: str + student_name: str + student_id: Optional[str] + file_path: Optional[str] + file_name: Optional[str] + ocr_text: Optional[str] + status: StudentKlausurStatus + criteria_scores: Dict[str, Dict[str, Any]] + annotations: List[AnnotationResponse] + gutachten: Optional[GutachtenResponse] + raw_points: int + grade_points: int + grade_label: str + first_examiner: Optional[ExaminerResultResponse] + second_examiner: Optional[ExaminerResultResponse] + created_at: datetime + updated_at: datetime + + +class KlausurCreate(BaseModel): + """Request zum Erstellen einer Klausur.""" + title: str + subject: str + modus: KlausurModus + year: int = Field(ge=2020, le=2100) + semester: str # Q1, Q2, Q3, Q4 + kurs: str = "" # z.B. "Deutsch LK", "Englisch GK" + class_id: Optional[str] = None # ID der zugeordneten Klasse + + +class KlausurUpdate(BaseModel): + """Request zum Aktualisieren einer Klausur.""" + title: Optional[str] = None + subject: Optional[str] = None + kurs: Optional[str] = None + status: Optional[KlausurStatus] = None + + +class KlausurResponse(BaseModel): + """Response für eine Klausur.""" + id: str + title: str + subject: str + modus: KlausurModus + year: int + semester: str + kurs: str + class_id: Optional[str] + status: KlausurStatus + text_sources: List[TextSourceResponse] + erwartungshorizont: Optional[ErwartungshorizontResponse] + student_count: int + completed_count: int + average_grade: Optional[float] + created_at: datetime + updated_at: datetime + + +class NiBiSAufgabe(BaseModel): + """NiBiS-Aufgabe aus dem Katalog.""" + id: str + year: int + subject: str + title: str + type: str # "schriftlich", "mündlich" + text_preview: str + download_url: Optional[str] + + +class FairnessReport(BaseModel): + """Fairness-Bericht für eine Klausur.""" + klausur_id: str + total_students: int + average_grade: float + grade_distribution: Dict[int, int] + criteria_averages: Dict[str, float] + outliers: List[Dict[str, Any]] + recommendations: List[str] + + +# ============================================================================ +# Internal Data Classes (In-Memory Storage) +# ============================================================================ + +@dataclass +class TextSource: + """Textquelle für Klausur.""" + id: str + source_type: TextSourceType + title: str + author: str + content: str + nibis_id: Optional[str] + license_status: TextSourceStatus + license_info: Optional[Dict[str, Any]] + created_at: datetime + + +@dataclass +class Aufgabe: + """Aufgabe im Erwartungshorizont.""" + id: str + nummer: str + text: str + operator: str + anforderungsbereich: int + erwartete_leistungen: List[str] + punkte: int + + +@dataclass +class Erwartungshorizont: + """Erwartungshorizont für Klausur.""" + id: str + aufgaben: List[Aufgabe] + max_points: int + hinweise: str + generated: bool + created_at: datetime + + +@dataclass +class Annotation: + """Overlay-Annotation auf Dokument.""" + id: str + page: int + x: float + y: float + width: float + height: float + text: str + type: str + color: str + created_at: datetime + + +@dataclass +class Gutachten: + """Gutachten für Schüler-Klausur.""" + id: str + einleitung: str + hauptteil: str + fazit: str + staerken: List[str] + schwaechen: List[str] + generated: bool + edited: bool + created_at: datetime + + +@dataclass +class ExaminerResult: + """Prüfer-Ergebnis.""" + examiner_id: str + examiner_name: str + grade_points: int + comment: str + submitted_at: datetime + + +@dataclass +class CriterionScore: + """Bewertung für ein Kriterium.""" + score: int + weight: float + annotations: List[str] + comment: str + ai_suggestions: List[str] + + +@dataclass +class StudentKlausur: + """Schüler-Klausur.""" + id: str + student_name: str + student_id: Optional[str] + file_path: Optional[str] + file_name: Optional[str] + ocr_text: Optional[str] + status: StudentKlausurStatus + criteria_scores: Dict[str, CriterionScore] + annotations: List[Annotation] + gutachten: Optional[Gutachten] + raw_points: int + grade_points: int + first_examiner: Optional[ExaminerResult] + second_examiner: Optional[ExaminerResult] + created_at: datetime + updated_at: datetime + + +@dataclass +class AbiturKlausur: + """Abitur-Klausur.""" + id: str + title: str + subject: str + modus: KlausurModus + year: int + semester: str + kurs: str + class_id: Optional[str] # ID der zugeordneten Klasse + status: KlausurStatus + text_sources: List[TextSource] + erwartungshorizont: Optional[Erwartungshorizont] + students: List[StudentKlausur] + created_at: datetime + updated_at: datetime + + +# ============================================================================ +# In-Memory Storage +# ============================================================================ + +_klausuren: Dict[str, AbiturKlausur] = {} + +# NiBiS-Katalog (Demo-Daten) +_nibis_katalog: Dict[str, NiBiSAufgabe] = { + "nibis-2024-deutsch-01": NiBiSAufgabe( + id="nibis-2024-deutsch-01", + year=2024, + subject="Deutsch", + title="Abitur 2024 - Aufgabenstellung I (Lyrik)", + type="schriftlich", + text_preview="Analysieren Sie das Gedicht von Else Lasker-Schüler...", + download_url=None + ), + "nibis-2024-deutsch-02": NiBiSAufgabe( + id="nibis-2024-deutsch-02", + year=2024, + subject="Deutsch", + title="Abitur 2024 - Aufgabenstellung II (Drama)", + type="schriftlich", + text_preview="Analysieren Sie den Szenenausschnitt aus 'Faust I'...", + download_url=None + ), + "nibis-2024-deutsch-03": NiBiSAufgabe( + id="nibis-2024-deutsch-03", + year=2024, + subject="Deutsch", + title="Abitur 2024 - Aufgabenstellung III (Epik)", + type="schriftlich", + text_preview="Analysieren Sie den Romanausschnitt aus 'Der Prozess'...", + download_url=None + ), + "nibis-2023-deutsch-01": NiBiSAufgabe( + id="nibis-2023-deutsch-01", + year=2023, + subject="Deutsch", + title="Abitur 2023 - Aufgabenstellung I (Lyrik)", + type="schriftlich", + text_preview="Analysieren Sie das Gedicht 'Mondnacht' von Eichendorff...", + download_url=None + ), + "nibis-2024-englisch-01": NiBiSAufgabe( + id="nibis-2024-englisch-01", + year=2024, + subject="Englisch", + title="Abitur 2024 - Reading Comprehension", + type="schriftlich", + text_preview="Read the following article about climate change...", + download_url=None + ), +} + + +# ============================================================================ +# Helper Functions +# ============================================================================ + +def calculate_15_point_grade(percentage: float) -> int: + """Berechnet 15-Punkte-Note aus Prozent.""" + for points, threshold in sorted(GRADE_THRESHOLDS.items(), reverse=True): + if percentage >= threshold: + return points + return 0 + + +def calculate_raw_points(criteria_scores: Dict[str, CriterionScore], max_points: int) -> int: + """Berechnet Rohpunkte aus Kriterien-Scores.""" + if not criteria_scores: + return 0 + + weighted_sum = 0.0 + total_weight = 0.0 + + for criterion, score in criteria_scores.items(): + weighted_sum += score.score * score.weight + total_weight += score.weight + + if total_weight == 0: + return 0 + + percentage = weighted_sum / total_weight + return int(max_points * percentage / 100) + + +def get_grade_label(grade_points: int) -> str: + """Gibt Label für Note zurück.""" + return GRADE_LABELS.get(grade_points, "unbekannt") + + +def _to_text_source_response(ts: TextSource) -> TextSourceResponse: + """Konvertiert TextSource zu Response.""" + return TextSourceResponse( + id=ts.id, + source_type=ts.source_type, + title=ts.title, + author=ts.author, + content=ts.content, + nibis_id=ts.nibis_id, + license_status=ts.license_status, + license_info=ts.license_info, + created_at=ts.created_at + ) + + +def _to_aufgabe_response(a: Aufgabe) -> AufgabeResponse: + """Konvertiert Aufgabe zu Response.""" + return AufgabeResponse( + id=a.id, + nummer=a.nummer, + text=a.text, + operator=a.operator, + anforderungsbereich=a.anforderungsbereich, + erwartete_leistungen=a.erwartete_leistungen, + punkte=a.punkte + ) + + +def _to_erwartungshorizont_response(eh: Erwartungshorizont) -> ErwartungshorizontResponse: + """Konvertiert Erwartungshorizont zu Response.""" + return ErwartungshorizontResponse( + id=eh.id, + aufgaben=[_to_aufgabe_response(a) for a in eh.aufgaben], + max_points=eh.max_points, + hinweise=eh.hinweise, + generated=eh.generated, + created_at=eh.created_at + ) + + +def _to_annotation_response(a: Annotation) -> AnnotationResponse: + """Konvertiert Annotation zu Response.""" + return AnnotationResponse( + id=a.id, + page=a.page, + x=a.x, + y=a.y, + width=a.width, + height=a.height, + text=a.text, + type=a.type, + color=a.color, + created_at=a.created_at + ) + + +def _to_gutachten_response(g: Gutachten) -> GutachtenResponse: + """Konvertiert Gutachten zu Response.""" + return GutachtenResponse( + id=g.id, + einleitung=g.einleitung, + hauptteil=g.hauptteil, + fazit=g.fazit, + staerken=g.staerken, + schwaechen=g.schwaechen, + generated=g.generated, + edited=g.edited, + created_at=g.created_at + ) + + +def _to_examiner_response(e: ExaminerResult) -> ExaminerResultResponse: + """Konvertiert ExaminerResult zu Response.""" + return ExaminerResultResponse( + examiner_id=e.examiner_id, + examiner_name=e.examiner_name, + grade_points=e.grade_points, + comment=e.comment, + submitted_at=e.submitted_at + ) + + +def _to_student_klausur_response(sk: StudentKlausur) -> StudentKlausurResponse: + """Konvertiert StudentKlausur zu Response.""" + return StudentKlausurResponse( + id=sk.id, + student_name=sk.student_name, + student_id=sk.student_id, + file_path=sk.file_path, + file_name=sk.file_name, + ocr_text=sk.ocr_text, + status=sk.status, + criteria_scores={ + k: {"score": v.score, "weight": v.weight, "annotations": v.annotations, + "comment": v.comment, "ai_suggestions": v.ai_suggestions} + for k, v in sk.criteria_scores.items() + }, + annotations=[_to_annotation_response(a) for a in sk.annotations], + gutachten=_to_gutachten_response(sk.gutachten) if sk.gutachten else None, + raw_points=sk.raw_points, + grade_points=sk.grade_points, + grade_label=get_grade_label(sk.grade_points), + first_examiner=_to_examiner_response(sk.first_examiner) if sk.first_examiner else None, + second_examiner=_to_examiner_response(sk.second_examiner) if sk.second_examiner else None, + created_at=sk.created_at, + updated_at=sk.updated_at + ) + + +def _to_klausur_response(k: AbiturKlausur) -> KlausurResponse: + """Konvertiert AbiturKlausur zu Response.""" + completed = [s for s in k.students if s.status == StudentKlausurStatus.COMPLETED] + avg_grade = None + if completed: + avg_grade = sum(s.grade_points for s in completed) / len(completed) + + return KlausurResponse( + id=k.id, + title=k.title, + subject=k.subject, + modus=k.modus, + year=k.year, + semester=k.semester, + kurs=k.kurs, + class_id=k.class_id, + status=k.status, + text_sources=[_to_text_source_response(ts) for ts in k.text_sources], + erwartungshorizont=_to_erwartungshorizont_response(k.erwartungshorizont) if k.erwartungshorizont else None, + student_count=len(k.students), + completed_count=len(completed), + average_grade=avg_grade, + created_at=k.created_at, + updated_at=k.updated_at + ) + + +# ============================================================================ +# Background Tasks +# ============================================================================ + +async def _process_ocr(klausur_id: str, student_id: str, file_path: str): + """Background Task für OCR-Verarbeitung.""" + klausur = _klausuren.get(klausur_id) + if not klausur: + return + + student = next((s for s in klausur.students if s.id == student_id), None) + if not student: + return + + try: + student.status = StudentKlausurStatus.OCR_PROCESSING + student.updated_at = datetime.utcnow() + + # OCR durchführen + processor = FileProcessor() + result = processor.process_file(file_path) + + if result.success and result.text: + student.ocr_text = result.text + student.status = StudentKlausurStatus.OCR_COMPLETE + logger.info(f"OCR completed for {student_id}: {len(result.text)} characters") + else: + # Bei OCR-Fehlschlag auf UPLOADED zurücksetzen, damit manuell weitergearbeitet werden kann + student.ocr_text = None + student.status = StudentKlausurStatus.UPLOADED + logger.warning(f"OCR failed for {student_id}, keeping status as UPLOADED") + + student.updated_at = datetime.utcnow() + + except Exception as e: + logger.error(f"OCR error for {student_id}: {e}") + # Bei Fehlern auf UPLOADED zurücksetzen, nicht ERROR + student.status = StudentKlausurStatus.UPLOADED + student.ocr_text = None + student.updated_at = datetime.utcnow() + + +# ============================================================================ +# API Endpoints - Klausuren +# ============================================================================ + +@router.post("/klausuren", response_model=KlausurResponse) +async def create_klausur(data: KlausurCreate): + """Erstellt eine neue Klausur.""" + klausur_id = str(uuid.uuid4()) + now = datetime.utcnow() + + klausur = AbiturKlausur( + id=klausur_id, + title=data.title, + subject=data.subject, + modus=data.modus, + year=data.year, + semester=data.semester, + kurs=data.kurs, + class_id=data.class_id, + status=KlausurStatus.DRAFT, + text_sources=[], + erwartungshorizont=None, + students=[], + created_at=now, + updated_at=now + ) + + _klausuren[klausur_id] = klausur + logger.info(f"Created klausur {klausur_id}: {data.title}") + + return _to_klausur_response(klausur) + + +@router.get("/klausuren", response_model=List[KlausurResponse]) +async def list_klausuren( + modus: Optional[KlausurModus] = None, + subject: Optional[str] = None, + year: Optional[int] = None, + status: Optional[KlausurStatus] = None +): + """Listet alle Klausuren auf.""" + klausuren = list(_klausuren.values()) + + if modus: + klausuren = [k for k in klausuren if k.modus == modus] + if subject: + klausuren = [k for k in klausuren if k.subject == subject] + if year: + klausuren = [k for k in klausuren if k.year == year] + if status: + klausuren = [k for k in klausuren if k.status == status] + + klausuren.sort(key=lambda x: x.created_at, reverse=True) + return [_to_klausur_response(k) for k in klausuren] + + +@router.get("/klausuren/{klausur_id}", response_model=KlausurResponse) +async def get_klausur(klausur_id: str): + """Ruft eine Klausur ab.""" + klausur = _klausuren.get(klausur_id) + if not klausur: + raise HTTPException(status_code=404, detail="Klausur nicht gefunden") + + return _to_klausur_response(klausur) + + +@router.put("/klausuren/{klausur_id}", response_model=KlausurResponse) +async def update_klausur(klausur_id: str, data: KlausurUpdate): + """Aktualisiert eine Klausur.""" + klausur = _klausuren.get(klausur_id) + if not klausur: + raise HTTPException(status_code=404, detail="Klausur nicht gefunden") + + if data.title is not None: + klausur.title = data.title + if data.subject is not None: + klausur.subject = data.subject + if data.kurs is not None: + klausur.kurs = data.kurs + if data.status is not None: + klausur.status = data.status + + klausur.updated_at = datetime.utcnow() + return _to_klausur_response(klausur) + + +@router.delete("/klausuren/{klausur_id}") +async def delete_klausur(klausur_id: str): + """Löscht eine Klausur.""" + if klausur_id not in _klausuren: + raise HTTPException(status_code=404, detail="Klausur nicht gefunden") + + klausur = _klausuren[klausur_id] + + # Lösche hochgeladene Dateien + for student in klausur.students: + if student.file_path and os.path.exists(student.file_path): + try: + os.remove(student.file_path) + except Exception as e: + logger.warning(f"Could not delete file {student.file_path}: {e}") + + del _klausuren[klausur_id] + logger.info(f"Deleted klausur {klausur_id}") + + return {"status": "deleted", "id": klausur_id} + + +# ============================================================================ +# API Endpoints - Text-Quellen +# ============================================================================ + +@router.post("/klausuren/{klausur_id}/text-sources", response_model=TextSourceResponse) +async def add_text_source(klausur_id: str, data: TextSourceCreate): + """Fügt eine Textquelle zur Klausur hinzu.""" + klausur = _klausuren.get(klausur_id) + if not klausur: + raise HTTPException(status_code=404, detail="Klausur nicht gefunden") + + ts_id = str(uuid.uuid4()) + now = datetime.utcnow() + + # Status basierend auf Typ setzen + if data.source_type == TextSourceType.NIBIS: + license_status = TextSourceStatus.VERIFIED + elif data.source_type == TextSourceType.EIGENTEXT: + license_status = TextSourceStatus.VERIFIED + else: + license_status = TextSourceStatus.PENDING + + text_source = TextSource( + id=ts_id, + source_type=data.source_type, + title=data.title, + author=data.author, + content=data.content, + nibis_id=data.nibis_id, + license_status=license_status, + license_info=data.license_info, + created_at=now + ) + + klausur.text_sources.append(text_source) + klausur.status = KlausurStatus.TEXT_SOURCE + klausur.updated_at = now + + logger.info(f"Added text source {ts_id} to klausur {klausur_id}") + return _to_text_source_response(text_source) + + +@router.post("/text-sources/{text_source_id}/verify", response_model=TextSourceResponse) +async def verify_text_source(text_source_id: str, license_info: Dict[str, Any] = None): + """Verifiziert eine Textquelle (Rights-Gate).""" + # Finde Textquelle + for klausur in _klausuren.values(): + for ts in klausur.text_sources: + if ts.id == text_source_id: + ts.license_status = TextSourceStatus.VERIFIED + ts.license_info = license_info or {"verified_at": datetime.utcnow().isoformat()} + + # Prüfe ob alle Textquellen verifiziert + all_verified = all( + t.license_status == TextSourceStatus.VERIFIED + for t in klausur.text_sources + ) + if all_verified and klausur.text_sources: + klausur.status = KlausurStatus.ERWARTUNGSHORIZONT + + klausur.updated_at = datetime.utcnow() + return _to_text_source_response(ts) + + raise HTTPException(status_code=404, detail="Textquelle nicht gefunden") + + +@router.delete("/klausuren/{klausur_id}/text-sources/{text_source_id}") +async def delete_text_source(klausur_id: str, text_source_id: str): + """Löscht eine Textquelle.""" + klausur = _klausuren.get(klausur_id) + if not klausur: + raise HTTPException(status_code=404, detail="Klausur nicht gefunden") + + klausur.text_sources = [ts for ts in klausur.text_sources if ts.id != text_source_id] + klausur.updated_at = datetime.utcnow() + + return {"status": "deleted", "id": text_source_id} + + +# ============================================================================ +# API Endpoints - NiBiS-Katalog +# ============================================================================ + +@router.get("/nibis/aufgaben", response_model=List[NiBiSAufgabe]) +async def list_nibis_aufgaben( + subject: Optional[str] = None, + year: Optional[int] = None +): + """Listet NiBiS-Aufgaben aus dem Katalog auf.""" + aufgaben = list(_nibis_katalog.values()) + + if subject: + aufgaben = [a for a in aufgaben if a.subject.lower() == subject.lower()] + if year: + aufgaben = [a for a in aufgaben if a.year == year] + + aufgaben.sort(key=lambda x: (x.year, x.subject), reverse=True) + return aufgaben + + +@router.get("/nibis/aufgaben/{aufgabe_id}", response_model=NiBiSAufgabe) +async def get_nibis_aufgabe(aufgabe_id: str): + """Ruft eine NiBiS-Aufgabe ab.""" + aufgabe = _nibis_katalog.get(aufgabe_id) + if not aufgabe: + raise HTTPException(status_code=404, detail="NiBiS-Aufgabe nicht gefunden") + return aufgabe + + +# ============================================================================ +# API Endpoints - Erwartungshorizont +# ============================================================================ + +@router.post("/klausuren/{klausur_id}/erwartungshorizont/generate", response_model=ErwartungshorizontResponse) +async def generate_erwartungshorizont(klausur_id: str): + """Generiert KI-basierten Erwartungshorizont.""" + klausur = _klausuren.get(klausur_id) + if not klausur: + raise HTTPException(status_code=404, detail="Klausur nicht gefunden") + + if not klausur.text_sources: + raise HTTPException(status_code=400, detail="Keine Textquellen vorhanden") + + # Kombiniere alle Texte + combined_text = "\n\n".join(ts.content for ts in klausur.text_sources) + + # Generiere Demo-Erwartungshorizont (in Produktion: KI-generiert) + eh_id = str(uuid.uuid4()) + now = datetime.utcnow() + + aufgaben = [ + Aufgabe( + id=str(uuid.uuid4()), + nummer="1", + text="Analysieren Sie die sprachlichen und stilistischen Mittel des Textes.", + operator="analysieren", + anforderungsbereich=2, + erwartete_leistungen=[ + "Erkennung von Metaphern und deren Wirkung", + "Analyse der Syntax und Satzstruktur", + "Einordnung in den historischen Kontext" + ], + punkte=30 + ), + Aufgabe( + id=str(uuid.uuid4()), + nummer="2", + text="Erörtern Sie die Aktualität des Themas in Bezug auf die Gegenwart.", + operator="erörtern", + anforderungsbereich=3, + erwartete_leistungen=[ + "Herausarbeitung der zentralen Thesen", + "Vergleich mit aktuellen gesellschaftlichen Entwicklungen", + "Differenzierte Stellungnahme mit Begründung" + ], + punkte=40 + ), + Aufgabe( + id=str(uuid.uuid4()), + nummer="3", + text="Verfassen Sie einen Kommentar zum Thema aus persönlicher Perspektive.", + operator="gestalten", + anforderungsbereich=3, + erwartete_leistungen=[ + "Einhaltung der Textsortenmerkmale (Kommentar)", + "Klare Positionierung und Argumentation", + "Sprachliche Angemessenheit und Stilsicherheit" + ], + punkte=30 + ) + ] + + erwartungshorizont = Erwartungshorizont( + id=eh_id, + aufgaben=aufgaben, + max_points=100, + hinweise="Der Erwartungshorizont dient als Orientierung. Abweichende, aber schlüssige Lösungen sind möglich.", + generated=True, + created_at=now + ) + + klausur.erwartungshorizont = erwartungshorizont + klausur.status = KlausurStatus.COLLECTING + klausur.updated_at = now + + logger.info(f"Generated erwartungshorizont for klausur {klausur_id}") + return _to_erwartungshorizont_response(erwartungshorizont) + + +@router.put("/klausuren/{klausur_id}/erwartungshorizont", response_model=ErwartungshorizontResponse) +async def update_erwartungshorizont(klausur_id: str, data: ErwartungshorizontCreate): + """Aktualisiert den Erwartungshorizont.""" + klausur = _klausuren.get(klausur_id) + if not klausur: + raise HTTPException(status_code=404, detail="Klausur nicht gefunden") + + now = datetime.utcnow() + + aufgaben = [ + Aufgabe( + id=str(uuid.uuid4()), + nummer=a.nummer, + text=a.text, + operator=a.operator, + anforderungsbereich=a.anforderungsbereich, + erwartete_leistungen=a.erwartete_leistungen, + punkte=a.punkte + ) + for a in data.aufgaben + ] + + if klausur.erwartungshorizont: + klausur.erwartungshorizont.aufgaben = aufgaben + klausur.erwartungshorizont.max_points = data.max_points + klausur.erwartungshorizont.hinweise = data.hinweise + else: + klausur.erwartungshorizont = Erwartungshorizont( + id=str(uuid.uuid4()), + aufgaben=aufgaben, + max_points=data.max_points, + hinweise=data.hinweise, + generated=False, + created_at=now + ) + + klausur.updated_at = now + return _to_erwartungshorizont_response(klausur.erwartungshorizont) + + +@router.get("/operators", response_model=Dict[str, List[Dict[str, str]]]) +async def get_operators(): + """Gibt alle Operatoren nach Anforderungsbereich zurück.""" + return OPERATORS + + +# ============================================================================ +# API Endpoints - Schülerarbeiten +# ============================================================================ + +@router.post("/klausuren/{klausur_id}/students", response_model=StudentKlausurResponse) +async def add_student( + klausur_id: str, + background_tasks: BackgroundTasks, + student_name: str = Form(...), + student_id: Optional[str] = Form(None), + file: UploadFile = File(...) +): + """Lädt eine Schülerarbeit hoch.""" + klausur = _klausuren.get(klausur_id) + if not klausur: + raise HTTPException(status_code=404, detail="Klausur nicht gefunden") + + # Validiere Dateiformat + allowed_extensions = {".pdf", ".png", ".jpg", ".jpeg"} + file_ext = Path(file.filename).suffix.lower() if file.filename else "" + + if file_ext not in allowed_extensions: + raise HTTPException( + status_code=400, + detail=f"Ungültiges Dateiformat. Erlaubt: {', '.join(allowed_extensions)}" + ) + + sk_id = str(uuid.uuid4()) + now = datetime.utcnow() + + # Speichere Datei + file_path = UPLOAD_DIR / f"{sk_id}{file_ext}" + + try: + content = await file.read() + with open(file_path, "wb") as f: + f.write(content) + except Exception as e: + logger.error(f"Upload error: {e}") + raise HTTPException(status_code=500, detail=f"Upload fehlgeschlagen: {str(e)}") + + # Initialisiere Kriterien-Scores + criteria_scores = { + k: CriterionScore( + score=0, + weight=v["weight"], + annotations=[], + comment="", + ai_suggestions=[] + ) + for k, v in DEFAULT_CRITERIA.items() + } + + student_klausur = StudentKlausur( + id=sk_id, + student_name=student_name, + student_id=student_id, + file_path=str(file_path), + file_name=file.filename, + ocr_text=None, + status=StudentKlausurStatus.UPLOADED, + criteria_scores=criteria_scores, + annotations=[], + gutachten=None, + raw_points=0, + grade_points=0, + first_examiner=None, + second_examiner=None, + created_at=now, + updated_at=now + ) + + klausur.students.append(student_klausur) + klausur.status = KlausurStatus.CORRECTING + klausur.updated_at = now + + # Starte OCR im Hintergrund + background_tasks.add_task(_process_ocr, klausur_id, sk_id, str(file_path)) + + logger.info(f"Added student {student_name} to klausur {klausur_id}") + return _to_student_klausur_response(student_klausur) + + +@router.get("/klausuren/{klausur_id}/students", response_model=List[StudentKlausurResponse]) +async def list_students(klausur_id: str): + """Listet alle Schülerarbeiten einer Klausur auf.""" + klausur = _klausuren.get(klausur_id) + if not klausur: + raise HTTPException(status_code=404, detail="Klausur nicht gefunden") + + return [_to_student_klausur_response(s) for s in klausur.students] + + +@router.get("/students/{student_id}", response_model=StudentKlausurResponse) +async def get_student(student_id: str): + """Ruft eine Schülerarbeit ab.""" + for klausur in _klausuren.values(): + for student in klausur.students: + if student.id == student_id: + return _to_student_klausur_response(student) + + raise HTTPException(status_code=404, detail="Schülerarbeit nicht gefunden") + + +@router.delete("/students/{student_id}") +async def delete_student(student_id: str): + """Löscht eine Schülerarbeit und deren Datei.""" + for klausur in _klausuren.values(): + for i, student in enumerate(klausur.students): + if student.id == student_id: + # Lösche die Datei, falls vorhanden + if student.file_path: + file_path = Path(student.file_path) + if file_path.exists(): + try: + file_path.unlink() + logger.info(f"Deleted file: {file_path}") + except Exception as e: + logger.warning(f"Could not delete file {file_path}: {e}") + + # Entferne aus der Liste + klausur.students.pop(i) + klausur.updated_at = datetime.utcnow() + + logger.info(f"Deleted student work {student_id} ({student.student_name})") + return {"success": True, "message": f"Schülerarbeit '{student.student_name}' gelöscht"} + + raise HTTPException(status_code=404, detail="Schülerarbeit nicht gefunden") + + +@router.get("/students/{student_id}/file") +async def get_student_file(student_id: str): + """Liefert die Datei einer Schülerarbeit aus.""" + for klausur in _klausuren.values(): + for student in klausur.students: + if student.id == student_id: + if not student.file_path: + raise HTTPException(status_code=404, detail="Keine Datei vorhanden") + + file_path = Path(student.file_path) + if not file_path.exists(): + raise HTTPException(status_code=404, detail="Datei nicht gefunden") + + # Determine media type + suffix = file_path.suffix.lower() + media_types = { + ".pdf": "application/pdf", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + } + media_type = media_types.get(suffix, "application/octet-stream") + + return FileResponse( + path=str(file_path), + media_type=media_type, + filename=student.file_name or file_path.name + ) + + raise HTTPException(status_code=404, detail="Schülerarbeit nicht gefunden") + + +@router.post("/students/{student_id}/ocr") +async def start_ocr(student_id: str, background_tasks: BackgroundTasks): + """Startet OCR für eine Schülerarbeit (erneut).""" + for klausur in _klausuren.values(): + for student in klausur.students: + if student.id == student_id: + if not student.file_path: + raise HTTPException(status_code=400, detail="Keine Datei vorhanden") + + student.status = StudentKlausurStatus.UPLOADED + student.ocr_text = None + student.updated_at = datetime.utcnow() + + background_tasks.add_task(_process_ocr, klausur.id, student_id, student.file_path) + + return {"status": "started", "student_id": student_id} + + raise HTTPException(status_code=404, detail="Schülerarbeit nicht gefunden") + + +# ============================================================================ +# API Endpoints - Bewertung +# ============================================================================ + +@router.post("/students/{student_id}/evaluate", response_model=StudentKlausurResponse) +async def evaluate_student(student_id: str): + """Führt KI-basierte Bewertung durch.""" + for klausur in _klausuren.values(): + for student in klausur.students: + if student.id == student_id: + if student.status not in [StudentKlausurStatus.OCR_COMPLETE, StudentKlausurStatus.ANALYZING]: + raise HTTPException( + status_code=400, + detail=f"OCR muss abgeschlossen sein. Aktueller Status: {student.status}" + ) + + student.status = StudentKlausurStatus.ANALYZING + + # Demo-Bewertung (in Produktion: KI-basiert) + import random + + for criterion in student.criteria_scores: + base_score = random.randint(50, 90) + student.criteria_scores[criterion].score = base_score + student.criteria_scores[criterion].ai_suggestions = [ + f"Verbesserungsvorschlag für {criterion}" + ] + + # Berechne Punkte + max_points = klausur.erwartungshorizont.max_points if klausur.erwartungshorizont else 100 + student.raw_points = calculate_raw_points(student.criteria_scores, max_points) + + percentage = (student.raw_points / max_points * 100) if max_points > 0 else 0 + student.grade_points = calculate_15_point_grade(percentage) + + student.status = StudentKlausurStatus.FIRST_EXAMINER + student.updated_at = datetime.utcnow() + + logger.info(f"Evaluated student {student_id}: {student.grade_points} points") + return _to_student_klausur_response(student) + + raise HTTPException(status_code=404, detail="Schülerarbeit nicht gefunden") + + +@router.put("/students/{student_id}/criteria", response_model=StudentKlausurResponse) +async def update_criteria(student_id: str, updates: Dict[str, CriterionScoreUpdate]): + """Aktualisiert Kriterien-Bewertungen.""" + for klausur in _klausuren.values(): + for student in klausur.students: + if student.id == student_id: + for criterion, update in updates.items(): + if criterion in student.criteria_scores: + student.criteria_scores[criterion].score = update.score + student.criteria_scores[criterion].annotations = update.annotations + student.criteria_scores[criterion].comment = update.comment + + # Neuberechnung + max_points = klausur.erwartungshorizont.max_points if klausur.erwartungshorizont else 100 + student.raw_points = calculate_raw_points(student.criteria_scores, max_points) + + percentage = (student.raw_points / max_points * 100) if max_points > 0 else 0 + student.grade_points = calculate_15_point_grade(percentage) + + student.updated_at = datetime.utcnow() + return _to_student_klausur_response(student) + + raise HTTPException(status_code=404, detail="Schülerarbeit nicht gefunden") + + +@router.post("/students/{student_id}/annotations", response_model=AnnotationResponse) +async def add_annotation(student_id: str, data: AnnotationCreate): + """Fügt eine Annotation hinzu.""" + for klausur in _klausuren.values(): + for student in klausur.students: + if student.id == student_id: + annotation = Annotation( + id=str(uuid.uuid4()), + page=data.page, + x=data.x, + y=data.y, + width=data.width, + height=data.height, + text=data.text, + type=data.type, + color=data.color, + created_at=datetime.utcnow() + ) + + student.annotations.append(annotation) + student.updated_at = datetime.utcnow() + + return _to_annotation_response(annotation) + + raise HTTPException(status_code=404, detail="Schülerarbeit nicht gefunden") + + +@router.delete("/students/{student_id}/annotations/{annotation_id}") +async def delete_annotation(student_id: str, annotation_id: str): + """Löscht eine Annotation.""" + for klausur in _klausuren.values(): + for student in klausur.students: + if student.id == student_id: + student.annotations = [a for a in student.annotations if a.id != annotation_id] + student.updated_at = datetime.utcnow() + return {"status": "deleted", "id": annotation_id} + + raise HTTPException(status_code=404, detail="Schülerarbeit nicht gefunden") + + +# ============================================================================ +# API Endpoints - Gutachten +# ============================================================================ + +@router.post("/students/{student_id}/gutachten/generate", response_model=GutachtenResponse) +async def generate_gutachten(student_id: str): + """Generiert KI-basiertes Gutachten.""" + for klausur in _klausuren.values(): + for student in klausur.students: + if student.id == student_id: + now = datetime.utcnow() + grade_label = get_grade_label(student.grade_points) + + # Demo-Gutachten (in Produktion: KI-generiert) + gutachten = Gutachten( + id=str(uuid.uuid4()), + einleitung=f"Die vorliegende Klausur von {student.student_name} im Fach {klausur.subject} " + f"wird im Folgenden nach den Kriterien des niedersächsischen Kerncurriculums bewertet.", + hauptteil=f"Die Arbeit zeigt in der inhaltlichen Auseinandersetzung mit dem Thema " + f"{'überzeugende' if student.grade_points >= 10 else 'grundlegende'} Kompetenzen. " + f"Die sprachliche Gestaltung ist {'angemessen' if student.grade_points >= 8 else 'ausbaufähig'}. " + f"Der Aufbau der Argumentation folgt {'einer klaren' if student.grade_points >= 10 else 'einer erkennbaren'} Struktur.", + fazit=f"Insgesamt erreicht die Arbeit {student.raw_points} von " + f"{klausur.erwartungshorizont.max_points if klausur.erwartungshorizont else 100} Punkten, " + f"was der Note {grade_label} entspricht.", + staerken=["Textverständnis", "Argumentationsaufbau"] if student.grade_points >= 10 else ["Grundlegende Texterfassung"], + schwaechen=["Sprachliche Vielfalt"] if student.grade_points >= 10 else ["Inhaltliche Tiefe", "Sprachliche Richtigkeit"], + generated=True, + edited=False, + created_at=now + ) + + student.gutachten = gutachten + student.updated_at = now + + logger.info(f"Generated gutachten for student {student_id}") + return _to_gutachten_response(gutachten) + + raise HTTPException(status_code=404, detail="Schülerarbeit nicht gefunden") + + +@router.put("/students/{student_id}/gutachten", response_model=GutachtenResponse) +async def update_gutachten(student_id: str, data: GutachtenCreate): + """Aktualisiert das Gutachten.""" + for klausur in _klausuren.values(): + for student in klausur.students: + if student.id == student_id: + now = datetime.utcnow() + + if student.gutachten: + student.gutachten.einleitung = data.einleitung + student.gutachten.hauptteil = data.hauptteil + student.gutachten.fazit = data.fazit + student.gutachten.staerken = data.staerken + student.gutachten.schwaechen = data.schwaechen + student.gutachten.edited = True + else: + student.gutachten = Gutachten( + id=str(uuid.uuid4()), + einleitung=data.einleitung, + hauptteil=data.hauptteil, + fazit=data.fazit, + staerken=data.staerken, + schwaechen=data.schwaechen, + generated=False, + edited=True, + created_at=now + ) + + student.updated_at = now + return _to_gutachten_response(student.gutachten) + + raise HTTPException(status_code=404, detail="Schülerarbeit nicht gefunden") + + +# ============================================================================ +# API Endpoints - Note & Finalisierung +# ============================================================================ + +@router.post("/students/{student_id}/grade", response_model=StudentKlausurResponse) +async def calculate_grade(student_id: str): + """Berechnet die Note neu.""" + for klausur in _klausuren.values(): + for student in klausur.students: + if student.id == student_id: + max_points = klausur.erwartungshorizont.max_points if klausur.erwartungshorizont else 100 + student.raw_points = calculate_raw_points(student.criteria_scores, max_points) + + percentage = (student.raw_points / max_points * 100) if max_points > 0 else 0 + student.grade_points = calculate_15_point_grade(percentage) + + student.updated_at = datetime.utcnow() + return _to_student_klausur_response(student) + + raise HTTPException(status_code=404, detail="Schülerarbeit nicht gefunden") + + +@router.put("/students/{student_id}/finalize", response_model=StudentKlausurResponse) +async def finalize_student(student_id: str): + """Schließt eine Schülerarbeit ab.""" + for klausur in _klausuren.values(): + for student in klausur.students: + if student.id == student_id: + student.status = StudentKlausurStatus.COMPLETED + student.updated_at = datetime.utcnow() + + # Prüfe ob alle Schüler abgeschlossen sind + all_completed = all( + s.status == StudentKlausurStatus.COMPLETED + for s in klausur.students + ) + if all_completed and klausur.students: + klausur.status = KlausurStatus.COMPLETED + klausur.updated_at = datetime.utcnow() + + logger.info(f"Finalized student {student_id}") + return _to_student_klausur_response(student) + + raise HTTPException(status_code=404, detail="Schülerarbeit nicht gefunden") + + +# ============================================================================ +# API Endpoints - Erst-/Zweitprüfer +# ============================================================================ + +@router.post("/students/{student_id}/examiner", response_model=StudentKlausurResponse) +async def submit_examiner_result(student_id: str, data: ExaminerResultCreate): + """Reicht Prüfer-Ergebnis ein.""" + for klausur in _klausuren.values(): + for student in klausur.students: + if student.id == student_id: + now = datetime.utcnow() + + result = ExaminerResult( + examiner_id=data.examiner_id, + examiner_name=data.examiner_name, + grade_points=data.grade_points, + comment=data.comment, + submitted_at=now + ) + + if student.status == StudentKlausurStatus.FIRST_EXAMINER: + student.first_examiner = result + student.status = StudentKlausurStatus.SECOND_EXAMINER + elif student.status == StudentKlausurStatus.SECOND_EXAMINER: + student.second_examiner = result + + # Berechne Durchschnitt wenn beide Prüfer bewertet haben + if student.first_examiner: + avg = (student.first_examiner.grade_points + result.grade_points) / 2 + student.grade_points = round(avg) + + student.status = StudentKlausurStatus.COMPLETED + else: + raise HTTPException( + status_code=400, + detail=f"Kann Prüfer-Ergebnis nicht einreichen. Status: {student.status}" + ) + + student.updated_at = now + return _to_student_klausur_response(student) + + raise HTTPException(status_code=404, detail="Schülerarbeit nicht gefunden") + + +@router.get("/examiner/queue", response_model=List[StudentKlausurResponse]) +async def get_examiner_queue(examiner_role: str = "first"): + """Gibt Warteschlange für Prüfer zurück.""" + target_status = ( + StudentKlausurStatus.FIRST_EXAMINER + if examiner_role == "first" + else StudentKlausurStatus.SECOND_EXAMINER + ) + + queue = [] + for klausur in _klausuren.values(): + for student in klausur.students: + if student.status == target_status: + queue.append(_to_student_klausur_response(student)) + + return queue + + +# ============================================================================ +# API Endpoints - Fairness & Export +# ============================================================================ + +@router.get("/klausuren/{klausur_id}/fairness", response_model=FairnessReport) +async def get_fairness_report(klausur_id: str): + """Erstellt Fairness-Bericht für Klausur.""" + klausur = _klausuren.get(klausur_id) + if not klausur: + raise HTTPException(status_code=404, detail="Klausur nicht gefunden") + + students = klausur.students + if not students: + raise HTTPException(status_code=400, detail="Keine Schülerarbeiten vorhanden") + + # Notenverteilung + grade_distribution = {} + for s in students: + grade_distribution[s.grade_points] = grade_distribution.get(s.grade_points, 0) + 1 + + # Durchschnitte + avg_grade = sum(s.grade_points for s in students) / len(students) + + criteria_averages = {} + for criterion in DEFAULT_CRITERIA: + scores = [s.criteria_scores[criterion].score for s in students if criterion in s.criteria_scores] + if scores: + criteria_averages[criterion] = sum(scores) / len(scores) + + # Ausreißer (>2 Standardabweichungen) + grades = [s.grade_points for s in students] + mean = sum(grades) / len(grades) + variance = sum((g - mean) ** 2 for g in grades) / len(grades) + std_dev = variance ** 0.5 + + outliers = [] + for s in students: + if abs(s.grade_points - mean) > 2 * std_dev: + outliers.append({ + "student_name": s.student_name, + "grade_points": s.grade_points, + "deviation": s.grade_points - mean + }) + + # Empfehlungen + recommendations = [] + if std_dev > 4: + recommendations.append("Hohe Streuung der Noten - Erwartungshorizont prüfen") + if avg_grade < 5: + recommendations.append("Durchschnitt unter 5 Punkten - Aufgabenstellung überprüfen") + if avg_grade > 12: + recommendations.append("Durchschnitt über 12 Punkten - Bewertungsmaßstab prüfen") + if outliers: + recommendations.append(f"{len(outliers)} Ausreißer gefunden - Einzelfälle prüfen") + + return FairnessReport( + klausur_id=klausur_id, + total_students=len(students), + average_grade=avg_grade, + grade_distribution=grade_distribution, + criteria_averages=criteria_averages, + outliers=outliers, + recommendations=recommendations + ) + + +@router.post("/klausuren/{klausur_id}/export") +async def export_klausur( + klausur_id: str, + student_ids: Optional[List[str]] = None, + include_gutachten: bool = True +): + """Exportiert Klausur als PDF.""" + klausur = _klausuren.get(klausur_id) + if not klausur: + raise HTTPException(status_code=404, detail="Klausur nicht gefunden") + + # Filter Schüler falls IDs angegeben + students = klausur.students + if student_ids: + students = [s for s in students if s.id in student_ids] + + if not students: + raise HTTPException(status_code=400, detail="Keine Schüler zum Exportieren") + + # Demo-Response (in Produktion: PDF generieren) + return { + "status": "success", + "klausur_id": klausur_id, + "exported_students": len(students), + "message": "PDF-Export wird generiert..." + } + + +@router.get("/students/{student_id}/export-pdf") +async def export_student_pdf(student_id: str): + """Exportiert einzelne Schülerarbeit als PDF.""" + for klausur in _klausuren.values(): + for student in klausur.students: + if student.id == student_id: + try: + pdf_service = PDFService() + + # Erstelle HTML für Gutachten + grade_label = get_grade_label(student.grade_points) + + html_content = f""" + + + + + +

            {klausur.title}

            +
            +

            Schüler/in: {student.student_name}

            +

            Fach: {klausur.subject}

            +

            Kurs: {klausur.kurs}

            +

            Datum: {student.created_at.strftime('%d.%m.%Y')}

            +
            + +

            Ergebnis

            +

            {student.grade_points} Punkte - {grade_label}

            +

            Erreichte Rohpunkte: {student.raw_points} / {klausur.erwartungshorizont.max_points if klausur.erwartungshorizont else 100}

            + +

            Kriterien-Bewertung

            + + + + + + + """ + + for crit_name, crit_data in student.criteria_scores.items(): + label = DEFAULT_CRITERIA.get(crit_name, {}).get("label", crit_name) + html_content += f""" + + + + + + """ + + html_content += "
            KriteriumGewichtungErreicht
            {label}{int(crit_data.weight * 100)}%{crit_data.score}%
            " + + if student.gutachten: + html_content += f""" +
            +

            Gutachten

            +

            {student.gutachten.einleitung}

            +

            {student.gutachten.hauptteil}

            +

            {student.gutachten.fazit}

            + +

            Stärken

            +
              + {''.join(f'
            • {s}
            • ' for s in student.gutachten.staerken)} +
            + +

            Verbesserungsbereiche

            +
              + {''.join(f'
            • {s}
            • ' for s in student.gutachten.schwaechen)} +
            +
            + """ + + html_content += """ +
            +

            Datum: _________________

            +

            Unterschrift Lehrkraft: _________________

            +
            + + + """ + + pdf_bytes = pdf_service.html_to_pdf(html_content) + + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={ + "Content-Disposition": f'attachment; filename="klausur_{student.student_name}_{klausur.subject}.pdf"' + } + ) + + except Exception as e: + logger.error(f"PDF export error: {e}") + raise HTTPException(status_code=500, detail=f"PDF-Export fehlgeschlagen: {str(e)}") + + raise HTTPException(status_code=404, detail="Schülerarbeit nicht gefunden") + + +# ============================================================================ +# API Endpoints - Hilfsfunktionen +# ============================================================================ + +@router.get("/grade-scale", response_model=Dict[str, Any]) +async def get_grade_scale(): + """Gibt den 15-Punkte-Notenschlüssel zurück.""" + return { + "thresholds": GRADE_THRESHOLDS, + "labels": GRADE_LABELS + } + + +@router.get("/criteria", response_model=Dict[str, Dict[str, Any]]) +async def get_default_criteria(): + """Gibt die Standard-Bewertungskriterien zurück.""" + return DEFAULT_CRITERIA + + +# ============================================================================ +# Backwards-compatibility aliases (used by tests) +# ============================================================================ +klausuren_db = _klausuren diff --git a/backend/klausur_service_proxy.py b/backend/klausur_service_proxy.py new file mode 100644 index 0000000..0c7b841 --- /dev/null +++ b/backend/klausur_service_proxy.py @@ -0,0 +1,135 @@ +""" +Klausur-Service API Proxy +Routes API requests from /api/klausur/* to the klausur-service microservice +""" + +import os +import jwt +import datetime +import httpx +from fastapi import APIRouter, Request, HTTPException, Response + +# Klausur Service URL +KLAUSUR_SERVICE_URL = os.getenv("KLAUSUR_SERVICE_URL", "http://klausur-service:8086") +JWT_SECRET = os.getenv("JWT_SECRET", "your-super-secret-jwt-key-change-in-production") +ENVIRONMENT = os.getenv("ENVIRONMENT", "development") + +# Demo teacher UUID for development mode +DEMO_TEACHER_ID = "e9484ad9-32ee-4f2b-a4e1-d182e02ccf20" + +router = APIRouter(prefix="/klausur", tags=["klausur"]) + + +def get_demo_token() -> str: + """Generate a demo JWT token for development mode""" + payload = { + "user_id": DEMO_TEACHER_ID, + "email": "demo@breakpilot.app", + "role": "admin", + "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=24), + "iat": datetime.datetime.now(datetime.timezone.utc) + } + return jwt.encode(payload, JWT_SECRET, algorithm="HS256") + + +async def proxy_request(request: Request, path: str) -> Response: + """Forward a request to the klausur service""" + url = f"{KLAUSUR_SERVICE_URL}/api/v1{path}" + + # Forward headers, especially Authorization + headers = {} + if "authorization" in request.headers: + headers["Authorization"] = request.headers["authorization"] + elif ENVIRONMENT == "development": + # In development mode, use demo token if no auth provided + demo_token = get_demo_token() + headers["Authorization"] = f"Bearer {demo_token}" + if "content-type" in request.headers: + headers["Content-Type"] = request.headers["content-type"] + + # Get request body for POST/PUT/PATCH/DELETE + body = None + if request.method in ("POST", "PUT", "PATCH", "DELETE"): + body = await request.body() + + async with httpx.AsyncClient(timeout=60.0) as client: + try: + response = await client.request( + method=request.method, + url=url, + headers=headers, + content=body, + params=request.query_params + ) + return Response( + content=response.content, + status_code=response.status_code, + headers=dict(response.headers), + media_type=response.headers.get("content-type", "application/json") + ) + except httpx.ConnectError: + raise HTTPException( + status_code=503, + detail="Klausur service unavailable" + ) + except httpx.TimeoutException: + raise HTTPException( + status_code=504, + detail="Klausur service timeout" + ) + + +# Health check +@router.get("/health") +async def health(): + """Health check for klausur service connection""" + async with httpx.AsyncClient(timeout=5.0) as client: + try: + response = await client.get(f"{KLAUSUR_SERVICE_URL}/health") + return {"klausur_service": "healthy", "connected": response.status_code == 200} + except Exception: + return {"klausur_service": "unhealthy", "connected": False} + + +# Klausuren +@router.api_route("/klausuren", methods=["GET", "POST"]) +async def klausuren(request: Request): + return await proxy_request(request, "/klausuren") + + +@router.api_route("/klausuren/{klausur_id}", methods=["GET", "PUT", "DELETE"]) +async def klausur_by_id(klausur_id: str, request: Request): + return await proxy_request(request, f"/klausuren/{klausur_id}") + + +# Students +@router.api_route("/klausuren/{klausur_id}/students", methods=["GET", "POST"]) +async def klausur_students(klausur_id: str, request: Request): + return await proxy_request(request, f"/klausuren/{klausur_id}/students") + + +@router.api_route("/students/{student_id}", methods=["GET", "DELETE"]) +async def student_by_id(student_id: str, request: Request): + return await proxy_request(request, f"/students/{student_id}") + + +# Grading +@router.api_route("/students/{student_id}/criteria", methods=["PUT"]) +async def update_criteria(student_id: str, request: Request): + return await proxy_request(request, f"/students/{student_id}/criteria") + + +@router.api_route("/students/{student_id}/gutachten", methods=["PUT"]) +async def update_gutachten(student_id: str, request: Request): + return await proxy_request(request, f"/students/{student_id}/gutachten") + + +@router.api_route("/students/{student_id}/finalize", methods=["POST"]) +async def finalize_student(student_id: str, request: Request): + return await proxy_request(request, f"/students/{student_id}/finalize") + + +# Grade info +@router.get("/grade-info") +async def grade_info(request: Request): + return await proxy_request(request, "/grade-info") diff --git a/backend/learning_units.py b/backend/learning_units.py new file mode 100644 index 0000000..425ad2f --- /dev/null +++ b/backend/learning_units.py @@ -0,0 +1,178 @@ +from __future__ import annotations +from pydantic import BaseModel, Field +from typing import List, Dict, Optional +from pathlib import Path +from datetime import datetime +import uuid +import json +import threading + +# Basisverzeichnis für Arbeitsblätter & Lerneinheiten +BASE_DIR = Path.home() / "Arbeitsblaetter" +LEARNING_UNITS_DIR = BASE_DIR / "Lerneinheiten" +LEARNING_UNITS_FILE = LEARNING_UNITS_DIR / "learning_units.json" + +# Thread-Lock, damit Dateizugriffe sicher bleiben +_lock = threading.Lock() + + +class LearningUnitBase(BaseModel): + title: str = Field(..., description="Titel der Lerneinheit, z.B. 'Das Auge – Klasse 7'") + description: Optional[str] = Field(None, description="Freitext-Beschreibung") + topic: Optional[str] = Field(None, description="Kurz-Thema, z.B. 'Auge'") + grade_level: Optional[str] = Field(None, description="Klassenstufe, z.B. '7'") + language: Optional[str] = Field("de", description="Hauptsprache der Lerneinheit (z.B. 'de', 'tr')") + worksheet_files: List[str] = Field( + default_factory=list, + description="Liste der zugeordneten Arbeitsblatt-Dateien (Basenames oder Pfade)" + ) + status: str = Field( + "raw", + description="Pipeline-Status: raw, cleaned, qa_generated, mc_generated, cloze_generated" + ) + + +class LearningUnitCreate(LearningUnitBase): + """Payload zum Erstellen einer neuen Lerneinheit.""" + pass + + +class LearningUnitUpdate(BaseModel): + """Teil-Update für eine Lerneinheit.""" + title: Optional[str] = None + description: Optional[str] = None + topic: Optional[str] = None + grade_level: Optional[str] = None + language: Optional[str] = None + worksheet_files: Optional[List[str]] = None + status: Optional[str] = None + + +class LearningUnit(LearningUnitBase): + id: str + created_at: datetime + updated_at: datetime + + @classmethod + def from_dict(cls, data: Dict) -> "LearningUnit": + data = data.copy() + if isinstance(data.get("created_at"), str): + data["created_at"] = datetime.fromisoformat(data["created_at"]) + if isinstance(data.get("updated_at"), str): + data["updated_at"] = datetime.fromisoformat(data["updated_at"]) + return cls(**data) + + def to_dict(self) -> Dict: + d = self.dict() + d["created_at"] = self.created_at.isoformat() + d["updated_at"] = self.updated_at.isoformat() + return d + + +def _ensure_storage(): + """Sorgt dafür, dass der Ordner und die JSON-Datei existieren.""" + LEARNING_UNITS_DIR.mkdir(parents=True, exist_ok=True) + if not LEARNING_UNITS_FILE.exists(): + with LEARNING_UNITS_FILE.open("w", encoding="utf-8") as f: + json.dump({}, f) + + +def _load_all_units() -> Dict[str, Dict]: + _ensure_storage() + with LEARNING_UNITS_FILE.open("r", encoding="utf-8") as f: + try: + data = json.load(f) + if not isinstance(data, dict): + return {} + return data + except json.JSONDecodeError: + return {} + + +def _save_all_units(raw: Dict[str, Dict]) -> None: + _ensure_storage() + with LEARNING_UNITS_FILE.open("w", encoding="utf-8") as f: + json.dump(raw, f, ensure_ascii=False, indent=2) + + +def list_learning_units() -> List[LearningUnit]: + with _lock: + raw = _load_all_units() + return [LearningUnit.from_dict(v) for v in raw.values()] + + +def get_learning_unit(unit_id: str) -> Optional[LearningUnit]: + with _lock: + raw = _load_all_units() + data = raw.get(unit_id) + if not data: + return None + return LearningUnit.from_dict(data) + + +def create_learning_unit(payload: LearningUnitCreate) -> LearningUnit: + now = datetime.utcnow() + lu = LearningUnit( + id=str(uuid.uuid4()), + created_at=now, + updated_at=now, + **payload.dict() + ) + with _lock: + raw = _load_all_units() + raw[lu.id] = lu.to_dict() + _save_all_units(raw) + return lu + + +def update_learning_unit(unit_id: str, payload: LearningUnitUpdate) -> Optional[LearningUnit]: + with _lock: + raw = _load_all_units() + existing = raw.get(unit_id) + if not existing: + return None + + lu = LearningUnit.from_dict(existing) + update_data = payload.dict(exclude_unset=True) + + for field, value in update_data.items(): + setattr(lu, field, value) + + lu.updated_at = datetime.utcnow() + raw[lu.id] = lu.to_dict() + _save_all_units(raw) + return lu + + +def delete_learning_unit(unit_id: str) -> bool: + with _lock: + raw = _load_all_units() + if unit_id not in raw: + return False + del raw[unit_id] + _save_all_units(raw) + return True + + +def attach_worksheets(unit_id: str, worksheet_files: List[str]) -> Optional[LearningUnit]: + """ + Hängt eine Liste von Arbeitsblatt-Dateien an eine bestehende Lerneinheit an. + Doppelte Einträge werden vermieden. + """ + with _lock: + raw = _load_all_units() + existing = raw.get(unit_id) + if not existing: + return None + + lu = LearningUnit.from_dict(existing) + current_set = set(lu.worksheet_files) + for f in worksheet_files: + current_set.add(f) + lu.worksheet_files = sorted(current_set) + lu.updated_at = datetime.utcnow() + + raw[lu.id] = lu.to_dict() + _save_all_units(raw) + return lu + diff --git a/backend/learning_units_api.py b/backend/learning_units_api.py new file mode 100644 index 0000000..0d07459 --- /dev/null +++ b/backend/learning_units_api.py @@ -0,0 +1,197 @@ +from typing import List, Dict, Any, Optional +from datetime import datetime + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + +from learning_units import ( + LearningUnit, + LearningUnitCreate, + LearningUnitUpdate, + list_learning_units, + get_learning_unit, + create_learning_unit, + update_learning_unit, + delete_learning_unit, +) + + +router = APIRouter( + prefix="/learning-units", + tags=["learning-units"], +) + + +# ---------- Payload-Modelle für das Frontend ---------- + + +class LearningUnitCreatePayload(BaseModel): + """ + Payload so, wie er aus dem Frontend kommt: + { + "student": "...", + "subject": "...", + "title": "...", + "grade": "7a" + } + """ + student: Optional[str] = None + subject: Optional[str] = None + title: Optional[str] = None + grade: Optional[str] = None + + +class AttachWorksheetsPayload(BaseModel): + worksheet_files: List[str] + + +class RemoveWorksheetPayload(BaseModel): + worksheet_file: str + + +# ---------- Hilfsfunktion: Backend-Modell -> Frontend-Objekt ---------- + + +def unit_to_frontend_dict(lu: LearningUnit) -> Dict[str, Any]: + """ + Wandelt eine LearningUnit in das Format um, das das Frontend erwartet. + Wichtig sind: + - id + - label (sichtbarer Name) + - meta (Untertitelzeile) + - worksheet_files (Liste von Dateinamen) + """ + label = lu.title or "Lerneinheit" + + # Meta-Text: z.B. "Thema: Auge · Klasse: 7a · angelegt am 10.12.2025" + meta_parts: List[str] = [] + if lu.topic: + meta_parts.append(f"Thema: {lu.topic}") + if lu.grade_level: + meta_parts.append(f"Klasse: {lu.grade_level}") + created_str = lu.created_at.strftime("%d.%m.%Y") + meta_parts.append(f"angelegt am {created_str}") + + meta = " · ".join(meta_parts) + + return { + "id": lu.id, + "label": label, + "meta": meta, + "title": lu.title, + "topic": lu.topic, + "grade_level": lu.grade_level, + "language": lu.language, + "status": lu.status, + "worksheet_files": lu.worksheet_files, + "created_at": lu.created_at.isoformat(), + "updated_at": lu.updated_at.isoformat(), + } + + +# ---------- Endpunkte ---------- + + +@router.get("/", response_model=List[Dict[str, Any]]) +def api_list_learning_units(): + """Alle Lerneinheiten für das Frontend auflisten.""" + units = list_learning_units() + return [unit_to_frontend_dict(u) for u in units] + + +@router.post("/", response_model=Dict[str, Any]) +def api_create_learning_unit(payload: LearningUnitCreatePayload): + """ + Neue Lerneinheit anlegen. + Mapped das Frontend-Payload (student/subject/title/grade) + auf das generische LearningUnit-Modell. + """ + + # Mindestens eines der Felder muss gesetzt sein + if not (payload.student or payload.subject or payload.title): + raise HTTPException( + status_code=400, + detail="Bitte mindestens Schüler/in, Fach oder Thema angeben.", + ) + + # Titel/Topic bestimmen + # sichtbarer Titel: bevorzugt Thema (title), sonst Kombination + if payload.title: + title = payload.title + else: + parts = [] + if payload.subject: + parts.append(payload.subject) + if payload.student: + parts.append(payload.student) + title = " – ".join(parts) if parts else "Lerneinheit" + + topic = payload.title or payload.subject or None + grade_level = payload.grade or None + + lu_create = LearningUnitCreate( + title=title, + description=None, + topic=topic, + grade_level=grade_level, + language="de", + worksheet_files=[], + status="raw", + ) + + lu = create_learning_unit(lu_create) + return unit_to_frontend_dict(lu) + + +@router.post("/{unit_id}/attach-worksheets", response_model=Dict[str, Any]) +def api_attach_worksheets(unit_id: str, payload: AttachWorksheetsPayload): + """ + Fügt der Lerneinheit eine oder mehrere Arbeitsblätter hinzu. + """ + lu = get_learning_unit(unit_id) + if not lu: + raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.") + + files_to_add = [f for f in payload.worksheet_files if f not in lu.worksheet_files] + if files_to_add: + new_list = lu.worksheet_files + files_to_add + update = LearningUnitUpdate(worksheet_files=new_list) + lu = update_learning_unit(unit_id, update) + if not lu: + raise HTTPException(status_code=500, detail="Lerneinheit konnte nicht aktualisiert werden.") + + return unit_to_frontend_dict(lu) + + +@router.post("/{unit_id}/remove-worksheet", response_model=Dict[str, Any]) +def api_remove_worksheet(unit_id: str, payload: RemoveWorksheetPayload): + """ + Entfernt genau ein Arbeitsblatt aus der Lerneinheit. + """ + lu = get_learning_unit(unit_id) + if not lu: + raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.") + + if payload.worksheet_file not in lu.worksheet_files: + # Nichts zu tun, aber kein Fehler – einfach unverändert zurückgeben + return unit_to_frontend_dict(lu) + + new_list = [f for f in lu.worksheet_files if f != payload.worksheet_file] + update = LearningUnitUpdate(worksheet_files=new_list) + lu = update_learning_unit(unit_id, update) + if not lu: + raise HTTPException(status_code=500, detail="Lerneinheit konnte nicht aktualisiert werden.") + + return unit_to_frontend_dict(lu) + + +@router.delete("/{unit_id}") +def api_delete_learning_unit(unit_id: str): + """ + Lerneinheit komplett löschen (aktuell vom Frontend noch nicht verwendet). + """ + ok = delete_learning_unit(unit_id) + if not ok: + raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.") + return {"status": "deleted", "id": unit_id} + diff --git a/backend/letters_api.py b/backend/letters_api.py new file mode 100644 index 0000000..b95606d --- /dev/null +++ b/backend/letters_api.py @@ -0,0 +1,641 @@ +""" +Letters API - Elternbrief-Verwaltung für BreakPilot. + +Bietet Endpoints für: +- Speichern und Laden von Elternbriefen +- PDF-Export von Briefen +- Versenden per Email +- GFK-Integration für Textverbesserung + +Arbeitet zusammen mit: +- services/pdf_service.py für PDF-Generierung +- llm_gateway/services/communication_service.py für GFK-Verbesserungen +""" + +import logging +import os +import uuid +from datetime import datetime +from typing import Optional, List, Dict, Any +from enum import Enum + +from fastapi import APIRouter, HTTPException, Response, Query +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field +import httpx +import io + +# PDF service requires WeasyPrint with system libraries - make optional for CI +try: + from services.pdf_service import generate_letter_pdf, SchoolInfo + _pdf_available = True +except (ImportError, OSError): + generate_letter_pdf = None # type: ignore + SchoolInfo = None # type: ignore + _pdf_available = False + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/letters", tags=["letters"]) + + +# ============================================================================= +# Enums +# ============================================================================= + +class LetterType(str, Enum): + """Typen von Elternbriefen.""" + GENERAL = "general" # Allgemeine Information + HALBJAHR = "halbjahr" # Halbjahresinformation + FEHLZEITEN = "fehlzeiten" # Fehlzeiten-Mitteilung + ELTERNABEND = "elternabend" # Einladung Elternabend + LOB = "lob" # Positives Feedback + CUSTOM = "custom" # Benutzerdefiniert + + +class LetterTone(str, Enum): + """Tonalität der Briefe.""" + FORMAL = "formal" + PROFESSIONAL = "professional" + WARM = "warm" + CONCERNED = "concerned" + APPRECIATIVE = "appreciative" + + +class LetterStatus(str, Enum): + """Status eines Briefes.""" + DRAFT = "draft" + SENT = "sent" + ARCHIVED = "archived" + + +# ============================================================================= +# Pydantic Models +# ============================================================================= + +class SchoolInfoModel(BaseModel): + """Schulinformationen für Briefkopf.""" + name: str + address: str + phone: str + email: str + website: Optional[str] = None + principal: Optional[str] = None + logo_path: Optional[str] = None + + +class LegalReferenceModel(BaseModel): + """Rechtliche Referenz.""" + law: str + paragraph: str + title: str + summary: Optional[str] = None + relevance: Optional[str] = None + + +class LetterCreateRequest(BaseModel): + """Request zum Erstellen eines neuen Briefes.""" + recipient_name: str = Field(..., description="Name des Empfängers (z.B. 'Familie Müller')") + recipient_address: str = Field(..., description="Adresse des Empfängers") + student_name: str = Field(..., description="Name des Schülers") + student_class: str = Field(..., description="Klasse des Schülers") + subject: str = Field(..., description="Betreff des Briefes") + content: str = Field(..., description="Inhalt des Briefes") + letter_type: LetterType = Field(LetterType.GENERAL, description="Art des Briefes") + tone: LetterTone = Field(LetterTone.PROFESSIONAL, description="Tonalität des Briefes") + teacher_name: str = Field(..., description="Name des Lehrers") + teacher_title: Optional[str] = Field(None, description="Titel des Lehrers (z.B. 'Klassenlehrerin')") + school_info: Optional[SchoolInfoModel] = Field(None, description="Schulinformationen für Briefkopf") + legal_references: Optional[List[LegalReferenceModel]] = Field(None, description="Rechtliche Referenzen") + gfk_principles_applied: Optional[List[str]] = Field(None, description="Angewandte GFK-Prinzipien") + + +class LetterUpdateRequest(BaseModel): + """Request zum Aktualisieren eines Briefes.""" + recipient_name: Optional[str] = None + recipient_address: Optional[str] = None + student_name: Optional[str] = None + student_class: Optional[str] = None + subject: Optional[str] = None + content: Optional[str] = None + letter_type: Optional[LetterType] = None + tone: Optional[LetterTone] = None + teacher_name: Optional[str] = None + teacher_title: Optional[str] = None + school_info: Optional[SchoolInfoModel] = None + legal_references: Optional[List[LegalReferenceModel]] = None + gfk_principles_applied: Optional[List[str]] = None + status: Optional[LetterStatus] = None + + +class LetterResponse(BaseModel): + """Response mit Briefdaten.""" + id: str + recipient_name: str + recipient_address: str + student_name: str + student_class: str + subject: str + content: str + letter_type: LetterType + tone: LetterTone + teacher_name: str + teacher_title: Optional[str] + school_info: Optional[SchoolInfoModel] + legal_references: Optional[List[LegalReferenceModel]] + gfk_principles_applied: Optional[List[str]] + gfk_score: Optional[float] + status: LetterStatus + pdf_path: Optional[str] + dsms_cid: Optional[str] + sent_at: Optional[datetime] + created_at: datetime + updated_at: datetime + + +class LetterListResponse(BaseModel): + """Response mit Liste von Briefen.""" + letters: List[LetterResponse] + total: int + page: int + page_size: int + + +class ExportPDFRequest(BaseModel): + """Request zum PDF-Export.""" + letter_id: Optional[str] = Field(None, description="ID eines gespeicherten Briefes") + letter_data: Optional[LetterCreateRequest] = Field(None, description="Oder direkte Briefdaten") + + +class ImproveRequest(BaseModel): + """Request zur GFK-Verbesserung.""" + content: str = Field(..., description="Text zur Verbesserung") + communication_type: Optional[str] = Field("general_info", description="Art der Kommunikation") + tone: Optional[str] = Field("professional", description="Gewünschte Tonalität") + + +class ImproveResponse(BaseModel): + """Response mit verbessertem Text.""" + improved_content: str + changes: List[str] + gfk_score: float + gfk_principles_applied: List[str] + + +class SendEmailRequest(BaseModel): + """Request zum Email-Versand.""" + letter_id: str + recipient_email: str + cc_emails: Optional[List[str]] = None + include_pdf: bool = True + + +class SendEmailResponse(BaseModel): + """Response nach Email-Versand.""" + success: bool + message: str + sent_at: Optional[datetime] + + +# ============================================================================= +# In-Memory Storage (Prototyp - später durch DB ersetzen) +# ============================================================================= + +_letters_store: Dict[str, Dict[str, Any]] = {} + + +def _get_letter(letter_id: str) -> Dict[str, Any]: + """Holt Brief aus dem Store.""" + if letter_id not in _letters_store: + raise HTTPException(status_code=404, detail=f"Brief mit ID {letter_id} nicht gefunden") + return _letters_store[letter_id] + + +def _save_letter(letter_data: Dict[str, Any]) -> str: + """Speichert Brief und gibt ID zurück.""" + letter_id = letter_data.get("id") or str(uuid.uuid4()) + letter_data["id"] = letter_id + letter_data["updated_at"] = datetime.now() + if "created_at" not in letter_data: + letter_data["created_at"] = datetime.now() + _letters_store[letter_id] = letter_data + return letter_id + + +# ============================================================================= +# API Endpoints +# ============================================================================= + +@router.post("/", response_model=LetterResponse) +async def create_letter(request: LetterCreateRequest): + """ + Erstellt einen neuen Elternbrief. + + Der Brief wird als Entwurf gespeichert und kann später bearbeitet, + als PDF exportiert oder per Email versendet werden. + """ + logger.info(f"Creating new letter for student: {request.student_name}") + + letter_data = { + "recipient_name": request.recipient_name, + "recipient_address": request.recipient_address, + "student_name": request.student_name, + "student_class": request.student_class, + "subject": request.subject, + "content": request.content, + "letter_type": request.letter_type, + "tone": request.tone, + "teacher_name": request.teacher_name, + "teacher_title": request.teacher_title, + "school_info": request.school_info.model_dump() if request.school_info else None, + "legal_references": [ref.model_dump() for ref in request.legal_references] if request.legal_references else None, + "gfk_principles_applied": request.gfk_principles_applied, + "gfk_score": None, + "status": LetterStatus.DRAFT, + "pdf_path": None, + "dsms_cid": None, + "sent_at": None, + } + + letter_id = _save_letter(letter_data) + letter_data["id"] = letter_id + + logger.info(f"Letter created with ID: {letter_id}") + return LetterResponse(**letter_data) + + +# NOTE: Static routes must come BEFORE dynamic routes like /{letter_id} +@router.get("/types") +async def get_letter_types(): + """ + Gibt alle verfügbaren Brieftypen zurück. + """ + return { + "types": [ + {"value": t.value, "label": _get_type_label(t)} + for t in LetterType + ] + } + + +@router.get("/tones") +async def get_letter_tones(): + """ + Gibt alle verfügbaren Tonalitäten zurück. + """ + return { + "tones": [ + {"value": t.value, "label": _get_tone_label(t)} + for t in LetterTone + ] + } + + +@router.get("/{letter_id}", response_model=LetterResponse) +async def get_letter(letter_id: str): + """ + Lädt einen gespeicherten Brief. + """ + logger.info(f"Getting letter: {letter_id}") + letter_data = _get_letter(letter_id) + return LetterResponse(**letter_data) + + +@router.get("/", response_model=LetterListResponse) +async def list_letters( + student_id: Optional[str] = Query(None, description="Filter nach Schüler-ID"), + class_name: Optional[str] = Query(None, description="Filter nach Klasse"), + letter_type: Optional[LetterType] = Query(None, description="Filter nach Brief-Typ"), + status: Optional[LetterStatus] = Query(None, description="Filter nach Status"), + page: int = Query(1, ge=1, description="Seitennummer"), + page_size: int = Query(20, ge=1, le=100, description="Einträge pro Seite") +): + """ + Listet alle gespeicherten Briefe mit optionalen Filtern. + """ + logger.info("Listing letters with filters") + + # Filter anwenden + filtered_letters = list(_letters_store.values()) + + if class_name: + filtered_letters = [l for l in filtered_letters if l.get("student_class") == class_name] + if letter_type: + filtered_letters = [l for l in filtered_letters if l.get("letter_type") == letter_type] + if status: + filtered_letters = [l for l in filtered_letters if l.get("status") == status] + + # Sortieren nach Erstelldatum (neueste zuerst) + filtered_letters.sort(key=lambda x: x.get("created_at", datetime.min), reverse=True) + + # Paginierung + total = len(filtered_letters) + start = (page - 1) * page_size + end = start + page_size + paginated_letters = filtered_letters[start:end] + + return LetterListResponse( + letters=[LetterResponse(**l) for l in paginated_letters], + total=total, + page=page, + page_size=page_size + ) + + +@router.put("/{letter_id}", response_model=LetterResponse) +async def update_letter(letter_id: str, request: LetterUpdateRequest): + """ + Aktualisiert einen bestehenden Brief. + """ + logger.info(f"Updating letter: {letter_id}") + letter_data = _get_letter(letter_id) + + # Nur übergebene Felder aktualisieren + update_data = request.model_dump(exclude_unset=True) + for key, value in update_data.items(): + if value is not None: + if key == "school_info" and value: + letter_data[key] = value if isinstance(value, dict) else value.model_dump() + elif key == "legal_references" and value: + letter_data[key] = [ref if isinstance(ref, dict) else ref.model_dump() for ref in value] + else: + letter_data[key] = value + + _save_letter(letter_data) + + return LetterResponse(**letter_data) + + +@router.delete("/{letter_id}") +async def delete_letter(letter_id: str): + """ + Löscht einen Brief. + """ + logger.info(f"Deleting letter: {letter_id}") + if letter_id not in _letters_store: + raise HTTPException(status_code=404, detail=f"Brief mit ID {letter_id} nicht gefunden") + + del _letters_store[letter_id] + return {"message": f"Brief {letter_id} wurde gelöscht"} + + +@router.post("/export-pdf") +async def export_letter_pdf(request: ExportPDFRequest): + """ + Exportiert einen Brief als PDF. + + Kann entweder einen gespeicherten Brief (per letter_id) oder + direkte Briefdaten (per letter_data) als PDF exportieren. + + Gibt das PDF als Download zurück. + """ + logger.info("Exporting letter as PDF") + + # Briefdaten ermitteln + if request.letter_id: + letter_data = _get_letter(request.letter_id) + elif request.letter_data: + letter_data = request.letter_data.model_dump() + else: + raise HTTPException( + status_code=400, + detail="Entweder letter_id oder letter_data muss angegeben werden" + ) + + # Datum hinzufügen falls nicht vorhanden + if "date" not in letter_data: + letter_data["date"] = datetime.now().strftime("%d.%m.%Y") + + # PDF generieren + try: + pdf_bytes = generate_letter_pdf(letter_data) + except Exception as e: + logger.error(f"Error generating PDF: {e}") + raise HTTPException(status_code=500, detail=f"Fehler bei PDF-Generierung: {str(e)}") + + # Dateiname erstellen + student_name = letter_data.get("student_name", "Brief").replace(" ", "_") + date_str = datetime.now().strftime("%Y%m%d") + filename = f"Elternbrief_{student_name}_{date_str}.pdf" + + # PDF als Download zurückgeben + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={ + "Content-Disposition": f"attachment; filename={filename}", + "Content-Length": str(len(pdf_bytes)) + } + ) + + +@router.post("/{letter_id}/export-pdf") +async def export_saved_letter_pdf(letter_id: str): + """ + Exportiert einen gespeicherten Brief als PDF (Kurzform). + """ + return await export_letter_pdf(ExportPDFRequest(letter_id=letter_id)) + + +@router.post("/improve", response_model=ImproveResponse) +async def improve_letter_content(request: ImproveRequest): + """ + Verbessert den Briefinhalt nach GFK-Prinzipien. + + Nutzt die Communication Service API für KI-gestützte Verbesserungen. + """ + logger.info("Improving letter content with GFK principles") + + # Communication Service URL (läuft im gleichen Backend) + comm_service_url = os.getenv( + "COMMUNICATION_SERVICE_URL", + "http://localhost:8000/v1/communication" + ) + + try: + async with httpx.AsyncClient() as client: + # Validierung des aktuellen Textes + validate_response = await client.post( + f"{comm_service_url}/validate", + json={"text": request.content}, + timeout=30.0 + ) + + if validate_response.status_code != 200: + logger.warning(f"Validation service returned {validate_response.status_code}") + # Fallback: Original-Text zurückgeben + return ImproveResponse( + improved_content=request.content, + changes=["Verbesserungsservice nicht verfügbar"], + gfk_score=0.5, + gfk_principles_applied=[] + ) + + validation_data = validate_response.json() + + # Falls Text schon gut ist, keine Änderungen + if validation_data.get("is_valid", False) and validation_data.get("gfk_score", 0) > 0.8: + return ImproveResponse( + improved_content=request.content, + changes=["Text entspricht bereits GFK-Standards"], + gfk_score=validation_data.get("gfk_score", 0.8), + gfk_principles_applied=validation_data.get("positive_elements", []) + ) + + # Verbesserungsvorschläge als Änderungen + changes = validation_data.get("suggestions", []) + gfk_score = validation_data.get("gfk_score", 0.5) + gfk_principles = validation_data.get("positive_elements", []) + + # TODO: Hier könnte ein LLM den Text basierend auf den Vorschlägen verbessern + # Für jetzt geben wir den Original-Text mit den Verbesserungsvorschlägen zurück + return ImproveResponse( + improved_content=request.content, + changes=changes, + gfk_score=gfk_score, + gfk_principles_applied=gfk_principles + ) + + except httpx.TimeoutException: + logger.error("Timeout while calling communication service") + return ImproveResponse( + improved_content=request.content, + changes=["Zeitüberschreitung beim Verbesserungsservice"], + gfk_score=0.5, + gfk_principles_applied=[] + ) + except Exception as e: + logger.error(f"Error improving content: {e}") + return ImproveResponse( + improved_content=request.content, + changes=[f"Fehler: {str(e)}"], + gfk_score=0.5, + gfk_principles_applied=[] + ) + + +@router.post("/{letter_id}/send", response_model=SendEmailResponse) +async def send_letter_email(letter_id: str, request: SendEmailRequest): + """ + Versendet einen Brief per Email. + + Der Brief wird als PDF angehängt (wenn include_pdf=True) + und der Status wird auf 'sent' gesetzt. + """ + logger.info(f"Sending letter {letter_id} to {request.recipient_email}") + + # Brief laden + letter_data = _get_letter(letter_id) + + # Email-Service URL (Mailpit oder SMTP) + email_service_url = os.getenv( + "EMAIL_SERVICE_URL", + "http://localhost:8025/api/v1/send" # Mailpit default + ) + + try: + # PDF generieren falls gewünscht + pdf_attachment = None + if request.include_pdf: + letter_data["date"] = datetime.now().strftime("%d.%m.%Y") + pdf_bytes = generate_letter_pdf(letter_data) + pdf_attachment = { + "filename": f"Elternbrief_{letter_data.get('student_name', 'Brief').replace(' ', '_')}.pdf", + "content": pdf_bytes.hex(), # Hex-encoded für JSON + "content_type": "application/pdf" + } + + # Email senden (vereinfachte Implementierung) + # In der Praxis würde hier ein richtiger Email-Service aufgerufen + async with httpx.AsyncClient() as client: + email_data = { + "to": request.recipient_email, + "cc": request.cc_emails or [], + "subject": letter_data.get("subject", "Elternbrief"), + "body": letter_data.get("content", ""), + "attachments": [pdf_attachment] if pdf_attachment else [] + } + + # Für Prototyp: Nur loggen, nicht wirklich senden + logger.info(f"Would send email: {email_data['subject']} to {email_data['to']}") + + # Status aktualisieren + letter_data["status"] = LetterStatus.SENT + letter_data["sent_at"] = datetime.now() + _save_letter(letter_data) + + return SendEmailResponse( + success=True, + message=f"Brief wurde an {request.recipient_email} gesendet", + sent_at=datetime.now() + ) + + except Exception as e: + logger.error(f"Error sending email: {e}") + return SendEmailResponse( + success=False, + message=f"Fehler beim Versenden: {str(e)}", + sent_at=None + ) + + +@router.get("/student/{student_id}", response_model=LetterListResponse) +async def get_letters_for_student( + student_id: str, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100) +): + """ + Lädt alle Briefe für einen bestimmten Schüler. + """ + logger.info(f"Getting letters for student: {student_id}") + + # In einem echten System würde hier nach student_id gefiltert + # Für Prototyp filtern wir nach student_name + filtered_letters = [ + l for l in _letters_store.values() + if student_id.lower() in l.get("student_name", "").lower() + ] + + # Sortieren und Paginierung + filtered_letters.sort(key=lambda x: x.get("created_at", datetime.min), reverse=True) + total = len(filtered_letters) + start = (page - 1) * page_size + end = start + page_size + paginated_letters = filtered_letters[start:end] + + return LetterListResponse( + letters=[LetterResponse(**l) for l in paginated_letters], + total=total, + page=page, + page_size=page_size + ) + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def _get_type_label(letter_type: LetterType) -> str: + """Gibt menschenlesbare Labels für Brieftypen zurück.""" + labels = { + LetterType.GENERAL: "Allgemeine Information", + LetterType.HALBJAHR: "Halbjahresinformation", + LetterType.FEHLZEITEN: "Fehlzeiten-Mitteilung", + LetterType.ELTERNABEND: "Einladung Elternabend", + LetterType.LOB: "Positives Feedback", + LetterType.CUSTOM: "Benutzerdefiniert", + } + return labels.get(letter_type, letter_type.value) + + +def _get_tone_label(tone: LetterTone) -> str: + """Gibt menschenlesbare Labels für Tonalitäten zurück.""" + labels = { + LetterTone.FORMAL: "Sehr förmlich", + LetterTone.PROFESSIONAL: "Professionell-freundlich", + LetterTone.WARM: "Warmherzig", + LetterTone.CONCERNED: "Besorgt", + LetterTone.APPRECIATIVE: "Wertschätzend", + } + return labels.get(tone, tone.value) diff --git a/backend/llm_gateway/__init__.py b/backend/llm_gateway/__init__.py new file mode 100644 index 0000000..b7af98a --- /dev/null +++ b/backend/llm_gateway/__init__.py @@ -0,0 +1,8 @@ +""" +BreakPilot LLM Gateway + +OpenAI-kompatibles API Gateway für Self-hosted LLMs. +Unterstützt: Ollama (lokal), vLLM (remote), Claude API (Fallback) +""" + +__version__ = "0.1.0" diff --git a/backend/llm_gateway/config.py b/backend/llm_gateway/config.py new file mode 100644 index 0000000..3f855b4 --- /dev/null +++ b/backend/llm_gateway/config.py @@ -0,0 +1,122 @@ +""" +LLM Gateway Konfiguration + +Lädt Einstellungen aus Umgebungsvariablen. +""" + +import os +from typing import Optional +from dataclasses import dataclass, field + + +@dataclass +class LLMBackendConfig: + """Konfiguration für ein LLM Backend.""" + name: str + base_url: str + api_key: Optional[str] = None + default_model: str = "" + timeout: int = 120 + enabled: bool = True + + +@dataclass +class GatewayConfig: + """Hauptkonfiguration für das LLM Gateway.""" + + # Server + host: str = "0.0.0.0" + port: int = 8002 + debug: bool = False + + # Auth + jwt_secret: str = "" + api_keys: list[str] = field(default_factory=list) + + # Rate Limiting + rate_limit_requests_per_minute: int = 60 + rate_limit_tokens_per_minute: int = 100000 + + # Backends + ollama: Optional[LLMBackendConfig] = None + vllm: Optional[LLMBackendConfig] = None + anthropic: Optional[LLMBackendConfig] = None + + # Default Backend Priorität + backend_priority: list[str] = field(default_factory=lambda: ["ollama", "vllm", "anthropic"]) + + # Playbooks + playbooks_enabled: bool = True + + # Logging + log_level: str = "INFO" + audit_logging: bool = True + + +def load_config() -> GatewayConfig: + """Lädt Konfiguration aus Umgebungsvariablen.""" + + config = GatewayConfig( + host=os.getenv("LLM_GATEWAY_HOST", "0.0.0.0"), + port=int(os.getenv("LLM_GATEWAY_PORT", "8002")), + debug=os.getenv("LLM_GATEWAY_DEBUG", "false").lower() == "true", + jwt_secret=os.getenv("JWT_SECRET", ""), + api_keys=os.getenv("LLM_API_KEYS", "").split(",") if os.getenv("LLM_API_KEYS") else [], + rate_limit_requests_per_minute=int(os.getenv("LLM_RATE_LIMIT_RPM", "60")), + rate_limit_tokens_per_minute=int(os.getenv("LLM_RATE_LIMIT_TPM", "100000")), + log_level=os.getenv("LLM_LOG_LEVEL", "INFO"), + audit_logging=os.getenv("LLM_AUDIT_LOGGING", "true").lower() == "true", + ) + + # Ollama Backend (lokal) + ollama_url = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") + if ollama_url: + config.ollama = LLMBackendConfig( + name="ollama", + base_url=ollama_url, + default_model=os.getenv("OLLAMA_DEFAULT_MODEL", "llama3.1:8b"), + timeout=int(os.getenv("OLLAMA_TIMEOUT", "120")), + enabled=os.getenv("OLLAMA_ENABLED", "true").lower() == "true", + ) + + # vLLM Backend (remote, z.B. vast.ai) + vllm_url = os.getenv("VLLM_BASE_URL") + if vllm_url: + config.vllm = LLMBackendConfig( + name="vllm", + base_url=vllm_url, + api_key=os.getenv("VLLM_API_KEY"), + default_model=os.getenv("VLLM_DEFAULT_MODEL", "meta-llama/Meta-Llama-3.1-8B-Instruct"), + timeout=int(os.getenv("VLLM_TIMEOUT", "120")), + enabled=os.getenv("VLLM_ENABLED", "true").lower() == "true", + ) + + # Anthropic Backend (Claude API Fallback) + anthropic_key = os.getenv("ANTHROPIC_API_KEY") + if anthropic_key: + config.anthropic = LLMBackendConfig( + name="anthropic", + base_url="https://api.anthropic.com", + api_key=anthropic_key, + default_model=os.getenv("ANTHROPIC_DEFAULT_MODEL", "claude-3-5-sonnet-20241022"), + timeout=int(os.getenv("ANTHROPIC_TIMEOUT", "120")), + enabled=os.getenv("ANTHROPIC_ENABLED", "true").lower() == "true", + ) + + # Backend Priorität + priority = os.getenv("LLM_BACKEND_PRIORITY", "ollama,vllm,anthropic") + config.backend_priority = [b.strip() for b in priority.split(",")] + + return config + + +# Globale Konfiguration (Singleton) +_config: Optional[GatewayConfig] = None + + +def get_config() -> GatewayConfig: + """Gibt die globale Konfiguration zurück.""" + global _config + if _config is None: + _config = load_config() + return _config diff --git a/backend/llm_gateway/main.py b/backend/llm_gateway/main.py new file mode 100644 index 0000000..d266281 --- /dev/null +++ b/backend/llm_gateway/main.py @@ -0,0 +1,85 @@ +""" +BreakPilot LLM Gateway - Main Application + +OpenAI-kompatibles API Gateway für Self-hosted LLMs. +""" + +import logging +from contextlib import asynccontextmanager +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from .config import get_config +from .routes import chat_router, playbooks_router, health_router, comparison_router, edu_search_seeds_router, communication_router +from .services.inference import get_inference_service + +# Logging Setup +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Lifecycle Management für den Gateway.""" + logger.info("Starting LLM Gateway...") + config = get_config() + logger.info(f"Debug mode: {config.debug}") + logger.info(f"Backends configured: ollama={bool(config.ollama)}, vllm={bool(config.vllm)}, anthropic={bool(config.anthropic)}") + + yield + + # Cleanup + logger.info("Shutting down LLM Gateway...") + inference_service = get_inference_service() + await inference_service.close() + + +def create_app() -> FastAPI: + """Factory Function für die FastAPI App.""" + config = get_config() + + app = FastAPI( + title="BreakPilot LLM Gateway", + description="OpenAI-kompatibles API Gateway für Self-hosted LLMs", + version="0.1.0", + lifespan=lifespan, + docs_url="/docs" if config.debug else None, + redoc_url="/redoc" if config.debug else None, + ) + + # CORS + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # In Produktion einschränken + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Routes + app.include_router(health_router) + app.include_router(chat_router, prefix="/v1") + app.include_router(playbooks_router) + app.include_router(comparison_router, prefix="/v1") + app.include_router(edu_search_seeds_router, prefix="/v1") + app.include_router(communication_router, prefix="/v1") + + return app + + +# App Instance für uvicorn +app = create_app() + + +if __name__ == "__main__": + import uvicorn + config = get_config() + uvicorn.run( + "llm_gateway.main:app", + host=config.host, + port=config.port, + reload=config.debug, + ) diff --git a/backend/llm_gateway/middleware/__init__.py b/backend/llm_gateway/middleware/__init__.py new file mode 100644 index 0000000..0b386d8 --- /dev/null +++ b/backend/llm_gateway/middleware/__init__.py @@ -0,0 +1,7 @@ +""" +LLM Gateway Middleware. +""" + +from .auth import verify_api_key + +__all__ = ["verify_api_key"] diff --git a/backend/llm_gateway/middleware/auth.py b/backend/llm_gateway/middleware/auth.py new file mode 100644 index 0000000..5d5f7ab --- /dev/null +++ b/backend/llm_gateway/middleware/auth.py @@ -0,0 +1,96 @@ +""" +Auth Middleware für LLM Gateway. + +Unterstützt: +- API Key Auth (X-API-Key Header oder Authorization Bearer) +- JWT Token Auth (vom Consent Service) +""" + +import logging +from typing import Optional +from fastapi import HTTPException, Header, Depends +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +import jwt + +from ..config import get_config + +logger = logging.getLogger(__name__) + +security = HTTPBearer(auto_error=False) + + +async def verify_api_key( + x_api_key: Optional[str] = Header(None, alias="X-API-Key"), + authorization: Optional[HTTPAuthorizationCredentials] = Depends(security), +) -> str: + """ + Verifiziert den API Key oder JWT Token. + + Akzeptiert: + - X-API-Key Header + - Authorization: Bearer + + Returns: + str: User ID oder "api_key" bei API Key Auth + """ + config = get_config() + + # 1. Prüfe X-API-Key Header + if x_api_key: + if x_api_key in config.api_keys: + return "api_key" + logger.warning(f"Invalid API key attempted") + raise HTTPException( + status_code=401, + detail={"error": "unauthorized", "message": "Invalid API key"}, + ) + + # 2. Prüfe Authorization Header + if authorization: + token = authorization.credentials + + # Prüfe ob es ein API Key ist + if token in config.api_keys: + return "api_key" + + # Versuche JWT zu dekodieren + if config.jwt_secret: + try: + payload = jwt.decode( + token, + config.jwt_secret, + algorithms=["HS256"], + ) + user_id = payload.get("user_id") or payload.get("sub") + if user_id: + return str(user_id) + except jwt.ExpiredSignatureError: + raise HTTPException( + status_code=401, + detail={"error": "token_expired", "message": "Token has expired"}, + ) + except jwt.InvalidTokenError as e: + logger.warning(f"Invalid JWT token: {e}") + raise HTTPException( + status_code=401, + detail={"error": "invalid_token", "message": "Invalid token"}, + ) + + # 3. In Development Mode ohne Auth erlauben + if config.debug: + logger.warning("Auth bypassed in debug mode") + return "debug_user" + + # 4. Keine gültige Auth gefunden + raise HTTPException( + status_code=401, + detail={ + "error": "unauthorized", + "message": "API key or valid token required", + }, + ) + + +def get_current_user_id(user_id: str = Depends(verify_api_key)) -> str: + """Dependency um die aktuelle User ID zu bekommen.""" + return user_id diff --git a/backend/llm_gateway/models/__init__.py b/backend/llm_gateway/models/__init__.py new file mode 100644 index 0000000..16b6a87 --- /dev/null +++ b/backend/llm_gateway/models/__init__.py @@ -0,0 +1,31 @@ +""" +Pydantic Models für OpenAI-kompatible API. +""" + +from .chat import ( + ChatMessage, + ChatCompletionRequest, + ChatCompletionResponse, + ChatCompletionChunk, + ChatChoice, + ChatChoiceDelta, + Usage, + ToolCall, + FunctionCall, + Tool, + ToolFunction, +) + +__all__ = [ + "ChatMessage", + "ChatCompletionRequest", + "ChatCompletionResponse", + "ChatCompletionChunk", + "ChatChoice", + "ChatChoiceDelta", + "Usage", + "ToolCall", + "FunctionCall", + "Tool", + "ToolFunction", +] diff --git a/backend/llm_gateway/models/chat.py b/backend/llm_gateway/models/chat.py new file mode 100644 index 0000000..2b99448 --- /dev/null +++ b/backend/llm_gateway/models/chat.py @@ -0,0 +1,135 @@ +""" +OpenAI-kompatible Chat Completion Models. + +Basiert auf OpenAI API Spezifikation: +https://platform.openai.com/docs/api-reference/chat/create +""" + +from __future__ import annotations +from typing import Optional, Literal, Any, Union, List, Dict +from pydantic import BaseModel, Field +import time +import uuid + + +class FunctionCall(BaseModel): + """Function call in einer Tool-Anfrage.""" + name: str + arguments: str # JSON string + + +class ToolCall(BaseModel): + """Tool Call vom Modell.""" + id: str = Field(default_factory=lambda: f"call_{uuid.uuid4().hex[:12]}") + type: Literal["function"] = "function" + function: FunctionCall + + +class ChatMessage(BaseModel): + """Eine Nachricht im Chat.""" + role: Literal["system", "user", "assistant", "tool"] + content: Optional[str] = None + name: Optional[str] = None + tool_call_id: Optional[str] = None + tool_calls: Optional[list[ToolCall]] = None + + +class ToolFunction(BaseModel): + """Definition einer Tool-Funktion.""" + name: str + description: Optional[str] = None + parameters: dict[str, Any] = Field(default_factory=dict) + + +class Tool(BaseModel): + """Tool-Definition für Function Calling.""" + type: Literal["function"] = "function" + function: ToolFunction + + +class RequestMetadata(BaseModel): + """Zusätzliche Metadaten für die Anfrage.""" + playbook_id: Optional[str] = None + tenant_id: Optional[str] = None + user_id: Optional[str] = None + + +class ChatCompletionRequest(BaseModel): + """Request für Chat Completions.""" + model: str + messages: list[ChatMessage] + stream: bool = False + temperature: Optional[float] = Field(default=0.7, ge=0, le=2) + top_p: Optional[float] = Field(default=1.0, ge=0, le=1) + max_tokens: Optional[int] = Field(default=None, ge=1) + stop: Optional[Union[List[str], str]] = None + presence_penalty: Optional[float] = Field(default=0, ge=-2, le=2) + frequency_penalty: Optional[float] = Field(default=0, ge=-2, le=2) + user: Optional[str] = None + tools: Optional[list[Tool]] = None + tool_choice: Optional[Union[str, Dict[str, Any]]] = None + metadata: Optional[RequestMetadata] = None + + +class ChatChoice(BaseModel): + """Ein Choice in der Response.""" + index: int = 0 + message: ChatMessage + finish_reason: Optional[Literal["stop", "length", "tool_calls", "content_filter"]] = None + + +class ChatChoiceDelta(BaseModel): + """Delta für Streaming Response.""" + role: Optional[str] = None + content: Optional[str] = None + tool_calls: Optional[list[ToolCall]] = None + + +class StreamChoice(BaseModel): + """Choice in Streaming Response.""" + index: int = 0 + delta: ChatChoiceDelta + finish_reason: Optional[Literal["stop", "length", "tool_calls", "content_filter"]] = None + + +class Usage(BaseModel): + """Token Usage Statistiken.""" + prompt_tokens: int = 0 + completion_tokens: int = 0 + total_tokens: int = 0 + + +class ChatCompletionResponse(BaseModel): + """Response für Chat Completions (non-streaming).""" + id: str = Field(default_factory=lambda: f"chatcmpl-{uuid.uuid4().hex[:12]}") + object: Literal["chat.completion"] = "chat.completion" + created: int = Field(default_factory=lambda: int(time.time())) + model: str + choices: list[ChatChoice] + usage: Optional[Usage] = None + + +class ChatCompletionChunk(BaseModel): + """Chunk für Streaming Response.""" + id: str = Field(default_factory=lambda: f"chatcmpl-{uuid.uuid4().hex[:12]}") + object: Literal["chat.completion.chunk"] = "chat.completion.chunk" + created: int = Field(default_factory=lambda: int(time.time())) + model: str + choices: list[StreamChoice] + + +# Model Info +class ModelInfo(BaseModel): + """Information über ein verfügbares Modell.""" + id: str + object: Literal["model"] = "model" + created: int = Field(default_factory=lambda: int(time.time())) + owned_by: str = "breakpilot" + description: Optional[str] = None + context_length: int = 8192 + + +class ModelListResponse(BaseModel): + """Response für /v1/models.""" + object: Literal["list"] = "list" + data: list[ModelInfo] diff --git a/backend/llm_gateway/routes/__init__.py b/backend/llm_gateway/routes/__init__.py new file mode 100644 index 0000000..5bc10bf --- /dev/null +++ b/backend/llm_gateway/routes/__init__.py @@ -0,0 +1,21 @@ +""" +LLM Gateway Routes. +""" + +from .chat import router as chat_router +from .playbooks import router as playbooks_router +from .health import router as health_router +from .tools import router as tools_router +from .comparison import router as comparison_router +from .edu_search_seeds import router as edu_search_seeds_router +from .communication import router as communication_router + +__all__ = [ + "chat_router", + "playbooks_router", + "health_router", + "tools_router", + "comparison_router", + "edu_search_seeds_router", + "communication_router", +] diff --git a/backend/llm_gateway/routes/chat.py b/backend/llm_gateway/routes/chat.py new file mode 100644 index 0000000..a63ec37 --- /dev/null +++ b/backend/llm_gateway/routes/chat.py @@ -0,0 +1,112 @@ +""" +Chat Completions Route - OpenAI-kompatible API. +""" + +import logging +import json +from typing import AsyncIterator +from fastapi import APIRouter, HTTPException, Depends +from fastapi.responses import StreamingResponse + +from ..models.chat import ( + ChatCompletionRequest, + ChatCompletionResponse, + ChatMessage, + ModelListResponse, +) +from ..services.inference import get_inference_service, InferenceService +from ..services.playbook_service import get_playbook_service, PlaybookService +from ..middleware.auth import verify_api_key + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["LLM"]) + + +def get_services(): + """Dependency für Services.""" + return get_inference_service(), get_playbook_service() + + +@router.post("/chat/completions", response_model=ChatCompletionResponse) +async def chat_completions( + request: ChatCompletionRequest, + _: str = Depends(verify_api_key), +): + """ + OpenAI-kompatible Chat Completions. + + Unterstützt: + - Streaming (stream=true) + - Playbook-basierte System Prompts (metadata.playbook_id) + - Multiple Models (breakpilot-teacher-8b, claude-3-5-sonnet, etc.) + """ + inference_service, playbook_service = get_services() + + # Playbook System Prompt injizieren + if request.metadata and request.metadata.playbook_id: + playbook = playbook_service.get_playbook(request.metadata.playbook_id) + if playbook: + # System Prompt an den Anfang der Messages einfügen + system_msg = ChatMessage(role="system", content=playbook.system_prompt) + # Prüfen ob bereits ein System Prompt existiert + has_system = any(m.role == "system" for m in request.messages) + if not has_system: + request.messages.insert(0, system_msg) + else: + # Playbook Prompt vor bestehenden System Prompt setzen + for i, msg in enumerate(request.messages): + if msg.role == "system": + msg.content = f"{playbook.system_prompt}\n\n{msg.content}" + break + + try: + if request.stream: + return StreamingResponse( + stream_response(request, inference_service), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + else: + response = await inference_service.complete(request) + return response + + except ValueError as e: + logger.error(f"Chat completion error: {e}") + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.exception(f"Chat completion failed: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + + +async def stream_response( + request: ChatCompletionRequest, + inference_service: InferenceService, +) -> AsyncIterator[str]: + """Generator für SSE Streaming.""" + try: + async for chunk in inference_service.stream(request): + data = chunk.model_dump_json() + yield f"data: {data}\n\n" + yield "data: [DONE]\n\n" + except Exception as e: + logger.exception(f"Streaming error: {e}") + error_data = json.dumps({"error": str(e)}) + yield f"data: {error_data}\n\n" + + +@router.get("/models", response_model=ModelListResponse) +async def list_models( + _: str = Depends(verify_api_key), +): + """ + Liste verfügbarer Modelle. + + Gibt alle konfigurierten Modelle zurück, die aktuell verfügbar sind. + """ + inference_service = get_inference_service() + return await inference_service.list_models() diff --git a/backend/llm_gateway/routes/communication.py b/backend/llm_gateway/routes/communication.py new file mode 100644 index 0000000..6571865 --- /dev/null +++ b/backend/llm_gateway/routes/communication.py @@ -0,0 +1,403 @@ +""" +Communication API Routes. + +API-Endpoints für KI-gestützte Lehrer-Eltern-Kommunikation. +Basiert auf den Prinzipien der gewaltfreien Kommunikation (GFK) +und deutschen Schulgesetzen. +""" + +import logging +from typing import Optional, List +from datetime import datetime + +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel, Field + +from ..services.communication_service import ( + get_communication_service, + CommunicationService, + CommunicationType, + CommunicationTone, +) +from ..services.inference import InferenceService, get_inference_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/communication", tags=["communication"]) + + +# ============================================================================= +# Pydantic Models +# ============================================================================= + + +class CommunicationTypeResponse(BaseModel): + """Response für Kommunikationstypen.""" + value: str + label: str + + +class ToneResponse(BaseModel): + """Response für Tonalitäten.""" + value: str + label: str + + +class StateResponse(BaseModel): + """Response für Bundesländer.""" + value: str + label: str + + +class LegalReferenceResponse(BaseModel): + """Response für rechtliche Referenzen.""" + law: str + paragraph: str + title: str + summary: str + relevance: str + + +class GFKPrincipleResponse(BaseModel): + """Response für GFK-Prinzipien.""" + principle: str + description: str + example: str + + +class GenerateRequest(BaseModel): + """Request für Nachrichtengenerierung.""" + communication_type: str = Field(..., description="Art der Kommunikation (z.B. 'behavior', 'academic')") + tone: str = Field("professional", description="Tonalität (formal, professional, warm, concerned, appreciative)") + state: str = Field("NRW", description="Bundesland für rechtliche Referenzen") + student_name: str = Field(..., description="Name des Schülers/der Schülerin") + parent_name: str = Field(..., description="Name der Eltern (z.B. 'Frau Müller')") + situation: str = Field(..., description="Beschreibung der Situation") + additional_info: Optional[str] = Field(None, description="Zusätzliche Informationen") + + +class GenerateResponse(BaseModel): + """Response für generierte Nachrichten.""" + message: str + subject: str + validation: dict + legal_references: List[LegalReferenceResponse] + gfk_principles: List[GFKPrincipleResponse] + + +class ValidateRequest(BaseModel): + """Request für Textvalidierung.""" + text: str = Field(..., description="Der zu validierende Text") + + +class ValidateResponse(BaseModel): + """Response für Validierung.""" + is_valid: bool + issues: List[str] + suggestions: List[str] + positive_elements: List[str] + gfk_score: float + + +# ============================================================================= +# Endpoints +# ============================================================================= + + +@router.get("/types", response_model=List[CommunicationTypeResponse]) +async def get_communication_types(): + """ + Gibt alle verfügbaren Kommunikationstypen zurück. + + Returns: + Liste aller Kommunikationstypen mit Wert und Label + """ + service = get_communication_service() + return service.get_all_communication_types() + + +@router.get("/tones", response_model=List[ToneResponse]) +async def get_tones(): + """ + Gibt alle verfügbaren Tonalitäten zurück. + + Returns: + Liste aller Tonalitäten mit Wert und Label + """ + service = get_communication_service() + return service.get_all_tones() + + +@router.get("/states", response_model=List[StateResponse]) +async def get_states(): + """ + Gibt alle verfügbaren Bundesländer zurück. + + Returns: + Liste aller Bundesländer mit Wert und Label + """ + service = get_communication_service() + return service.get_states() + + +@router.get("/legal-references/{state}") +async def get_legal_references(state: str): + """ + Gibt rechtliche Referenzen für ein Bundesland zurück. + + Args: + state: Bundesland-Kürzel (z.B. NRW, BY) + + Returns: + Rechtliche Referenzen für das Bundesland + """ + service = get_communication_service() + refs = service.get_legal_references(state, "elternpflichten") + + return [ + LegalReferenceResponse( + law=ref.law, + paragraph=ref.paragraph, + title=ref.title, + summary=ref.summary, + relevance=ref.relevance + ) + for ref in refs + ] + + +@router.get("/gfk-principles", response_model=List[GFKPrincipleResponse]) +async def get_gfk_principles(): + """ + Gibt die Prinzipien der gewaltfreien Kommunikation zurück. + + Returns: + Liste der GFK-Prinzipien mit Beschreibung und Beispielen + """ + service = get_communication_service() + principles = service.get_gfk_guidance(CommunicationType.GENERAL_INFO) + + return [ + GFKPrincipleResponse( + principle=p.principle, + description=p.description, + example=p.example + ) + for p in principles + ] + + +@router.post("/generate", response_model=GenerateResponse) +async def generate_communication(request: GenerateRequest): + """ + Generiert einen Elternbrief basierend auf dem Kontext. + + Args: + request: GenerateRequest mit allen nötigen Informationen + + Returns: + GenerateResponse mit generiertem Text und Metadaten + """ + service = get_communication_service() + + # Kommunikationstyp validieren + try: + comm_type = CommunicationType(request.communication_type) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"Ungültiger Kommunikationstyp: {request.communication_type}" + ) + + # Tonalität validieren + try: + tone = CommunicationTone(request.tone) + except ValueError: + tone = CommunicationTone.PROFESSIONAL + + # System- und User-Prompt erstellen + system_prompt = service.build_system_prompt(comm_type, request.state, tone) + user_prompt = service.build_user_prompt(comm_type, { + "student_name": request.student_name, + "parent_name": request.parent_name, + "situation": request.situation, + "additional_info": request.additional_info, + }) + + # Inference-Service aufrufen + try: + inference_service = get_inference_service() + response = await inference_service.generate( + prompt=user_prompt, + system_prompt=system_prompt, + temperature=0.7, # Etwas kreativ, aber kontrolliert + max_tokens=2000, + ) + generated_message = response.get("content", "") + except Exception as e: + logger.error(f"Fehler bei der Nachrichtengenerierung: {e}") + # Fallback: Vorlage verwenden + template = service.get_template(comm_type) + generated_message = f"""{template['opening'].format( + parent_name=request.parent_name, + student_name=request.student_name, + topic=request.situation[:50] + '...' if len(request.situation) > 50 else request.situation + )} + +{request.situation} + +{template['closing'].format( + student_name=request.student_name, + legal_reference=f"des Schulgesetzes" +)}""" + + # Validierung durchführen + validation = service.validate_communication(generated_message) + + # Rechtliche Referenzen holen + topic_map = { + CommunicationType.ATTENDANCE: "schulpflicht", + CommunicationType.BEHAVIOR: "ordnungsmassnahmen", + CommunicationType.ACADEMIC: "foerderung", + CommunicationType.SPECIAL_NEEDS: "foerderung", + } + topic = topic_map.get(comm_type, "elternpflichten") + legal_refs = service.get_legal_references(request.state, topic) + + # GFK-Prinzipien + gfk_principles = service.get_gfk_guidance(comm_type) + + # Betreff generieren + template = service.get_template(comm_type) + subject = template.get("subject", "Mitteilung der Schule").format( + student_name=request.student_name, + topic=request.situation[:30] + '...' if len(request.situation) > 30 else request.situation + ) + + return GenerateResponse( + message=generated_message, + subject=subject, + validation=validation, + legal_references=[ + LegalReferenceResponse( + law=ref.law, + paragraph=ref.paragraph, + title=ref.title, + summary=ref.summary, + relevance=ref.relevance + ) + for ref in legal_refs + ], + gfk_principles=[ + GFKPrincipleResponse( + principle=p.principle, + description=p.description, + example=p.example + ) + for p in gfk_principles + ] + ) + + +@router.post("/validate", response_model=ValidateResponse) +async def validate_communication(request: ValidateRequest): + """ + Validiert einen Text auf GFK-Konformität. + + Args: + request: ValidateRequest mit dem zu prüfenden Text + + Returns: + ValidateResponse mit Validierungsergebnissen + """ + service = get_communication_service() + result = service.validate_communication(request.text) + + return ValidateResponse( + is_valid=result["is_valid"], + issues=result["issues"], + suggestions=result["suggestions"], + positive_elements=result["positive_elements"], + gfk_score=result["gfk_score"] + ) + + +@router.post("/improve") +async def improve_communication(request: ValidateRequest): + """ + Verbessert einen bestehenden Text nach GFK-Prinzipien. + + Args: + request: ValidateRequest mit dem zu verbessernden Text + + Returns: + Verbesserter Text mit Änderungsvorschlägen + """ + service = get_communication_service() + + # Erst validieren + validation = service.validate_communication(request.text) + + if validation["is_valid"] and validation["gfk_score"] >= 0.8: + return { + "improved_text": request.text, + "changes": [], + "was_improved": False, + "message": "Der Text entspricht bereits den GFK-Prinzipien." + } + + # System-Prompt für Verbesserung + system_prompt = """Du bist ein Experte für gewaltfreie Kommunikation (GFK) nach Marshall Rosenberg. +Deine Aufgabe ist es, einen Elternbrief zu verbessern, sodass er den GFK-Prinzipien entspricht. + +VERBESSERUNGSREGELN: +1. Ersetze Bewertungen durch Beobachtungen +2. Ersetze "Sie müssen/sollten" durch Ich-Botschaften und Bitten +3. Entferne Schuldzuweisungen +4. Füge empathische Elemente hinzu +5. Behalte den sachlichen Inhalt bei + +Gib den verbesserten Text zurück und erkläre kurz die wichtigsten Änderungen.""" + + user_prompt = f"""Bitte verbessere folgenden Elternbrief nach den GFK-Prinzipien: + +--- +{request.text} +--- + +Identifizierte Probleme: +{', '.join(validation['issues']) if validation['issues'] else 'Keine spezifischen Probleme gefunden, aber GFK-Score könnte verbessert werden.'} + +Vorschläge: +{', '.join(validation['suggestions']) if validation['suggestions'] else 'Allgemeine Verbesserungen möglich.'}""" + + try: + inference_service = get_inference_service() + response = await inference_service.generate( + prompt=user_prompt, + system_prompt=system_prompt, + temperature=0.5, + max_tokens=2500, + ) + improved_text = response.get("content", request.text) + + # Nochmal validieren + new_validation = service.validate_communication(improved_text) + + return { + "improved_text": improved_text, + "original_issues": validation["issues"], + "was_improved": True, + "old_score": validation["gfk_score"], + "new_score": new_validation["gfk_score"], + "remaining_issues": new_validation["issues"], + } + except Exception as e: + logger.error(f"Fehler bei der Textverbesserung: {e}") + return { + "improved_text": request.text, + "changes": [], + "was_improved": False, + "error": str(e), + "message": "Die automatische Verbesserung ist derzeit nicht verfügbar." + } diff --git a/backend/llm_gateway/routes/comparison.py b/backend/llm_gateway/routes/comparison.py new file mode 100644 index 0000000..b662d40 --- /dev/null +++ b/backend/llm_gateway/routes/comparison.py @@ -0,0 +1,584 @@ +""" +LLM Comparison Route - Vergleicht Antworten verschiedener LLM Backends. + +Dieses Modul ermoeglicht: +- Parallele Anfragen an OpenAI, Claude, Self-hosted+Tavily, Self-hosted+EduSearch +- Speichern von Vergleichsergebnissen fuer QA +- Parameter-Tuning fuer Self-hosted Modelle +""" + +import asyncio +import logging +import time +import uuid +from datetime import datetime, timezone +from typing import Optional +from pydantic import BaseModel, Field +from fastapi import APIRouter, HTTPException, Depends + +from ..models.chat import ChatMessage +from ..middleware.auth import verify_api_key + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/comparison", tags=["LLM Comparison"]) + + +class ComparisonRequest(BaseModel): + """Request fuer LLM-Vergleich.""" + prompt: str = Field(..., description="User prompt (z.B. Lehrer-Frage)") + system_prompt: Optional[str] = Field(None, description="Optionaler System Prompt") + enable_openai: bool = Field(True, description="OpenAI/ChatGPT aktivieren") + enable_claude: bool = Field(True, description="Claude aktivieren") + enable_selfhosted_tavily: bool = Field(True, description="Self-hosted + Tavily aktivieren") + enable_selfhosted_edusearch: bool = Field(True, description="Self-hosted + EduSearch aktivieren") + + # Parameter fuer Self-hosted Modelle + selfhosted_model: str = Field("llama3.2:3b", description="Self-hosted Modell") + temperature: float = Field(0.7, ge=0.0, le=2.0, description="Temperature") + top_p: float = Field(0.9, ge=0.0, le=1.0, description="Top-p Sampling") + max_tokens: int = Field(2048, ge=1, le=8192, description="Max Tokens") + + # Search Parameter + search_results_count: int = Field(5, ge=1, le=20, description="Anzahl Suchergebnisse") + edu_search_filters: Optional[dict] = Field(None, description="Filter fuer EduSearch") + + +class LLMResponse(BaseModel): + """Antwort eines einzelnen LLM.""" + provider: str + model: str + response: str + latency_ms: int + tokens_used: Optional[int] = None + search_results: Optional[list] = None + error: Optional[str] = None + timestamp: datetime = Field(default_factory=datetime.utcnow) + + +class ComparisonResponse(BaseModel): + """Gesamt-Antwort des Vergleichs.""" + comparison_id: str + prompt: str + system_prompt: Optional[str] + responses: list[LLMResponse] + created_at: datetime = Field(default_factory=datetime.utcnow) + + +class SavedComparison(BaseModel): + """Gespeicherter Vergleich fuer QA.""" + comparison_id: str + prompt: str + system_prompt: Optional[str] + responses: list[LLMResponse] + notes: Optional[str] = None + rating: Optional[dict] = None # {"openai": 4, "claude": 5, ...} + created_at: datetime + created_by: Optional[str] = None + + +# In-Memory Storage (in Production: Database) +_comparisons_store: dict[str, SavedComparison] = {} +_system_prompts_store: dict[str, dict] = { + "default": { + "id": "default", + "name": "Standard Lehrer-Assistent", + "prompt": """Du bist ein hilfreicher Assistent fuer Lehrkraefte in Deutschland. +Deine Aufgaben: +- Hilfe bei der Unterrichtsplanung +- Erklaerung von Fachinhalten +- Erstellung von Arbeitsblaettern und Pruefungen +- Beratung zu paedagogischen Methoden + +Antworte immer auf Deutsch und beachte den deutschen Lehrplankontext.""", + "created_at": datetime.now(timezone.utc).isoformat(), + }, + "curriculum": { + "id": "curriculum", + "name": "Lehrplan-Experte", + "prompt": """Du bist ein Experte fuer deutsche Lehrplaene und Bildungsstandards. +Du kennst: +- Lehrplaene aller 16 Bundeslaender +- KMK Bildungsstandards +- Kompetenzorientierung im deutschen Bildungssystem + +Beziehe dich immer auf konkrete Lehrplanvorgaben wenn moeglich.""", + "created_at": datetime.now(timezone.utc).isoformat(), + }, + "worksheet": { + "id": "worksheet", + "name": "Arbeitsblatt-Generator", + "prompt": """Du bist ein spezialisierter Assistent fuer die Erstellung von Arbeitsblaettern. +Erstelle didaktisch sinnvolle Aufgaben mit: +- Klaren Arbeitsanweisungen +- Differenzierungsmoeglichkeiten +- Loesungshinweisen + +Format: Markdown mit klarer Struktur.""", + "created_at": datetime.now(timezone.utc).isoformat(), + }, +} + + +async def _call_openai(prompt: str, system_prompt: Optional[str]) -> LLMResponse: + """Ruft OpenAI ChatGPT auf.""" + import os + import httpx + + start_time = time.time() + api_key = os.getenv("OPENAI_API_KEY") + + if not api_key: + return LLMResponse( + provider="openai", + model="gpt-4o-mini", + response="", + latency_ms=0, + error="OPENAI_API_KEY nicht konfiguriert" + ) + + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + + try: + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + "https://api.openai.com/v1/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + json={ + "model": "gpt-4o-mini", + "messages": messages, + "temperature": 0.7, + "max_tokens": 2048, + }, + ) + response.raise_for_status() + data = response.json() + + latency_ms = int((time.time() - start_time) * 1000) + content = data["choices"][0]["message"]["content"] + tokens = data.get("usage", {}).get("total_tokens") + + return LLMResponse( + provider="openai", + model="gpt-4o-mini", + response=content, + latency_ms=latency_ms, + tokens_used=tokens, + ) + except Exception as e: + return LLMResponse( + provider="openai", + model="gpt-4o-mini", + response="", + latency_ms=int((time.time() - start_time) * 1000), + error=str(e), + ) + + +async def _call_claude(prompt: str, system_prompt: Optional[str]) -> LLMResponse: + """Ruft Anthropic Claude auf.""" + import os + + start_time = time.time() + api_key = os.getenv("ANTHROPIC_API_KEY") + + if not api_key: + return LLMResponse( + provider="claude", + model="claude-3-5-sonnet-20241022", + response="", + latency_ms=0, + error="ANTHROPIC_API_KEY nicht konfiguriert" + ) + + try: + import anthropic + client = anthropic.AsyncAnthropic(api_key=api_key) + + response = await client.messages.create( + model="claude-3-5-sonnet-20241022", + max_tokens=2048, + system=system_prompt or "", + messages=[{"role": "user", "content": prompt}], + ) + + latency_ms = int((time.time() - start_time) * 1000) + content = response.content[0].text if response.content else "" + tokens = response.usage.input_tokens + response.usage.output_tokens + + return LLMResponse( + provider="claude", + model="claude-3-5-sonnet-20241022", + response=content, + latency_ms=latency_ms, + tokens_used=tokens, + ) + except Exception as e: + return LLMResponse( + provider="claude", + model="claude-3-5-sonnet-20241022", + response="", + latency_ms=int((time.time() - start_time) * 1000), + error=str(e), + ) + + +async def _search_tavily(query: str, count: int = 5) -> list[dict]: + """Sucht mit Tavily API.""" + import os + import httpx + + api_key = os.getenv("TAVILY_API_KEY") + if not api_key: + return [] + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + "https://api.tavily.com/search", + json={ + "api_key": api_key, + "query": query, + "max_results": count, + "include_domains": [ + "kmk.org", "bildungsserver.de", "bpb.de", + "bayern.de", "nrw.de", "berlin.de", + ], + }, + ) + response.raise_for_status() + data = response.json() + return data.get("results", []) + except Exception as e: + logger.error(f"Tavily search error: {e}") + return [] + + +async def _search_edusearch(query: str, count: int = 5, filters: Optional[dict] = None) -> list[dict]: + """Sucht mit EduSearch API.""" + import os + import httpx + + edu_search_url = os.getenv("EDU_SEARCH_URL", "http://edu-search-service:8084") + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + payload = { + "q": query, + "limit": count, + "mode": "keyword", + } + if filters: + payload["filters"] = filters + + response = await client.post( + f"{edu_search_url}/v1/search", + json=payload, + ) + response.raise_for_status() + data = response.json() + + # Formatiere Ergebnisse + results = [] + for r in data.get("results", []): + results.append({ + "title": r.get("title", ""), + "url": r.get("url", ""), + "content": r.get("snippet", ""), + "score": r.get("scores", {}).get("final", 0), + }) + return results + except Exception as e: + logger.error(f"EduSearch error: {e}") + return [] + + +async def _call_selfhosted_with_search( + prompt: str, + system_prompt: Optional[str], + search_provider: str, + search_results: list[dict], + model: str, + temperature: float, + top_p: float, + max_tokens: int, +) -> LLMResponse: + """Ruft Self-hosted LLM mit Suchergebnissen auf.""" + import os + import httpx + + start_time = time.time() + ollama_url = os.getenv("OLLAMA_URL", "http://localhost:11434") + + # Baue Kontext aus Suchergebnissen + context_parts = [] + for i, result in enumerate(search_results, 1): + context_parts.append(f"[{i}] {result.get('title', 'Untitled')}") + context_parts.append(f" URL: {result.get('url', '')}") + context_parts.append(f" {result.get('content', '')[:500]}") + context_parts.append("") + + search_context = "\n".join(context_parts) + + # Erweitere System Prompt mit Suchergebnissen + augmented_system = f"""{system_prompt or ''} + +Du hast Zugriff auf folgende Suchergebnisse aus {"Tavily" if search_provider == "tavily" else "EduSearch (deutsche Bildungsquellen)"}: + +{search_context} + +Nutze diese Quellen um deine Antwort zu unterstuetzen. Zitiere relevante Quellen mit [Nummer].""" + + messages = [ + {"role": "system", "content": augmented_system}, + {"role": "user", "content": prompt}, + ] + + try: + async with httpx.AsyncClient(timeout=120.0) as client: + response = await client.post( + f"{ollama_url}/api/chat", + json={ + "model": model, + "messages": messages, + "stream": False, + "options": { + "temperature": temperature, + "top_p": top_p, + "num_predict": max_tokens, + }, + }, + ) + response.raise_for_status() + data = response.json() + + latency_ms = int((time.time() - start_time) * 1000) + content = data.get("message", {}).get("content", "") + tokens = data.get("prompt_eval_count", 0) + data.get("eval_count", 0) + + return LLMResponse( + provider=f"selfhosted_{search_provider}", + model=model, + response=content, + latency_ms=latency_ms, + tokens_used=tokens, + search_results=search_results, + ) + except Exception as e: + return LLMResponse( + provider=f"selfhosted_{search_provider}", + model=model, + response="", + latency_ms=int((time.time() - start_time) * 1000), + error=str(e), + search_results=search_results, + ) + + +@router.post("/run", response_model=ComparisonResponse) +async def run_comparison( + request: ComparisonRequest, + _: str = Depends(verify_api_key), +): + """ + Fuehrt LLM-Vergleich durch. + + Sendet den Prompt parallel an alle aktivierten Provider und + sammelt die Antworten. + """ + comparison_id = f"cmp-{uuid.uuid4().hex[:12]}" + tasks = [] + + # System Prompt vorbereiten + system_prompt = request.system_prompt + + # OpenAI + if request.enable_openai: + tasks.append(("openai", _call_openai(request.prompt, system_prompt))) + + # Claude + if request.enable_claude: + tasks.append(("claude", _call_claude(request.prompt, system_prompt))) + + # Self-hosted + Tavily + if request.enable_selfhosted_tavily: + tavily_results = await _search_tavily(request.prompt, request.search_results_count) + tasks.append(( + "selfhosted_tavily", + _call_selfhosted_with_search( + request.prompt, + system_prompt, + "tavily", + tavily_results, + request.selfhosted_model, + request.temperature, + request.top_p, + request.max_tokens, + ) + )) + + # Self-hosted + EduSearch + if request.enable_selfhosted_edusearch: + edu_results = await _search_edusearch( + request.prompt, + request.search_results_count, + request.edu_search_filters, + ) + tasks.append(( + "selfhosted_edusearch", + _call_selfhosted_with_search( + request.prompt, + system_prompt, + "edusearch", + edu_results, + request.selfhosted_model, + request.temperature, + request.top_p, + request.max_tokens, + ) + )) + + # Parallele Ausfuehrung + responses = [] + if tasks: + results = await asyncio.gather(*[t[1] for t in tasks], return_exceptions=True) + for (name, _), result in zip(tasks, results): + if isinstance(result, Exception): + responses.append(LLMResponse( + provider=name, + model="unknown", + response="", + latency_ms=0, + error=str(result), + )) + else: + responses.append(result) + + return ComparisonResponse( + comparison_id=comparison_id, + prompt=request.prompt, + system_prompt=system_prompt, + responses=responses, + ) + + +@router.post("/save/{comparison_id}") +async def save_comparison( + comparison_id: str, + comparison: ComparisonResponse, + notes: Optional[str] = None, + rating: Optional[dict] = None, + _: str = Depends(verify_api_key), +): + """Speichert einen Vergleich fuer spaetere Analyse.""" + saved = SavedComparison( + comparison_id=comparison_id, + prompt=comparison.prompt, + system_prompt=comparison.system_prompt, + responses=comparison.responses, + notes=notes, + rating=rating, + created_at=comparison.created_at, + ) + _comparisons_store[comparison_id] = saved + return {"status": "saved", "comparison_id": comparison_id} + + +@router.get("/history") +async def get_comparison_history( + limit: int = 50, + _: str = Depends(verify_api_key), +): + """Gibt gespeicherte Vergleiche zurueck.""" + comparisons = list(_comparisons_store.values()) + comparisons.sort(key=lambda x: x.created_at, reverse=True) + return {"comparisons": comparisons[:limit]} + + +@router.get("/history/{comparison_id}") +async def get_comparison( + comparison_id: str, + _: str = Depends(verify_api_key), +): + """Gibt einen bestimmten Vergleich zurueck.""" + if comparison_id not in _comparisons_store: + raise HTTPException(status_code=404, detail="Vergleich nicht gefunden") + return _comparisons_store[comparison_id] + + +# System Prompt Management + +@router.get("/prompts") +async def list_system_prompts( + _: str = Depends(verify_api_key), +): + """Listet alle gespeicherten System Prompts.""" + return {"prompts": list(_system_prompts_store.values())} + + +@router.post("/prompts") +async def create_system_prompt( + name: str, + prompt: str, + _: str = Depends(verify_api_key), +): + """Erstellt einen neuen System Prompt.""" + prompt_id = f"sp-{uuid.uuid4().hex[:8]}" + _system_prompts_store[prompt_id] = { + "id": prompt_id, + "name": name, + "prompt": prompt, + "created_at": datetime.now(timezone.utc).isoformat(), + } + return {"status": "created", "prompt_id": prompt_id} + + +@router.put("/prompts/{prompt_id}") +async def update_system_prompt( + prompt_id: str, + name: str, + prompt: str, + _: str = Depends(verify_api_key), +): + """Aktualisiert einen System Prompt.""" + if prompt_id not in _system_prompts_store: + raise HTTPException(status_code=404, detail="System Prompt nicht gefunden") + + _system_prompts_store[prompt_id].update({ + "name": name, + "prompt": prompt, + "updated_at": datetime.now(timezone.utc).isoformat(), + }) + return {"status": "updated", "prompt_id": prompt_id} + + +@router.delete("/prompts/{prompt_id}") +async def delete_system_prompt( + prompt_id: str, + _: str = Depends(verify_api_key), +): + """Loescht einen System Prompt.""" + if prompt_id not in _system_prompts_store: + raise HTTPException(status_code=404, detail="System Prompt nicht gefunden") + if prompt_id in ["default", "curriculum", "worksheet"]: + raise HTTPException(status_code=400, detail="Standard-Prompts koennen nicht geloescht werden") + + del _system_prompts_store[prompt_id] + return {"status": "deleted", "prompt_id": prompt_id} + + +@router.get("/prompts/{prompt_id}") +async def get_system_prompt( + prompt_id: str, + _: str = Depends(verify_api_key), +): + """Gibt einen System Prompt zurueck.""" + if prompt_id not in _system_prompts_store: + raise HTTPException(status_code=404, detail="System Prompt nicht gefunden") + return _system_prompts_store[prompt_id] diff --git a/backend/llm_gateway/routes/edu_search_seeds.py b/backend/llm_gateway/routes/edu_search_seeds.py new file mode 100644 index 0000000..8c3134a --- /dev/null +++ b/backend/llm_gateway/routes/edu_search_seeds.py @@ -0,0 +1,710 @@ +""" +EduSearch Seeds API Routes. + +CRUD operations for managing education search crawler seed URLs. +Direct database access to PostgreSQL. +""" + +import os +import logging +from typing import Optional, List +from datetime import datetime +from uuid import UUID + +from fastapi import APIRouter, HTTPException, Depends, Query +from pydantic import BaseModel, Field, HttpUrl +import asyncpg + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/edu-search", tags=["edu-search"]) + +# Database connection pool +_pool: Optional[asyncpg.Pool] = None + + +async def get_db_pool() -> asyncpg.Pool: + """Get or create database connection pool.""" + global _pool + if _pool is None: + database_url = os.environ.get("DATABASE_URL") + if not database_url: + raise RuntimeError("DATABASE_URL nicht konfiguriert - bitte via Vault oder Umgebungsvariable setzen") + _pool = await asyncpg.create_pool(database_url, min_size=2, max_size=10) + return _pool + + +# ============================================================================= +# Pydantic Models +# ============================================================================= + + +class CategoryResponse(BaseModel): + """Category response model.""" + id: str + name: str + display_name: str + description: Optional[str] = None + icon: Optional[str] = None + sort_order: int + is_active: bool + + +class SeedBase(BaseModel): + """Base seed model for creation/update.""" + url: str = Field(..., max_length=500) + name: str = Field(..., max_length=255) + description: Optional[str] = None + category_name: Optional[str] = Field(None, description="Category name (federal, states, etc.)") + source_type: str = Field("GOV", description="GOV, EDU, UNI, etc.") + scope: str = Field("FEDERAL", description="FEDERAL, STATE, etc.") + state: Optional[str] = Field(None, max_length=5, description="State code (BW, BY, etc.)") + trust_boost: float = Field(0.50, ge=0.0, le=1.0) + enabled: bool = True + crawl_depth: int = Field(2, ge=1, le=5) + crawl_frequency: str = Field("weekly", description="hourly, daily, weekly, monthly") + + +class SeedCreate(SeedBase): + """Seed creation model.""" + pass + + +class SeedUpdate(BaseModel): + """Seed update model (all fields optional).""" + url: Optional[str] = Field(None, max_length=500) + name: Optional[str] = Field(None, max_length=255) + description: Optional[str] = None + category_name: Optional[str] = None + source_type: Optional[str] = None + scope: Optional[str] = None + state: Optional[str] = Field(None, max_length=5) + trust_boost: Optional[float] = Field(None, ge=0.0, le=1.0) + enabled: Optional[bool] = None + crawl_depth: Optional[int] = Field(None, ge=1, le=5) + crawl_frequency: Optional[str] = None + + +class SeedResponse(BaseModel): + """Seed response model.""" + id: str + url: str + name: str + description: Optional[str] = None + category: Optional[str] = None + category_display_name: Optional[str] = None + source_type: str + scope: str + state: Optional[str] = None + trust_boost: float + enabled: bool + crawl_depth: int + crawl_frequency: str + last_crawled_at: Optional[datetime] = None + last_crawl_status: Optional[str] = None + last_crawl_docs: int = 0 + total_documents: int = 0 + created_at: datetime + updated_at: datetime + + +class SeedsListResponse(BaseModel): + """List response with pagination info.""" + seeds: List[SeedResponse] + total: int + page: int + page_size: int + + +class StatsResponse(BaseModel): + """Crawl statistics response.""" + total_seeds: int + enabled_seeds: int + total_documents: int + seeds_by_category: dict + seeds_by_state: dict + last_crawl_time: Optional[datetime] = None + + +class BulkImportRequest(BaseModel): + """Bulk import request.""" + seeds: List[SeedCreate] + + +class BulkImportResponse(BaseModel): + """Bulk import response.""" + imported: int + skipped: int + errors: List[str] + + +# ============================================================================= +# API Endpoints +# ============================================================================= + + +@router.get("/categories", response_model=List[CategoryResponse]) +async def list_categories(): + """List all seed categories.""" + pool = await get_db_pool() + async with pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT id, name, display_name, description, icon, sort_order, is_active + FROM edu_search_categories + WHERE is_active = TRUE + ORDER BY sort_order + """) + return [ + CategoryResponse( + id=str(row["id"]), + name=row["name"], + display_name=row["display_name"], + description=row["description"], + icon=row["icon"], + sort_order=row["sort_order"], + is_active=row["is_active"], + ) + for row in rows + ] + + +@router.get("/seeds", response_model=SeedsListResponse) +async def list_seeds( + category: Optional[str] = Query(None, description="Filter by category name"), + state: Optional[str] = Query(None, description="Filter by state code"), + enabled: Optional[bool] = Query(None, description="Filter by enabled status"), + search: Optional[str] = Query(None, description="Search in name/url"), + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), +): + """List seeds with optional filtering and pagination.""" + pool = await get_db_pool() + async with pool.acquire() as conn: + # Build WHERE clause + conditions = [] + params = [] + param_idx = 1 + + if category: + conditions.append(f"c.name = ${param_idx}") + params.append(category) + param_idx += 1 + + if state: + conditions.append(f"s.state = ${param_idx}") + params.append(state) + param_idx += 1 + + if enabled is not None: + conditions.append(f"s.enabled = ${param_idx}") + params.append(enabled) + param_idx += 1 + + if search: + conditions.append(f"(s.name ILIKE ${param_idx} OR s.url ILIKE ${param_idx})") + params.append(f"%{search}%") + param_idx += 1 + + where_clause = " AND ".join(conditions) if conditions else "TRUE" + + # Count total + count_query = f""" + SELECT COUNT(*) FROM edu_search_seeds s + LEFT JOIN edu_search_categories c ON s.category_id = c.id + WHERE {where_clause} + """ + total = await conn.fetchval(count_query, *params) + + # Get paginated results + offset = (page - 1) * page_size + params.extend([page_size, offset]) + + query = f""" + SELECT + s.id, s.url, s.name, s.description, + c.name as category, c.display_name as category_display_name, + s.source_type, s.scope, s.state, s.trust_boost, s.enabled, + s.crawl_depth, s.crawl_frequency, s.last_crawled_at, + s.last_crawl_status, s.last_crawl_docs, s.total_documents, + s.created_at, s.updated_at + FROM edu_search_seeds s + LEFT JOIN edu_search_categories c ON s.category_id = c.id + WHERE {where_clause} + ORDER BY c.sort_order, s.name + LIMIT ${param_idx} OFFSET ${param_idx + 1} + """ + + rows = await conn.fetch(query, *params) + + seeds = [ + SeedResponse( + id=str(row["id"]), + url=row["url"], + name=row["name"], + description=row["description"], + category=row["category"], + category_display_name=row["category_display_name"], + source_type=row["source_type"], + scope=row["scope"], + state=row["state"], + trust_boost=float(row["trust_boost"]), + enabled=row["enabled"], + crawl_depth=row["crawl_depth"], + crawl_frequency=row["crawl_frequency"], + last_crawled_at=row["last_crawled_at"], + last_crawl_status=row["last_crawl_status"], + last_crawl_docs=row["last_crawl_docs"] or 0, + total_documents=row["total_documents"] or 0, + created_at=row["created_at"], + updated_at=row["updated_at"], + ) + for row in rows + ] + + return SeedsListResponse( + seeds=seeds, + total=total, + page=page, + page_size=page_size, + ) + + +@router.get("/seeds/{seed_id}", response_model=SeedResponse) +async def get_seed(seed_id: str): + """Get a single seed by ID.""" + pool = await get_db_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow(""" + SELECT + s.id, s.url, s.name, s.description, + c.name as category, c.display_name as category_display_name, + s.source_type, s.scope, s.state, s.trust_boost, s.enabled, + s.crawl_depth, s.crawl_frequency, s.last_crawled_at, + s.last_crawl_status, s.last_crawl_docs, s.total_documents, + s.created_at, s.updated_at + FROM edu_search_seeds s + LEFT JOIN edu_search_categories c ON s.category_id = c.id + WHERE s.id = $1 + """, seed_id) + + if not row: + raise HTTPException(status_code=404, detail="Seed nicht gefunden") + + return SeedResponse( + id=str(row["id"]), + url=row["url"], + name=row["name"], + description=row["description"], + category=row["category"], + category_display_name=row["category_display_name"], + source_type=row["source_type"], + scope=row["scope"], + state=row["state"], + trust_boost=float(row["trust_boost"]), + enabled=row["enabled"], + crawl_depth=row["crawl_depth"], + crawl_frequency=row["crawl_frequency"], + last_crawled_at=row["last_crawled_at"], + last_crawl_status=row["last_crawl_status"], + last_crawl_docs=row["last_crawl_docs"] or 0, + total_documents=row["total_documents"] or 0, + created_at=row["created_at"], + updated_at=row["updated_at"], + ) + + +@router.post("/seeds", response_model=SeedResponse, status_code=201) +async def create_seed(seed: SeedCreate): + """Create a new seed URL.""" + pool = await get_db_pool() + async with pool.acquire() as conn: + # Get category ID if provided + category_id = None + if seed.category_name: + category_id = await conn.fetchval( + "SELECT id FROM edu_search_categories WHERE name = $1", + seed.category_name + ) + + try: + row = await conn.fetchrow(""" + INSERT INTO edu_search_seeds ( + url, name, description, category_id, source_type, scope, + state, trust_boost, enabled, crawl_depth, crawl_frequency + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id, created_at, updated_at + """, + seed.url, seed.name, seed.description, category_id, + seed.source_type, seed.scope, seed.state, seed.trust_boost, + seed.enabled, seed.crawl_depth, seed.crawl_frequency + ) + except asyncpg.UniqueViolationError: + raise HTTPException(status_code=409, detail="URL existiert bereits") + + return SeedResponse( + id=str(row["id"]), + url=seed.url, + name=seed.name, + description=seed.description, + category=seed.category_name, + category_display_name=None, + source_type=seed.source_type, + scope=seed.scope, + state=seed.state, + trust_boost=seed.trust_boost, + enabled=seed.enabled, + crawl_depth=seed.crawl_depth, + crawl_frequency=seed.crawl_frequency, + last_crawled_at=None, + last_crawl_status=None, + last_crawl_docs=0, + total_documents=0, + created_at=row["created_at"], + updated_at=row["updated_at"], + ) + + +@router.put("/seeds/{seed_id}", response_model=SeedResponse) +async def update_seed(seed_id: str, seed: SeedUpdate): + """Update an existing seed.""" + pool = await get_db_pool() + async with pool.acquire() as conn: + # Build update statement dynamically + updates = [] + params = [] + param_idx = 1 + + if seed.url is not None: + updates.append(f"url = ${param_idx}") + params.append(seed.url) + param_idx += 1 + + if seed.name is not None: + updates.append(f"name = ${param_idx}") + params.append(seed.name) + param_idx += 1 + + if seed.description is not None: + updates.append(f"description = ${param_idx}") + params.append(seed.description) + param_idx += 1 + + if seed.category_name is not None: + category_id = await conn.fetchval( + "SELECT id FROM edu_search_categories WHERE name = $1", + seed.category_name + ) + updates.append(f"category_id = ${param_idx}") + params.append(category_id) + param_idx += 1 + + if seed.source_type is not None: + updates.append(f"source_type = ${param_idx}") + params.append(seed.source_type) + param_idx += 1 + + if seed.scope is not None: + updates.append(f"scope = ${param_idx}") + params.append(seed.scope) + param_idx += 1 + + if seed.state is not None: + updates.append(f"state = ${param_idx}") + params.append(seed.state) + param_idx += 1 + + if seed.trust_boost is not None: + updates.append(f"trust_boost = ${param_idx}") + params.append(seed.trust_boost) + param_idx += 1 + + if seed.enabled is not None: + updates.append(f"enabled = ${param_idx}") + params.append(seed.enabled) + param_idx += 1 + + if seed.crawl_depth is not None: + updates.append(f"crawl_depth = ${param_idx}") + params.append(seed.crawl_depth) + param_idx += 1 + + if seed.crawl_frequency is not None: + updates.append(f"crawl_frequency = ${param_idx}") + params.append(seed.crawl_frequency) + param_idx += 1 + + if not updates: + raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren") + + updates.append("updated_at = NOW()") + params.append(seed_id) + + query = f""" + UPDATE edu_search_seeds + SET {", ".join(updates)} + WHERE id = ${param_idx} + RETURNING id + """ + + result = await conn.fetchrow(query, *params) + if not result: + raise HTTPException(status_code=404, detail="Seed nicht gefunden") + + # Return updated seed + return await get_seed(seed_id) + + +@router.delete("/seeds/{seed_id}") +async def delete_seed(seed_id: str): + """Delete a seed.""" + pool = await get_db_pool() + async with pool.acquire() as conn: + result = await conn.execute( + "DELETE FROM edu_search_seeds WHERE id = $1", + seed_id + ) + if result == "DELETE 0": + raise HTTPException(status_code=404, detail="Seed nicht gefunden") + + return {"status": "deleted", "id": seed_id} + + +@router.post("/seeds/bulk-import", response_model=BulkImportResponse) +async def bulk_import_seeds(request: BulkImportRequest): + """Bulk import seeds (skip duplicates).""" + pool = await get_db_pool() + imported = 0 + skipped = 0 + errors = [] + + async with pool.acquire() as conn: + # Pre-fetch all category IDs + categories = {} + rows = await conn.fetch("SELECT id, name FROM edu_search_categories") + for row in rows: + categories[row["name"]] = row["id"] + + for seed in request.seeds: + try: + category_id = categories.get(seed.category_name) if seed.category_name else None + + await conn.execute(""" + INSERT INTO edu_search_seeds ( + url, name, description, category_id, source_type, scope, + state, trust_boost, enabled, crawl_depth, crawl_frequency + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (url) DO NOTHING + """, + seed.url, seed.name, seed.description, category_id, + seed.source_type, seed.scope, seed.state, seed.trust_boost, + seed.enabled, seed.crawl_depth, seed.crawl_frequency + ) + imported += 1 + except asyncpg.UniqueViolationError: + skipped += 1 + except Exception as e: + errors.append(f"{seed.url}: {str(e)}") + + return BulkImportResponse(imported=imported, skipped=skipped, errors=errors) + + +@router.get("/stats", response_model=StatsResponse) +async def get_stats(): + """Get crawl statistics.""" + pool = await get_db_pool() + async with pool.acquire() as conn: + # Basic counts + total = await conn.fetchval("SELECT COUNT(*) FROM edu_search_seeds") + enabled = await conn.fetchval("SELECT COUNT(*) FROM edu_search_seeds WHERE enabled = TRUE") + total_docs = await conn.fetchval("SELECT COALESCE(SUM(total_documents), 0) FROM edu_search_seeds") + + # By category + cat_rows = await conn.fetch(""" + SELECT c.name, COUNT(s.id) as count + FROM edu_search_categories c + LEFT JOIN edu_search_seeds s ON c.id = s.category_id + GROUP BY c.name + """) + by_category = {row["name"]: row["count"] for row in cat_rows} + + # By state + state_rows = await conn.fetch(""" + SELECT COALESCE(state, 'federal') as state, COUNT(*) as count + FROM edu_search_seeds + GROUP BY state + """) + by_state = {row["state"]: row["count"] for row in state_rows} + + # Last crawl time + last_crawl = await conn.fetchval( + "SELECT MAX(last_crawled_at) FROM edu_search_seeds" + ) + + return StatsResponse( + total_seeds=total, + enabled_seeds=enabled, + total_documents=total_docs, + seeds_by_category=by_category, + seeds_by_state=by_state, + last_crawl_time=last_crawl, + ) + + +# Export for external use (edu-search-service) +@router.get("/seeds/export/for-crawler") +async def export_seeds_for_crawler(): + """Export enabled seeds in format suitable for crawler.""" + pool = await get_db_pool() + async with pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT + s.url, s.trust_boost, s.source_type, s.scope, s.state, + s.crawl_depth, c.name as category + FROM edu_search_seeds s + LEFT JOIN edu_search_categories c ON s.category_id = c.id + WHERE s.enabled = TRUE + ORDER BY s.trust_boost DESC + """) + + return { + "seeds": [ + { + "url": row["url"], + "trust": float(row["trust_boost"]), + "source": row["source_type"], + "scope": row["scope"], + "state": row["state"], + "depth": row["crawl_depth"], + "category": row["category"], + } + for row in rows + ], + "total": len(rows), + "exported_at": datetime.utcnow().isoformat(), + } + + +# ============================================================================= +# Crawl Status Feedback (from edu-search-service) +# ============================================================================= + + +class CrawlStatusUpdate(BaseModel): + """Crawl status update from edu-search-service.""" + seed_url: str = Field(..., description="The seed URL that was crawled") + status: str = Field(..., description="Crawl status: success, error, partial") + documents_crawled: int = Field(0, ge=0, description="Number of documents crawled") + error_message: Optional[str] = Field(None, description="Error message if status is error") + crawl_duration_seconds: float = Field(0.0, ge=0.0, description="Duration of the crawl in seconds") + + +class CrawlStatusResponse(BaseModel): + """Response for crawl status update.""" + success: bool + seed_url: str + message: str + + +@router.post("/seeds/crawl-status", response_model=CrawlStatusResponse) +async def update_crawl_status(update: CrawlStatusUpdate): + """Update crawl status for a seed URL (called by edu-search-service).""" + pool = await get_db_pool() + async with pool.acquire() as conn: + # Find the seed by URL + seed = await conn.fetchrow( + "SELECT id, total_documents FROM edu_search_seeds WHERE url = $1", + update.seed_url + ) + + if not seed: + raise HTTPException( + status_code=404, + detail=f"Seed nicht gefunden: {update.seed_url}" + ) + + # Update the seed with crawl status + new_total = (seed["total_documents"] or 0) + update.documents_crawled + + await conn.execute(""" + UPDATE edu_search_seeds + SET + last_crawled_at = NOW(), + last_crawl_status = $2, + last_crawl_docs = $3, + total_documents = $4, + updated_at = NOW() + WHERE id = $1 + """, seed["id"], update.status, update.documents_crawled, new_total) + + logger.info( + f"Crawl status updated: {update.seed_url} - " + f"status={update.status}, docs={update.documents_crawled}, " + f"duration={update.crawl_duration_seconds:.1f}s" + ) + + return CrawlStatusResponse( + success=True, + seed_url=update.seed_url, + message=f"Status aktualisiert: {update.documents_crawled} Dokumente gecrawlt" + ) + + +class BulkCrawlStatusUpdate(BaseModel): + """Bulk crawl status update.""" + updates: List[CrawlStatusUpdate] + + +class BulkCrawlStatusResponse(BaseModel): + """Response for bulk crawl status update.""" + updated: int + failed: int + errors: List[str] + + +@router.post("/seeds/crawl-status/bulk", response_model=BulkCrawlStatusResponse) +async def bulk_update_crawl_status(request: BulkCrawlStatusUpdate): + """Bulk update crawl status for multiple seeds.""" + pool = await get_db_pool() + updated = 0 + failed = 0 + errors = [] + + async with pool.acquire() as conn: + for update in request.updates: + try: + seed = await conn.fetchrow( + "SELECT id, total_documents FROM edu_search_seeds WHERE url = $1", + update.seed_url + ) + + if not seed: + failed += 1 + errors.append(f"Seed nicht gefunden: {update.seed_url}") + continue + + new_total = (seed["total_documents"] or 0) + update.documents_crawled + + await conn.execute(""" + UPDATE edu_search_seeds + SET + last_crawled_at = NOW(), + last_crawl_status = $2, + last_crawl_docs = $3, + total_documents = $4, + updated_at = NOW() + WHERE id = $1 + """, seed["id"], update.status, update.documents_crawled, new_total) + + updated += 1 + + except Exception as e: + failed += 1 + errors.append(f"{update.seed_url}: {str(e)}") + + logger.info(f"Bulk crawl status update: {updated} updated, {failed} failed") + + return BulkCrawlStatusResponse( + updated=updated, + failed=failed, + errors=errors + ) diff --git a/backend/llm_gateway/routes/health.py b/backend/llm_gateway/routes/health.py new file mode 100644 index 0000000..83969bd --- /dev/null +++ b/backend/llm_gateway/routes/health.py @@ -0,0 +1,127 @@ +""" +Health Check Route. +""" + +import logging +from datetime import datetime +from fastapi import APIRouter +from pydantic import BaseModel + +from ..config import get_config + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["Health"]) + + +class ComponentStatus(BaseModel): + """Status einer Komponente.""" + name: str + status: str # healthy, degraded, unhealthy + message: str = "" + + +class HealthResponse(BaseModel): + """Health Check Response.""" + status: str # ok, degraded, error + ts: str + version: str + components: list[ComponentStatus] + + +@router.get("/health", response_model=HealthResponse) +async def health_check(): + """ + Health Check Endpoint. + + Prüft den Status aller Komponenten: + - Gateway selbst + - LLM Backend Erreichbarkeit + - Datenbank (wenn konfiguriert) + """ + config = get_config() + components = [] + overall_status = "ok" + + # Gateway selbst + components.append(ComponentStatus( + name="gateway", + status="healthy", + message="Gateway is running", + )) + + # Ollama Backend + if config.ollama and config.ollama.enabled: + try: + import httpx + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get(f"{config.ollama.base_url}/api/tags") + if response.status_code == 200: + components.append(ComponentStatus( + name="ollama", + status="healthy", + message="Ollama is reachable", + )) + else: + components.append(ComponentStatus( + name="ollama", + status="degraded", + message=f"Ollama returned status {response.status_code}", + )) + overall_status = "degraded" + except Exception as e: + components.append(ComponentStatus( + name="ollama", + status="unhealthy", + message=f"Cannot reach Ollama: {str(e)}", + )) + # Nicht critical wenn andere Backends verfügbar + if not (config.vllm and config.vllm.enabled) and not (config.anthropic and config.anthropic.enabled): + overall_status = "error" + + # vLLM Backend + if config.vllm and config.vllm.enabled: + try: + import httpx + headers = {} + if config.vllm.api_key: + headers["Authorization"] = f"Bearer {config.vllm.api_key}" + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get( + f"{config.vllm.base_url}/v1/models", + headers=headers, + ) + if response.status_code == 200: + components.append(ComponentStatus( + name="vllm", + status="healthy", + message="vLLM is reachable", + )) + else: + components.append(ComponentStatus( + name="vllm", + status="degraded", + message=f"vLLM returned status {response.status_code}", + )) + overall_status = "degraded" + except Exception as e: + components.append(ComponentStatus( + name="vllm", + status="unhealthy", + message=f"Cannot reach vLLM: {str(e)}", + )) + + # Anthropic Backend + if config.anthropic and config.anthropic.enabled: + components.append(ComponentStatus( + name="anthropic", + status="healthy", + message="Anthropic API configured (not checked)", + )) + + return HealthResponse( + status=overall_status, + ts=datetime.utcnow().isoformat() + "Z", + version="0.1.0", + components=components, + ) diff --git a/backend/llm_gateway/routes/legal_crawler.py b/backend/llm_gateway/routes/legal_crawler.py new file mode 100644 index 0000000..971c593 --- /dev/null +++ b/backend/llm_gateway/routes/legal_crawler.py @@ -0,0 +1,173 @@ +""" +Legal Crawler API Routes. + +Endpoints für das Crawlen und Abrufen von rechtlichen Bildungsinhalten. +""" + +import logging +import asyncio +from typing import List, Optional + +from fastapi import APIRouter, HTTPException, BackgroundTasks +from pydantic import BaseModel + +from ..services.legal_crawler import get_legal_crawler, LegalCrawler + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/legal-crawler", tags=["legal-crawler"]) + + +class CrawlStatusResponse(BaseModel): + """Response für Crawl-Status.""" + status: str + message: str + stats: Optional[dict] = None + + +class LegalDocumentResponse(BaseModel): + """Response für ein rechtliches Dokument.""" + id: str + url: str + title: str + law_name: Optional[str] + state: Optional[str] + paragraphs: Optional[list] + last_crawled_at: Optional[str] + + +class LegalReferenceFromDB(BaseModel): + """Rechtliche Referenz aus der DB.""" + law: str + url: str + state: Optional[str] + title: str + paragraphs: list + + +# Globaler Status für laufenden Crawl +_crawl_status = { + "running": False, + "last_run": None, + "last_stats": None, +} + + +async def _run_crawl(db_pool): + """Führt den Crawl asynchron durch.""" + global _crawl_status + _crawl_status["running"] = True + + try: + crawler = get_legal_crawler() + stats = await crawler.crawl_legal_seeds(db_pool) + _crawl_status["last_stats"] = stats + _crawl_status["last_run"] = "completed" + except Exception as e: + logger.error(f"Crawl-Fehler: {e}") + _crawl_status["last_run"] = f"error: {str(e)}" + finally: + _crawl_status["running"] = False + + +@router.post("/start", response_model=CrawlStatusResponse) +async def start_crawl(background_tasks: BackgroundTasks): + """ + Startet einen neuen Crawl für alle Legal-Seeds. + + Der Crawl läuft im Hintergrund und kann über /status abgefragt werden. + """ + global _crawl_status + + if _crawl_status["running"]: + return CrawlStatusResponse( + status="already_running", + message="Ein Crawl läuft bereits. Bitte warten Sie, bis er abgeschlossen ist." + ) + + # Hinweis: In Produktion würde hier der DB-Pool übergeben werden + # Für jetzt nur Status setzen + _crawl_status["running"] = True + _crawl_status["last_run"] = "started" + + return CrawlStatusResponse( + status="started", + message="Crawl wurde gestartet. Nutzen Sie /status um den Fortschritt zu prüfen." + ) + + +@router.get("/status", response_model=CrawlStatusResponse) +async def get_crawl_status(): + """Gibt den aktuellen Crawl-Status zurück.""" + return CrawlStatusResponse( + status="running" if _crawl_status["running"] else "idle", + message=_crawl_status.get("last_run") or "Noch nie gecrawlt", + stats=_crawl_status.get("last_stats") + ) + + +@router.get("/documents", response_model=List[LegalDocumentResponse]) +async def get_legal_documents( + state: Optional[str] = None, + doc_type: Optional[str] = None, + limit: int = 50 +): + """ + Gibt gecrawlte rechtliche Dokumente zurück. + + Args: + state: Filter nach Bundesland (z.B. "NW", "BY") + doc_type: Filter nach Dokumenttyp (z.B. "schulgesetz") + limit: Max. Anzahl Dokumente + + Returns: + Liste von LegalDocumentResponse + """ + # TODO: DB-Query implementieren wenn DB-Pool verfügbar + # Für jetzt leere Liste zurückgeben + return [] + + +@router.get("/references/{state}") +async def get_legal_references_for_state(state: str): + """ + Gibt rechtliche Referenzen für ein Bundesland zurück. + + Dies ist der Endpoint, den der Communication-Service nutzt. + + Args: + state: Bundesland-Kürzel (z.B. "NW", "BY", "BE") + + Returns: + Dict mit Schulgesetz-Informationen und Paragraphen + """ + # TODO: Aus DB laden + # Mapping von state-Kürzeln zu DB-Werten + state_mapping = { + "NRW": "NW", + "NW": "NW", + "BY": "BY", + "BW": "BW", + "BE": "BE", + "BB": "BB", + "HB": "HB", + "HH": "HH", + "HE": "HE", + "MV": "MV", + "NI": "NI", + "RP": "RP", + "SL": "SL", + "SN": "SN", + "ST": "ST", + "SH": "SH", + "TH": "TH", + } + + db_state = state_mapping.get(state.upper(), state.upper()) + + # Placeholder - später aus DB + return { + "state": state, + "documents": [], + "message": "Dokumente werden nach dem ersten Crawl verfügbar sein" + } diff --git a/backend/llm_gateway/routes/playbooks.py b/backend/llm_gateway/routes/playbooks.py new file mode 100644 index 0000000..6949195 --- /dev/null +++ b/backend/llm_gateway/routes/playbooks.py @@ -0,0 +1,96 @@ +""" +Playbooks Route - System Prompt Verwaltung. +""" + +import logging +from typing import Optional +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel + +from ..services.playbook_service import get_playbook_service, Playbook +from ..middleware.auth import verify_api_key + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/playbooks", tags=["Playbooks"]) + + +class PlaybookSummary(BaseModel): + """Zusammenfassung eines Playbooks (ohne System Prompt).""" + id: str + name: str + description: str + prompt_version: str + recommended_models: list[str] + + +class PlaybookDetail(BaseModel): + """Vollständige Playbook-Details.""" + id: str + name: str + description: str + system_prompt: str + prompt_version: str + recommended_models: list[str] + tool_policy: dict + status: str + + +class PlaybookListResponse(BaseModel): + """Response für Playbook-Liste.""" + items: list[PlaybookSummary] + + +@router.get("", response_model=PlaybookListResponse) +async def list_playbooks( + status: Optional[str] = "published", + _: str = Depends(verify_api_key), +): + """ + Liste verfügbarer Playbooks. + + Playbooks sind versionierte System-Prompt-Vorlagen für spezifische Schulkontexte. + """ + service = get_playbook_service() + playbooks = service.list_playbooks(status=status) + + return PlaybookListResponse( + items=[ + PlaybookSummary( + id=p.id, + name=p.name, + description=p.description, + prompt_version=p.prompt_version, + recommended_models=p.recommended_models, + ) + for p in playbooks + ] + ) + + +@router.get("/{playbook_id}", response_model=PlaybookDetail) +async def get_playbook( + playbook_id: str, + _: str = Depends(verify_api_key), +): + """ + Details zu einem Playbook abrufen. + + Enthält den vollständigen System Prompt und Tool-Policies. + """ + service = get_playbook_service() + playbook = service.get_playbook(playbook_id) + + if not playbook: + raise HTTPException(status_code=404, detail=f"Playbook {playbook_id} not found") + + return PlaybookDetail( + id=playbook.id, + name=playbook.name, + description=playbook.description, + system_prompt=playbook.system_prompt, + prompt_version=playbook.prompt_version, + recommended_models=playbook.recommended_models, + tool_policy=playbook.tool_policy, + status=playbook.status, + ) diff --git a/backend/llm_gateway/routes/schools.py b/backend/llm_gateway/routes/schools.py new file mode 100644 index 0000000..ee76f0e --- /dev/null +++ b/backend/llm_gateway/routes/schools.py @@ -0,0 +1,867 @@ +""" +Schools API Routes. + +CRUD operations for managing German schools (~40,000 schools). +Direct database access to PostgreSQL. +""" + +import os +import logging +from typing import Optional, List +from datetime import datetime +from uuid import UUID + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel, Field +import asyncpg + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/schools", tags=["schools"]) + +# Database connection pool +_pool: Optional[asyncpg.Pool] = None + + +async def get_db_pool() -> asyncpg.Pool: + """Get or create database connection pool.""" + global _pool + if _pool is None: + database_url = os.environ.get( + "DATABASE_URL", + "postgresql://breakpilot:breakpilot123@postgres:5432/breakpilot_db" + ) + _pool = await asyncpg.create_pool(database_url, min_size=2, max_size=10) + return _pool + + +# ============================================================================= +# Pydantic Models +# ============================================================================= + + +class SchoolTypeResponse(BaseModel): + """School type response model.""" + id: str + name: str + name_short: Optional[str] = None + category: Optional[str] = None + description: Optional[str] = None + + +class SchoolBase(BaseModel): + """Base school model for creation/update.""" + name: str = Field(..., max_length=255) + school_number: Optional[str] = Field(None, max_length=20) + school_type_id: Optional[str] = None + school_type_raw: Optional[str] = None + state: str = Field(..., max_length=10) + district: Optional[str] = None + city: Optional[str] = None + postal_code: Optional[str] = None + street: Optional[str] = None + address_full: Optional[str] = None + latitude: Optional[float] = None + longitude: Optional[float] = None + website: Optional[str] = None + email: Optional[str] = None + phone: Optional[str] = None + fax: Optional[str] = None + principal_name: Optional[str] = None + principal_title: Optional[str] = None + principal_email: Optional[str] = None + principal_phone: Optional[str] = None + secretary_name: Optional[str] = None + secretary_email: Optional[str] = None + secretary_phone: Optional[str] = None + student_count: Optional[int] = None + teacher_count: Optional[int] = None + class_count: Optional[int] = None + founded_year: Optional[int] = None + is_public: bool = True + is_all_day: Optional[bool] = None + has_inclusion: Optional[bool] = None + languages: Optional[List[str]] = None + specializations: Optional[List[str]] = None + source: Optional[str] = None + source_url: Optional[str] = None + + +class SchoolCreate(SchoolBase): + """School creation model.""" + pass + + +class SchoolUpdate(BaseModel): + """School update model (all fields optional).""" + name: Optional[str] = Field(None, max_length=255) + school_number: Optional[str] = None + school_type_id: Optional[str] = None + state: Optional[str] = None + district: Optional[str] = None + city: Optional[str] = None + postal_code: Optional[str] = None + street: Optional[str] = None + website: Optional[str] = None + email: Optional[str] = None + phone: Optional[str] = None + principal_name: Optional[str] = None + student_count: Optional[int] = None + teacher_count: Optional[int] = None + is_active: Optional[bool] = None + + +class SchoolResponse(BaseModel): + """School response model.""" + id: str + name: str + school_number: Optional[str] = None + school_type: Optional[str] = None + school_type_short: Optional[str] = None + school_category: Optional[str] = None + state: str + district: Optional[str] = None + city: Optional[str] = None + postal_code: Optional[str] = None + street: Optional[str] = None + address_full: Optional[str] = None + latitude: Optional[float] = None + longitude: Optional[float] = None + website: Optional[str] = None + email: Optional[str] = None + phone: Optional[str] = None + fax: Optional[str] = None + principal_name: Optional[str] = None + principal_email: Optional[str] = None + student_count: Optional[int] = None + teacher_count: Optional[int] = None + is_public: bool = True + is_all_day: Optional[bool] = None + staff_count: int = 0 + source: Optional[str] = None + crawled_at: Optional[datetime] = None + is_active: bool = True + created_at: datetime + updated_at: datetime + + +class SchoolsListResponse(BaseModel): + """List response with pagination info.""" + schools: List[SchoolResponse] + total: int + page: int + page_size: int + + +class SchoolStaffBase(BaseModel): + """Base school staff model.""" + first_name: Optional[str] = None + last_name: str + full_name: Optional[str] = None + title: Optional[str] = None + position: Optional[str] = None + position_type: Optional[str] = None + subjects: Optional[List[str]] = None + email: Optional[str] = None + phone: Optional[str] = None + + +class SchoolStaffCreate(SchoolStaffBase): + """School staff creation model.""" + school_id: str + + +class SchoolStaffResponse(SchoolStaffBase): + """School staff response model.""" + id: str + school_id: str + school_name: Optional[str] = None + profile_url: Optional[str] = None + photo_url: Optional[str] = None + is_active: bool = True + created_at: datetime + + +class SchoolStaffListResponse(BaseModel): + """Staff list response.""" + staff: List[SchoolStaffResponse] + total: int + + +class SchoolStatsResponse(BaseModel): + """School statistics response.""" + total_schools: int + total_staff: int + schools_by_state: dict + schools_by_type: dict + schools_with_website: int + schools_with_email: int + schools_with_principal: int + total_students: int + total_teachers: int + last_crawl_time: Optional[datetime] = None + + +class BulkImportRequest(BaseModel): + """Bulk import request.""" + schools: List[SchoolCreate] + + +class BulkImportResponse(BaseModel): + """Bulk import response.""" + imported: int + updated: int + skipped: int + errors: List[str] + + +# ============================================================================= +# School Type Endpoints +# ============================================================================= + + +@router.get("/types", response_model=List[SchoolTypeResponse]) +async def list_school_types(): + """List all school types.""" + pool = await get_db_pool() + async with pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT id, name, name_short, category, description + FROM school_types + ORDER BY category, name + """) + return [ + SchoolTypeResponse( + id=str(row["id"]), + name=row["name"], + name_short=row["name_short"], + category=row["category"], + description=row["description"], + ) + for row in rows + ] + + +# ============================================================================= +# School Endpoints +# ============================================================================= + + +@router.get("", response_model=SchoolsListResponse) +async def list_schools( + state: Optional[str] = Query(None, description="Filter by state code (BW, BY, etc.)"), + school_type: Optional[str] = Query(None, description="Filter by school type name"), + city: Optional[str] = Query(None, description="Filter by city"), + district: Optional[str] = Query(None, description="Filter by district"), + postal_code: Optional[str] = Query(None, description="Filter by postal code prefix"), + search: Optional[str] = Query(None, description="Search in name, city"), + has_email: Optional[bool] = Query(None, description="Filter schools with email"), + has_website: Optional[bool] = Query(None, description="Filter schools with website"), + is_public: Optional[bool] = Query(None, description="Filter public/private schools"), + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), +): + """List schools with optional filtering and pagination.""" + pool = await get_db_pool() + async with pool.acquire() as conn: + # Build WHERE clause + conditions = ["s.is_active = TRUE"] + params = [] + param_idx = 1 + + if state: + conditions.append(f"s.state = ${param_idx}") + params.append(state.upper()) + param_idx += 1 + + if school_type: + conditions.append(f"st.name = ${param_idx}") + params.append(school_type) + param_idx += 1 + + if city: + conditions.append(f"LOWER(s.city) = LOWER(${param_idx})") + params.append(city) + param_idx += 1 + + if district: + conditions.append(f"LOWER(s.district) LIKE LOWER(${param_idx})") + params.append(f"%{district}%") + param_idx += 1 + + if postal_code: + conditions.append(f"s.postal_code LIKE ${param_idx}") + params.append(f"{postal_code}%") + param_idx += 1 + + if search: + conditions.append(f""" + (LOWER(s.name) LIKE LOWER(${param_idx}) + OR LOWER(s.city) LIKE LOWER(${param_idx}) + OR LOWER(s.district) LIKE LOWER(${param_idx})) + """) + params.append(f"%{search}%") + param_idx += 1 + + if has_email is not None: + if has_email: + conditions.append("s.email IS NOT NULL") + else: + conditions.append("s.email IS NULL") + + if has_website is not None: + if has_website: + conditions.append("s.website IS NOT NULL") + else: + conditions.append("s.website IS NULL") + + if is_public is not None: + conditions.append(f"s.is_public = ${param_idx}") + params.append(is_public) + param_idx += 1 + + where_clause = " AND ".join(conditions) + + # Count total + count_query = f""" + SELECT COUNT(*) FROM schools s + LEFT JOIN school_types st ON s.school_type_id = st.id + WHERE {where_clause} + """ + total = await conn.fetchval(count_query, *params) + + # Fetch schools + offset = (page - 1) * page_size + query = f""" + SELECT + s.id, s.name, s.school_number, s.state, s.district, s.city, + s.postal_code, s.street, s.address_full, s.latitude, s.longitude, + s.website, s.email, s.phone, s.fax, + s.principal_name, s.principal_email, + s.student_count, s.teacher_count, + s.is_public, s.is_all_day, s.source, s.crawled_at, + s.is_active, s.created_at, s.updated_at, + st.name as school_type, st.name_short as school_type_short, st.category as school_category, + (SELECT COUNT(*) FROM school_staff ss WHERE ss.school_id = s.id AND ss.is_active = TRUE) as staff_count + FROM schools s + LEFT JOIN school_types st ON s.school_type_id = st.id + WHERE {where_clause} + ORDER BY s.state, s.city, s.name + LIMIT ${param_idx} OFFSET ${param_idx + 1} + """ + params.extend([page_size, offset]) + rows = await conn.fetch(query, *params) + + schools = [ + SchoolResponse( + id=str(row["id"]), + name=row["name"], + school_number=row["school_number"], + school_type=row["school_type"], + school_type_short=row["school_type_short"], + school_category=row["school_category"], + state=row["state"], + district=row["district"], + city=row["city"], + postal_code=row["postal_code"], + street=row["street"], + address_full=row["address_full"], + latitude=row["latitude"], + longitude=row["longitude"], + website=row["website"], + email=row["email"], + phone=row["phone"], + fax=row["fax"], + principal_name=row["principal_name"], + principal_email=row["principal_email"], + student_count=row["student_count"], + teacher_count=row["teacher_count"], + is_public=row["is_public"], + is_all_day=row["is_all_day"], + staff_count=row["staff_count"], + source=row["source"], + crawled_at=row["crawled_at"], + is_active=row["is_active"], + created_at=row["created_at"], + updated_at=row["updated_at"], + ) + for row in rows + ] + + return SchoolsListResponse( + schools=schools, + total=total, + page=page, + page_size=page_size, + ) + + +@router.get("/stats", response_model=SchoolStatsResponse) +async def get_school_stats(): + """Get school statistics.""" + pool = await get_db_pool() + async with pool.acquire() as conn: + # Total schools and staff + totals = await conn.fetchrow(""" + SELECT + (SELECT COUNT(*) FROM schools WHERE is_active = TRUE) as total_schools, + (SELECT COUNT(*) FROM school_staff WHERE is_active = TRUE) as total_staff, + (SELECT COUNT(*) FROM schools WHERE is_active = TRUE AND website IS NOT NULL) as with_website, + (SELECT COUNT(*) FROM schools WHERE is_active = TRUE AND email IS NOT NULL) as with_email, + (SELECT COUNT(*) FROM schools WHERE is_active = TRUE AND principal_name IS NOT NULL) as with_principal, + (SELECT COALESCE(SUM(student_count), 0) FROM schools WHERE is_active = TRUE) as total_students, + (SELECT COALESCE(SUM(teacher_count), 0) FROM schools WHERE is_active = TRUE) as total_teachers, + (SELECT MAX(crawled_at) FROM schools) as last_crawl + """) + + # By state + state_rows = await conn.fetch(""" + SELECT state, COUNT(*) as count + FROM schools + WHERE is_active = TRUE + GROUP BY state + ORDER BY state + """) + schools_by_state = {row["state"]: row["count"] for row in state_rows} + + # By type + type_rows = await conn.fetch(""" + SELECT COALESCE(st.name, 'Unbekannt') as type_name, COUNT(*) as count + FROM schools s + LEFT JOIN school_types st ON s.school_type_id = st.id + WHERE s.is_active = TRUE + GROUP BY st.name + ORDER BY count DESC + """) + schools_by_type = {row["type_name"]: row["count"] for row in type_rows} + + return SchoolStatsResponse( + total_schools=totals["total_schools"], + total_staff=totals["total_staff"], + schools_by_state=schools_by_state, + schools_by_type=schools_by_type, + schools_with_website=totals["with_website"], + schools_with_email=totals["with_email"], + schools_with_principal=totals["with_principal"], + total_students=totals["total_students"], + total_teachers=totals["total_teachers"], + last_crawl_time=totals["last_crawl"], + ) + + +@router.get("/{school_id}", response_model=SchoolResponse) +async def get_school(school_id: str): + """Get a single school by ID.""" + pool = await get_db_pool() + async with pool.acquire() as conn: + row = await conn.fetchrow(""" + SELECT + s.id, s.name, s.school_number, s.state, s.district, s.city, + s.postal_code, s.street, s.address_full, s.latitude, s.longitude, + s.website, s.email, s.phone, s.fax, + s.principal_name, s.principal_email, + s.student_count, s.teacher_count, + s.is_public, s.is_all_day, s.source, s.crawled_at, + s.is_active, s.created_at, s.updated_at, + st.name as school_type, st.name_short as school_type_short, st.category as school_category, + (SELECT COUNT(*) FROM school_staff ss WHERE ss.school_id = s.id AND ss.is_active = TRUE) as staff_count + FROM schools s + LEFT JOIN school_types st ON s.school_type_id = st.id + WHERE s.id = $1 + """, school_id) + + if not row: + raise HTTPException(status_code=404, detail="School not found") + + return SchoolResponse( + id=str(row["id"]), + name=row["name"], + school_number=row["school_number"], + school_type=row["school_type"], + school_type_short=row["school_type_short"], + school_category=row["school_category"], + state=row["state"], + district=row["district"], + city=row["city"], + postal_code=row["postal_code"], + street=row["street"], + address_full=row["address_full"], + latitude=row["latitude"], + longitude=row["longitude"], + website=row["website"], + email=row["email"], + phone=row["phone"], + fax=row["fax"], + principal_name=row["principal_name"], + principal_email=row["principal_email"], + student_count=row["student_count"], + teacher_count=row["teacher_count"], + is_public=row["is_public"], + is_all_day=row["is_all_day"], + staff_count=row["staff_count"], + source=row["source"], + crawled_at=row["crawled_at"], + is_active=row["is_active"], + created_at=row["created_at"], + updated_at=row["updated_at"], + ) + + +@router.post("/bulk-import", response_model=BulkImportResponse) +async def bulk_import_schools(request: BulkImportRequest): + """Bulk import schools. Updates existing schools based on school_number + state.""" + pool = await get_db_pool() + imported = 0 + updated = 0 + skipped = 0 + errors = [] + + async with pool.acquire() as conn: + # Get school type mapping + type_rows = await conn.fetch("SELECT id, name FROM school_types") + type_map = {row["name"].lower(): str(row["id"]) for row in type_rows} + + for school in request.schools: + try: + # Find school type ID + school_type_id = None + if school.school_type_raw: + school_type_id = type_map.get(school.school_type_raw.lower()) + + # Check if school exists (by school_number + state, or by name + city + state) + existing = None + if school.school_number: + existing = await conn.fetchrow( + "SELECT id FROM schools WHERE school_number = $1 AND state = $2", + school.school_number, school.state + ) + if not existing and school.city: + existing = await conn.fetchrow( + "SELECT id FROM schools WHERE LOWER(name) = LOWER($1) AND LOWER(city) = LOWER($2) AND state = $3", + school.name, school.city, school.state + ) + + if existing: + # Update existing school + await conn.execute(""" + UPDATE schools SET + name = $2, + school_type_id = COALESCE($3, school_type_id), + school_type_raw = COALESCE($4, school_type_raw), + district = COALESCE($5, district), + city = COALESCE($6, city), + postal_code = COALESCE($7, postal_code), + street = COALESCE($8, street), + address_full = COALESCE($9, address_full), + latitude = COALESCE($10, latitude), + longitude = COALESCE($11, longitude), + website = COALESCE($12, website), + email = COALESCE($13, email), + phone = COALESCE($14, phone), + fax = COALESCE($15, fax), + principal_name = COALESCE($16, principal_name), + principal_title = COALESCE($17, principal_title), + principal_email = COALESCE($18, principal_email), + principal_phone = COALESCE($19, principal_phone), + student_count = COALESCE($20, student_count), + teacher_count = COALESCE($21, teacher_count), + is_public = $22, + source = COALESCE($23, source), + source_url = COALESCE($24, source_url), + updated_at = NOW() + WHERE id = $1 + """, + existing["id"], + school.name, + school_type_id, + school.school_type_raw, + school.district, + school.city, + school.postal_code, + school.street, + school.address_full, + school.latitude, + school.longitude, + school.website, + school.email, + school.phone, + school.fax, + school.principal_name, + school.principal_title, + school.principal_email, + school.principal_phone, + school.student_count, + school.teacher_count, + school.is_public, + school.source, + school.source_url, + ) + updated += 1 + else: + # Insert new school + await conn.execute(""" + INSERT INTO schools ( + name, school_number, school_type_id, school_type_raw, + state, district, city, postal_code, street, address_full, + latitude, longitude, website, email, phone, fax, + principal_name, principal_title, principal_email, principal_phone, + student_count, teacher_count, is_public, + source, source_url, crawled_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, + $21, $22, $23, $24, $25, NOW() + ) + """, + school.name, + school.school_number, + school_type_id, + school.school_type_raw, + school.state, + school.district, + school.city, + school.postal_code, + school.street, + school.address_full, + school.latitude, + school.longitude, + school.website, + school.email, + school.phone, + school.fax, + school.principal_name, + school.principal_title, + school.principal_email, + school.principal_phone, + school.student_count, + school.teacher_count, + school.is_public, + school.source, + school.source_url, + ) + imported += 1 + + except Exception as e: + errors.append(f"Error importing {school.name}: {str(e)}") + if len(errors) > 100: + errors.append("... (more errors truncated)") + break + + return BulkImportResponse( + imported=imported, + updated=updated, + skipped=skipped, + errors=errors[:100], + ) + + +# ============================================================================= +# School Staff Endpoints +# ============================================================================= + + +@router.get("/{school_id}/staff", response_model=SchoolStaffListResponse) +async def get_school_staff(school_id: str): + """Get staff members for a school.""" + pool = await get_db_pool() + async with pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT + ss.id, ss.school_id, ss.first_name, ss.last_name, ss.full_name, + ss.title, ss.position, ss.position_type, ss.subjects, + ss.email, ss.phone, ss.profile_url, ss.photo_url, + ss.is_active, ss.created_at, + s.name as school_name + FROM school_staff ss + JOIN schools s ON ss.school_id = s.id + WHERE ss.school_id = $1 AND ss.is_active = TRUE + ORDER BY + CASE ss.position_type + WHEN 'principal' THEN 1 + WHEN 'vice_principal' THEN 2 + WHEN 'secretary' THEN 3 + ELSE 4 + END, + ss.last_name + """, school_id) + + staff = [ + SchoolStaffResponse( + id=str(row["id"]), + school_id=str(row["school_id"]), + school_name=row["school_name"], + first_name=row["first_name"], + last_name=row["last_name"], + full_name=row["full_name"], + title=row["title"], + position=row["position"], + position_type=row["position_type"], + subjects=row["subjects"], + email=row["email"], + phone=row["phone"], + profile_url=row["profile_url"], + photo_url=row["photo_url"], + is_active=row["is_active"], + created_at=row["created_at"], + ) + for row in rows + ] + + return SchoolStaffListResponse( + staff=staff, + total=len(staff), + ) + + +@router.post("/{school_id}/staff", response_model=SchoolStaffResponse) +async def create_school_staff(school_id: str, staff: SchoolStaffBase): + """Add a staff member to a school.""" + pool = await get_db_pool() + async with pool.acquire() as conn: + # Verify school exists + school = await conn.fetchrow("SELECT name FROM schools WHERE id = $1", school_id) + if not school: + raise HTTPException(status_code=404, detail="School not found") + + # Create full name + full_name = staff.full_name + if not full_name: + parts = [] + if staff.title: + parts.append(staff.title) + if staff.first_name: + parts.append(staff.first_name) + parts.append(staff.last_name) + full_name = " ".join(parts) + + row = await conn.fetchrow(""" + INSERT INTO school_staff ( + school_id, first_name, last_name, full_name, title, + position, position_type, subjects, email, phone + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id, created_at + """, + school_id, + staff.first_name, + staff.last_name, + full_name, + staff.title, + staff.position, + staff.position_type, + staff.subjects, + staff.email, + staff.phone, + ) + + return SchoolStaffResponse( + id=str(row["id"]), + school_id=school_id, + school_name=school["name"], + first_name=staff.first_name, + last_name=staff.last_name, + full_name=full_name, + title=staff.title, + position=staff.position, + position_type=staff.position_type, + subjects=staff.subjects, + email=staff.email, + phone=staff.phone, + is_active=True, + created_at=row["created_at"], + ) + + +# ============================================================================= +# Search Endpoints +# ============================================================================= + + +@router.get("/search/staff", response_model=SchoolStaffListResponse) +async def search_school_staff( + q: Optional[str] = Query(None, description="Search query"), + state: Optional[str] = Query(None, description="Filter by state"), + position_type: Optional[str] = Query(None, description="Filter by position type"), + has_email: Optional[bool] = Query(None, description="Only staff with email"), + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), +): + """Search school staff across all schools.""" + pool = await get_db_pool() + async with pool.acquire() as conn: + conditions = ["ss.is_active = TRUE", "s.is_active = TRUE"] + params = [] + param_idx = 1 + + if q: + conditions.append(f""" + (LOWER(ss.full_name) LIKE LOWER(${param_idx}) + OR LOWER(ss.last_name) LIKE LOWER(${param_idx}) + OR LOWER(s.name) LIKE LOWER(${param_idx})) + """) + params.append(f"%{q}%") + param_idx += 1 + + if state: + conditions.append(f"s.state = ${param_idx}") + params.append(state.upper()) + param_idx += 1 + + if position_type: + conditions.append(f"ss.position_type = ${param_idx}") + params.append(position_type) + param_idx += 1 + + if has_email is not None and has_email: + conditions.append("ss.email IS NOT NULL") + + where_clause = " AND ".join(conditions) + + # Count total + total = await conn.fetchval(f""" + SELECT COUNT(*) FROM school_staff ss + JOIN schools s ON ss.school_id = s.id + WHERE {where_clause} + """, *params) + + # Fetch staff + offset = (page - 1) * page_size + rows = await conn.fetch(f""" + SELECT + ss.id, ss.school_id, ss.first_name, ss.last_name, ss.full_name, + ss.title, ss.position, ss.position_type, ss.subjects, + ss.email, ss.phone, ss.profile_url, ss.photo_url, + ss.is_active, ss.created_at, + s.name as school_name + FROM school_staff ss + JOIN schools s ON ss.school_id = s.id + WHERE {where_clause} + ORDER BY ss.last_name, ss.first_name + LIMIT ${param_idx} OFFSET ${param_idx + 1} + """, *params, page_size, offset) + + staff = [ + SchoolStaffResponse( + id=str(row["id"]), + school_id=str(row["school_id"]), + school_name=row["school_name"], + first_name=row["first_name"], + last_name=row["last_name"], + full_name=row["full_name"], + title=row["title"], + position=row["position"], + position_type=row["position_type"], + subjects=row["subjects"], + email=row["email"], + phone=row["phone"], + profile_url=row["profile_url"], + photo_url=row["photo_url"], + is_active=row["is_active"], + created_at=row["created_at"], + ) + for row in rows + ] + + return SchoolStaffListResponse( + staff=staff, + total=total, + ) diff --git a/backend/llm_gateway/routes/tools.py b/backend/llm_gateway/routes/tools.py new file mode 100644 index 0000000..b6e6067 --- /dev/null +++ b/backend/llm_gateway/routes/tools.py @@ -0,0 +1,174 @@ +""" +Tool Routes für LLM Gateway. + +Bietet API-Endpoints für externe Tools wie Web Search. +""" + +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from ..middleware.auth import verify_api_key +from ..services.tool_gateway import ( + ToolGateway, + get_tool_gateway, + SearchDepth, + TavilyError, + ToolGatewayError, +) + + +router = APIRouter() + + +# Request/Response Models +class SearchRequest(BaseModel): + """Request für Web-Suche.""" + query: str = Field(..., min_length=1, max_length=1000, description="Suchanfrage") + search_depth: Optional[SearchDepth] = Field( + default=None, + description="Suchtiefe: basic (schnell) oder advanced (gründlich)", + ) + max_results: Optional[int] = Field( + default=None, + ge=1, + le=20, + description="Maximale Anzahl Ergebnisse (1-20)", + ) + include_domains: Optional[list[str]] = Field( + default=None, + description="Nur diese Domains durchsuchen", + ) + exclude_domains: Optional[list[str]] = Field( + default=None, + description="Diese Domains ausschließen", + ) + + +class SearchResultItem(BaseModel): + """Ein Suchergebnis.""" + title: str + url: str + content: str + score: float + published_date: Optional[str] = None + + +class SearchResponse(BaseModel): + """Response für Web-Suche.""" + query: str + redacted_query: Optional[str] = Field( + default=None, + description="Redaktierte Query (nur wenn PII gefunden)", + ) + results: list[SearchResultItem] + answer: Optional[str] = Field( + default=None, + description="KI-generierte Zusammenfassung der Ergebnisse", + ) + pii_detected: bool = Field( + default=False, + description="True wenn PII in der Anfrage erkannt und redaktiert wurde", + ) + pii_types: list[str] = Field( + default_factory=list, + description="Liste der erkannten PII-Typen", + ) + response_time_ms: int = Field( + default=0, + description="Antwortzeit in Millisekunden", + ) + + +class ToolsHealthResponse(BaseModel): + """Health-Status der Tools.""" + tavily: dict + pii_redaction: dict + + +@router.post("/search", response_model=SearchResponse) +async def web_search( + request: SearchRequest, + _: str = Depends(verify_api_key), + tool_gateway: ToolGateway = Depends(get_tool_gateway), +): + """ + Führt eine Web-Suche durch. + + Die Suchanfrage wird automatisch auf personenbezogene Daten (PII) + geprüft. Gefundene PII werden vor dem Versand an den Suchdienst + redaktiert, um DSGVO-Konformität zu gewährleisten. + + **PII-Erkennung umfasst:** + - E-Mail-Adressen + - Telefonnummern + - IBAN/Bankkonten + - Kreditkartennummern + - Sozialversicherungsnummern + - IP-Adressen + - Geburtsdaten + + **Beispiel:** + ``` + POST /llm/tools/search + { + "query": "Schulrecht Bayern Datenschutz", + "max_results": 5 + } + ``` + """ + try: + result = await tool_gateway.search( + query=request.query, + search_depth=request.search_depth, + max_results=request.max_results, + include_domains=request.include_domains, + exclude_domains=request.exclude_domains, + ) + + return SearchResponse( + query=result.query, + redacted_query=result.redacted_query, + results=[ + SearchResultItem( + title=r.title, + url=r.url, + content=r.content, + score=r.score, + published_date=r.published_date, + ) + for r in result.results + ], + answer=result.answer, + pii_detected=result.pii_detected, + pii_types=result.pii_types, + response_time_ms=result.response_time_ms, + ) + + except TavilyError as e: + # TavilyError first (more specific, inherits from ToolGatewayError) + raise HTTPException( + status_code=502, + detail=f"Search service error: {e}", + ) + except ToolGatewayError as e: + raise HTTPException( + status_code=503, + detail=f"Tool service unavailable: {e}", + ) + + +@router.get("/health", response_model=ToolsHealthResponse) +async def tools_health( + _: str = Depends(verify_api_key), + tool_gateway: ToolGateway = Depends(get_tool_gateway), +): + """ + Prüft den Gesundheitsstatus der Tool-Services. + + Gibt Status für jeden konfigurierten Tool-Service zurück: + - Tavily: Web-Suche + - PII Redaction: Datenschutz-Filter + """ + status = await tool_gateway.health_check() + return ToolsHealthResponse(**status) diff --git a/backend/llm_gateway/services/__init__.py b/backend/llm_gateway/services/__init__.py new file mode 100644 index 0000000..87135cb --- /dev/null +++ b/backend/llm_gateway/services/__init__.py @@ -0,0 +1,21 @@ +""" +LLM Gateway Services. +""" + +from .inference import InferenceService, get_inference_service +from .playbook_service import PlaybookService +from .pii_detector import PIIDetector, get_pii_detector, PIIType, RedactionResult +from .tool_gateway import ToolGateway, get_tool_gateway, SearchDepth + +__all__ = [ + "InferenceService", + "get_inference_service", + "PlaybookService", + "PIIDetector", + "get_pii_detector", + "PIIType", + "RedactionResult", + "ToolGateway", + "get_tool_gateway", + "SearchDepth", +] diff --git a/backend/llm_gateway/services/communication_service.py b/backend/llm_gateway/services/communication_service.py new file mode 100644 index 0000000..c2f34f1 --- /dev/null +++ b/backend/llm_gateway/services/communication_service.py @@ -0,0 +1,614 @@ +""" +Communication Service - KI-gestützte Lehrer-Eltern-Kommunikation. + +Unterstützt Lehrkräfte bei der Erstellung professioneller, rechtlich fundierter +Kommunikation mit Eltern. Basiert auf den Prinzipien der gewaltfreien Kommunikation +(GFK nach Marshall Rosenberg) und deutschen Schulgesetzen. + +Die rechtlichen Referenzen werden dynamisch aus der Datenbank geladen +(edu_search_documents Tabelle), nicht mehr hardcoded. +""" + +import logging +import os +from typing import Optional, List, Dict, Any +from enum import Enum, auto +from dataclasses import dataclass + +import httpx + +logger = logging.getLogger(__name__) + +# Legal Crawler API URL (für dynamische Rechtsinhalte) +LEGAL_CRAWLER_API_URL = os.getenv( + "LEGAL_CRAWLER_API_URL", + "http://localhost:8000/v1/legal-crawler" +) + + +class CommunicationType(str, Enum): + """Arten von Eltern-Kommunikation.""" + GENERAL_INFO = "general_info" # Allgemeine Information + BEHAVIOR = "behavior" # Verhalten/Disziplin + ACADEMIC = "academic" # Schulleistungen + ATTENDANCE = "attendance" # Anwesenheit/Fehlzeiten + MEETING_INVITE = "meeting_invite" # Einladung zum Gespräch + POSITIVE_FEEDBACK = "positive_feedback" # Positives Feedback + CONCERN = "concern" # Bedenken äußern + CONFLICT = "conflict" # Konfliktlösung + SPECIAL_NEEDS = "special_needs" # Förderbedarf + + +class CommunicationTone(str, Enum): + """Tonalität der Kommunikation.""" + FORMAL = "formal" # Sehr förmlich + PROFESSIONAL = "professional" # Professionell-freundlich + WARM = "warm" # Warmherzig + CONCERNED = "concerned" # Besorgt + APPRECIATIVE = "appreciative" # Wertschätzend + + +@dataclass +class LegalReference: + """Rechtliche Referenz für Kommunikation.""" + law: str # z.B. "SchulG NRW" + paragraph: str # z.B. "§ 42" + title: str # z.B. "Pflichten der Eltern" + summary: str # Kurzzusammenfassung + relevance: str # Warum relevant für diesen Fall + + +@dataclass +class GFKPrinciple: + """Prinzip der Gewaltfreien Kommunikation.""" + principle: str # z.B. "Beobachtung" + description: str # Erklärung + example: str # Beispiel im Kontext + + +# Fallback Rechtliche Grundlagen (nur verwendet wenn DB leer) +# Die primäre Quelle sind gecrawlte Dokumente in der edu_search_documents Tabelle +FALLBACK_LEGAL_REFERENCES: Dict[str, Dict[str, LegalReference]] = { + "DEFAULT": { + "elternpflichten": LegalReference( + law="Landesschulgesetz", + paragraph="(je nach Bundesland)", + title="Pflichten der Eltern", + summary="Eltern haben die Pflicht, die schulische Entwicklung zu unterstützen.", + relevance="Grundlage für Kooperationsaufforderungen" + ), + "schulpflicht": LegalReference( + law="Landesschulgesetz", + paragraph="(je nach Bundesland)", + title="Schulpflicht", + summary="Kinder sind schulpflichtig. Eltern sind verantwortlich für regelmäßigen Schulbesuch.", + relevance="Bei Fehlzeiten und Anwesenheitsproblemen" + ), + } +} + + +async def fetch_legal_references_from_db(state: str) -> List[Dict[str, Any]]: + """ + Lädt rechtliche Referenzen aus der Datenbank (via Legal Crawler API). + + Args: + state: Bundesland-Kürzel (z.B. "NRW", "BY", "NW") + + Returns: + Liste von Rechtsdokumenten mit Paragraphen + """ + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get( + f"{LEGAL_CRAWLER_API_URL}/references/{state}" + ) + + if response.status_code == 200: + data = response.json() + return data.get("documents", []) + else: + logger.warning(f"Legal API returned {response.status_code} for state {state}") + return [] + + except Exception as e: + logger.error(f"Fehler beim Laden rechtlicher Referenzen für {state}: {e}") + return [] + + +def parse_db_references_to_legal_refs( + db_docs: List[Dict[str, Any]], + topic: str +) -> List[LegalReference]: + """ + Konvertiert DB-Dokumente in LegalReference-Objekte. + + Filtert nach relevanten Paragraphen basierend auf dem Topic. + """ + references = [] + + # Topic zu relevanten Paragraph-Nummern mapping + topic_keywords = { + "elternpflichten": ["42", "76", "85", "eltern", "pflicht"], + "schulpflicht": ["41", "35", "schulpflicht", "pflicht"], + "ordnungsmassnahmen": ["53", "ordnung", "erzieh", "maßnahm"], + "datenschutz": ["120", "daten", "schutz"], + "foerderung": ["2", "förder", "bildung", "auftrag"], + } + + keywords = topic_keywords.get(topic, ["eltern"]) + + for doc in db_docs: + law_name = doc.get("law_name", doc.get("title", "Schulgesetz")) + paragraphs = doc.get("paragraphs", []) + + if not paragraphs: + # Wenn keine Paragraphen extrahiert, allgemeine Referenz erstellen + references.append(LegalReference( + law=law_name, + paragraph="(siehe Gesetzestext)", + title=doc.get("title", "Schulgesetz"), + summary=f"Rechtliche Grundlage aus {law_name}", + relevance=f"Relevant für {topic}" + )) + continue + + # Relevante Paragraphen finden + for para in paragraphs[:10]: # Max 10 Paragraphen prüfen + para_nr = para.get("nr", "") + para_title = para.get("title", "") + + # Prüfen ob Paragraph relevant ist + is_relevant = False + for keyword in keywords: + if keyword.lower() in para_nr.lower() or keyword.lower() in para_title.lower(): + is_relevant = True + break + + if is_relevant: + references.append(LegalReference( + law=law_name, + paragraph=para_nr, + title=para_title[:100], + summary=f"{para_title[:150]}", + relevance=f"Relevant für {topic}" + )) + + return references + +# GFK-Prinzipien +GFK_PRINCIPLES = [ + GFKPrinciple( + principle="Beobachtung", + description="Konkrete Handlungen beschreiben ohne Bewertung oder Interpretation", + example="'Ich habe bemerkt, dass Max in den letzten zwei Wochen dreimal ohne Hausaufgaben kam.' statt 'Max ist faul.'" + ), + GFKPrinciple( + principle="Gefühle", + description="Eigene Gefühle ausdrücken (Ich-Botschaften)", + example="'Ich mache mir Sorgen...' statt 'Sie müssen endlich...'" + ), + GFKPrinciple( + principle="Bedürfnisse", + description="Dahinterliegende Bedürfnisse benennen", + example="'Mir ist wichtig, dass Max sein Potential entfalten kann.' statt 'Sie müssen mehr kontrollieren.'" + ), + GFKPrinciple( + principle="Bitten", + description="Konkrete, erfüllbare Bitten formulieren", + example="'Wären Sie bereit, täglich die Hausaufgaben zu prüfen?' statt 'Tun Sie endlich etwas!'" + ), +] + + +# Kommunikationsvorlagen +COMMUNICATION_TEMPLATES: Dict[CommunicationType, Dict[str, str]] = { + CommunicationType.GENERAL_INFO: { + "subject": "Information: {topic}", + "opening": "Sehr geehrte/r {parent_name},\n\nich möchte Sie über folgendes informieren:", + "closing": "Bei Fragen stehe ich Ihnen gerne zur Verfügung.\n\nMit freundlichen Grüßen", + }, + CommunicationType.BEHAVIOR: { + "subject": "Gesprächswunsch: {student_name}", + "opening": "Sehr geehrte/r {parent_name},\n\nich wende mich heute an Sie, da mir das Wohlergehen von {student_name} sehr am Herzen liegt.", + "closing": "Ich bin überzeugt, dass wir gemeinsam eine gute Lösung finden können. Ich würde mich über ein Gespräch freuen.\n\nMit freundlichen Grüßen", + }, + CommunicationType.ACADEMIC: { + "subject": "Schulische Entwicklung: {student_name}", + "opening": "Sehr geehrte/r {parent_name},\n\nich möchte Sie über die schulische Entwicklung von {student_name} informieren.", + "closing": "Ich würde mich freuen, wenn wir gemeinsam überlegen könnten, wie wir {student_name} optimal unterstützen können.\n\nMit freundlichen Grüßen", + }, + CommunicationType.ATTENDANCE: { + "subject": "Fehlzeiten: {student_name}", + "opening": "Sehr geehrte/r {parent_name},\n\nich wende mich an Sie bezüglich der Anwesenheit von {student_name}.", + "closing": "Gemäß {legal_reference} sind regelmäßige Fehlzeiten meldepflichtig. Ich bin sicher, dass wir gemeinsam eine Lösung finden.\n\nMit freundlichen Grüßen", + }, + CommunicationType.MEETING_INVITE: { + "subject": "Einladung zum Elterngespräch", + "opening": "Sehr geehrte/r {parent_name},\n\nich würde mich freuen, Sie zu einem persönlichen Gespräch einzuladen.", + "closing": "Bitte teilen Sie mir mit, ob einer der vorgeschlagenen Termine für Sie passt, oder nennen Sie mir einen Alternativtermin.\n\nMit freundlichen Grüßen", + }, + CommunicationType.POSITIVE_FEEDBACK: { + "subject": "Positive Rückmeldung: {student_name}", + "opening": "Sehr geehrte/r {parent_name},\n\nich freue mich, Ihnen heute eine erfreuliche Nachricht mitteilen zu können.", + "closing": "Ich freue mich, {student_name} auf diesem positiven Weg weiter begleiten zu dürfen.\n\nMit herzlichen Grüßen", + }, + CommunicationType.CONCERN: { + "subject": "Gemeinsame Sorge: {student_name}", + "opening": "Sehr geehrte/r {parent_name},\n\nich wende mich heute an Sie, weil mir etwas aufgefallen ist, das ich gerne mit Ihnen besprechen würde.", + "closing": "Ich bin überzeugt, dass wir im Sinne von {student_name} gemeinsam eine gute Lösung finden werden.\n\nMit freundlichen Grüßen", + }, + CommunicationType.CONFLICT: { + "subject": "Bitte um ein klärendes Gespräch", + "opening": "Sehr geehrte/r {parent_name},\n\nich möchte das Gespräch mit Ihnen suchen, da mir eine konstruktive Zusammenarbeit sehr wichtig ist.", + "closing": "Mir liegt eine gute Kooperation zum Wohl von {student_name} am Herzen. Ich bin überzeugt, dass wir im Dialog eine für alle Seiten gute Lösung finden können.\n\nMit freundlichen Grüßen", + }, + CommunicationType.SPECIAL_NEEDS: { + "subject": "Förderung: {student_name}", + "opening": "Sehr geehrte/r {parent_name},\n\nich möchte mit Ihnen über die individuelle Förderung von {student_name} sprechen.", + "closing": "Gemäß dem Bildungsauftrag ({legal_reference}) ist es uns ein besonderes Anliegen, jedes Kind optimal zu fördern. Lassen Sie uns gemeinsam überlegen, wie wir {student_name} bestmöglich unterstützen können.\n\nMit freundlichen Grüßen", + }, +} + + +class CommunicationService: + """ + Service zur Unterstützung von Lehrer-Eltern-Kommunikation. + + Generiert professionelle, rechtlich fundierte und empathische Nachrichten + basierend auf den Prinzipien der gewaltfreien Kommunikation. + + Rechtliche Referenzen werden dynamisch aus der DB geladen (via Legal Crawler API). + """ + + def __init__(self): + self.fallback_references = FALLBACK_LEGAL_REFERENCES + self.gfk_principles = GFK_PRINCIPLES + self.templates = COMMUNICATION_TEMPLATES + # Cache für DB-Referenzen (um wiederholte API-Calls zu vermeiden) + self._cached_references: Dict[str, List[LegalReference]] = {} + + async def get_legal_references_async( + self, + state: str, + topic: str + ) -> List[LegalReference]: + """ + Gibt relevante rechtliche Referenzen für ein Bundesland und Thema zurück. + Lädt aus DB via Legal Crawler API. + + Args: + state: Bundesland-Kürzel (z.B. "NRW", "BY", "NW") + topic: Themenbereich (z.B. "elternpflichten", "schulpflicht") + + Returns: + Liste relevanter LegalReference-Objekte + """ + cache_key = f"{state}:{topic}" + + # Cache prüfen + if cache_key in self._cached_references: + return self._cached_references[cache_key] + + # Aus DB laden + db_docs = await fetch_legal_references_from_db(state) + + if db_docs: + # DB-Dokumente in LegalReference konvertieren + references = parse_db_references_to_legal_refs(db_docs, topic) + if references: + self._cached_references[cache_key] = references + return references + + # Fallback wenn DB leer + logger.info(f"Keine DB-Referenzen für {state}/{topic}, nutze Fallback") + return self._get_fallback_references(state, topic) + + def get_legal_references( + self, + state: str, + topic: str + ) -> List[LegalReference]: + """ + Synchrone Methode für Rückwärtskompatibilität. + Nutzt nur Fallback-Referenzen (für non-async Kontexte). + + Für dynamische DB-Referenzen bitte get_legal_references_async() verwenden. + """ + return self._get_fallback_references(state, topic) + + def _get_fallback_references( + self, + state: str, + topic: str + ) -> List[LegalReference]: + """Gibt Fallback-Referenzen zurück.""" + state_refs = self.fallback_references.get("DEFAULT", {}) + + if topic in state_refs: + return [state_refs[topic]] + + return list(state_refs.values()) + + def get_gfk_guidance( + self, + comm_type: CommunicationType + ) -> List[GFKPrinciple]: + """ + Gibt GFK-Leitlinien für einen Kommunikationstyp zurück. + """ + return self.gfk_principles + + def get_template( + self, + comm_type: CommunicationType + ) -> Dict[str, str]: + """ + Gibt die Vorlage für einen Kommunikationstyp zurück. + """ + return self.templates.get(comm_type, self.templates[CommunicationType.GENERAL_INFO]) + + def build_system_prompt( + self, + comm_type: CommunicationType, + state: str, + tone: CommunicationTone + ) -> str: + """ + Erstellt den System-Prompt für die KI-gestützte Nachrichtengenerierung. + + Args: + comm_type: Art der Kommunikation + state: Bundesland für rechtliche Referenzen + tone: Gewünschte Tonalität + + Returns: + System-Prompt für LLM + """ + # Rechtliche Referenzen sammeln + topic_map = { + CommunicationType.ATTENDANCE: "schulpflicht", + CommunicationType.BEHAVIOR: "ordnungsmassnahmen", + CommunicationType.ACADEMIC: "foerderung", + CommunicationType.SPECIAL_NEEDS: "foerderung", + CommunicationType.CONCERN: "elternpflichten", + CommunicationType.CONFLICT: "elternpflichten", + } + topic = topic_map.get(comm_type, "elternpflichten") + legal_refs = self.get_legal_references(state, topic) + + legal_context = "" + if legal_refs: + legal_context = "\n\nRechtliche Grundlagen:\n" + for ref in legal_refs: + legal_context += f"- {ref.law} {ref.paragraph} ({ref.title}): {ref.summary}\n" + + # Tonalität beschreiben + tone_descriptions = { + CommunicationTone.FORMAL: "Verwende eine sehr formelle, sachliche Sprache.", + CommunicationTone.PROFESSIONAL: "Verwende eine professionelle, aber freundliche Sprache.", + CommunicationTone.WARM: "Verwende eine warmherzige, einladende Sprache.", + CommunicationTone.CONCERNED: "Drücke aufrichtige Sorge und Empathie aus.", + CommunicationTone.APPRECIATIVE: "Betone Wertschätzung und positives Feedback.", + } + tone_desc = tone_descriptions.get(tone, tone_descriptions[CommunicationTone.PROFESSIONAL]) + + system_prompt = f"""Du bist ein erfahrener Kommunikationsberater für Lehrkräfte im deutschen Schulsystem. +Deine Aufgabe ist es, professionelle, empathische und rechtlich fundierte Elternbriefe zu verfassen. + +GRUNDPRINZIPIEN (Gewaltfreie Kommunikation nach Marshall Rosenberg): + +1. BEOBACHTUNG: Beschreibe konkrete Handlungen ohne Bewertung + Beispiel: "Ich habe bemerkt, dass..." statt "Das Kind ist..." + +2. GEFÜHLE: Drücke Gefühle als Ich-Botschaften aus + Beispiel: "Ich mache mir Sorgen..." statt "Sie müssen..." + +3. BEDÜRFNISSE: Benenne dahinterliegende Bedürfnisse + Beispiel: "Mir ist wichtig, dass..." statt "Sie sollten..." + +4. BITTEN: Formuliere konkrete, erfüllbare Bitten + Beispiel: "Wären Sie bereit, ...?" statt "Tun Sie endlich...!" + +WICHTIGE REGELN: +- Immer die Würde aller Beteiligten wahren +- Keine Schuldzuweisungen oder Vorwürfe +- Lösungsorientiert statt problemfokussiert +- Auf Augenhöhe kommunizieren +- Kooperation statt Konfrontation +- Deutsche Sprache, förmliche Anrede (Sie) +- Sachlich, aber empathisch +{legal_context} + +TONALITÄT: +{tone_desc} + +FORMAT: +- Verfasse den Brief als vollständigen, versandfertigen Text +- Beginne mit der Anrede +- Strukturiere den Inhalt klar und verständlich +- Schließe mit einer freundlichen Grußformel +- Die Signatur (Name der Lehrkraft) wird später hinzugefügt + +WICHTIG: Der Brief soll professionell und rechtlich einwandfrei sein, aber gleichzeitig +menschlich und einladend wirken. Ziel ist immer eine konstruktive Zusammenarbeit.""" + + return system_prompt + + def build_user_prompt( + self, + comm_type: CommunicationType, + context: Dict[str, Any] + ) -> str: + """ + Erstellt den User-Prompt aus dem Kontext. + + Args: + comm_type: Art der Kommunikation + context: Kontextinformationen (student_name, parent_name, situation, etc.) + + Returns: + User-Prompt für LLM + """ + student_name = context.get("student_name", "das Kind") + parent_name = context.get("parent_name", "Frau/Herr") + situation = context.get("situation", "") + additional_info = context.get("additional_info", "") + + type_descriptions = { + CommunicationType.GENERAL_INFO: "eine allgemeine Information", + CommunicationType.BEHAVIOR: "ein Verhalten, das besprochen werden sollte", + CommunicationType.ACADEMIC: "die schulische Entwicklung", + CommunicationType.ATTENDANCE: "Fehlzeiten oder Anwesenheitsprobleme", + CommunicationType.MEETING_INVITE: "eine Einladung zum Elterngespräch", + CommunicationType.POSITIVE_FEEDBACK: "positives Feedback", + CommunicationType.CONCERN: "eine Sorge oder ein Anliegen", + CommunicationType.CONFLICT: "eine konflikthafte Situation", + CommunicationType.SPECIAL_NEEDS: "Förderbedarf oder besondere Unterstützung", + } + type_desc = type_descriptions.get(comm_type, "ein Anliegen") + + user_prompt = f"""Schreibe einen Elternbrief zu folgendem Anlass: {type_desc} + +Schülername: {student_name} +Elternname: {parent_name} + +Situation: +{situation} +""" + + if additional_info: + user_prompt += f"\nZusätzliche Informationen:\n{additional_info}\n" + + user_prompt += """ +Bitte verfasse einen professionellen, empathischen Brief nach den GFK-Prinzipien. +Der Brief sollte: +- Die Situation sachlich beschreiben (Beobachtung) +- Verständnis und Sorge ausdrücken (Gefühle) +- Das gemeinsame Ziel betonen (Bedürfnisse) +- Einen konstruktiven Vorschlag machen (Bitte) +""" + + return user_prompt + + def validate_communication(self, text: str) -> Dict[str, Any]: + """ + Validiert eine generierte Kommunikation auf GFK-Konformität. + + Args: + text: Der zu prüfende Text + + Returns: + Validierungsergebnis mit Verbesserungsvorschlägen + """ + issues = [] + suggestions = [] + + # Prüfe auf problematische Formulierungen + problematic_patterns = [ + ("Sie müssen", "Vorschlag: 'Wären Sie bereit, ...' oder 'Ich bitte Sie, ...'"), + ("Sie sollten", "Vorschlag: 'Ich würde mir wünschen, ...'"), + ("Das Kind ist", "Vorschlag: 'Ich habe beobachtet, dass ...'"), + ("immer", "Vorsicht bei Verallgemeinerungen - besser konkrete Beispiele"), + ("nie", "Vorsicht bei Verallgemeinerungen - besser konkrete Beispiele"), + ("faul", "Vorschlag: Verhalten konkret beschreiben statt bewerten"), + ("unverschämt", "Vorschlag: Verhalten konkret beschreiben statt bewerten"), + ("respektlos", "Vorschlag: Verhalten konkret beschreiben statt bewerten"), + ] + + for pattern, suggestion in problematic_patterns: + if pattern.lower() in text.lower(): + issues.append(f"Problematische Formulierung gefunden: '{pattern}'") + suggestions.append(suggestion) + + # Prüfe auf positive Elemente + positive_elements = [] + positive_patterns = [ + ("Ich habe bemerkt", "Gute Beobachtung"), + ("Ich möchte", "Gute Ich-Botschaft"), + ("gemeinsam", "Gute Kooperationsorientierung"), + ("wichtig", "Gutes Bedürfnis-Statement"), + ("freuen", "Positive Tonalität"), + ("Wären Sie bereit", "Gute Bitte-Formulierung"), + ] + + for pattern, feedback in positive_patterns: + if pattern.lower() in text.lower(): + positive_elements.append(feedback) + + return { + "is_valid": len(issues) == 0, + "issues": issues, + "suggestions": suggestions, + "positive_elements": positive_elements, + "gfk_score": max(0, 100 - len(issues) * 15 + len(positive_elements) * 10) / 100 + } + + def get_all_communication_types(self) -> List[Dict[str, str]]: + """Gibt alle verfügbaren Kommunikationstypen zurück.""" + return [ + {"value": ct.value, "label": self._get_type_label(ct)} + for ct in CommunicationType + ] + + def _get_type_label(self, ct: CommunicationType) -> str: + """Gibt das deutsche Label für einen Kommunikationstyp zurück.""" + labels = { + CommunicationType.GENERAL_INFO: "Allgemeine Information", + CommunicationType.BEHAVIOR: "Verhalten/Disziplin", + CommunicationType.ACADEMIC: "Schulleistungen", + CommunicationType.ATTENDANCE: "Fehlzeiten", + CommunicationType.MEETING_INVITE: "Einladung zum Gespräch", + CommunicationType.POSITIVE_FEEDBACK: "Positives Feedback", + CommunicationType.CONCERN: "Bedenken äußern", + CommunicationType.CONFLICT: "Konfliktlösung", + CommunicationType.SPECIAL_NEEDS: "Förderbedarf", + } + return labels.get(ct, ct.value) + + def get_all_tones(self) -> List[Dict[str, str]]: + """Gibt alle verfügbaren Tonalitäten zurück.""" + labels = { + CommunicationTone.FORMAL: "Sehr förmlich", + CommunicationTone.PROFESSIONAL: "Professionell-freundlich", + CommunicationTone.WARM: "Warmherzig", + CommunicationTone.CONCERNED: "Besorgt", + CommunicationTone.APPRECIATIVE: "Wertschätzend", + } + return [ + {"value": t.value, "label": labels.get(t, t.value)} + for t in CommunicationTone + ] + + def get_states(self) -> List[Dict[str, str]]: + """Gibt alle verfügbaren Bundesländer zurück.""" + return [ + {"value": "NRW", "label": "Nordrhein-Westfalen"}, + {"value": "BY", "label": "Bayern"}, + {"value": "BW", "label": "Baden-Württemberg"}, + {"value": "NI", "label": "Niedersachsen"}, + {"value": "HE", "label": "Hessen"}, + {"value": "SN", "label": "Sachsen"}, + {"value": "RP", "label": "Rheinland-Pfalz"}, + {"value": "SH", "label": "Schleswig-Holstein"}, + {"value": "BE", "label": "Berlin"}, + {"value": "BB", "label": "Brandenburg"}, + {"value": "MV", "label": "Mecklenburg-Vorpommern"}, + {"value": "ST", "label": "Sachsen-Anhalt"}, + {"value": "TH", "label": "Thüringen"}, + {"value": "HH", "label": "Hamburg"}, + {"value": "HB", "label": "Bremen"}, + {"value": "SL", "label": "Saarland"}, + ] + + +# Singleton-Instanz +_communication_service: Optional[CommunicationService] = None + + +def get_communication_service() -> CommunicationService: + """Gibt die Singleton-Instanz des CommunicationService zurück.""" + global _communication_service + if _communication_service is None: + _communication_service = CommunicationService() + return _communication_service diff --git a/backend/llm_gateway/services/inference.py b/backend/llm_gateway/services/inference.py new file mode 100644 index 0000000..756afc5 --- /dev/null +++ b/backend/llm_gateway/services/inference.py @@ -0,0 +1,522 @@ +""" +Inference Service - Kommunikation mit LLM Backends. + +Unterstützt: +- Ollama (lokal) +- vLLM (remote, OpenAI-kompatibel) +- Anthropic Claude API (Fallback) +""" + +import httpx +import json +import logging +from typing import AsyncIterator, Optional +from dataclasses import dataclass + +from ..config import get_config, LLMBackendConfig +from ..models.chat import ( + ChatCompletionRequest, + ChatCompletionResponse, + ChatCompletionChunk, + ChatMessage, + ChatChoice, + StreamChoice, + ChatChoiceDelta, + Usage, + ModelInfo, + ModelListResponse, +) + +logger = logging.getLogger(__name__) + + +@dataclass +class InferenceResult: + """Ergebnis einer Inference-Anfrage.""" + content: str + model: str + backend: str + usage: Optional[Usage] = None + finish_reason: str = "stop" + + +class InferenceService: + """Service für LLM Inference über verschiedene Backends.""" + + def __init__(self): + self.config = get_config() + self._client: Optional[httpx.AsyncClient] = None + + async def get_client(self) -> httpx.AsyncClient: + """Lazy initialization des HTTP Clients.""" + if self._client is None: + self._client = httpx.AsyncClient(timeout=120.0) + return self._client + + async def close(self): + """Schließt den HTTP Client.""" + if self._client: + await self._client.aclose() + self._client = None + + def _get_available_backend(self, preferred_model: Optional[str] = None) -> Optional[LLMBackendConfig]: + """Findet das erste verfügbare Backend basierend auf Priorität.""" + for backend_name in self.config.backend_priority: + backend = getattr(self.config, backend_name, None) + if backend and backend.enabled: + return backend + return None + + def _map_model_to_backend(self, model: str) -> tuple[str, LLMBackendConfig]: + """ + Mapped ein Modell-Name zum entsprechenden Backend. + + Beispiele: + - "breakpilot-teacher-8b" → Ollama/vLLM mit llama3.1:8b + - "claude-3-5-sonnet" → Anthropic + """ + model_lower = model.lower() + + # Explizite Claude-Modelle → Anthropic + if "claude" in model_lower: + if self.config.anthropic and self.config.anthropic.enabled: + return self.config.anthropic.default_model, self.config.anthropic + raise ValueError("Anthropic backend not configured") + + # BreakPilot Modelle → primäres Backend + if "breakpilot" in model_lower or "teacher" in model_lower: + backend = self._get_available_backend() + if backend: + # Map zu tatsächlichem Modell-Namen + if "70b" in model_lower: + actual_model = "llama3.1:70b" if backend.name == "ollama" else "meta-llama/Meta-Llama-3.1-70B-Instruct" + else: + actual_model = "llama3.1:8b" if backend.name == "ollama" else "meta-llama/Meta-Llama-3.1-8B-Instruct" + return actual_model, backend + raise ValueError("No LLM backend available") + + # Mistral Modelle + if "mistral" in model_lower: + backend = self._get_available_backend() + if backend: + actual_model = "mistral:7b" if backend.name == "ollama" else "mistralai/Mistral-7B-Instruct-v0.2" + return actual_model, backend + raise ValueError("No LLM backend available") + + # Fallback: verwende Modell-Name direkt + backend = self._get_available_backend() + if backend: + return model, backend + raise ValueError("No LLM backend available") + + async def _call_ollama( + self, + backend: LLMBackendConfig, + model: str, + request: ChatCompletionRequest, + ) -> InferenceResult: + """Ruft Ollama API auf (nicht OpenAI-kompatibel).""" + client = await self.get_client() + + # Ollama verwendet eigenes Format + messages = [{"role": m.role, "content": m.content or ""} for m in request.messages] + + payload = { + "model": model, + "messages": messages, + "stream": False, + "options": { + "temperature": request.temperature, + "top_p": request.top_p, + }, + } + + if request.max_tokens: + payload["options"]["num_predict"] = request.max_tokens + + response = await client.post( + f"{backend.base_url}/api/chat", + json=payload, + timeout=backend.timeout, + ) + response.raise_for_status() + data = response.json() + + return InferenceResult( + content=data.get("message", {}).get("content", ""), + model=model, + backend="ollama", + usage=Usage( + prompt_tokens=data.get("prompt_eval_count", 0), + completion_tokens=data.get("eval_count", 0), + total_tokens=data.get("prompt_eval_count", 0) + data.get("eval_count", 0), + ), + finish_reason="stop" if data.get("done") else "length", + ) + + async def _stream_ollama( + self, + backend: LLMBackendConfig, + model: str, + request: ChatCompletionRequest, + response_id: str, + ) -> AsyncIterator[ChatCompletionChunk]: + """Streamt von Ollama.""" + client = await self.get_client() + + messages = [{"role": m.role, "content": m.content or ""} for m in request.messages] + + payload = { + "model": model, + "messages": messages, + "stream": True, + "options": { + "temperature": request.temperature, + "top_p": request.top_p, + }, + } + + if request.max_tokens: + payload["options"]["num_predict"] = request.max_tokens + + async with client.stream( + "POST", + f"{backend.base_url}/api/chat", + json=payload, + timeout=backend.timeout, + ) as response: + response.raise_for_status() + async for line in response.aiter_lines(): + if not line: + continue + try: + data = json.loads(line) + content = data.get("message", {}).get("content", "") + done = data.get("done", False) + + yield ChatCompletionChunk( + id=response_id, + model=model, + choices=[ + StreamChoice( + index=0, + delta=ChatChoiceDelta(content=content), + finish_reason="stop" if done else None, + ) + ], + ) + except json.JSONDecodeError: + continue + + async def _call_openai_compatible( + self, + backend: LLMBackendConfig, + model: str, + request: ChatCompletionRequest, + ) -> InferenceResult: + """Ruft OpenAI-kompatible API auf (vLLM, etc.).""" + client = await self.get_client() + + headers = {"Content-Type": "application/json"} + if backend.api_key: + headers["Authorization"] = f"Bearer {backend.api_key}" + + payload = { + "model": model, + "messages": [m.model_dump(exclude_none=True) for m in request.messages], + "stream": False, + "temperature": request.temperature, + "top_p": request.top_p, + } + + if request.max_tokens: + payload["max_tokens"] = request.max_tokens + if request.stop: + payload["stop"] = request.stop + + response = await client.post( + f"{backend.base_url}/v1/chat/completions", + json=payload, + headers=headers, + timeout=backend.timeout, + ) + response.raise_for_status() + data = response.json() + + choice = data.get("choices", [{}])[0] + usage_data = data.get("usage", {}) + + return InferenceResult( + content=choice.get("message", {}).get("content", ""), + model=model, + backend=backend.name, + usage=Usage( + prompt_tokens=usage_data.get("prompt_tokens", 0), + completion_tokens=usage_data.get("completion_tokens", 0), + total_tokens=usage_data.get("total_tokens", 0), + ), + finish_reason=choice.get("finish_reason", "stop"), + ) + + async def _stream_openai_compatible( + self, + backend: LLMBackendConfig, + model: str, + request: ChatCompletionRequest, + response_id: str, + ) -> AsyncIterator[ChatCompletionChunk]: + """Streamt von OpenAI-kompatibler API.""" + client = await self.get_client() + + headers = {"Content-Type": "application/json"} + if backend.api_key: + headers["Authorization"] = f"Bearer {backend.api_key}" + + payload = { + "model": model, + "messages": [m.model_dump(exclude_none=True) for m in request.messages], + "stream": True, + "temperature": request.temperature, + "top_p": request.top_p, + } + + if request.max_tokens: + payload["max_tokens"] = request.max_tokens + + async with client.stream( + "POST", + f"{backend.base_url}/v1/chat/completions", + json=payload, + headers=headers, + timeout=backend.timeout, + ) as response: + response.raise_for_status() + async for line in response.aiter_lines(): + if not line or not line.startswith("data: "): + continue + data_str = line[6:] # Remove "data: " prefix + if data_str == "[DONE]": + break + try: + data = json.loads(data_str) + choice = data.get("choices", [{}])[0] + delta = choice.get("delta", {}) + + yield ChatCompletionChunk( + id=response_id, + model=model, + choices=[ + StreamChoice( + index=0, + delta=ChatChoiceDelta( + role=delta.get("role"), + content=delta.get("content"), + ), + finish_reason=choice.get("finish_reason"), + ) + ], + ) + except json.JSONDecodeError: + continue + + async def _call_anthropic( + self, + backend: LLMBackendConfig, + model: str, + request: ChatCompletionRequest, + ) -> InferenceResult: + """Ruft Anthropic Claude API auf.""" + # Anthropic SDK verwenden (bereits installiert) + try: + import anthropic + except ImportError: + raise ImportError("anthropic package required for Claude API") + + client = anthropic.AsyncAnthropic(api_key=backend.api_key) + + # System message extrahieren + system_content = "" + messages = [] + for msg in request.messages: + if msg.role == "system": + system_content += (msg.content or "") + "\n" + else: + messages.append({"role": msg.role, "content": msg.content or ""}) + + response = await client.messages.create( + model=model, + max_tokens=request.max_tokens or 4096, + system=system_content.strip() if system_content else None, + messages=messages, + temperature=request.temperature, + top_p=request.top_p, + ) + + content = "" + if response.content: + content = response.content[0].text if response.content[0].type == "text" else "" + + return InferenceResult( + content=content, + model=model, + backend="anthropic", + usage=Usage( + prompt_tokens=response.usage.input_tokens, + completion_tokens=response.usage.output_tokens, + total_tokens=response.usage.input_tokens + response.usage.output_tokens, + ), + finish_reason="stop" if response.stop_reason == "end_turn" else response.stop_reason or "stop", + ) + + async def _stream_anthropic( + self, + backend: LLMBackendConfig, + model: str, + request: ChatCompletionRequest, + response_id: str, + ) -> AsyncIterator[ChatCompletionChunk]: + """Streamt von Anthropic Claude API.""" + try: + import anthropic + except ImportError: + raise ImportError("anthropic package required for Claude API") + + client = anthropic.AsyncAnthropic(api_key=backend.api_key) + + # System message extrahieren + system_content = "" + messages = [] + for msg in request.messages: + if msg.role == "system": + system_content += (msg.content or "") + "\n" + else: + messages.append({"role": msg.role, "content": msg.content or ""}) + + async with client.messages.stream( + model=model, + max_tokens=request.max_tokens or 4096, + system=system_content.strip() if system_content else None, + messages=messages, + temperature=request.temperature, + top_p=request.top_p, + ) as stream: + async for text in stream.text_stream: + yield ChatCompletionChunk( + id=response_id, + model=model, + choices=[ + StreamChoice( + index=0, + delta=ChatChoiceDelta(content=text), + finish_reason=None, + ) + ], + ) + + # Final chunk with finish_reason + yield ChatCompletionChunk( + id=response_id, + model=model, + choices=[ + StreamChoice( + index=0, + delta=ChatChoiceDelta(), + finish_reason="stop", + ) + ], + ) + + async def complete(self, request: ChatCompletionRequest) -> ChatCompletionResponse: + """ + Führt Chat Completion durch (non-streaming). + """ + actual_model, backend = self._map_model_to_backend(request.model) + + logger.info(f"Inference request: model={request.model} → {actual_model} via {backend.name}") + + if backend.name == "ollama": + result = await self._call_ollama(backend, actual_model, request) + elif backend.name == "anthropic": + result = await self._call_anthropic(backend, actual_model, request) + else: + result = await self._call_openai_compatible(backend, actual_model, request) + + return ChatCompletionResponse( + model=request.model, # Original requested model name + choices=[ + ChatChoice( + index=0, + message=ChatMessage(role="assistant", content=result.content), + finish_reason=result.finish_reason, + ) + ], + usage=result.usage, + ) + + async def stream(self, request: ChatCompletionRequest) -> AsyncIterator[ChatCompletionChunk]: + """ + Führt Chat Completion mit Streaming durch. + """ + import uuid + response_id = f"chatcmpl-{uuid.uuid4().hex[:12]}" + + actual_model, backend = self._map_model_to_backend(request.model) + + logger.info(f"Streaming request: model={request.model} → {actual_model} via {backend.name}") + + if backend.name == "ollama": + async for chunk in self._stream_ollama(backend, actual_model, request, response_id): + yield chunk + elif backend.name == "anthropic": + async for chunk in self._stream_anthropic(backend, actual_model, request, response_id): + yield chunk + else: + async for chunk in self._stream_openai_compatible(backend, actual_model, request, response_id): + yield chunk + + async def list_models(self) -> ModelListResponse: + """Listet verfügbare Modelle.""" + models = [] + + # BreakPilot Modelle (mapped zu verfügbaren Backends) + backend = self._get_available_backend() + if backend: + models.extend([ + ModelInfo( + id="breakpilot-teacher-8b", + owned_by="breakpilot", + description="Llama 3.1 8B optimiert für Schulkontext", + context_length=8192, + ), + ModelInfo( + id="breakpilot-teacher-70b", + owned_by="breakpilot", + description="Llama 3.1 70B für komplexe Aufgaben", + context_length=8192, + ), + ]) + + # Claude Modelle (wenn Anthropic konfiguriert) + if self.config.anthropic and self.config.anthropic.enabled: + models.append( + ModelInfo( + id="claude-3-5-sonnet", + owned_by="anthropic", + description="Claude 3.5 Sonnet - Fallback für höchste Qualität", + context_length=200000, + ) + ) + + return ModelListResponse(data=models) + + +# Singleton +_inference_service: Optional[InferenceService] = None + + +def get_inference_service() -> InferenceService: + """Gibt den Inference Service Singleton zurück.""" + global _inference_service + if _inference_service is None: + _inference_service = InferenceService() + return _inference_service diff --git a/backend/llm_gateway/services/legal_crawler.py b/backend/llm_gateway/services/legal_crawler.py new file mode 100644 index 0000000..008a3bb --- /dev/null +++ b/backend/llm_gateway/services/legal_crawler.py @@ -0,0 +1,290 @@ +""" +Legal Content Crawler Service. + +Crawlt Schulgesetze und rechtliche Inhalte von den Seed-URLs +und speichert sie in der Datenbank für den Communication-Service. +""" + +import asyncio +import hashlib +import logging +import re +from datetime import datetime +from typing import Dict, List, Optional +from dataclasses import dataclass + +import httpx +from bs4 import BeautifulSoup + +logger = logging.getLogger(__name__) + + +@dataclass +class CrawledDocument: + """Repräsentiert ein gecrawltes Dokument.""" + url: str + canonical_url: Optional[str] + title: str + content: str + content_hash: str + category: str + doc_type: str + state: Optional[str] + law_name: Optional[str] + paragraphs: Optional[List[Dict]] + trust_score: float + + +class LegalCrawler: + """Crawler für rechtliche Bildungsinhalte.""" + + def __init__(self, db_pool=None): + self.db_pool = db_pool + self.user_agent = "BreakPilot-Crawler/1.0 (Educational Purpose)" + self.timeout = 30.0 + self.rate_limit_delay = 1.0 # Sekunden zwischen Requests + + async def crawl_url(self, url: str, seed_info: Dict) -> Optional[CrawledDocument]: + """ + Crawlt eine URL und extrahiert den Inhalt. + + Args: + url: Die zu crawlende URL + seed_info: Metadaten vom Seed (category, state, trust_boost) + + Returns: + CrawledDocument oder None bei Fehler + """ + try: + async with httpx.AsyncClient( + follow_redirects=True, + timeout=self.timeout, + headers={"User-Agent": self.user_agent} + ) as client: + response = await client.get(url) + + if response.status_code != 200: + logger.warning(f"HTTP {response.status_code} für {url}") + return None + + content_type = response.headers.get("content-type", "") + + # PDF-Handling (für Saarland etc.) + if "pdf" in content_type.lower(): + return await self._process_pdf(response, url, seed_info) + + # HTML-Handling + if "html" in content_type.lower(): + return await self._process_html(response, url, seed_info) + + logger.warning(f"Unbekannter Content-Type: {content_type} für {url}") + return None + + except Exception as e: + logger.error(f"Fehler beim Crawlen von {url}: {e}") + return None + + async def _process_html( + self, + response: httpx.Response, + url: str, + seed_info: Dict + ) -> Optional[CrawledDocument]: + """Verarbeitet HTML-Inhalte.""" + html = response.text + soup = BeautifulSoup(html, "html.parser") + + # Titel extrahieren + title = "" + title_tag = soup.find("title") + if title_tag: + title = title_tag.get_text(strip=True) + + # Haupt-Content extrahieren (verschiedene Strategien) + content = "" + + # Strategie 1: main oder article Tag + main = soup.find("main") or soup.find("article") + if main: + content = main.get_text(separator="\n", strip=True) + else: + # Strategie 2: Body ohne Navigation etc. + for tag in soup.find_all(["nav", "header", "footer", "aside", "script", "style"]): + tag.decompose() + body = soup.find("body") + if body: + content = body.get_text(separator="\n", strip=True) + + if not content: + return None + + # Paragraphen extrahieren (für Schulgesetze) + paragraphs = self._extract_paragraphs(soup, content) + + # Law name ermitteln + law_name = seed_info.get("name", "") + if not law_name and title: + # Aus Titel extrahieren + law_patterns = [ + r"(SchulG\s+\w+)", + r"(Schulgesetz\s+\w+)", + r"(BayEUG)", + r"(\w+SchulG)", + ] + for pattern in law_patterns: + match = re.search(pattern, title) + if match: + law_name = match.group(1) + break + + # Content Hash berechnen + content_hash = hashlib.sha256(content.encode()).hexdigest()[:64] + + return CrawledDocument( + url=url, + canonical_url=str(response.url), + title=title, + content=content[:100000], # Max 100k Zeichen + content_hash=content_hash, + category=seed_info.get("category", "legal"), + doc_type="schulgesetz", + state=seed_info.get("state"), + law_name=law_name, + paragraphs=paragraphs, + trust_score=seed_info.get("trust_boost", 0.9), + ) + + async def _process_pdf( + self, + response: httpx.Response, + url: str, + seed_info: Dict + ) -> Optional[CrawledDocument]: + """Verarbeitet PDF-Inhalte (Placeholder - benötigt PDF-Library).""" + # TODO: PDF-Extraktion mit PyPDF2 oder pdfplumber + logger.info(f"PDF erkannt: {url} - PDF-Extraktion noch nicht implementiert") + return None + + def _extract_paragraphs( + self, + soup: BeautifulSoup, + content: str + ) -> Optional[List[Dict]]: + """ + Extrahiert Paragraphen aus Gesetzestexten. + + Sucht nach Mustern wie: + - § 42 Titel + - Paragraph 42 + """ + paragraphs = [] + + # Pattern für Paragraphen + paragraph_pattern = r"(§\s*\d+[a-z]?)\s*([^\n§]+)" + matches = re.findall(paragraph_pattern, content, re.MULTILINE) + + for nr, title in matches[:50]: # Max 50 Paragraphen + paragraphs.append({ + "nr": nr.strip(), + "title": title.strip()[:200], + }) + + return paragraphs if paragraphs else None + + async def crawl_legal_seeds(self, db_pool) -> Dict: + """ + Crawlt alle Seeds der Kategorie 'legal'. + + Returns: + Statistik über gecrawlte Dokumente + """ + stats = { + "total": 0, + "success": 0, + "failed": 0, + "skipped": 0, + } + + # Seeds aus DB laden + async with db_pool.acquire() as conn: + seeds = await conn.fetch(""" + SELECT s.id, s.url, s.name, s.state, s.trust_boost, + c.name as category + FROM edu_search_seeds s + LEFT JOIN edu_search_categories c ON s.category_id = c.id + WHERE c.name = 'legal' AND s.enabled = true + """) + + stats["total"] = len(seeds) + logger.info(f"Crawle {len(seeds)} Legal-Seeds...") + + for seed in seeds: + # Rate Limiting + await asyncio.sleep(self.rate_limit_delay) + + seed_info = { + "name": seed["name"], + "state": seed["state"], + "trust_boost": seed["trust_boost"], + "category": seed["category"], + } + + doc = await self.crawl_url(seed["url"], seed_info) + + if doc: + # In DB speichern + try: + await conn.execute(""" + INSERT INTO edu_search_documents + (url, canonical_url, title, content, content_hash, + category, doc_type, state, law_name, paragraphs, + trust_score, seed_id, last_crawled_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11, $12, NOW()) + ON CONFLICT (url) DO UPDATE SET + title = EXCLUDED.title, + content = EXCLUDED.content, + content_hash = EXCLUDED.content_hash, + paragraphs = EXCLUDED.paragraphs, + last_crawled_at = NOW(), + content_updated_at = CASE + WHEN edu_search_documents.content_hash != EXCLUDED.content_hash + THEN NOW() + ELSE edu_search_documents.content_updated_at + END + """, + doc.url, doc.canonical_url, doc.title, doc.content, + doc.content_hash, doc.category, doc.doc_type, doc.state, + doc.law_name, + str(doc.paragraphs) if doc.paragraphs else None, + doc.trust_score, seed["id"] + ) + stats["success"] += 1 + logger.info(f"✓ Gecrawlt: {doc.title[:50]}...") + except Exception as e: + logger.error(f"DB-Fehler für {doc.url}: {e}") + stats["failed"] += 1 + else: + stats["failed"] += 1 + + # Seed-Status aktualisieren + await conn.execute(""" + UPDATE edu_search_seeds + SET last_crawled_at = NOW(), + last_crawl_status = $1 + WHERE id = $2 + """, "success" if doc else "failed", seed["id"]) + + logger.info(f"Crawl abgeschlossen: {stats}") + return stats + + +# Singleton-Instanz +_crawler_instance: Optional[LegalCrawler] = None + + +def get_legal_crawler() -> LegalCrawler: + """Gibt die Singleton-Instanz des Legal Crawlers zurück.""" + global _crawler_instance + if _crawler_instance is None: + _crawler_instance = LegalCrawler() + return _crawler_instance diff --git a/backend/llm_gateway/services/pii_detector.py b/backend/llm_gateway/services/pii_detector.py new file mode 100644 index 0000000..f179045 --- /dev/null +++ b/backend/llm_gateway/services/pii_detector.py @@ -0,0 +1,249 @@ +""" +PII Detector Service. + +Erkennt und redaktiert personenbezogene Daten (PII) in Texten +bevor sie an externe Services wie Tavily gesendet werden. +""" + +import re +from dataclasses import dataclass, field +from typing import Optional +from enum import Enum + + +class PIIType(Enum): + """Typen von PII.""" + EMAIL = "email" + PHONE = "phone" + IBAN = "iban" + CREDIT_CARD = "credit_card" + SSN = "ssn" # Sozialversicherungsnummer + NAME = "name" + ADDRESS = "address" + DATE_OF_BIRTH = "date_of_birth" + IP_ADDRESS = "ip_address" + + +@dataclass +class PIIMatch: + """Ein gefundenes PII-Element.""" + type: PIIType + value: str + start: int + end: int + replacement: str + + +@dataclass +class RedactionResult: + """Ergebnis der PII-Redaktion.""" + original_text: str + redacted_text: str + matches: list[PIIMatch] = field(default_factory=list) + pii_found: bool = False + + +class PIIDetector: + """ + Service zur Erkennung und Redaktion von PII. + + Verwendet Regex-Pattern für deutsche und internationale Formate. + """ + + # Regex Patterns für verschiedene PII-Typen + PATTERNS = { + PIIType.EMAIL: r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', + + # Deutsche Telefonnummern (verschiedene Formate) + PIIType.PHONE: r'(?:\+49|0049|0)[\s\-/]?(?:\d{2,5})[\s\-/]?(?:\d{3,8})[\s\-/]?(?:\d{0,5})', + + # IBAN (deutsch und international) + PIIType.IBAN: r'\b[A-Z]{2}\d{2}[\s]?(?:\d{4}[\s]?){4,7}\d{0,2}\b', + + # Kreditkarten (Visa, Mastercard, Amex) + PIIType.CREDIT_CARD: r'\b(?:4\d{3}|5[1-5]\d{2}|3[47]\d{2})[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}\b', + + # Deutsche Sozialversicherungsnummer + PIIType.SSN: r'\b\d{2}[\s]?\d{6}[\s]?[A-Z][\s]?\d{3}\b', + + # IP-Adressen (IPv4) + PIIType.IP_ADDRESS: r'\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b', + + # Geburtsdatum (deutsche Formate) + PIIType.DATE_OF_BIRTH: r'\b(?:0?[1-9]|[12]\d|3[01])\.(?:0?[1-9]|1[0-2])\.(?:19|20)\d{2}\b', + } + + # Ersetzungstexte + REPLACEMENTS = { + PIIType.EMAIL: "[EMAIL_REDACTED]", + PIIType.PHONE: "[PHONE_REDACTED]", + PIIType.IBAN: "[IBAN_REDACTED]", + PIIType.CREDIT_CARD: "[CARD_REDACTED]", + PIIType.SSN: "[SSN_REDACTED]", + PIIType.NAME: "[NAME_REDACTED]", + PIIType.ADDRESS: "[ADDRESS_REDACTED]", + PIIType.DATE_OF_BIRTH: "[DOB_REDACTED]", + PIIType.IP_ADDRESS: "[IP_REDACTED]", + } + + # Priorität für überlappende Matches (höher = wird bevorzugt) + PRIORITY = { + PIIType.EMAIL: 100, + PIIType.IBAN: 90, + PIIType.CREDIT_CARD: 85, + PIIType.SSN: 80, + PIIType.IP_ADDRESS: 70, + PIIType.DATE_OF_BIRTH: 60, + PIIType.PHONE: 50, # Niedrigere Priorität wegen False Positives + PIIType.NAME: 40, + PIIType.ADDRESS: 30, + } + + def __init__(self, enabled_types: Optional[list[PIIType]] = None): + """ + Initialisiert den PII Detector. + + Args: + enabled_types: Liste der zu erkennenden PII-Typen. + None = alle Typen aktiviert. + Leere Liste = keine Erkennung. + """ + if enabled_types is not None: + self.enabled_types = enabled_types + else: + self.enabled_types = list(PIIType) + + self._compiled_patterns = { + pii_type: re.compile(pattern, re.IGNORECASE) + for pii_type, pattern in self.PATTERNS.items() + if pii_type in self.enabled_types + } + + def detect(self, text: str) -> list[PIIMatch]: + """ + Erkennt PII in einem Text. + + Bei überlappenden Matches wird der Match mit höherer Priorität + bevorzugt (z.B. IBAN über Telefon). + + Args: + text: Der zu analysierende Text. + + Returns: + Liste der gefundenen PII-Matches. + """ + all_matches = [] + + for pii_type, pattern in self._compiled_patterns.items(): + for match in pattern.finditer(text): + all_matches.append(PIIMatch( + type=pii_type, + value=match.group(), + start=match.start(), + end=match.end(), + replacement=self.REPLACEMENTS[pii_type], + )) + + # Überlappende Matches filtern (höhere Priorität gewinnt) + matches = self._filter_overlapping(all_matches) + + # Nach Position sortieren (für korrekte Redaktion) + matches.sort(key=lambda m: m.start) + return matches + + def _filter_overlapping(self, matches: list[PIIMatch]) -> list[PIIMatch]: + """ + Filtert überlappende Matches, bevorzugt höhere Priorität. + + Args: + matches: Alle gefundenen Matches. + + Returns: + Gefilterte Liste ohne Überlappungen. + """ + if not matches: + return [] + + # Nach Priorität sortieren (höchste zuerst) + sorted_matches = sorted( + matches, + key=lambda m: self.PRIORITY.get(m.type, 0), + reverse=True, + ) + + result = [] + used_ranges: list[tuple[int, int]] = [] + + for match in sorted_matches: + # Prüfen ob dieser Match mit einem bereits akzeptierten überlappt + overlaps = False + for start, end in used_ranges: + # Überlappung wenn: match.start < end AND match.end > start + if match.start < end and match.end > start: + overlaps = True + break + + if not overlaps: + result.append(match) + used_ranges.append((match.start, match.end)) + + return result + + def redact(self, text: str) -> RedactionResult: + """ + Erkennt und redaktiert PII in einem Text. + + Args: + text: Der zu redaktierende Text. + + Returns: + RedactionResult mit originalem und redaktiertem Text. + """ + matches = self.detect(text) + + if not matches: + return RedactionResult( + original_text=text, + redacted_text=text, + matches=[], + pii_found=False, + ) + + # Von hinten nach vorne ersetzen (um Indizes zu erhalten) + redacted = text + for match in reversed(matches): + redacted = redacted[:match.start] + match.replacement + redacted[match.end:] + + return RedactionResult( + original_text=text, + redacted_text=redacted, + matches=matches, + pii_found=True, + ) + + def contains_pii(self, text: str) -> bool: + """ + Prüft schnell, ob Text PII enthält. + + Args: + text: Der zu prüfende Text. + + Returns: + True wenn PII gefunden wurde. + """ + for pattern in self._compiled_patterns.values(): + if pattern.search(text): + return True + return False + + +# Singleton Instance +_pii_detector: Optional[PIIDetector] = None + + +def get_pii_detector() -> PIIDetector: + """Gibt Singleton-Instanz des PII Detectors zurück.""" + global _pii_detector + if _pii_detector is None: + _pii_detector = PIIDetector() + return _pii_detector diff --git a/backend/llm_gateway/services/playbook_service.py b/backend/llm_gateway/services/playbook_service.py new file mode 100644 index 0000000..8fed8e6 --- /dev/null +++ b/backend/llm_gateway/services/playbook_service.py @@ -0,0 +1,322 @@ +""" +Playbook Service - Verwaltung von System Prompts. + +Playbooks sind versionierte System-Prompt-Vorlagen für spezifische Schulkontexte. +""" + +import logging +from typing import Optional +from dataclasses import dataclass, field +from datetime import datetime + +logger = logging.getLogger(__name__) + + +@dataclass +class Playbook: + """Ein Playbook mit System Prompt.""" + id: str + name: str + description: str + system_prompt: str + prompt_version: str + recommended_models: list[str] = field(default_factory=list) + tool_policy: dict = field(default_factory=dict) + status: str = "published" # draft, review, approved, published + created_at: datetime = field(default_factory=datetime.now) + updated_at: datetime = field(default_factory=datetime.now) + + +# Initiale Playbooks (später aus DB laden) +DEFAULT_PLAYBOOKS: dict[str, Playbook] = { + "pb_default": Playbook( + id="pb_default", + name="Standard-Assistent", + description="Allgemeiner Assistent für Lehrkräfte", + system_prompt="""Du bist ein hilfreicher Assistent für Lehrkräfte an deutschen Schulen. + +Richtlinien: +- Antworte präzise und verständlich +- Berücksichtige den deutschen Schulkontext +- Beachte datenschutzrechtliche Aspekte (DSGVO) +- Verwende geschlechtergerechte Sprache +- Gib bei rechtlichen Fragen den Hinweis, dass du keine Rechtsberatung ersetzen kannst""", + prompt_version="1.0.0", + recommended_models=["breakpilot-teacher-8b", "breakpilot-teacher-70b"], + tool_policy={"allow_web_search": True, "no_pii_in_output": True}, + ), + "pb_elternbrief": Playbook( + id="pb_elternbrief", + name="Elternbrief", + description="Professionelle Elternkommunikation verfassen", + system_prompt="""Du bist ein erfahrener Schulassistent, der Lehrkräften hilft, professionelle Elternbriefe zu verfassen. + +Richtlinien für Elternbriefe: +- Höflicher, respektvoller Ton +- Klare, verständliche Sprache (kein Fachjargon) +- Strukturierte Gliederung mit Datum, Betreff, Anrede +- Wichtige Informationen hervorheben +- Handlungsaufforderungen klar formulieren +- Kontaktmöglichkeiten angeben +- Keine personenbezogenen Daten einzelner Schüler*innen nennen +- DSGVO-konform formulieren + +Format: +- Briefkopf mit Schule, Datum +- Betreff-Zeile +- Anrede "Sehr geehrte Eltern und Erziehungsberechtigte," +- Haupttext in Absätzen +- Grußformel +- Unterschrift mit Name und Funktion""", + prompt_version="1.1.0", + recommended_models=["breakpilot-teacher-8b"], + tool_policy={"allow_web_search": False, "no_pii_in_output": True}, + ), + "pb_arbeitsblatt": Playbook( + id="pb_arbeitsblatt", + name="Arbeitsblatt erstellen", + description="Arbeitsblätter für verschiedene Klassenstufen und Fächer", + system_prompt="""Du bist ein erfahrener Didaktiker, der Lehrkräften bei der Erstellung von Arbeitsblättern hilft. + +Bei der Erstellung von Arbeitsblättern beachte: +- Klassenstufe und Lernstand berücksichtigen +- Klare, verständliche Aufgabenstellungen +- Differenzierungsmöglichkeiten anbieten (leicht/mittel/schwer) +- Platz für Antworten einplanen +- Visualisierungen wo sinnvoll vorschlagen +- Bezug zum Lehrplan herstellen +- Zeitaufwand realistisch einschätzen + +Format für Arbeitsblätter: +- Titel und Thema +- Klassenstufe/Fach +- Lernziele (für Lehrkraft) +- Aufgaben mit Nummerierung +- Platzhalter für Antworten [___] +- Optionale Zusatzaufgaben +- Lösungshinweise (optional, für Lehrkraft)""", + prompt_version="1.2.0", + recommended_models=["breakpilot-teacher-8b", "breakpilot-teacher-70b"], + tool_policy={"allow_web_search": True, "no_pii_in_output": True}, + ), + "pb_foerderplan": Playbook( + id="pb_foerderplan", + name="Förderplan", + description="Individuelle Förderpläne erstellen", + system_prompt="""Du bist ein erfahrener Sonderpädagoge/Förderschullehrer, der bei der Erstellung von Förderplänen unterstützt. + +WICHTIG: Förderpläne enthalten sensible Daten. Erstelle nur Vorlagen und Strukturen, keine echten Schülerdaten. + +Struktur eines Förderplans: +1. Ausgangslage + - Stärken des Kindes + - Entwicklungsbereiche + - Bisherige Fördermaßnahmen + +2. Förderziele (SMART formuliert) + - Spezifisch, Messbar, Attraktiv, Realistisch, Terminiert + - Kurzfristige Ziele (4-6 Wochen) + - Mittelfristige Ziele (Halbjahr) + +3. Maßnahmen + - Konkrete Fördermaßnahmen + - Methoden und Materialien + - Verantwortlichkeiten + +4. Evaluation + - Beobachtungskriterien + - Dokumentation + - Anpassungszeitpunkte + +Rechtliche Hinweise: +- Förderpläne sind vertrauliche Dokumente +- Eltern haben Einsichtsrecht +- Regelmäßige Fortschreibung erforderlich""", + prompt_version="1.0.0", + recommended_models=["breakpilot-teacher-70b"], + tool_policy={"allow_web_search": False, "no_pii_in_output": True}, + ), + "pb_rechtlich": Playbook( + id="pb_rechtlich", + name="Rechtliche Fragen", + description="Schulrechtliche und datenschutzrechtliche Fragen", + system_prompt="""Du bist ein Experte für Schulrecht und Datenschutz im Bildungsbereich. + +WICHTIGER HINWEIS: Du gibst allgemeine Informationen, keine Rechtsberatung. Bei konkreten Rechtsfragen sollte immer ein Fachanwalt oder die Schulbehörde konsultiert werden. + +Themengebiete: +- DSGVO im Schulkontext +- Schulgesetze der Bundesländer +- Aufsichtspflicht +- Urheberrecht im Unterricht +- Elternrechte und -pflichten +- Dokumentationspflichten +- Datenschutz bei digitalen Medien + +Bei Antworten: +- Auf Bundesland-spezifische Regelungen hinweisen +- Rechtsquellen nennen (z.B. SchulG, DSGVO-Artikel) +- Auf Aktualität der Informationen hinweisen +- Immer empfehlen, aktuelle Regelungen zu prüfen +- Bei Unsicherheit an zuständige Stellen verweisen""", + prompt_version="1.0.0", + recommended_models=["breakpilot-teacher-70b", "claude-3-5-sonnet"], + tool_policy={"allow_web_search": True, "no_pii_in_output": True}, + ), + "pb_kommunikation": Playbook( + id="pb_kommunikation", + name="Elternkommunikation", + description="Kommunikation mit Eltern in verschiedenen Situationen", + system_prompt="""Du bist ein erfahrener Schulberater, der bei der Kommunikation mit Eltern unterstützt. + +Kommunikationssituationen: +- Elterngespräche vorbereiten +- Schwierige Gespräche führen +- Konflikte deeskalieren +- Positive Rückmeldungen formulieren +- Unterstützung einfordern + +Kommunikationsgrundsätze: +- Wertschätzender, respektvoller Ton +- Sachlich bleiben, auch bei Emotionen +- Ich-Botschaften verwenden +- Konkrete Beobachtungen statt Bewertungen +- Gemeinsame Lösungen suchen +- Ressourcen und Stärken betonen +- Vertraulichkeit wahren + +Struktur für Elterngespräche: +1. Begrüßung und Gesprächsrahmen +2. Positiver Einstieg +3. Beobachtungen mitteilen +4. Perspektive der Eltern hören +5. Gemeinsame Ziele definieren +6. Konkrete Vereinbarungen treffen +7. Positiver Abschluss""", + prompt_version="1.0.0", + recommended_models=["breakpilot-teacher-8b", "breakpilot-teacher-70b"], + tool_policy={"allow_web_search": False, "no_pii_in_output": True}, + ), + "mail_analysis": Playbook( + id="mail_analysis", + name="E-Mail-Analyse", + description="Analyse eingehender E-Mails für Schulleiter/innen", + system_prompt="""Du bist ein intelligenter Assistent für Schulleitungen in Niedersachsen. + +Deine Aufgabe ist die Analyse eingehender E-Mails: + +1. ABSENDER-KLASSIFIKATION: + Erkenne den Absender-Typ: + - kultusministerium: Kultusministerium (MK) + - landesschulbehoerde: Landesschulbehörde (NLSchB) + - rlsb: Regionales Landesamt für Schule und Bildung + - schulamt: Schulamt + - nibis: Niedersächsischer Bildungsserver + - schultraeger: Schulträger/Kommune + - elternvertreter: Elternvertreter/Elternrat + - gewerkschaft: GEW, VBE, etc. + - fortbildungsinstitut: NLQ, etc. + - privatperson: Privatperson + - unternehmen: Firma + - unbekannt: Nicht einzuordnen + +2. FRISTEN-ERKENNUNG: + Extrahiere alle genannten Fristen und Termine: + - Datum im Format YYYY-MM-DD + - Beschreibung der Frist + - Verbindlichkeit (ja/nein) + +3. KATEGORISIERUNG: + Ordne die E-Mail einer Kategorie zu: + - dienstlich: Offizielle Dienstangelegenheiten + - personal: Personalangelegenheiten + - finanzen: Haushalts-/Finanzthemen + - eltern: Elternkommunikation + - schueler: Schülerangelegenheiten + - fortbildung: Fortbildungen + - veranstaltung: Termine/Events + - sicherheit: Sicherheit/Hygiene + - technik: IT/Digitales + - newsletter: Informationen + - sonstiges: Andere + +4. PRIORITÄT: + Schlage eine Priorität vor: + - urgent: Sofort bearbeiten + - high: Zeitnah bearbeiten + - medium: Normale Bearbeitung + - low: Kann warten + +Antworte präzise im geforderten Format. Keine langen Erklärungen. +Beachte deutsche Datums- und Behördenformate.""", + prompt_version="1.0.0", + recommended_models=["breakpilot-teacher-8b", "llama-3.1-8b-instruct"], + tool_policy={"allow_web_search": False, "no_pii_in_output": True}, + ), +} + + +class PlaybookService: + """Service für Playbook-Verwaltung.""" + + def __init__(self): + # In-Memory Storage (später DB) + self._playbooks = DEFAULT_PLAYBOOKS.copy() + + def list_playbooks(self, status: Optional[str] = "published") -> list[Playbook]: + """Listet alle Playbooks mit optionalem Status-Filter.""" + playbooks = list(self._playbooks.values()) + if status: + playbooks = [p for p in playbooks if p.status == status] + return playbooks + + def get_playbook(self, playbook_id: str) -> Optional[Playbook]: + """Holt ein Playbook by ID.""" + return self._playbooks.get(playbook_id) + + def get_system_prompt(self, playbook_id: str) -> Optional[str]: + """Holt nur den System Prompt eines Playbooks.""" + playbook = self.get_playbook(playbook_id) + return playbook.system_prompt if playbook else None + + def create_playbook(self, playbook: Playbook) -> Playbook: + """Erstellt ein neues Playbook.""" + if playbook.id in self._playbooks: + raise ValueError(f"Playbook with id {playbook.id} already exists") + self._playbooks[playbook.id] = playbook + logger.info(f"Created playbook: {playbook.id}") + return playbook + + def update_playbook(self, playbook_id: str, **updates) -> Optional[Playbook]: + """Aktualisiert ein Playbook.""" + playbook = self._playbooks.get(playbook_id) + if not playbook: + return None + + for key, value in updates.items(): + if hasattr(playbook, key): + setattr(playbook, key, value) + + playbook.updated_at = datetime.now() + logger.info(f"Updated playbook: {playbook_id}") + return playbook + + def delete_playbook(self, playbook_id: str) -> bool: + """Löscht ein Playbook.""" + if playbook_id in self._playbooks: + del self._playbooks[playbook_id] + logger.info(f"Deleted playbook: {playbook_id}") + return True + return False + + +# Singleton +_playbook_service: Optional[PlaybookService] = None + + +def get_playbook_service() -> PlaybookService: + """Gibt den Playbook Service Singleton zurück.""" + global _playbook_service + if _playbook_service is None: + _playbook_service = PlaybookService() + return _playbook_service diff --git a/backend/llm_gateway/services/tool_gateway.py b/backend/llm_gateway/services/tool_gateway.py new file mode 100644 index 0000000..31586a7 --- /dev/null +++ b/backend/llm_gateway/services/tool_gateway.py @@ -0,0 +1,285 @@ +""" +Tool Gateway Service. + +Bietet sichere Schnittstelle zu externen Tools wie Tavily Web Search. +Alle Anfragen werden vor dem Versand auf PII geprüft und redaktiert. +""" + +import os +import httpx +import logging +from dataclasses import dataclass, field +from typing import Optional, Any +from enum import Enum + +from .pii_detector import PIIDetector, get_pii_detector, RedactionResult + + +logger = logging.getLogger(__name__) + + +class SearchDepth(str, Enum): + """Suchtiefe für Tavily.""" + BASIC = "basic" + ADVANCED = "advanced" + + +@dataclass +class SearchResult: + """Ein einzelnes Suchergebnis.""" + title: str + url: str + content: str + score: float = 0.0 + published_date: Optional[str] = None + + +@dataclass +class SearchResponse: + """Antwort einer Suche.""" + query: str + redacted_query: Optional[str] = None + results: list[SearchResult] = field(default_factory=list) + answer: Optional[str] = None + pii_detected: bool = False + pii_types: list[str] = field(default_factory=list) + response_time_ms: int = 0 + + +@dataclass +class ToolGatewayConfig: + """Konfiguration für den Tool Gateway.""" + tavily_api_key: Optional[str] = None + tavily_base_url: str = "https://api.tavily.com" + timeout: int = 30 + max_results: int = 5 + search_depth: SearchDepth = SearchDepth.BASIC + include_answer: bool = True + include_images: bool = False + pii_redaction_enabled: bool = True + + @classmethod + def from_env(cls) -> "ToolGatewayConfig": + """Erstellt Config aus Umgebungsvariablen.""" + return cls( + tavily_api_key=os.getenv("TAVILY_API_KEY"), + tavily_base_url=os.getenv("TAVILY_BASE_URL", "https://api.tavily.com"), + timeout=int(os.getenv("TAVILY_TIMEOUT", "30")), + max_results=int(os.getenv("TAVILY_MAX_RESULTS", "5")), + search_depth=SearchDepth(os.getenv("TAVILY_SEARCH_DEPTH", "basic")), + include_answer=os.getenv("TAVILY_INCLUDE_ANSWER", "true").lower() == "true", + include_images=os.getenv("TAVILY_INCLUDE_IMAGES", "false").lower() == "true", + pii_redaction_enabled=os.getenv("PII_REDACTION_ENABLED", "true").lower() == "true", + ) + + +class ToolGatewayError(Exception): + """Fehler im Tool Gateway.""" + pass + + +class TavilyError(ToolGatewayError): + """Fehler bei Tavily API.""" + pass + + +class ToolGateway: + """ + Gateway für externe Tools mit PII-Schutz. + + Alle Anfragen werden vor dem Versand auf personenbezogene Daten + geprüft und diese redaktiert. Dies gewährleistet DSGVO-Konformität. + """ + + def __init__( + self, + config: Optional[ToolGatewayConfig] = None, + pii_detector: Optional[PIIDetector] = None, + ): + """ + Initialisiert den Tool Gateway. + + Args: + config: Konfiguration. None = aus Umgebungsvariablen. + pii_detector: PII Detector. None = Standard-Detector. + """ + self.config = config or ToolGatewayConfig.from_env() + self.pii_detector = pii_detector or get_pii_detector() + self._client: Optional[httpx.AsyncClient] = None + + @property + def tavily_available(self) -> bool: + """Prüft ob Tavily konfiguriert ist.""" + return bool(self.config.tavily_api_key) + + async def _get_client(self) -> httpx.AsyncClient: + """Lazy-init HTTP Client.""" + if self._client is None: + self._client = httpx.AsyncClient( + timeout=self.config.timeout, + headers={"Content-Type": "application/json"}, + ) + return self._client + + async def close(self): + """Schließt HTTP Client.""" + if self._client: + await self._client.aclose() + self._client = None + + def _redact_query(self, query: str) -> RedactionResult: + """ + Redaktiert PII aus einer Suchanfrage. + + Args: + query: Die originale Suchanfrage. + + Returns: + RedactionResult mit redaktiertem Text. + """ + if not self.config.pii_redaction_enabled: + return RedactionResult( + original_text=query, + redacted_text=query, + matches=[], + pii_found=False, + ) + return self.pii_detector.redact(query) + + async def search( + self, + query: str, + search_depth: Optional[SearchDepth] = None, + max_results: Optional[int] = None, + include_domains: Optional[list[str]] = None, + exclude_domains: Optional[list[str]] = None, + ) -> SearchResponse: + """ + Führt eine Web-Suche mit Tavily durch. + + PII wird automatisch aus der Anfrage entfernt bevor sie + an Tavily gesendet wird. + + Args: + query: Die Suchanfrage. + search_depth: Suchtiefe (basic/advanced). + max_results: Maximale Anzahl Ergebnisse. + include_domains: Nur diese Domains durchsuchen. + exclude_domains: Diese Domains ausschließen. + + Returns: + SearchResponse mit Ergebnissen. + + Raises: + TavilyError: Bei API-Fehlern. + ToolGatewayError: Bei Konfigurationsfehlern. + """ + import time + start_time = time.time() + + if not self.tavily_available: + raise ToolGatewayError("Tavily API key not configured") + + # PII redaktieren + redaction = self._redact_query(query) + + if redaction.pii_found: + logger.warning( + f"PII detected in search query. Types: {[m.type.value for m in redaction.matches]}" + ) + + # Request an Tavily + client = await self._get_client() + + payload: dict[str, Any] = { + "api_key": self.config.tavily_api_key, + "query": redaction.redacted_text, + "search_depth": (search_depth or self.config.search_depth).value, + "max_results": max_results or self.config.max_results, + "include_answer": self.config.include_answer, + "include_images": self.config.include_images, + } + + if include_domains: + payload["include_domains"] = include_domains + if exclude_domains: + payload["exclude_domains"] = exclude_domains + + try: + response = await client.post( + f"{self.config.tavily_base_url}/search", + json=payload, + ) + response.raise_for_status() + data = response.json() + + except httpx.HTTPStatusError as e: + logger.error(f"Tavily API error: {e.response.status_code} - {e.response.text}") + raise TavilyError(f"Tavily API error: {e.response.status_code}") + except httpx.RequestError as e: + logger.error(f"Tavily request error: {e}") + raise TavilyError(f"Failed to connect to Tavily: {e}") + + # Response parsen + results = [ + SearchResult( + title=r.get("title", ""), + url=r.get("url", ""), + content=r.get("content", ""), + score=r.get("score", 0.0), + published_date=r.get("published_date"), + ) + for r in data.get("results", []) + ] + + elapsed_ms = int((time.time() - start_time) * 1000) + + return SearchResponse( + query=query, + redacted_query=redaction.redacted_text if redaction.pii_found else None, + results=results, + answer=data.get("answer"), + pii_detected=redaction.pii_found, + pii_types=[m.type.value for m in redaction.matches], + response_time_ms=elapsed_ms, + ) + + async def health_check(self) -> dict[str, Any]: + """ + Prüft Verfügbarkeit der Tools. + + Returns: + Dict mit Status der einzelnen Tools. + """ + status = { + "tavily": { + "configured": self.tavily_available, + "healthy": False, + }, + "pii_redaction": { + "enabled": self.config.pii_redaction_enabled, + }, + } + + # Tavily Health Check (einfache Suche) + if self.tavily_available: + try: + result = await self.search("test", max_results=1) + status["tavily"]["healthy"] = True + status["tavily"]["response_time_ms"] = result.response_time_ms + except Exception as e: + status["tavily"]["error"] = str(e) + + return status + + +# Singleton Instance +_tool_gateway: Optional[ToolGateway] = None + + +def get_tool_gateway() -> ToolGateway: + """Gibt Singleton-Instanz des Tool Gateways zurück.""" + global _tool_gateway + if _tool_gateway is None: + _tool_gateway = ToolGateway() + return _tool_gateway diff --git a/backend/llm_test_api.py b/backend/llm_test_api.py new file mode 100644 index 0000000..472c117 --- /dev/null +++ b/backend/llm_test_api.py @@ -0,0 +1,455 @@ +""" +LLM Compare Test API - Test Runner fuer LLM Provider Vergleich +Endpoint: /api/admin/llm-tests +""" + +from fastapi import APIRouter +from pydantic import BaseModel +from typing import List, Optional, Literal +import httpx +import asyncio +import time +import os + +router = APIRouter(prefix="/api/admin/llm-tests", tags=["LLM Tests"]) + +# ============================================== +# Models +# ============================================== + +class TestResult(BaseModel): + name: str + description: str + expected: str + actual: str + status: Literal["passed", "failed", "pending", "skipped"] + duration_ms: float + error_message: Optional[str] = None + + +class TestCategoryResult(BaseModel): + category: str + display_name: str + description: str + tests: List[TestResult] + passed: int + failed: int + total: int + + +class FullTestResults(BaseModel): + categories: List[TestCategoryResult] + total_passed: int + total_failed: int + total_tests: int + duration_ms: float + + +# ============================================== +# Configuration +# ============================================== + +BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000") +LLM_GATEWAY_ENABLED = os.getenv("LLM_GATEWAY_ENABLED", "false").lower() == "true" +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "") +ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "") + + +# ============================================== +# Test Implementations +# ============================================== + +async def test_llm_gateway_health() -> TestResult: + """Test LLM Gateway Health Endpoint""" + start = time.time() + + if not LLM_GATEWAY_ENABLED: + return TestResult( + name="LLM Gateway Health", + description="Prueft ob das LLM Gateway aktiviert und erreichbar ist", + expected="LLM Gateway aktiv", + actual="LLM_GATEWAY_ENABLED=false", + status="skipped", + duration_ms=(time.time() - start) * 1000, + error_message="LLM Gateway nicht aktiviert" + ) + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(f"{BACKEND_URL}/llm/health") + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + data = response.json() + providers = data.get("providers", []) + return TestResult( + name="LLM Gateway Health", + description="Prueft ob das LLM Gateway aktiviert und erreichbar ist", + expected="LLM Gateway aktiv", + actual=f"Aktiv, {len(providers)} Provider", + status="passed", + duration_ms=duration + ) + else: + return TestResult( + name="LLM Gateway Health", + description="Prueft ob das LLM Gateway aktiviert und erreichbar ist", + expected="LLM Gateway aktiv", + actual=f"HTTP {response.status_code}", + status="failed", + duration_ms=duration, + error_message="Gateway nicht erreichbar" + ) + except Exception as e: + return TestResult( + name="LLM Gateway Health", + description="Prueft ob das LLM Gateway aktiviert und erreichbar ist", + expected="LLM Gateway aktiv", + actual=f"Fehler: {str(e)}", + status="failed", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +async def test_openai_api() -> TestResult: + """Test OpenAI API Connection""" + start = time.time() + + if not OPENAI_API_KEY: + return TestResult( + name="OpenAI API Verbindung", + description="Prueft ob die OpenAI API konfiguriert und erreichbar ist", + expected="API Key konfiguriert", + actual="OPENAI_API_KEY nicht gesetzt", + status="skipped", + duration_ms=(time.time() - start) * 1000, + error_message="API Key fehlt" + ) + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get( + "https://api.openai.com/v1/models", + headers={"Authorization": f"Bearer {OPENAI_API_KEY}"} + ) + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + data = response.json() + models = [m["id"] for m in data.get("data", [])[:5]] + return TestResult( + name="OpenAI API Verbindung", + description="Prueft ob die OpenAI API konfiguriert und erreichbar ist", + expected="API erreichbar mit Modellen", + actual=f"Verfuegbar: {', '.join(models[:3])}...", + status="passed", + duration_ms=duration + ) + else: + return TestResult( + name="OpenAI API Verbindung", + description="Prueft ob die OpenAI API konfiguriert und erreichbar ist", + expected="API erreichbar mit Modellen", + actual=f"HTTP {response.status_code}", + status="failed", + duration_ms=duration, + error_message="API-Authentifizierung fehlgeschlagen" + ) + except Exception as e: + return TestResult( + name="OpenAI API Verbindung", + description="Prueft ob die OpenAI API konfiguriert und erreichbar ist", + expected="API erreichbar mit Modellen", + actual=f"Fehler: {str(e)}", + status="failed", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +async def test_anthropic_api() -> TestResult: + """Test Anthropic API Connection""" + start = time.time() + + if not ANTHROPIC_API_KEY: + return TestResult( + name="Anthropic API Verbindung", + description="Prueft ob die Anthropic (Claude) API konfiguriert ist", + expected="API Key konfiguriert", + actual="ANTHROPIC_API_KEY nicht gesetzt", + status="skipped", + duration_ms=(time.time() - start) * 1000, + error_message="API Key fehlt" + ) + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + # Anthropic doesn't have a models endpoint, so we do a minimal completion + response = await client.post( + "https://api.anthropic.com/v1/messages", + headers={ + "x-api-key": ANTHROPIC_API_KEY, + "anthropic-version": "2023-06-01", + "content-type": "application/json" + }, + json={ + "model": "claude-3-haiku-20240307", + "max_tokens": 10, + "messages": [{"role": "user", "content": "Hi"}] + } + ) + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + return TestResult( + name="Anthropic API Verbindung", + description="Prueft ob die Anthropic (Claude) API konfiguriert ist", + expected="API erreichbar", + actual="Claude API verfuegbar", + status="passed", + duration_ms=duration + ) + elif response.status_code == 401: + return TestResult( + name="Anthropic API Verbindung", + description="Prueft ob die Anthropic (Claude) API konfiguriert ist", + expected="API erreichbar", + actual="Ungueltige Credentials", + status="failed", + duration_ms=duration, + error_message="API Key ungueltig" + ) + else: + return TestResult( + name="Anthropic API Verbindung", + description="Prueft ob die Anthropic (Claude) API konfiguriert ist", + expected="API erreichbar", + actual=f"HTTP {response.status_code}", + status="failed", + duration_ms=duration, + error_message=f"Unerwarteter Status: {response.status_code}" + ) + except Exception as e: + return TestResult( + name="Anthropic API Verbindung", + description="Prueft ob die Anthropic (Claude) API konfiguriert ist", + expected="API erreichbar", + actual=f"Fehler: {str(e)}", + status="failed", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +async def test_local_llm() -> TestResult: + """Test Local LLM (Ollama) Connection""" + start = time.time() + ollama_url = os.getenv("OLLAMA_URL", "http://localhost:11434") + + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get(f"{ollama_url}/api/tags") + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + data = response.json() + models = [m["name"] for m in data.get("models", [])] + if models: + return TestResult( + name="Lokales LLM (Ollama)", + description="Prueft ob ein lokales LLM via Ollama verfuegbar ist", + expected="Ollama mit Modellen", + actual=f"Modelle: {', '.join(models[:3])}", + status="passed", + duration_ms=duration + ) + else: + return TestResult( + name="Lokales LLM (Ollama)", + description="Prueft ob ein lokales LLM via Ollama verfuegbar ist", + expected="Ollama mit Modellen", + actual="Ollama aktiv, keine Modelle", + status="passed", + duration_ms=duration + ) + else: + return TestResult( + name="Lokales LLM (Ollama)", + description="Prueft ob ein lokales LLM via Ollama verfuegbar ist", + expected="Ollama mit Modellen", + actual=f"HTTP {response.status_code}", + status="skipped", + duration_ms=duration, + error_message="Ollama nicht erreichbar" + ) + except Exception as e: + return TestResult( + name="Lokales LLM (Ollama)", + description="Prueft ob ein lokales LLM via Ollama verfuegbar ist", + expected="Ollama mit Modellen", + actual="Nicht verfuegbar", + status="skipped", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +async def test_playbooks_api() -> TestResult: + """Test LLM Playbooks API""" + start = time.time() + + if not LLM_GATEWAY_ENABLED: + return TestResult( + name="LLM Playbooks API", + description="Prueft ob die Playbooks-Verwaltung fuer vordefinierte Prompts verfuegbar ist", + expected="Playbooks API verfuegbar", + actual="LLM Gateway deaktiviert", + status="skipped", + duration_ms=(time.time() - start) * 1000, + error_message="LLM_GATEWAY_ENABLED=false" + ) + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(f"{BACKEND_URL}/llm/playbooks") + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + data = response.json() + count = len(data) if isinstance(data, list) else data.get("total", 0) + return TestResult( + name="LLM Playbooks API", + description="Prueft ob die Playbooks-Verwaltung fuer vordefinierte Prompts verfuegbar ist", + expected="Playbooks API verfuegbar", + actual=f"{count} Playbooks verfuegbar", + status="passed", + duration_ms=duration + ) + else: + return TestResult( + name="LLM Playbooks API", + description="Prueft ob die Playbooks-Verwaltung fuer vordefinierte Prompts verfuegbar ist", + expected="Playbooks API verfuegbar", + actual=f"HTTP {response.status_code}", + status="failed", + duration_ms=duration, + error_message="API nicht erreichbar" + ) + except Exception as e: + return TestResult( + name="LLM Playbooks API", + description="Prueft ob die Playbooks-Verwaltung fuer vordefinierte Prompts verfuegbar ist", + expected="Playbooks API verfuegbar", + actual=f"Fehler: {str(e)}", + status="failed", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +# ============================================== +# Category Runners +# ============================================== + +async def run_gateway_tests() -> TestCategoryResult: + """Run LLM Gateway tests""" + tests = await asyncio.gather( + test_llm_gateway_health(), + test_playbooks_api(), + ) + + passed = sum(1 for t in tests if t.status == "passed") + failed = sum(1 for t in tests if t.status == "failed") + + return TestCategoryResult( + category="gateway", + display_name="LLM Gateway", + description="Tests fuer das zentrale LLM Gateway", + tests=list(tests), + passed=passed, + failed=failed, + total=len(tests) + ) + + +async def run_provider_tests() -> TestCategoryResult: + """Run LLM Provider tests""" + tests = await asyncio.gather( + test_openai_api(), + test_anthropic_api(), + test_local_llm(), + ) + + passed = sum(1 for t in tests if t.status == "passed") + failed = sum(1 for t in tests if t.status == "failed") + + return TestCategoryResult( + category="providers", + display_name="LLM Provider", + description="Tests fuer externe und lokale LLM Provider", + tests=list(tests), + passed=passed, + failed=failed, + total=len(tests) + ) + + +# ============================================== +# API Endpoints +# ============================================== + +@router.post("/{category}", response_model=TestCategoryResult) +async def run_category_tests(category: str): + """Run tests for a specific category""" + runners = { + "gateway": run_gateway_tests, + "providers": run_provider_tests, + } + + if category not in runners: + return TestCategoryResult( + category=category, + display_name=f"Unbekannt: {category}", + description="Kategorie nicht gefunden", + tests=[], + passed=0, + failed=0, + total=0 + ) + + return await runners[category]() + + +@router.post("/run-all", response_model=FullTestResults) +async def run_all_tests(): + """Run all LLM tests""" + start = time.time() + + categories = await asyncio.gather( + run_gateway_tests(), + run_provider_tests(), + ) + + total_passed = sum(c.passed for c in categories) + total_failed = sum(c.failed for c in categories) + total_tests = sum(c.total for c in categories) + + return FullTestResults( + categories=list(categories), + total_passed=total_passed, + total_failed=total_failed, + total_tests=total_tests, + duration_ms=(time.time() - start) * 1000 + ) + + +@router.get("/categories") +async def get_categories(): + """Get available test categories""" + return { + "categories": [ + {"id": "gateway", "name": "LLM Gateway", "description": "Gateway Health & Playbooks"}, + {"id": "providers", "name": "LLM Provider", "description": "OpenAI, Anthropic, Ollama"}, + ] + } diff --git a/backend/mac_mini_api.py b/backend/mac_mini_api.py new file mode 100644 index 0000000..1836c3c --- /dev/null +++ b/backend/mac_mini_api.py @@ -0,0 +1,574 @@ +""" +Mac Mini Remote Control API. + +Provides endpoints for: +- Power control (shutdown, restart, wake-on-LAN) +- Status monitoring (ping, SSH, services) +- Docker container management +- Ollama model management + +This API can run in two modes: +1. Remote mode: Running on MacBook, controlling Mac Mini via SSH +2. Local mode: Running on Mac Mini (in Docker), using direct commands +""" + +import asyncio +import subprocess +import os +import httpx +from typing import Optional, List, Dict, Any +from fastapi import APIRouter, HTTPException, BackgroundTasks +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +import json +import socket + +router = APIRouter(prefix="/api/mac-mini", tags=["Mac Mini Control"]) + +# Configuration +MAC_MINI_IP = os.getenv("MAC_MINI_IP", "192.168.178.100") +MAC_MINI_USER = os.getenv("MAC_MINI_USER", "benjaminadmin") +MAC_MINI_MAC = os.getenv("MAC_MINI_MAC", "") # MAC address for Wake-on-LAN +PROJECT_PATH = "/Users/benjaminadmin/Projekte/breakpilot-pwa" + +# Detect if running inside Docker (local mode on Mac Mini) +# In Docker, we use host.docker.internal to access host services +RUNNING_IN_DOCKER = os.path.exists("/.dockerenv") +DOCKER_HOST_IP = "host.docker.internal" if RUNNING_IN_DOCKER else MAC_MINI_IP +OLLAMA_HOST = f"http://{DOCKER_HOST_IP}:11434" if RUNNING_IN_DOCKER else f"http://{MAC_MINI_IP}:11434" + + +class ModelPullRequest(BaseModel): + model: str + + +class CommandResponse(BaseModel): + success: bool + message: str + output: Optional[str] = None + + +async def run_ssh_command(command: str, timeout: int = 30) -> tuple[bool, str]: + """Run a command via SSH on Mac Mini.""" + ssh_cmd = f"ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no {MAC_MINI_USER}@{MAC_MINI_IP} \"{command}\"" + try: + process = await asyncio.create_subprocess_shell( + ssh_cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await asyncio.wait_for( + process.communicate(), + timeout=timeout + ) + output = stdout.decode() + stderr.decode() + return process.returncode == 0, output.strip() + except asyncio.TimeoutError: + return False, "Command timed out" + except Exception as e: + return False, str(e) + + +async def check_ping() -> bool: + """Check if Mac Mini responds to ping.""" + try: + process = await asyncio.create_subprocess_shell( + f"ping -c 1 -W 2 {MAC_MINI_IP}", + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL + ) + await process.wait() + return process.returncode == 0 + except: + return False + + +async def check_ssh() -> bool: + """Check if SSH is available.""" + success, _ = await run_ssh_command("echo ok", timeout=10) + return success + + +async def check_service_http(url: str, timeout: int = 5) -> bool: + """Check if an HTTP service is responding.""" + try: + async with httpx.AsyncClient(timeout=timeout) as client: + response = await client.get(url) + return response.status_code == 200 + except: + return False + + +async def check_internet() -> bool: + """Check if Mac Mini has internet access by pinging external servers.""" + # Try multiple methods for reliability + + # Method 1: HTTP check to a reliable endpoint + try: + async with httpx.AsyncClient(timeout=5) as client: + response = await client.get("https://www.google.com/generate_204") + if response.status_code == 204: + return True + except: + pass + + # Method 2: DNS resolution check + try: + socket.getaddrinfo("google.com", 80, socket.AF_INET) + return True + except: + pass + + # Method 3: Ping to 8.8.8.8 (Google DNS) + try: + process = await asyncio.create_subprocess_shell( + "ping -c 1 -W 2 8.8.8.8", + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL + ) + await asyncio.wait_for(process.wait(), timeout=5) + if process.returncode == 0: + return True + except: + pass + + return False + + +async def get_local_system_info(): + """Get system info when running locally (in Docker on Mac Mini).""" + uptime = None + cpu_load = None + memory = None + + # These won't work inside Docker, but we can try basic checks + # The host system info requires the Docker socket or host access + + return uptime, cpu_load, memory + + +async def get_docker_containers_local(): + """Get Docker container status when running inside Docker via Docker socket.""" + containers = [] + + docker_socket = "/var/run/docker.sock" + if not os.path.exists(docker_socket): + return False, containers + + try: + # Use Python to query Docker API via Unix socket (no curl needed) + import urllib.request + import urllib.error + + class UnixSocketHandler(urllib.request.AbstractHTTPHandler): + def unix_open(self, req): + import http.client + import socket as sock + + class UnixHTTPConnection(http.client.HTTPConnection): + def __init__(self, socket_path): + super().__init__("localhost") + self.socket_path = socket_path + + def connect(self): + self.sock = sock.socket(sock.AF_UNIX, sock.SOCK_STREAM) + self.sock.connect(self.socket_path) + + conn = UnixHTTPConnection(docker_socket) + conn.request(req.get_method(), req.selector) + return conn.getresponse() + + # Query Docker API + opener = urllib.request.build_opener(UnixSocketHandler()) + req = urllib.request.Request("unix:///containers/json?all=true") + response = opener.open(req, timeout=5) + data = json.loads(response.read().decode()) + + for c in data: + name = c.get("Names", ["/unknown"])[0].lstrip("/") + state = c.get("State", "unknown") + status = c.get("Status", state) + containers.append({"name": name, "status": status}) + return True, containers + except Exception as e: + # Fallback: try using httpx with unix socket support + try: + import httpx + transport = httpx.HTTPTransport(uds=docker_socket) + async with httpx.AsyncClient(transport=transport, timeout=5) as client: + response = await client.get("http://localhost/containers/json?all=true") + if response.status_code == 200: + data = response.json() + for c in data: + name = c.get("Names", ["/unknown"])[0].lstrip("/") + state = c.get("State", "unknown") + status = c.get("Status", state) + containers.append({"name": name, "status": status}) + return True, containers + except: + pass + + return False, containers + + +@router.get("/status") +async def get_status(): + """Get comprehensive Mac Mini status.""" + + # When running inside Docker on Mac Mini, we're always "online" + if RUNNING_IN_DOCKER: + # Local mode - running on Mac Mini itself + # Check services in parallel + ollama_task = asyncio.create_task(check_service_http(f"{OLLAMA_HOST}/api/tags")) + internet_task = asyncio.create_task(check_internet()) + docker_task = asyncio.create_task(get_docker_containers_local()) + + ollama_ok = await ollama_task + internet_ok = await internet_task + docker_ok, containers = await docker_task + + # Get Ollama models + models = [] + if ollama_ok: + try: + async with httpx.AsyncClient(timeout=10) as client: + response = await client.get(f"{OLLAMA_HOST}/api/tags") + if response.status_code == 200: + data = response.json() + models = data.get("models", []) + except: + pass + + return { + "online": True, + "ip": MAC_MINI_IP, + "ping": True, + "ssh": True, # We're running locally, SSH not needed + "docker": docker_ok, + "backend": True, # We're the backend + "ollama": ollama_ok, + "internet": internet_ok, # Neuer Status: Internet-Zugang + "uptime": "Lokal", # Can't get this from inside Docker easily + "cpu_load": None, + "memory": None, + "containers": containers, + "models": models + } + + # Remote mode - running on MacBook, checking Mac Mini via SSH + # Start all checks in parallel + ping_task = asyncio.create_task(check_ping()) + + # Wait for ping first + ping_ok = await ping_task + + if not ping_ok: + return { + "online": False, + "ip": MAC_MINI_IP, + "ping": False, + "ssh": False, + "docker": False, + "backend": False, + "ollama": False, + "internet": False, + "uptime": None, + "cpu_load": None, + "memory": None, + "containers": [], + "models": [] + } + + # If ping succeeds, check other services + ssh_task = asyncio.create_task(check_ssh()) + backend_task = asyncio.create_task(check_service_http(f"http://{MAC_MINI_IP}:8000/health")) + ollama_task = asyncio.create_task(check_service_http(f"http://{MAC_MINI_IP}:11434/api/tags")) + + ssh_ok = await ssh_task + backend_ok = await backend_task + ollama_ok = await ollama_task + + # Check internet via SSH on Mac Mini + internet_ok = False + + # Get system info if SSH is available + uptime = None + cpu_load = None + memory = None + docker_ok = False + containers = [] + models = [] + + if ssh_ok: + # Get uptime + success, output = await run_ssh_command("uptime | awk -F'up ' '{print $2}' | awk -F',' '{print $1}'") + if success: + uptime = output.strip() + + # Get CPU load + success, output = await run_ssh_command("sysctl -n vm.loadavg | awk '{print $2}'") + if success: + cpu_load = output.strip() + + # Get memory usage + success, output = await run_ssh_command("vm_stat | awk '/Pages active/ {active=$3} /Pages wired/ {wired=$3} END {print int((active+wired)*4096/1024/1024/1024*10)/10 \" GB\"}'") + if success: + memory = output.strip() + + # Check Docker and get containers + success, output = await run_ssh_command("/usr/local/bin/docker ps --format '{{.Names}}|{{.Status}}'") + if success: + docker_ok = True + for line in output.strip().split('\n'): + if '|' in line: + name, status = line.split('|', 1) + containers.append({"name": name, "status": status}) + + # Check internet access on Mac Mini + success, _ = await run_ssh_command("ping -c 1 -W 2 8.8.8.8", timeout=10) + internet_ok = success + + # Get Ollama models if available + if ollama_ok: + try: + async with httpx.AsyncClient(timeout=10) as client: + response = await client.get(f"http://{MAC_MINI_IP}:11434/api/tags") + if response.status_code == 200: + data = response.json() + models = data.get("models", []) + except: + pass + + return { + "online": ping_ok and ssh_ok, + "ip": MAC_MINI_IP, + "ping": ping_ok, + "ssh": ssh_ok, + "docker": docker_ok, + "backend": backend_ok, + "ollama": ollama_ok, + "internet": internet_ok, + "uptime": uptime, + "cpu_load": cpu_load, + "memory": memory, + "containers": containers, + "models": models + } + + +@router.post("/wake") +async def wake_on_lan(): + """Send Wake-on-LAN magic packet to Mac Mini.""" + if not MAC_MINI_MAC: + # Try to get MAC address from ARP cache + try: + process = await asyncio.create_subprocess_shell( + f"arp -n {MAC_MINI_IP} | awk '{{print $3}}'", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, _ = await process.communicate() + mac = stdout.decode().strip() + if not mac or mac == "(incomplete)": + return CommandResponse( + success=False, + message="MAC-Adresse nicht gefunden. Bitte MAC_MINI_MAC setzen.", + output="Wake-on-LAN benötigt die MAC-Adresse des Mac Mini" + ) + except: + return CommandResponse( + success=False, + message="Fehler beim Ermitteln der MAC-Adresse" + ) + else: + mac = MAC_MINI_MAC + + # Send WOL packet using wakeonlan if available, otherwise use Python + try: + # Try wakeonlan command first + process = await asyncio.create_subprocess_shell( + f"wakeonlan {mac}", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + + if process.returncode == 0: + return CommandResponse( + success=True, + message=f"Wake-on-LAN Paket an {mac} gesendet. Der Mac Mini sollte in 30-60 Sekunden starten.", + output=stdout.decode() + ) + else: + # Fall back to Python WOL + import socket + import struct + + # Create magic packet + mac_bytes = bytes.fromhex(mac.replace(':', '').replace('-', '')) + magic = b'\xff' * 6 + mac_bytes * 16 + + # Send to broadcast + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + sock.sendto(magic, ('255.255.255.255', 9)) + sock.close() + + return CommandResponse( + success=True, + message=f"Wake-on-LAN Paket an {mac} gesendet (Python fallback).", + output="Magic packet sent via broadcast" + ) + except Exception as e: + return CommandResponse( + success=False, + message=f"Fehler beim Senden des WOL-Pakets: {str(e)}" + ) + + +@router.post("/restart") +async def restart_mac_mini(): + """Restart Mac Mini via SSH.""" + # Use osascript for graceful restart + success, output = await run_ssh_command( + "osascript -e 'tell application \"System Events\" to restart'", + timeout=15 + ) + + if success: + return CommandResponse( + success=True, + message="Neustart wurde ausgelöst. Der Mac Mini startet in wenigen Sekunden neu.", + output=output + ) + else: + # Try sudo reboot as fallback + success, output = await run_ssh_command("sudo -n reboot", timeout=15) + if success: + return CommandResponse( + success=True, + message="Neustart wurde ausgelöst (sudo).", + output=output + ) + return CommandResponse( + success=False, + message="Neustart fehlgeschlagen. SSH-Verbindung oder Berechtigung fehlt.", + output=output + ) + + +@router.post("/shutdown") +async def shutdown_mac_mini(): + """Shutdown Mac Mini via SSH.""" + # Use osascript for graceful shutdown + success, output = await run_ssh_command( + "osascript -e 'tell application \"System Events\" to shut down'", + timeout=15 + ) + + if success: + return CommandResponse( + success=True, + message="Shutdown wurde ausgelöst. Der Mac Mini fährt in wenigen Sekunden herunter.", + output=output + ) + else: + return CommandResponse( + success=False, + message="Shutdown fehlgeschlagen. SSH-Verbindung oder Berechtigung fehlt.", + output=output + ) + + +@router.post("/docker/up") +async def docker_up(): + """Start Docker containers on Mac Mini.""" + success, output = await run_ssh_command( + f"cd {PROJECT_PATH} && /usr/local/bin/docker compose up -d", + timeout=120 + ) + + return CommandResponse( + success=success, + message="Container werden gestartet..." if success else "Fehler beim Starten der Container", + output=output + ) + + +@router.post("/docker/down") +async def docker_down(): + """Stop Docker containers on Mac Mini.""" + success, output = await run_ssh_command( + f"cd {PROJECT_PATH} && /usr/local/bin/docker compose down", + timeout=60 + ) + + return CommandResponse( + success=success, + message="Container werden gestoppt..." if success else "Fehler beim Stoppen der Container", + output=output + ) + + +@router.post("/ollama/pull") +async def pull_ollama_model(request: ModelPullRequest): + """Pull an Ollama model with streaming progress.""" + model_name = request.model + + async def stream_progress(): + """Stream the pull progress as JSON lines.""" + try: + async with httpx.AsyncClient(timeout=None) as client: + async with client.stream( + "POST", + f"{OLLAMA_HOST}/api/pull", + json={"name": model_name, "stream": True} + ) as response: + async for line in response.aiter_lines(): + if line: + yield line + "\n" + except Exception as e: + yield json.dumps({"status": f"error: {str(e)}"}) + "\n" + + return StreamingResponse( + stream_progress(), + media_type="application/x-ndjson" + ) + + +@router.get("/ollama/models") +async def get_ollama_models(): + """Get list of installed Ollama models.""" + try: + async with httpx.AsyncClient(timeout=10) as client: + response = await client.get(f"{OLLAMA_HOST}/api/tags") + if response.status_code == 200: + return response.json() + return {"models": []} + except: + return {"models": []} + + +@router.delete("/ollama/models/{model_name}") +async def delete_ollama_model(model_name: str): + """Delete an Ollama model.""" + try: + async with httpx.AsyncClient(timeout=30) as client: + response = await client.delete( + f"{OLLAMA_HOST}/api/delete", + json={"name": model_name} + ) + if response.status_code == 200: + return CommandResponse( + success=True, + message=f"Modell '{model_name}' wurde gelöscht" + ) + return CommandResponse( + success=False, + message=f"Fehler beim Löschen: {response.text}" + ) + except Exception as e: + return CommandResponse( + success=False, + message=f"Fehler: {str(e)}" + ) diff --git a/backend/mail_test_api.py b/backend/mail_test_api.py new file mode 100644 index 0000000..e7f178e --- /dev/null +++ b/backend/mail_test_api.py @@ -0,0 +1,542 @@ +""" +Mail Test API - Test Runner fuer E-Mail Integration (IMAP/SMTP/KI-Analyse) +Endpoint: /api/admin/mail-tests +""" + +from fastapi import APIRouter +from pydantic import BaseModel +from typing import List, Optional, Literal +import httpx +import asyncio +import time +import os +import socket + +router = APIRouter(prefix="/api/admin/mail-tests", tags=["Mail Tests"]) + +# ============================================== +# Models +# ============================================== + +class TestResult(BaseModel): + name: str + description: str + expected: str + actual: str + status: Literal["passed", "failed", "pending", "skipped"] + duration_ms: float + error_message: Optional[str] = None + + +class TestCategoryResult(BaseModel): + category: str + display_name: str + description: str + tests: List[TestResult] + passed: int + failed: int + total: int + + +class FullTestResults(BaseModel): + categories: List[TestCategoryResult] + total_passed: int + total_failed: int + total_tests: int + duration_ms: float + + +# ============================================== +# Configuration +# ============================================== + +MAILPIT_URL = os.getenv("MAILPIT_URL", "http://mailpit:8025") +SMTP_HOST = os.getenv("SMTP_HOST", "mailpit") +SMTP_PORT = int(os.getenv("SMTP_PORT", "1025")) +IMAP_HOST = os.getenv("IMAP_HOST", "mailpit") +IMAP_PORT = int(os.getenv("IMAP_PORT", "1143")) +BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000") + + +# ============================================== +# Test Implementations +# ============================================== + +async def test_smtp_connection() -> TestResult: + """Test SMTP Server Connection""" + start = time.time() + try: + # Try to connect to SMTP port + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5) + result = sock.connect_ex((SMTP_HOST, SMTP_PORT)) + sock.close() + duration = (time.time() - start) * 1000 + + if result == 0: + return TestResult( + name="SMTP Server Verbindung", + description="Prueft ob der SMTP Server fuer ausgehende E-Mails erreichbar ist", + expected=f"Verbindung zu {SMTP_HOST}:{SMTP_PORT}", + actual=f"SMTP Server erreichbar", + status="passed", + duration_ms=duration + ) + else: + return TestResult( + name="SMTP Server Verbindung", + description="Prueft ob der SMTP Server fuer ausgehende E-Mails erreichbar ist", + expected=f"Verbindung zu {SMTP_HOST}:{SMTP_PORT}", + actual=f"Verbindung fehlgeschlagen (Code: {result})", + status="failed", + duration_ms=duration, + error_message=f"Socket-Fehler: {result}" + ) + except Exception as e: + return TestResult( + name="SMTP Server Verbindung", + description="Prueft ob der SMTP Server fuer ausgehende E-Mails erreichbar ist", + expected=f"Verbindung zu {SMTP_HOST}:{SMTP_PORT}", + actual=f"Fehler: {str(e)}", + status="failed", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +async def test_imap_connection() -> TestResult: + """Test IMAP Server Connection""" + start = time.time() + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(5) + result = sock.connect_ex((IMAP_HOST, IMAP_PORT)) + sock.close() + duration = (time.time() - start) * 1000 + + if result == 0: + return TestResult( + name="IMAP Server Verbindung", + description="Prueft ob der IMAP Server fuer eingehende E-Mails erreichbar ist", + expected=f"Verbindung zu {IMAP_HOST}:{IMAP_PORT}", + actual=f"IMAP Server erreichbar", + status="passed", + duration_ms=duration + ) + else: + return TestResult( + name="IMAP Server Verbindung", + description="Prueft ob der IMAP Server fuer eingehende E-Mails erreichbar ist", + expected=f"Verbindung zu {IMAP_HOST}:{IMAP_PORT}", + actual=f"Verbindung fehlgeschlagen", + status="skipped", + duration_ms=duration, + error_message=f"IMAP nicht konfiguriert oder nicht erreichbar" + ) + except Exception as e: + return TestResult( + name="IMAP Server Verbindung", + description="Prueft ob der IMAP Server fuer eingehende E-Mails erreichbar ist", + expected=f"Verbindung zu {IMAP_HOST}:{IMAP_PORT}", + actual=f"Nicht verfuegbar", + status="skipped", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +async def test_mailpit_api() -> TestResult: + """Test Mailpit Web API (Development Mail Catcher)""" + start = time.time() + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(f"{MAILPIT_URL}/api/v1/info") + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + data = response.json() + version = data.get("Version", "unknown") + return TestResult( + name="Mailpit Web API", + description="Prueft ob die Mailpit Test-Oberflaeche verfuegbar ist", + expected="HTTP 200 mit Version-Info", + actual=f"Mailpit v{version} aktiv", + status="passed", + duration_ms=duration + ) + else: + return TestResult( + name="Mailpit Web API", + description="Prueft ob die Mailpit Test-Oberflaeche verfuegbar ist", + expected="HTTP 200 mit Version-Info", + actual=f"HTTP {response.status_code}", + status="failed", + duration_ms=duration, + error_message=f"Unerwarteter Status: {response.status_code}" + ) + except Exception as e: + return TestResult( + name="Mailpit Web API", + description="Prueft ob die Mailpit Test-Oberflaeche verfuegbar ist", + expected="HTTP 200 mit Version-Info", + actual=f"Fehler: {str(e)}", + status="failed", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +async def test_mailpit_messages() -> TestResult: + """Test Mailpit Message Storage""" + start = time.time() + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(f"{MAILPIT_URL}/api/v1/messages") + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + data = response.json() + total = data.get("total", 0) + return TestResult( + name="Mailpit Message Storage", + description="Prueft ob E-Mails im Entwicklungsmodus abgefangen werden", + expected="Nachrichtenliste abrufbar", + actual=f"{total} Nachrichten gespeichert", + status="passed", + duration_ms=duration + ) + else: + return TestResult( + name="Mailpit Message Storage", + description="Prueft ob E-Mails im Entwicklungsmodus abgefangen werden", + expected="Nachrichtenliste abrufbar", + actual=f"HTTP {response.status_code}", + status="failed", + duration_ms=duration, + error_message=f"API-Fehler: {response.status_code}" + ) + except Exception as e: + return TestResult( + name="Mailpit Message Storage", + description="Prueft ob E-Mails im Entwicklungsmodus abgefangen werden", + expected="Nachrichtenliste abrufbar", + actual=f"Fehler: {str(e)}", + status="failed", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +async def test_email_templates_api() -> TestResult: + """Test Email Templates API""" + start = time.time() + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(f"{BACKEND_URL}/api/email-templates") + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + data = response.json() + count = len(data) if isinstance(data, list) else data.get("total", 0) + return TestResult( + name="E-Mail Templates API", + description="Prueft ob die E-Mail-Vorlagen-Verwaltung verfuegbar ist", + expected="Template-Liste abrufbar", + actual=f"{count} Templates verfuegbar", + status="passed", + duration_ms=duration + ) + else: + return TestResult( + name="E-Mail Templates API", + description="Prueft ob die E-Mail-Vorlagen-Verwaltung verfuegbar ist", + expected="Template-Liste abrufbar", + actual=f"HTTP {response.status_code}", + status="failed", + duration_ms=duration, + error_message=f"API nicht verfuegbar" + ) + except Exception as e: + return TestResult( + name="E-Mail Templates API", + description="Prueft ob die E-Mail-Vorlagen-Verwaltung verfuegbar ist", + expected="Template-Liste abrufbar", + actual=f"Fehler: {str(e)}", + status="failed", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +async def test_email_settings_api() -> TestResult: + """Test Email Settings API""" + start = time.time() + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(f"{BACKEND_URL}/api/email-templates/settings") + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + data = response.json() + sender = data.get("sender_email", "nicht konfiguriert") + return TestResult( + name="E-Mail Einstellungen", + description="Prueft ob die globalen E-Mail-Einstellungen (Absender, Logo) verfuegbar sind", + expected="Einstellungen abrufbar", + actual=f"Absender: {sender}", + status="passed", + duration_ms=duration + ) + else: + return TestResult( + name="E-Mail Einstellungen", + description="Prueft ob die globalen E-Mail-Einstellungen (Absender, Logo) verfuegbar sind", + expected="Einstellungen abrufbar", + actual=f"HTTP {response.status_code}", + status="failed", + duration_ms=duration, + error_message=f"Einstellungen nicht verfuegbar" + ) + except Exception as e: + return TestResult( + name="E-Mail Einstellungen", + description="Prueft ob die globalen E-Mail-Einstellungen (Absender, Logo) verfuegbar sind", + expected="Einstellungen abrufbar", + actual=f"Fehler: {str(e)}", + status="failed", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +async def test_llm_analysis_endpoint() -> TestResult: + """Test LLM Mail Analysis Endpoint""" + start = time.time() + try: + async with httpx.AsyncClient(timeout=10.0) as client: + # Check if LLM gateway is enabled + response = await client.get(f"{BACKEND_URL}/llm/health") + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + return TestResult( + name="KI E-Mail Analyse", + description="Prueft ob die LLM-basierte E-Mail-Analyse verfuegbar ist", + expected="LLM Gateway aktiv", + actual="KI-Analyse bereit", + status="passed", + duration_ms=duration + ) + else: + return TestResult( + name="KI E-Mail Analyse", + description="Prueft ob die LLM-basierte E-Mail-Analyse verfuegbar ist", + expected="LLM Gateway aktiv", + actual="LLM Gateway nicht aktiviert", + status="skipped", + duration_ms=duration, + error_message="LLM_GATEWAY_ENABLED=false" + ) + except Exception as e: + return TestResult( + name="KI E-Mail Analyse", + description="Prueft ob die LLM-basierte E-Mail-Analyse verfuegbar ist", + expected="LLM Gateway aktiv", + actual="Nicht verfuegbar", + status="skipped", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +async def test_gfk_integration() -> TestResult: + """Test GFK (Gewaltfreie Kommunikation) Integration""" + start = time.time() + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(f"{BACKEND_URL}/v1/communication/health") + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + return TestResult( + name="GFK Integration", + description="Prueft ob die Gewaltfreie-Kommunikation Analyse fuer Elternbriefe aktiv ist", + expected="Communication Service verfuegbar", + actual="GFK-Analyse bereit", + status="passed", + duration_ms=duration + ) + else: + return TestResult( + name="GFK Integration", + description="Prueft ob die Gewaltfreie-Kommunikation Analyse fuer Elternbriefe aktiv ist", + expected="Communication Service verfuegbar", + actual=f"HTTP {response.status_code}", + status="skipped", + duration_ms=duration, + error_message="GFK-Service nicht konfiguriert" + ) + except Exception as e: + return TestResult( + name="GFK Integration", + description="Prueft ob die Gewaltfreie-Kommunikation Analyse fuer Elternbriefe aktiv ist", + expected="Communication Service verfuegbar", + actual="Nicht verfuegbar", + status="skipped", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +# ============================================== +# Category Runners +# ============================================== + +async def run_smtp_tests() -> TestCategoryResult: + """Run SMTP-related tests""" + tests = await asyncio.gather( + test_smtp_connection(), + test_mailpit_api(), + test_mailpit_messages(), + ) + + passed = sum(1 for t in tests if t.status == "passed") + failed = sum(1 for t in tests if t.status == "failed") + + return TestCategoryResult( + category="smtp", + display_name="SMTP / Ausgehende E-Mails", + description="Tests fuer den E-Mail-Versand", + tests=list(tests), + passed=passed, + failed=failed, + total=len(tests) + ) + + +async def run_imap_tests() -> TestCategoryResult: + """Run IMAP-related tests""" + tests = await asyncio.gather( + test_imap_connection(), + ) + + passed = sum(1 for t in tests if t.status == "passed") + failed = sum(1 for t in tests if t.status == "failed") + + return TestCategoryResult( + category="imap", + display_name="IMAP / Eingehende E-Mails", + description="Tests fuer den E-Mail-Empfang", + tests=list(tests), + passed=passed, + failed=failed, + total=len(tests) + ) + + +async def run_templates_tests() -> TestCategoryResult: + """Run Email Templates tests""" + tests = await asyncio.gather( + test_email_templates_api(), + test_email_settings_api(), + ) + + passed = sum(1 for t in tests if t.status == "passed") + failed = sum(1 for t in tests if t.status == "failed") + + return TestCategoryResult( + category="templates", + display_name="E-Mail Templates", + description="Tests fuer die E-Mail-Vorlagen-Verwaltung", + tests=list(tests), + passed=passed, + failed=failed, + total=len(tests) + ) + + +async def run_ai_tests() -> TestCategoryResult: + """Run AI Analysis tests""" + tests = await asyncio.gather( + test_llm_analysis_endpoint(), + test_gfk_integration(), + ) + + passed = sum(1 for t in tests if t.status == "passed") + failed = sum(1 for t in tests if t.status == "failed") + + return TestCategoryResult( + category="ai-analysis", + display_name="KI-Analyse", + description="Tests fuer die KI-basierte E-Mail-Analyse", + tests=list(tests), + passed=passed, + failed=failed, + total=len(tests) + ) + + +# ============================================== +# API Endpoints +# ============================================== + +@router.post("/{category}", response_model=TestCategoryResult) +async def run_category_tests(category: str): + """Run tests for a specific category""" + runners = { + "smtp": run_smtp_tests, + "imap": run_imap_tests, + "templates": run_templates_tests, + "ai-analysis": run_ai_tests, + } + + if category not in runners: + return TestCategoryResult( + category=category, + display_name=f"Unbekannt: {category}", + description="Kategorie nicht gefunden", + tests=[], + passed=0, + failed=0, + total=0 + ) + + return await runners[category]() + + +@router.post("/run-all", response_model=FullTestResults) +async def run_all_tests(): + """Run all mail tests""" + start = time.time() + + categories = await asyncio.gather( + run_smtp_tests(), + run_imap_tests(), + run_templates_tests(), + run_ai_tests(), + ) + + total_passed = sum(c.passed for c in categories) + total_failed = sum(c.failed for c in categories) + total_tests = sum(c.total for c in categories) + + return FullTestResults( + categories=list(categories), + total_passed=total_passed, + total_failed=total_failed, + total_tests=total_tests, + duration_ms=(time.time() - start) * 1000 + ) + + +@router.get("/categories") +async def get_categories(): + """Get available test categories""" + return { + "categories": [ + {"id": "smtp", "name": "SMTP", "description": "Ausgehende E-Mails"}, + {"id": "imap", "name": "IMAP", "description": "Eingehende E-Mails"}, + {"id": "templates", "name": "Templates", "description": "E-Mail-Vorlagen"}, + {"id": "ai-analysis", "name": "KI-Analyse", "description": "LLM & GFK"}, + ] + } diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..389f7f1 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,148 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from pathlib import Path + +from original_service import router as original_router +from learning_units_api import router as learning_units_router +from frontend.studio import router as studio_router +from frontend.preview import router as preview_router +from frontend.school import router as school_router +from frontend.meetings import router as meetings_router +from frontend.customer import router as customer_router +from frontend.dev_admin import router as dev_admin_router +from meetings_api import router as meetings_api_router +from recording_api import router as recording_api_router +from consent_api import router as consent_router +from consent_admin_api import router as consent_admin_router +from gdpr_api import router as gdpr_router, admin_router as gdpr_admin_router +from auth_api import router as auth_router +from notification_api import router as notification_router +from deadline_api import router as deadline_router +from email_template_api import router as email_template_router, versions_router as email_template_versions_router +from dsr_api import router as dsr_router +from dsr_admin_api import router as dsr_admin_router, templates_router as dsr_templates_router +from messenger_api import router as messenger_router +from jitsi_api import router as jitsi_router +from school_api import router as school_api_router +from letters_api import router as letters_router +from certificates_api import router as certificates_router +from worksheets_api import router as worksheets_router +from correction_api import router as correction_router +from state_engine_api import router as state_engine_router +from klausur_service_proxy import router as klausur_service_router +from abitur_docs_api import router as abitur_docs_router +from rbac_api import router as rbac_router +from security_api import router as security_router +from api.tests import router as tests_registry_router +from system_api import router as system_router +from classroom_api import router as classroom_router + +# LLM Gateway, Alerts Agent, und GPU Infra (optional, wenn konfiguriert) +import os +LLM_GATEWAY_ENABLED = os.getenv("LLM_GATEWAY_ENABLED", "false").lower() == "true" +ALERTS_AGENT_ENABLED = os.getenv("ALERTS_AGENT_ENABLED", "false").lower() == "true" +VAST_API_KEY = os.getenv("VAST_API_KEY") # vast.ai wird aktiviert wenn API Key gesetzt + +app = FastAPI(title="BreakPilot Backend") + +# Mount static files directory for CSS, JS, and other assets +static_dir = Path(__file__).parent / "frontend" / "static" +if static_dir.exists(): + app.mount("/static", StaticFiles(directory=str(static_dir)), name="static") + +# CORS-Konfiguration für Frontend-Zugriff +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # In Produktion spezifische Origins angeben + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Hier hängen wir die einzelnen Service-Router ein. +# Alle Routen bekommen das Präfix /api, damit das Frontend sie findet. +app.include_router(original_router, prefix="/api") +app.include_router(learning_units_router, prefix="/api") +app.include_router(consent_router, prefix="/api") +app.include_router(consent_admin_router, prefix="/api") +app.include_router(gdpr_router, prefix="/api") +app.include_router(gdpr_admin_router, prefix="/api") +app.include_router(auth_router, prefix="/api") +app.include_router(notification_router, prefix="/api") +app.include_router(deadline_router, prefix="/api") +app.include_router(email_template_router) # Hat bereits /api Präfix im Router +app.include_router(email_template_versions_router) # Hat bereits /api Präfix im Router +# DSR (Data Subject Requests / Betroffenenanfragen) +app.include_router(dsr_router, prefix="/api") +app.include_router(dsr_admin_router, prefix="/api") +app.include_router(dsr_templates_router, prefix="/api") +# Frontend-UI und Preview-Endpunkte (ohne /api Präfix) +app.include_router(studio_router) +app.include_router(preview_router) +app.include_router(school_router) # Schulverwaltung Frontend +app.include_router(meetings_router) # Meetings/Jitsi Frontend +app.include_router(meetings_api_router) # Meetings API (already has /api/meetings prefix) +app.include_router(recording_api_router) # Recording API (already has /api/recordings prefix) +app.include_router(customer_router) # Slim Customer Portal (/customer, /account, /mein-konto) +app.include_router(dev_admin_router) # Developer Admin Frontend (/dev-admin) +# Messenger API (Kontakte, Konversationen, Nachrichten) +app.include_router(messenger_router) # Hat bereits /api/messenger Präfix im Router +# Jitsi API (Meeting-Einladungen per Email) +app.include_router(jitsi_router) # Hat bereits /api/jitsi Präfix im Router +# School Service API Proxy (leitet an school-service:8084 weiter) +app.include_router(school_api_router, prefix="/api") +# Letters API (Elternbriefe mit PDF-Export und GFK-Integration) +app.include_router(letters_router, prefix="/api") +# Certificates API (Zeugnisse mit PDF-Export und Workflow) +app.include_router(certificates_router, prefix="/api") +# Worksheets API (Content-Generatoren: MC, Cloze, Mindmap, Quiz) +app.include_router(worksheets_router, prefix="/api") +# Correction API (OCR-basierte Klassenarbeits-Korrektur) +app.include_router(correction_router, prefix="/api") +# State Engine API (Begleiter-Modus mit Phasen und Antizipation) +app.include_router(state_engine_router, prefix="/api") +# Klausur-Service Proxy (leitet an klausur-service:8086 weiter) +app.include_router(klausur_service_router, prefix="/api") +# Abitur Docs API (NiBiS-Dokumente, RAG-Vorbereitung) +app.include_router(abitur_docs_router, prefix="/api") +# RBAC API (Lehrer- und Rollenverwaltung) +app.include_router(rbac_router, prefix="/api") +# Security API (DevSecOps Dashboard) +app.include_router(security_router, prefix="/api") +# Test Registry API (Test Dashboard mit Echtzeit-Fortschritt) +app.include_router(tests_registry_router) # Hat bereits /api/tests Praefix im Router +# System API (Health Check, Local IP fuer QR-Code Mobile Upload) +app.include_router(system_router) # Hat bereits Pfade im Router +# Classroom API (Unterrichts-Steuerung mit Phasen und Timer) +app.include_router(classroom_router, prefix="/api/classroom") + +# LLM Gateway Routes (optional) +if LLM_GATEWAY_ENABLED: + from llm_gateway.routes import chat_router, playbooks_router, health_router, tools_router + app.include_router(health_router, prefix="/llm", tags=["LLM Gateway"]) + app.include_router(chat_router, prefix="/llm/v1", tags=["LLM Gateway"]) + app.include_router(playbooks_router, prefix="/llm", tags=["LLM Gateway"]) + app.include_router(tools_router, prefix="/llm/tools", tags=["LLM Tools"]) + +# Alerts Agent Routes (optional) +if ALERTS_AGENT_ENABLED: + from alerts_agent.api import router as alerts_router + app.include_router(alerts_router, prefix="/api", tags=["Alerts Agent"]) + +# vast.ai GPU Infrastructure Routes (optional) +if VAST_API_KEY: + from infra.vast_power import router as vast_router + app.include_router(vast_router, tags=["GPU Infrastructure"]) + +# EduSearch Seeds API (immer aktiv - Admin-Verwaltung der Crawler-Seeds) +from llm_gateway.routes.edu_search_seeds import router as edu_search_seeds_router +app.include_router(edu_search_seeds_router, prefix="/v1", tags=["EduSearch"]) + +# Communication API (Lehrer-Eltern-Kommunikation mit GFK-Prinzipien) +from llm_gateway.routes.communication import router as communication_router +app.include_router(communication_router, prefix="/v1", tags=["Communication"]) + +# Legal Crawler API (Crawlt Schulgesetze und rechtliche Inhalte) +from llm_gateway.routes.legal_crawler import router as legal_crawler_router +app.include_router(legal_crawler_router, prefix="/v1", tags=["Legal Crawler"]) diff --git a/backend/main_backup.py b/backend/main_backup.py new file mode 100644 index 0000000..c85bf2a --- /dev/null +++ b/backend/main_backup.py @@ -0,0 +1,223 @@ +from pathlib import Path +from fastapi import FastAPI, UploadFile, File +import shutil + +from ai_processor import ( + dummy_process_scan, + describe_scan_with_ai, + analyze_scan_structure_with_ai, + build_clean_html_from_analysis, + remove_handwriting_from_scan, +) + +app = FastAPI() + +BASE_DIR = Path.home() / "Arbeitsblaetter" +EINGANG_DIR = BASE_DIR / "Eingang" +BEREINIGT_DIR = BASE_DIR / "Bereinigt" +EDITIERBAR_DIR = BASE_DIR / "Editierbar" +NEU_GENERIERT_DIR = BASE_DIR / "Neu_generiert" + +VALID_SUFFIXES = {".jpg", ".jpeg", ".png", ".pdf", ".JPG", ".JPEG", ".PNG", ".PDF"} + +for d in [EINGANG_DIR, BEREINIGT_DIR, EDITIERBAR_DIR, NEU_GENERIERT_DIR]: + d.mkdir(parents=True, exist_ok=True) + + +def is_valid_input_file(path: Path) -> bool: + return path.is_file() and not path.name.startswith(".") and path.suffix in VALID_SUFFIXES + + +@app.get("/") +def home(): + return { + "status": "OK", + "message": "Deine lokale App läuft!", + "base_dir": str(BASE_DIR), + } + + +@app.post("/upload-scan") +async def upload_scan(file: UploadFile = File(...)): + target_path = EINGANG_DIR / file.filename + with target_path.open("wb") as buffer: + shutil.copyfileobj(file.file, buffer) + return { + "status": "OK", + "message": "Scan gespeichert", + "saved_as": str(target_path), + } + + +@app.get("/eingang-dateien") +def list_eingang_files(): + files = [f.name for f in EINGANG_DIR.iterdir() if is_valid_input_file(f)] + return {"eingang": files} + + +@app.post("/process-all") +def process_all_scans(): + processed = [] + skipped = [] + for f in EINGANG_DIR.iterdir(): + if is_valid_input_file(f): + result_path = dummy_process_scan(f) + processed.append(result_path.name) + else: + skipped.append(f.name) + + return { + "status": "OK", + "message": "Dummy-Verarbeitung abgeschlossen", + "processed_files": processed, + "skipped": skipped, + } + + +@app.post("/describe-all") +def describe_all_scans(): + described = [] + errors = [] + skipped = [] + + for f in EINGANG_DIR.iterdir(): + if not is_valid_input_file(f): + skipped.append(f.name) + continue + try: + out_path = describe_scan_with_ai(f) + described.append(out_path.name) + except Exception as e: + errors.append({"file": f.name, "error": str(e)}) + + status = "OK" + message = "Beschreibungen erstellt" + if errors and not described: + status = "ERROR" + message = "Keine Beschreibungen erstellt, nur Fehler." + elif errors and described: + status = "PARTIAL" + message = "Einige Beschreibungen erstellt, aber auch Fehler." + + return { + "status": status, + "message": message, + "described": described, + "errors": errors, + "skipped": skipped, + } + + +@app.post("/analyze-all") +def analyze_all_scans(): + analyzed = [] + errors = [] + skipped = [] + + for f in EINGANG_DIR.iterdir(): + if not is_valid_input_file(f): + skipped.append(f.name) + continue + try: + out_path = analyze_scan_structure_with_ai(f) + analyzed.append(out_path.name) + except Exception as e: + errors.append({"file": f.name, "error": str(e)}) + + status = "OK" + message = "Analysen erstellt" + if errors and not analyzed: + status = "ERROR" + message = "Keine Analysen erstellt, nur Fehler." + elif errors and analyzed: + status = "PARTIAL" + message = "Einige Analysen erstellt, aber auch Fehler." + + return { + "status": status, + "message": message, + "analyzed": analyzed, + "errors": errors, + "skipped": skipped, + } + + +@app.post("/generate-clean") +def generate_clean_worksheets(): + # Nimmt alle *_analyse.json-Dateien und erzeugt *_clean.html-Arbeitsblätter. + generated = [] + errors = [] + for f in BEREINIGT_DIR.iterdir(): + if f.is_file() and f.suffix == ".json" and f.name.endswith("_analyse.json"): + try: + out_path = build_clean_html_from_analysis(f) + generated.append(out_path.name) + except Exception as e: + errors.append({"file": f.name, "error": str(e)}) + + status = "OK" + message = "Clean-HTML-Arbeitsblätter erzeugt" + if errors and not generated: + status = "ERROR" + message = "Keine HTML-Arbeitsblätter erzeugt, nur Fehler." + elif errors and generated: + status = "PARTIAL" + message = "Einige HTML-Arbeitsblätter erzeugt, aber auch Fehler." + + return { + "status": status, + "message": message, + "generated": generated, + "errors": errors, + } + + +@app.get("/bereinigt-dateien") +def list_bereinigt_files(): + files = [f.name for f in BEREINIGT_DIR.iterdir() if f.is_file()] + return {"bereinigt": files} + + +@app.post("/remove-handwriting-all") +def remove_handwriting_all(): + """ + Entfernt bei allen geeigneten Bilddateien im Eingang-Ordner möglichst die Handschrift + und legt bereinigte Bilder im Bereinigt-Ordner ab. + """ + cleaned = [] + errors = [] + skipped = [] + + for f in EINGANG_DIR.iterdir(): + if not is_valid_input_file(f): + skipped.append(f.name) + continue + + # PDFs etc. vorerst überspringen – Fokus auf JPG/PNG + if f.suffix.lower() not in {".jpg", ".jpeg", ".png"}: + skipped.append(f.name) + continue + + try: + out_path = remove_handwriting_from_scan(f) + cleaned.append(out_path.name) + except Exception as e: + errors.append({"file": f.name, "error": str(e)}) + + status = "OK" + message = "Bereinigte Bilder erzeugt." + if errors and not cleaned: + status = "ERROR" + message = "Keine bereinigten Bilder erzeugt, nur Fehler." + elif errors and cleaned: + status = "PARTIAL" + message = "Einige bereinigte Bilder erzeugt, aber auch Fehler." + + return { + "status": status, + "message": message, + "cleaned": cleaned, + "errors": errors, + "skipped": skipped, + } + diff --git a/backend/main_before_d.py b/backend/main_before_d.py new file mode 100644 index 0000000..8be97c5 --- /dev/null +++ b/backend/main_before_d.py @@ -0,0 +1,297 @@ +from pathlib import Path +from typing import List + +from fastapi import FastAPI, UploadFile, File +from fastapi.responses import HTMLResponse +import shutil + +from ai_processor import ( + dummy_process_scan, + describe_scan_with_ai, + analyze_scan_structure_with_ai, + build_clean_html_from_analysis, + remove_handwriting_from_scan, # B: Handschriftentfernung (muss in ai_processor.py existieren) +) + +app = FastAPI() + +BASE_DIR = Path.home() / "Arbeitsblaetter" +EINGANG_DIR = BASE_DIR / "Eingang" +BEREINIGT_DIR = BASE_DIR / "Bereinigt" +EDITIERBAR_DIR = BASE_DIR / "Editierbar" +NEU_GENERIERT_DIR = BASE_DIR / "Neu_generiert" + +VALID_SUFFIXES = {".jpg", ".jpeg", ".png", ".pdf", ".JPG", ".JPEG", ".PNG", ".PDF"} + +for d in [EINGANG_DIR, BEREINIGT_DIR, EDITIERBAR_DIR, NEU_GENERIERT_DIR]: + d.mkdir(parents=True, exist_ok=True) + + +def is_valid_input_file(path: Path) -> bool: + return path.is_file() and not path.name.startswith(".") and path.suffix in VALID_SUFFIXES + + +@app.get("/") +def home(): + return { + "status": "OK", + "message": "Deine lokale App läuft!", + "base_dir": str(BASE_DIR), + } + + +# --- Upload-Bereich --- + + +@app.post("/upload-scan") +async def upload_scan(file: UploadFile = File(...)): + """ + Einfache Variante: eine einzelne Datei hochladen. + """ + target_path = EINGANG_DIR / file.filename + with target_path.open("wb") as buffer: + shutil.copyfileobj(file.file, buffer) + return { + "status": "OK", + "message": "Scan gespeichert", + "saved_as": str(target_path), + } + + +@app.get("/upload-form", response_class=HTMLResponse) +def upload_form(): + """ + Einfache HTML-Seite für Upload mehrerer Dateien. + Kann z.B. vom Handy unter http://DEINE-IP:8000/upload-form aufgerufen werden. + """ + return """ + + + + + Arbeitsblätter hochladen + + + +

            Arbeitsblätter hochladen

            +
            +

            Wähle ein oder mehrere eingescannt Arbeitsblätter (JPG/PNG/PDF) aus und lade sie hoch.

            +
            + +
            + +
            +
            + + + """ + + +@app.post("/upload-multi") +async def upload_multi(files: List[UploadFile] = File(...)): + """ + Mehrere Dateien auf einmal hochladen. + Alle Dateien werden im Eingang-Ordner gespeichert. + """ + saved = [] + for file in files: + target_path = EINGANG_DIR / file.filename + with target_path.open("wb") as buffer: + shutil.copyfileobj(file.file, buffer) + saved.append(str(target_path)) + + return { + "status": "OK", + "message": "Dateien gespeichert", + "saved_as": saved, + } + + +# --- Dateiliste --- + + +@app.get("/eingang-dateien") +def list_eingang_files(): + files = [f.name for f in EINGANG_DIR.iterdir() if is_valid_input_file(f)] + return {"eingang": files} + + +# --- B: Dummy- und Handschrift-Verarbeitung --- + + +@app.post("/process-all") +def process_all_scans(): + processed = [] + skipped = [] + for f in EINGANG_DIR.iterdir(): + if is_valid_input_file(f): + result_path = dummy_process_scan(f) + processed.append(result_path.name) + else: + skipped.append(f.name) + + return { + "status": "OK", + "message": "Dummy-Verarbeitung abgeschlossen", + "processed_files": processed, + "skipped": skipped, + } + + +@app.post("/remove-handwriting-all") +def remove_handwriting_all(): + """ + Entfernt bei allen geeigneten Bilddateien im Eingang-Ordner möglichst die Handschrift + und legt bereinigte Bilder im Bereinigt-Ordner ab. + """ + cleaned = [] + errors = [] + skipped = [] + + for f in EINGANG_DIR.iterdir(): + if not is_valid_input_file(f): + skipped.append(f.name) + continue + + # Nur JPG/PNG für die Bild-Handschriftentfernung + if f.suffix.lower() not in {".jpg", ".jpeg", ".png"}: + skipped.append(f.name) + continue + + try: + out_path = remove_handwriting_from_scan(f) + cleaned.append(out_path.name) + except Exception as e: + errors.append({"file": f.name, "error": str(e)}) + + status = "OK" + message = "Bereinigte Bilder erzeugt." + if errors and not cleaned: + status = "ERROR" + message = "Keine bereinigten Bilder erzeugt, nur Fehler." + elif errors and cleaned: + status = "PARTIAL" + message = "Einige bereinigte Bilder erzeugt, aber auch Fehler." + + return { + "status": status, + "message": message, + "cleaned": cleaned, + "errors": errors, + "skipped": skipped, + } + + +# --- Beschreiben / Analysieren / Clean-HTML --- + + +@app.post("/describe-all") +def describe_all_scans(): + described = [] + errors = [] + skipped = [] + + for f in EINGANG_DIR.iterdir(): + if not is_valid_input_file(f): + skipped.append(f.name) + continue + try: + out_path = describe_scan_with_ai(f) + described.append(out_path.name) + except Exception as e: + errors.append({"file": f.name, "error": str(e)}) + + status = "OK" + message = "Beschreibungen erstellt" + if errors and not described: + status = "ERROR" + message = "Keine Beschreibungen erstellt, nur Fehler." + elif errors and described: + status = "PARTIAL" + message = "Einige Beschreibungen erstellt, aber auch Fehler." + + return { + "status": status, + "message": message, + "described": described, + "errors": errors, + "skipped": skipped, + } + + +@app.post("/analyze-all") +def analyze_all_scans(): + analyzed = [] + errors = [] + skipped = [] + + for f in EINGANG_DIR.iterdir(): + if not is_valid_input_file(f): + skipped.append(f.name) + continue + try: + out_path = analyze_scan_structure_with_ai(f) + analyzed.append(out_path.name) + except Exception as e: + errors.append({"file": f.name, "error": str(e)}) + + status = "OK" + message = "Analysen erstellt" + if errors and not analyzed: + status = "ERROR" + message = "Keine Analysen erstellt, nur Fehler." + elif errors and analyzed: + status = "PARTIAL" + message = "Einige Analysen erstellt, aber auch Fehler." + + return { + "status": status, + "message": message, + "analyzed": analyzed, + "errors": errors, + "skipped": skipped, + } + + +@app.post("/generate-clean") +def generate_clean_worksheets(): + """ + Nimmt alle *_analyse.json-Dateien und erzeugt *_clean.html-Arbeitsblätter. + """ + generated = [] + errors = [] + for f in BEREINIGT_DIR.iterdir(): + if f.is_file() and f.suffix == ".json" and f.name.endswith("_analyse.json"): + try: + out_path = build_clean_html_from_analysis(f) + generated.append(out_path.name) + except Exception as e: + errors.append({"file": f.name, "error": str(e)}) + + status = "OK" + message = "Clean-HTML-Arbeitsblätter erzeugt" + if errors and not generated: + status = "ERROR" + message = "Keine HTML-Arbeitsblätter erzeugt, nur Fehler." + elif errors and generated: + status = "PARTIAL" + message = "Einige HTML-Arbeitsblätter erzeugt, aber auch Fehler." + + return { + "status": status, + "message": message, + "generated": generated, + "errors": errors, + } + + +@app.get("/bereinigt-dateien") +def list_bereinigt_files(): + files = [f.name for f in BEREINIGT_DIR.iterdir() if f.is_file()] + return {"bereinigt": files} diff --git a/backend/meeting_consent_api.py b/backend/meeting_consent_api.py new file mode 100644 index 0000000..8886a25 --- /dev/null +++ b/backend/meeting_consent_api.py @@ -0,0 +1,437 @@ +""" +BreakPilot Meeting Consent API + +DSGVO-konforme Zustimmungsverwaltung fuer Meeting-Aufzeichnungen. +Alle Teilnehmer muessen zustimmen bevor eine Aufzeichnung gestartet wird. +""" + +import os +import uuid +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel, Field + +from fastapi import APIRouter, HTTPException, Query + +router = APIRouter(prefix="/api/meeting-consent", tags=["Meeting Consent"]) + + +# ========================================== +# PYDANTIC MODELS +# ========================================== + +class ConsentRequest(BaseModel): + """Request to initiate consent for recording.""" + meeting_id: str = Field(..., description="Jitsi meeting room ID") + consent_type: str = Field( + default="opt_in", + description="Consent type: opt_in (explicit), announced (verbal)" + ) + participant_count: int = Field( + default=0, + description="Expected number of participants" + ) + requested_by: Optional[str] = Field(None, description="User ID of requester") + + +class ParticipantConsent(BaseModel): + """Individual participant consent.""" + participant_id: str = Field(..., description="Participant identifier") + participant_name: Optional[str] = Field(None, description="Display name") + consented: bool = Field(..., description="Whether consent was given") + + +class ConsentStatus(BaseModel): + """Current consent status for a meeting.""" + id: str + meeting_id: str + consent_type: str + participant_count: int + consented_count: int + all_consented: bool + can_record: bool + status: str # pending, approved, rejected, withdrawn + requested_at: datetime + approved_at: Optional[datetime] + expires_at: Optional[datetime] + + +class ConsentWithdrawal(BaseModel): + """Request to withdraw consent.""" + reason: Optional[str] = Field(None, description="Reason for withdrawal") + + +# ========================================== +# IN-MEMORY STORAGE (Dev Mode) +# ========================================== + +_consent_store: dict = {} +_participant_consents: dict = {} # consent_id -> [participant consents] + + +# ========================================== +# API ENDPOINTS +# ========================================== + +@router.get("/health") +async def consent_health(): + """Health check for consent service.""" + return { + "status": "healthy", + "active_consents": len([c for c in _consent_store.values() if c["status"] in ["pending", "approved"]]), + "total_consents": len(_consent_store) + } + + +@router.post("/request", response_model=ConsentStatus) +async def request_recording_consent(request: ConsentRequest): + """ + Initiate a consent request for meeting recording. + + This creates a new consent record and returns a status object. + Recording can only start when all_consented is True. + """ + # Check if consent already exists for this meeting + existing = next( + (c for c in _consent_store.values() + if c["meeting_id"] == request.meeting_id and c["status"] not in ["withdrawn", "expired"]), + None + ) + if existing: + raise HTTPException( + status_code=409, + detail=f"Consent request already exists for meeting {request.meeting_id}" + ) + + consent_id = str(uuid.uuid4()) + now = datetime.utcnow() + + consent = { + "id": consent_id, + "meeting_id": request.meeting_id, + "consent_type": request.consent_type, + "participant_count": request.participant_count, + "consented_count": 0, + "all_consented": False, + "status": "pending", + "requested_by": request.requested_by, + "requested_at": now.isoformat(), + "approved_at": None, + "withdrawn_at": None, + "created_at": now.isoformat(), + "updated_at": now.isoformat() + } + + _consent_store[consent_id] = consent + _participant_consents[consent_id] = [] + + return ConsentStatus( + id=consent_id, + meeting_id=request.meeting_id, + consent_type=request.consent_type, + participant_count=request.participant_count, + consented_count=0, + all_consented=False, + can_record=False, + status="pending", + requested_at=now, + approved_at=None, + expires_at=None + ) + + +@router.get("/{meeting_id}", response_model=ConsentStatus) +async def get_consent_status(meeting_id: str): + """ + Get current consent status for a meeting. + + Returns the consent status including whether recording is allowed. + """ + consent = next( + (c for c in _consent_store.values() + if c["meeting_id"] == meeting_id and c["status"] not in ["withdrawn", "expired"]), + None + ) + + if not consent: + # Return default status (no consent requested) + return ConsentStatus( + id="", + meeting_id=meeting_id, + consent_type="none", + participant_count=0, + consented_count=0, + all_consented=False, + can_record=False, + status="not_requested", + requested_at=datetime.utcnow(), + approved_at=None, + expires_at=None + ) + + requested_at = datetime.fromisoformat(consent["requested_at"]) + approved_at = ( + datetime.fromisoformat(consent["approved_at"]) + if consent.get("approved_at") else None + ) + + return ConsentStatus( + id=consent["id"], + meeting_id=consent["meeting_id"], + consent_type=consent["consent_type"], + participant_count=consent["participant_count"], + consented_count=consent["consented_count"], + all_consented=consent["all_consented"], + can_record=consent["all_consented"] and consent["status"] == "approved", + status=consent["status"], + requested_at=requested_at, + approved_at=approved_at, + expires_at=None + ) + + +@router.post("/{meeting_id}/participant") +async def record_participant_consent( + meeting_id: str, + consent: ParticipantConsent +): + """ + Record individual participant consent. + + Each participant must explicitly consent to recording. + When all participants have consented, recording is automatically approved. + """ + consent_record = next( + (c for c in _consent_store.values() + if c["meeting_id"] == meeting_id and c["status"] == "pending"), + None + ) + + if not consent_record: + raise HTTPException( + status_code=404, + detail="No pending consent request found for this meeting" + ) + + consent_id = consent_record["id"] + + # Check if participant already consented + existing = next( + (p for p in _participant_consents.get(consent_id, []) + if p["participant_id"] == consent.participant_id), + None + ) + if existing: + # Update existing consent + existing["consented"] = consent.consented + existing["updated_at"] = datetime.utcnow().isoformat() + else: + # Add new participant consent + _participant_consents[consent_id].append({ + "participant_id": consent.participant_id, + "participant_name": consent.participant_name, + "consented": consent.consented, + "created_at": datetime.utcnow().isoformat(), + "updated_at": datetime.utcnow().isoformat() + }) + + # Recalculate consent count + participants = _participant_consents.get(consent_id, []) + consented_count = sum(1 for p in participants if p["consented"]) + + consent_record["consented_count"] = consented_count + consent_record["updated_at"] = datetime.utcnow().isoformat() + + # Check if all participants have consented + if consent_record["participant_count"] > 0: + all_consented = consented_count >= consent_record["participant_count"] + else: + # If participant_count is 0, check if we have at least one consent + all_consented = consented_count > 0 and all(p["consented"] for p in participants) + + consent_record["all_consented"] = all_consented + + # Auto-approve if all consented + if all_consented and consent_record["status"] == "pending": + consent_record["status"] = "approved" + consent_record["approved_at"] = datetime.utcnow().isoformat() + + return { + "success": True, + "meeting_id": meeting_id, + "participant_id": consent.participant_id, + "consented": consent.consented, + "consented_count": consented_count, + "all_consented": consent_record["all_consented"], + "can_record": consent_record["status"] == "approved" + } + + +@router.get("/{meeting_id}/participants") +async def get_participant_consents(meeting_id: str): + """ + Get list of participant consents for a meeting. + + Returns all recorded consents with anonymized participant IDs. + """ + consent_record = next( + (c for c in _consent_store.values() if c["meeting_id"] == meeting_id), + None + ) + + if not consent_record: + raise HTTPException( + status_code=404, + detail="No consent request found for this meeting" + ) + + participants = _participant_consents.get(consent_record["id"], []) + + return { + "meeting_id": meeting_id, + "consent_id": consent_record["id"], + "participant_count": consent_record["participant_count"], + "participants": [ + { + "participant_id": p["participant_id"][-8:], # Anonymize + "consented": p["consented"], + "timestamp": p["created_at"] + } + for p in participants + ] + } + + +@router.post("/{meeting_id}/withdraw") +async def withdraw_consent( + meeting_id: str, + withdrawal: ConsentWithdrawal +): + """ + Withdraw consent for a meeting recording (DSGVO right). + + This immediately stops any ongoing recording and marks the consent as withdrawn. + Existing recordings will be marked for deletion. + """ + consent_record = next( + (c for c in _consent_store.values() + if c["meeting_id"] == meeting_id and c["status"] in ["pending", "approved"]), + None + ) + + if not consent_record: + raise HTTPException( + status_code=404, + detail="No active consent found for this meeting" + ) + + consent_record["status"] = "withdrawn" + consent_record["withdrawn_at"] = datetime.utcnow().isoformat() + consent_record["withdrawal_reason"] = withdrawal.reason + consent_record["updated_at"] = datetime.utcnow().isoformat() + + return { + "success": True, + "meeting_id": meeting_id, + "status": "withdrawn", + "message": "Consent withdrawn. Recording stopped if active.", + "reason": withdrawal.reason + } + + +@router.delete("/{meeting_id}") +async def delete_consent_request( + meeting_id: str, + reason: str = Query(..., description="Reason for deletion") +): + """ + Delete a consent request (admin only). + + This removes the consent request entirely. Use withdraw for user-initiated cancellation. + """ + consent_record = next( + (c for c in _consent_store.values() if c["meeting_id"] == meeting_id), + None + ) + + if not consent_record: + raise HTTPException( + status_code=404, + detail="No consent request found for this meeting" + ) + + consent_id = consent_record["id"] + + # Remove from stores + del _consent_store[consent_id] + if consent_id in _participant_consents: + del _participant_consents[consent_id] + + return { + "success": True, + "meeting_id": meeting_id, + "deleted": True, + "reason": reason + } + + +# ========================================== +# BULK OPERATIONS +# ========================================== + +@router.post("/announce") +async def announce_recording( + meeting_id: str = Query(...), + announced_by: str = Query(..., description="Name of person announcing") +): + """ + Mark recording as verbally announced. + + For scenarios where verbal announcement is used instead of explicit opt-in. + This creates an 'announced' consent type that allows recording. + """ + # Check if consent already exists + existing = next( + (c for c in _consent_store.values() + if c["meeting_id"] == meeting_id and c["status"] not in ["withdrawn", "expired"]), + None + ) + if existing: + raise HTTPException( + status_code=409, + detail="Consent already exists for this meeting" + ) + + consent_id = str(uuid.uuid4()) + now = datetime.utcnow() + + consent = { + "id": consent_id, + "meeting_id": meeting_id, + "consent_type": "announced", + "participant_count": 0, + "consented_count": 0, + "all_consented": True, # Announced = implicit consent + "status": "approved", + "requested_by": announced_by, + "announced_by": announced_by, + "requested_at": now.isoformat(), + "approved_at": now.isoformat(), + "withdrawn_at": None, + "created_at": now.isoformat(), + "updated_at": now.isoformat() + } + + _consent_store[consent_id] = consent + + return { + "success": True, + "meeting_id": meeting_id, + "consent_id": consent_id, + "consent_type": "announced", + "can_record": True, + "announced_by": announced_by, + "message": "Recording announced. Participants should be informed verbally." + } + + diff --git a/backend/meeting_minutes_generator.py b/backend/meeting_minutes_generator.py new file mode 100644 index 0000000..d34c691 --- /dev/null +++ b/backend/meeting_minutes_generator.py @@ -0,0 +1,536 @@ +""" +BreakPilot Meeting Minutes Generator + +Generiert KI-basierte Meeting-Protokolle aus Transkriptionen. +Nutzt das LLM Gateway (Ollama/vLLM/Anthropic) fuer lokale Verarbeitung. + +Lizenz: MIT (kommerziell nutzbar) +""" + +import os +import json +import logging +import httpx +from datetime import datetime +from typing import Optional, List +from dataclasses import dataclass, asdict +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + +# ========================================== +# CONFIGURATION +# ========================================== + +LLM_GATEWAY_URL = os.getenv("LLM_GATEWAY_URL", "http://localhost:8002") +LLM_MODEL = os.getenv("MEETING_MINUTES_MODEL", "breakpilot-teacher-8b") +LLM_TIMEOUT = int(os.getenv("LLM_TIMEOUT", "120")) + + +# ========================================== +# PYDANTIC MODELS +# ========================================== + +class ActionItem(BaseModel): + """Ein Aktionspunkt aus dem Meeting.""" + task: str = Field(..., description="Die zu erledigende Aufgabe") + assignee: Optional[str] = Field(None, description="Verantwortliche Person (SPEAKER_XX oder Name)") + deadline: Optional[str] = Field(None, description="Faelligkeit, falls erwaehnt") + priority: str = Field(default="normal", description="Prioritaet: high, normal, low") + + +class Decision(BaseModel): + """Eine getroffene Entscheidung.""" + topic: str = Field(..., description="Thema der Entscheidung") + decision: str = Field(..., description="Die getroffene Entscheidung") + rationale: Optional[str] = Field(None, description="Begruendung, falls erwaehnt") + + +class TopicSummary(BaseModel): + """Zusammenfassung eines besprochenen Themas.""" + title: str = Field(..., description="Titel des Themas") + summary: str = Field(..., description="Kurze Zusammenfassung") + participants: List[str] = Field(default_factory=list, description="Beteiligte Sprecher") + duration_estimate: Optional[str] = Field(None, description="Geschaetzte Dauer") + + +class MeetingMinutes(BaseModel): + """Vollstaendiges Meeting-Protokoll.""" + id: str + recording_id: str + transcription_id: str + + # Metadaten + title: str = Field(..., description="Titel des Meetings") + date: str = Field(..., description="Datum des Meetings") + duration_minutes: Optional[int] = Field(None, description="Dauer in Minuten") + participant_count: int = Field(default=0, description="Anzahl Teilnehmer") + language: str = Field(default="de", description="Sprache") + + # Inhalt + summary: str = Field(..., description="Zusammenfassung in 3-5 Saetzen") + topics: List[TopicSummary] = Field(default_factory=list, description="Besprochene Themen") + decisions: List[Decision] = Field(default_factory=list, description="Getroffene Entscheidungen") + action_items: List[ActionItem] = Field(default_factory=list, description="Aktionspunkte/TODOs") + open_questions: List[str] = Field(default_factory=list, description="Offene Fragen") + + # KI-Metadaten + model_used: str = Field(..., description="Verwendetes LLM") + generated_at: datetime = Field(default_factory=datetime.utcnow) + generation_time_seconds: Optional[float] = Field(None, description="Generierungszeit") + + # Status + status: str = Field(default="completed", description="Status: pending, processing, completed, failed") + error_message: Optional[str] = Field(None, description="Fehlermeldung bei Status=failed") + + +class MinutesGenerationRequest(BaseModel): + """Anfrage zur Protokoll-Generierung.""" + title: Optional[str] = Field(None, description="Meeting-Titel (optional, wird generiert)") + model: str = Field(default=LLM_MODEL, description="LLM Modell") + include_action_items: bool = Field(default=True, description="Action Items extrahieren") + include_decisions: bool = Field(default=True, description="Entscheidungen extrahieren") + max_topics: int = Field(default=10, description="Maximale Anzahl Themen") + + +# ========================================== +# PROMPTS (German, Education Context) +# ========================================== + +SYSTEM_PROMPT = """Du bist ein Assistent für die Erstellung von Meeting-Protokollen in deutschen Bildungseinrichtungen (Schulen, Universitäten). + +Deine Aufgabe ist es, aus einer Transkription ein strukturiertes Protokoll zu erstellen. + +WICHTIG: +- Schreibe professionell und sachlich auf Deutsch +- Verwende die formelle Anrede (Sie) +- Halte dich an die Fakten der Transkription +- Erfinde KEINE Informationen, die nicht in der Transkription stehen +- Sprecher werden als SPEAKER_00, SPEAKER_01 etc. bezeichnet - behalte diese Bezeichnungen bei +- Wenn du dir bei etwas unsicher bist, schreibe "Unklar:" davor + +Format für die Ausgabe (JSON): +{ + "summary": "3-5 Sätze Zusammenfassung", + "topics": [ + {"title": "Thema", "summary": "Kurzbeschreibung", "participants": ["SPEAKER_00"]} + ], + "decisions": [ + {"topic": "Thema", "decision": "Was wurde entschieden", "rationale": "Begründung oder null"} + ], + "action_items": [ + {"task": "Aufgabe", "assignee": "SPEAKER_XX oder null", "deadline": "Datum oder null", "priority": "high/normal/low"} + ], + "open_questions": ["Frage 1", "Frage 2"] +}""" + +EXTRACTION_PROMPT = """Analysiere folgende Meeting-Transkription und erstelle ein strukturiertes Protokoll. + +Meeting-Titel: {title} +Datum: {date} +Dauer: {duration} Minuten +Teilnehmer: {participant_count} + +--- TRANSKRIPTION --- +{transcript} +--- ENDE TRANSKRIPTION --- + +Erstelle ein JSON-Protokoll mit: +1. summary: Zusammenfassung in 3-5 Sätzen +2. topics: Liste der besprochenen Themen (maximal {max_topics}) +3. decisions: Alle getroffenen Entscheidungen +4. action_items: Alle Aufgaben/TODOs mit Verantwortlichen (falls genannt) +5. open_questions: Offene Fragen, die nicht beantwortet wurden + +Antworte NUR mit dem JSON-Objekt, ohne zusätzlichen Text.""" + + +# ========================================== +# MEETING MINUTES GENERATOR +# ========================================== + +class MeetingMinutesGenerator: + """Generator fuer Meeting-Protokolle aus Transkriptionen.""" + + def __init__(self, llm_gateway_url: str = LLM_GATEWAY_URL): + self.llm_gateway_url = llm_gateway_url + self._client: Optional[httpx.AsyncClient] = None + + async def get_client(self) -> httpx.AsyncClient: + """Lazy initialization des HTTP Clients.""" + if self._client is None: + self._client = httpx.AsyncClient(timeout=LLM_TIMEOUT) + return self._client + + async def close(self): + """Schliesst den HTTP Client.""" + if self._client: + await self._client.aclose() + self._client = None + + async def _call_llm( + self, + messages: List[dict], + model: str = LLM_MODEL, + temperature: float = 0.3, + max_tokens: int = 4096 + ) -> str: + """Ruft das LLM Gateway auf.""" + client = await self.get_client() + + payload = { + "model": model, + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens, + "stream": False + } + + try: + response = await client.post( + f"{self.llm_gateway_url}/v1/chat/completions", + json=payload, + timeout=LLM_TIMEOUT + ) + response.raise_for_status() + data = response.json() + + content = data.get("choices", [{}])[0].get("message", {}).get("content", "") + return content + + except httpx.TimeoutException: + logger.error("LLM Gateway timeout") + raise RuntimeError("LLM Gateway antwortet nicht (Timeout)") + except httpx.HTTPStatusError as e: + logger.error(f"LLM Gateway error: {e.response.status_code}") + raise RuntimeError(f"LLM Gateway Fehler: {e.response.status_code}") + except Exception as e: + logger.error(f"LLM call failed: {e}") + raise RuntimeError(f"LLM Aufruf fehlgeschlagen: {str(e)}") + + def _parse_llm_response(self, response: str) -> dict: + """Parst die LLM-Antwort als JSON.""" + # Versuche JSON aus der Antwort zu extrahieren + response = response.strip() + + # Entferne eventuelle Markdown Code-Bloecke + if response.startswith("```json"): + response = response[7:] + if response.startswith("```"): + response = response[3:] + if response.endswith("```"): + response = response[:-3] + + response = response.strip() + + try: + return json.loads(response) + except json.JSONDecodeError as e: + logger.warning(f"JSON parse error: {e}. Response: {response[:200]}...") + # Fallback: Leeres Protokoll + return { + "summary": "Protokoll konnte nicht automatisch erstellt werden.", + "topics": [], + "decisions": [], + "action_items": [], + "open_questions": [] + } + + async def generate( + self, + transcript: str, + recording_id: str, + transcription_id: str, + title: Optional[str] = None, + date: Optional[str] = None, + duration_minutes: Optional[int] = None, + participant_count: int = 0, + model: str = LLM_MODEL, + max_topics: int = 10, + include_action_items: bool = True, + include_decisions: bool = True + ) -> MeetingMinutes: + """ + Generiert Meeting Minutes aus einer Transkription. + + Args: + transcript: Die vollstaendige Transkription + recording_id: ID der Aufzeichnung + transcription_id: ID der Transkription + title: Meeting-Titel (wird generiert falls nicht angegeben) + date: Datum des Meetings + duration_minutes: Dauer in Minuten + participant_count: Anzahl Teilnehmer + model: LLM Modell + max_topics: Maximale Anzahl Themen + include_action_items: Action Items extrahieren + include_decisions: Entscheidungen extrahieren + + Returns: + MeetingMinutes: Das generierte Protokoll + """ + import uuid + import time + + start_time = time.time() + minutes_id = str(uuid.uuid4()) + + # Defaults + if not title: + title = f"Meeting vom {date or datetime.utcnow().strftime('%d.%m.%Y')}" + if not date: + date = datetime.utcnow().strftime("%d.%m.%Y") + + # Transkription kuerzen falls zu lang (max ~8000 Tokens ~ 32000 chars) + max_chars = 32000 + if len(transcript) > max_chars: + logger.warning(f"Transcript too long ({len(transcript)} chars), truncating...") + transcript = transcript[:max_chars] + "\n\n[... Transkription gekürzt ...]" + + # Prompt erstellen + user_prompt = EXTRACTION_PROMPT.format( + title=title, + date=date, + duration=duration_minutes or "unbekannt", + participant_count=participant_count, + transcript=transcript, + max_topics=max_topics + ) + + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_prompt} + ] + + try: + # LLM aufrufen + logger.info(f"Generating minutes for recording {recording_id} using {model}") + response = await self._call_llm(messages, model=model) + + # Antwort parsen + parsed = self._parse_llm_response(response) + + generation_time = time.time() - start_time + + # MeetingMinutes erstellen + minutes = MeetingMinutes( + id=minutes_id, + recording_id=recording_id, + transcription_id=transcription_id, + title=title, + date=date, + duration_minutes=duration_minutes, + participant_count=participant_count, + language="de", + summary=parsed.get("summary", "Zusammenfassung nicht verfügbar."), + topics=[ + TopicSummary(**t) for t in parsed.get("topics", []) + ] if parsed.get("topics") else [], + decisions=[ + Decision(**d) for d in parsed.get("decisions", []) + ] if include_decisions and parsed.get("decisions") else [], + action_items=[ + ActionItem(**a) for a in parsed.get("action_items", []) + ] if include_action_items and parsed.get("action_items") else [], + open_questions=parsed.get("open_questions", []), + model_used=model, + generated_at=datetime.utcnow(), + generation_time_seconds=round(generation_time, 2), + status="completed" + ) + + logger.info(f"Minutes generated in {generation_time:.2f}s: {len(minutes.topics)} topics, {len(minutes.action_items)} action items") + + return minutes + + except Exception as e: + logger.error(f"Minutes generation failed: {e}") + return MeetingMinutes( + id=minutes_id, + recording_id=recording_id, + transcription_id=transcription_id, + title=title, + date=date, + duration_minutes=duration_minutes, + participant_count=participant_count, + language="de", + summary="", + model_used=model, + status="failed", + error_message=str(e) + ) + + +# ========================================== +# EXPORT FUNCTIONS +# ========================================== + +def minutes_to_markdown(minutes: MeetingMinutes) -> str: + """Exportiert Meeting Minutes als Markdown.""" + md = f"""# {minutes.title} + +**Datum:** {minutes.date} +**Dauer:** {minutes.duration_minutes or 'unbekannt'} Minuten +**Teilnehmer:** {minutes.participant_count} + +--- + +## Zusammenfassung + +{minutes.summary} + +--- + +## Besprochene Themen + +""" + + for i, topic in enumerate(minutes.topics, 1): + md += f"### {i}. {topic.title}\n\n" + md += f"{topic.summary}\n\n" + if topic.participants: + md += f"*Beteiligte: {', '.join(topic.participants)}*\n\n" + + if minutes.decisions: + md += "---\n\n## Entscheidungen\n\n" + for decision in minutes.decisions: + md += f"- **{decision.topic}:** {decision.decision}" + if decision.rationale: + md += f" *(Begründung: {decision.rationale})*" + md += "\n" + md += "\n" + + if minutes.action_items: + md += "---\n\n## Action Items\n\n" + md += "| Aufgabe | Verantwortlich | Fällig | Priorität |\n" + md += "|---------|----------------|--------|----------|\n" + for item in minutes.action_items: + md += f"| {item.task} | {item.assignee or '-'} | {item.deadline or '-'} | {item.priority} |\n" + md += "\n" + + if minutes.open_questions: + md += "---\n\n## Offene Fragen\n\n" + for q in minutes.open_questions: + md += f"- {q}\n" + md += "\n" + + md += f"""--- + +*Generiert am {minutes.generated_at.strftime('%d.%m.%Y um %H:%M Uhr')} mit {minutes.model_used}* +*Generierungszeit: {minutes.generation_time_seconds or 0:.1f} Sekunden* +""" + + return md + + +def minutes_to_html(minutes: MeetingMinutes) -> str: + """Exportiert Meeting Minutes als HTML (fuer PDF-Konvertierung).""" + html = f""" + + + + {minutes.title} + + + +

            {minutes.title}

            + +
            +

            Datum: {minutes.date}

            +

            Dauer: {minutes.duration_minutes or 'unbekannt'} Minuten

            +

            Teilnehmer: {minutes.participant_count}

            +
            + +

            Zusammenfassung

            +
            +

            {minutes.summary}

            +
            + +

            Besprochene Themen

            +""" + + for i, topic in enumerate(minutes.topics, 1): + html += f"""

            {i}. {topic.title}

            +

            {topic.summary}

            +""" + if topic.participants: + html += f"

            Beteiligte: {', '.join(topic.participants)}

            \n" + + if minutes.decisions: + html += "

            Entscheidungen

            \n" + for decision in minutes.decisions: + html += f"""
            + {decision.topic}: {decision.decision} +""" + if decision.rationale: + html += f"
            Begründung: {decision.rationale}\n" + html += "
            \n" + + if minutes.action_items: + html += """

            Action Items

            + + + + + +""" + for item in minutes.action_items: + priority_class = f"priority-{item.priority}" + html += f""" + + + + + +""" + html += """ +
            AufgabeVerantwortlichFälligPriorität
            {item.task}{item.assignee or '-'}{item.deadline or '-'}{item.priority}
            +""" + + if minutes.open_questions: + html += "

            Offene Fragen

            \n" + for q in minutes.open_questions: + html += f'
            {q}
            \n' + + html += f""" + + + +""" + + return html + + +# ========================================== +# SINGLETON +# ========================================== + +_generator: Optional[MeetingMinutesGenerator] = None + + +def get_minutes_generator() -> MeetingMinutesGenerator: + """Gibt den Meeting Minutes Generator Singleton zurueck.""" + global _generator + if _generator is None: + _generator = MeetingMinutesGenerator() + return _generator diff --git a/backend/meetings_api.py b/backend/meetings_api.py new file mode 100644 index 0000000..abd0bb1 --- /dev/null +++ b/backend/meetings_api.py @@ -0,0 +1,443 @@ +""" +Meetings API Module +Backend API endpoints for Jitsi Meet integration +""" +import os +import uuid +import httpx +from datetime import datetime, timedelta +from typing import Optional, List +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel, EmailStr + +router = APIRouter(prefix="/api/meetings", tags=["meetings"]) + + +# ============================================ +# Configuration +# ============================================ + +JITSI_BASE_URL = os.getenv("JITSI_PUBLIC_URL", "http://localhost:8443") +CONSENT_SERVICE_URL = os.getenv("CONSENT_SERVICE_URL", "http://localhost:8081") + + +# ============================================ +# Models +# ============================================ + +class MeetingConfig(BaseModel): + enable_lobby: bool = True + enable_recording: bool = False + start_with_audio_muted: bool = True + start_with_video_muted: bool = False + require_display_name: bool = True + enable_breakout: bool = False + + +class CreateMeetingRequest(BaseModel): + type: str = "quick" # quick, scheduled, training, parent, class + title: str = "Neues Meeting" + duration: int = 60 + scheduled_at: Optional[str] = None + config: Optional[MeetingConfig] = None + description: Optional[str] = None + invites: Optional[List[str]] = None + + +class ScheduleMeetingRequest(BaseModel): + title: str + scheduled_at: str + duration: int = 60 + description: Optional[str] = None + invites: Optional[List[str]] = None + + +class TrainingRequest(BaseModel): + title: str + description: Optional[str] = None + scheduled_at: str + duration: int = 120 + max_participants: int = 20 + trainer: str + config: Optional[MeetingConfig] = None + + +class ParentTeacherRequest(BaseModel): + student_name: str + parent_name: str + parent_email: Optional[str] = None + scheduled_at: str + reason: Optional[str] = None + send_invite: bool = True + duration: int = 30 + + +class MeetingResponse(BaseModel): + room_name: str + join_url: str + moderator_url: Optional[str] = None + password: Optional[str] = None + expires_at: Optional[str] = None + + +class MeetingStats(BaseModel): + active: int = 0 + scheduled: int = 0 + recordings: int = 0 + participants: int = 0 + + +class ActiveMeeting(BaseModel): + room_name: str + title: str + participants: int + started_at: str + + +# ============================================ +# In-Memory Storage (for demo purposes) +# In production, use database +# ============================================ + +scheduled_meetings = [] +active_meetings = [] +trainings = [] +recordings = [] + + +# ============================================ +# Helper Functions +# ============================================ + +def generate_room_name(prefix: str = "meeting") -> str: + """Generate a unique room name""" + return f"{prefix}-{uuid.uuid4().hex[:8]}" + + +def generate_password() -> str: + """Generate a simple password""" + return uuid.uuid4().hex[:8] + + +def build_jitsi_url(room_name: str, config: Optional[MeetingConfig] = None) -> str: + """Build Jitsi meeting URL with config parameters""" + params = [] + + if config: + if config.start_with_audio_muted: + params.append("config.startWithAudioMuted=true") + if config.start_with_video_muted: + params.append("config.startWithVideoMuted=true") + if config.require_display_name: + params.append("config.requireDisplayName=true") + + # Common config + params.extend([ + "config.prejoinPageEnabled=false", + "config.disableDeepLinking=true", + "config.defaultLanguage=de", + "interfaceConfig.SHOW_JITSI_WATERMARK=false", + "interfaceConfig.SHOW_BRAND_WATERMARK=false" + ]) + + url = f"{JITSI_BASE_URL}/{room_name}" + if params: + url += "#" + "&".join(params) + + return url + + +async def call_consent_service(endpoint: str, method: str = "GET", data: dict = None) -> dict: + """Call the consent service API""" + async with httpx.AsyncClient() as client: + url = f"{CONSENT_SERVICE_URL}{endpoint}" + + if method == "GET": + response = await client.get(url) + elif method == "POST": + response = await client.post(url, json=data) + else: + raise ValueError(f"Unsupported method: {method}") + + if response.status_code >= 400: + return None + + return response.json() + + +# ============================================ +# API Endpoints +# ============================================ + +@router.get("/stats", response_model=MeetingStats) +async def get_meeting_stats(): + """Get meeting statistics""" + return MeetingStats( + active=len(active_meetings), + scheduled=len(scheduled_meetings), + recordings=len(recordings), + participants=sum(m.get("participants", 0) for m in active_meetings) + ) + + +@router.get("/active", response_model=List[ActiveMeeting]) +async def get_active_meetings(): + """Get list of active meetings""" + return [ + ActiveMeeting( + room_name=m["room_name"], + title=m["title"], + participants=m.get("participants", 0), + started_at=m.get("started_at", datetime.now().isoformat()) + ) + for m in active_meetings + ] + + +@router.post("/create", response_model=MeetingResponse) +async def create_meeting(request: CreateMeetingRequest): + """Create a new meeting""" + config = request.config or MeetingConfig() + + # Generate room name based on type + if request.type == "quick": + room_name = generate_room_name("quick") + elif request.type == "training": + room_name = generate_room_name("schulung") + elif request.type == "parent": + room_name = generate_room_name("elterngespraech") + elif request.type == "class": + room_name = generate_room_name("klasse") + else: + room_name = generate_room_name("meeting") + + join_url = build_jitsi_url(room_name, config) + + # Store meeting if scheduled + if request.scheduled_at: + scheduled_meetings.append({ + "room_name": room_name, + "title": request.title, + "scheduled_at": request.scheduled_at, + "duration": request.duration, + "config": config.model_dump() if config else None + }) + + return MeetingResponse( + room_name=room_name, + join_url=join_url + ) + + +@router.post("/schedule", response_model=MeetingResponse) +async def schedule_meeting(request: ScheduleMeetingRequest): + """Schedule a new meeting""" + room_name = generate_room_name("meeting") + + meeting = { + "room_name": room_name, + "title": request.title, + "scheduled_at": request.scheduled_at, + "duration": request.duration, + "description": request.description, + "invites": request.invites or [] + } + + scheduled_meetings.append(meeting) + + join_url = build_jitsi_url(room_name) + + # TODO: Send email invites if configured + + return MeetingResponse( + room_name=room_name, + join_url=join_url + ) + + +@router.post("/training", response_model=MeetingResponse) +async def create_training(request: TrainingRequest): + """Create a training session""" + # Generate room name from title + title_slug = request.title.lower().replace(" ", "-")[:20] + room_name = f"schulung-{title_slug}-{uuid.uuid4().hex[:4]}" + + config = request.config or MeetingConfig( + enable_lobby=True, + enable_recording=True, + start_with_audio_muted=True + ) + + training = { + "room_name": room_name, + "title": request.title, + "description": request.description, + "scheduled_at": request.scheduled_at, + "duration": request.duration, + "max_participants": request.max_participants, + "trainer": request.trainer, + "config": config.model_dump() + } + + trainings.append(training) + scheduled_meetings.append(training) + + join_url = build_jitsi_url(room_name, config) + + return MeetingResponse( + room_name=room_name, + join_url=join_url + ) + + +@router.post("/parent-teacher", response_model=MeetingResponse) +async def create_parent_teacher_meeting(request: ParentTeacherRequest): + """Create a parent-teacher meeting""" + # Generate room name with student name and date + student_slug = request.student_name.lower().replace(" ", "-")[:15] + date_str = datetime.fromisoformat(request.scheduled_at).strftime("%Y%m%d-%H%M") + room_name = f"elterngespraech-{student_slug}-{date_str}" + + # Generate password for security + password = generate_password() + + config = MeetingConfig( + enable_lobby=True, + enable_recording=False, + start_with_audio_muted=False + ) + + meeting = { + "room_name": room_name, + "title": f"Elterngespräch - {request.student_name}", + "student_name": request.student_name, + "parent_name": request.parent_name, + "parent_email": request.parent_email, + "scheduled_at": request.scheduled_at, + "duration": request.duration, + "reason": request.reason, + "password": password, + "config": config.model_dump() + } + + scheduled_meetings.append(meeting) + + join_url = build_jitsi_url(room_name, config) + + # TODO: Send email invite to parents if configured + + return MeetingResponse( + room_name=room_name, + join_url=join_url, + password=password + ) + + +@router.get("/scheduled") +async def get_scheduled_meetings(): + """Get all scheduled meetings""" + return scheduled_meetings + + +@router.get("/trainings") +async def get_trainings(): + """Get all training sessions""" + return trainings + + +@router.delete("/{room_name}") +async def delete_meeting(room_name: str): + """Delete a scheduled meeting""" + # Find and remove the meeting (in-place modification) + for i, m in enumerate(scheduled_meetings): + if m["room_name"] == room_name: + scheduled_meetings.pop(i) + break + return {"status": "deleted"} + + +# ============================================ +# Recording Endpoints +# ============================================ + +@router.get("/recordings") +async def get_recordings(): + """Get list of recordings""" + # Demo data + return [ + { + "id": "docker-basics", + "title": "Docker Grundlagen Schulung", + "date": "2025-12-10T10:00:00", + "duration": "1:30:00", + "size_mb": 156, + "participants": 15 + }, + { + "id": "team-kw49", + "title": "Team-Meeting KW 49", + "date": "2025-12-06T14:00:00", + "duration": "1:00:00", + "size_mb": 98, + "participants": 8 + }, + { + "id": "parent-mueller", + "title": "Elterngespräch - Max Müller", + "date": "2025-12-02T16:00:00", + "duration": "0:28:00", + "size_mb": 42, + "participants": 2 + } + ] + + +@router.get("/recordings/{recording_id}") +async def get_recording(recording_id: str): + """Get recording details""" + return { + "id": recording_id, + "title": "Recording " + recording_id, + "date": "2025-12-10T10:00:00", + "duration": "1:30:00", + "size_mb": 156, + "download_url": f"/api/recordings/{recording_id}/download" + } + + +@router.get("/recordings/{recording_id}/download") +async def download_recording(recording_id: str): + """Download a recording""" + # In production, this would stream the actual file + raise HTTPException(status_code=404, detail="Recording file not found (demo mode)") + + +@router.delete("/recordings/{recording_id}") +async def delete_recording(recording_id: str): + """Delete a recording""" + return {"status": "deleted", "id": recording_id} + + +# ============================================ +# Health Check +# ============================================ + +@router.get("/health") +async def health_check(): + """Check meetings service health""" + # Check Jitsi availability + jitsi_healthy = False + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get(JITSI_BASE_URL) + jitsi_healthy = response.status_code == 200 + except Exception: + pass + + return { + "status": "healthy" if jitsi_healthy else "degraded", + "jitsi_url": JITSI_BASE_URL, + "jitsi_available": jitsi_healthy, + "scheduled_meetings": len(scheduled_meetings), + "active_meetings": len(active_meetings) + } diff --git a/backend/messenger_api.py b/backend/messenger_api.py new file mode 100644 index 0000000..3540496 --- /dev/null +++ b/backend/messenger_api.py @@ -0,0 +1,840 @@ +""" +BreakPilot Messenger API + +Stellt Endpoints fuer: +- Kontaktverwaltung (CRUD) +- Konversationen +- Nachrichten +- CSV-Import fuer Kontakte +- Gruppenmanagement + +DSGVO-konform: Alle Daten werden lokal gespeichert. +""" + +import os +import csv +import uuid +import json +from io import StringIO +from datetime import datetime +from typing import List, Optional, Dict, Any +from pathlib import Path + +from fastapi import APIRouter, HTTPException, UploadFile, File, Query +from pydantic import BaseModel, Field + +router = APIRouter(prefix="/api/messenger", tags=["Messenger"]) + +# Datenspeicherung (JSON-basiert fuer einfache Persistenz) +DATA_DIR = Path(__file__).parent / "data" / "messenger" +DATA_DIR.mkdir(parents=True, exist_ok=True) + +CONTACTS_FILE = DATA_DIR / "contacts.json" +CONVERSATIONS_FILE = DATA_DIR / "conversations.json" +MESSAGES_FILE = DATA_DIR / "messages.json" +GROUPS_FILE = DATA_DIR / "groups.json" + + +# ========================================== +# PYDANTIC MODELS +# ========================================== + +class ContactBase(BaseModel): + """Basis-Modell fuer Kontakte.""" + name: str = Field(..., min_length=1, max_length=200) + email: Optional[str] = None + phone: Optional[str] = None + role: str = Field(default="parent", description="parent, teacher, staff, student") + student_name: Optional[str] = Field(None, description="Name des zugehoerigen Schuelers") + class_name: Optional[str] = Field(None, description="Klasse z.B. 10a") + notes: Optional[str] = None + tags: List[str] = Field(default_factory=list) + matrix_id: Optional[str] = Field(None, description="Matrix-ID z.B. @user:matrix.org") + preferred_channel: str = Field(default="email", description="email, matrix, pwa") + + +class ContactCreate(ContactBase): + """Model fuer neuen Kontakt.""" + pass + + +class Contact(ContactBase): + """Vollstaendiger Kontakt mit ID.""" + id: str + created_at: str + updated_at: str + online: bool = False + last_seen: Optional[str] = None + + +class ContactUpdate(BaseModel): + """Update-Model fuer Kontakte.""" + name: Optional[str] = None + email: Optional[str] = None + phone: Optional[str] = None + role: Optional[str] = None + student_name: Optional[str] = None + class_name: Optional[str] = None + notes: Optional[str] = None + tags: Optional[List[str]] = None + matrix_id: Optional[str] = None + preferred_channel: Optional[str] = None + + +class GroupBase(BaseModel): + """Basis-Modell fuer Gruppen.""" + name: str = Field(..., min_length=1, max_length=100) + description: Optional[str] = None + group_type: str = Field(default="class", description="class, department, custom") + + +class GroupCreate(GroupBase): + """Model fuer neue Gruppe.""" + member_ids: List[str] = Field(default_factory=list) + + +class Group(GroupBase): + """Vollstaendige Gruppe mit ID.""" + id: str + member_ids: List[str] = [] + created_at: str + updated_at: str + + +class MessageBase(BaseModel): + """Basis-Modell fuer Nachrichten.""" + content: str = Field(..., min_length=1) + content_type: str = Field(default="text", description="text, file, image") + file_url: Optional[str] = None + send_email: bool = Field(default=False, description="Nachricht auch per Email senden") + + +class MessageCreate(MessageBase): + """Model fuer neue Nachricht.""" + conversation_id: str + + +class Message(MessageBase): + """Vollstaendige Nachricht mit ID.""" + id: str + conversation_id: str + sender_id: str # "self" fuer eigene Nachrichten + timestamp: str + read: bool = False + read_at: Optional[str] = None + email_sent: bool = False + email_sent_at: Optional[str] = None + email_error: Optional[str] = None + + +class ConversationBase(BaseModel): + """Basis-Modell fuer Konversationen.""" + name: Optional[str] = None + is_group: bool = False + + +class Conversation(ConversationBase): + """Vollstaendige Konversation mit ID.""" + id: str + participant_ids: List[str] = [] + group_id: Optional[str] = None + created_at: str + updated_at: str + last_message: Optional[str] = None + last_message_time: Optional[str] = None + unread_count: int = 0 + + +class CSVImportResult(BaseModel): + """Ergebnis eines CSV-Imports.""" + imported: int + skipped: int + errors: List[str] + contacts: List[Contact] + + +# ========================================== +# DATA HELPERS +# ========================================== + +def load_json(filepath: Path) -> List[Dict]: + """Laedt JSON-Daten aus Datei.""" + if not filepath.exists(): + return [] + try: + with open(filepath, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: + return [] + + +def save_json(filepath: Path, data: List[Dict]): + """Speichert Daten in JSON-Datei.""" + with open(filepath, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + +def get_contacts() -> List[Dict]: + return load_json(CONTACTS_FILE) + + +def save_contacts(contacts: List[Dict]): + save_json(CONTACTS_FILE, contacts) + + +def get_conversations() -> List[Dict]: + return load_json(CONVERSATIONS_FILE) + + +def save_conversations(conversations: List[Dict]): + save_json(CONVERSATIONS_FILE, conversations) + + +def get_messages() -> List[Dict]: + return load_json(MESSAGES_FILE) + + +def save_messages(messages: List[Dict]): + save_json(MESSAGES_FILE, messages) + + +def get_groups() -> List[Dict]: + return load_json(GROUPS_FILE) + + +def save_groups(groups: List[Dict]): + save_json(GROUPS_FILE, groups) + + +# ========================================== +# CONTACTS ENDPOINTS +# ========================================== + +@router.get("/contacts", response_model=List[Contact]) +async def list_contacts( + role: Optional[str] = Query(None, description="Filter by role"), + class_name: Optional[str] = Query(None, description="Filter by class"), + search: Optional[str] = Query(None, description="Search in name/email") +): + """Listet alle Kontakte auf.""" + contacts = get_contacts() + + # Filter anwenden + if role: + contacts = [c for c in contacts if c.get("role") == role] + if class_name: + contacts = [c for c in contacts if c.get("class_name") == class_name] + if search: + search_lower = search.lower() + contacts = [c for c in contacts if + search_lower in c.get("name", "").lower() or + search_lower in (c.get("email") or "").lower() or + search_lower in (c.get("student_name") or "").lower()] + + return contacts + + +@router.post("/contacts", response_model=Contact) +async def create_contact(contact: ContactCreate): + """Erstellt einen neuen Kontakt.""" + contacts = get_contacts() + + # Pruefen ob Email bereits existiert + if contact.email: + existing = [c for c in contacts if c.get("email") == contact.email] + if existing: + raise HTTPException(status_code=400, detail="Kontakt mit dieser Email existiert bereits") + + now = datetime.utcnow().isoformat() + new_contact = { + "id": str(uuid.uuid4()), + "created_at": now, + "updated_at": now, + "online": False, + "last_seen": None, + **contact.dict() + } + + contacts.append(new_contact) + save_contacts(contacts) + + return new_contact + + +@router.get("/contacts/{contact_id}", response_model=Contact) +async def get_contact(contact_id: str): + """Ruft einen einzelnen Kontakt ab.""" + contacts = get_contacts() + contact = next((c for c in contacts if c["id"] == contact_id), None) + + if not contact: + raise HTTPException(status_code=404, detail="Kontakt nicht gefunden") + + return contact + + +@router.put("/contacts/{contact_id}", response_model=Contact) +async def update_contact(contact_id: str, update: ContactUpdate): + """Aktualisiert einen Kontakt.""" + contacts = get_contacts() + contact_idx = next((i for i, c in enumerate(contacts) if c["id"] == contact_id), None) + + if contact_idx is None: + raise HTTPException(status_code=404, detail="Kontakt nicht gefunden") + + update_data = update.dict(exclude_unset=True) + contacts[contact_idx].update(update_data) + contacts[contact_idx]["updated_at"] = datetime.utcnow().isoformat() + + save_contacts(contacts) + return contacts[contact_idx] + + +@router.delete("/contacts/{contact_id}") +async def delete_contact(contact_id: str): + """Loescht einen Kontakt.""" + contacts = get_contacts() + contacts = [c for c in contacts if c["id"] != contact_id] + save_contacts(contacts) + + return {"status": "deleted", "id": contact_id} + + +@router.post("/contacts/import", response_model=CSVImportResult) +async def import_contacts_csv(file: UploadFile = File(...)): + """ + Importiert Kontakte aus einer CSV-Datei. + + Erwartete Spalten: + - name (required) + - email + - phone + - role (parent/teacher/staff/student) + - student_name + - class_name + - notes + - tags (komma-separiert) + """ + if not file.filename.endswith('.csv'): + raise HTTPException(status_code=400, detail="Nur CSV-Dateien werden unterstuetzt") + + content = await file.read() + try: + text = content.decode('utf-8') + except UnicodeDecodeError: + text = content.decode('latin-1') + + contacts = get_contacts() + existing_emails = {c.get("email") for c in contacts if c.get("email")} + + imported = [] + skipped = 0 + errors = [] + + reader = csv.DictReader(StringIO(text), delimiter=';') # Deutsche CSV meist mit Semikolon + if not reader.fieldnames or 'name' not in [f.lower() for f in reader.fieldnames]: + # Versuche mit Komma + reader = csv.DictReader(StringIO(text), delimiter=',') + + for row_num, row in enumerate(reader, start=2): + try: + # Normalisiere Spaltennamen + row = {k.lower().strip(): v.strip() if v else "" for k, v in row.items()} + + name = row.get('name') or row.get('kontakt') or row.get('elternname') + if not name: + errors.append(f"Zeile {row_num}: Name fehlt") + skipped += 1 + continue + + email = row.get('email') or row.get('e-mail') or row.get('mail') + if email and email in existing_emails: + errors.append(f"Zeile {row_num}: Email {email} existiert bereits") + skipped += 1 + continue + + now = datetime.utcnow().isoformat() + tags_str = row.get('tags') or row.get('kategorien') or "" + tags = [t.strip() for t in tags_str.split(',') if t.strip()] + + # Matrix-ID und preferred_channel auslesen + matrix_id = row.get('matrix_id') or row.get('matrix') or None + preferred_channel = row.get('preferred_channel') or row.get('kanal') or "email" + if preferred_channel not in ["email", "matrix", "pwa"]: + preferred_channel = "email" + + new_contact = { + "id": str(uuid.uuid4()), + "name": name, + "email": email if email else None, + "phone": row.get('phone') or row.get('telefon') or row.get('tel'), + "role": row.get('role') or row.get('rolle') or "parent", + "student_name": row.get('student_name') or row.get('schueler') or row.get('kind'), + "class_name": row.get('class_name') or row.get('klasse'), + "notes": row.get('notes') or row.get('notizen') or row.get('bemerkungen'), + "tags": tags, + "matrix_id": matrix_id if matrix_id else None, + "preferred_channel": preferred_channel, + "created_at": now, + "updated_at": now, + "online": False, + "last_seen": None + } + + contacts.append(new_contact) + imported.append(new_contact) + if email: + existing_emails.add(email) + + except Exception as e: + errors.append(f"Zeile {row_num}: {str(e)}") + skipped += 1 + + save_contacts(contacts) + + return CSVImportResult( + imported=len(imported), + skipped=skipped, + errors=errors[:20], # Maximal 20 Fehler zurueckgeben + contacts=imported + ) + + +@router.get("/contacts/export/csv") +async def export_contacts_csv(): + """Exportiert alle Kontakte als CSV.""" + from fastapi.responses import StreamingResponse + + contacts = get_contacts() + + output = StringIO() + fieldnames = ['name', 'email', 'phone', 'role', 'student_name', 'class_name', 'notes', 'tags', 'matrix_id', 'preferred_channel'] + writer = csv.DictWriter(output, fieldnames=fieldnames, delimiter=';') + writer.writeheader() + + for contact in contacts: + writer.writerow({ + 'name': contact.get('name', ''), + 'email': contact.get('email', ''), + 'phone': contact.get('phone', ''), + 'role': contact.get('role', ''), + 'student_name': contact.get('student_name', ''), + 'class_name': contact.get('class_name', ''), + 'notes': contact.get('notes', ''), + 'tags': ','.join(contact.get('tags', [])), + 'matrix_id': contact.get('matrix_id', ''), + 'preferred_channel': contact.get('preferred_channel', 'email') + }) + + output.seek(0) + + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=kontakte.csv"} + ) + + +# ========================================== +# GROUPS ENDPOINTS +# ========================================== + +@router.get("/groups", response_model=List[Group]) +async def list_groups(): + """Listet alle Gruppen auf.""" + return get_groups() + + +@router.post("/groups", response_model=Group) +async def create_group(group: GroupCreate): + """Erstellt eine neue Gruppe.""" + groups = get_groups() + + now = datetime.utcnow().isoformat() + new_group = { + "id": str(uuid.uuid4()), + "created_at": now, + "updated_at": now, + **group.dict() + } + + groups.append(new_group) + save_groups(groups) + + return new_group + + +@router.put("/groups/{group_id}/members") +async def update_group_members(group_id: str, member_ids: List[str]): + """Aktualisiert die Mitglieder einer Gruppe.""" + groups = get_groups() + group_idx = next((i for i, g in enumerate(groups) if g["id"] == group_id), None) + + if group_idx is None: + raise HTTPException(status_code=404, detail="Gruppe nicht gefunden") + + groups[group_idx]["member_ids"] = member_ids + groups[group_idx]["updated_at"] = datetime.utcnow().isoformat() + + save_groups(groups) + return groups[group_idx] + + +@router.delete("/groups/{group_id}") +async def delete_group(group_id: str): + """Loescht eine Gruppe.""" + groups = get_groups() + groups = [g for g in groups if g["id"] != group_id] + save_groups(groups) + + return {"status": "deleted", "id": group_id} + + +# ========================================== +# CONVERSATIONS ENDPOINTS +# ========================================== + +@router.get("/conversations", response_model=List[Conversation]) +async def list_conversations(): + """Listet alle Konversationen auf.""" + conversations = get_conversations() + messages = get_messages() + + # Unread count und letzte Nachricht hinzufuegen + for conv in conversations: + conv_messages = [m for m in messages if m.get("conversation_id") == conv["id"]] + conv["unread_count"] = len([m for m in conv_messages if not m.get("read") and m.get("sender_id") != "self"]) + + if conv_messages: + last_msg = max(conv_messages, key=lambda m: m.get("timestamp", "")) + conv["last_message"] = last_msg.get("content", "")[:50] + conv["last_message_time"] = last_msg.get("timestamp") + + # Nach letzter Nachricht sortieren + conversations.sort(key=lambda c: c.get("last_message_time") or "", reverse=True) + + return conversations + + +@router.post("/conversations", response_model=Conversation) +async def create_conversation(contact_id: Optional[str] = None, group_id: Optional[str] = None): + """ + Erstellt eine neue Konversation. + Entweder mit einem Kontakt (1:1) oder einer Gruppe. + """ + conversations = get_conversations() + + if not contact_id and not group_id: + raise HTTPException(status_code=400, detail="Entweder contact_id oder group_id erforderlich") + + # Pruefen ob Konversation bereits existiert + if contact_id: + existing = next((c for c in conversations + if not c.get("is_group") and contact_id in c.get("participant_ids", [])), None) + if existing: + return existing + + now = datetime.utcnow().isoformat() + + if group_id: + groups = get_groups() + group = next((g for g in groups if g["id"] == group_id), None) + if not group: + raise HTTPException(status_code=404, detail="Gruppe nicht gefunden") + + new_conv = { + "id": str(uuid.uuid4()), + "name": group.get("name"), + "is_group": True, + "participant_ids": group.get("member_ids", []), + "group_id": group_id, + "created_at": now, + "updated_at": now, + "last_message": None, + "last_message_time": None, + "unread_count": 0 + } + else: + contacts = get_contacts() + contact = next((c for c in contacts if c["id"] == contact_id), None) + if not contact: + raise HTTPException(status_code=404, detail="Kontakt nicht gefunden") + + new_conv = { + "id": str(uuid.uuid4()), + "name": contact.get("name"), + "is_group": False, + "participant_ids": [contact_id], + "group_id": None, + "created_at": now, + "updated_at": now, + "last_message": None, + "last_message_time": None, + "unread_count": 0 + } + + conversations.append(new_conv) + save_conversations(conversations) + + return new_conv + + +@router.get("/conversations/{conversation_id}", response_model=Conversation) +async def get_conversation(conversation_id: str): + """Ruft eine Konversation ab.""" + conversations = get_conversations() + conv = next((c for c in conversations if c["id"] == conversation_id), None) + + if not conv: + raise HTTPException(status_code=404, detail="Konversation nicht gefunden") + + return conv + + +@router.delete("/conversations/{conversation_id}") +async def delete_conversation(conversation_id: str): + """Loescht eine Konversation und alle zugehoerigen Nachrichten.""" + conversations = get_conversations() + conversations = [c for c in conversations if c["id"] != conversation_id] + save_conversations(conversations) + + messages = get_messages() + messages = [m for m in messages if m.get("conversation_id") != conversation_id] + save_messages(messages) + + return {"status": "deleted", "id": conversation_id} + + +# ========================================== +# MESSAGES ENDPOINTS +# ========================================== + +@router.get("/conversations/{conversation_id}/messages", response_model=List[Message]) +async def list_messages( + conversation_id: str, + limit: int = Query(50, ge=1, le=200), + before: Optional[str] = Query(None, description="Load messages before this timestamp") +): + """Ruft Nachrichten einer Konversation ab.""" + messages = get_messages() + conv_messages = [m for m in messages if m.get("conversation_id") == conversation_id] + + if before: + conv_messages = [m for m in conv_messages if m.get("timestamp", "") < before] + + # Nach Zeit sortieren (neueste zuletzt) + conv_messages.sort(key=lambda m: m.get("timestamp", "")) + + return conv_messages[-limit:] + + +@router.post("/conversations/{conversation_id}/messages", response_model=Message) +async def send_message(conversation_id: str, message: MessageBase): + """ + Sendet eine Nachricht in einer Konversation. + + Wenn send_email=True und der Kontakt eine Email-Adresse hat, + wird die Nachricht auch per Email versendet. + """ + conversations = get_conversations() + conv = next((c for c in conversations if c["id"] == conversation_id), None) + + if not conv: + raise HTTPException(status_code=404, detail="Konversation nicht gefunden") + + now = datetime.utcnow().isoformat() + + new_message = { + "id": str(uuid.uuid4()), + "conversation_id": conversation_id, + "sender_id": "self", + "timestamp": now, + "read": True, + "read_at": now, + "email_sent": False, + "email_sent_at": None, + "email_error": None, + **message.dict() + } + + # Email-Versand wenn gewuenscht + if message.send_email and not conv.get("is_group"): + # Kontakt laden + participant_ids = conv.get("participant_ids", []) + if participant_ids: + contacts = get_contacts() + contact = next((c for c in contacts if c["id"] == participant_ids[0]), None) + + if contact and contact.get("email"): + try: + from email_service import email_service + + result = email_service.send_messenger_notification( + to_email=contact["email"], + to_name=contact.get("name", ""), + sender_name="BreakPilot Lehrer", # TODO: Aktuellen User-Namen verwenden + message_content=message.content + ) + + if result.success: + new_message["email_sent"] = True + new_message["email_sent_at"] = result.sent_at + else: + new_message["email_error"] = result.error + + except Exception as e: + new_message["email_error"] = str(e) + + messages = get_messages() + messages.append(new_message) + save_messages(messages) + + # Konversation aktualisieren + conv_idx = next(i for i, c in enumerate(conversations) if c["id"] == conversation_id) + conversations[conv_idx]["last_message"] = message.content[:50] + conversations[conv_idx]["last_message_time"] = now + conversations[conv_idx]["updated_at"] = now + save_conversations(conversations) + + return new_message + + +@router.put("/messages/{message_id}/read") +async def mark_message_read(message_id: str): + """Markiert eine Nachricht als gelesen.""" + messages = get_messages() + msg_idx = next((i for i, m in enumerate(messages) if m["id"] == message_id), None) + + if msg_idx is None: + raise HTTPException(status_code=404, detail="Nachricht nicht gefunden") + + messages[msg_idx]["read"] = True + messages[msg_idx]["read_at"] = datetime.utcnow().isoformat() + save_messages(messages) + + return {"status": "read", "id": message_id} + + +@router.put("/conversations/{conversation_id}/read-all") +async def mark_all_messages_read(conversation_id: str): + """Markiert alle Nachrichten einer Konversation als gelesen.""" + messages = get_messages() + now = datetime.utcnow().isoformat() + + for msg in messages: + if msg.get("conversation_id") == conversation_id and not msg.get("read"): + msg["read"] = True + msg["read_at"] = now + + save_messages(messages) + + return {"status": "all_read", "conversation_id": conversation_id} + + +# ========================================== +# TEMPLATES ENDPOINTS +# ========================================== + +DEFAULT_TEMPLATES = [ + { + "id": "1", + "name": "Terminbestaetigung", + "content": "Vielen Dank fuer Ihre Terminanfrage. Ich bestaetige den Termin am [DATUM] um [UHRZEIT]. Bitte geben Sie mir Bescheid, falls sich etwas aendern sollte.", + "category": "termin" + }, + { + "id": "2", + "name": "Hausaufgaben-Info", + "content": "Zur Information: Die Hausaufgaben fuer diese Woche umfassen [THEMA]. Abgabetermin ist [DATUM]. Bei Fragen stehe ich gerne zur Verfuegung.", + "category": "hausaufgaben" + }, + { + "id": "3", + "name": "Entschuldigung bestaetigen", + "content": "Ich bestaetige den Erhalt der Entschuldigung fuer [NAME] am [DATUM]. Die Fehlzeiten wurden entsprechend vermerkt.", + "category": "entschuldigung" + }, + { + "id": "4", + "name": "Gespraechsanfrage", + "content": "Ich wuerde gerne einen Termin fuer ein Gespraech mit Ihnen vereinbaren, um [THEMA] zu besprechen. Waeren Sie am [DATUM] um [UHRZEIT] verfuegbar?", + "category": "gespraech" + }, + { + "id": "5", + "name": "Krankmeldung bestaetigen", + "content": "Vielen Dank fuer Ihre Krankmeldung fuer [NAME]. Ich wuensche gute Besserung. Bitte reichen Sie eine schriftliche Entschuldigung nach, sobald Ihr Kind wieder gesund ist.", + "category": "krankmeldung" + } +] + + +@router.get("/templates") +async def list_templates(): + """Listet alle Nachrichtenvorlagen auf.""" + templates_file = DATA_DIR / "templates.json" + if templates_file.exists(): + templates = load_json(templates_file) + else: + templates = DEFAULT_TEMPLATES + save_json(templates_file, templates) + + return templates + + +@router.post("/templates") +async def create_template(name: str, content: str, category: str = "custom"): + """Erstellt eine neue Vorlage.""" + templates_file = DATA_DIR / "templates.json" + templates = load_json(templates_file) if templates_file.exists() else DEFAULT_TEMPLATES.copy() + + new_template = { + "id": str(uuid.uuid4()), + "name": name, + "content": content, + "category": category + } + + templates.append(new_template) + save_json(templates_file, templates) + + return new_template + + +@router.delete("/templates/{template_id}") +async def delete_template(template_id: str): + """Loescht eine Vorlage.""" + templates_file = DATA_DIR / "templates.json" + templates = load_json(templates_file) if templates_file.exists() else DEFAULT_TEMPLATES.copy() + + templates = [t for t in templates if t["id"] != template_id] + save_json(templates_file, templates) + + return {"status": "deleted", "id": template_id} + + +# ========================================== +# STATS ENDPOINT +# ========================================== + +@router.get("/stats") +async def get_messenger_stats(): + """Gibt Statistiken zum Messenger zurueck.""" + contacts = get_contacts() + conversations = get_conversations() + messages = get_messages() + groups = get_groups() + + unread_total = sum(1 for m in messages if not m.get("read") and m.get("sender_id") != "self") + + return { + "total_contacts": len(contacts), + "total_groups": len(groups), + "total_conversations": len(conversations), + "total_messages": len(messages), + "unread_messages": unread_total, + "contacts_by_role": { + role: len([c for c in contacts if c.get("role") == role]) + for role in set(c.get("role", "parent") for c in contacts) + } + } diff --git a/backend/middleware/__init__.py b/backend/middleware/__init__.py new file mode 100644 index 0000000..1497144 --- /dev/null +++ b/backend/middleware/__init__.py @@ -0,0 +1,26 @@ +""" +BreakPilot Middleware Stack + +This module provides middleware components for the FastAPI backend: +- Request-ID: Adds unique request identifiers for tracing +- Security Headers: Adds security headers to all responses +- Rate Limiter: Protects against abuse (Valkey-based) +- PII Redactor: Redacts sensitive data from logs +- Input Gate: Validates request body size and content types +""" + +from .request_id import RequestIDMiddleware, get_request_id +from .security_headers import SecurityHeadersMiddleware +from .rate_limiter import RateLimiterMiddleware +from .pii_redactor import PIIRedactor, redact_pii +from .input_gate import InputGateMiddleware + +__all__ = [ + "RequestIDMiddleware", + "get_request_id", + "SecurityHeadersMiddleware", + "RateLimiterMiddleware", + "PIIRedactor", + "redact_pii", + "InputGateMiddleware", +] diff --git a/backend/middleware/input_gate.py b/backend/middleware/input_gate.py new file mode 100644 index 0000000..38f64cc --- /dev/null +++ b/backend/middleware/input_gate.py @@ -0,0 +1,260 @@ +""" +Input Validation Gate Middleware + +Validates incoming requests for: +- Request body size limits +- Content-Type validation +- File upload limits +- Malicious content detection + +Usage: + from middleware import InputGateMiddleware + + app.add_middleware( + InputGateMiddleware, + max_body_size=10 * 1024 * 1024, # 10MB + allowed_content_types=["application/json", "multipart/form-data"], + ) +""" + +import os +from dataclasses import dataclass, field +from typing import List, Optional, Set + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + + +@dataclass +class InputGateConfig: + """Configuration for input validation.""" + + # Maximum request body size (default: 10MB) + max_body_size: int = 10 * 1024 * 1024 + + # Allowed content types + allowed_content_types: Set[str] = field(default_factory=lambda: { + "application/json", + "application/x-www-form-urlencoded", + "multipart/form-data", + "text/plain", + }) + + # File upload specific limits + max_file_size: int = 50 * 1024 * 1024 # 50MB for file uploads + allowed_file_types: Set[str] = field(default_factory=lambda: { + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "text/csv", + }) + + # Blocked file extensions (potential malware) + blocked_extensions: Set[str] = field(default_factory=lambda: { + ".exe", ".bat", ".cmd", ".com", ".msi", + ".dll", ".scr", ".pif", ".vbs", ".js", + ".jar", ".sh", ".ps1", ".app", + }) + + # Paths that allow larger uploads (e.g., file upload endpoints) + large_upload_paths: List[str] = field(default_factory=lambda: [ + "/api/files/upload", + "/api/documents/upload", + "/api/attachments", + ]) + + # Paths excluded from validation + excluded_paths: List[str] = field(default_factory=lambda: [ + "/health", + "/metrics", + ]) + + # Enable strict content type checking + strict_content_type: bool = True + + +class InputGateMiddleware(BaseHTTPMiddleware): + """ + Middleware that validates incoming request bodies and content types. + + Protects against: + - Oversized request bodies + - Invalid content types + - Potentially malicious file uploads + """ + + def __init__( + self, + app, + config: Optional[InputGateConfig] = None, + max_body_size: Optional[int] = None, + allowed_content_types: Optional[Set[str]] = None, + ): + super().__init__(app) + + self.config = config or InputGateConfig() + + # Apply overrides + if max_body_size is not None: + self.config.max_body_size = max_body_size + if allowed_content_types is not None: + self.config.allowed_content_types = allowed_content_types + + # Auto-configure from environment + env_max_size = os.getenv("MAX_REQUEST_BODY_SIZE") + if env_max_size: + try: + self.config.max_body_size = int(env_max_size) + except ValueError: + pass + + def _is_excluded_path(self, path: str) -> bool: + """Check if path is excluded from validation.""" + return path in self.config.excluded_paths + + def _is_large_upload_path(self, path: str) -> bool: + """Check if path allows larger uploads.""" + for upload_path in self.config.large_upload_paths: + if path.startswith(upload_path): + return True + return False + + def _get_max_size(self, path: str) -> int: + """Get the maximum allowed body size for this path.""" + if self._is_large_upload_path(path): + return self.config.max_file_size + return self.config.max_body_size + + def _validate_content_type(self, content_type: Optional[str]) -> tuple[bool, str]: + """ + Validate the content type. + + Returns: + Tuple of (is_valid, error_message) + """ + if not content_type: + # Allow requests without content type (e.g., GET requests) + return True, "" + + # Extract base content type (remove charset, boundary, etc.) + base_type = content_type.split(";")[0].strip().lower() + + if base_type not in self.config.allowed_content_types: + return False, f"Content-Type '{base_type}' is not allowed" + + return True, "" + + def _check_blocked_extension(self, filename: str) -> bool: + """Check if filename has a blocked extension.""" + if not filename: + return False + + lower_filename = filename.lower() + for ext in self.config.blocked_extensions: + if lower_filename.endswith(ext): + return True + return False + + async def dispatch(self, request: Request, call_next) -> Response: + # Skip excluded paths + if self._is_excluded_path(request.url.path): + return await call_next(request) + + # Skip validation for GET, HEAD, OPTIONS requests + if request.method in ("GET", "HEAD", "OPTIONS"): + return await call_next(request) + + # Validate content type for requests with body + content_type = request.headers.get("Content-Type") + if self.config.strict_content_type: + is_valid, error_msg = self._validate_content_type(content_type) + if not is_valid: + return JSONResponse( + status_code=415, + content={ + "error": "unsupported_media_type", + "message": error_msg, + }, + ) + + # Check Content-Length header + content_length = request.headers.get("Content-Length") + if content_length: + try: + length = int(content_length) + max_size = self._get_max_size(request.url.path) + + if length > max_size: + return JSONResponse( + status_code=413, + content={ + "error": "payload_too_large", + "message": f"Request body exceeds maximum size of {max_size} bytes", + "max_size": max_size, + }, + ) + except ValueError: + return JSONResponse( + status_code=400, + content={ + "error": "invalid_content_length", + "message": "Invalid Content-Length header", + }, + ) + + # For multipart uploads, check for blocked file extensions + if content_type and "multipart/form-data" in content_type: + # Note: Full file validation would require reading the body + # which we avoid in middleware for performance reasons. + # Detailed file validation should happen in the handler. + pass + + # Process request + return await call_next(request) + + +def validate_file_upload( + filename: str, + content_type: str, + size: int, + config: Optional[InputGateConfig] = None, +) -> tuple[bool, str]: + """ + Validate a file upload. + + Use this in upload handlers for detailed validation. + + Args: + filename: Original filename + content_type: MIME type of the file + size: File size in bytes + config: Optional custom configuration + + Returns: + Tuple of (is_valid, error_message) + """ + cfg = config or InputGateConfig() + + # Check size + if size > cfg.max_file_size: + return False, f"File size exceeds maximum of {cfg.max_file_size} bytes" + + # Check extension + if filename: + lower_filename = filename.lower() + for ext in cfg.blocked_extensions: + if lower_filename.endswith(ext): + return False, f"File extension '{ext}' is not allowed" + + # Check content type + if content_type and content_type not in cfg.allowed_file_types: + return False, f"File type '{content_type}' is not allowed" + + return True, "" diff --git a/backend/middleware/pii_redactor.py b/backend/middleware/pii_redactor.py new file mode 100644 index 0000000..654d237 --- /dev/null +++ b/backend/middleware/pii_redactor.py @@ -0,0 +1,316 @@ +""" +PII Redactor + +Redacts Personally Identifiable Information (PII) from logs and responses. +Essential for DSGVO/GDPR compliance in BreakPilot. + +Redacted data types: +- Email addresses +- IP addresses +- German phone numbers +- Names (when identified) +- Student IDs +- Credit card numbers +- IBAN numbers + +Usage: + from middleware import PIIRedactor, redact_pii + + # Use in logging + logger.info(redact_pii(f"User {email} logged in from {ip}")) + + # Configure redactor + redactor = PIIRedactor(patterns=["email", "ip", "phone"]) + safe_message = redactor.redact(sensitive_message) +""" + +import re +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Pattern, Set + + +@dataclass +class PIIPattern: + """Definition of a PII pattern.""" + name: str + pattern: Pattern + replacement: str + + +# Pre-compiled regex patterns for common PII +PII_PATTERNS: Dict[str, PIIPattern] = { + "email": PIIPattern( + name="email", + pattern=re.compile( + r'\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b', + re.IGNORECASE + ), + replacement="[EMAIL_REDACTED]", + ), + "ip_v4": PIIPattern( + name="ip_v4", + pattern=re.compile( + r'\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b' + ), + replacement="[IP_REDACTED]", + ), + "ip_v6": PIIPattern( + name="ip_v6", + pattern=re.compile( + r'\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b' + ), + replacement="[IP_REDACTED]", + ), + "phone_de": PIIPattern( + name="phone_de", + pattern=re.compile( + r'(? str: + """ + Redact PII from the given text. + + Args: + text: The text to redact PII from + + Returns: + Text with PII replaced by redaction markers + """ + if not text: + return text + + result = text + for pattern in self._active_patterns: + if self.preserve_format: + # Replace with same-length placeholder + def replace_preserve(match): + length = len(match.group()) + return "*" * length + result = pattern.pattern.sub(replace_preserve, result) + else: + result = pattern.pattern.sub(pattern.replacement, result) + + return result + + def contains_pii(self, text: str) -> bool: + """ + Check if text contains any PII. + + Args: + text: The text to check + + Returns: + True if PII is detected + """ + if not text: + return False + + for pattern in self._active_patterns: + if pattern.pattern.search(text): + return True + return False + + def find_pii(self, text: str) -> List[Dict[str, str]]: + """ + Find all PII in text with their types. + + Args: + text: The text to search + + Returns: + List of dicts with 'type' and 'match' keys + """ + if not text: + return [] + + findings = [] + for pattern in self._active_patterns: + for match in pattern.pattern.finditer(text): + findings.append({ + "type": pattern.name, + "match": match.group(), + "start": match.start(), + "end": match.end(), + }) + + return findings + + +# Module-level default redactor instance +_default_redactor: Optional[PIIRedactor] = None + + +def get_default_redactor() -> PIIRedactor: + """Get or create the default redactor instance.""" + global _default_redactor + if _default_redactor is None: + _default_redactor = PIIRedactor() + return _default_redactor + + +def redact_pii(text: str) -> str: + """ + Convenience function to redact PII using the default redactor. + + Args: + text: Text to redact + + Returns: + Redacted text + + Example: + logger.info(redact_pii(f"User {email} logged in")) + """ + return get_default_redactor().redact(text) + + +class PIIRedactingLogFilter: + """ + Logging filter that automatically redacts PII from log messages. + + Usage: + import logging + + handler = logging.StreamHandler() + handler.addFilter(PIIRedactingLogFilter()) + logger = logging.getLogger() + logger.addHandler(handler) + """ + + def __init__(self, redactor: Optional[PIIRedactor] = None): + self.redactor = redactor or get_default_redactor() + + def filter(self, record): + # Redact the message + if record.msg: + record.msg = self.redactor.redact(str(record.msg)) + + # Redact args if present + if record.args: + if isinstance(record.args, dict): + record.args = { + k: self.redactor.redact(str(v)) if isinstance(v, str) else v + for k, v in record.args.items() + } + elif isinstance(record.args, tuple): + record.args = tuple( + self.redactor.redact(str(v)) if isinstance(v, str) else v + for v in record.args + ) + + return True + + +def create_safe_dict(data: dict, redactor: Optional[PIIRedactor] = None) -> dict: + """ + Create a copy of a dictionary with PII redacted. + + Args: + data: Dictionary to redact + redactor: Optional custom redactor + + Returns: + New dictionary with redacted values + """ + r = redactor or get_default_redactor() + + def redact_value(value): + if isinstance(value, str): + return r.redact(value) + elif isinstance(value, dict): + return create_safe_dict(value, r) + elif isinstance(value, list): + return [redact_value(v) for v in value] + return value + + return {k: redact_value(v) for k, v in data.items()} diff --git a/backend/middleware/rate_limiter.py b/backend/middleware/rate_limiter.py new file mode 100644 index 0000000..1513d13 --- /dev/null +++ b/backend/middleware/rate_limiter.py @@ -0,0 +1,363 @@ +""" +Rate Limiter Middleware + +Implements distributed rate limiting using Valkey (Redis-fork). +Supports IP-based, user-based, and endpoint-specific rate limits. + +Features: +- Sliding window rate limiting +- IP-based limits for unauthenticated requests +- User-based limits for authenticated requests +- Stricter limits for auth endpoints (anti-brute-force) +- IP whitelist/blacklist support +- Graceful fallback when Valkey is unavailable + +Usage: + from middleware import RateLimiterMiddleware + + app.add_middleware( + RateLimiterMiddleware, + valkey_url="redis://localhost:6379", + ip_limit=100, + user_limit=500, + ) +""" + +from __future__ import annotations + +import asyncio +import hashlib +import os +import time +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Set + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +# Try to import redis (valkey-compatible) +try: + import redis.asyncio as redis + REDIS_AVAILABLE = True +except ImportError: + REDIS_AVAILABLE = False + redis = None + + +@dataclass +class RateLimitConfig: + """Configuration for rate limiting.""" + + # Valkey/Redis connection + valkey_url: str = "redis://localhost:6379" + + # Default limits (requests per minute) + ip_limit: int = 100 + user_limit: int = 500 + + # Stricter limits for auth endpoints + auth_limit: int = 20 + auth_endpoints: List[str] = field(default_factory=lambda: [ + "/api/auth/login", + "/api/auth/register", + "/api/auth/password-reset", + "/api/auth/forgot-password", + ]) + + # Window size in seconds + window_size: int = 60 + + # IP whitelist (never rate limited) + ip_whitelist: Set[str] = field(default_factory=lambda: { + "127.0.0.1", + "::1", + }) + + # IP blacklist (always blocked) + ip_blacklist: Set[str] = field(default_factory=set) + + # Skip internal Docker network + skip_internal_network: bool = True + + # Excluded paths + excluded_paths: List[str] = field(default_factory=lambda: [ + "/health", + "/metrics", + "/api/health", + ]) + + # Fallback to in-memory when Valkey is unavailable + fallback_enabled: bool = True + + # Key prefix for rate limit keys + key_prefix: str = "ratelimit" + + +class InMemoryRateLimiter: + """Fallback in-memory rate limiter when Valkey is unavailable.""" + + def __init__(self): + self._counts: Dict[str, List[float]] = {} + self._lock = asyncio.Lock() + + async def check_rate_limit(self, key: str, limit: int, window: int) -> tuple[bool, int]: + """ + Check if rate limit is exceeded. + + Returns: + Tuple of (is_allowed, remaining_requests) + """ + async with self._lock: + now = time.time() + window_start = now - window + + # Get or create entry + if key not in self._counts: + self._counts[key] = [] + + # Remove old entries + self._counts[key] = [t for t in self._counts[key] if t > window_start] + + # Check limit + current_count = len(self._counts[key]) + if current_count >= limit: + return False, 0 + + # Add new request + self._counts[key].append(now) + return True, limit - current_count - 1 + + async def cleanup(self): + """Remove expired entries.""" + async with self._lock: + now = time.time() + for key in list(self._counts.keys()): + self._counts[key] = [t for t in self._counts[key] if t > now - 3600] + if not self._counts[key]: + del self._counts[key] + + +class RateLimiterMiddleware(BaseHTTPMiddleware): + """ + Middleware that implements distributed rate limiting. + + Uses Valkey (Redis-fork) for distributed state, with fallback + to in-memory rate limiting when Valkey is unavailable. + """ + + def __init__( + self, + app, + config: Optional[RateLimitConfig] = None, + # Individual overrides + valkey_url: Optional[str] = None, + ip_limit: Optional[int] = None, + user_limit: Optional[int] = None, + auth_limit: Optional[int] = None, + ): + super().__init__(app) + + self.config = config or RateLimitConfig() + + # Apply overrides + if valkey_url is not None: + self.config.valkey_url = valkey_url + if ip_limit is not None: + self.config.ip_limit = ip_limit + if user_limit is not None: + self.config.user_limit = user_limit + if auth_limit is not None: + self.config.auth_limit = auth_limit + + # Auto-configure from environment + self.config.valkey_url = os.getenv("VALKEY_URL", self.config.valkey_url) + + # Initialize Valkey client + self._redis: Optional[redis.Redis] = None + self._fallback = InMemoryRateLimiter() + self._valkey_available = False + + async def _get_redis(self) -> Optional[redis.Redis]: + """Get or create Redis/Valkey connection.""" + if not REDIS_AVAILABLE: + return None + + if self._redis is None: + try: + self._redis = redis.from_url( + self.config.valkey_url, + decode_responses=True, + socket_timeout=1.0, + socket_connect_timeout=1.0, + ) + await self._redis.ping() + self._valkey_available = True + except Exception: + self._valkey_available = False + self._redis = None + + return self._redis + + def _get_client_ip(self, request: Request) -> str: + """Extract client IP from request.""" + # Check X-Forwarded-For header + xff = request.headers.get("X-Forwarded-For") + if xff: + return xff.split(",")[0].strip() + + # Check X-Real-IP header + xri = request.headers.get("X-Real-IP") + if xri: + return xri + + # Fall back to direct client IP + if request.client: + return request.client.host + return "unknown" + + def _get_user_id(self, request: Request) -> Optional[str]: + """Extract user ID from request state (set by session middleware).""" + if hasattr(request.state, "session") and request.state.session: + return getattr(request.state.session, "user_id", None) + return None + + def _is_internal_network(self, ip: str) -> bool: + """Check if IP is from internal Docker network.""" + return ( + ip.startswith("172.") or + ip.startswith("10.") or + ip.startswith("192.168.") + ) + + def _get_rate_limit(self, request: Request) -> int: + """Determine the rate limit for this request.""" + path = request.url.path + + # Auth endpoints get stricter limits + for auth_path in self.config.auth_endpoints: + if path.startswith(auth_path): + return self.config.auth_limit + + # Authenticated users get higher limits + if self._get_user_id(request): + return self.config.user_limit + + # Default IP-based limit + return self.config.ip_limit + + def _get_rate_limit_key(self, request: Request) -> str: + """Generate the rate limit key for this request.""" + # Use user ID if authenticated + user_id = self._get_user_id(request) + if user_id: + identifier = f"user:{user_id}" + else: + ip = self._get_client_ip(request) + # Hash IP for privacy + ip_hash = hashlib.sha256(ip.encode()).hexdigest()[:16] + identifier = f"ip:{ip_hash}" + + # Include path for endpoint-specific limits + path = request.url.path + for auth_path in self.config.auth_endpoints: + if path.startswith(auth_path): + return f"{self.config.key_prefix}:auth:{identifier}" + + return f"{self.config.key_prefix}:{identifier}" + + async def _check_rate_limit_valkey( + self, key: str, limit: int, window: int + ) -> tuple[bool, int]: + """Check rate limit using Valkey.""" + r = await self._get_redis() + if not r: + return await self._fallback.check_rate_limit(key, limit, window) + + try: + # Use sliding window with sorted set + now = time.time() + window_start = now - window + + pipe = r.pipeline() + # Remove old entries + pipe.zremrangebyscore(key, "-inf", window_start) + # Count current entries + pipe.zcard(key) + # Add new entry + pipe.zadd(key, {str(now): now}) + # Set expiry + pipe.expire(key, window + 10) + + results = await pipe.execute() + current_count = results[1] + + if current_count >= limit: + return False, 0 + + return True, limit - current_count - 1 + + except Exception: + # Fallback to in-memory + self._valkey_available = False + return await self._fallback.check_rate_limit(key, limit, window) + + async def dispatch(self, request: Request, call_next) -> Response: + # Skip excluded paths + if request.url.path in self.config.excluded_paths: + return await call_next(request) + + # Get client IP + ip = self._get_client_ip(request) + + # Check blacklist + if ip in self.config.ip_blacklist: + return JSONResponse( + status_code=403, + content={ + "error": "ip_blocked", + "message": "Your IP address has been blocked.", + }, + ) + + # Skip whitelist + if ip in self.config.ip_whitelist: + return await call_next(request) + + # Skip internal network + if self.config.skip_internal_network and self._is_internal_network(ip): + return await call_next(request) + + # Get rate limit parameters + limit = self._get_rate_limit(request) + key = self._get_rate_limit_key(request) + window = self.config.window_size + + # Check rate limit + allowed, remaining = await self._check_rate_limit_valkey(key, limit, window) + + if not allowed: + return JSONResponse( + status_code=429, + content={ + "error": "rate_limit_exceeded", + "message": "Too many requests. Please try again later.", + "retry_after": window, + }, + headers={ + "Retry-After": str(window), + "X-RateLimit-Limit": str(limit), + "X-RateLimit-Remaining": "0", + "X-RateLimit-Reset": str(int(time.time()) + window), + }, + ) + + # Process request + response = await call_next(request) + + # Add rate limit headers + response.headers["X-RateLimit-Limit"] = str(limit) + response.headers["X-RateLimit-Remaining"] = str(remaining) + response.headers["X-RateLimit-Reset"] = str(int(time.time()) + window) + + return response diff --git a/backend/middleware/request_id.py b/backend/middleware/request_id.py new file mode 100644 index 0000000..3a1c6f2 --- /dev/null +++ b/backend/middleware/request_id.py @@ -0,0 +1,138 @@ +""" +Request-ID Middleware + +Generates and propagates unique request identifiers for distributed tracing. +Supports both X-Request-ID and X-Correlation-ID headers. + +Usage: + from middleware import RequestIDMiddleware, get_request_id + + app.add_middleware(RequestIDMiddleware) + + @app.get("/api/example") + async def example(): + request_id = get_request_id() + logger.info(f"Processing request", extra={"request_id": request_id}) +""" + +import uuid +from contextvars import ContextVar +from typing import Optional + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +# Context variable to store request ID across async calls +_request_id_ctx: ContextVar[Optional[str]] = ContextVar("request_id", default=None) + +# Header names +REQUEST_ID_HEADER = "X-Request-ID" +CORRELATION_ID_HEADER = "X-Correlation-ID" + + +def get_request_id() -> Optional[str]: + """ + Get the current request ID from context. + + Returns: + The request ID string or None if not in a request context. + + Example: + request_id = get_request_id() + logger.info("Processing", extra={"request_id": request_id}) + """ + return _request_id_ctx.get() + + +def set_request_id(request_id: str) -> None: + """ + Set the request ID in the current context. + + Args: + request_id: The request ID to set + """ + _request_id_ctx.set(request_id) + + +def generate_request_id() -> str: + """ + Generate a new unique request ID. + + Returns: + A UUID4 string + """ + return str(uuid.uuid4()) + + +class RequestIDMiddleware(BaseHTTPMiddleware): + """ + Middleware that generates and propagates request IDs. + + For each incoming request: + 1. Check for existing X-Request-ID or X-Correlation-ID header + 2. If not present, generate a new UUID + 3. Store in context for use by handlers and logging + 4. Add to response headers + + Attributes: + header_name: The primary header name to use (default: X-Request-ID) + generator: Function to generate new IDs (default: uuid4) + """ + + def __init__( + self, + app, + header_name: str = REQUEST_ID_HEADER, + generator=generate_request_id, + ): + super().__init__(app) + self.header_name = header_name + self.generator = generator + + async def dispatch(self, request: Request, call_next) -> Response: + # Try to get existing request ID from headers + request_id = ( + request.headers.get(REQUEST_ID_HEADER) + or request.headers.get(CORRELATION_ID_HEADER) + ) + + # Generate new ID if not provided + if not request_id: + request_id = self.generator() + + # Store in context for logging and handlers + set_request_id(request_id) + + # Store in request state for direct access + request.state.request_id = request_id + + # Process request + response = await call_next(request) + + # Add request ID to response headers + response.headers[REQUEST_ID_HEADER] = request_id + response.headers[CORRELATION_ID_HEADER] = request_id + + return response + + +class RequestIDLogFilter: + """ + Logging filter that adds request_id to log records. + + Usage: + import logging + + handler = logging.StreamHandler() + handler.addFilter(RequestIDLogFilter()) + + formatter = logging.Formatter( + '%(asctime)s [%(request_id)s] %(levelname)s %(message)s' + ) + handler.setFormatter(formatter) + """ + + def filter(self, record): + record.request_id = get_request_id() or "no-request-id" + return True diff --git a/backend/middleware/security_headers.py b/backend/middleware/security_headers.py new file mode 100644 index 0000000..44755e2 --- /dev/null +++ b/backend/middleware/security_headers.py @@ -0,0 +1,202 @@ +""" +Security Headers Middleware + +Adds security headers to all HTTP responses to protect against common attacks. + +Headers added: +- X-Content-Type-Options: nosniff +- X-Frame-Options: DENY +- X-XSS-Protection: 1; mode=block +- Strict-Transport-Security (HSTS) +- Content-Security-Policy +- Referrer-Policy +- Permissions-Policy + +Usage: + from middleware import SecurityHeadersMiddleware + + app.add_middleware(SecurityHeadersMiddleware) + + # Or with custom configuration: + app.add_middleware( + SecurityHeadersMiddleware, + hsts_enabled=True, + csp_policy="default-src 'self'", + ) +""" + +import os +from dataclasses import dataclass, field +from typing import Dict, List, Optional + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + + +@dataclass +class SecurityHeadersConfig: + """Configuration for security headers.""" + + # X-Content-Type-Options + content_type_options: str = "nosniff" + + # X-Frame-Options + frame_options: str = "DENY" + + # X-XSS-Protection (legacy, but still useful for older browsers) + xss_protection: str = "1; mode=block" + + # Strict-Transport-Security + hsts_enabled: bool = True + hsts_max_age: int = 31536000 # 1 year + hsts_include_subdomains: bool = True + hsts_preload: bool = False + + # Content-Security-Policy + csp_enabled: bool = True + csp_policy: str = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:; frame-ancestors 'none'" + + # Referrer-Policy + referrer_policy: str = "strict-origin-when-cross-origin" + + # Permissions-Policy (formerly Feature-Policy) + permissions_policy: str = "geolocation=(), microphone=(), camera=()" + + # Cross-Origin headers + cross_origin_opener_policy: str = "same-origin" + cross_origin_embedder_policy: str = "require-corp" + cross_origin_resource_policy: str = "same-origin" + + # Development mode (relaxes some restrictions) + development_mode: bool = False + + # Excluded paths (e.g., for health checks) + excluded_paths: List[str] = field(default_factory=lambda: ["/health", "/metrics"]) + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + """ + Middleware that adds security headers to all responses. + + Attributes: + config: SecurityHeadersConfig instance + """ + + def __init__( + self, + app, + config: Optional[SecurityHeadersConfig] = None, + # Individual overrides for convenience + hsts_enabled: Optional[bool] = None, + csp_policy: Optional[str] = None, + csp_enabled: Optional[bool] = None, + development_mode: Optional[bool] = None, + ): + super().__init__(app) + + # Use provided config or create default + self.config = config or SecurityHeadersConfig() + + # Apply individual overrides + if hsts_enabled is not None: + self.config.hsts_enabled = hsts_enabled + if csp_policy is not None: + self.config.csp_policy = csp_policy + if csp_enabled is not None: + self.config.csp_enabled = csp_enabled + if development_mode is not None: + self.config.development_mode = development_mode + + # Auto-detect development mode from environment + if development_mode is None: + env = os.getenv("ENVIRONMENT", "development") + self.config.development_mode = env.lower() in ("development", "dev", "local") + + def _build_hsts_header(self) -> str: + """Build the Strict-Transport-Security header value.""" + parts = [f"max-age={self.config.hsts_max_age}"] + if self.config.hsts_include_subdomains: + parts.append("includeSubDomains") + if self.config.hsts_preload: + parts.append("preload") + return "; ".join(parts) + + def _get_headers(self) -> Dict[str, str]: + """Build the security headers dictionary.""" + headers = {} + + # Always add these headers + headers["X-Content-Type-Options"] = self.config.content_type_options + headers["X-Frame-Options"] = self.config.frame_options + headers["X-XSS-Protection"] = self.config.xss_protection + headers["Referrer-Policy"] = self.config.referrer_policy + + # HSTS (only in production or if explicitly enabled) + if self.config.hsts_enabled and not self.config.development_mode: + headers["Strict-Transport-Security"] = self._build_hsts_header() + + # Content-Security-Policy + if self.config.csp_enabled: + headers["Content-Security-Policy"] = self.config.csp_policy + + # Permissions-Policy + if self.config.permissions_policy: + headers["Permissions-Policy"] = self.config.permissions_policy + + # Cross-Origin headers (relaxed in development) + if not self.config.development_mode: + headers["Cross-Origin-Opener-Policy"] = self.config.cross_origin_opener_policy + # Note: COEP can break loading of external resources, be careful + # headers["Cross-Origin-Embedder-Policy"] = self.config.cross_origin_embedder_policy + headers["Cross-Origin-Resource-Policy"] = self.config.cross_origin_resource_policy + + return headers + + async def dispatch(self, request: Request, call_next) -> Response: + # Skip security headers for excluded paths + if request.url.path in self.config.excluded_paths: + return await call_next(request) + + # Process request + response = await call_next(request) + + # Add security headers + for header_name, header_value in self._get_headers().items(): + response.headers[header_name] = header_value + + return response + + +def get_default_csp_for_environment(environment: str) -> str: + """ + Get a sensible default CSP for the given environment. + + Args: + environment: "development", "staging", or "production" + + Returns: + CSP policy string + """ + if environment.lower() in ("development", "dev", "local"): + # Relaxed CSP for development + return ( + "default-src 'self' localhost:* ws://localhost:*; " + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: https: blob:; " + "font-src 'self' data:; " + "connect-src 'self' localhost:* ws://localhost:* https:; " + "frame-ancestors 'self'" + ) + else: + # Strict CSP for production + return ( + "default-src 'self'; " + "script-src 'self' 'unsafe-inline'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: https:; " + "font-src 'self' data:; " + "connect-src 'self' https://breakpilot.app https://*.breakpilot.app; " + "frame-ancestors 'none'" + ) diff --git a/backend/middleware_admin_api.py b/backend/middleware_admin_api.py new file mode 100644 index 0000000..027bbe0 --- /dev/null +++ b/backend/middleware_admin_api.py @@ -0,0 +1,535 @@ +""" +Middleware Admin API Endpoints for BreakPilot + +Provides admin functionality for managing middleware configurations: +- View and update middleware settings +- Rate limiting IP whitelist/blacklist +- View middleware events/statistics +""" + +import os +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from pydantic import BaseModel, Field + +# Database connection +import asyncpg + +# Session middleware for authentication +from session import require_permission, Session + + +router = APIRouter(prefix="/api/admin/middleware", tags=["middleware-admin"]) + +# Database URL +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql://breakpilot:breakpilot@localhost:5432/breakpilot_dev" +) + +# Lazy database pool +_db_pool: Optional[asyncpg.Pool] = None + + +async def get_db_pool() -> asyncpg.Pool: + """Get or create database connection pool.""" + global _db_pool + if _db_pool is None: + _db_pool = await asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=10) + return _db_pool + + +# ============================================== +# Request/Response Models +# ============================================== + + +class MiddlewareConfigResponse(BaseModel): + """Response model for middleware configuration.""" + id: str + middleware_name: str + enabled: bool + config: Dict[str, Any] + updated_at: Optional[datetime] = None + + +class MiddlewareConfigUpdateRequest(BaseModel): + """Request model for updating middleware configuration.""" + enabled: Optional[bool] = None + config: Optional[Dict[str, Any]] = None + + +class RateLimitIPRequest(BaseModel): + """Request model for adding IP to whitelist/blacklist.""" + ip_address: str + list_type: str = Field(..., pattern="^(whitelist|blacklist)$") + reason: Optional[str] = None + expires_at: Optional[datetime] = None + + +class RateLimitIPResponse(BaseModel): + """Response model for rate limit IP entry.""" + id: str + ip_address: str + list_type: str + reason: Optional[str] = None + expires_at: Optional[datetime] = None + created_at: datetime + + +class MiddlewareEventResponse(BaseModel): + """Response model for middleware event.""" + id: str + middleware_name: str + event_type: str + ip_address: Optional[str] = None + user_id: Optional[str] = None + request_path: Optional[str] = None + request_method: Optional[str] = None + details: Optional[Dict[str, Any]] = None + created_at: datetime + + +class MiddlewareStatsResponse(BaseModel): + """Response model for middleware statistics.""" + middleware_name: str + total_events: int + events_last_hour: int + events_last_24h: int + top_event_types: List[Dict[str, Any]] + top_ips: List[Dict[str, Any]] + + +# ============================================== +# Middleware Configuration Endpoints +# ============================================== + + +@router.get("", response_model=List[MiddlewareConfigResponse]) +async def list_middleware_configs( + session: Session = Depends(require_permission("settings:read")), +): + """ + List all middleware configurations. + + Requires: settings:read permission + """ + pool = await get_db_pool() + + rows = await pool.fetch(""" + SELECT id, middleware_name, enabled, config, updated_at + FROM middleware_config + ORDER BY middleware_name + """) + + return [ + MiddlewareConfigResponse( + id=str(row["id"]), + middleware_name=row["middleware_name"], + enabled=row["enabled"], + config=row["config"] or {}, + updated_at=row["updated_at"], + ) + for row in rows + ] + + +@router.get("/{name}", response_model=MiddlewareConfigResponse) +async def get_middleware_config( + name: str, + session: Session = Depends(require_permission("settings:read")), +): + """ + Get configuration for a specific middleware. + + Requires: settings:read permission + """ + pool = await get_db_pool() + + row = await pool.fetchrow(""" + SELECT id, middleware_name, enabled, config, updated_at + FROM middleware_config + WHERE middleware_name = $1 + """, name) + + if not row: + raise HTTPException(status_code=404, detail=f"Middleware '{name}' not found") + + return MiddlewareConfigResponse( + id=str(row["id"]), + middleware_name=row["middleware_name"], + enabled=row["enabled"], + config=row["config"] or {}, + updated_at=row["updated_at"], + ) + + +@router.put("/{name}", response_model=MiddlewareConfigResponse) +async def update_middleware_config( + name: str, + data: MiddlewareConfigUpdateRequest, + session: Session = Depends(require_permission("settings:write")), +): + """ + Update configuration for a specific middleware. + + Requires: settings:write permission + """ + pool = await get_db_pool() + + # Build update query dynamically + updates = [] + params = [name] + param_idx = 2 + + if data.enabled is not None: + updates.append(f"enabled = ${param_idx}") + params.append(data.enabled) + param_idx += 1 + + if data.config is not None: + updates.append(f"config = ${param_idx}") + params.append(data.config) + param_idx += 1 + + if not updates: + raise HTTPException(status_code=400, detail="No fields to update") + + updates.append("updated_at = NOW()") + updates.append(f"updated_by = ${param_idx}") + params.append(session.user_id) + + query = f""" + UPDATE middleware_config + SET {", ".join(updates)} + WHERE middleware_name = $1 + RETURNING id, middleware_name, enabled, config, updated_at + """ + + row = await pool.fetchrow(query, *params) + + if not row: + raise HTTPException(status_code=404, detail=f"Middleware '{name}' not found") + + # Log the configuration change + await pool.execute(""" + INSERT INTO middleware_events (middleware_name, event_type, user_id, details) + VALUES ($1, 'config_changed', $2, $3) + """, name, session.user_id, {"changes": data.dict(exclude_none=True)}) + + return MiddlewareConfigResponse( + id=str(row["id"]), + middleware_name=row["middleware_name"], + enabled=row["enabled"], + config=row["config"] or {}, + updated_at=row["updated_at"], + ) + + +# ============================================== +# Rate Limiting IP Management +# ============================================== + + +@router.get("/rate-limit/ip-list", response_model=List[RateLimitIPResponse]) +async def list_rate_limit_ips( + list_type: Optional[str] = Query(None, pattern="^(whitelist|blacklist)$"), + session: Session = Depends(require_permission("settings:read")), +): + """ + List all IPs in whitelist/blacklist. + + Requires: settings:read permission + """ + pool = await get_db_pool() + + if list_type: + rows = await pool.fetch(""" + SELECT id, ip_address::text, list_type, reason, expires_at, created_at + FROM rate_limit_ip_list + WHERE list_type = $1 + ORDER BY created_at DESC + """, list_type) + else: + rows = await pool.fetch(""" + SELECT id, ip_address::text, list_type, reason, expires_at, created_at + FROM rate_limit_ip_list + ORDER BY list_type, created_at DESC + """) + + return [ + RateLimitIPResponse( + id=str(row["id"]), + ip_address=row["ip_address"], + list_type=row["list_type"], + reason=row["reason"], + expires_at=row["expires_at"], + created_at=row["created_at"], + ) + for row in rows + ] + + +@router.post("/rate-limit/ip-list", response_model=RateLimitIPResponse, status_code=201) +async def add_rate_limit_ip( + data: RateLimitIPRequest, + session: Session = Depends(require_permission("settings:write")), +): + """ + Add IP to whitelist or blacklist. + + Requires: settings:write permission + """ + pool = await get_db_pool() + + try: + row = await pool.fetchrow(""" + INSERT INTO rate_limit_ip_list (ip_address, list_type, reason, expires_at, created_by) + VALUES ($1::inet, $2, $3, $4, $5) + RETURNING id, ip_address::text, list_type, reason, expires_at, created_at + """, data.ip_address, data.list_type, data.reason, data.expires_at, session.user_id) + except asyncpg.UniqueViolationError: + raise HTTPException( + status_code=409, + detail=f"IP {data.ip_address} already exists in {data.list_type}" + ) + + # Log the event + await pool.execute(""" + INSERT INTO middleware_events (middleware_name, event_type, ip_address, user_id, details) + VALUES ('rate_limiter', $1, $2::inet, $3, $4) + """, f"ip_{data.list_type}_add", data.ip_address, session.user_id, + {"reason": data.reason}) + + return RateLimitIPResponse( + id=str(row["id"]), + ip_address=row["ip_address"], + list_type=row["list_type"], + reason=row["reason"], + expires_at=row["expires_at"], + created_at=row["created_at"], + ) + + +@router.delete("/rate-limit/ip-list/{ip_id}") +async def remove_rate_limit_ip( + ip_id: str, + session: Session = Depends(require_permission("settings:write")), +): + """ + Remove IP from whitelist/blacklist. + + Requires: settings:write permission + """ + pool = await get_db_pool() + + # Get the entry first for logging + row = await pool.fetchrow(""" + SELECT ip_address::text, list_type FROM rate_limit_ip_list WHERE id = $1 + """, ip_id) + + if not row: + raise HTTPException(status_code=404, detail="IP entry not found") + + await pool.execute(""" + DELETE FROM rate_limit_ip_list WHERE id = $1 + """, ip_id) + + # Log the event + await pool.execute(""" + INSERT INTO middleware_events (middleware_name, event_type, ip_address, user_id, details) + VALUES ('rate_limiter', $1, $2::inet, $3, $4) + """, f"ip_{row['list_type']}_remove", row["ip_address"], session.user_id, {}) + + return {"message": "IP removed successfully"} + + +# ============================================== +# Middleware Events & Statistics +# ============================================== + + +@router.get("/events", response_model=List[MiddlewareEventResponse]) +async def list_middleware_events( + middleware_name: Optional[str] = None, + event_type: Optional[str] = None, + limit: int = Query(100, le=1000), + offset: int = 0, + session: Session = Depends(require_permission("audit:read")), +): + """ + List middleware events (rate limit triggers, config changes, etc.). + + Requires: audit:read permission + """ + pool = await get_db_pool() + + conditions = [] + params = [] + param_idx = 1 + + if middleware_name: + conditions.append(f"middleware_name = ${param_idx}") + params.append(middleware_name) + param_idx += 1 + + if event_type: + conditions.append(f"event_type = ${param_idx}") + params.append(event_type) + param_idx += 1 + + where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else "" + + params.extend([limit, offset]) + query = f""" + SELECT id, middleware_name, event_type, ip_address::text, user_id::text, + request_path, request_method, details, created_at + FROM middleware_events + {where_clause} + ORDER BY created_at DESC + LIMIT ${param_idx} OFFSET ${param_idx + 1} + """ + + rows = await pool.fetch(query, *params) + + return [ + MiddlewareEventResponse( + id=str(row["id"]), + middleware_name=row["middleware_name"], + event_type=row["event_type"], + ip_address=row["ip_address"], + user_id=row["user_id"], + request_path=row["request_path"], + request_method=row["request_method"], + details=row["details"], + created_at=row["created_at"], + ) + for row in rows + ] + + +@router.get("/stats", response_model=List[MiddlewareStatsResponse]) +async def get_middleware_stats( + session: Session = Depends(require_permission("settings:read")), +): + """ + Get statistics for all middlewares. + + Requires: settings:read permission + """ + pool = await get_db_pool() + + stats = [] + middlewares = ["request_id", "security_headers", "cors", "rate_limiter", "pii_redactor", "input_gate"] + + for mw in middlewares: + # Get event counts + counts = await pool.fetchrow(""" + SELECT + COUNT(*) as total, + COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '1 hour') as last_hour, + COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '24 hours') as last_24h + FROM middleware_events + WHERE middleware_name = $1 + """, mw) + + # Get top event types + top_events = await pool.fetch(""" + SELECT event_type, COUNT(*) as count + FROM middleware_events + WHERE middleware_name = $1 AND created_at > NOW() - INTERVAL '24 hours' + GROUP BY event_type + ORDER BY count DESC + LIMIT 5 + """, mw) + + # Get top IPs (for rate limiter) + top_ips = await pool.fetch(""" + SELECT ip_address::text, COUNT(*) as count + FROM middleware_events + WHERE middleware_name = $1 + AND ip_address IS NOT NULL + AND created_at > NOW() - INTERVAL '24 hours' + GROUP BY ip_address + ORDER BY count DESC + LIMIT 5 + """, mw) + + stats.append(MiddlewareStatsResponse( + middleware_name=mw, + total_events=counts["total"] or 0, + events_last_hour=counts["last_hour"] or 0, + events_last_24h=counts["last_24h"] or 0, + top_event_types=[ + {"event_type": r["event_type"], "count": r["count"]} + for r in top_events + ], + top_ips=[ + {"ip_address": r["ip_address"], "count": r["count"]} + for r in top_ips + ], + )) + + return stats + + +@router.get("/stats/{name}", response_model=MiddlewareStatsResponse) +async def get_middleware_stats_by_name( + name: str, + session: Session = Depends(require_permission("settings:read")), +): + """ + Get statistics for a specific middleware. + + Requires: settings:read permission + """ + pool = await get_db_pool() + + # Get event counts + counts = await pool.fetchrow(""" + SELECT + COUNT(*) as total, + COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '1 hour') as last_hour, + COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '24 hours') as last_24h + FROM middleware_events + WHERE middleware_name = $1 + """, name) + + # Get top event types + top_events = await pool.fetch(""" + SELECT event_type, COUNT(*) as count + FROM middleware_events + WHERE middleware_name = $1 AND created_at > NOW() - INTERVAL '24 hours' + GROUP BY event_type + ORDER BY count DESC + LIMIT 10 + """, name) + + # Get top IPs + top_ips = await pool.fetch(""" + SELECT ip_address::text, COUNT(*) as count + FROM middleware_events + WHERE middleware_name = $1 + AND ip_address IS NOT NULL + AND created_at > NOW() - INTERVAL '24 hours' + GROUP BY ip_address + ORDER BY count DESC + LIMIT 10 + """, name) + + return MiddlewareStatsResponse( + middleware_name=name, + total_events=counts["total"] or 0, + events_last_hour=counts["last_hour"] or 0, + events_last_24h=counts["last_24h"] or 0, + top_event_types=[ + {"event_type": r["event_type"], "count": r["count"]} + for r in top_events + ], + top_ips=[ + {"ip_address": r["ip_address"], "count": r["count"]} + for r in top_ips + ], + ) diff --git a/backend/notification_api.py b/backend/notification_api.py new file mode 100644 index 0000000..ecc50f4 --- /dev/null +++ b/backend/notification_api.py @@ -0,0 +1,142 @@ +""" +Notification API - Proxy zu Go Consent Service für Benachrichtigungen +""" + +from fastapi import APIRouter, HTTPException, Header, Query +from typing import Optional +import httpx + +router = APIRouter(prefix="/v1/notifications", tags=["Notifications"]) + +CONSENT_SERVICE_URL = "http://localhost:8081" + + +async def proxy_request( + method: str, + path: str, + authorization: Optional[str] = None, + json_data: dict = None, + params: dict = None +): + """Proxy request to Go consent service.""" + headers = {} + if authorization: + headers["Authorization"] = authorization + + async with httpx.AsyncClient() as client: + try: + response = await client.request( + method, + f"{CONSENT_SERVICE_URL}{path}", + headers=headers, + json=json_data, + params=params, + timeout=30.0 + ) + + if response.status_code >= 400: + raise HTTPException( + status_code=response.status_code, + detail=response.json().get("error", "Request failed") + ) + + return response.json() + except httpx.RequestError as e: + raise HTTPException(status_code=503, detail=f"Consent service unavailable: {str(e)}") + + +@router.get("") +async def get_notifications( + limit: int = Query(20, ge=1, le=100), + offset: int = Query(0, ge=0), + unread_only: bool = Query(False), + authorization: Optional[str] = Header(None) +): + """Holt alle Benachrichtigungen des aktuellen Benutzers.""" + params = { + "limit": limit, + "offset": offset, + "unread_only": str(unread_only).lower() + } + return await proxy_request( + "GET", + "/api/v1/notifications", + authorization=authorization, + params=params + ) + + +@router.get("/unread-count") +async def get_unread_count( + authorization: Optional[str] = Header(None) +): + """Gibt die Anzahl ungelesener Benachrichtigungen zurück.""" + return await proxy_request( + "GET", + "/api/v1/notifications/unread-count", + authorization=authorization + ) + + +@router.put("/{notification_id}/read") +async def mark_as_read( + notification_id: str, + authorization: Optional[str] = Header(None) +): + """Markiert eine Benachrichtigung als gelesen.""" + return await proxy_request( + "PUT", + f"/api/v1/notifications/{notification_id}/read", + authorization=authorization + ) + + +@router.put("/read-all") +async def mark_all_as_read( + authorization: Optional[str] = Header(None) +): + """Markiert alle Benachrichtigungen als gelesen.""" + return await proxy_request( + "PUT", + "/api/v1/notifications/read-all", + authorization=authorization + ) + + +@router.delete("/{notification_id}") +async def delete_notification( + notification_id: str, + authorization: Optional[str] = Header(None) +): + """Löscht eine Benachrichtigung.""" + return await proxy_request( + "DELETE", + f"/api/v1/notifications/{notification_id}", + authorization=authorization + ) + + +@router.get("/preferences") +async def get_preferences( + authorization: Optional[str] = Header(None) +): + """Holt die Benachrichtigungseinstellungen des Benutzers.""" + return await proxy_request( + "GET", + "/api/v1/notifications/preferences", + authorization=authorization + ) + + +@router.put("/preferences") +async def update_preferences( + preferences: dict, + authorization: Optional[str] = Header(None) +): + """Aktualisiert die Benachrichtigungseinstellungen.""" + return await proxy_request( + "PUT", + "/api/v1/notifications/preferences", + authorization=authorization, + json_data=preferences + ) diff --git a/backend/original_service.py b/backend/original_service.py new file mode 100644 index 0000000..579e7a3 --- /dev/null +++ b/backend/original_service.py @@ -0,0 +1,1041 @@ +from datetime import date +from typing import List + +from fastapi import APIRouter, UploadFile, File +from fastapi.responses import HTMLResponse +import shutil + +from config import ( + BASE_DIR, + EINGANG_DIR, + BEREINIGT_DIR, + EDITIERBAR_DIR, + NEU_GENERIERT_DIR, + is_valid_input_file, +) +from ai_processor import ( + dummy_process_scan, + describe_scan_with_ai, + analyze_scan_structure_with_ai, + build_clean_html_from_analysis, + remove_handwriting_from_scan, + generate_mc_from_analysis, + generate_cloze_from_analysis, + generate_qa_from_analysis, + update_leitner_progress, + get_next_review_items, + generate_print_version_qa, + generate_print_version_cloze, + generate_print_version_mc, + generate_print_version_worksheet, + generate_mindmap_data, + generate_mindmap_html, + save_mindmap_for_worksheet, +) + +router = APIRouter() + + +@router.get("/") +def home(): + """Basis-Info des Backends (unter /api/ erreichbar).""" + return { + "status": "OK", + "message": "BreakPilot Backend läuft.", + "base_dir": str(BASE_DIR), + } + + +# === UPLOAD === + + +@router.post("/upload-scan") +async def upload_scan(file: UploadFile = File(...)): + """ + Einzelnen Scan hochladen. + Dateiname bekommt automatisch ein Datum vorne dran: YYYY-MM-DD_originalname.ext + """ + today = date.today().isoformat() + safe_name = file.filename.replace("/", "_") + target_name = f"{today}_{safe_name}" + target_path = EINGANG_DIR / target_name + with target_path.open("wb") as buffer: + shutil.copyfileobj(file.file, buffer) + return { + "status": "OK", + "message": "Scan gespeichert", + "saved_as": str(target_path), + } + + +@router.get("/upload-form", response_class=HTMLResponse) +def upload_form(): + """Einfache HTML-Upload-Seite (für schnelle Tests im Browser).""" + return """ + + +

            Arbeitsblätter hochladen

            +
            + + +
            + + + """ + + +@router.post("/upload-multi") +async def upload_multi(files: List[UploadFile] = File(...)): + """ + Mehrere Dateien per Stapel hochladen. + Alle Dateien landen im Ordner Eingang mit Datumspräfix. + """ + today = date.today().isoformat() + saved = [] + for file in files: + safe_name = file.filename.replace("/", "_") + target_name = f"{today}_{safe_name}" + target_path = EINGANG_DIR / target_name + with target_path.open("wb") as buffer: + shutil.copyfileobj(file.file, buffer) + saved.append(str(target_path)) + return {"status": "OK", "message": "Dateien gespeichert", "saved_as": saved} + + +# === LISTEN & LÖSCHEN === + + +@router.get("/eingang-dateien") +def list_eingang_files(): + files = [f.name for f in EINGANG_DIR.iterdir() if is_valid_input_file(f)] + return {"eingang": files} + + +@router.delete("/eingang-dateien/{filename}") +def delete_eingang_file(filename: str): + """ + Löscht eine Datei komplett und entfernt sie aus allen Lerneinheiten. + """ + from learning_units import get_all_units, update_unit + + path = EINGANG_DIR / filename + if not path.exists() or not path.is_file(): + return {"status": "ERROR", "message": "Datei nicht gefunden", "filename": filename} + + # Datei aus allen Lerneinheiten entfernen + units = get_all_units() + units_updated = 0 + for unit in units: + if filename in unit.get("worksheet_files", []): + # Datei aus der Liste entfernen + unit["worksheet_files"].remove(filename) + # Lerneinheit aktualisieren + update_unit(unit["id"], unit) + units_updated += 1 + + # Physische Datei löschen + path.unlink() + + return { + "status": "OK", + "message": "Datei gelöscht", + "filename": filename, + "units_updated": units_updated + } + + +@router.get("/bereinigt-dateien") +def list_bereinigt_files(): + files = [f.name for f in BEREINIGT_DIR.iterdir() if f.is_file()] + return {"bereinigt": files} + + +# === VERARBEITUNG: Dummy / Beschreiben / Analysieren / HTML erzeugen === + + +@router.post("/process-all") +def process_all_scans(): + """ + Einfache Dummy-Verarbeitung aller Eingangsdateien. + Dient vor allem als Fallback / Debug. + """ + processed = [] + skipped = [] + for f in EINGANG_DIR.iterdir(): + if is_valid_input_file(f): + out = dummy_process_scan(f) + processed.append(out.name) + else: + skipped.append(f.name) + return { + "status": "OK", + "processed_files": processed, + "skipped": skipped, + } + + +@router.post("/remove-handwriting-all") +def remove_handwriting_all(): + """ + MVP-Version: erzeugt *_clean-Bilder im Ordner Bereinigt. + Die eigentliche inhaltliche „Bereinigung“ (keine Handschrift, keine durchgestrichenen Wörter) + passiert später beim HTML-Neuaufbau. + """ + cleaned = [] + errors = [] + skipped = [] + for f in EINGANG_DIR.iterdir(): + if not is_valid_input_file(f): + skipped.append(f.name) + continue + if f.suffix.lower() not in {".jpg", ".jpeg", ".png"}: + skipped.append(f.name) + continue + try: + out = remove_handwriting_from_scan(f) + cleaned.append(out.name) + except Exception as e: + errors.append({"file": f.name, "error": str(e)}) + return { + "status": "OK" if cleaned else "ERROR", + "cleaned": cleaned, + "errors": errors, + "skipped": skipped, + } + + +@router.post("/describe-all") +def describe_all_scans(): + """ + Vision-Modell beschreibt alle gültigen Eingangsdateien kurz. + Ergebnis: *_beschreibung.txt in Bereinigt. + """ + described = [] + errors = [] + skipped = [] + for f in EINGANG_DIR.iterdir(): + if not is_valid_input_file(f): + skipped.append(f.name) + continue + try: + out = describe_scan_with_ai(f) + described.append(out.name) + except Exception as e: + errors.append({"file": f.name, "error": str(e)}) + return {"status": "OK", "described": described, "errors": errors, "skipped": skipped} + + +@router.post("/analyze-all") +def analyze_all_scans(): + """ + Vision-Modell analysiert Struktur aller Eingangsdateien. + Ergebnis: *_analyse.json in Bereinigt. + """ + analyzed = [] + errors = [] + skipped = [] + for f in EINGANG_DIR.iterdir(): + if not is_valid_input_file(f): + skipped.append(f.name) + continue + try: + out = analyze_scan_structure_with_ai(f) + analyzed.append(out.name) + except Exception as e: + errors.append({"file": f.name, "error": str(e)}) + return {"status": "OK", "analyzed": analyzed, "errors": errors, "skipped": skipped} + + +@router.post("/generate-clean") +def generate_clean_worksheets(): + """ + Baut aus allen *_analyse.json Dateien saubere HTML-Arbeitsblätter. + Ergebnis: *_clean.html in Bereinigt. + """ + generated = [] + errors = [] + for f in BEREINIGT_DIR.iterdir(): + if f.suffix == ".json" and f.name.endswith("_analyse.json"): + try: + out = build_clean_html_from_analysis(f) + generated.append(out.name) + except Exception as e: + errors.append({"file": f.name, "error": str(e)}) + return {"status": "OK", "generated": generated, "errors": errors} + + +# === Mapping Alt vs. Neu + HTML-/Image-Auslieferung === + + +@router.get("/worksheet-pairs") +def list_worksheet_pairs(): + """ + Für jede Datei im Eingang wird nach einem passenden „sauberen“ Pendant gesucht: + + - HTML: + - _clean.html + - __clean.html (kompatibel zu früherem Bug mit doppeltem Unterstrich) + - .html + + - Bild: + - _clean.(jpg|jpeg|png|JPG|JPEG|PNG) + - __clean.(...) (falls so erzeugt) + """ + pairs = [] + for f in EINGANG_DIR.iterdir(): + if not is_valid_input_file(f): + continue + base = f.stem + clean_html = None + clean_image = None + + # HTML-Kandidaten + html_candidates = [ + f"{base}_clean.html", + f"{base}__clean.html", + f"{base}.html", + ] + for cand in html_candidates: + p = BEREINIGT_DIR / cand + if p.exists() and p.is_file(): + clean_html = cand + break + + # Bild-Kandidaten (immer suchen, unabhängig von HTML) + exts = [".jpg", ".jpeg", ".png", ".JPG", ".JPEG", ".PNG"] + found = False + for ext in exts: + for suffix in ["_clean", "__clean"]: + cand_img = f"{base}{suffix}{ext}" + p = BEREINIGT_DIR / cand_img + if p.exists() and p.is_file(): + clean_image = cand_img + found = True + break + if found: + break + + pairs.append( + { + "original": f.name, + "clean_html": clean_html, + "clean_image": clean_image, + } + ) + + return {"pairs": pairs} + + +@router.get("/clean-html/{filename}", response_class=HTMLResponse) +def get_clean_html(filename: str): + """ + Liefert eine der erzeugten clean-HTML-Dateien aus dem Ordner Bereinigt. + Wird im Frontend für die „neu aufgebautes Arbeitsblatt"-Vorschau genutzt. + """ + path = BEREINIGT_DIR / filename + if not path.exists() or not path.is_file() or path.suffix != ".html": + return HTMLResponse( + "

            Kein neu aufgebautes Arbeitsblatt gefunden.

            ", + status_code=404, + ) + content = path.read_text(encoding="utf-8") + return HTMLResponse(content) + + +# === MULTIPLE CHOICE === + + +@router.post("/generate-mc") +def generate_mc_all(): + """ + Generiert Multiple-Choice-Fragen für alle analysierten Arbeitsblätter. + + Voraussetzung: Die Analyse (*_analyse.json) muss bereits existieren. + Ergebnis: *_mc.json Dateien im Ordner Bereinigt. + + Die MC-Fragen: + - Entsprechen dem Schwierigkeitsgrad des Originals + - Haben zufällig angeordnete Antworten (nicht immer A = richtig) + """ + generated = [] + errors = [] + skipped = [] + + for f in BEREINIGT_DIR.iterdir(): + if f.suffix == ".json" and f.name.endswith("_analyse.json"): + try: + out = generate_mc_from_analysis(f) + generated.append(out.name) + except Exception as e: + errors.append({"file": f.name, "error": str(e)}) + elif f.suffix == ".json": + skipped.append(f.name) + + return { + "status": "OK" if generated else "ERROR", + "generated": generated, + "errors": errors, + "skipped": skipped, + } + + +@router.get("/mc-data/{filename}") +def get_mc_data(filename: str): + """ + Liefert die generierten MC-Fragen für ein Arbeitsblatt. + + Args: + filename: Name der Original-Datei (z.B. "2024-12-10_mathe.jpg") + oder der MC-Datei (z.B. "2024-12-10_mathe_mc.json") + + Returns: + JSON mit questions und metadata + """ + import json + + # Versuche verschiedene Dateinamen-Formate + base = filename + if base.endswith("_mc.json"): + mc_filename = base + else: + # Entferne Dateiendung und füge _mc.json hinzu + base_stem = filename.rsplit(".", 1)[0] if "." in filename else filename + mc_filename = f"{base_stem}_mc.json" + + path = BEREINIGT_DIR / mc_filename + if not path.exists() or not path.is_file(): + return {"status": "NOT_FOUND", "message": f"MC-Daten nicht gefunden: {mc_filename}"} + + try: + data = json.loads(path.read_text(encoding="utf-8")) + return {"status": "OK", "data": data, "filename": mc_filename} + except json.JSONDecodeError as e: + return {"status": "ERROR", "message": f"Ungültige JSON-Datei: {e}"} + + +@router.get("/mc-list") +def list_mc_files(): + """ + Listet alle generierten MC-Dateien auf. + + Returns: + Liste der MC-Dateien mit zugehörigen Original-Dateinamen + """ + mc_files = [] + + for f in BEREINIGT_DIR.iterdir(): + if f.suffix == ".json" and f.name.endswith("_mc.json"): + # Versuche Original-Datei zu finden + base_stem = f.stem.replace("_mc", "") + original = None + + # Suche nach passender Datei im Eingang + for ext in [".jpg", ".jpeg", ".png", ".JPG", ".JPEG", ".PNG", ".pdf", ".PDF"]: + candidate = EINGANG_DIR / f"{base_stem}{ext}" + if candidate.exists(): + original = candidate.name + break + + mc_files.append({ + "mc_file": f.name, + "original": original, + "base_name": base_stem, + }) + + return {"mc_files": mc_files} + + +# === LÜCKENTEXT === + + +@router.post("/generate-cloze") +def generate_cloze_all(target_language: str = "tr"): + """ + Generiert Lückentexte für alle analysierten Arbeitsblätter. + + Voraussetzung: Die Analyse (*_analyse.json) muss bereits existieren. + Ergebnis: *_cloze.json Dateien im Ordner Bereinigt. + + Die Lückentexte: + - Haben mehrere sinnvolle Lücken pro Satz + - Entsprechen dem Schwierigkeitsgrad des Originals + - Enthalten eine Übersetzung mit denselben Lücken + + Args: + target_language: Sprachcode für Übersetzung (default: "tr" für Türkisch) + Unterstützt: tr, ar, ru, en, fr, es, pl, uk + """ + generated = [] + errors = [] + skipped = [] + + for f in BEREINIGT_DIR.iterdir(): + if f.suffix == ".json" and f.name.endswith("_analyse.json"): + try: + out = generate_cloze_from_analysis(f, target_language) + generated.append(out.name) + except Exception as e: + errors.append({"file": f.name, "error": str(e)}) + elif f.suffix == ".json": + skipped.append(f.name) + + return { + "status": "OK" if generated else "ERROR", + "generated": generated, + "errors": errors, + "skipped": skipped, + "target_language": target_language, + } + + +@router.get("/cloze-data/{filename}") +def get_cloze_data(filename: str): + """ + Liefert die generierten Lückentexte für ein Arbeitsblatt. + + Args: + filename: Name der Original-Datei (z.B. "2024-12-10_deutsch.jpg") + oder der Cloze-Datei (z.B. "2024-12-10_deutsch_cloze.json") + + Returns: + JSON mit cloze_items und metadata + """ + import json + + # Versuche verschiedene Dateinamen-Formate + base = filename + if base.endswith("_cloze.json"): + cloze_filename = base + else: + # Entferne Dateiendung und füge _cloze.json hinzu + base_stem = filename.rsplit(".", 1)[0] if "." in filename else filename + cloze_filename = f"{base_stem}_cloze.json" + + path = BEREINIGT_DIR / cloze_filename + if not path.exists() or not path.is_file(): + return {"status": "NOT_FOUND", "message": f"Lückentext-Daten nicht gefunden: {cloze_filename}"} + + try: + data = json.loads(path.read_text(encoding="utf-8")) + return {"status": "OK", "data": data, "filename": cloze_filename} + except json.JSONDecodeError as e: + return {"status": "ERROR", "message": f"Ungültige JSON-Datei: {e}"} + + +@router.get("/cloze-list") +def list_cloze_files(): + """ + Listet alle generierten Lückentext-Dateien auf. + + Returns: + Liste der Cloze-Dateien mit zugehörigen Original-Dateinamen + """ + cloze_files = [] + + for f in BEREINIGT_DIR.iterdir(): + if f.suffix == ".json" and f.name.endswith("_cloze.json"): + # Versuche Original-Datei zu finden + base_stem = f.stem.replace("_cloze", "") + original = None + + # Suche nach passender Datei im Eingang + for ext in [".jpg", ".jpeg", ".png", ".JPG", ".JPEG", ".PNG", ".pdf", ".PDF"]: + candidate = EINGANG_DIR / f"{base_stem}{ext}" + if candidate.exists(): + original = candidate.name + break + + cloze_files.append({ + "cloze_file": f.name, + "original": original, + "base_name": base_stem, + }) + + return {"cloze_files": cloze_files} + + +# === FRAGE-ANTWORT (Q&A) MIT LEITNER-SYSTEM === + + +@router.post("/generate-qa") +def generate_qa_all(num_questions: int = 8): + """ + Generiert Frage-Antwort-Paare für alle analysierten Arbeitsblätter. + + Voraussetzung: Die Analyse (*_analyse.json) muss bereits existieren. + Ergebnis: *_qa.json Dateien im Ordner Bereinigt. + + Die Q&A-Paare: + - Fragen sind fast wörtlich aus dem Text entnommen + - Schlüsselbegriffe sind markiert für Spaced Repetition + - Enthalten Leitner-Box Metadaten für Lernfortschritt + + Args: + num_questions: Anzahl der zu generierenden Fragen (default: 8) + """ + generated = [] + errors = [] + skipped = [] + + for f in BEREINIGT_DIR.iterdir(): + if f.suffix == ".json" and f.name.endswith("_analyse.json"): + try: + out = generate_qa_from_analysis(f, num_questions) + generated.append(out.name) + except Exception as e: + errors.append({"file": f.name, "error": str(e)}) + elif f.suffix == ".json": + skipped.append(f.name) + + return { + "status": "OK" if generated else "ERROR", + "generated": generated, + "errors": errors, + "skipped": skipped, + "num_questions": num_questions, + } + + +@router.get("/qa-data/{filename}") +def get_qa_data(filename: str): + """ + Liefert die generierten Q&A-Paare für ein Arbeitsblatt. + + Args: + filename: Name der Original-Datei (z.B. "2024-12-10_deutsch.jpg") + oder der QA-Datei (z.B. "2024-12-10_deutsch_qa.json") + + Returns: + JSON mit qa_items und metadata inkl. Leitner-Fortschritt + """ + import json + + # Versuche verschiedene Dateinamen-Formate + base = filename + if base.endswith("_qa.json"): + qa_filename = base + else: + # Entferne Dateiendung und füge _qa.json hinzu + base_stem = filename.rsplit(".", 1)[0] if "." in filename else filename + qa_filename = f"{base_stem}_qa.json" + + path = BEREINIGT_DIR / qa_filename + if not path.exists() or not path.is_file(): + return {"status": "NOT_FOUND", "message": f"Q&A-Daten nicht gefunden: {qa_filename}"} + + try: + data = json.loads(path.read_text(encoding="utf-8")) + return {"status": "OK", "data": data, "filename": qa_filename} + except json.JSONDecodeError as e: + return {"status": "ERROR", "message": f"Ungültige JSON-Datei: {e}"} + + +@router.get("/qa-list") +def list_qa_files(): + """ + Listet alle generierten Q&A-Dateien auf. + + Returns: + Liste der QA-Dateien mit zugehörigen Original-Dateinamen + """ + qa_files = [] + + for f in BEREINIGT_DIR.iterdir(): + if f.suffix == ".json" and f.name.endswith("_qa.json"): + # Versuche Original-Datei zu finden + base_stem = f.stem.replace("_qa", "") + original = None + + # Suche nach passender Datei im Eingang + for ext in [".jpg", ".jpeg", ".png", ".JPG", ".JPEG", ".PNG", ".pdf", ".PDF"]: + candidate = EINGANG_DIR / f"{base_stem}{ext}" + if candidate.exists(): + original = candidate.name + break + + qa_files.append({ + "qa_file": f.name, + "original": original, + "base_name": base_stem, + }) + + return {"qa_files": qa_files} + + +@router.post("/qa-progress") +def update_qa_progress(filename: str, item_id: str, correct: bool): + """ + Aktualisiert den Leitner-Lernfortschritt für eine Q&A-Frage. + + Das Leitner-System: + - Box 0 (neu): Wiederholung nach 1 Tag + - Box 1 (gelernt): Wiederholung nach 3 Tagen + - Box 2 (gefestigt): Wiederholung nach 7 Tagen + - Falsche Antwort: Eine Box zurück, Wiederholung nach 4 Stunden + + Args: + filename: Name der QA-Datei + item_id: ID der Frage (z.B. "q_0") + correct: True wenn richtig beantwortet, False wenn falsch + + Returns: + Aktualisierter Fortschritt für die Frage + """ + # Finde QA-Datei + base = filename + if base.endswith("_qa.json"): + qa_filename = base + else: + base_stem = filename.rsplit(".", 1)[0] if "." in filename else filename + qa_filename = f"{base_stem}_qa.json" + + path = BEREINIGT_DIR / qa_filename + if not path.exists() or not path.is_file(): + return {"status": "NOT_FOUND", "message": f"Q&A-Daten nicht gefunden: {qa_filename}"} + + try: + result = update_leitner_progress(path, item_id, correct) + return {"status": "OK", "progress": result, "filename": qa_filename} + except Exception as e: + return {"status": "ERROR", "message": str(e)} + + +@router.get("/qa-review/{filename}") +def get_qa_review_items(filename: str, limit: int = 5): + """ + Liefert die nächsten zu wiederholenden Fragen basierend auf dem Leitner-System. + + Priorisierung: + 1. Überfällige Fragen (nach next_review Zeit) + 2. Niedrigere Boxen zuerst (Box 0 vor Box 1 vor Box 2) + 3. Längere Zeit nicht gesehen + + Args: + filename: Name der QA-Datei + limit: Maximale Anzahl zurückzugebender Fragen (default: 5) + + Returns: + Liste der zu wiederholenden Fragen mit Leitner-Metadaten + """ + # Finde QA-Datei + base = filename + if base.endswith("_qa.json"): + qa_filename = base + else: + base_stem = filename.rsplit(".", 1)[0] if "." in filename else filename + qa_filename = f"{base_stem}_qa.json" + + path = BEREINIGT_DIR / qa_filename + if not path.exists() or not path.is_file(): + return {"status": "NOT_FOUND", "message": f"Q&A-Daten nicht gefunden: {qa_filename}"} + + try: + items = get_next_review_items(path, limit) + return {"status": "OK", "review_items": items, "filename": qa_filename, "count": len(items)} + except Exception as e: + return {"status": "ERROR", "message": str(e)} + + +# === PRINT-VERSIONEN === + + +@router.get("/print-qa/{filename}", response_class=HTMLResponse) +def get_print_qa(filename: str, show_answers: bool = False): + """ + Liefert eine druckbare HTML-Version der Q&A-Fragen. + + Args: + filename: Name der QA-Datei oder Original-Datei + show_answers: True für Lösungsblatt, False für Fragenblatt + + Returns: + Druckbare HTML-Seite + """ + # Finde QA-Datei + base = filename + if base.endswith("_qa.json"): + qa_filename = base + else: + base_stem = filename.rsplit(".", 1)[0] if "." in filename else filename + qa_filename = f"{base_stem}_qa.json" + + path = BEREINIGT_DIR / qa_filename + if not path.exists() or not path.is_file(): + return HTMLResponse( + "

            Q&A-Daten nicht gefunden.

            ", + status_code=404, + ) + + try: + html = generate_print_version_qa(path, show_answers) + return HTMLResponse(html) + except Exception as e: + return HTMLResponse( + f"

            Fehler: {e}

            ", + status_code=500, + ) + + +@router.get("/print-cloze/{filename}", response_class=HTMLResponse) +def get_print_cloze(filename: str, show_answers: bool = False): + """ + Liefert eine druckbare HTML-Version der Lückentexte. + + Args: + filename: Name der Cloze-Datei oder Original-Datei + show_answers: True für Lösungsblatt mit ausgefüllten Lücken, + False für Übungsblatt mit Wortbank + + Returns: + Druckbare HTML-Seite + """ + # Finde Cloze-Datei + base = filename + if base.endswith("_cloze.json"): + cloze_filename = base + else: + base_stem = filename.rsplit(".", 1)[0] if "." in filename else filename + cloze_filename = f"{base_stem}_cloze.json" + + path = BEREINIGT_DIR / cloze_filename + if not path.exists() or not path.is_file(): + return HTMLResponse( + "

            Lückentext-Daten nicht gefunden.

            ", + status_code=404, + ) + + try: + html = generate_print_version_cloze(path, show_answers) + return HTMLResponse(html) + except Exception as e: + return HTMLResponse( + f"

            Fehler: {e}

            ", + status_code=500, + ) + + +@router.get("/print-mc/{filename}", response_class=HTMLResponse) +def get_print_mc(filename: str, show_answers: bool = False): + """ + Liefert eine druckbare HTML-Version der Multiple-Choice-Fragen. + + Args: + filename: Name der MC-Datei oder Original-Datei + show_answers: True für Lösungsblatt mit markierten Antworten, + False für Testblatt zum Ausfüllen + + Returns: + Druckbare HTML-Seite + """ + # Finde MC-Datei + base = filename + if base.endswith("_mc.json"): + mc_filename = base + else: + base_stem = filename.rsplit(".", 1)[0] if "." in filename else filename + mc_filename = f"{base_stem}_mc.json" + + path = BEREINIGT_DIR / mc_filename + if not path.exists() or not path.is_file(): + return HTMLResponse( + "

            MC-Daten nicht gefunden.

            ", + status_code=404, + ) + + try: + html = generate_print_version_mc(path, show_answers) + return HTMLResponse(html) + except Exception as e: + return HTMLResponse( + f"

            Fehler beim Erstellen der MC-Druckversion: {e}

            ", + status_code=500, + ) + + +@router.get("/print-worksheet/{filename}", response_class=HTMLResponse) +def get_print_worksheet(filename: str): + """ + Liefert eine druckoptimierte HTML-Version des neu aufgebauten Arbeitsblatts. + + - Große, klare Schrift + - Schwarz-weiß / Graustufen-tauglich + - Direkt druckbar für Eltern + """ + # Analysedatei finden + base = filename.rsplit(".", 1)[0] # Entfernt Dateiendung + analysis_path = BEREINIGT_DIR / f"{base}_analyse.json" + + if not analysis_path.exists(): + return HTMLResponse( + f"

            Keine Analyse gefunden für: {filename}

            ", + status_code=404, + ) + + try: + html = generate_print_version_worksheet(analysis_path) + return HTMLResponse(html) + except Exception as e: + return HTMLResponse( + f"

            Fehler beim Erstellen der Druckversion: {e}

            ", + status_code=500, + ) + + +# === MINDMAP LERNPOSTER === + + +@router.post("/generate-mindmap/{filename}") +def generate_mindmap(filename: str): + """ + Generiert eine kindgerechte Mindmap aus einem analysierten Arbeitsblatt. + + Die Mindmap: + - Zeigt das Hauptthema in der Mitte + - Gruppiert Fachbegriffe in farbige Kategorien + - Enthält Emojis für bessere visuelle Orientierung + - Kann als A3 Poster gedruckt werden + + Args: + filename: Name der Original-Datei (z.B. "2024-12-10_biologie.jpg") + + Returns: + Status und Dateiname der generierten Mindmap + """ + # Analysedatei finden + base_stem = filename.rsplit(".", 1)[0] if "." in filename else filename + analysis_path = BEREINIGT_DIR / f"{base_stem}_analyse.json" + + if not analysis_path.exists(): + return { + "status": "NOT_FOUND", + "message": f"Keine Analyse gefunden für: {filename}. Bitte zuerst analysieren." + } + + try: + # Mindmap-Daten generieren + mindmap_data = generate_mindmap_data(analysis_path) + + # Mindmap speichern + mindmap_path = save_mindmap_for_worksheet(analysis_path, mindmap_data) + + return { + "status": "OK", + "message": "Mindmap erfolgreich generiert", + "filename": mindmap_path.name, + "topic": mindmap_data.get("topic", ""), + "categories_count": len(mindmap_data.get("categories", [])), + } + except Exception as e: + return { + "status": "ERROR", + "message": f"Fehler bei der Mindmap-Generierung: {e}" + } + + +@router.get("/mindmap-data/{filename}") +def get_mindmap_data(filename: str): + """ + Liefert die generierten Mindmap-Daten für ein Arbeitsblatt. + + Args: + filename: Name der Original-Datei oder der Mindmap-Datei + + Returns: + JSON mit topic, subject und categories + """ + import json + + # Versuche verschiedene Dateinamen-Formate + base = filename + if base.endswith("_mindmap.json"): + mindmap_filename = base + else: + base_stem = filename.rsplit(".", 1)[0] if "." in filename else filename + mindmap_filename = f"{base_stem}_mindmap.json" + + path = BEREINIGT_DIR / mindmap_filename + if not path.exists() or not path.is_file(): + return {"status": "NOT_FOUND", "message": f"Mindmap-Daten nicht gefunden: {mindmap_filename}"} + + try: + data = json.loads(path.read_text(encoding="utf-8")) + return {"status": "OK", "data": data, "filename": mindmap_filename} + except json.JSONDecodeError as e: + return {"status": "ERROR", "message": f"Ungültige JSON-Datei: {e}"} + + +@router.get("/mindmap-html/{filename}", response_class=HTMLResponse) +def get_mindmap_html(filename: str, format: str = "a3"): + """ + Liefert die Mindmap als druckbares HTML mit SVG. + + Args: + filename: Name der Original-Datei oder der Mindmap-Datei + format: Druckformat - "a3" (Standard, Poster) oder "a4" + + Returns: + HTML-Seite mit SVG-Mindmap, druckoptimiert + """ + import json + + # Versuche verschiedene Dateinamen-Formate + base = filename + if base.endswith("_mindmap.json"): + mindmap_filename = base + else: + base_stem = filename.rsplit(".", 1)[0] if "." in filename else filename + mindmap_filename = f"{base_stem}_mindmap.json" + + path = BEREINIGT_DIR / mindmap_filename + if not path.exists() or not path.is_file(): + return HTMLResponse( + "

            Mindmap-Daten nicht gefunden. Bitte zuerst generieren.

            ", + status_code=404, + ) + + try: + data = json.loads(path.read_text(encoding="utf-8")) + html = generate_mindmap_html(data, format) + return HTMLResponse(html) + except json.JSONDecodeError: + return HTMLResponse( + "

            Ungültige Mindmap-Datei.

            ", + status_code=500, + ) + except Exception as e: + return HTMLResponse( + f"

            Fehler beim Erstellen der Mindmap: {e}

            ", + status_code=500, + ) + + +@router.get("/mindmap-list") +def list_mindmap_files(): + """ + Listet alle generierten Mindmap-Dateien auf. + + Returns: + Liste der Mindmap-Dateien mit zugehörigen Original-Dateinamen + """ + mindmap_files = [] + + for f in BEREINIGT_DIR.iterdir(): + if f.suffix == ".json" and f.name.endswith("_mindmap.json"): + # Versuche Original-Datei zu finden + base_stem = f.stem.replace("_mindmap", "") + original = None + + # Suche nach passender Datei im Eingang + for ext in [".jpg", ".jpeg", ".png", ".JPG", ".JPEG", ".PNG", ".pdf", ".PDF"]: + candidate = EINGANG_DIR / f"{base_stem}{ext}" + if candidate.exists(): + original = candidate.name + break + + mindmap_files.append({ + "mindmap_file": f.name, + "original": original, + "base_name": base_stem, + }) + + return {"mindmap_files": mindmap_files} diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..cd9e85c --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "breakpilot-backend" +version = "1.0.0" +description = "BreakPilot Backend - FastAPI Server" +requires-python = ">=3.10" + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +asyncio_mode = "auto" +# Add current directory to PYTHONPATH so local packages like classroom_engine are found +pythonpath = ["."] +# Define custom markers +markers = [ + "integration: marks tests as integration tests (require external services)", + "requires_postgres: marks tests that require PostgreSQL database", + "requires_weasyprint: marks tests that require WeasyPrint system libraries", +] +# Filter specific warnings +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::pytest.PytestUnraisableExceptionWarning", +] + +[tool.coverage.run] +source = ["."] +omit = ["tests/*", "venv/*", "*/__pycache__/*"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if __name__ == .__main__.:", + "raise NotImplementedError", +] diff --git a/backend/rag_test_api.py b/backend/rag_test_api.py new file mode 100644 index 0000000..011370d --- /dev/null +++ b/backend/rag_test_api.py @@ -0,0 +1,506 @@ +""" +RAG & Training Test API - Test Runner fuer Retrieval Augmented Generation +Endpoint: /api/admin/rag-tests +""" + +from fastapi import APIRouter +from pydantic import BaseModel +from typing import List, Optional, Literal +import httpx +import asyncio +import time +import os + +router = APIRouter(prefix="/api/admin/rag-tests", tags=["RAG Tests"]) + +# ============================================== +# Models +# ============================================== + +class TestResult(BaseModel): + name: str + description: str + expected: str + actual: str + status: Literal["passed", "failed", "pending", "skipped"] + duration_ms: float + error_message: Optional[str] = None + + +class TestCategoryResult(BaseModel): + category: str + display_name: str + description: str + tests: List[TestResult] + passed: int + failed: int + total: int + + +class FullTestResults(BaseModel): + categories: List[TestCategoryResult] + total_passed: int + total_failed: int + total_tests: int + duration_ms: float + + +# ============================================== +# Configuration +# ============================================== + +BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000") +VECTOR_DB_URL = os.getenv("VECTOR_DB_URL", "http://localhost:6333") # Qdrant +EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small") + + +# ============================================== +# Test Implementations +# ============================================== + +async def test_vector_db_health() -> TestResult: + """Test Vector Database Connection (Qdrant)""" + start = time.time() + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(f"{VECTOR_DB_URL}/health") + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + return TestResult( + name="Vector Datenbank (Qdrant)", + description="Prueft ob die Vector-DB fuer Embeddings erreichbar ist", + expected="Qdrant erreichbar", + actual="Qdrant aktiv und gesund", + status="passed", + duration_ms=duration + ) + else: + return TestResult( + name="Vector Datenbank (Qdrant)", + description="Prueft ob die Vector-DB fuer Embeddings erreichbar ist", + expected="Qdrant erreichbar", + actual=f"HTTP {response.status_code}", + status="failed", + duration_ms=duration, + error_message="Qdrant nicht erreichbar" + ) + except Exception as e: + return TestResult( + name="Vector Datenbank (Qdrant)", + description="Prueft ob die Vector-DB fuer Embeddings erreichbar ist", + expected="Qdrant erreichbar", + actual="Nicht verfuegbar", + status="skipped", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +async def test_vector_collections() -> TestResult: + """Test Vector Collections""" + start = time.time() + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(f"{VECTOR_DB_URL}/collections") + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + data = response.json() + collections = data.get("result", {}).get("collections", []) + names = [c.get("name", "?") for c in collections] + return TestResult( + name="Vector Collections", + description="Prueft ob RAG-Collections (Dokumente, Embeddings) existieren", + expected="Collections verfuegbar", + actual=f"{len(collections)} Collections: {', '.join(names[:3])}", + status="passed" if collections else "skipped", + duration_ms=duration, + error_message=None if collections else "Keine Collections gefunden" + ) + else: + return TestResult( + name="Vector Collections", + description="Prueft ob RAG-Collections (Dokumente, Embeddings) existieren", + expected="Collections verfuegbar", + actual=f"HTTP {response.status_code}", + status="failed", + duration_ms=duration, + error_message="Collections nicht abrufbar" + ) + except Exception as e: + return TestResult( + name="Vector Collections", + description="Prueft ob RAG-Collections (Dokumente, Embeddings) existieren", + expected="Collections verfuegbar", + actual="Nicht verfuegbar", + status="skipped", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +async def test_embedding_api() -> TestResult: + """Test Embedding Generation API""" + start = time.time() + openai_key = os.getenv("OPENAI_API_KEY", "") + + if not openai_key: + return TestResult( + name="Embedding API (OpenAI)", + description="Prueft ob Embeddings generiert werden koennen", + expected="Embedding-Modell verfuegbar", + actual="OPENAI_API_KEY nicht gesetzt", + status="skipped", + duration_ms=(time.time() - start) * 1000, + error_message="API Key fehlt" + ) + + try: + async with httpx.AsyncClient(timeout=15.0) as client: + response = await client.post( + "https://api.openai.com/v1/embeddings", + headers={"Authorization": f"Bearer {openai_key}"}, + json={ + "model": EMBEDDING_MODEL, + "input": "Test embedding" + } + ) + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + data = response.json() + dims = len(data.get("data", [{}])[0].get("embedding", [])) + return TestResult( + name="Embedding API (OpenAI)", + description="Prueft ob Embeddings generiert werden koennen", + expected="Embedding-Modell verfuegbar", + actual=f"{EMBEDDING_MODEL}: {dims} Dimensionen", + status="passed", + duration_ms=duration + ) + else: + return TestResult( + name="Embedding API (OpenAI)", + description="Prueft ob Embeddings generiert werden koennen", + expected="Embedding-Modell verfuegbar", + actual=f"HTTP {response.status_code}", + status="failed", + duration_ms=duration, + error_message="Embedding-Generierung fehlgeschlagen" + ) + except Exception as e: + return TestResult( + name="Embedding API (OpenAI)", + description="Prueft ob Embeddings generiert werden koennen", + expected="Embedding-Modell verfuegbar", + actual=f"Fehler: {str(e)}", + status="failed", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +async def test_document_api() -> TestResult: + """Test Document Management API""" + start = time.time() + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(f"{BACKEND_URL}/api/rag/documents") + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + data = response.json() + count = len(data) if isinstance(data, list) else data.get("total", 0) + return TestResult( + name="Dokument-Verwaltung API", + description="Prueft ob die RAG-Dokument-Verwaltung verfuegbar ist", + expected="Dokument-API verfuegbar", + actual=f"{count} Dokumente indiziert", + status="passed", + duration_ms=duration + ) + elif response.status_code == 404: + return TestResult( + name="Dokument-Verwaltung API", + description="Prueft ob die RAG-Dokument-Verwaltung verfuegbar ist", + expected="Dokument-API verfuegbar", + actual="Endpoint nicht implementiert", + status="skipped", + duration_ms=duration, + error_message="RAG API nicht aktiviert" + ) + else: + return TestResult( + name="Dokument-Verwaltung API", + description="Prueft ob die RAG-Dokument-Verwaltung verfuegbar ist", + expected="Dokument-API verfuegbar", + actual=f"HTTP {response.status_code}", + status="failed", + duration_ms=duration, + error_message=f"Unerwarteter Status: {response.status_code}" + ) + except Exception as e: + return TestResult( + name="Dokument-Verwaltung API", + description="Prueft ob die RAG-Dokument-Verwaltung verfuegbar ist", + expected="Dokument-API verfuegbar", + actual=f"Fehler: {str(e)}", + status="failed", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +async def test_training_api() -> TestResult: + """Test Training Jobs API""" + start = time.time() + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(f"{BACKEND_URL}/api/training/jobs") + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + data = response.json() + count = len(data) if isinstance(data, list) else data.get("total", 0) + return TestResult( + name="Training Jobs API", + description="Prueft ob die Modell-Training-Verwaltung verfuegbar ist", + expected="Training-API verfuegbar", + actual=f"{count} Training Jobs", + status="passed", + duration_ms=duration + ) + elif response.status_code == 404: + return TestResult( + name="Training Jobs API", + description="Prueft ob die Modell-Training-Verwaltung verfuegbar ist", + expected="Training-API verfuegbar", + actual="Endpoint nicht implementiert", + status="skipped", + duration_ms=duration, + error_message="Training API nicht aktiviert" + ) + else: + return TestResult( + name="Training Jobs API", + description="Prueft ob die Modell-Training-Verwaltung verfuegbar ist", + expected="Training-API verfuegbar", + actual=f"HTTP {response.status_code}", + status="failed", + duration_ms=duration, + error_message=f"Unerwarteter Status: {response.status_code}" + ) + except Exception as e: + return TestResult( + name="Training Jobs API", + description="Prueft ob die Modell-Training-Verwaltung verfuegbar ist", + expected="Training-API verfuegbar", + actual=f"Fehler: {str(e)}", + status="failed", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +async def test_edu_search_api() -> TestResult: + """Test EduSearch RAG API""" + start = time.time() + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(f"{BACKEND_URL}/v1/edu-search/seeds") + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + data = response.json() + count = len(data) if isinstance(data, list) else data.get("total", 0) + return TestResult( + name="EduSearch Seeds API", + description="Prueft ob die Bildungs-Suchmaschinen-Seeds verfuegbar sind", + expected="EduSearch API verfuegbar", + actual=f"{count} Crawler Seeds", + status="passed", + duration_ms=duration + ) + elif response.status_code == 404: + return TestResult( + name="EduSearch Seeds API", + description="Prueft ob die Bildungs-Suchmaschinen-Seeds verfuegbar sind", + expected="EduSearch API verfuegbar", + actual="Endpoint nicht implementiert", + status="skipped", + duration_ms=duration, + error_message="EduSearch nicht aktiviert" + ) + else: + return TestResult( + name="EduSearch Seeds API", + description="Prueft ob die Bildungs-Suchmaschinen-Seeds verfuegbar sind", + expected="EduSearch API verfuegbar", + actual=f"HTTP {response.status_code}", + status="failed", + duration_ms=duration, + error_message=f"Unerwarteter Status" + ) + except Exception as e: + return TestResult( + name="EduSearch Seeds API", + description="Prueft ob die Bildungs-Suchmaschinen-Seeds verfuegbar sind", + expected="EduSearch API verfuegbar", + actual=f"Fehler: {str(e)}", + status="failed", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +# ============================================== +# Category Runners +# ============================================== + +async def run_vector_tests() -> TestCategoryResult: + """Run Vector DB tests""" + tests = await asyncio.gather( + test_vector_db_health(), + test_vector_collections(), + ) + + passed = sum(1 for t in tests if t.status == "passed") + failed = sum(1 for t in tests if t.status == "failed") + + return TestCategoryResult( + category="vector-db", + display_name="Vector Datenbank", + description="Tests fuer Qdrant Vector Store", + tests=list(tests), + passed=passed, + failed=failed, + total=len(tests) + ) + + +async def run_embedding_tests() -> TestCategoryResult: + """Run Embedding tests""" + tests = await asyncio.gather( + test_embedding_api(), + ) + + passed = sum(1 for t in tests if t.status == "passed") + failed = sum(1 for t in tests if t.status == "failed") + + return TestCategoryResult( + category="embeddings", + display_name="Embeddings", + description="Tests fuer Embedding-Generierung", + tests=list(tests), + passed=passed, + failed=failed, + total=len(tests) + ) + + +async def run_rag_tests() -> TestCategoryResult: + """Run RAG Pipeline tests""" + tests = await asyncio.gather( + test_document_api(), + test_edu_search_api(), + ) + + passed = sum(1 for t in tests if t.status == "passed") + failed = sum(1 for t in tests if t.status == "failed") + + return TestCategoryResult( + category="rag-pipeline", + display_name="RAG Pipeline", + description="Tests fuer Retrieval Augmented Generation", + tests=list(tests), + passed=passed, + failed=failed, + total=len(tests) + ) + + +async def run_training_tests() -> TestCategoryResult: + """Run Training tests""" + tests = await asyncio.gather( + test_training_api(), + ) + + passed = sum(1 for t in tests if t.status == "passed") + failed = sum(1 for t in tests if t.status == "failed") + + return TestCategoryResult( + category="training", + display_name="Model Training", + description="Tests fuer Fine-Tuning und Training Jobs", + tests=list(tests), + passed=passed, + failed=failed, + total=len(tests) + ) + + +# ============================================== +# API Endpoints +# ============================================== + +@router.post("/{category}", response_model=TestCategoryResult) +async def run_category_tests(category: str): + """Run tests for a specific category""" + runners = { + "vector-db": run_vector_tests, + "embeddings": run_embedding_tests, + "rag-pipeline": run_rag_tests, + "training": run_training_tests, + } + + if category not in runners: + return TestCategoryResult( + category=category, + display_name=f"Unbekannt: {category}", + description="Kategorie nicht gefunden", + tests=[], + passed=0, + failed=0, + total=0 + ) + + return await runners[category]() + + +@router.post("/run-all", response_model=FullTestResults) +async def run_all_tests(): + """Run all RAG tests""" + start = time.time() + + categories = await asyncio.gather( + run_vector_tests(), + run_embedding_tests(), + run_rag_tests(), + run_training_tests(), + ) + + total_passed = sum(c.passed for c in categories) + total_failed = sum(c.failed for c in categories) + total_tests = sum(c.total for c in categories) + + return FullTestResults( + categories=list(categories), + total_passed=total_passed, + total_failed=total_failed, + total_tests=total_tests, + duration_ms=(time.time() - start) * 1000 + ) + + +@router.get("/categories") +async def get_categories(): + """Get available test categories""" + return { + "categories": [ + {"id": "vector-db", "name": "Vector DB", "description": "Qdrant Health & Collections"}, + {"id": "embeddings", "name": "Embeddings", "description": "Embedding-Generierung"}, + {"id": "rag-pipeline", "name": "RAG Pipeline", "description": "Dokumente & Suche"}, + {"id": "training", "name": "Training", "description": "Fine-Tuning Jobs"}, + ] + } diff --git a/backend/rbac_api.py b/backend/rbac_api.py new file mode 100644 index 0000000..3ba257b --- /dev/null +++ b/backend/rbac_api.py @@ -0,0 +1,819 @@ +""" +RBAC API - Teacher and Role Management Endpoints + +Provides API endpoints for: +- Listing all teachers +- Listing all available roles +- Assigning/revoking roles to teachers +- Viewing role assignments per teacher + +Architecture: +- Authentication: Keycloak (when configured) or local JWT +- Authorization: Custom rbac.py for fine-grained permissions +""" + +import os +import asyncpg +from datetime import datetime, timezone +from typing import Optional, List, Dict, Any +from fastapi import APIRouter, HTTPException, Depends, Request +from pydantic import BaseModel + +# Import hybrid auth module +try: + from auth import get_current_user, TokenExpiredError, TokenInvalidError +except ImportError: + # Fallback for standalone testing + from auth.keycloak_auth import get_current_user, TokenExpiredError, TokenInvalidError + +# Configuration from environment - NO DEFAULT SECRETS +ENVIRONMENT = os.environ.get("ENVIRONMENT", "development") + +router = APIRouter(prefix="/rbac", tags=["rbac"]) + +# Connection pool +_pool: Optional[asyncpg.Pool] = None + + +def _get_database_url() -> str: + """Get DATABASE_URL from environment, raising error if not set.""" + url = os.environ.get("DATABASE_URL") + if not url: + raise RuntimeError("DATABASE_URL nicht konfiguriert - bitte via Vault oder Umgebungsvariable setzen") + return url + + +async def get_pool() -> asyncpg.Pool: + """Get or create database connection pool""" + global _pool + if _pool is None: + database_url = _get_database_url() + _pool = await asyncpg.create_pool(database_url, min_size=2, max_size=10) + return _pool + + +async def close_pool(): + """Close database connection pool""" + global _pool + if _pool: + await _pool.close() + _pool = None + + +# Pydantic Models +class RoleAssignmentCreate(BaseModel): + user_id: str + role: str + resource_type: str = "tenant" + resource_id: str + valid_to: Optional[str] = None + + +class RoleAssignmentRevoke(BaseModel): + assignment_id: str + + +class TeacherCreate(BaseModel): + email: str + first_name: str + last_name: str + teacher_code: Optional[str] = None + title: Optional[str] = None + roles: List[str] = [] + + +class TeacherUpdate(BaseModel): + email: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + teacher_code: Optional[str] = None + title: Optional[str] = None + is_active: Optional[bool] = None + + +class CustomRoleCreate(BaseModel): + role_key: str + display_name: str + description: str + category: str + + +class CustomRoleUpdate(BaseModel): + display_name: Optional[str] = None + description: Optional[str] = None + category: Optional[str] = None + + +class TeacherResponse(BaseModel): + id: str + user_id: str + email: str + name: str + teacher_code: Optional[str] + title: Optional[str] + first_name: str + last_name: str + is_active: bool + roles: List[str] + + +class RoleInfo(BaseModel): + role: str + display_name: str + description: str + category: str + + +class RoleAssignmentResponse(BaseModel): + id: str + user_id: str + role: str + resource_type: str + resource_id: str + valid_from: str + valid_to: Optional[str] + granted_at: str + is_active: bool + + +# Role definitions with German display names +AVAILABLE_ROLES = { + # Klausur-Korrekturkette + "erstkorrektor": { + "display_name": "Erstkorrektor", + "description": "Fuehrt die erste Korrektur der Klausur durch", + "category": "klausur" + }, + "zweitkorrektor": { + "display_name": "Zweitkorrektor", + "description": "Fuehrt die zweite Korrektur der Klausur durch", + "category": "klausur" + }, + "drittkorrektor": { + "display_name": "Drittkorrektor", + "description": "Fuehrt die dritte Korrektur bei Notenabweichung durch", + "category": "klausur" + }, + # Zeugnis-Workflow + "klassenlehrer": { + "display_name": "Klassenlehrer/in", + "description": "Erstellt Zeugnisse, traegt Kopfnoten und Bemerkungen ein", + "category": "zeugnis" + }, + "fachlehrer": { + "display_name": "Fachlehrer/in", + "description": "Traegt Fachnoten ein", + "category": "zeugnis" + }, + "zeugnisbeauftragter": { + "display_name": "Zeugnisbeauftragte/r", + "description": "Qualitaetskontrolle und Freigabe von Zeugnissen", + "category": "zeugnis" + }, + "sekretariat": { + "display_name": "Sekretariat", + "description": "Druck, Versand und Archivierung von Dokumenten", + "category": "verwaltung" + }, + # Leitung + "fachvorsitz": { + "display_name": "Fachvorsitz", + "description": "Fachpruefungsleitung und Qualitaetssicherung", + "category": "leitung" + }, + "pruefungsvorsitz": { + "display_name": "Pruefungsvorsitz", + "description": "Pruefungsleitung und finale Freigabe", + "category": "leitung" + }, + "schulleitung": { + "display_name": "Schulleitung", + "description": "Finale Freigabe und Unterschrift", + "category": "leitung" + }, + "stufenleitung": { + "display_name": "Stufenleitung", + "description": "Koordination einer Jahrgangsstufe", + "category": "leitung" + }, + # Administration + "schul_admin": { + "display_name": "Schul-Administrator", + "description": "Technische Administration der Schule", + "category": "admin" + }, + "teacher_assistant": { + "display_name": "Referendar/in", + "description": "Lehrkraft in Ausbildung mit eingeschraenkten Rechten", + "category": "other" + }, +} + + +# Note: get_user_from_token is replaced by the imported get_current_user dependency +# from auth module which supports both Keycloak and local JWT authentication + + +# API Endpoints + +@router.get("/roles") +async def list_available_roles() -> List[RoleInfo]: + """List all available roles with their descriptions""" + return [ + RoleInfo( + role=role_key, + display_name=role_data["display_name"], + description=role_data["description"], + category=role_data["category"] + ) + for role_key, role_data in AVAILABLE_ROLES.items() + ] + + +@router.get("/teachers") +async def list_teachers(user: Dict[str, Any] = Depends(get_current_user)) -> List[TeacherResponse]: + """List all teachers with their current roles""" + pool = await get_pool() + + async with pool.acquire() as conn: + # Get all teachers with their user info + teachers = await conn.fetch(""" + SELECT + t.id, t.user_id, t.teacher_code, t.title, + t.first_name, t.last_name, t.is_active, + u.email, u.name + FROM teachers t + JOIN users u ON t.user_id = u.id + WHERE t.school_id = 'a0000000-0000-0000-0000-000000000001' + ORDER BY t.last_name, t.first_name + """) + + # Get role assignments for all teachers + role_assignments = await conn.fetch(""" + SELECT user_id, role + FROM role_assignments + WHERE tenant_id = 'a0000000-0000-0000-0000-000000000001' + AND revoked_at IS NULL + AND (valid_to IS NULL OR valid_to > NOW()) + """) + + # Build role lookup + role_lookup: Dict[str, List[str]] = {} + for ra in role_assignments: + uid = str(ra["user_id"]) + if uid not in role_lookup: + role_lookup[uid] = [] + role_lookup[uid].append(ra["role"]) + + # Build response + result = [] + for t in teachers: + uid = str(t["user_id"]) + result.append(TeacherResponse( + id=str(t["id"]), + user_id=uid, + email=t["email"], + name=t["name"] or f"{t['first_name']} {t['last_name']}", + teacher_code=t["teacher_code"], + title=t["title"], + first_name=t["first_name"], + last_name=t["last_name"], + is_active=t["is_active"], + roles=role_lookup.get(uid, []) + )) + + return result + + +@router.get("/teachers/{teacher_id}/roles") +async def get_teacher_roles(teacher_id: str, user: Dict[str, Any] = Depends(get_current_user)) -> List[RoleAssignmentResponse]: + """Get all role assignments for a specific teacher""" + pool = await get_pool() + + async with pool.acquire() as conn: + # Get teacher's user_id + teacher = await conn.fetchrow( + "SELECT user_id FROM teachers WHERE id = $1", + teacher_id + ) + if not teacher: + raise HTTPException(status_code=404, detail="Teacher not found") + + # Get role assignments + assignments = await conn.fetch(""" + SELECT id, user_id, role, resource_type, resource_id, + valid_from, valid_to, granted_at, revoked_at + FROM role_assignments + WHERE user_id = $1 + ORDER BY granted_at DESC + """, teacher["user_id"]) + + return [ + RoleAssignmentResponse( + id=str(a["id"]), + user_id=str(a["user_id"]), + role=a["role"], + resource_type=a["resource_type"], + resource_id=str(a["resource_id"]), + valid_from=a["valid_from"].isoformat() if a["valid_from"] else None, + valid_to=a["valid_to"].isoformat() if a["valid_to"] else None, + granted_at=a["granted_at"].isoformat() if a["granted_at"] else None, + is_active=a["revoked_at"] is None and ( + a["valid_to"] is None or a["valid_to"] > datetime.now(timezone.utc) + ) + ) + for a in assignments + ] + + +@router.get("/roles/{role}/teachers") +async def get_teachers_by_role(role: str, user: Dict[str, Any] = Depends(get_current_user)) -> List[TeacherResponse]: + """Get all teachers with a specific role""" + if role not in AVAILABLE_ROLES: + raise HTTPException(status_code=400, detail=f"Unknown role: {role}") + + pool = await get_pool() + + async with pool.acquire() as conn: + teachers = await conn.fetch(""" + SELECT DISTINCT + t.id, t.user_id, t.teacher_code, t.title, + t.first_name, t.last_name, t.is_active, + u.email, u.name + FROM teachers t + JOIN users u ON t.user_id = u.id + JOIN role_assignments ra ON t.user_id = ra.user_id + WHERE ra.role = $1 + AND ra.revoked_at IS NULL + AND (ra.valid_to IS NULL OR ra.valid_to > NOW()) + AND t.school_id = 'a0000000-0000-0000-0000-000000000001' + ORDER BY t.last_name, t.first_name + """, role) + + # Get all roles for these teachers + if teachers: + user_ids = [t["user_id"] for t in teachers] + role_assignments = await conn.fetch(""" + SELECT user_id, role + FROM role_assignments + WHERE user_id = ANY($1) + AND revoked_at IS NULL + AND (valid_to IS NULL OR valid_to > NOW()) + """, user_ids) + + role_lookup: Dict[str, List[str]] = {} + for ra in role_assignments: + uid = str(ra["user_id"]) + if uid not in role_lookup: + role_lookup[uid] = [] + role_lookup[uid].append(ra["role"]) + else: + role_lookup = {} + + return [ + TeacherResponse( + id=str(t["id"]), + user_id=str(t["user_id"]), + email=t["email"], + name=t["name"] or f"{t['first_name']} {t['last_name']}", + teacher_code=t["teacher_code"], + title=t["title"], + first_name=t["first_name"], + last_name=t["last_name"], + is_active=t["is_active"], + roles=role_lookup.get(str(t["user_id"]), []) + ) + for t in teachers + ] + + +@router.post("/assignments") +async def assign_role(assignment: RoleAssignmentCreate, user: Dict[str, Any] = Depends(get_current_user)) -> RoleAssignmentResponse: + """Assign a role to a user""" + if assignment.role not in AVAILABLE_ROLES: + raise HTTPException(status_code=400, detail=f"Unknown role: {assignment.role}") + + pool = await get_pool() + + async with pool.acquire() as conn: + # Check if assignment already exists + existing = await conn.fetchrow(""" + SELECT id FROM role_assignments + WHERE user_id = $1 AND role = $2 AND resource_id = $3 + AND revoked_at IS NULL + """, assignment.user_id, assignment.role, assignment.resource_id) + + if existing: + raise HTTPException( + status_code=409, + detail="Role assignment already exists" + ) + + # Parse valid_to if provided + valid_to = None + if assignment.valid_to: + valid_to = datetime.fromisoformat(assignment.valid_to) + + # Create assignment + result = await conn.fetchrow(""" + INSERT INTO role_assignments + (user_id, role, resource_type, resource_id, tenant_id, valid_to, granted_by) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, user_id, role, resource_type, resource_id, valid_from, valid_to, granted_at + """, + assignment.user_id, + assignment.role, + assignment.resource_type, + assignment.resource_id, + assignment.resource_id, # tenant_id same as resource_id for tenant-level roles + valid_to, + user.get("user_id") + ) + + return RoleAssignmentResponse( + id=str(result["id"]), + user_id=str(result["user_id"]), + role=result["role"], + resource_type=result["resource_type"], + resource_id=str(result["resource_id"]), + valid_from=result["valid_from"].isoformat(), + valid_to=result["valid_to"].isoformat() if result["valid_to"] else None, + granted_at=result["granted_at"].isoformat(), + is_active=True + ) + + +@router.delete("/assignments/{assignment_id}") +async def revoke_role(assignment_id: str, user: Dict[str, Any] = Depends(get_current_user)): + """Revoke a role assignment""" + pool = await get_pool() + + async with pool.acquire() as conn: + result = await conn.execute(""" + UPDATE role_assignments + SET revoked_at = NOW() + WHERE id = $1 AND revoked_at IS NULL + """, assignment_id) + + if result == "UPDATE 0": + raise HTTPException(status_code=404, detail="Assignment not found or already revoked") + + return {"status": "revoked", "assignment_id": assignment_id} + + +@router.get("/summary") +async def get_role_summary(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]: + """Get a summary of roles and their assignment counts""" + pool = await get_pool() + + async with pool.acquire() as conn: + counts = await conn.fetch(""" + SELECT role, COUNT(*) as count + FROM role_assignments + WHERE tenant_id = 'a0000000-0000-0000-0000-000000000001' + AND revoked_at IS NULL + AND (valid_to IS NULL OR valid_to > NOW()) + GROUP BY role + ORDER BY role + """) + + total_teachers = await conn.fetchval(""" + SELECT COUNT(*) FROM teachers + WHERE school_id = 'a0000000-0000-0000-0000-000000000001' + AND is_active = true + """) + + role_counts = {c["role"]: c["count"] for c in counts} + + # Also include custom roles from database + custom_roles = await conn.fetch(""" + SELECT role_key, display_name, category + FROM custom_roles + WHERE tenant_id = 'a0000000-0000-0000-0000-000000000001' + AND is_active = true + """) + + all_roles = [ + { + "role": role_key, + "display_name": role_data["display_name"], + "category": role_data["category"], + "count": role_counts.get(role_key, 0), + "is_custom": False + } + for role_key, role_data in AVAILABLE_ROLES.items() + ] + + for cr in custom_roles: + all_roles.append({ + "role": cr["role_key"], + "display_name": cr["display_name"], + "category": cr["category"], + "count": role_counts.get(cr["role_key"], 0), + "is_custom": True + }) + + return { + "total_teachers": total_teachers, + "roles": all_roles + } + + +# ========================================== +# TEACHER MANAGEMENT ENDPOINTS +# ========================================== + +@router.post("/teachers") +async def create_teacher(teacher: TeacherCreate, user: Dict[str, Any] = Depends(get_current_user)) -> TeacherResponse: + """Create a new teacher with optional initial roles""" + pool = await get_pool() + + import uuid + + async with pool.acquire() as conn: + # Check if email already exists + existing = await conn.fetchrow( + "SELECT id FROM users WHERE email = $1", + teacher.email + ) + if existing: + raise HTTPException(status_code=409, detail="Email already exists") + + # Generate UUIDs + user_id = str(uuid.uuid4()) + teacher_id = str(uuid.uuid4()) + + # Create user first + await conn.execute(""" + INSERT INTO users (id, email, name, password_hash, role, is_active) + VALUES ($1, $2, $3, '', 'teacher', true) + """, user_id, teacher.email, f"{teacher.first_name} {teacher.last_name}") + + # Create teacher record + await conn.execute(""" + INSERT INTO teachers (id, user_id, school_id, first_name, last_name, teacher_code, title, is_active) + VALUES ($1, $2, 'a0000000-0000-0000-0000-000000000001', $3, $4, $5, $6, true) + """, teacher_id, user_id, teacher.first_name, teacher.last_name, + teacher.teacher_code, teacher.title) + + # Assign initial roles if provided + assigned_roles = [] + for role in teacher.roles: + if role in AVAILABLE_ROLES or await conn.fetchrow( + "SELECT 1 FROM custom_roles WHERE role_key = $1 AND is_active = true", role + ): + await conn.execute(""" + INSERT INTO role_assignments (user_id, role, resource_type, resource_id, tenant_id, granted_by) + VALUES ($1, $2, 'tenant', 'a0000000-0000-0000-0000-000000000001', + 'a0000000-0000-0000-0000-000000000001', $3) + """, user_id, role, user.get("user_id")) + assigned_roles.append(role) + + return TeacherResponse( + id=teacher_id, + user_id=user_id, + email=teacher.email, + name=f"{teacher.first_name} {teacher.last_name}", + teacher_code=teacher.teacher_code, + title=teacher.title, + first_name=teacher.first_name, + last_name=teacher.last_name, + is_active=True, + roles=assigned_roles + ) + + +@router.put("/teachers/{teacher_id}") +async def update_teacher(teacher_id: str, updates: TeacherUpdate, user: Dict[str, Any] = Depends(get_current_user)) -> TeacherResponse: + """Update teacher information""" + pool = await get_pool() + + async with pool.acquire() as conn: + # Get current teacher data + teacher = await conn.fetchrow(""" + SELECT t.id, t.user_id, t.teacher_code, t.title, t.first_name, t.last_name, t.is_active, + u.email, u.name + FROM teachers t + JOIN users u ON t.user_id = u.id + WHERE t.id = $1 + """, teacher_id) + + if not teacher: + raise HTTPException(status_code=404, detail="Teacher not found") + + # Build update queries + if updates.email: + await conn.execute("UPDATE users SET email = $1 WHERE id = $2", + updates.email, teacher["user_id"]) + + teacher_updates = [] + teacher_values = [] + idx = 1 + + if updates.first_name: + teacher_updates.append(f"first_name = ${idx}") + teacher_values.append(updates.first_name) + idx += 1 + if updates.last_name: + teacher_updates.append(f"last_name = ${idx}") + teacher_values.append(updates.last_name) + idx += 1 + if updates.teacher_code is not None: + teacher_updates.append(f"teacher_code = ${idx}") + teacher_values.append(updates.teacher_code) + idx += 1 + if updates.title is not None: + teacher_updates.append(f"title = ${idx}") + teacher_values.append(updates.title) + idx += 1 + if updates.is_active is not None: + teacher_updates.append(f"is_active = ${idx}") + teacher_values.append(updates.is_active) + idx += 1 + + if teacher_updates: + teacher_values.append(teacher_id) + await conn.execute( + f"UPDATE teachers SET {', '.join(teacher_updates)} WHERE id = ${idx}", + *teacher_values + ) + + # Update user name if first/last name changed + if updates.first_name or updates.last_name: + new_first = updates.first_name or teacher["first_name"] + new_last = updates.last_name or teacher["last_name"] + await conn.execute("UPDATE users SET name = $1 WHERE id = $2", + f"{new_first} {new_last}", teacher["user_id"]) + + # Fetch updated data + updated = await conn.fetchrow(""" + SELECT t.id, t.user_id, t.teacher_code, t.title, t.first_name, t.last_name, t.is_active, + u.email, u.name + FROM teachers t + JOIN users u ON t.user_id = u.id + WHERE t.id = $1 + """, teacher_id) + + # Get roles + roles = await conn.fetch(""" + SELECT role FROM role_assignments + WHERE user_id = $1 AND revoked_at IS NULL + AND (valid_to IS NULL OR valid_to > NOW()) + """, updated["user_id"]) + + return TeacherResponse( + id=str(updated["id"]), + user_id=str(updated["user_id"]), + email=updated["email"], + name=updated["name"], + teacher_code=updated["teacher_code"], + title=updated["title"], + first_name=updated["first_name"], + last_name=updated["last_name"], + is_active=updated["is_active"], + roles=[r["role"] for r in roles] + ) + + +@router.delete("/teachers/{teacher_id}") +async def deactivate_teacher(teacher_id: str, user: Dict[str, Any] = Depends(get_current_user)): + """Deactivate a teacher (soft delete)""" + pool = await get_pool() + + async with pool.acquire() as conn: + result = await conn.execute(""" + UPDATE teachers SET is_active = false WHERE id = $1 + """, teacher_id) + + if result == "UPDATE 0": + raise HTTPException(status_code=404, detail="Teacher not found") + + return {"status": "deactivated", "teacher_id": teacher_id} + + +# ========================================== +# CUSTOM ROLE MANAGEMENT ENDPOINTS +# ========================================== + +@router.get("/custom-roles") +async def list_custom_roles(user: Dict[str, Any] = Depends(get_current_user)) -> List[RoleInfo]: + """List all custom roles""" + pool = await get_pool() + + async with pool.acquire() as conn: + roles = await conn.fetch(""" + SELECT role_key, display_name, description, category + FROM custom_roles + WHERE tenant_id = 'a0000000-0000-0000-0000-000000000001' + AND is_active = true + ORDER BY category, display_name + """) + + return [ + RoleInfo( + role=r["role_key"], + display_name=r["display_name"], + description=r["description"], + category=r["category"] + ) + for r in roles + ] + + +@router.post("/custom-roles") +async def create_custom_role(role: CustomRoleCreate, user: Dict[str, Any] = Depends(get_current_user)) -> RoleInfo: + """Create a new custom role""" + pool = await get_pool() + + # Check if role_key conflicts with built-in roles + if role.role_key in AVAILABLE_ROLES: + raise HTTPException(status_code=409, detail="Role key conflicts with built-in role") + + async with pool.acquire() as conn: + # Check if custom role already exists + existing = await conn.fetchrow(""" + SELECT id FROM custom_roles + WHERE role_key = $1 AND tenant_id = 'a0000000-0000-0000-0000-000000000001' + """, role.role_key) + + if existing: + raise HTTPException(status_code=409, detail="Custom role already exists") + + await conn.execute(""" + INSERT INTO custom_roles (role_key, display_name, description, category, tenant_id, created_by) + VALUES ($1, $2, $3, $4, 'a0000000-0000-0000-0000-000000000001', $5) + """, role.role_key, role.display_name, role.description, role.category, user.get("user_id")) + + return RoleInfo( + role=role.role_key, + display_name=role.display_name, + description=role.description, + category=role.category + ) + + +@router.put("/custom-roles/{role_key}") +async def update_custom_role(role_key: str, updates: CustomRoleUpdate, user: Dict[str, Any] = Depends(get_current_user)) -> RoleInfo: + """Update a custom role""" + + if role_key in AVAILABLE_ROLES: + raise HTTPException(status_code=400, detail="Cannot modify built-in roles") + + pool = await get_pool() + + async with pool.acquire() as conn: + current = await conn.fetchrow(""" + SELECT role_key, display_name, description, category + FROM custom_roles + WHERE role_key = $1 AND tenant_id = 'a0000000-0000-0000-0000-000000000001' + AND is_active = true + """, role_key) + + if not current: + raise HTTPException(status_code=404, detail="Custom role not found") + + new_display = updates.display_name or current["display_name"] + new_desc = updates.description or current["description"] + new_cat = updates.category or current["category"] + + await conn.execute(""" + UPDATE custom_roles + SET display_name = $1, description = $2, category = $3 + WHERE role_key = $4 AND tenant_id = 'a0000000-0000-0000-0000-000000000001' + """, new_display, new_desc, new_cat, role_key) + + return RoleInfo( + role=role_key, + display_name=new_display, + description=new_desc, + category=new_cat + ) + + +@router.delete("/custom-roles/{role_key}") +async def delete_custom_role(role_key: str, user: Dict[str, Any] = Depends(get_current_user)): + """Delete a custom role (soft delete)""" + + if role_key in AVAILABLE_ROLES: + raise HTTPException(status_code=400, detail="Cannot delete built-in roles") + + pool = await get_pool() + + async with pool.acquire() as conn: + # Soft delete the role + result = await conn.execute(""" + UPDATE custom_roles SET is_active = false + WHERE role_key = $1 AND tenant_id = 'a0000000-0000-0000-0000-000000000001' + """, role_key) + + if result == "UPDATE 0": + raise HTTPException(status_code=404, detail="Custom role not found") + + # Also revoke all assignments with this role + await conn.execute(""" + UPDATE role_assignments SET revoked_at = NOW() + WHERE role = $1 AND tenant_id = 'a0000000-0000-0000-0000-000000000001' + AND revoked_at IS NULL + """, role_key) + + return {"status": "deleted", "role_key": role_key} diff --git a/backend/rbac_test_api.py b/backend/rbac_test_api.py new file mode 100644 index 0000000..c2f06f1 --- /dev/null +++ b/backend/rbac_test_api.py @@ -0,0 +1,722 @@ +""" +RBAC Test API - Test Runner fuer Rollen- und Berechtigungsverwaltung +Endpoint: /api/admin/rbac-tests +""" + +from fastapi import APIRouter +from pydantic import BaseModel +from typing import List, Optional, Literal +import httpx +import asyncio +import time +import os + +router = APIRouter(prefix="/api/admin/rbac-tests", tags=["RBAC Tests"]) + +# ============================================== +# Models +# ============================================== + +class TestResult(BaseModel): + name: str + description: str + expected: str + actual: str + status: Literal["passed", "failed", "pending", "skipped"] + duration_ms: float + error_message: Optional[str] = None + + +class TestCategoryResult(BaseModel): + category: str + display_name: str + description: str + tests: List[TestResult] + passed: int + failed: int + total: int + + +class FullTestResults(BaseModel): + categories: List[TestCategoryResult] + total_passed: int + total_failed: int + total_tests: int + duration_ms: float + + +# ============================================== +# Configuration +# ============================================== + +BACKEND_URL = os.getenv("BACKEND_URL", "http://localhost:8000") +CONSENT_SERVICE_URL = os.getenv("CONSENT_SERVICE_URL", "http://consent-service:8081") + + +# ============================================== +# Test Implementations - Authentication +# ============================================== + +async def test_auth_api_health() -> TestResult: + """Test Auth API Health""" + start = time.time() + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(f"{BACKEND_URL}/api/auth/health") + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + return TestResult( + name="Auth API Health", + description="Prueft ob die Authentifizierungs-API erreichbar ist", + expected="Auth API aktiv", + actual="Auth API laeuft", + status="passed", + duration_ms=duration + ) + elif response.status_code == 404: + return TestResult( + name="Auth API Health", + description="Prueft ob die Authentifizierungs-API erreichbar ist", + expected="Auth API aktiv", + actual="Endpoint nicht implementiert", + status="skipped", + duration_ms=duration, + error_message="Auth Health Endpoint fehlt" + ) + else: + return TestResult( + name="Auth API Health", + description="Prueft ob die Authentifizierungs-API erreichbar ist", + expected="Auth API aktiv", + actual=f"HTTP {response.status_code}", + status="failed", + duration_ms=duration, + error_message="Auth API nicht erreichbar" + ) + except Exception as e: + return TestResult( + name="Auth API Health", + description="Prueft ob die Authentifizierungs-API erreichbar ist", + expected="Auth API aktiv", + actual=f"Fehler: {str(e)}", + status="failed", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +async def test_jwt_validation() -> TestResult: + """Test JWT Token Validation""" + start = time.time() + try: + async with httpx.AsyncClient(timeout=10.0) as client: + # Test mit ungueltigem Token - sollte 401 zurueckgeben + response = await client.get( + f"{BACKEND_URL}/api/auth/me", + headers={"Authorization": "Bearer invalid-token"} + ) + duration = (time.time() - start) * 1000 + + if response.status_code == 401: + return TestResult( + name="JWT Validierung", + description="Prueft ob ungueltige Tokens abgelehnt werden", + expected="401 fuer ungueltige Tokens", + actual="401 Unauthorized - korrekt abgelehnt", + status="passed", + duration_ms=duration + ) + elif response.status_code == 404: + return TestResult( + name="JWT Validierung", + description="Prueft ob ungueltige Tokens abgelehnt werden", + expected="401 fuer ungueltige Tokens", + actual="Endpoint nicht implementiert", + status="skipped", + duration_ms=duration, + error_message="/api/auth/me Endpoint fehlt" + ) + else: + return TestResult( + name="JWT Validierung", + description="Prueft ob ungueltige Tokens abgelehnt werden", + expected="401 fuer ungueltige Tokens", + actual=f"HTTP {response.status_code}", + status="failed", + duration_ms=duration, + error_message="JWT Validierung nicht korrekt" + ) + except Exception as e: + return TestResult( + name="JWT Validierung", + description="Prueft ob ungueltige Tokens abgelehnt werden", + expected="401 fuer ungueltige Tokens", + actual=f"Fehler: {str(e)}", + status="failed", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +async def test_login_endpoint() -> TestResult: + """Test Login Endpoint exists""" + start = time.time() + try: + async with httpx.AsyncClient(timeout=10.0) as client: + # OPTIONS Request um zu pruefen ob Endpoint existiert + response = await client.post( + f"{BACKEND_URL}/api/auth/login", + json={"email": "test@example.com", "password": "wrong"} + ) + duration = (time.time() - start) * 1000 + + # 401 ist erwartet (falsche Credentials), aber zeigt dass Endpoint existiert + if response.status_code in [401, 400, 422]: + return TestResult( + name="Login Endpoint", + description="Prueft ob der Login-Endpoint verfuegbar ist", + expected="Login Endpoint aktiv", + actual=f"Endpoint aktiv (HTTP {response.status_code})", + status="passed", + duration_ms=duration + ) + elif response.status_code == 404: + return TestResult( + name="Login Endpoint", + description="Prueft ob der Login-Endpoint verfuegbar ist", + expected="Login Endpoint aktiv", + actual="Endpoint nicht implementiert", + status="skipped", + duration_ms=duration, + error_message="Login Endpoint fehlt" + ) + else: + return TestResult( + name="Login Endpoint", + description="Prueft ob der Login-Endpoint verfuegbar ist", + expected="Login Endpoint aktiv", + actual=f"HTTP {response.status_code}", + status="passed", + duration_ms=duration + ) + except Exception as e: + return TestResult( + name="Login Endpoint", + description="Prueft ob der Login-Endpoint verfuegbar ist", + expected="Login Endpoint aktiv", + actual=f"Fehler: {str(e)}", + status="failed", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +# ============================================== +# Test Implementations - Roles +# ============================================== + +async def test_roles_api() -> TestResult: + """Test Roles API""" + start = time.time() + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(f"{BACKEND_URL}/api/rbac/roles") + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + data = response.json() + count = len(data) if isinstance(data, list) else data.get("total", 0) + return TestResult( + name="Rollen-API", + description="Prueft ob die Rollen-Verwaltung verfuegbar ist", + expected="Rollen-API aktiv", + actual=f"{count} Rollen definiert", + status="passed", + duration_ms=duration + ) + elif response.status_code == 401: + return TestResult( + name="Rollen-API", + description="Prueft ob die Rollen-Verwaltung verfuegbar ist", + expected="Rollen-API aktiv", + actual="Authentifizierung erforderlich", + status="passed", + duration_ms=duration, + error_message="API erfordert Auth (korrekt)" + ) + elif response.status_code == 404: + return TestResult( + name="Rollen-API", + description="Prueft ob die Rollen-Verwaltung verfuegbar ist", + expected="Rollen-API aktiv", + actual="Endpoint nicht implementiert", + status="skipped", + duration_ms=duration, + error_message="RBAC Roles Endpoint fehlt" + ) + else: + return TestResult( + name="Rollen-API", + description="Prueft ob die Rollen-Verwaltung verfuegbar ist", + expected="Rollen-API aktiv", + actual=f"HTTP {response.status_code}", + status="failed", + duration_ms=duration, + error_message=f"Unerwarteter Status" + ) + except Exception as e: + return TestResult( + name="Rollen-API", + description="Prueft ob die Rollen-Verwaltung verfuegbar ist", + expected="Rollen-API aktiv", + actual=f"Fehler: {str(e)}", + status="failed", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +async def test_default_roles() -> TestResult: + """Test Default Roles exist""" + start = time.time() + expected_roles = ["user", "admin", "data_protection_officer"] + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(f"{BACKEND_URL}/api/rbac/roles") + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + data = response.json() + roles = data if isinstance(data, list) else data.get("roles", []) + role_names = [r.get("name", r) if isinstance(r, dict) else r for r in roles] + + found_roles = [r for r in expected_roles if r in role_names] + missing_roles = [r for r in expected_roles if r not in role_names] + + if len(missing_roles) == 0: + return TestResult( + name="Standard-Rollen", + description="Prueft ob alle Standard-Rollen existieren", + expected="user, admin, data_protection_officer", + actual=f"Alle {len(found_roles)} Standard-Rollen vorhanden", + status="passed", + duration_ms=duration + ) + else: + return TestResult( + name="Standard-Rollen", + description="Prueft ob alle Standard-Rollen existieren", + expected="user, admin, data_protection_officer", + actual=f"Fehlend: {', '.join(missing_roles)}", + status="failed", + duration_ms=duration, + error_message=f"{len(missing_roles)} Rollen fehlen" + ) + elif response.status_code in [401, 404]: + return TestResult( + name="Standard-Rollen", + description="Prueft ob alle Standard-Rollen existieren", + expected="user, admin, data_protection_officer", + actual="API nicht verfuegbar", + status="skipped", + duration_ms=duration, + error_message="Roles API nicht erreichbar" + ) + else: + return TestResult( + name="Standard-Rollen", + description="Prueft ob alle Standard-Rollen existieren", + expected="user, admin, data_protection_officer", + actual=f"HTTP {response.status_code}", + status="failed", + duration_ms=duration, + error_message="Unerwartete Antwort" + ) + except Exception as e: + return TestResult( + name="Standard-Rollen", + description="Prueft ob alle Standard-Rollen existieren", + expected="user, admin, data_protection_officer", + actual=f"Fehler: {str(e)}", + status="failed", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +# ============================================== +# Test Implementations - Permissions +# ============================================== + +async def test_permissions_api() -> TestResult: + """Test Permissions API""" + start = time.time() + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(f"{BACKEND_URL}/api/rbac/permissions") + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + data = response.json() + count = len(data) if isinstance(data, list) else data.get("total", 0) + return TestResult( + name="Berechtigungs-API", + description="Prueft ob die Berechtigungs-Verwaltung verfuegbar ist", + expected="Permissions-API aktiv", + actual=f"{count} Berechtigungen definiert", + status="passed", + duration_ms=duration + ) + elif response.status_code == 401: + return TestResult( + name="Berechtigungs-API", + description="Prueft ob die Berechtigungs-Verwaltung verfuegbar ist", + expected="Permissions-API aktiv", + actual="Authentifizierung erforderlich", + status="passed", + duration_ms=duration, + error_message="API erfordert Auth (korrekt)" + ) + elif response.status_code == 404: + return TestResult( + name="Berechtigungs-API", + description="Prueft ob die Berechtigungs-Verwaltung verfuegbar ist", + expected="Permissions-API aktiv", + actual="Endpoint nicht implementiert", + status="skipped", + duration_ms=duration, + error_message="RBAC Permissions Endpoint fehlt" + ) + else: + return TestResult( + name="Berechtigungs-API", + description="Prueft ob die Berechtigungs-Verwaltung verfuegbar ist", + expected="Permissions-API aktiv", + actual=f"HTTP {response.status_code}", + status="failed", + duration_ms=duration, + error_message="Unerwarteter Status" + ) + except Exception as e: + return TestResult( + name="Berechtigungs-API", + description="Prueft ob die Berechtigungs-Verwaltung verfuegbar ist", + expected="Permissions-API aktiv", + actual=f"Fehler: {str(e)}", + status="failed", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +async def test_role_permissions_mapping() -> TestResult: + """Test Role-Permissions Mapping""" + start = time.time() + try: + async with httpx.AsyncClient(timeout=10.0) as client: + # Versuche admin Rolle abzurufen + response = await client.get(f"{BACKEND_URL}/api/rbac/roles/admin/permissions") + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + data = response.json() + permissions = data if isinstance(data, list) else data.get("permissions", []) + return TestResult( + name="Rollen-Berechtigungs-Zuordnung", + description="Prueft ob Berechtigungen Rollen zugeordnet werden koennen", + expected="Mapping funktioniert", + actual=f"Admin hat {len(permissions)} Berechtigungen", + status="passed", + duration_ms=duration + ) + elif response.status_code in [401, 404]: + return TestResult( + name="Rollen-Berechtigungs-Zuordnung", + description="Prueft ob Berechtigungen Rollen zugeordnet werden koennen", + expected="Mapping funktioniert", + actual="API nicht verfuegbar", + status="skipped", + duration_ms=duration, + error_message="Role-Permissions Endpoint fehlt" + ) + else: + return TestResult( + name="Rollen-Berechtigungs-Zuordnung", + description="Prueft ob Berechtigungen Rollen zugeordnet werden koennen", + expected="Mapping funktioniert", + actual=f"HTTP {response.status_code}", + status="failed", + duration_ms=duration, + error_message="Unerwartete Antwort" + ) + except Exception as e: + return TestResult( + name="Rollen-Berechtigungs-Zuordnung", + description="Prueft ob Berechtigungen Rollen zugeordnet werden koennen", + expected="Mapping funktioniert", + actual=f"Fehler: {str(e)}", + status="failed", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +# ============================================== +# Test Implementations - Users +# ============================================== + +async def test_users_api() -> TestResult: + """Test Users API""" + start = time.time() + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(f"{BACKEND_URL}/api/rbac/users") + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + data = response.json() + count = len(data) if isinstance(data, list) else data.get("total", 0) + return TestResult( + name="Benutzer-API", + description="Prueft ob die Benutzer-Verwaltung verfuegbar ist", + expected="Users-API aktiv", + actual=f"{count} Benutzer registriert", + status="passed", + duration_ms=duration + ) + elif response.status_code == 401: + return TestResult( + name="Benutzer-API", + description="Prueft ob die Benutzer-Verwaltung verfuegbar ist", + expected="Users-API aktiv", + actual="Authentifizierung erforderlich", + status="passed", + duration_ms=duration, + error_message="API erfordert Auth (korrekt)" + ) + elif response.status_code == 404: + return TestResult( + name="Benutzer-API", + description="Prueft ob die Benutzer-Verwaltung verfuegbar ist", + expected="Users-API aktiv", + actual="Endpoint nicht implementiert", + status="skipped", + duration_ms=duration, + error_message="RBAC Users Endpoint fehlt" + ) + else: + return TestResult( + name="Benutzer-API", + description="Prueft ob die Benutzer-Verwaltung verfuegbar ist", + expected="Users-API aktiv", + actual=f"HTTP {response.status_code}", + status="failed", + duration_ms=duration, + error_message="Unerwarteter Status" + ) + except Exception as e: + return TestResult( + name="Benutzer-API", + description="Prueft ob die Benutzer-Verwaltung verfuegbar ist", + expected="Users-API aktiv", + actual=f"Fehler: {str(e)}", + status="failed", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +async def test_consent_service_auth() -> TestResult: + """Test Consent Service Authentication""" + start = time.time() + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(f"{CONSENT_SERVICE_URL}/health") + duration = (time.time() - start) * 1000 + + if response.status_code == 200: + return TestResult( + name="Consent Service Auth", + description="Prueft ob der Go Consent Service fuer Auth erreichbar ist", + expected="Consent Service erreichbar", + actual="Service aktiv", + status="passed", + duration_ms=duration + ) + else: + return TestResult( + name="Consent Service Auth", + description="Prueft ob der Go Consent Service fuer Auth erreichbar ist", + expected="Consent Service erreichbar", + actual=f"HTTP {response.status_code}", + status="failed", + duration_ms=duration, + error_message="Consent Service nicht erreichbar" + ) + except Exception as e: + return TestResult( + name="Consent Service Auth", + description="Prueft ob der Go Consent Service fuer Auth erreichbar ist", + expected="Consent Service erreichbar", + actual="Nicht verfuegbar", + status="skipped", + duration_ms=(time.time() - start) * 1000, + error_message=str(e) + ) + + +# ============================================== +# Category Runners +# ============================================== + +async def run_auth_tests() -> TestCategoryResult: + """Run Authentication tests""" + tests = await asyncio.gather( + test_auth_api_health(), + test_jwt_validation(), + test_login_endpoint(), + ) + + passed = sum(1 for t in tests if t.status == "passed") + failed = sum(1 for t in tests if t.status == "failed") + + return TestCategoryResult( + category="authentication", + display_name="Authentifizierung", + description="Tests fuer Login und JWT-Validierung", + tests=list(tests), + passed=passed, + failed=failed, + total=len(tests) + ) + + +async def run_roles_tests() -> TestCategoryResult: + """Run Roles tests""" + tests = await asyncio.gather( + test_roles_api(), + test_default_roles(), + ) + + passed = sum(1 for t in tests if t.status == "passed") + failed = sum(1 for t in tests if t.status == "failed") + + return TestCategoryResult( + category="roles", + display_name="Rollen", + description="Tests fuer Rollen-Verwaltung", + tests=list(tests), + passed=passed, + failed=failed, + total=len(tests) + ) + + +async def run_permissions_tests() -> TestCategoryResult: + """Run Permissions tests""" + tests = await asyncio.gather( + test_permissions_api(), + test_role_permissions_mapping(), + ) + + passed = sum(1 for t in tests if t.status == "passed") + failed = sum(1 for t in tests if t.status == "failed") + + return TestCategoryResult( + category="permissions", + display_name="Berechtigungen", + description="Tests fuer Berechtigungs-Verwaltung", + tests=list(tests), + passed=passed, + failed=failed, + total=len(tests) + ) + + +async def run_users_tests() -> TestCategoryResult: + """Run Users tests""" + tests = await asyncio.gather( + test_users_api(), + test_consent_service_auth(), + ) + + passed = sum(1 for t in tests if t.status == "passed") + failed = sum(1 for t in tests if t.status == "failed") + + return TestCategoryResult( + category="users", + display_name="Benutzer", + description="Tests fuer Benutzer-Verwaltung", + tests=list(tests), + passed=passed, + failed=failed, + total=len(tests) + ) + + +# ============================================== +# API Endpoints +# ============================================== + +@router.post("/{category}", response_model=TestCategoryResult) +async def run_category_tests(category: str): + """Run tests for a specific category""" + runners = { + "authentication": run_auth_tests, + "roles": run_roles_tests, + "permissions": run_permissions_tests, + "users": run_users_tests, + } + + if category not in runners: + return TestCategoryResult( + category=category, + display_name=f"Unbekannt: {category}", + description="Kategorie nicht gefunden", + tests=[], + passed=0, + failed=0, + total=0 + ) + + return await runners[category]() + + +@router.post("/run-all", response_model=FullTestResults) +async def run_all_tests(): + """Run all RBAC tests""" + start = time.time() + + categories = await asyncio.gather( + run_auth_tests(), + run_roles_tests(), + run_permissions_tests(), + run_users_tests(), + ) + + total_passed = sum(c.passed for c in categories) + total_failed = sum(c.failed for c in categories) + total_tests = sum(c.total for c in categories) + + return FullTestResults( + categories=list(categories), + total_passed=total_passed, + total_failed=total_failed, + total_tests=total_tests, + duration_ms=(time.time() - start) * 1000 + ) + + +@router.get("/categories") +async def get_categories(): + """Get available test categories""" + return { + "categories": [ + {"id": "authentication", "name": "Authentifizierung", "description": "Login & JWT"}, + {"id": "roles", "name": "Rollen", "description": "Rollenverwaltung"}, + {"id": "permissions", "name": "Berechtigungen", "description": "Berechtigungszuordnung"}, + {"id": "users", "name": "Benutzer", "description": "Benutzerverwaltung"}, + ] + } diff --git a/backend/recording_api.py b/backend/recording_api.py new file mode 100644 index 0000000..22f0d4c --- /dev/null +++ b/backend/recording_api.py @@ -0,0 +1,848 @@ +""" +BreakPilot Recording API + +Verwaltet Jibri Meeting-Aufzeichnungen und deren Metadaten. +Empfaengt Webhooks von Jibri nach Upload zu MinIO. +""" + +import os +import uuid +from datetime import datetime, timedelta +from typing import Optional, List +from pydantic import BaseModel, Field + +from fastapi import APIRouter, HTTPException, Query, Depends, Request +from fastapi.responses import JSONResponse + +router = APIRouter(prefix="/api/recordings", tags=["Recordings"]) + +# ========================================== +# ENVIRONMENT CONFIGURATION +# ========================================== + +MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minio:9000") +MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "breakpilot") +MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "breakpilot123") +MINIO_BUCKET = os.getenv("MINIO_BUCKET", "breakpilot-recordings") +MINIO_SECURE = os.getenv("MINIO_SECURE", "false").lower() == "true" + +# Default retention period in days (DSGVO compliance) +DEFAULT_RETENTION_DAYS = int(os.getenv("RECORDING_RETENTION_DAYS", "365")) + + +# ========================================== +# PYDANTIC MODELS +# ========================================== + +class JibriWebhookPayload(BaseModel): + """Webhook payload from Jibri finalize.sh script.""" + event: str = Field(..., description="Event type: recording_completed") + recording_name: str = Field(..., description="Unique recording identifier") + storage_path: str = Field(..., description="Path in MinIO bucket") + audio_path: Optional[str] = Field(None, description="Extracted audio path") + file_size_bytes: int = Field(..., description="Video file size in bytes") + timestamp: str = Field(..., description="ISO timestamp of upload") + + +class RecordingCreate(BaseModel): + """Manual recording creation (for testing).""" + meeting_id: str + title: Optional[str] = None + storage_path: str + audio_path: Optional[str] = None + duration_seconds: Optional[int] = None + participant_count: Optional[int] = 0 + retention_days: Optional[int] = DEFAULT_RETENTION_DAYS + + +class RecordingResponse(BaseModel): + """Recording details response.""" + id: str + meeting_id: str + title: Optional[str] + storage_path: str + audio_path: Optional[str] + file_size_bytes: Optional[int] + duration_seconds: Optional[int] + participant_count: int + status: str + recorded_at: datetime + retention_days: int + retention_expires_at: datetime + transcription_status: Optional[str] = None + transcription_id: Optional[str] = None + + +class RecordingListResponse(BaseModel): + """Paginated list of recordings.""" + recordings: List[RecordingResponse] + total: int + page: int + page_size: int + + +class TranscriptionRequest(BaseModel): + """Request to start transcription.""" + language: str = Field(default="de", description="Language code: de, en, etc.") + model: str = Field(default="large-v3", description="Whisper model to use") + priority: int = Field(default=0, description="Queue priority (higher = sooner)") + + +class TranscriptionStatusResponse(BaseModel): + """Transcription status and progress.""" + id: str + recording_id: str + status: str + language: str + model: str + word_count: Optional[int] + confidence_score: Optional[float] + processing_duration_seconds: Optional[int] + error_message: Optional[str] + created_at: datetime + completed_at: Optional[datetime] + + +# ========================================== +# IN-MEMORY STORAGE (Dev Mode) +# ========================================== +# In production, these would be database queries + +_recordings_store: dict = {} +_transcriptions_store: dict = {} +_audit_log: list = [] + + +def log_audit( + action: str, + recording_id: Optional[str] = None, + transcription_id: Optional[str] = None, + user_id: Optional[str] = None, + metadata: Optional[dict] = None +): + """Log audit event for DSGVO compliance.""" + _audit_log.append({ + "id": str(uuid.uuid4()), + "recording_id": recording_id, + "transcription_id": transcription_id, + "user_id": user_id, + "action": action, + "metadata": metadata or {}, + "created_at": datetime.utcnow().isoformat() + }) + + +# ========================================== +# WEBHOOK ENDPOINT (Jibri) +# ========================================== + +@router.post("/webhook") +async def jibri_webhook(payload: JibriWebhookPayload, request: Request): + """ + Webhook endpoint called by Jibri finalize.sh after upload. + + This creates a new recording entry and optionally triggers transcription. + """ + if payload.event != "recording_completed": + return JSONResponse( + status_code=400, + content={"error": f"Unknown event type: {payload.event}"} + ) + + # Extract meeting_id from recording_name (format: meetingId_timestamp) + parts = payload.recording_name.split("_") + meeting_id = parts[0] if parts else payload.recording_name + + # Create recording entry + recording_id = str(uuid.uuid4()) + recorded_at = datetime.utcnow() + + recording = { + "id": recording_id, + "meeting_id": meeting_id, + "jibri_session_id": payload.recording_name, + "title": f"Recording {meeting_id}", + "storage_path": payload.storage_path, + "audio_path": payload.audio_path, + "file_size_bytes": payload.file_size_bytes, + "duration_seconds": None, # Will be updated after analysis + "participant_count": 0, + "status": "uploaded", + "recorded_at": recorded_at.isoformat(), + "retention_days": DEFAULT_RETENTION_DAYS, + "created_at": datetime.utcnow().isoformat(), + "updated_at": datetime.utcnow().isoformat() + } + + _recordings_store[recording_id] = recording + + # Log the creation + log_audit( + action="created", + recording_id=recording_id, + metadata={ + "source": "jibri_webhook", + "storage_path": payload.storage_path, + "file_size_bytes": payload.file_size_bytes + } + ) + + return { + "success": True, + "recording_id": recording_id, + "meeting_id": meeting_id, + "status": "uploaded", + "message": "Recording registered successfully" + } + + +# ========================================== +# HEALTH & AUDIT ENDPOINTS (must be before parameterized routes) +# ========================================== + +@router.get("/health") +async def recordings_health(): + """Health check for recording service.""" + return { + "status": "healthy", + "recordings_count": len(_recordings_store), + "transcriptions_count": len(_transcriptions_store), + "minio_endpoint": MINIO_ENDPOINT, + "bucket": MINIO_BUCKET + } + + +@router.get("/audit/log") +async def get_audit_log( + recording_id: Optional[str] = Query(None), + action: Optional[str] = Query(None), + limit: int = Query(100, ge=1, le=1000) +): + """ + Get audit log entries (DSGVO compliance). + + Admin-only endpoint for reviewing recording access history. + """ + logs = _audit_log.copy() + + if recording_id: + logs = [l for l in logs if l.get("recording_id") == recording_id] + if action: + logs = [l for l in logs if l.get("action") == action] + + # Sort by created_at descending + logs.sort(key=lambda x: x["created_at"], reverse=True) + + return { + "entries": logs[:limit], + "total": len(logs) + } + + +# ========================================== +# RECORDING MANAGEMENT ENDPOINTS +# ========================================== + +@router.get("", response_model=RecordingListResponse) +async def list_recordings( + status: Optional[str] = Query(None, description="Filter by status"), + meeting_id: Optional[str] = Query(None, description="Filter by meeting ID"), + page: int = Query(1, ge=1, description="Page number"), + page_size: int = Query(20, ge=1, le=100, description="Items per page") +): + """ + List all recordings with optional filtering. + + Supports pagination and filtering by status or meeting ID. + """ + # Filter recordings + recordings = list(_recordings_store.values()) + + if status: + recordings = [r for r in recordings if r["status"] == status] + if meeting_id: + recordings = [r for r in recordings if r["meeting_id"] == meeting_id] + + # Sort by recorded_at descending + recordings.sort(key=lambda x: x["recorded_at"], reverse=True) + + # Paginate + total = len(recordings) + start = (page - 1) * page_size + end = start + page_size + page_recordings = recordings[start:end] + + # Convert to response format + result = [] + for rec in page_recordings: + recorded_at = datetime.fromisoformat(rec["recorded_at"]) + retention_expires = recorded_at + timedelta(days=rec["retention_days"]) + + # Check for transcription + trans = next( + (t for t in _transcriptions_store.values() if t["recording_id"] == rec["id"]), + None + ) + + result.append(RecordingResponse( + id=rec["id"], + meeting_id=rec["meeting_id"], + title=rec.get("title"), + storage_path=rec["storage_path"], + audio_path=rec.get("audio_path"), + file_size_bytes=rec.get("file_size_bytes"), + duration_seconds=rec.get("duration_seconds"), + participant_count=rec.get("participant_count", 0), + status=rec["status"], + recorded_at=recorded_at, + retention_days=rec["retention_days"], + retention_expires_at=retention_expires, + transcription_status=trans["status"] if trans else None, + transcription_id=trans["id"] if trans else None + )) + + return RecordingListResponse( + recordings=result, + total=total, + page=page, + page_size=page_size + ) + + +@router.get("/{recording_id}", response_model=RecordingResponse) +async def get_recording(recording_id: str): + """ + Get details for a specific recording. + """ + recording = _recordings_store.get(recording_id) + if not recording: + raise HTTPException(status_code=404, detail="Recording not found") + + # Log view action + log_audit(action="viewed", recording_id=recording_id) + + recorded_at = datetime.fromisoformat(recording["recorded_at"]) + retention_expires = recorded_at + timedelta(days=recording["retention_days"]) + + # Check for transcription + trans = next( + (t for t in _transcriptions_store.values() if t["recording_id"] == recording_id), + None + ) + + return RecordingResponse( + id=recording["id"], + meeting_id=recording["meeting_id"], + title=recording.get("title"), + storage_path=recording["storage_path"], + audio_path=recording.get("audio_path"), + file_size_bytes=recording.get("file_size_bytes"), + duration_seconds=recording.get("duration_seconds"), + participant_count=recording.get("participant_count", 0), + status=recording["status"], + recorded_at=recorded_at, + retention_days=recording["retention_days"], + retention_expires_at=retention_expires, + transcription_status=trans["status"] if trans else None, + transcription_id=trans["id"] if trans else None + ) + + +@router.delete("/{recording_id}") +async def delete_recording( + recording_id: str, + reason: str = Query(..., description="Reason for deletion (DSGVO audit)") +): + """ + Soft-delete a recording (DSGVO compliance). + + The recording is marked as deleted but retained for audit purposes. + Actual file deletion happens after the audit retention period. + """ + recording = _recordings_store.get(recording_id) + if not recording: + raise HTTPException(status_code=404, detail="Recording not found") + + # Soft delete + recording["status"] = "deleted" + recording["deleted_at"] = datetime.utcnow().isoformat() + recording["updated_at"] = datetime.utcnow().isoformat() + + # Log deletion with reason + log_audit( + action="deleted", + recording_id=recording_id, + metadata={"reason": reason} + ) + + return { + "success": True, + "recording_id": recording_id, + "status": "deleted", + "message": "Recording marked for deletion" + } + + +# ========================================== +# TRANSCRIPTION ENDPOINTS +# ========================================== + +@router.post("/{recording_id}/transcribe", response_model=TranscriptionStatusResponse) +async def start_transcription(recording_id: str, request: TranscriptionRequest): + """ + Start transcription for a recording. + + Queues the recording for processing by the transcription worker. + """ + recording = _recordings_store.get(recording_id) + if not recording: + raise HTTPException(status_code=404, detail="Recording not found") + + if recording["status"] == "deleted": + raise HTTPException(status_code=400, detail="Cannot transcribe deleted recording") + + # Check if transcription already exists + existing = next( + (t for t in _transcriptions_store.values() + if t["recording_id"] == recording_id and t["status"] != "failed"), + None + ) + if existing: + raise HTTPException( + status_code=409, + detail=f"Transcription already exists with status: {existing['status']}" + ) + + # Create transcription entry + transcription_id = str(uuid.uuid4()) + now = datetime.utcnow() + + transcription = { + "id": transcription_id, + "recording_id": recording_id, + "language": request.language, + "model": request.model, + "status": "pending", + "full_text": None, + "word_count": None, + "confidence_score": None, + "vtt_path": None, + "srt_path": None, + "json_path": None, + "error_message": None, + "processing_started_at": None, + "processing_completed_at": None, + "processing_duration_seconds": None, + "created_at": now.isoformat(), + "updated_at": now.isoformat() + } + + _transcriptions_store[transcription_id] = transcription + + # Update recording status + recording["status"] = "processing" + recording["updated_at"] = now.isoformat() + + # Log transcription start + log_audit( + action="transcription_started", + recording_id=recording_id, + transcription_id=transcription_id, + metadata={"language": request.language, "model": request.model} + ) + + # TODO: Queue job to Redis/Valkey for transcription worker + # from redis import Redis + # from rq import Queue + # q = Queue(connection=Redis.from_url(os.getenv("REDIS_URL"))) + # q.enqueue('transcription_worker.tasks.transcribe', transcription_id, ...) + + return TranscriptionStatusResponse( + id=transcription_id, + recording_id=recording_id, + status="pending", + language=request.language, + model=request.model, + word_count=None, + confidence_score=None, + processing_duration_seconds=None, + error_message=None, + created_at=now, + completed_at=None + ) + + +@router.get("/{recording_id}/transcription", response_model=TranscriptionStatusResponse) +async def get_transcription_status(recording_id: str): + """ + Get transcription status for a recording. + """ + transcription = next( + (t for t in _transcriptions_store.values() if t["recording_id"] == recording_id), + None + ) + if not transcription: + raise HTTPException(status_code=404, detail="No transcription found for this recording") + + return TranscriptionStatusResponse( + id=transcription["id"], + recording_id=transcription["recording_id"], + status=transcription["status"], + language=transcription["language"], + model=transcription["model"], + word_count=transcription.get("word_count"), + confidence_score=transcription.get("confidence_score"), + processing_duration_seconds=transcription.get("processing_duration_seconds"), + error_message=transcription.get("error_message"), + created_at=datetime.fromisoformat(transcription["created_at"]), + completed_at=( + datetime.fromisoformat(transcription["processing_completed_at"]) + if transcription.get("processing_completed_at") else None + ) + ) + + +@router.get("/{recording_id}/transcription/text") +async def get_transcription_text(recording_id: str): + """ + Get the full transcription text. + """ + transcription = next( + (t for t in _transcriptions_store.values() if t["recording_id"] == recording_id), + None + ) + if not transcription: + raise HTTPException(status_code=404, detail="No transcription found for this recording") + + if transcription["status"] != "completed": + raise HTTPException( + status_code=400, + detail=f"Transcription not ready. Status: {transcription['status']}" + ) + + return { + "transcription_id": transcription["id"], + "recording_id": recording_id, + "language": transcription["language"], + "text": transcription.get("full_text", ""), + "word_count": transcription.get("word_count", 0) + } + + +@router.get("/{recording_id}/transcription/vtt") +async def get_transcription_vtt(recording_id: str): + """ + Download transcription as WebVTT subtitle file. + """ + from fastapi.responses import PlainTextResponse + + transcription = next( + (t for t in _transcriptions_store.values() if t["recording_id"] == recording_id), + None + ) + if not transcription: + raise HTTPException(status_code=404, detail="No transcription found for this recording") + + if transcription["status"] != "completed": + raise HTTPException( + status_code=400, + detail=f"Transcription not ready. Status: {transcription['status']}" + ) + + # Generate VTT content + # In production, this would read from the stored VTT file + vtt_content = "WEBVTT\n\n" + text = transcription.get("full_text", "") + + if text: + # Simple VTT generation - split into sentences + sentences = text.replace(".", ".\n").split("\n") + time_offset = 0 + for sentence in sentences: + sentence = sentence.strip() + if sentence: + start = format_vtt_time(time_offset) + # Estimate ~3 seconds per sentence + time_offset += 3000 + end = format_vtt_time(time_offset) + vtt_content += f"{start} --> {end}\n{sentence}\n\n" + + return PlainTextResponse( + content=vtt_content, + media_type="text/vtt", + headers={"Content-Disposition": f"attachment; filename=transcript_{recording_id}.vtt"} + ) + + +@router.get("/{recording_id}/transcription/srt") +async def get_transcription_srt(recording_id: str): + """ + Download transcription as SRT subtitle file. + """ + from fastapi.responses import PlainTextResponse + + transcription = next( + (t for t in _transcriptions_store.values() if t["recording_id"] == recording_id), + None + ) + if not transcription: + raise HTTPException(status_code=404, detail="No transcription found for this recording") + + if transcription["status"] != "completed": + raise HTTPException( + status_code=400, + detail=f"Transcription not ready. Status: {transcription['status']}" + ) + + # Generate SRT content + srt_content = "" + text = transcription.get("full_text", "") + + if text: + sentences = text.replace(".", ".\n").split("\n") + time_offset = 0 + index = 1 + for sentence in sentences: + sentence = sentence.strip() + if sentence: + start = format_srt_time(time_offset) + time_offset += 3000 + end = format_srt_time(time_offset) + srt_content += f"{index}\n{start} --> {end}\n{sentence}\n\n" + index += 1 + + return PlainTextResponse( + content=srt_content, + media_type="text/plain", + headers={"Content-Disposition": f"attachment; filename=transcript_{recording_id}.srt"} + ) + + +def format_vtt_time(ms: int) -> str: + """Format milliseconds to VTT timestamp (HH:MM:SS.mmm).""" + hours = ms // 3600000 + minutes = (ms % 3600000) // 60000 + seconds = (ms % 60000) // 1000 + millis = ms % 1000 + return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{millis:03d}" + + +def format_srt_time(ms: int) -> str: + """Format milliseconds to SRT timestamp (HH:MM:SS,mmm).""" + hours = ms // 3600000 + minutes = (ms % 3600000) // 60000 + seconds = (ms % 60000) // 1000 + millis = ms % 1000 + return f"{hours:02d}:{minutes:02d}:{seconds:02d},{millis:03d}" + + +@router.get("/{recording_id}/download") +async def download_recording(recording_id: str): + """ + Download the recording file. + + In production, this would generate a presigned URL to MinIO. + """ + recording = _recordings_store.get(recording_id) + if not recording: + raise HTTPException(status_code=404, detail="Recording not found") + + if recording["status"] == "deleted": + raise HTTPException(status_code=410, detail="Recording has been deleted") + + # Log download action + log_audit(action="downloaded", recording_id=recording_id) + + # In production, generate presigned URL to MinIO + # For now, return info about where the file is + return { + "recording_id": recording_id, + "storage_path": recording["storage_path"], + "file_size_bytes": recording.get("file_size_bytes"), + "message": "In production, this would redirect to a presigned MinIO URL" + } + + +# ========================================== +# MEETING MINUTES ENDPOINTS +# ========================================== + +# In-memory store for meeting minutes (dev mode) +_minutes_store: dict = {} + + +@router.post("/{recording_id}/minutes") +async def generate_meeting_minutes( + recording_id: str, + title: Optional[str] = Query(None, description="Meeting-Titel"), + model: str = Query("breakpilot-teacher-8b", description="LLM Modell") +): + """ + Generiert KI-basierte Meeting Minutes aus der Transkription. + + Nutzt das LLM Gateway (Ollama/vLLM) fuer lokale Verarbeitung. + """ + from meeting_minutes_generator import get_minutes_generator, MeetingMinutes + + # Check recording exists + recording = _recordings_store.get(recording_id) + if not recording: + raise HTTPException(status_code=404, detail="Recording not found") + + # Check transcription exists and is completed + transcription = next( + (t for t in _transcriptions_store.values() if t["recording_id"] == recording_id), + None + ) + if not transcription: + raise HTTPException(status_code=400, detail="No transcription found. Please transcribe first.") + + if transcription["status"] != "completed": + raise HTTPException( + status_code=400, + detail=f"Transcription not ready. Status: {transcription['status']}" + ) + + # Check if minutes already exist + existing = _minutes_store.get(recording_id) + if existing and existing.get("status") == "completed": + # Return existing minutes + return existing + + # Get transcript text + transcript_text = transcription.get("full_text", "") + if not transcript_text: + raise HTTPException(status_code=400, detail="Transcription has no text content") + + # Generate meeting minutes + generator = get_minutes_generator() + + try: + minutes = await generator.generate( + transcript=transcript_text, + recording_id=recording_id, + transcription_id=transcription["id"], + title=title, + date=recording.get("recorded_at", "")[:10] if recording.get("recorded_at") else None, + duration_minutes=recording.get("duration_seconds", 0) // 60 if recording.get("duration_seconds") else None, + participant_count=recording.get("participant_count", 0), + model=model + ) + + # Store minutes + minutes_dict = minutes.model_dump() + minutes_dict["generated_at"] = minutes.generated_at.isoformat() + _minutes_store[recording_id] = minutes_dict + + # Log action + log_audit( + action="minutes_generated", + recording_id=recording_id, + metadata={"model": model, "generation_time": minutes.generation_time_seconds} + ) + + return minutes_dict + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Minutes generation failed: {str(e)}") + + +@router.get("/{recording_id}/minutes") +async def get_meeting_minutes(recording_id: str): + """ + Ruft generierte Meeting Minutes ab. + """ + minutes = _minutes_store.get(recording_id) + if not minutes: + raise HTTPException(status_code=404, detail="No meeting minutes found. Generate them first with POST.") + + return minutes + + +@router.get("/{recording_id}/minutes/markdown") +async def get_minutes_markdown(recording_id: str): + """ + Exportiert Meeting Minutes als Markdown. + """ + from fastapi.responses import PlainTextResponse + from meeting_minutes_generator import minutes_to_markdown, MeetingMinutes + + minutes_dict = _minutes_store.get(recording_id) + if not minutes_dict: + raise HTTPException(status_code=404, detail="No meeting minutes found") + + # Convert dict back to MeetingMinutes + minutes_dict_copy = minutes_dict.copy() + if isinstance(minutes_dict_copy.get("generated_at"), str): + minutes_dict_copy["generated_at"] = datetime.fromisoformat(minutes_dict_copy["generated_at"]) + + minutes = MeetingMinutes(**minutes_dict_copy) + markdown = minutes_to_markdown(minutes) + + return PlainTextResponse( + content=markdown, + media_type="text/markdown", + headers={"Content-Disposition": f"attachment; filename=protokoll_{recording_id}.md"} + ) + + +@router.get("/{recording_id}/minutes/html") +async def get_minutes_html(recording_id: str): + """ + Exportiert Meeting Minutes als HTML. + """ + from fastapi.responses import HTMLResponse + from meeting_minutes_generator import minutes_to_html, MeetingMinutes + + minutes_dict = _minutes_store.get(recording_id) + if not minutes_dict: + raise HTTPException(status_code=404, detail="No meeting minutes found") + + # Convert dict back to MeetingMinutes + minutes_dict_copy = minutes_dict.copy() + if isinstance(minutes_dict_copy.get("generated_at"), str): + minutes_dict_copy["generated_at"] = datetime.fromisoformat(minutes_dict_copy["generated_at"]) + + minutes = MeetingMinutes(**minutes_dict_copy) + html = minutes_to_html(minutes) + + return HTMLResponse(content=html) + + +@router.get("/{recording_id}/minutes/pdf") +async def get_minutes_pdf(recording_id: str): + """ + Exportiert Meeting Minutes als PDF. + + Benoetigt WeasyPrint (pip install weasyprint). + """ + from meeting_minutes_generator import minutes_to_html, MeetingMinutes + + minutes_dict = _minutes_store.get(recording_id) + if not minutes_dict: + raise HTTPException(status_code=404, detail="No meeting minutes found") + + # Convert dict back to MeetingMinutes + minutes_dict_copy = minutes_dict.copy() + if isinstance(minutes_dict_copy.get("generated_at"), str): + minutes_dict_copy["generated_at"] = datetime.fromisoformat(minutes_dict_copy["generated_at"]) + + minutes = MeetingMinutes(**minutes_dict_copy) + html = minutes_to_html(minutes) + + try: + from weasyprint import HTML + from fastapi.responses import Response + + pdf_bytes = HTML(string=html).write_pdf() + + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={"Content-Disposition": f"attachment; filename=protokoll_{recording_id}.pdf"} + ) + except ImportError: + raise HTTPException( + status_code=501, + detail="PDF export not available. Install weasyprint: pip install weasyprint" + ) diff --git a/backend/requirements-worker.txt b/backend/requirements-worker.txt new file mode 100644 index 0000000..a190d7b --- /dev/null +++ b/backend/requirements-worker.txt @@ -0,0 +1,38 @@ +# BreakPilot Transcription Worker Dependencies +# License: All packages are open source and commercially usable + +# Task Queue +rq>=1.16.0 # BSD-2-Clause - Redis Queue + +# Transcription +faster-whisper>=1.0.0 # MIT - CTranslate2 Whisper (GPU optimized) +ctranslate2>=4.0.0 # MIT - Fast inference engine + +# Speaker Diarization +pyannote.audio>=3.1.0 # MIT - Speaker diarization +torch>=2.0.0 # BSD-style - PyTorch +torchaudio>=2.0.0 # BSD-style - Audio processing + +# Audio Processing +ffmpeg-python>=0.2.0 # Apache-2.0 - FFmpeg bindings +soundfile>=0.12.0 # BSD-3-Clause - Audio file I/O +librosa>=0.10.0 # ISC - Audio analysis + +# Subtitle Export +webvtt-py>=0.4.6 # MIT - WebVTT generation +pysrt>=1.1.2 # GPL-3.0 - SRT generation + +# MinIO Storage +minio>=7.2.0 # Apache-2.0 - S3-compatible client + +# Database +psycopg2-binary>=2.9.0 # LGPL - PostgreSQL adapter +sqlalchemy>=2.0.0 # MIT - ORM + +# Utilities +pydantic>=2.0.0 # MIT - Data validation +python-dotenv>=1.0.0 # BSD-3-Clause - Environment config +structlog>=24.0.0 # Apache-2.0 - Structured logging + +# HuggingFace (for model downloads) +huggingface-hub>=0.20.0 # Apache-2.0 - Model hub access diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..b9ebc99 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,60 @@ +# BreakPilot Backend Dependencies + +# Web Framework +fastapi==0.123.9 +uvicorn==0.38.0 +starlette==0.49.3 + +# HTTP Client +httpx==0.28.1 +requests==2.32.5 + +# Validation & Types +pydantic==2.12.5 +pydantic_core==2.41.5 +email-validator==2.3.0 +annotated-types==0.7.0 + +# Authentication +PyJWT==2.10.1 +python-multipart==0.0.20 + +# AI / Anthropic +anthropic==0.75.0 + +# Secrets Management +hvac==2.4.0 + +# PDF Generation +weasyprint==66.0 +Jinja2==3.1.6 + +# Image Processing +pillow==11.3.0 +opencv-python==4.12.0.88 +numpy==2.0.2 + +# Document Processing +python-docx==1.2.0 +mammoth==1.11.0 +Markdown==3.9 + +# Utilities +python-dateutil==2.9.0.post0 + +# Database +asyncpg==0.30.0 +SQLAlchemy==2.0.36 +psycopg2-binary==2.9.10 + +# Cache (Valkey/Redis) +redis==5.2.1 + +# Testing +pytest==8.4.2 +pytest-asyncio==1.2.0 +beautifulsoup4==4.12.3 + +# Security: Pin transitive dependencies to patched versions +idna>=3.7 # CVE-2024-3651: DoS via resource consumption +cryptography>=42.0.0 # GHSA-h4gh-qq45-vh27: NULL pointer dereference diff --git a/backend/school_api.py b/backend/school_api.py new file mode 100644 index 0000000..f49c5be --- /dev/null +++ b/backend/school_api.py @@ -0,0 +1,250 @@ +""" +School Service API Proxy +Routes API requests from /api/school/* to the Go school-service +""" + +import os +import jwt +import datetime +import httpx +from fastapi import APIRouter, Request, HTTPException, Response + +# School Service URL +SCHOOL_SERVICE_URL = os.getenv("SCHOOL_SERVICE_URL", "http://school-service:8084") +JWT_SECRET = os.getenv("JWT_SECRET", "your-super-secret-jwt-key-change-in-production") +ENVIRONMENT = os.getenv("ENVIRONMENT", "development") + +# Demo teacher UUID for development mode +DEMO_TEACHER_ID = "e9484ad9-32ee-4f2b-a4e1-d182e02ccf20" + +router = APIRouter(prefix="/school", tags=["school"]) + + +def get_demo_token() -> str: + """Generate a demo JWT token for development mode""" + payload = { + "user_id": DEMO_TEACHER_ID, + "email": "demo@breakpilot.app", + "role": "admin", + "exp": datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=24), + "iat": datetime.datetime.now(datetime.timezone.utc) + } + return jwt.encode(payload, JWT_SECRET, algorithm="HS256") + + +async def proxy_request(request: Request, path: str) -> Response: + """Forward a request to the school service""" + url = f"{SCHOOL_SERVICE_URL}/api/v1/school{path}" + + # Forward headers, especially Authorization + headers = {} + if "authorization" in request.headers: + headers["Authorization"] = request.headers["authorization"] + elif ENVIRONMENT == "development": + # In development mode, use demo token if no auth provided + demo_token = get_demo_token() + headers["Authorization"] = f"Bearer {demo_token}" + if "content-type" in request.headers: + headers["Content-Type"] = request.headers["content-type"] + + # Get request body for POST/PUT/PATCH + body = None + if request.method in ("POST", "PUT", "PATCH"): + body = await request.body() + + async with httpx.AsyncClient(timeout=30.0) as client: + try: + response = await client.request( + method=request.method, + url=url, + headers=headers, + content=body, + params=request.query_params + ) + return Response( + content=response.content, + status_code=response.status_code, + headers=dict(response.headers), + media_type=response.headers.get("content-type", "application/json") + ) + except httpx.ConnectError: + raise HTTPException( + status_code=503, + detail="School service unavailable" + ) + except httpx.TimeoutException: + raise HTTPException( + status_code=504, + detail="School service timeout" + ) + + +# Health check +@router.get("/health") +async def health(): + """Health check for school service connection""" + async with httpx.AsyncClient(timeout=5.0) as client: + try: + response = await client.get(f"{SCHOOL_SERVICE_URL}/health") + return {"school_service": "healthy", "connected": response.status_code == 200} + except Exception: + return {"school_service": "unhealthy", "connected": False} + + +# School Years +@router.api_route("/years", methods=["GET", "POST"]) +async def years(request: Request): + return await proxy_request(request, "/years") + + +@router.api_route("/years/{year_id}", methods=["GET", "PUT", "DELETE"]) +async def year_by_id(request: Request, year_id: str): + return await proxy_request(request, f"/years/{year_id}") + + +# Classes +@router.api_route("/classes", methods=["GET", "POST"]) +async def classes(request: Request): + return await proxy_request(request, "/classes") + + +@router.api_route("/classes/{class_id}", methods=["GET", "PUT", "DELETE"]) +async def class_by_id(request: Request, class_id: str): + return await proxy_request(request, f"/classes/{class_id}") + + +# Students +@router.api_route("/classes/{class_id}/students", methods=["GET", "POST"]) +async def students(request: Request, class_id: str): + return await proxy_request(request, f"/classes/{class_id}/students") + + +@router.api_route("/classes/{class_id}/students/import", methods=["POST"]) +async def import_students(request: Request, class_id: str): + return await proxy_request(request, f"/classes/{class_id}/students/import") + + +@router.api_route("/classes/{class_id}/students/{student_id}", methods=["GET", "PUT", "DELETE"]) +async def student_by_id(request: Request, class_id: str, student_id: str): + return await proxy_request(request, f"/classes/{class_id}/students/{student_id}") + + +# Subjects +@router.api_route("/subjects", methods=["GET", "POST"]) +async def subjects(request: Request): + return await proxy_request(request, "/subjects") + + +@router.api_route("/subjects/{subject_id}", methods=["GET", "PUT", "DELETE"]) +async def subject_by_id(request: Request, subject_id: str): + return await proxy_request(request, f"/subjects/{subject_id}") + + +# Exams +@router.api_route("/exams", methods=["GET", "POST"]) +async def exams(request: Request): + return await proxy_request(request, "/exams") + + +@router.api_route("/exams/{exam_id}", methods=["GET", "PUT", "DELETE"]) +async def exam_by_id(request: Request, exam_id: str): + return await proxy_request(request, f"/exams/{exam_id}") + + +@router.api_route("/exams/{exam_id}/results", methods=["GET", "POST"]) +async def exam_results(request: Request, exam_id: str): + return await proxy_request(request, f"/exams/{exam_id}/results") + + +@router.api_route("/exams/{exam_id}/results/{student_id}", methods=["PUT"]) +async def exam_result_update(request: Request, exam_id: str, student_id: str): + return await proxy_request(request, f"/exams/{exam_id}/results/{student_id}") + + +@router.api_route("/exams/{exam_id}/results/{student_id}/approve", methods=["PUT"]) +async def approve_result(request: Request, exam_id: str, student_id: str): + return await proxy_request(request, f"/exams/{exam_id}/results/{student_id}/approve") + + +@router.api_route("/exams/{exam_id}/generate-variant", methods=["POST"]) +async def generate_variant(request: Request, exam_id: str): + return await proxy_request(request, f"/exams/{exam_id}/generate-variant") + + +# Grades +@router.api_route("/grades/{class_id}", methods=["GET"]) +async def grades_by_class(request: Request, class_id: str): + return await proxy_request(request, f"/grades/{class_id}") + + +@router.api_route("/grades/student/{student_id}", methods=["GET"]) +async def grades_by_student(request: Request, student_id: str): + return await proxy_request(request, f"/grades/student/{student_id}") + + +@router.api_route("/grades/{student_id}/{subject_id}/oral", methods=["PUT"]) +async def update_oral_grade(request: Request, student_id: str, subject_id: str): + return await proxy_request(request, f"/grades/{student_id}/{subject_id}/oral") + + +@router.api_route("/grades/calculate", methods=["POST"]) +async def calculate_grades(request: Request): + return await proxy_request(request, "/grades/calculate") + + +# Attendance +@router.api_route("/attendance/{class_id}", methods=["GET"]) +async def attendance_by_class(request: Request, class_id: str): + return await proxy_request(request, f"/attendance/{class_id}") + + +@router.api_route("/attendance", methods=["POST"]) +async def create_attendance(request: Request): + return await proxy_request(request, "/attendance") + + +@router.api_route("/attendance/bulk", methods=["POST"]) +async def bulk_attendance(request: Request): + return await proxy_request(request, "/attendance/bulk") + + +# Gradebook +@router.api_route("/gradebook/{class_id}", methods=["GET"]) +async def gradebook_by_class(request: Request, class_id: str): + return await proxy_request(request, f"/gradebook/{class_id}") + + +@router.api_route("/gradebook", methods=["POST"]) +async def create_gradebook_entry(request: Request): + return await proxy_request(request, "/gradebook") + + +@router.api_route("/gradebook/{entry_id}", methods=["DELETE"]) +async def delete_gradebook_entry(request: Request, entry_id: str): + return await proxy_request(request, f"/gradebook/{entry_id}") + + +# Certificates +@router.api_route("/certificates/templates", methods=["GET"]) +async def certificate_templates(request: Request): + return await proxy_request(request, "/certificates/templates") + + +@router.api_route("/certificates/generate", methods=["POST"]) +async def generate_certificate(request: Request): + return await proxy_request(request, "/certificates/generate") + + +@router.api_route("/certificates/{certificate_id}", methods=["GET"]) +async def certificate_by_id(request: Request, certificate_id: str): + return await proxy_request(request, f"/certificates/{certificate_id}") + + +@router.api_route("/certificates/{certificate_id}/pdf", methods=["GET"]) +async def certificate_pdf(request: Request, certificate_id: str): + return await proxy_request(request, f"/certificates/{certificate_id}/pdf") + + +@router.api_route("/certificates/{certificate_id}/finalize", methods=["PUT"]) +async def finalize_certificate(request: Request, certificate_id: str): + return await proxy_request(request, f"/certificates/{certificate_id}/finalize") diff --git a/backend/scripts/import_dsr_templates.py b/backend/scripts/import_dsr_templates.py new file mode 100644 index 0000000..5e75cba --- /dev/null +++ b/backend/scripts/import_dsr_templates.py @@ -0,0 +1,369 @@ +#!/usr/bin/env python3 +""" +DSR Template Import Script + +Importiert DOCX-Vorlagen aus dem Datenschutz-Ordner und erstellt +initiale Template-Versionen in der Datenbank. + +Verwendung: + cd backend + source venv/bin/activate + python scripts/import_dsr_templates.py + +Voraussetzungen: + pip install mammoth python-docx httpx +""" + +import os +import sys +import re +import asyncio +from pathlib import Path +from typing import Optional, Dict, List, Tuple + +# Third-party imports +try: + import mammoth + import httpx +except ImportError: + print("Bitte installieren Sie die erforderlichen Pakete:") + print(" pip install mammoth httpx") + sys.exit(1) + +# Configuration +CONSENT_SERVICE_URL = os.getenv("CONSENT_SERVICE_URL", "http://localhost:8081") +DOCS_PATH = Path(__file__).parent.parent.parent / "docs" / "Datenschutz" + +# Mapping von DOCX-Dateien zu Template-Typen +DOCX_TEMPLATE_MAPPING: Dict[str, Dict] = { + # Eingangsbestätigungen + "Muster_1_Eingangsbestätigung": { + "template_type": "dsr_receipt_access", + "name": "Eingangsbestätigung (Auskunft Art. 15)", + "request_types": ["access"], + }, + + # Identitätsprüfung + "Muster_2_Anfrage Kontaktdaten": { + "template_type": "dsr_identity_request", + "name": "Anfrage Kontaktdaten zur Identifikation", + "request_types": ["access", "rectification", "erasure", "restriction", "portability"], + }, + "Muster_2_Anfrage Identität": { + "template_type": "dsr_identity_request", + "name": "Anfrage Identitätsnachweis", + "request_types": ["access", "rectification", "erasure", "restriction", "portability"], + }, + "Muster_3_Anfrage Identität": { + "template_type": "dsr_identity_request", + "name": "Anfrage Identitätsnachweis (erweitert)", + "request_types": ["access", "rectification", "erasure", "restriction", "portability"], + }, + + # Bearbeitungsbestätigungen + "Muster_3_Bearbeitungsbestätigung": { + "template_type": "dsr_processing_started", + "name": "Bearbeitungsbestätigung", + "request_types": ["access", "rectification", "erasure", "restriction", "portability"], + }, + "Muster_4_Bearbeitungsbestätigung": { + "template_type": "dsr_processing_update", + "name": "Bearbeitungsupdate", + "request_types": ["access", "rectification", "erasure", "restriction", "portability"], + }, + + # Rückfragen + "Muster_4_Rückfragen Begehren": { + "template_type": "dsr_clarification_request", + "name": "Rückfragen zum Begehren", + "request_types": ["access", "rectification", "erasure", "restriction", "portability"], + }, + "Muster_5_Rückfragen Umfang": { + "template_type": "dsr_clarification_request", + "name": "Rückfragen zum Umfang", + "request_types": ["access"], + }, + + # Abschluss - Auskunft + "Muster_5_ Negativauskunft": { + "template_type": "dsr_completed_access", + "name": "Negativauskunft (keine Daten gefunden)", + "request_types": ["access"], + "variant": "no_data", + }, + "Muster_6_ Beauskunftung Art. 15": { + "template_type": "dsr_completed_access", + "name": "Beauskunftung nach Art. 15", + "request_types": ["access"], + }, + + # Abschluss - Berichtigung + "Muster_4_Information Berichtigung": { + "template_type": "dsr_completed_rectification", + "name": "Information über durchgeführte Berichtigung", + "request_types": ["rectification"], + }, + + # Abschluss - Löschung + "Muster_6_Information Löschung": { + "template_type": "dsr_completed_erasure", + "name": "Information über durchgeführte Löschung", + "request_types": ["erasure"], + }, + + # Abschluss - Datenübertragbarkeit + "Muster_5_Information Übermittlung": { + "template_type": "dsr_completed_portability", + "name": "Information über Datenübermittlung", + "request_types": ["portability"], + }, + + # Drittübermittlung + "Muster_4_Anfrage Drittübermittlung": { + "template_type": "dsr_third_party_notification", + "name": "Anfrage zur Drittübermittlung", + "request_types": ["rectification", "erasure", "restriction"], + }, +} + +# Standard-Platzhalter-Mappings +PLACEHOLDER_REPLACEMENTS = { + # Antragsteller + "[Name]": "{{requester_name}}", + "[Vorname Name]": "{{requester_name}}", + "[Anrede]": "{{requester_salutation}}", + "[E-Mail]": "{{requester_email}}", + "[Adresse]": "{{requester_address}}", + + # Anfrage + "[Vorgangsnummer]": "{{request_number}}", + "[Aktenzeichen]": "{{request_number}}", + "[Datum des Eingangs]": "{{request_date}}", + "[Eingangsdatum]": "{{request_date}}", + "[Frist]": "{{deadline_date}}", + "[Fristdatum]": "{{deadline_date}}", + + # Unternehmen + "[Firmenname]": "{{company_name}}", + "[Unternehmen]": "{{company_name}}", + "[Unternehmensname]": "{{company_name}}", + "[Firma]": "{{company_name}}", + + # DSB + "[DSB Name]": "{{dpo_name}}", + "[Name DSB]": "{{dpo_name}}", + "[Datenschutzbeauftragter]": "{{dpo_name}}", + "[E-Mail DSB]": "{{dpo_email}}", + "[DSB E-Mail]": "{{dpo_email}}", + + # Sonstiges + "[Datum]": "{{current_date}}", + "[Portal-URL]": "{{portal_url}}", +} + + +def convert_docx_to_html(docx_path: Path) -> Tuple[str, List[str]]: + """Konvertiert DOCX zu HTML mit mammoth.""" + with open(docx_path, "rb") as f: + result = mammoth.convert_to_html(f) + + html = result.value + messages = [msg.message for msg in result.messages] + + # Bereinige HTML + html = html.replace('

            ', '') + html = re.sub(r'\s+', ' ', html) + + return html, messages + + +def replace_placeholders(html: str) -> str: + """Ersetzt Word-Platzhalter durch Template-Variablen.""" + for placeholder, variable in PLACEHOLDER_REPLACEMENTS.items(): + html = html.replace(placeholder, variable) + + # Suche nach nicht ersetzten Platzhaltern + remaining = re.findall(r'\[([^\]]+)\]', html) + if remaining: + print(f" Warnung: Nicht ersetzte Platzhalter gefunden: {remaining}") + + return html + + +def html_to_text(html: str) -> str: + """Konvertiert HTML zu Plain-Text.""" + # Entferne HTML-Tags + text = re.sub(r'', '\n', html) + text = re.sub(r'

            ', '\n\n', text) + text = re.sub(r'<[^>]+>', '', text) + + # Entferne übermäßige Leerzeilen + text = re.sub(r'\n{3,}', '\n\n', text) + + # Decode HTML entities + text = text.replace(' ', ' ') + text = text.replace('&', '&') + text = text.replace('<', '<') + text = text.replace('>', '>') + text = text.replace('"', '"') + + return text.strip() + + +def find_matching_template(filename: str) -> Optional[Dict]: + """Findet das passende Template-Mapping für eine Datei.""" + for pattern, mapping in DOCX_TEMPLATE_MAPPING.items(): + if pattern in filename: + return mapping + return None + + +async def get_template_id(template_type: str) -> Optional[str]: + """Holt die Template-ID aus der Datenbank.""" + async with httpx.AsyncClient() as client: + try: + # Simuliere Admin-Token (für lokale Entwicklung) + headers = { + "Authorization": "Bearer dev-admin-token", + "Content-Type": "application/json" + } + + response = await client.get( + f"{CONSENT_SERVICE_URL}/api/v1/admin/dsr-templates", + headers=headers, + timeout=10.0 + ) + + if response.status_code == 200: + data = response.json() + templates = data.get("templates", []) + for t in templates: + if t.get("template_type") == template_type: + return t.get("id") + except Exception as e: + print(f" Fehler beim Abrufen der Templates: {e}") + + return None + + +async def create_template_version( + template_id: str, + version: str, + subject: str, + body_html: str, + body_text: str +) -> bool: + """Erstellt eine neue Template-Version.""" + async with httpx.AsyncClient() as client: + try: + headers = { + "Authorization": "Bearer dev-admin-token", + "Content-Type": "application/json" + } + + response = await client.post( + f"{CONSENT_SERVICE_URL}/api/v1/admin/dsr-templates/{template_id}/versions", + headers=headers, + json={ + "version": version, + "language": "de", + "subject": subject, + "body_html": body_html, + "body_text": body_text + }, + timeout=30.0 + ) + + return response.status_code in (200, 201) + except Exception as e: + print(f" Fehler beim Erstellen der Version: {e}") + return False + + +async def process_docx_file(docx_path: Path) -> bool: + """Verarbeitet eine einzelne DOCX-Datei.""" + filename = docx_path.stem + print(f"\nVerarbeite: {filename}") + + # Finde passendes Template + mapping = find_matching_template(filename) + if not mapping: + print(f" Übersprungen: Kein Mapping gefunden") + return False + + template_type = mapping["template_type"] + print(f" Template-Typ: {template_type}") + + # Konvertiere DOCX zu HTML + try: + html, warnings = convert_docx_to_html(docx_path) + if warnings: + print(f" Warnungen: {warnings[:3]}") # Zeige max. 3 Warnungen + except Exception as e: + print(f" Fehler bei der Konvertierung: {e}") + return False + + # Ersetze Platzhalter + html = replace_placeholders(html) + text = html_to_text(html) + + # Extrahiere Versionsnummer aus Dateinamen + version_match = re.search(r'_v(\d+)', filename) + version = f"1.{version_match.group(1)}.0" if version_match else "1.0.0" + + # Generiere Betreff + subject = mapping["name"] + if "{{request_number}}" not in subject: + subject = f"{subject} - {{{{request_number}}}}" + + print(f" Version: {version}") + print(f" Betreff: {subject}") + print(f" HTML-Länge: {len(html)} Zeichen") + + # Speichere als lokale Datei für manuelle Überprüfung + output_dir = DOCS_PATH / "converted" + output_dir.mkdir(exist_ok=True) + + with open(output_dir / f"{template_type}_{version}.html", "w", encoding="utf-8") as f: + f.write(html) + with open(output_dir / f"{template_type}_{version}.txt", "w", encoding="utf-8") as f: + f.write(text) + + print(f" Gespeichert in: {output_dir}") + + return True + + +async def main(): + """Hauptfunktion.""" + print("=" * 60) + print("DSR Template Import Script") + print("=" * 60) + + if not DOCS_PATH.exists(): + print(f"Fehler: Verzeichnis nicht gefunden: {DOCS_PATH}") + sys.exit(1) + + # Finde alle DOCX-Dateien + docx_files = list(DOCS_PATH.glob("*.docx")) + print(f"\nGefunden: {len(docx_files)} DOCX-Dateien") + + # Verarbeite jede Datei + success_count = 0 + for docx_path in sorted(docx_files): + if await process_docx_file(docx_path): + success_count += 1 + + print("\n" + "=" * 60) + print(f"Verarbeitet: {success_count}/{len(docx_files)} Dateien") + print("=" * 60) + + print("\nHinweis: Die konvertierten Vorlagen wurden im Ordner") + print(f" {DOCS_PATH / 'converted'}") + print("gespeichert und können manuell überprüft werden.") + print("\nUm die Vorlagen in die Datenbank zu laden, verwenden Sie") + print("das Admin-Panel unter /app → Consent Admin → Betroffenenanfragen") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/scripts/load_initial_seeds.py b/backend/scripts/load_initial_seeds.py new file mode 100644 index 0000000..46087ce --- /dev/null +++ b/backend/scripts/load_initial_seeds.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +""" +Load Initial EduSearch Seeds into the Database. + +This script uses the bulk import API to load all German education sources +that were provided by the user. +""" + +import httpx +import asyncio +import os + +API_BASE = os.environ.get("LLM_GATEWAY_URL", "http://localhost:8000") + +# All German education seeds organized by category +INITIAL_SEEDS = [ + # ===== BUNDESEBENE (Federal) ===== + {"url": "https://www.kmk.org", "name": "Kultusministerkonferenz (KMK)", "description": "Lehrpläne, Bildungsstandards, Abiturregelungen", "category": "federal", "trust_boost": 0.95, "source_type": "GOV", "scope": "FEDERAL"}, + {"url": "https://www.bildungsserver.de", "name": "Deutscher Bildungsserver (DIPF)", "description": "Zentrale Meta-Plattform für alle Länder", "category": "federal", "trust_boost": 0.95, "source_type": "GOV", "scope": "FEDERAL"}, + {"url": "https://www.bpb.de", "name": "Bundeszentrale für politische Bildung", "description": "Unterrichtsmaterialien, Dossiers, Arbeitsblätter", "category": "federal", "trust_boost": 0.90, "source_type": "GOV", "scope": "FEDERAL"}, + {"url": "https://www.bmbf.de", "name": "Bundesministerium für Bildung und Forschung", "description": "Förderprogramme, Bildungsberichte, Initiativen", "category": "federal", "trust_boost": 0.95, "source_type": "GOV", "scope": "FEDERAL"}, + {"url": "https://www.iqb.hu-berlin.de", "name": "Institut zur Qualitätsentwicklung (IQB)", "description": "Bildungsstandards, Vergleichsarbeiten, Abiturpools", "category": "federal", "trust_boost": 0.95, "source_type": "GOV", "scope": "FEDERAL"}, + + # ===== BADEN-WÜRTTEMBERG (BW) ===== + {"url": "https://km-bw.de", "name": "BW Kultusministerium", "description": "Baden-Württemberg Kultusministerium", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "BW"}, + {"url": "https://www.bildungsplaene-bw.de", "name": "BW Bildungspläne", "description": "Bildungspläne Baden-Württemberg", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "BW"}, + {"url": "https://zsl.kultus-bw.de", "name": "BW Zentrum für Schulqualität", "description": "ZSL Baden-Württemberg", "category": "states", "trust_boost": 0.90, "source_type": "GOV", "scope": "STATE", "state": "BW"}, + {"url": "https://lehrerfortbildung-bw.de", "name": "BW Lehrerfortbildung", "description": "Lehrerfortbildung Baden-Württemberg", "category": "states", "trust_boost": 0.85, "source_type": "GOV", "scope": "STATE", "state": "BW"}, + {"url": "https://rp.baden-wuerttemberg.de", "name": "BW Regierungspräsidien", "description": "Bildungsaufsicht Baden-Württemberg", "category": "authorities", "trust_boost": 0.90, "source_type": "GOV", "scope": "STATE", "state": "BW"}, + + # ===== BAYERN (BY) ===== + {"url": "https://www.km.bayern.de", "name": "Bayern Kultusministerium", "description": "Bayerisches Staatsministerium für Unterricht und Kultus", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "BY"}, + {"url": "https://www.isb.bayern.de", "name": "Bayern ISB", "description": "Staatsinstitut für Schulqualität und Bildungsforschung", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "BY"}, + {"url": "https://www.mebis.bayern.de", "name": "Bayern mebis", "description": "Medien-Bildung-Service Bayern", "category": "states", "trust_boost": 0.90, "source_type": "GOV", "scope": "STATE", "state": "BY"}, + {"url": "https://www.bycs.de", "name": "Bayern Cloud Schule", "description": "Bayerische Schulcloud", "category": "states", "trust_boost": 0.85, "source_type": "GOV", "scope": "STATE", "state": "BY"}, + {"url": "https://www.schulberatung.bayern.de", "name": "Bayern Schulberatung", "description": "Staatliche Schulberatung Bayern", "category": "states", "trust_boost": 0.85, "source_type": "GOV", "scope": "STATE", "state": "BY"}, + + # ===== BERLIN (BE) ===== + {"url": "https://www.berlin.de/sen/bjf", "name": "Berlin Senatsverwaltung", "description": "Senatsverwaltung für Bildung, Jugend und Familie", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "BE"}, + {"url": "https://bildungsserver.berlin-brandenburg.de", "name": "Berlin-Brandenburg Bildungsserver", "description": "Gemeinsamer Bildungsserver Berlin-Brandenburg", "category": "states", "trust_boost": 0.90, "source_type": "GOV", "scope": "STATE", "state": "BE"}, + {"url": "https://www.berlin.de/schule", "name": "Berlin Schulportal", "description": "Berliner Schulportal", "category": "states", "trust_boost": 0.85, "source_type": "GOV", "scope": "STATE", "state": "BE"}, + {"url": "https://www.berlin.de/landesinstitut-schule-medien", "name": "Berlin LISUM", "description": "Landesinstitut für Schule und Medien", "category": "states", "trust_boost": 0.90, "source_type": "GOV", "scope": "STATE", "state": "BE"}, + + # ===== BRANDENBURG (BB) ===== + {"url": "https://mbjs.brandenburg.de", "name": "Brandenburg MBJS", "description": "Ministerium für Bildung, Jugend und Sport", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "BB"}, + {"url": "https://lisum.berlin-brandenburg.de", "name": "Brandenburg LISUM", "description": "Landesinstitut für Schule und Medien", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "BB"}, + {"url": "https://www.schulportal.brandenburg.de", "name": "Brandenburg Schulportal", "description": "Schulportal Brandenburg", "category": "states", "trust_boost": 0.90, "source_type": "GOV", "scope": "STATE", "state": "BB"}, + {"url": "https://lehrplan.brandenburg.de", "name": "Brandenburg Lehrpläne", "description": "Rahmenlehrpläne Brandenburg", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "BB"}, + + # ===== BREMEN (HB) ===== + {"url": "https://www.bildung.bremen.de", "name": "Bremen Bildung", "description": "Senatorin für Kinder und Bildung Bremen", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "HB"}, + {"url": "https://www.lis.bremen.de", "name": "Bremen LIS", "description": "Landesinstitut für Schule Bremen", "category": "states", "trust_boost": 0.90, "source_type": "GOV", "scope": "STATE", "state": "HB"}, + {"url": "https://www.bildungsplaene.bremen.de", "name": "Bremen Bildungspläne", "description": "Bildungspläne Bremen", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "HB"}, + + # ===== HAMBURG (HH) ===== + {"url": "https://www.hamburg.de/bsb", "name": "Hamburg BSB", "description": "Behörde für Schule und Berufsbildung", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "HH"}, + {"url": "https://li.hamburg.de", "name": "Hamburg Landesinstitut", "description": "Landesinstitut für Lehrerbildung und Schulentwicklung", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "HH"}, + {"url": "https://www.bildungsplaene.hamburg.de", "name": "Hamburg Bildungspläne", "description": "Hamburger Bildungspläne", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "HH"}, + + # ===== HESSEN (HE) ===== + {"url": "https://kultusministerium.hessen.de", "name": "Hessen Kultusministerium", "description": "Hessisches Kultusministerium", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "HE"}, + {"url": "https://lehrplaene.hessen.de", "name": "Hessen Lehrpläne", "description": "Kerncurricula Hessen", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "HE"}, + {"url": "https://www.schulportal.hessen.de", "name": "Hessen Schulportal", "description": "Hessisches Schulportal", "category": "states", "trust_boost": 0.90, "source_type": "GOV", "scope": "STATE", "state": "HE"}, + {"url": "https://la.hessen.de", "name": "Hessen Lehrkräfteakademie", "description": "Hessische Lehrkräfteakademie", "category": "authorities", "trust_boost": 0.90, "source_type": "GOV", "scope": "STATE", "state": "HE"}, + + # ===== MECKLENBURG-VORPOMMERN (MV) ===== + {"url": "https://www.regierung-mv.de/Landesregierung/bm", "name": "MV Bildungsministerium", "description": "Bildungsministerium Mecklenburg-Vorpommern", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "MV"}, + {"url": "https://www.bildung-mv.de", "name": "MV Bildungsportal", "description": "Bildungsserver Mecklenburg-Vorpommern", "category": "states", "trust_boost": 0.90, "source_type": "GOV", "scope": "STATE", "state": "MV"}, + + # ===== NIEDERSACHSEN (NI) ===== + {"url": "https://www.mk.niedersachsen.de", "name": "Niedersachsen MK", "description": "Niedersächsisches Kultusministerium", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "NI"}, + {"url": "https://www.nibis.de", "name": "Niedersachsen NiBiS", "description": "Niedersächsischer Bildungsserver", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "NI"}, + + # ===== NORDRHEIN-WESTFALEN (NW) ===== + {"url": "https://www.msb.nrw", "name": "NRW Schulministerium", "description": "Ministerium für Schule und Bildung NRW", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "NW"}, + {"url": "https://www.schulentwicklung.nrw.de", "name": "NRW Schulentwicklung", "description": "Schulentwicklung NRW", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "NW"}, + {"url": "https://www.qua-lis.nrw.de", "name": "NRW QUA-LiS", "description": "Qualitäts- und UnterstützungsAgentur", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "NW"}, + {"url": "https://www.standardsicherung.schulministerium.nrw.de", "name": "NRW Standardsicherung", "description": "Standardsicherung und Prüfungen NRW", "category": "states", "trust_boost": 0.90, "source_type": "GOV", "scope": "STATE", "state": "NW"}, + + # ===== RHEINLAND-PFALZ (RP) ===== + {"url": "https://bm.rlp.de", "name": "RLP Bildungsministerium", "description": "Ministerium für Bildung Rheinland-Pfalz", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "RP"}, + {"url": "https://bildung.rlp.de", "name": "RLP Bildungsserver", "description": "Bildungsserver Rheinland-Pfalz", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "RP"}, + {"url": "https://lehrplaene.rlp.de", "name": "RLP Lehrpläne", "description": "Lehrpläne Rheinland-Pfalz", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "RP"}, + + # ===== SAARLAND (SL) ===== + {"url": "https://www.saarland.de/mbk", "name": "Saarland MBK", "description": "Ministerium für Bildung und Kultur Saarland", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "SL"}, + {"url": "https://www.bildungsserver.saarland.de", "name": "Saarland Bildungsserver", "description": "Bildungsserver Saarland", "category": "states", "trust_boost": 0.90, "source_type": "GOV", "scope": "STATE", "state": "SL"}, + + # ===== SACHSEN (SN) ===== + {"url": "https://www.smk.sachsen.de", "name": "Sachsen SMK", "description": "Sächsisches Staatsministerium für Kultus", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "SN"}, + {"url": "https://www.schule.sachsen.de", "name": "Sachsen Schulportal", "description": "Sächsisches Schulportal", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "SN"}, + + # ===== SACHSEN-ANHALT (ST) ===== + {"url": "https://mb.sachsen-anhalt.de", "name": "Sachsen-Anhalt MB", "description": "Ministerium für Bildung Sachsen-Anhalt", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "ST"}, + {"url": "https://lisa.sachsen-anhalt.de", "name": "Sachsen-Anhalt LISA", "description": "Landesinstitut für Schulqualität", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "ST"}, + {"url": "https://www.bildung-lsa.de", "name": "Sachsen-Anhalt Bildungsserver", "description": "Bildungsserver Sachsen-Anhalt", "category": "states", "trust_boost": 0.90, "source_type": "GOV", "scope": "STATE", "state": "ST"}, + + # ===== SCHLESWIG-HOLSTEIN (SH) ===== + {"url": "https://www.schleswig-holstein.de/BILDUNG", "name": "SH Bildungsministerium", "description": "Ministerium für Allgemeine und Berufliche Bildung", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "SH"}, + {"url": "https://fachanforderungen.schleswig-holstein.de", "name": "SH Fachanforderungen", "description": "Fachanforderungen Schleswig-Holstein", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "SH"}, + + # ===== THÜRINGEN (TH) ===== + {"url": "https://bildung.thueringen.de", "name": "Thüringen Bildungsministerium", "description": "Thüringer Ministerium für Bildung", "category": "states", "trust_boost": 0.95, "source_type": "GOV", "scope": "STATE", "state": "TH"}, + {"url": "https://www.schulportal-thueringen.de", "name": "Thüringen Schulportal", "description": "Thüringer Schulportal", "category": "states", "trust_boost": 0.90, "source_type": "GOV", "scope": "STATE", "state": "TH"}, + + # ===== WISSENSCHAFT & STUDIEN ===== + {"url": "https://www.bertelsmann-stiftung.de/bildung", "name": "Bertelsmann Stiftung", "description": "Bildungsstudien und Ländermonitor", "category": "science", "trust_boost": 0.85, "source_type": "NGO", "scope": "FEDERAL"}, + {"url": "https://www.oecd.org/pisa", "name": "OECD PISA", "description": "Internationale Schulleistungsstudie PISA", "category": "science", "trust_boost": 0.90, "source_type": "INT", "scope": "INTERNATIONAL"}, + + # ===== BILDUNGSPORTALE ===== + {"url": "https://www.lehrer-online.de", "name": "Lehrer-Online", "description": "Unterrichtsmaterialien und Fachinformationen", "category": "portals", "trust_boost": 0.80, "source_type": "PORTAL", "scope": "FEDERAL"}, + {"url": "https://www.4teachers.de", "name": "4teachers", "description": "Unterrichtsmaterialien von Lehrern für Lehrer", "category": "portals", "trust_boost": 0.75, "source_type": "PORTAL", "scope": "FEDERAL"}, + {"url": "https://www.zum.de", "name": "ZUM", "description": "Zentrale für Unterrichtsmedien im Internet", "category": "portals", "trust_boost": 0.80, "source_type": "NGO", "scope": "FEDERAL"}, +] + + +async def load_seeds(): + """ + Load initial seeds via bulk import API. + + Returns: + bool: True if successful, False otherwise + + Raises: + httpx.ConnectError: If API is not reachable + httpx.TimeoutException: If request times out + """ + print(f"Loading {len(INITIAL_SEEDS)} seeds into {API_BASE}...") + print(f"Seeds breakdown:") + categories = {} + for seed in INITIAL_SEEDS: + cat = seed.get("category", "unknown") + categories[cat] = categories.get(cat, 0) + 1 + for cat, count in sorted(categories.items()): + print(f" - {cat}: {count}") + + try: + async with httpx.AsyncClient(timeout=60.0) as client: + # Check API health first + try: + health_response = await client.get(f"{API_BASE}/health") + if health_response.status_code != 200: + print(f"WARNING: Health check returned {health_response.status_code}") + except httpx.ConnectError: + print(f"ERROR: Cannot connect to API at {API_BASE}") + print("Make sure the backend service is running:") + print(" docker compose up -d backend") + return False + + # Import seeds + print("\nImporting seeds...") + response = await client.post( + f"{API_BASE}/v1/edu-search/seeds/bulk-import", + json={"seeds": INITIAL_SEEDS} + ) + + if response.status_code == 200: + result = response.json() + imported = result.get('imported', 0) + skipped = result.get('skipped', 0) + errors = result.get('errors', []) + + print(f"\nResult:") + print(f" Imported: {imported}") + print(f" Skipped (duplicates): {skipped}") + + if errors: + print(f" Errors: {len(errors)}") + for err in errors[:5]: + print(f" - {err}") + if len(errors) > 5: + print(f" ... and {len(errors) - 5} more") + else: + print(f"\nERROR: Import failed with status {response.status_code}") + try: + error_detail = response.json() + print(f" Detail: {error_detail.get('detail', response.text)}") + except Exception: + print(f" Response: {response.text[:500]}") + return False + + # Get stats + print("\nFetching statistics...") + stats_response = await client.get(f"{API_BASE}/v1/edu-search/stats") + if stats_response.status_code == 200: + stats = stats_response.json() + print(f"\nDatabase Statistics:") + print(f" Total seeds: {stats.get('total_seeds', 0)}") + print(f" Enabled seeds: {stats.get('enabled_seeds', 0)}") + print(f" Disabled seeds: {stats.get('disabled_seeds', 0)}") + print(f" Avg trust boost: {stats.get('avg_trust_boost', 0):.2f}") + + per_category = stats.get('seeds_per_category', {}) + if per_category: + print(f"\n Seeds per category:") + for cat, count in sorted(per_category.items()): + print(f" - {cat}: {count}") + + print("\nDone!") + return True + + except httpx.ConnectError as e: + print(f"\nERROR: Connection failed - {e}") + print(f"Make sure the API is running at {API_BASE}") + return False + except httpx.TimeoutException: + print(f"\nERROR: Request timed out") + print("The server may be overloaded. Try again later.") + return False + except Exception as e: + print(f"\nERROR: Unexpected error - {e}") + return False + + +if __name__ == "__main__": + import sys + success = asyncio.run(load_seeds()) + sys.exit(0 if success else 1) diff --git a/backend/scripts/load_university_seeds.py b/backend/scripts/load_university_seeds.py new file mode 100644 index 0000000..388892c --- /dev/null +++ b/backend/scripts/load_university_seeds.py @@ -0,0 +1,538 @@ +#!/usr/bin/env python3 +""" +Load German Universities (Hochschulen) as EduSearch Seeds. + +Based on the HRK (Hochschulrektorenkonferenz) database, Germany has approximately: +- 120 Universities (Universitäten) +- 220 Universities of Applied Sciences (Fachhochschulen/HAW) +- 50 Art/Music Academies +- 100+ Private Universities + +This script loads a comprehensive list of German higher education institutions. +""" + +import httpx +import asyncio +import os + +API_BASE = os.environ.get("LLM_GATEWAY_URL", "http://localhost:8000") + +# ============================================================================= +# GERMAN UNIVERSITIES (UNIVERSITÄTEN) - Public +# ============================================================================= + +UNIVERSITAETEN = [ + # ===== BADEN-WÜRTTEMBERG ===== + {"url": "https://www.uni-freiburg.de", "name": "Albert-Ludwigs-Universität Freiburg", "state": "BW"}, + {"url": "https://www.uni-heidelberg.de", "name": "Ruprecht-Karls-Universität Heidelberg", "state": "BW"}, + {"url": "https://www.uni-konstanz.de", "name": "Universität Konstanz", "state": "BW"}, + {"url": "https://www.uni-mannheim.de", "name": "Universität Mannheim", "state": "BW"}, + {"url": "https://www.uni-stuttgart.de", "name": "Universität Stuttgart", "state": "BW"}, + {"url": "https://www.kit.edu", "name": "Karlsruher Institut für Technologie (KIT)", "state": "BW"}, + {"url": "https://www.uni-tuebingen.de", "name": "Eberhard Karls Universität Tübingen", "state": "BW"}, + {"url": "https://www.uni-ulm.de", "name": "Universität Ulm", "state": "BW"}, + {"url": "https://www.uni-hohenheim.de", "name": "Universität Hohenheim", "state": "BW"}, + + # ===== BAYERN ===== + {"url": "https://www.lmu.de", "name": "Ludwig-Maximilians-Universität München", "state": "BY"}, + {"url": "https://www.tum.de", "name": "Technische Universität München", "state": "BY"}, + {"url": "https://www.fau.de", "name": "Friedrich-Alexander-Universität Erlangen-Nürnberg", "state": "BY"}, + {"url": "https://www.uni-wuerzburg.de", "name": "Julius-Maximilians-Universität Würzburg", "state": "BY"}, + {"url": "https://www.uni-regensburg.de", "name": "Universität Regensburg", "state": "BY"}, + {"url": "https://www.uni-augsburg.de", "name": "Universität Augsburg", "state": "BY"}, + {"url": "https://www.uni-bamberg.de", "name": "Otto-Friedrich-Universität Bamberg", "state": "BY"}, + {"url": "https://www.uni-bayreuth.de", "name": "Universität Bayreuth", "state": "BY"}, + {"url": "https://www.uni-passau.de", "name": "Universität Passau", "state": "BY"}, + {"url": "https://www.ku.de", "name": "Katholische Universität Eichstätt-Ingolstadt", "state": "BY"}, + + # ===== BERLIN ===== + {"url": "https://www.fu-berlin.de", "name": "Freie Universität Berlin", "state": "BE"}, + {"url": "https://www.hu-berlin.de", "name": "Humboldt-Universität zu Berlin", "state": "BE"}, + {"url": "https://www.tu-berlin.de", "name": "Technische Universität Berlin", "state": "BE"}, + {"url": "https://www.charite.de", "name": "Charité – Universitätsmedizin Berlin", "state": "BE"}, + + # ===== BRANDENBURG ===== + {"url": "https://www.uni-potsdam.de", "name": "Universität Potsdam", "state": "BB"}, + {"url": "https://www.b-tu.de", "name": "Brandenburgische Technische Universität Cottbus-Senftenberg", "state": "BB"}, + {"url": "https://www.europa-uni.de", "name": "Europa-Universität Viadrina Frankfurt (Oder)", "state": "BB"}, + + # ===== BREMEN ===== + {"url": "https://www.uni-bremen.de", "name": "Universität Bremen", "state": "HB"}, + {"url": "https://www.jacobs-university.de", "name": "Jacobs University Bremen", "state": "HB"}, + + # ===== HAMBURG ===== + {"url": "https://www.uni-hamburg.de", "name": "Universität Hamburg", "state": "HH"}, + {"url": "https://www.tuhh.de", "name": "Technische Universität Hamburg", "state": "HH"}, + {"url": "https://www.hsu-hh.de", "name": "Helmut-Schmidt-Universität Hamburg", "state": "HH"}, + {"url": "https://www.hcu-hamburg.de", "name": "HafenCity Universität Hamburg", "state": "HH"}, + + # ===== HESSEN ===== + {"url": "https://www.uni-frankfurt.de", "name": "Goethe-Universität Frankfurt am Main", "state": "HE"}, + {"url": "https://www.tu-darmstadt.de", "name": "Technische Universität Darmstadt", "state": "HE"}, + {"url": "https://www.uni-giessen.de", "name": "Justus-Liebig-Universität Gießen", "state": "HE"}, + {"url": "https://www.uni-marburg.de", "name": "Philipps-Universität Marburg", "state": "HE"}, + {"url": "https://www.uni-kassel.de", "name": "Universität Kassel", "state": "HE"}, + + # ===== MECKLENBURG-VORPOMMERN ===== + {"url": "https://www.uni-rostock.de", "name": "Universität Rostock", "state": "MV"}, + {"url": "https://www.uni-greifswald.de", "name": "Universität Greifswald", "state": "MV"}, + + # ===== NIEDERSACHSEN ===== + {"url": "https://www.uni-goettingen.de", "name": "Georg-August-Universität Göttingen", "state": "NI"}, + {"url": "https://www.uni-hannover.de", "name": "Leibniz Universität Hannover", "state": "NI"}, + {"url": "https://www.tu-braunschweig.de", "name": "Technische Universität Braunschweig", "state": "NI"}, + {"url": "https://www.tu-clausthal.de", "name": "Technische Universität Clausthal", "state": "NI"}, + {"url": "https://www.uni-oldenburg.de", "name": "Carl von Ossietzky Universität Oldenburg", "state": "NI"}, + {"url": "https://www.uni-osnabrueck.de", "name": "Universität Osnabrück", "state": "NI"}, + {"url": "https://www.uni-hildesheim.de", "name": "Universität Hildesheim", "state": "NI"}, + {"url": "https://www.leuphana.de", "name": "Leuphana Universität Lüneburg", "state": "NI"}, + {"url": "https://www.uni-vechta.de", "name": "Universität Vechta", "state": "NI"}, + {"url": "https://www.mh-hannover.de", "name": "Medizinische Hochschule Hannover", "state": "NI"}, + {"url": "https://www.tiho-hannover.de", "name": "Stiftung Tierärztliche Hochschule Hannover", "state": "NI"}, + + # ===== NORDRHEIN-WESTFALEN ===== + {"url": "https://www.uni-koeln.de", "name": "Universität zu Köln", "state": "NW"}, + {"url": "https://www.uni-bonn.de", "name": "Rheinische Friedrich-Wilhelms-Universität Bonn", "state": "NW"}, + {"url": "https://www.uni-muenster.de", "name": "Westfälische Wilhelms-Universität Münster", "state": "NW"}, + {"url": "https://www.rwth-aachen.de", "name": "RWTH Aachen", "state": "NW"}, + {"url": "https://www.tu-dortmund.de", "name": "Technische Universität Dortmund", "state": "NW"}, + {"url": "https://www.ruhr-uni-bochum.de", "name": "Ruhr-Universität Bochum", "state": "NW"}, + {"url": "https://www.uni-due.de", "name": "Universität Duisburg-Essen", "state": "NW"}, + {"url": "https://www.hhu.de", "name": "Heinrich-Heine-Universität Düsseldorf", "state": "NW"}, + {"url": "https://www.uni-bielefeld.de", "name": "Universität Bielefeld", "state": "NW"}, + {"url": "https://www.uni-paderborn.de", "name": "Universität Paderborn", "state": "NW"}, + {"url": "https://www.uni-siegen.de", "name": "Universität Siegen", "state": "NW"}, + {"url": "https://www.uni-wuppertal.de", "name": "Bergische Universität Wuppertal", "state": "NW"}, + {"url": "https://www.fernuni-hagen.de", "name": "FernUniversität in Hagen", "state": "NW"}, + {"url": "https://www.dshs-koeln.de", "name": "Deutsche Sporthochschule Köln", "state": "NW"}, + + # ===== RHEINLAND-PFALZ ===== + {"url": "https://www.uni-mainz.de", "name": "Johannes Gutenberg-Universität Mainz", "state": "RP"}, + {"url": "https://www.uni-trier.de", "name": "Universität Trier", "state": "RP"}, + {"url": "https://www.uni-koblenz.de", "name": "Universität Koblenz", "state": "RP"}, + {"url": "https://rptu.de", "name": "RPTU Kaiserslautern-Landau", "state": "RP"}, + + # ===== SAARLAND ===== + {"url": "https://www.uni-saarland.de", "name": "Universität des Saarlandes", "state": "SL"}, + + # ===== SACHSEN ===== + {"url": "https://www.tu-dresden.de", "name": "Technische Universität Dresden", "state": "SN"}, + {"url": "https://www.uni-leipzig.de", "name": "Universität Leipzig", "state": "SN"}, + {"url": "https://www.tu-chemnitz.de", "name": "Technische Universität Chemnitz", "state": "SN"}, + {"url": "https://tu-freiberg.de", "name": "TU Bergakademie Freiberg", "state": "SN"}, + + # ===== SACHSEN-ANHALT ===== + {"url": "https://www.uni-halle.de", "name": "Martin-Luther-Universität Halle-Wittenberg", "state": "ST"}, + {"url": "https://www.ovgu.de", "name": "Otto-von-Guericke-Universität Magdeburg", "state": "ST"}, + + # ===== SCHLESWIG-HOLSTEIN ===== + {"url": "https://www.uni-kiel.de", "name": "Christian-Albrechts-Universität zu Kiel", "state": "SH"}, + {"url": "https://www.uni-luebeck.de", "name": "Universität zu Lübeck", "state": "SH"}, + {"url": "https://www.uni-flensburg.de", "name": "Europa-Universität Flensburg", "state": "SH"}, + + # ===== THÜRINGEN ===== + {"url": "https://www.uni-jena.de", "name": "Friedrich-Schiller-Universität Jena", "state": "TH"}, + {"url": "https://www.tu-ilmenau.de", "name": "Technische Universität Ilmenau", "state": "TH"}, + {"url": "https://www.uni-weimar.de", "name": "Bauhaus-Universität Weimar", "state": "TH"}, + {"url": "https://www.uni-erfurt.de", "name": "Universität Erfurt", "state": "TH"}, +] + +# ============================================================================= +# FACHHOCHSCHULEN / HOCHSCHULEN FÜR ANGEWANDTE WISSENSCHAFTEN (HAW) +# ============================================================================= + +FACHHOCHSCHULEN = [ + # ===== BADEN-WÜRTTEMBERG ===== + {"url": "https://www.hs-aalen.de", "name": "Hochschule Aalen", "state": "BW"}, + {"url": "https://www.hs-albsig.de", "name": "Hochschule Albstadt-Sigmaringen", "state": "BW"}, + {"url": "https://www.hs-biberach.de", "name": "Hochschule Biberach", "state": "BW"}, + {"url": "https://www.hs-esslingen.de", "name": "Hochschule Esslingen", "state": "BW"}, + {"url": "https://www.hs-furtwangen.de", "name": "Hochschule Furtwangen", "state": "BW"}, + {"url": "https://www.hs-heilbronn.de", "name": "Hochschule Heilbronn", "state": "BW"}, + {"url": "https://www.hka.de", "name": "Hochschule Karlsruhe", "state": "BW"}, + {"url": "https://www.hs-kehl.de", "name": "Hochschule Kehl", "state": "BW"}, + {"url": "https://www.htwg-konstanz.de", "name": "HTWG Konstanz", "state": "BW"}, + {"url": "https://www.hs-ludwigsburg.de", "name": "Hochschule Ludwigsburg", "state": "BW"}, + {"url": "https://www.hs-mannheim.de", "name": "Hochschule Mannheim", "state": "BW"}, + {"url": "https://www.hdm-stuttgart.de", "name": "Hochschule der Medien Stuttgart", "state": "BW"}, + {"url": "https://www.hs-nueringen-geislingen.de", "name": "Hochschule für Wirtschaft und Umwelt Nürtingen-Geislingen", "state": "BW"}, + {"url": "https://www.hs-offenburg.de", "name": "Hochschule Offenburg", "state": "BW"}, + {"url": "https://www.hs-pforzheim.de", "name": "Hochschule Pforzheim", "state": "BW"}, + {"url": "https://www.hs-ravensburg-weingarten.de", "name": "RWU Ravensburg-Weingarten", "state": "BW"}, + {"url": "https://www.reutlingen-university.de", "name": "Hochschule Reutlingen", "state": "BW"}, + {"url": "https://www.hs-rottenburg.de", "name": "Hochschule für Forstwirtschaft Rottenburg", "state": "BW"}, + {"url": "https://www.hft-stuttgart.de", "name": "Hochschule für Technik Stuttgart", "state": "BW"}, + {"url": "https://www.hs-ulm.de", "name": "Technische Hochschule Ulm", "state": "BW"}, + {"url": "https://www.dhbw.de", "name": "Duale Hochschule Baden-Württemberg", "state": "BW"}, + + # ===== BAYERN ===== + {"url": "https://www.hm.edu", "name": "Hochschule München", "state": "BY"}, + {"url": "https://www.oth-regensburg.de", "name": "OTH Regensburg", "state": "BY"}, + {"url": "https://www.th-nuernberg.de", "name": "Technische Hochschule Nürnberg", "state": "BY"}, + {"url": "https://www.th-deg.de", "name": "Technische Hochschule Deggendorf", "state": "BY"}, + {"url": "https://www.haw-landshut.de", "name": "Hochschule Landshut", "state": "BY"}, + {"url": "https://www.hs-kempten.de", "name": "Hochschule Kempten", "state": "BY"}, + {"url": "https://www.hs-coburg.de", "name": "Hochschule Coburg", "state": "BY"}, + {"url": "https://www.hs-ansbach.de", "name": "Hochschule Ansbach", "state": "BY"}, + {"url": "https://www.hs-augsburg.de", "name": "Hochschule Augsburg", "state": "BY"}, + {"url": "https://www.th-ab.de", "name": "Technische Hochschule Aschaffenburg", "state": "BY"}, + {"url": "https://www.oth-aw.de", "name": "OTH Amberg-Weiden", "state": "BY"}, + {"url": "https://www.hswt.de", "name": "Hochschule Weihenstephan-Triesdorf", "state": "BY"}, + {"url": "https://www.th-rosenheim.de", "name": "Technische Hochschule Rosenheim", "state": "BY"}, + {"url": "https://www.fhws.de", "name": "Hochschule für angewandte Wissenschaften Würzburg-Schweinfurt", "state": "BY"}, + {"url": "https://www.hs-neu-ulm.de", "name": "Hochschule Neu-Ulm", "state": "BY"}, + {"url": "https://www.th-ingolstadt.de", "name": "Technische Hochschule Ingolstadt", "state": "BY"}, + {"url": "https://www.iubh.de", "name": "IU Internationale Hochschule", "state": "BY"}, + + # ===== BERLIN ===== + {"url": "https://www.htw-berlin.de", "name": "HTW Berlin", "state": "BE"}, + {"url": "https://www.bht-berlin.de", "name": "Berliner Hochschule für Technik", "state": "BE"}, + {"url": "https://www.hwr-berlin.de", "name": "HWR Berlin", "state": "BE"}, + {"url": "https://www.ash-berlin.eu", "name": "Alice Salomon Hochschule Berlin", "state": "BE"}, + {"url": "https://www.khb.hfm-berlin.de", "name": "Kunsthochschule Berlin-Weißensee", "state": "BE"}, + {"url": "https://www.hfpv-berlin.de", "name": "HfPV - Hochschule für Polizei und öffentliche Verwaltung NRW", "state": "BE"}, + + # ===== BRANDENBURG ===== + {"url": "https://www.th-wildau.de", "name": "Technische Hochschule Wildau", "state": "BB"}, + {"url": "https://www.fh-potsdam.de", "name": "Fachhochschule Potsdam", "state": "BB"}, + {"url": "https://www.th-brandenburg.de", "name": "Technische Hochschule Brandenburg", "state": "BB"}, + {"url": "https://www.hnee.de", "name": "Hochschule für nachhaltige Entwicklung Eberswalde", "state": "BB"}, + + # ===== BREMEN ===== + {"url": "https://www.hs-bremen.de", "name": "Hochschule Bremen", "state": "HB"}, + {"url": "https://www.hs-bremerhaven.de", "name": "Hochschule Bremerhaven", "state": "HB"}, + + # ===== HAMBURG ===== + {"url": "https://www.haw-hamburg.de", "name": "HAW Hamburg", "state": "HH"}, + {"url": "https://www.fh-wedel.de", "name": "Fachhochschule Wedel", "state": "HH"}, + + # ===== HESSEN ===== + {"url": "https://www.h-da.de", "name": "Hochschule Darmstadt", "state": "HE"}, + {"url": "https://www.thm.de", "name": "Technische Hochschule Mittelhessen", "state": "HE"}, + {"url": "https://www.frankfurt-university.de", "name": "Frankfurt University of Applied Sciences", "state": "HE"}, + {"url": "https://www.hs-fulda.de", "name": "Hochschule Fulda", "state": "HE"}, + {"url": "https://www.hs-rm.de", "name": "Hochschule RheinMain", "state": "HE"}, + + # ===== MECKLENBURG-VORPOMMERN ===== + {"url": "https://www.hs-wismar.de", "name": "Hochschule Wismar", "state": "MV"}, + {"url": "https://www.hs-nb.de", "name": "Hochschule Neubrandenburg", "state": "MV"}, + {"url": "https://www.hs-stralsund.de", "name": "Hochschule Stralsund", "state": "MV"}, + + # ===== NIEDERSACHSEN ===== + {"url": "https://www.hs-hannover.de", "name": "Hochschule Hannover", "state": "NI"}, + {"url": "https://www.ostfalia.de", "name": "Ostfalia Hochschule", "state": "NI"}, + {"url": "https://www.jade-hs.de", "name": "Jade Hochschule", "state": "NI"}, + {"url": "https://www.hs-osnabrueck.de", "name": "Hochschule Osnabrück", "state": "NI"}, + {"url": "https://www.hawk.de", "name": "HAWK Hildesheim/Holzminden/Göttingen", "state": "NI"}, + {"url": "https://www.hs-emden-leer.de", "name": "Hochschule Emden/Leer", "state": "NI"}, + + # ===== NORDRHEIN-WESTFALEN ===== + {"url": "https://www.th-koeln.de", "name": "TH Köln", "state": "NW"}, + {"url": "https://www.fh-dortmund.de", "name": "Fachhochschule Dortmund", "state": "NW"}, + {"url": "https://www.fh-aachen.de", "name": "FH Aachen", "state": "NW"}, + {"url": "https://www.hs-duesseldorf.de", "name": "Hochschule Düsseldorf", "state": "NW"}, + {"url": "https://www.hs-niederrhein.de", "name": "Hochschule Niederrhein", "state": "NW"}, + {"url": "https://www.hs-bochum.de", "name": "Hochschule Bochum", "state": "NW"}, + {"url": "https://www.hs-owl.de", "name": "Technische Hochschule Ostwestfalen-Lippe", "state": "NW"}, + {"url": "https://www.w-hs.de", "name": "Westfälische Hochschule", "state": "NW"}, + {"url": "https://www.fh-bielefeld.de", "name": "FH Bielefeld", "state": "NW"}, + {"url": "https://www.hs-hamm-lippstadt.de", "name": "Hochschule Hamm-Lippstadt", "state": "NW"}, + {"url": "https://www.fh-swf.de", "name": "Fachhochschule Südwestfalen", "state": "NW"}, + {"url": "https://www.hsbi.de", "name": "Hochschule Bielefeld", "state": "NW"}, + {"url": "https://www.hs-rhein-waal.de", "name": "Hochschule Rhein-Waal", "state": "NW"}, + {"url": "https://www.hs-ruhrwest.de", "name": "Hochschule Ruhr West", "state": "NW"}, + + # ===== RHEINLAND-PFALZ ===== + {"url": "https://www.hs-mainz.de", "name": "Hochschule Mainz", "state": "RP"}, + {"url": "https://www.hs-koblenz.de", "name": "Hochschule Koblenz", "state": "RP"}, + {"url": "https://www.hs-worms.de", "name": "Hochschule Worms", "state": "RP"}, + {"url": "https://www.hs-trier.de", "name": "Hochschule Trier", "state": "RP"}, + {"url": "https://www.hs-kaiserslautern.de", "name": "Hochschule Kaiserslautern", "state": "RP"}, + {"url": "https://www.hs-lu.de", "name": "Hochschule für Wirtschaft und Gesellschaft Ludwigshafen", "state": "RP"}, + {"url": "https://www.hs-bingen.de", "name": "Technische Hochschule Bingen", "state": "RP"}, + + # ===== SAARLAND ===== + {"url": "https://www.htwsaar.de", "name": "htw saar", "state": "SL"}, + + # ===== SACHSEN ===== + {"url": "https://www.htw-dresden.de", "name": "HTW Dresden", "state": "SN"}, + {"url": "https://www.htwk-leipzig.de", "name": "HTWK Leipzig", "state": "SN"}, + {"url": "https://www.hs-mittweida.de", "name": "Hochschule Mittweida", "state": "SN"}, + {"url": "https://www.fh-zwickau.de", "name": "Westsächsische Hochschule Zwickau", "state": "SN"}, + {"url": "https://www.hs-zittau-goerlitz.de", "name": "Hochschule Zittau/Görlitz", "state": "SN"}, + + # ===== SACHSEN-ANHALT ===== + {"url": "https://www.hs-magdeburg.de", "name": "Hochschule Magdeburg-Stendal", "state": "ST"}, + {"url": "https://www.hs-harz.de", "name": "Hochschule Harz", "state": "ST"}, + {"url": "https://www.hs-merseburg.de", "name": "Hochschule Merseburg", "state": "ST"}, + {"url": "https://www.hs-anhalt.de", "name": "Hochschule Anhalt", "state": "ST"}, + + # ===== SCHLESWIG-HOLSTEIN ===== + {"url": "https://www.fh-kiel.de", "name": "Fachhochschule Kiel", "state": "SH"}, + {"url": "https://www.fh-westkueste.de", "name": "Fachhochschule Westküste", "state": "SH"}, + {"url": "https://www.th-luebeck.de", "name": "Technische Hochschule Lübeck", "state": "SH"}, + {"url": "https://www.fh-flensburg.de", "name": "Hochschule Flensburg", "state": "SH"}, + + # ===== THÜRINGEN ===== + {"url": "https://www.fh-erfurt.de", "name": "Fachhochschule Erfurt", "state": "TH"}, + {"url": "https://www.eah-jena.de", "name": "Ernst-Abbe-Hochschule Jena", "state": "TH"}, + {"url": "https://www.hs-schmalkalden.de", "name": "Hochschule Schmalkalden", "state": "TH"}, + {"url": "https://www.hs-nordhausen.de", "name": "Hochschule Nordhausen", "state": "TH"}, +] + +# ============================================================================= +# PÄDAGOGISCHE HOCHSCHULEN (nur Baden-Württemberg) +# ============================================================================= + +PAEDAGOGISCHE_HOCHSCHULEN = [ + {"url": "https://www.ph-freiburg.de", "name": "Pädagogische Hochschule Freiburg", "state": "BW"}, + {"url": "https://www.ph-heidelberg.de", "name": "Pädagogische Hochschule Heidelberg", "state": "BW"}, + {"url": "https://www.ph-karlsruhe.de", "name": "Pädagogische Hochschule Karlsruhe", "state": "BW"}, + {"url": "https://www.ph-ludwigsburg.de", "name": "Pädagogische Hochschule Ludwigsburg", "state": "BW"}, + {"url": "https://www.ph-schwäbisch-gmünd.de", "name": "Pädagogische Hochschule Schwäbisch Gmünd", "state": "BW"}, + {"url": "https://www.ph-weingarten.de", "name": "Pädagogische Hochschule Weingarten", "state": "BW"}, +] + +# ============================================================================= +# KUNST- UND MUSIKHOCHSCHULEN +# ============================================================================= + +KUNSTHOCHSCHULEN = [ + # ===== BADEN-WÜRTTEMBERG ===== + {"url": "https://www.abk-stuttgart.de", "name": "Staatliche Akademie der Bildenden Künste Stuttgart", "state": "BW"}, + {"url": "https://www.hfg-karlsruhe.de", "name": "Staatliche Hochschule für Gestaltung Karlsruhe", "state": "BW"}, + {"url": "https://www.mh-freiburg.de", "name": "Hochschule für Musik Freiburg", "state": "BW"}, + {"url": "https://www.hmdk-stuttgart.de", "name": "Hochschule für Musik und Darstellende Kunst Stuttgart", "state": "BW"}, + {"url": "https://www.hfm-karlsruhe.de", "name": "Hochschule für Musik Karlsruhe", "state": "BW"}, + {"url": "https://www.hfm-trossingen.de", "name": "Hochschule für Musik Trossingen", "state": "BW"}, + {"url": "https://www.filmakademie.de", "name": "Filmakademie Baden-Württemberg", "state": "BW"}, + {"url": "https://www.popakademie.de", "name": "Popakademie Baden-Württemberg", "state": "BW"}, + + # ===== BAYERN ===== + {"url": "https://www.adbk.de", "name": "Akademie der Bildenden Künste München", "state": "BY"}, + {"url": "https://www.adbk-nuernberg.de", "name": "Akademie der Bildenden Künste Nürnberg", "state": "BY"}, + {"url": "https://www.hmtm.de", "name": "Hochschule für Musik und Theater München", "state": "BY"}, + {"url": "https://www.hfm-wuerzburg.de", "name": "Hochschule für Musik Würzburg", "state": "BY"}, + {"url": "https://www.hff-muenchen.de", "name": "Hochschule für Fernsehen und Film München", "state": "BY"}, + + # ===== BERLIN ===== + {"url": "https://www.udk-berlin.de", "name": "Universität der Künste Berlin", "state": "BE"}, + {"url": "https://www.hfm-berlin.de", "name": "Hochschule für Musik Hanns Eisler Berlin", "state": "BE"}, + {"url": "https://www.dffb.de", "name": "Deutsche Film- und Fernsehakademie Berlin", "state": "BE"}, + + # ===== HAMBURG ===== + {"url": "https://www.hfbk-hamburg.de", "name": "Hochschule für bildende Künste Hamburg", "state": "HH"}, + {"url": "https://www.hfmt-hamburg.de", "name": "Hochschule für Musik und Theater Hamburg", "state": "HH"}, + + # ===== HESSEN ===== + {"url": "https://www.hfg-offenbach.de", "name": "Hochschule für Gestaltung Offenbach", "state": "HE"}, + {"url": "https://www.hfmdk-frankfurt.de", "name": "Hochschule für Musik und Darstellende Kunst Frankfurt", "state": "HE"}, + + # ===== NORDRHEIN-WESTFALEN ===== + {"url": "https://www.kunstakademie-duesseldorf.de", "name": "Kunstakademie Düsseldorf", "state": "NW"}, + {"url": "https://www.kunstakademie-muenster.de", "name": "Kunstakademie Münster", "state": "NW"}, + {"url": "https://www.hfmt-koeln.de", "name": "Hochschule für Musik und Tanz Köln", "state": "NW"}, + {"url": "https://www.folkwang-uni.de", "name": "Folkwang Universität der Künste", "state": "NW"}, + {"url": "https://www.rsh-duesseldorf.de", "name": "Robert Schumann Hochschule Düsseldorf", "state": "NW"}, + {"url": "https://www.hfm-detmold.de", "name": "Hochschule für Musik Detmold", "state": "NW"}, + + # ===== SACHSEN ===== + {"url": "https://www.hfbk-dresden.de", "name": "Hochschule für Bildende Künste Dresden", "state": "SN"}, + {"url": "https://www.hmt-leipzig.de", "name": "Hochschule für Musik und Theater Leipzig", "state": "SN"}, + {"url": "https://www.hfmdd.de", "name": "Hochschule für Musik Carl Maria von Weber Dresden", "state": "SN"}, + {"url": "https://www.palucca.eu", "name": "Palucca Hochschule für Tanz Dresden", "state": "SN"}, + + # Other states + {"url": "https://www.hfk-bremen.de", "name": "Hochschule für Künste Bremen", "state": "HB"}, + {"url": "https://www.burg-halle.de", "name": "Burg Giebichenstein Kunsthochschule Halle", "state": "ST"}, + {"url": "https://www.hmtm-hannover.de", "name": "Hochschule für Musik, Theater und Medien Hannover", "state": "NI"}, + {"url": "https://www.hfk-bremen.de", "name": "Hochschule für Künste Bremen", "state": "HB"}, + {"url": "https://www.muho-mannheim.de", "name": "Hochschule für Musik und Darstellende Kunst Mannheim", "state": "BW"}, + {"url": "https://www.hfm-saar.de", "name": "Hochschule für Musik Saar", "state": "SL"}, + {"url": "https://www.hfm-weimar.de", "name": "Hochschule für Musik Franz Liszt Weimar", "state": "TH"}, + {"url": "https://www.mh-luebeck.de", "name": "Musikhochschule Lübeck", "state": "SH"}, + {"url": "https://www.hmt-rostock.de", "name": "Hochschule für Musik und Theater Rostock", "state": "MV"}, +] + +# ============================================================================= +# PRIVATE HOCHSCHULEN (Auswahl der wichtigsten) +# ============================================================================= + +PRIVATE_HOCHSCHULEN = [ + {"url": "https://www.srh.de", "name": "SRH Hochschule", "state": "BW"}, + {"url": "https://www.escp.eu", "name": "ESCP Business School Berlin", "state": "BE"}, + {"url": "https://www.hertie-school.org", "name": "Hertie School", "state": "BE"}, + {"url": "https://www.steinbeis-hochschule.de", "name": "Steinbeis Hochschule", "state": "BE"}, + {"url": "https://www.code.berlin", "name": "CODE University of Applied Sciences", "state": "BE"}, + {"url": "https://www.whu.edu", "name": "WHU – Otto Beisheim School of Management", "state": "RP"}, + {"url": "https://www.ebs.edu", "name": "EBS Universität", "state": "HE"}, + {"url": "https://www.fom.de", "name": "FOM Hochschule", "state": "NW"}, + {"url": "https://www.macromedia-fachhochschule.de", "name": "Hochschule Macromedia", "state": "BY"}, + {"url": "https://www.ism.de", "name": "International School of Management", "state": "NW"}, + {"url": "https://www.hhl.de", "name": "HHL Leipzig Graduate School of Management", "state": "SN"}, + {"url": "https://www.fs.de", "name": "Frankfurt School of Finance & Management", "state": "HE"}, + {"url": "https://www.bits-iserlohn.de", "name": "BiTS Hochschule", "state": "NW"}, + {"url": "https://www.umit.at", "name": "UMIT - Private Universität für Gesundheitswissenschaften", "state": "BY"}, + {"url": "https://www.bucerius-law-school.de", "name": "Bucerius Law School", "state": "HH"}, + {"url": "https://www.akad.de", "name": "AKAD Hochschule Stuttgart", "state": "BW"}, + {"url": "https://www.diploma.de", "name": "DIPLOMA Hochschule", "state": "HE"}, + {"url": "https://www.apollon-hochschule.de", "name": "APOLLON Hochschule", "state": "HB"}, + {"url": "https://www.euro-fh.de", "name": "Euro-FH Hamburg", "state": "HH"}, + {"url": "https://www.wings.de", "name": "WINGS Fernstudium", "state": "MV"}, +] + + +def generate_seed(uni: dict, category: str, source_type: str, trust_boost: float) -> dict: + """Generate a seed entry from university data.""" + return { + "url": uni["url"], + "name": uni["name"], + "description": f"Deutsche Hochschule - {uni['name']}", + "category_name": category, # API expects category_name, not category + "trust_boost": trust_boost, + "source_type": source_type, + "scope": "STATE", + "state": uni.get("state", "") + } + + +def get_all_university_seeds() -> list: + """Get all university seeds formatted for API.""" + seeds = [] + + # Universitäten - höchster Trust + for uni in UNIVERSITAETEN: + seeds.append(generate_seed(uni, "universities", "UNI", 0.90)) + + # Fachhochschulen + for uni in FACHHOCHSCHULEN: + seeds.append(generate_seed(uni, "universities", "FH", 0.85)) + + # Pädagogische Hochschulen - sehr relevant für Bildung + for uni in PAEDAGOGISCHE_HOCHSCHULEN: + seeds.append(generate_seed(uni, "universities", "PH", 0.92)) + + # Kunst- und Musikhochschulen + for uni in KUNSTHOCHSCHULEN: + seeds.append(generate_seed(uni, "universities", "KUNST", 0.80)) + + # Private Hochschulen + for uni in PRIVATE_HOCHSCHULEN: + seeds.append(generate_seed(uni, "universities", "PRIVATE", 0.75)) + + return seeds + + +async def load_university_seeds(): + """Load university seeds via bulk import API.""" + seeds = get_all_university_seeds() + + print(f"Loading {len(seeds)} university seeds into {API_BASE}...") + print(f"\nBreakdown:") + print(f" - Universitäten: {len(UNIVERSITAETEN)}") + print(f" - Fachhochschulen: {len(FACHHOCHSCHULEN)}") + print(f" - Pädagogische Hochschulen: {len(PAEDAGOGISCHE_HOCHSCHULEN)}") + print(f" - Kunst-/Musikhochschulen: {len(KUNSTHOCHSCHULEN)}") + print(f" - Private Hochschulen: {len(PRIVATE_HOCHSCHULEN)}") + print(f" - TOTAL: {len(seeds)}") + + # Count by state + by_state = {} + for seed in seeds: + state = seed.get("state", "unknown") + by_state[state] = by_state.get(state, 0) + 1 + print(f"\nBy state:") + for state, count in sorted(by_state.items()): + print(f" - {state}: {count}") + + try: + async with httpx.AsyncClient(timeout=120.0) as client: + # Check API health first + try: + health_response = await client.get(f"{API_BASE}/health") + if health_response.status_code != 200: + print(f"WARNING: Health check returned {health_response.status_code}") + except httpx.ConnectError: + print(f"ERROR: Cannot connect to API at {API_BASE}") + print("Make sure the backend service is running:") + print(" docker compose up -d backend") + return False + + # Import seeds in batches to avoid timeout + batch_size = 100 + total_imported = 0 + total_skipped = 0 + all_errors = [] + + for i in range(0, len(seeds), batch_size): + batch = seeds[i:i + batch_size] + print(f"\nImporting batch {i // batch_size + 1} ({len(batch)} seeds)...") + + response = await client.post( + f"{API_BASE}/v1/edu-search/seeds/bulk-import", + json={"seeds": batch} + ) + + if response.status_code == 200: + result = response.json() + imported = result.get('imported', 0) + skipped = result.get('skipped', 0) + errors = result.get('errors', []) + + total_imported += imported + total_skipped += skipped + all_errors.extend(errors) + + print(f" Imported: {imported}, Skipped: {skipped}") + else: + print(f" ERROR: Batch failed with status {response.status_code}") + try: + error_detail = response.json() + print(f" Detail: {error_detail.get('detail', response.text[:200])}") + except Exception: + print(f" Response: {response.text[:200]}") + + print(f"\n{'='*50}") + print(f"TOTAL RESULTS:") + print(f" Imported: {total_imported}") + print(f" Skipped (duplicates): {total_skipped}") + + if all_errors: + print(f" Errors: {len(all_errors)}") + for err in all_errors[:10]: + print(f" - {err}") + if len(all_errors) > 10: + print(f" ... and {len(all_errors) - 10} more") + + # Get stats + print("\nFetching statistics...") + stats_response = await client.get(f"{API_BASE}/v1/edu-search/stats") + if stats_response.status_code == 200: + stats = stats_response.json() + print(f"\nDatabase Statistics:") + print(f" Total seeds: {stats.get('total_seeds', 0)}") + print(f" Enabled seeds: {stats.get('enabled_seeds', 0)}") + + print("\nDone!") + return True + + except httpx.ConnectError as e: + print(f"\nERROR: Connection failed - {e}") + return False + except httpx.TimeoutException: + print(f"\nERROR: Request timed out") + return False + except Exception as e: + print(f"\nERROR: Unexpected error - {e}") + import traceback + traceback.print_exc() + return False + + +if __name__ == "__main__": + import sys + print("="*60) + print("German University Seeds Loader") + print("="*60) + success = asyncio.run(load_university_seeds()) + sys.exit(0 if success else 1) diff --git a/backend/scripts/test_compliance_ai_endpoints.py b/backend/scripts/test_compliance_ai_endpoints.py new file mode 100755 index 0000000..def0d17 --- /dev/null +++ b/backend/scripts/test_compliance_ai_endpoints.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +""" +Test script for Compliance AI API Endpoints. + +Usage: + python scripts/test_compliance_ai_endpoints.py + +Environment: + BACKEND_URL: Base URL of the backend (default: http://localhost:8000) + COMPLIANCE_LLM_PROVIDER: Set to "mock" for testing without API keys +""" + +import asyncio +import os +import sys +from typing import Dict, Any + +import httpx + + +class ComplianceAITester: + """Tester for Compliance AI endpoints.""" + + def __init__(self, base_url: str = "http://localhost:8000"): + self.base_url = base_url.rstrip("/") + self.api_prefix = f"{self.base_url}/api/v1/compliance" + + async def test_ai_status(self) -> Dict[str, Any]: + """Test GET /ai/status endpoint.""" + print("\n=== Testing AI Status ===") + + async with httpx.AsyncClient() as client: + response = await client.get(f"{self.api_prefix}/ai/status") + response.raise_for_status() + data = response.json() + + print(f"Provider: {data['provider']}") + print(f"Model: {data['model']}") + print(f"Available: {data['is_available']}") + print(f"Is Mock: {data['is_mock']}") + + if data.get("error"): + print(f"Error: {data['error']}") + + return data + + async def test_interpret_requirement(self, requirement_id: str) -> Dict[str, Any]: + """Test POST /ai/interpret endpoint.""" + print(f"\n=== Testing Requirement Interpretation ===") + print(f"Requirement ID: {requirement_id}") + + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + f"{self.api_prefix}/ai/interpret", + json={ + "requirement_id": requirement_id, + "force_refresh": False + } + ) + + if response.status_code == 404: + print(f"ERROR: Requirement {requirement_id} not found") + return {} + + response.raise_for_status() + data = response.json() + + print(f"\nSummary: {data['summary'][:100]}...") + print(f"Applicability: {data['applicability'][:100]}...") + print(f"Risk Level: {data['risk_level']}") + print(f"Affected Modules: {', '.join(data['affected_modules'])}") + print(f"Technical Measures: {len(data['technical_measures'])} measures") + print(f"Confidence: {data['confidence_score']:.2f}") + + if data.get("error"): + print(f"Error: {data['error']}") + + return data + + async def test_suggest_controls(self, requirement_id: str) -> Dict[str, Any]: + """Test POST /ai/suggest-controls endpoint.""" + print(f"\n=== Testing Control Suggestions ===") + print(f"Requirement ID: {requirement_id}") + + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + f"{self.api_prefix}/ai/suggest-controls", + json={"requirement_id": requirement_id} + ) + + if response.status_code == 404: + print(f"ERROR: Requirement {requirement_id} not found") + return {} + + response.raise_for_status() + data = response.json() + + print(f"\nFound {len(data['suggestions'])} control suggestions:") + for i, ctrl in enumerate(data['suggestions'], 1): + print(f"\n{i}. {ctrl['control_id']}: {ctrl['title']}") + print(f" Domain: {ctrl['domain']}") + print(f" Priority: {ctrl['priority']}") + print(f" Automated: {ctrl['is_automated']}") + if ctrl['automation_tool']: + print(f" Tool: {ctrl['automation_tool']}") + print(f" Confidence: {ctrl['confidence_score']:.2f}") + + return data + + async def test_assess_module_risk(self, module_id: str) -> Dict[str, Any]: + """Test POST /ai/assess-risk endpoint.""" + print(f"\n=== Testing Module Risk Assessment ===") + print(f"Module ID: {module_id}") + + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + f"{self.api_prefix}/ai/assess-risk", + json={"module_id": module_id} + ) + + if response.status_code == 404: + print(f"ERROR: Module {module_id} not found") + return {} + + response.raise_for_status() + data = response.json() + + print(f"\nModule: {data['module_name']}") + print(f"Overall Risk: {data['overall_risk']}") + print(f"\nRisk Factors:") + for factor in data['risk_factors']: + print(f" - {factor['factor']}") + print(f" Severity: {factor['severity']}, Likelihood: {factor['likelihood']}") + + print(f"\nRecommendations:") + for rec in data['recommendations']: + print(f" - {rec}") + + print(f"\nCompliance Gaps:") + for gap in data['compliance_gaps']: + print(f" - {gap}") + + print(f"\nConfidence: {data['confidence_score']:.2f}") + + return data + + async def test_gap_analysis(self, requirement_id: str) -> Dict[str, Any]: + """Test POST /ai/gap-analysis endpoint.""" + print(f"\n=== Testing Gap Analysis ===") + print(f"Requirement ID: {requirement_id}") + + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + f"{self.api_prefix}/ai/gap-analysis", + json={"requirement_id": requirement_id} + ) + + if response.status_code == 404: + print(f"ERROR: Requirement {requirement_id} not found") + return {} + + response.raise_for_status() + data = response.json() + + print(f"\nRequirement: {data['requirement_title']}") + print(f"Coverage Level: {data['coverage_level']}") + + print(f"\nExisting Controls:") + for ctrl in data['existing_controls']: + print(f" - {ctrl}") + + print(f"\nMissing Coverage:") + for missing in data['missing_coverage']: + print(f" - {missing}") + + print(f"\nSuggested Actions:") + for action in data['suggested_actions']: + print(f" - {action}") + + return data + + async def test_batch_interpret(self, requirement_ids: list) -> Dict[str, Any]: + """Test POST /ai/batch-interpret endpoint.""" + print(f"\n=== Testing Batch Interpretation ===") + print(f"Requirements: {len(requirement_ids)}") + + async with httpx.AsyncClient(timeout=120.0) as client: + response = await client.post( + f"{self.api_prefix}/ai/batch-interpret", + json={ + "requirement_ids": requirement_ids, + "rate_limit": 1.0 + } + ) + response.raise_for_status() + data = response.json() + + print(f"\nTotal: {data['total']}") + print(f"Processed: {data['processed']}") + print(f"Success Rate: {data['processed']/data['total']*100:.1f}%") + + if data['interpretations']: + print(f"\nFirst interpretation:") + first = data['interpretations'][0] + print(f" ID: {first['requirement_id']}") + print(f" Summary: {first['summary'][:100]}...") + print(f" Risk: {first['risk_level']}") + + return data + + async def get_sample_requirement_id(self) -> str: + """Get a sample requirement ID from the database.""" + async with httpx.AsyncClient() as client: + # Try to get requirements + response = await client.get(f"{self.api_prefix}/requirements?limit=1") + if response.status_code == 200: + data = response.json() + if data["requirements"]: + return data["requirements"][0]["id"] + + return None + + async def get_sample_module_id(self) -> str: + """Get a sample module ID from the database.""" + async with httpx.AsyncClient() as client: + # Try to get modules + response = await client.get(f"{self.api_prefix}/modules") + if response.status_code == 200: + data = response.json() + if data["modules"]: + return data["modules"][0]["id"] + + return None + + async def run_all_tests(self): + """Run all endpoint tests.""" + print("=" * 70) + print("Compliance AI Endpoints Test Suite") + print("=" * 70) + + # Test AI status first + try: + status = await self.test_ai_status() + if not status.get("is_available"): + print("\n⚠️ WARNING: AI provider is not available!") + print("Set COMPLIANCE_LLM_PROVIDER=mock for testing without API keys") + return + except Exception as e: + print(f"\n❌ ERROR: Could not connect to backend: {e}") + return + + # Get sample IDs + print("\n--- Fetching sample data ---") + requirement_id = await self.get_sample_requirement_id() + module_id = await self.get_sample_module_id() + + if not requirement_id: + print("\n⚠️ WARNING: No requirements found in database") + print("Run seed command first: POST /api/v1/compliance/seed") + return + + print(f"Sample Requirement ID: {requirement_id}") + if module_id: + print(f"Sample Module ID: {module_id}") + + # Run tests + tests = [ + ("Interpret Requirement", self.test_interpret_requirement(requirement_id)), + ("Suggest Controls", self.test_suggest_controls(requirement_id)), + ("Gap Analysis", self.test_gap_analysis(requirement_id)), + ] + + if module_id: + tests.append(("Assess Module Risk", self.test_assess_module_risk(module_id))) + + # Execute tests + results = {"passed": 0, "failed": 0} + for test_name, test_coro in tests: + try: + await test_coro + results["passed"] += 1 + print(f"\n✅ {test_name} - PASSED") + except Exception as e: + results["failed"] += 1 + print(f"\n❌ {test_name} - FAILED: {e}") + + # Summary + print("\n" + "=" * 70) + print("Test Summary") + print("=" * 70) + print(f"✅ Passed: {results['passed']}") + print(f"❌ Failed: {results['failed']}") + print(f"Total: {results['passed'] + results['failed']}") + print("=" * 70) + + +async def main(): + """Main entry point.""" + backend_url = os.getenv("BACKEND_URL", "http://localhost:8000") + + print(f"Backend URL: {backend_url}") + print(f"Provider: {os.getenv('COMPLIANCE_LLM_PROVIDER', 'default')}") + + tester = ComplianceAITester(base_url=backend_url) + + try: + await tester.run_all_tests() + except KeyboardInterrupt: + print("\n\nTest interrupted by user") + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/scripts/verify_sprint4.sh b/backend/scripts/verify_sprint4.sh new file mode 100755 index 0000000..c002f35 --- /dev/null +++ b/backend/scripts/verify_sprint4.sh @@ -0,0 +1,141 @@ +#!/bin/bash + +# Verification script for Sprint 4 implementation +# Checks that all required components are present + +echo "========================================" +echo "Sprint 4 - KI-Integration Verifikation" +echo "========================================" +echo "" + +# Color codes +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +FAILED=0 +PASSED=0 + +check_file() { + local file="$1" + local description="$2" + + if [ -f "$file" ]; then + echo -e "${GREEN}✓${NC} $description" + echo " → $file" + ((PASSED++)) + else + echo -e "${RED}✗${NC} $description" + echo " → $file (NOT FOUND)" + ((FAILED++)) + fi +} + +check_content() { + local file="$1" + local pattern="$2" + local description="$3" + + if [ -f "$file" ] && grep -q "$pattern" "$file"; then + echo -e "${GREEN}✓${NC} $description" + ((PASSED++)) + else + echo -e "${RED}✗${NC} $description" + echo " → Pattern '$pattern' not found in $file" + ((FAILED++)) + fi +} + +echo "1. Core Components" +echo "-------------------" +check_file "compliance/services/llm_provider.py" "LLM Provider Abstraction" +check_file "compliance/services/ai_compliance_assistant.py" "AI Compliance Assistant" +check_file "compliance/api/routes.py" "API Routes" +check_file "compliance/api/schemas.py" "API Schemas" +echo "" + +echo "2. LLM Provider Classes" +echo "------------------------" +check_content "compliance/services/llm_provider.py" "class LLMProvider" "Abstrakte LLMProvider Klasse" +check_content "compliance/services/llm_provider.py" "class AnthropicProvider" "AnthropicProvider" +check_content "compliance/services/llm_provider.py" "class SelfHostedProvider" "SelfHostedProvider" +check_content "compliance/services/llm_provider.py" "class MockProvider" "MockProvider" +check_content "compliance/services/llm_provider.py" "def get_llm_provider" "get_llm_provider() Factory" +echo "" + +echo "3. AI Assistant Methods" +echo "------------------------" +check_content "compliance/services/ai_compliance_assistant.py" "async def interpret_requirement" "interpret_requirement()" +check_content "compliance/services/ai_compliance_assistant.py" "async def suggest_controls" "suggest_controls()" +check_content "compliance/services/ai_compliance_assistant.py" "async def assess_module_risk" "assess_module_risk()" +check_content "compliance/services/ai_compliance_assistant.py" "async def analyze_gap" "analyze_gap()" +check_content "compliance/services/ai_compliance_assistant.py" "async def batch_interpret_requirements" "batch_interpret_requirements()" +echo "" + +echo "4. API Endpoints" +echo "-----------------" +check_content "compliance/api/routes.py" "async def get_ai_status" "GET /ai/status" +check_content "compliance/api/routes.py" "async def interpret_requirement" "POST /ai/interpret" +check_content "compliance/api/routes.py" "async def suggest_controls" "POST /ai/suggest-controls" +check_content "compliance/api/routes.py" "async def assess_module_risk" "POST /ai/assess-risk" +check_content "compliance/api/routes.py" "async def analyze_gap" "POST /ai/gap-analysis" +check_content "compliance/api/routes.py" "async def batch_interpret_requirements" "POST /ai/batch-interpret" +echo "" + +echo "5. Pydantic Schemas" +echo "--------------------" +check_content "compliance/api/schemas.py" "class AIStatusResponse" "AIStatusResponse" +check_content "compliance/api/schemas.py" "class AIInterpretationRequest" "AIInterpretationRequest" +check_content "compliance/api/schemas.py" "class AIInterpretationResponse" "AIInterpretationResponse" +check_content "compliance/api/schemas.py" "class AIControlSuggestionRequest" "AIControlSuggestionRequest" +check_content "compliance/api/schemas.py" "class AIControlSuggestionResponse" "AIControlSuggestionResponse" +check_content "compliance/api/schemas.py" "class AIRiskAssessmentRequest" "AIRiskAssessmentRequest" +check_content "compliance/api/schemas.py" "class AIRiskAssessmentResponse" "AIRiskAssessmentResponse" +check_content "compliance/api/schemas.py" "class AIGapAnalysisRequest" "AIGapAnalysisRequest" +check_content "compliance/api/schemas.py" "class AIGapAnalysisResponse" "AIGapAnalysisResponse" +echo "" + +echo "6. Environment Variables" +echo "-------------------------" +check_content ".env.example" "COMPLIANCE_LLM_PROVIDER" "COMPLIANCE_LLM_PROVIDER" +check_content ".env.example" "ANTHROPIC_MODEL" "ANTHROPIC_MODEL" +check_content ".env.example" "SELF_HOSTED_LLM_URL" "SELF_HOSTED_LLM_URL" +check_content ".env.example" "COMPLIANCE_LLM_MAX_TOKENS" "COMPLIANCE_LLM_MAX_TOKENS" +echo "" + +echo "7. Documentation" +echo "-----------------" +check_file "docs/compliance_ai_integration.md" "Vollständige Dokumentation" +check_file "compliance/README_AI.md" "Quick-Start Guide" +check_file "compliance/SPRINT_4_SUMMARY.md" "Sprint 4 Zusammenfassung" +echo "" + +echo "8. Tests" +echo "---------" +check_file "tests/test_compliance_ai.py" "Unit Tests" +check_file "scripts/test_compliance_ai_endpoints.py" "Integration Test Script" +echo "" + +echo "9. German Prompts" +echo "------------------" +check_content "compliance/services/ai_compliance_assistant.py" "Du bist ein Compliance-Experte" "Deutscher System Prompt" +check_content "compliance/services/ai_compliance_assistant.py" "Breakpilot" "Breakpilot-spezifisch" +check_content "compliance/services/ai_compliance_assistant.py" "EdTech" "EdTech Kontext" +echo "" + +# Summary +echo "========================================" +echo "Zusammenfassung" +echo "========================================" +echo -e "${GREEN}Bestanden: $PASSED${NC}" +echo -e "${RED}Fehlgeschlagen: $FAILED${NC}" +echo "" + +if [ $FAILED -eq 0 ]; then + echo -e "${GREEN}✓ Sprint 4 ist vollständig implementiert!${NC}" + exit 0 +else + echo -e "${RED}✗ Es fehlen noch $FAILED Komponenten${NC}" + exit 1 +fi diff --git a/backend/secret_store/__init__.py b/backend/secret_store/__init__.py new file mode 100644 index 0000000..26b5947 --- /dev/null +++ b/backend/secret_store/__init__.py @@ -0,0 +1,34 @@ +""" +BreakPilot Secret Store Module + +This module provides a unified interface for accessing secrets from: +1. HashiCorp Vault (production) +2. Environment variables (development fallback) +3. Docker secrets (containerized environments) + +Security Architecture: +- Vault is the primary secrets store in production +- Environment variables serve as fallback for development +- No secrets are ever hardcoded in the application code + +Note: Renamed from 'secrets' to 'secret_store' to avoid conflict +with Python's built-in secrets module. +""" + +from .vault_client import ( + SecretsManager, + get_secrets_manager, + get_secret, + VaultConfig, + SecretNotFoundError, + VaultConnectionError, +) + +__all__ = [ + "SecretsManager", + "get_secrets_manager", + "get_secret", + "VaultConfig", + "SecretNotFoundError", + "VaultConnectionError", +] diff --git a/backend/secret_store/vault_client.py b/backend/secret_store/vault_client.py new file mode 100644 index 0000000..0fdb85f --- /dev/null +++ b/backend/secret_store/vault_client.py @@ -0,0 +1,515 @@ +""" +HashiCorp Vault Client for BreakPilot + +Provides secure secrets management with: +- Vault KV v2 secrets engine support +- Automatic token renewal +- Fallback to environment variables in development +- Docker secrets support for containerized environments +- Caching to reduce Vault API calls + +Usage: + from secret_store import get_secret + + # Get a secret (tries Vault first, then env, then Docker secrets) + api_key = get_secret("anthropic_api_key") + + # Get with default value + debug = get_secret("debug_mode", default="false") + +License: Apache-2.0 (HashiCorp Vault compatible) +""" + +import os +import logging +import functools +from dataclasses import dataclass, field +from typing import Optional, Dict, Any, List +from pathlib import Path +import json +import time + +logger = logging.getLogger(__name__) + + +# ============================================= +# Exceptions +# ============================================= + +class SecretNotFoundError(Exception): + """Raised when a secret cannot be found in any source.""" + pass + + +# ============================================= +# Environment Variable to Vault Path Mapping +# ============================================= + +# Maps common environment variable names to Vault paths +# Format: env_var_name -> vault_path (relative to secrets_path) +ENV_TO_VAULT_MAPPING = { + # API Keys + "ANTHROPIC_API_KEY": "api_keys/anthropic", + "VAST_API_KEY": "api_keys/vast", + "TAVILY_API_KEY": "api_keys/tavily", + "STRIPE_SECRET_KEY": "api_keys/stripe", + "STRIPE_WEBHOOK_SECRET": "api_keys/stripe_webhook", + "OPENAI_API_KEY": "api_keys/openai", + + # Database + "DATABASE_URL": "database/postgres", + "POSTGRES_PASSWORD": "database/postgres", + "SYNAPSE_DB_PASSWORD": "database/synapse", + + # Auth + "JWT_SECRET": "auth/jwt", + "JWT_REFRESH_SECRET": "auth/jwt", + "KEYCLOAK_CLIENT_SECRET": "auth/keycloak", + + # Communication + "MATRIX_ACCESS_TOKEN": "communication/matrix", + "JITSI_APP_SECRET": "communication/jitsi", + + # Storage + "MINIO_ACCESS_KEY": "storage/minio", + "MINIO_SECRET_KEY": "storage/minio", + + # Infrastructure + "CONTROL_API_KEY": "infra/vast", + "VAST_INSTANCE_ID": "infra/vast", +} + + +class VaultConnectionError(Exception): + """Raised when Vault is configured but unreachable.""" + pass + + +class VaultAuthenticationError(Exception): + """Raised when Vault authentication fails.""" + pass + + +# ============================================= +# Configuration +# ============================================= + +@dataclass +class VaultConfig: + """Configuration for HashiCorp Vault connection.""" + + # Vault server URL + url: str = "" + + # Authentication method: "token", "approle", "kubernetes" + auth_method: str = "token" + + # Token for token auth + token: Optional[str] = None + + # AppRole credentials + role_id: Optional[str] = None + secret_id: Optional[str] = None + + # Kubernetes auth + kubernetes_role: Optional[str] = None + kubernetes_jwt_path: str = "/var/run/secrets/kubernetes.io/serviceaccount/token" + + # KV secrets engine configuration + secrets_mount: str = "secret" # KV v2 mount path + secrets_path: str = "breakpilot" # Base path for secrets + + # TLS configuration + verify_ssl: bool = True + ca_cert: Optional[str] = None + + # Caching + cache_ttl_seconds: int = 300 # 5 minutes + + # Namespace (Vault Enterprise) + namespace: Optional[str] = None + + +# ============================================= +# Secrets Manager +# ============================================= + +class SecretsManager: + """ + Unified secrets management with multiple backends. + + Priority order: + 1. HashiCorp Vault (if configured and available) + 2. Environment variables + 3. Docker secrets (/run/secrets/) + 4. Default value (if provided) + """ + + def __init__( + self, + vault_config: Optional[VaultConfig] = None, + environment: str = "development" + ): + self.vault_config = vault_config + self.environment = environment + self._vault_client = None + self._cache: Dict[str, tuple] = {} # key -> (value, expiry_time) + self._vault_available = False + + # Initialize Vault client if configured + if vault_config and vault_config.url: + self._init_vault_client() + + def _init_vault_client(self): + """Initialize the Vault client with hvac library.""" + try: + import hvac + + config = self.vault_config + + # Create client + self._vault_client = hvac.Client( + url=config.url, + verify=config.verify_ssl if config.ca_cert is None else config.ca_cert, + namespace=config.namespace, + ) + + # Authenticate based on method + if config.auth_method == "token": + if config.token: + self._vault_client.token = config.token + + elif config.auth_method == "approle": + if config.role_id and config.secret_id: + self._vault_client.auth.approle.login( + role_id=config.role_id, + secret_id=config.secret_id, + ) + + elif config.auth_method == "kubernetes": + jwt_path = Path(config.kubernetes_jwt_path) + if jwt_path.exists(): + jwt = jwt_path.read_text().strip() + self._vault_client.auth.kubernetes.login( + role=config.kubernetes_role, + jwt=jwt, + ) + + # Check if authenticated + if self._vault_client.is_authenticated(): + self._vault_available = True + logger.info("Vault client initialized successfully") + else: + logger.warning("Vault client created but not authenticated") + + except ImportError: + logger.warning("hvac library not installed - Vault integration disabled") + except Exception as e: + logger.warning(f"Failed to initialize Vault client: {e}") + + def _get_from_vault(self, key: str) -> Optional[str]: + """Get a secret from Vault KV v2.""" + if not self._vault_available or not self._vault_client: + return None + + try: + config = self.vault_config + + # Use mapping if available, otherwise use key as path + vault_path = ENV_TO_VAULT_MAPPING.get(key, key) + path = f"{config.secrets_path}/{vault_path}" + + # Read from KV v2 + response = self._vault_client.secrets.kv.v2.read_secret_version( + path=path, + mount_point=config.secrets_mount, + ) + + if response and "data" in response and "data" in response["data"]: + data = response["data"]["data"] + + # For multi-field secrets like JWT, get specific field + field_mapping = { + "JWT_SECRET": "secret", + "JWT_REFRESH_SECRET": "refresh_secret", + "DATABASE_URL": "url", + "POSTGRES_PASSWORD": "password", + "MINIO_ACCESS_KEY": "access_key", + "MINIO_SECRET_KEY": "secret_key", + "VAST_INSTANCE_ID": "instance_id", + "CONTROL_API_KEY": "control_key", + } + + # Get specific field if mapped + if key in field_mapping and field_mapping[key] in data: + return data[field_mapping[key]] + + # Return the 'value' field or the first field + if "value" in data: + return data["value"] + elif data: + return list(data.values())[0] + + except Exception as e: + logger.debug(f"Vault lookup failed for {key}: {e}") + + return None + + def _get_from_env(self, key: str) -> Optional[str]: + """Get a secret from environment variables.""" + # Try exact key + value = os.environ.get(key) + if value: + return value + + # Try uppercase + value = os.environ.get(key.upper()) + if value: + return value + + return None + + def _get_from_docker_secrets(self, key: str) -> Optional[str]: + """Get a secret from Docker secrets mount.""" + secrets_dir = Path("/run/secrets") + + if not secrets_dir.exists(): + return None + + # Try exact filename + secret_file = secrets_dir / key + if secret_file.exists(): + return secret_file.read_text().strip() + + # Try lowercase + secret_file = secrets_dir / key.lower() + if secret_file.exists(): + return secret_file.read_text().strip() + + return None + + def _check_cache(self, key: str) -> Optional[str]: + """Check if a secret is cached and not expired.""" + if key in self._cache: + value, expiry = self._cache[key] + if time.time() < expiry: + return value + else: + del self._cache[key] + return None + + def _cache_secret(self, key: str, value: str): + """Cache a secret with TTL.""" + if self.vault_config: + ttl = self.vault_config.cache_ttl_seconds + else: + ttl = 300 + self._cache[key] = (value, time.time() + ttl) + + def get( + self, + key: str, + default: Optional[str] = None, + required: bool = False, + cache: bool = True + ) -> Optional[str]: + """ + Get a secret from the best available source. + + Args: + key: The secret key to look up + default: Default value if not found + required: If True, raise SecretNotFoundError when not found + cache: Whether to cache the result + + Returns: + The secret value or default + + Raises: + SecretNotFoundError: If required and not found + """ + # Check cache first + if cache: + cached = self._check_cache(key) + if cached is not None: + return cached + + # Try Vault (production) + value = self._get_from_vault(key) + + # Fall back to environment variables + if value is None: + value = self._get_from_env(key) + + # Fall back to Docker secrets + if value is None: + value = self._get_from_docker_secrets(key) + + # Use default or raise error + if value is None: + if required: + raise SecretNotFoundError( + f"Secret '{key}' not found in Vault, environment, or Docker secrets" + ) + value = default + + # Cache the result + if value is not None and cache: + self._cache_secret(key, value) + + return value + + def get_all(self, prefix: str = "") -> Dict[str, str]: + """ + Get all secrets with a given prefix. + + Args: + prefix: Filter secrets by prefix + + Returns: + Dictionary of secret key -> value + """ + secrets = {} + + # Get from Vault if available + if self._vault_available and self._vault_client: + try: + config = self.vault_config + path = f"{config.secrets_path}/{prefix}" if prefix else config.secrets_path + + response = self._vault_client.secrets.kv.v2.list_secrets( + path=path, + mount_point=config.secrets_mount, + ) + + if response and "data" in response and "keys" in response["data"]: + for key in response["data"]["keys"]: + if not key.endswith("/"): # Skip directories + full_key = f"{prefix}/{key}" if prefix else key + value = self.get(full_key, cache=False) + if value: + secrets[full_key] = value + + except Exception as e: + logger.debug(f"Vault list failed: {e}") + + # Also get from environment (for development) + for key, value in os.environ.items(): + if prefix: + if key.lower().startswith(prefix.lower()): + secrets[key] = value + else: + # Only add secrets that look like secrets + if any(s in key.lower() for s in ["key", "secret", "password", "token"]): + secrets[key] = value + + return secrets + + def is_vault_available(self) -> bool: + """Check if Vault is available and authenticated.""" + return self._vault_available + + def clear_cache(self): + """Clear the secrets cache.""" + self._cache.clear() + + +# ============================================= +# Global Instance & Helper Functions +# ============================================= + +_secrets_manager: Optional[SecretsManager] = None + + +def get_secrets_manager() -> SecretsManager: + """Get or create the global SecretsManager instance.""" + global _secrets_manager + + if _secrets_manager is None: + # Read Vault configuration from environment + vault_url = os.environ.get("VAULT_ADDR", "") + + if vault_url: + config = VaultConfig( + url=vault_url, + auth_method=os.environ.get("VAULT_AUTH_METHOD", "token"), + token=os.environ.get("VAULT_TOKEN"), + role_id=os.environ.get("VAULT_ROLE_ID"), + secret_id=os.environ.get("VAULT_SECRET_ID"), + kubernetes_role=os.environ.get("VAULT_K8S_ROLE"), + secrets_mount=os.environ.get("VAULT_SECRETS_MOUNT", "secret"), + secrets_path=os.environ.get("VAULT_SECRETS_PATH", "breakpilot"), + verify_ssl=os.environ.get("VAULT_SKIP_VERIFY", "false").lower() != "true", + namespace=os.environ.get("VAULT_NAMESPACE"), + ) + else: + config = None + + environment = os.environ.get("ENVIRONMENT", "development") + _secrets_manager = SecretsManager(vault_config=config, environment=environment) + + return _secrets_manager + + +def get_secret( + key: str, + default: Optional[str] = None, + required: bool = False +) -> Optional[str]: + """ + Convenience function to get a secret. + + Usage: + api_key = get_secret("ANTHROPIC_API_KEY") + db_url = get_secret("DATABASE_URL", required=True) + """ + manager = get_secrets_manager() + return manager.get(key, default=default, required=required) + + +# ============================================= +# Secret Key Mappings +# ============================================= + +# Mapping of standard secret names to their paths in Vault +SECRET_MAPPINGS = { + # API Keys + "ANTHROPIC_API_KEY": "api_keys/anthropic", + "VAST_API_KEY": "api_keys/vast", + "TAVILY_API_KEY": "api_keys/tavily", + "STRIPE_SECRET_KEY": "api_keys/stripe", + "STRIPE_WEBHOOK_SECRET": "api_keys/stripe_webhook", + + # Database + "DATABASE_URL": "database/url", + "POSTGRES_PASSWORD": "database/postgres_password", + + # JWT + "JWT_SECRET": "auth/jwt_secret", + "JWT_REFRESH_SECRET": "auth/jwt_refresh_secret", + + # Keycloak + "KEYCLOAK_CLIENT_SECRET": "auth/keycloak_client_secret", + + # Matrix + "MATRIX_ACCESS_TOKEN": "communication/matrix_token", + "SYNAPSE_DB_PASSWORD": "communication/synapse_db_password", + + # Jitsi + "JITSI_APP_SECRET": "communication/jitsi_secret", + "JITSI_JICOFO_AUTH_PASSWORD": "communication/jitsi_jicofo", + "JITSI_JVB_AUTH_PASSWORD": "communication/jitsi_jvb", + + # MinIO + "MINIO_SECRET_KEY": "storage/minio_secret", +} + + +def get_mapped_secret(key: str, default: Optional[str] = None) -> Optional[str]: + """ + Get a secret using the standard name mapping. + + This maps environment variable names to Vault paths for consistency. + """ + vault_path = SECRET_MAPPINGS.get(key, key) + return get_secret(vault_path, default=default) diff --git a/backend/security_api.py b/backend/security_api.py new file mode 100644 index 0000000..f86169a --- /dev/null +++ b/backend/security_api.py @@ -0,0 +1,995 @@ +""" +BreakPilot Security API + +Endpunkte fuer das Security Dashboard: +- Tool-Status abfragen +- Scan-Ergebnisse abrufen +- Scans ausloesen +- SBOM-Daten abrufen +- Scan-Historie anzeigen + +Features: +- Liest Security-Reports aus dem security-reports/ Verzeichnis +- Fuehrt Security-Scans via subprocess aus +- Parst Gitleaks, Semgrep, Trivy, Grype JSON-Reports +- Generiert SBOM mit Syft +""" + +import os +import json +import subprocess +import asyncio +from datetime import datetime +from pathlib import Path +from typing import List, Dict, Any, Optional +from fastapi import APIRouter, HTTPException, BackgroundTasks +from pydantic import BaseModel + +router = APIRouter(prefix="/v1/security", tags=["Security"]) + +# Pfade - innerhalb des Backend-Verzeichnisses +# In Docker: /app/security-reports, /app/scripts +# Lokal: backend/security-reports, backend/scripts +BACKEND_DIR = Path(__file__).parent +REPORTS_DIR = BACKEND_DIR / "security-reports" +SCRIPTS_DIR = BACKEND_DIR / "scripts" + +# Sicherstellen, dass das Reports-Verzeichnis existiert +try: + REPORTS_DIR.mkdir(exist_ok=True) +except PermissionError: + # Falls keine Schreibrechte, verwende tmp-Verzeichnis + REPORTS_DIR = Path("/tmp/security-reports") + REPORTS_DIR.mkdir(exist_ok=True) + + +# =========================== +# Pydantic Models +# =========================== + +class ToolStatus(BaseModel): + name: str + installed: bool + version: Optional[str] = None + last_run: Optional[str] = None + last_findings: int = 0 + + +class Finding(BaseModel): + id: str + tool: str + severity: str + title: str + message: Optional[str] = None + file: Optional[str] = None + line: Optional[int] = None + found_at: str + + +class SeveritySummary(BaseModel): + critical: int = 0 + high: int = 0 + medium: int = 0 + low: int = 0 + info: int = 0 + total: int = 0 + + +class ScanResult(BaseModel): + tool: str + status: str + started_at: str + completed_at: Optional[str] = None + findings_count: int = 0 + report_path: Optional[str] = None + + +class HistoryItem(BaseModel): + timestamp: str + title: str + description: str + status: str # success, warning, error + + +# =========================== +# Utility Functions +# =========================== + +def check_tool_installed(tool_name: str) -> tuple[bool, Optional[str]]: + """Prueft, ob ein Tool installiert ist und gibt die Version zurueck.""" + try: + if tool_name == "gitleaks": + result = subprocess.run(["gitleaks", "version"], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + return True, result.stdout.strip() + elif tool_name == "semgrep": + result = subprocess.run(["semgrep", "--version"], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + return True, result.stdout.strip().split('\n')[0] + elif tool_name == "bandit": + result = subprocess.run(["bandit", "--version"], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + return True, result.stdout.strip() + elif tool_name == "trivy": + result = subprocess.run(["trivy", "version"], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + # Parse "Version: 0.48.x" + for line in result.stdout.split('\n'): + if line.startswith('Version:'): + return True, line.split(':')[1].strip() + return True, result.stdout.strip().split('\n')[0] + elif tool_name == "grype": + result = subprocess.run(["grype", "version"], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + return True, result.stdout.strip().split('\n')[0] + elif tool_name == "syft": + result = subprocess.run(["syft", "version"], capture_output=True, text=True, timeout=5) + if result.returncode == 0: + return True, result.stdout.strip().split('\n')[0] + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + return False, None + + +def get_latest_report(tool_prefix: str) -> Optional[Path]: + """Findet den neuesten Report fuer ein Tool.""" + if not REPORTS_DIR.exists(): + return None + + reports = list(REPORTS_DIR.glob(f"{tool_prefix}*.json")) + if not reports: + return None + + return max(reports, key=lambda p: p.stat().st_mtime) + + +def parse_gitleaks_report(report_path: Path) -> List[Finding]: + """Parst Gitleaks JSON Report.""" + findings = [] + try: + with open(report_path) as f: + data = json.load(f) + if isinstance(data, list): + for item in data: + findings.append(Finding( + id=item.get("Fingerprint", "unknown"), + tool="gitleaks", + severity="HIGH", # Secrets sind immer kritisch + title=item.get("Description", "Secret detected"), + message=f"Rule: {item.get('RuleID', 'unknown')}", + file=item.get("File", ""), + line=item.get("StartLine", 0), + found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat() + )) + except (json.JSONDecodeError, KeyError, FileNotFoundError): + pass + return findings + + +def parse_semgrep_report(report_path: Path) -> List[Finding]: + """Parst Semgrep JSON Report.""" + findings = [] + try: + with open(report_path) as f: + data = json.load(f) + results = data.get("results", []) + for item in results: + severity = item.get("extra", {}).get("severity", "INFO").upper() + findings.append(Finding( + id=item.get("check_id", "unknown"), + tool="semgrep", + severity=severity, + title=item.get("extra", {}).get("message", "Finding"), + message=item.get("check_id", ""), + file=item.get("path", ""), + line=item.get("start", {}).get("line", 0), + found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat() + )) + except (json.JSONDecodeError, KeyError, FileNotFoundError): + pass + return findings + + +def parse_bandit_report(report_path: Path) -> List[Finding]: + """Parst Bandit JSON Report.""" + findings = [] + try: + with open(report_path) as f: + data = json.load(f) + results = data.get("results", []) + for item in results: + severity = item.get("issue_severity", "LOW").upper() + findings.append(Finding( + id=item.get("test_id", "unknown"), + tool="bandit", + severity=severity, + title=item.get("issue_text", "Finding"), + message=f"CWE: {item.get('issue_cwe', {}).get('id', 'N/A')}", + file=item.get("filename", ""), + line=item.get("line_number", 0), + found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat() + )) + except (json.JSONDecodeError, KeyError, FileNotFoundError): + pass + return findings + + +def parse_trivy_report(report_path: Path) -> List[Finding]: + """Parst Trivy JSON Report.""" + findings = [] + try: + with open(report_path) as f: + data = json.load(f) + results = data.get("Results", []) + for result in results: + vulnerabilities = result.get("Vulnerabilities", []) or [] + target = result.get("Target", "") + for vuln in vulnerabilities: + severity = vuln.get("Severity", "UNKNOWN").upper() + findings.append(Finding( + id=vuln.get("VulnerabilityID", "unknown"), + tool="trivy", + severity=severity, + title=vuln.get("Title", vuln.get("VulnerabilityID", "CVE")), + message=f"{vuln.get('PkgName', '')} {vuln.get('InstalledVersion', '')}", + file=target, + line=None, + found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat() + )) + except (json.JSONDecodeError, KeyError, FileNotFoundError): + pass + return findings + + +def parse_grype_report(report_path: Path) -> List[Finding]: + """Parst Grype JSON Report.""" + findings = [] + try: + with open(report_path) as f: + data = json.load(f) + matches = data.get("matches", []) + for match in matches: + vuln = match.get("vulnerability", {}) + artifact = match.get("artifact", {}) + severity = vuln.get("severity", "Unknown").upper() + findings.append(Finding( + id=vuln.get("id", "unknown"), + tool="grype", + severity=severity, + title=vuln.get("description", vuln.get("id", "CVE"))[:100], + message=f"{artifact.get('name', '')} {artifact.get('version', '')}", + file=artifact.get("locations", [{}])[0].get("path", "") if artifact.get("locations") else "", + line=None, + found_at=datetime.fromtimestamp(report_path.stat().st_mtime).isoformat() + )) + except (json.JSONDecodeError, KeyError, FileNotFoundError): + pass + return findings + + +def get_all_findings() -> List[Finding]: + """Sammelt alle Findings aus allen Reports.""" + findings = [] + + # Gitleaks + gitleaks_report = get_latest_report("gitleaks") + if gitleaks_report: + findings.extend(parse_gitleaks_report(gitleaks_report)) + + # Semgrep + semgrep_report = get_latest_report("semgrep") + if semgrep_report: + findings.extend(parse_semgrep_report(semgrep_report)) + + # Bandit + bandit_report = get_latest_report("bandit") + if bandit_report: + findings.extend(parse_bandit_report(bandit_report)) + + # Trivy (filesystem) + trivy_fs_report = get_latest_report("trivy-fs") + if trivy_fs_report: + findings.extend(parse_trivy_report(trivy_fs_report)) + + # Grype + grype_report = get_latest_report("grype") + if grype_report: + findings.extend(parse_grype_report(grype_report)) + + return findings + + +def calculate_summary(findings: List[Finding]) -> SeveritySummary: + """Berechnet die Severity-Zusammenfassung.""" + summary = SeveritySummary() + for finding in findings: + severity = finding.severity.upper() + if severity == "CRITICAL": + summary.critical += 1 + elif severity == "HIGH": + summary.high += 1 + elif severity == "MEDIUM": + summary.medium += 1 + elif severity == "LOW": + summary.low += 1 + else: + summary.info += 1 + summary.total = len(findings) + return summary + + +# =========================== +# API Endpoints +# =========================== + +@router.get("/tools", response_model=List[ToolStatus]) +async def get_tool_status(): + """Gibt den Status aller DevSecOps-Tools zurueck.""" + tools = [] + + tool_names = ["gitleaks", "semgrep", "bandit", "trivy", "grype", "syft"] + + for tool_name in tool_names: + installed, version = check_tool_installed(tool_name) + + # Letzten Report finden + last_run = None + last_findings = 0 + report = get_latest_report(tool_name) + if report: + last_run = datetime.fromtimestamp(report.stat().st_mtime).strftime("%d.%m.%Y %H:%M") + + tools.append(ToolStatus( + name=tool_name.capitalize(), + installed=installed, + version=version, + last_run=last_run, + last_findings=last_findings + )) + + return tools + + +@router.get("/findings", response_model=List[Finding]) +async def get_findings( + tool: Optional[str] = None, + severity: Optional[str] = None, + limit: int = 100 +): + """Gibt alle Security-Findings zurueck.""" + findings = get_all_findings() + + # Fallback zu Mock-Daten wenn keine echten vorhanden + if not findings: + findings = get_mock_findings() + + # Filter by tool + if tool: + findings = [f for f in findings if f.tool.lower() == tool.lower()] + + # Filter by severity + if severity: + findings = [f for f in findings if f.severity.upper() == severity.upper()] + + # Sort by severity (critical first) + severity_order = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "INFO": 4, "UNKNOWN": 5} + findings.sort(key=lambda f: severity_order.get(f.severity.upper(), 5)) + + return findings[:limit] + + +@router.get("/summary", response_model=SeveritySummary) +async def get_summary(): + """Gibt eine Zusammenfassung der Findings nach Severity zurueck.""" + findings = get_all_findings() + # Fallback zu Mock-Daten wenn keine echten vorhanden + if not findings: + findings = get_mock_findings() + return calculate_summary(findings) + + +@router.get("/sbom") +async def get_sbom(): + """Gibt das aktuelle SBOM zurueck.""" + sbom_report = get_latest_report("sbom") + if not sbom_report: + # Versuche CycloneDX Format + sbom_report = get_latest_report("sbom-") + + if not sbom_report or not sbom_report.exists(): + # Fallback zu Mock-Daten + return get_mock_sbom_data() + + try: + with open(sbom_report) as f: + data = json.load(f) + return data + except (json.JSONDecodeError, FileNotFoundError): + # Fallback zu Mock-Daten + return get_mock_sbom_data() + + +@router.get("/history", response_model=List[HistoryItem]) +async def get_history(limit: int = 20): + """Gibt die Scan-Historie zurueck.""" + history = [] + + if REPORTS_DIR.exists(): + # Alle JSON-Reports sammeln + reports = list(REPORTS_DIR.glob("*.json")) + reports.sort(key=lambda p: p.stat().st_mtime, reverse=True) + + for report in reports[:limit]: + tool_name = report.stem.split("-")[0] + timestamp = datetime.fromtimestamp(report.stat().st_mtime).isoformat() + + # Status basierend auf Findings bestimmen + status = "success" + findings_count = 0 + try: + with open(report) as f: + data = json.load(f) + if isinstance(data, list): + findings_count = len(data) + elif isinstance(data, dict): + findings_count = len(data.get("results", [])) or len(data.get("matches", [])) or len(data.get("Results", [])) + + if findings_count > 0: + status = "warning" + except: + pass + + history.append(HistoryItem( + timestamp=timestamp, + title=f"{tool_name.capitalize()} Scan", + description=f"{findings_count} Findings" if findings_count > 0 else "Keine Findings", + status=status + )) + + # Fallback zu Mock-Daten wenn keine echten vorhanden + if not history: + history = get_mock_history() + + # Apply limit to final result (including mock data) + return history[:limit] + + +@router.get("/reports/{tool}") +async def get_tool_report(tool: str): + """Gibt den vollstaendigen Report eines Tools zurueck.""" + report = get_latest_report(tool.lower()) + if not report or not report.exists(): + raise HTTPException(status_code=404, detail=f"Kein Report fuer {tool} gefunden") + + try: + with open(report) as f: + return json.load(f) + except (json.JSONDecodeError, FileNotFoundError) as e: + raise HTTPException(status_code=500, detail=f"Fehler beim Lesen des Reports: {str(e)}") + + +@router.post("/scan/{scan_type}") +async def run_scan(scan_type: str, background_tasks: BackgroundTasks): + """ + Startet einen Security-Scan. + + scan_type kann sein: + - secrets (Gitleaks) + - sast (Semgrep, Bandit) + - deps (Trivy, Grype) + - containers (Trivy image) + - sbom (Syft) + - all (Alle Scans) + """ + valid_types = ["secrets", "sast", "deps", "containers", "sbom", "all"] + if scan_type not in valid_types: + raise HTTPException( + status_code=400, + detail=f"Ungueltiger Scan-Typ. Erlaubt: {', '.join(valid_types)}" + ) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + async def run_scan_async(scan_type: str): + """Fuehrt den Scan asynchron aus.""" + try: + if scan_type == "secrets" or scan_type == "all": + # Gitleaks + installed, _ = check_tool_installed("gitleaks") + if installed: + subprocess.run( + ["gitleaks", "detect", "--source", str(PROJECT_ROOT), + "--config", str(PROJECT_ROOT / ".gitleaks.toml"), + "--report-path", str(REPORTS_DIR / f"gitleaks-{timestamp}.json"), + "--report-format", "json"], + capture_output=True, + timeout=300 + ) + + if scan_type == "sast" or scan_type == "all": + # Semgrep + installed, _ = check_tool_installed("semgrep") + if installed: + subprocess.run( + ["semgrep", "scan", "--config", "auto", + "--config", str(PROJECT_ROOT / ".semgrep.yml"), + "--json", "--output", str(REPORTS_DIR / f"semgrep-{timestamp}.json")], + capture_output=True, + timeout=600, + cwd=str(PROJECT_ROOT) + ) + + # Bandit + installed, _ = check_tool_installed("bandit") + if installed: + subprocess.run( + ["bandit", "-r", str(PROJECT_ROOT / "backend"), "-ll", + "-x", str(PROJECT_ROOT / "backend" / "tests"), + "-f", "json", "-o", str(REPORTS_DIR / f"bandit-{timestamp}.json")], + capture_output=True, + timeout=300 + ) + + if scan_type == "deps" or scan_type == "all": + # Trivy filesystem scan + installed, _ = check_tool_installed("trivy") + if installed: + subprocess.run( + ["trivy", "fs", str(PROJECT_ROOT), + "--config", str(PROJECT_ROOT / ".trivy.yaml"), + "--format", "json", + "--output", str(REPORTS_DIR / f"trivy-fs-{timestamp}.json")], + capture_output=True, + timeout=600 + ) + + # Grype + installed, _ = check_tool_installed("grype") + if installed: + result = subprocess.run( + ["grype", f"dir:{PROJECT_ROOT}", "-o", "json"], + capture_output=True, + text=True, + timeout=600 + ) + if result.stdout: + with open(REPORTS_DIR / f"grype-{timestamp}.json", "w") as f: + f.write(result.stdout) + + if scan_type == "sbom" or scan_type == "all": + # Syft SBOM generation + installed, _ = check_tool_installed("syft") + if installed: + subprocess.run( + ["syft", f"dir:{PROJECT_ROOT}", + "-o", f"cyclonedx-json={REPORTS_DIR / f'sbom-{timestamp}.json'}"], + capture_output=True, + timeout=300 + ) + + if scan_type == "containers" or scan_type == "all": + # Trivy image scan + installed, _ = check_tool_installed("trivy") + if installed: + images = ["breakpilot-pwa-backend", "breakpilot-pwa-consent-service"] + for image in images: + subprocess.run( + ["trivy", "image", image, + "--format", "json", + "--output", str(REPORTS_DIR / f"trivy-image-{image}-{timestamp}.json")], + capture_output=True, + timeout=600 + ) + + except subprocess.TimeoutExpired: + pass + except Exception as e: + print(f"Scan error: {e}") + + # Scan im Hintergrund ausfuehren + background_tasks.add_task(run_scan_async, scan_type) + + return { + "status": "started", + "scan_type": scan_type, + "timestamp": timestamp, + "message": f"Scan '{scan_type}' wurde gestartet" + } + + +@router.get("/health") +async def health_check(): + """Health-Check fuer die Security API.""" + tools_installed = 0 + for tool in ["gitleaks", "semgrep", "bandit", "trivy", "grype", "syft"]: + installed, _ = check_tool_installed(tool) + if installed: + tools_installed += 1 + + return { + "status": "healthy", + "tools_installed": tools_installed, + "tools_total": 6, + "reports_dir": str(REPORTS_DIR), + "reports_exist": REPORTS_DIR.exists() + } + + +# =========================== +# Mock Data for Demo/Development +# =========================== + +def get_mock_sbom_data() -> Dict[str, Any]: + """Generiert realistische Mock-SBOM-Daten basierend auf requirements.txt.""" + return { + "bomFormat": "CycloneDX", + "specVersion": "1.4", + "version": 1, + "metadata": { + "timestamp": datetime.now().isoformat(), + "tools": [{"vendor": "BreakPilot", "name": "DevSecOps", "version": "1.0.0"}], + "component": { + "type": "application", + "name": "breakpilot-pwa", + "version": "2.0.0" + } + }, + "components": [ + {"type": "library", "name": "fastapi", "version": "0.109.0", "purl": "pkg:pypi/fastapi@0.109.0", "licenses": [{"license": {"id": "MIT"}}]}, + {"type": "library", "name": "uvicorn", "version": "0.27.0", "purl": "pkg:pypi/uvicorn@0.27.0", "licenses": [{"license": {"id": "BSD-3-Clause"}}]}, + {"type": "library", "name": "pydantic", "version": "2.5.3", "purl": "pkg:pypi/pydantic@2.5.3", "licenses": [{"license": {"id": "MIT"}}]}, + {"type": "library", "name": "httpx", "version": "0.26.0", "purl": "pkg:pypi/httpx@0.26.0", "licenses": [{"license": {"id": "BSD-3-Clause"}}]}, + {"type": "library", "name": "python-jose", "version": "3.3.0", "purl": "pkg:pypi/python-jose@3.3.0", "licenses": [{"license": {"id": "MIT"}}]}, + {"type": "library", "name": "passlib", "version": "1.7.4", "purl": "pkg:pypi/passlib@1.7.4", "licenses": [{"license": {"id": "BSD-3-Clause"}}]}, + {"type": "library", "name": "bcrypt", "version": "4.1.2", "purl": "pkg:pypi/bcrypt@4.1.2", "licenses": [{"license": {"id": "Apache-2.0"}}]}, + {"type": "library", "name": "psycopg2-binary", "version": "2.9.9", "purl": "pkg:pypi/psycopg2-binary@2.9.9", "licenses": [{"license": {"id": "LGPL-3.0"}}]}, + {"type": "library", "name": "sqlalchemy", "version": "2.0.25", "purl": "pkg:pypi/sqlalchemy@2.0.25", "licenses": [{"license": {"id": "MIT"}}]}, + {"type": "library", "name": "alembic", "version": "1.13.1", "purl": "pkg:pypi/alembic@1.13.1", "licenses": [{"license": {"id": "MIT"}}]}, + {"type": "library", "name": "weasyprint", "version": "60.2", "purl": "pkg:pypi/weasyprint@60.2", "licenses": [{"license": {"id": "BSD-3-Clause"}}]}, + {"type": "library", "name": "jinja2", "version": "3.1.3", "purl": "pkg:pypi/jinja2@3.1.3", "licenses": [{"license": {"id": "BSD-3-Clause"}}]}, + {"type": "library", "name": "python-multipart", "version": "0.0.6", "purl": "pkg:pypi/python-multipart@0.0.6", "licenses": [{"license": {"id": "Apache-2.0"}}]}, + {"type": "library", "name": "aiofiles", "version": "23.2.1", "purl": "pkg:pypi/aiofiles@23.2.1", "licenses": [{"license": {"id": "Apache-2.0"}}]}, + {"type": "library", "name": "pytest", "version": "7.4.4", "purl": "pkg:pypi/pytest@7.4.4", "licenses": [{"license": {"id": "MIT"}}]}, + {"type": "library", "name": "pytest-asyncio", "version": "0.23.3", "purl": "pkg:pypi/pytest-asyncio@0.23.3", "licenses": [{"license": {"id": "Apache-2.0"}}]}, + {"type": "library", "name": "anthropic", "version": "0.18.1", "purl": "pkg:pypi/anthropic@0.18.1", "licenses": [{"license": {"id": "MIT"}}]}, + {"type": "library", "name": "openai", "version": "1.12.0", "purl": "pkg:pypi/openai@1.12.0", "licenses": [{"license": {"id": "MIT"}}]}, + {"type": "library", "name": "langchain", "version": "0.1.6", "purl": "pkg:pypi/langchain@0.1.6", "licenses": [{"license": {"id": "MIT"}}]}, + {"type": "library", "name": "chromadb", "version": "0.4.22", "purl": "pkg:pypi/chromadb@0.4.22", "licenses": [{"license": {"id": "Apache-2.0"}}]}, + ] + } + + +def get_mock_findings() -> List[Finding]: + """Generiert Mock-Findings fuer Demo wenn keine echten Scan-Ergebnisse vorhanden.""" + # Alle kritischen Findings wurden behoben: + # - idna >= 3.7 gepinnt (CVE-2024-3651) + # - cryptography >= 42.0.0 gepinnt (GHSA-h4gh-qq45-vh27) + # - jinja2 3.1.6 installiert (CVE-2024-34064) + # - .env.example Placeholders verbessert + # - Keine shell=True Verwendung im Code + return [ + Finding( + id="info-scan-complete", + tool="system", + severity="INFO", + title="Letzte Sicherheitspruefung erfolgreich", + message="Keine kritischen Schwachstellen gefunden. Naechster Scan: taeglich 03:00 Uhr.", + file="", + line=None, + found_at=datetime.now().isoformat() + ), + ] + + +def get_mock_history() -> List[HistoryItem]: + """Generiert Mock-Scan-Historie.""" + base_time = datetime.now() + return [ + HistoryItem( + timestamp=(base_time).isoformat(), + title="Full Security Scan", + description="7 Findings (1 High, 3 Medium, 3 Low)", + status="warning" + ), + HistoryItem( + timestamp=(base_time.replace(hour=base_time.hour-2)).isoformat(), + title="SBOM Generation", + description="20 Components analysiert", + status="success" + ), + HistoryItem( + timestamp=(base_time.replace(hour=base_time.hour-4)).isoformat(), + title="Container Scan", + description="Keine kritischen CVEs", + status="success" + ), + HistoryItem( + timestamp=(base_time.replace(day=base_time.day-1)).isoformat(), + title="Secrets Scan", + description="1 Finding (API Key in .env.example)", + status="warning" + ), + HistoryItem( + timestamp=(base_time.replace(day=base_time.day-1, hour=10)).isoformat(), + title="SAST Scan", + description="3 Findings (Bandit, Semgrep)", + status="warning" + ), + HistoryItem( + timestamp=(base_time.replace(day=base_time.day-2)).isoformat(), + title="Dependency Scan", + description="3 vulnerable packages", + status="warning" + ), + ] + + +# =========================== +# Demo-Mode Endpoints (with Mock Data) +# =========================== + +@router.get("/demo/sbom") +async def get_demo_sbom(): + """Gibt Demo-SBOM-Daten zurueck wenn keine echten verfuegbar.""" + # Erst echte Daten versuchen + sbom_report = get_latest_report("sbom") + if sbom_report and sbom_report.exists(): + try: + with open(sbom_report) as f: + return json.load(f) + except: + pass + # Fallback zu Mock-Daten + return get_mock_sbom_data() + + +@router.get("/demo/findings") +async def get_demo_findings(): + """Gibt Demo-Findings zurueck wenn keine echten verfuegbar.""" + # Erst echte Daten versuchen + real_findings = get_all_findings() + if real_findings: + return real_findings + # Fallback zu Mock-Daten + return get_mock_findings() + + +@router.get("/demo/summary") +async def get_demo_summary(): + """Gibt Demo-Summary zurueck.""" + real_findings = get_all_findings() + if real_findings: + return calculate_summary(real_findings) + # Mock summary + mock_findings = get_mock_findings() + return calculate_summary(mock_findings) + + +@router.get("/demo/history") +async def get_demo_history(): + """Gibt Demo-Historie zurueck wenn keine echten verfuegbar.""" + real_history = await get_history() + if real_history: + return real_history + return get_mock_history() + + +# =========================== +# Monitoring Endpoints +# =========================== + +class LogEntry(BaseModel): + timestamp: str + level: str + service: str + message: str + + +class MetricValue(BaseModel): + name: str + value: float + unit: str + trend: Optional[str] = None # up, down, stable + + +class ContainerStatus(BaseModel): + name: str + status: str + health: str + cpu_percent: float + memory_mb: float + uptime: str + + +class ServiceStatus(BaseModel): + name: str + url: str + status: str + response_time_ms: int + last_check: str + + +@router.get("/monitoring/logs", response_model=List[LogEntry]) +async def get_logs(service: Optional[str] = None, level: Optional[str] = None, limit: int = 50): + """Gibt Log-Eintraege zurueck (Demo-Daten).""" + import random + from datetime import timedelta + + services = ["backend", "consent-service", "postgres", "mailpit"] + levels = ["INFO", "INFO", "INFO", "WARNING", "ERROR", "DEBUG"] + messages = { + "backend": [ + "Request completed: GET /api/consent/health 200", + "Request completed: POST /api/auth/login 200", + "Database connection established", + "JWT token validated successfully", + "Starting background task: email_notification", + "Cache miss for key: user_session_abc123", + "Request completed: GET /api/v1/security/demo/sbom 200", + ], + "consent-service": [ + "Health check passed", + "Document version created: v1.2.0", + "Consent recorded for user: user-12345", + "GDPR export job started", + "Database query executed in 12ms", + ], + "postgres": [ + "checkpoint starting: time", + "automatic analyze of table completed", + "connection authorized: user=breakpilot", + "statement: SELECT * FROM documents WHERE...", + ], + "mailpit": [ + "SMTP connection from 172.18.0.3", + "Email received: Consent Confirmation", + "Message stored: id=msg-001", + ], + } + + logs = [] + base_time = datetime.now() + + for i in range(limit): + svc = random.choice(services) if not service else service + lvl = random.choice(levels) if not level else level + msg_list = messages.get(svc, messages["backend"]) + msg = random.choice(msg_list) + + # Add some variety to error messages + if lvl == "ERROR": + msg = random.choice([ + "Connection timeout after 30s", + "Failed to parse JSON response", + "Database query failed: connection reset", + "Rate limit exceeded for IP 192.168.1.1", + ]) + elif lvl == "WARNING": + msg = random.choice([ + "Slow query detected: 523ms", + "Memory usage above 80%", + "Retry attempt 2/3 for external API", + "Deprecated API endpoint called", + ]) + + logs.append(LogEntry( + timestamp=(base_time - timedelta(seconds=i*random.randint(1, 30))).isoformat(), + level=lvl, + service=svc, + message=msg + )) + + # Filter + if service: + logs = [l for l in logs if l.service == service] + if level: + logs = [l for l in logs if l.level.upper() == level.upper()] + + return logs[:limit] + + +@router.get("/monitoring/metrics", response_model=List[MetricValue]) +async def get_metrics(): + """Gibt System-Metriken zurueck (Demo-Daten).""" + import random + + return [ + MetricValue(name="CPU Usage", value=round(random.uniform(15, 45), 1), unit="%", trend="stable"), + MetricValue(name="Memory Usage", value=round(random.uniform(40, 65), 1), unit="%", trend="up"), + MetricValue(name="Disk Usage", value=round(random.uniform(25, 40), 1), unit="%", trend="stable"), + MetricValue(name="Network In", value=round(random.uniform(1.2, 5.8), 2), unit="MB/s", trend="up"), + MetricValue(name="Network Out", value=round(random.uniform(0.5, 2.1), 2), unit="MB/s", trend="stable"), + MetricValue(name="Active Connections", value=random.randint(12, 48), unit="", trend="up"), + MetricValue(name="Requests/min", value=random.randint(120, 350), unit="req/min", trend="up"), + MetricValue(name="Avg Response Time", value=round(random.uniform(45, 120), 0), unit="ms", trend="down"), + MetricValue(name="Error Rate", value=round(random.uniform(0.1, 0.8), 2), unit="%", trend="stable"), + MetricValue(name="Cache Hit Rate", value=round(random.uniform(85, 98), 1), unit="%", trend="up"), + ] + + +@router.get("/monitoring/containers", response_model=List[ContainerStatus]) +async def get_container_status(): + """Gibt Container-Status zurueck (versucht Docker, sonst Demo-Daten).""" + import random + + # Versuche echte Docker-Daten + try: + result = subprocess.run( + ["docker", "ps", "--format", "{{.Names}}\t{{.Status}}\t{{.State}}"], + capture_output=True, + text=True, + timeout=5 + ) + if result.returncode == 0 and result.stdout.strip(): + containers = [] + for line in result.stdout.strip().split('\n'): + parts = line.split('\t') + if len(parts) >= 3: + name, status, state = parts[0], parts[1], parts[2] + # Parse uptime from status like "Up 2 hours" + uptime = status if "Up" in status else "N/A" + + containers.append(ContainerStatus( + name=name, + status=state, + health="healthy" if state == "running" else "unhealthy", + cpu_percent=round(random.uniform(0.5, 15), 1), + memory_mb=round(random.uniform(50, 500), 0), + uptime=uptime + )) + if containers: + return containers + except: + pass + + # Fallback: Demo-Daten + return [ + ContainerStatus(name="breakpilot-pwa-backend", status="running", health="healthy", + cpu_percent=round(random.uniform(2, 12), 1), memory_mb=round(random.uniform(180, 280), 0), uptime="Up 4 hours"), + ContainerStatus(name="breakpilot-pwa-consent-service", status="running", health="healthy", + cpu_percent=round(random.uniform(1, 8), 1), memory_mb=round(random.uniform(80, 150), 0), uptime="Up 4 hours"), + ContainerStatus(name="breakpilot-pwa-postgres", status="running", health="healthy", + cpu_percent=round(random.uniform(0.5, 5), 1), memory_mb=round(random.uniform(120, 200), 0), uptime="Up 4 hours"), + ContainerStatus(name="breakpilot-pwa-mailpit", status="running", health="healthy", + cpu_percent=round(random.uniform(0.1, 2), 1), memory_mb=round(random.uniform(30, 60), 0), uptime="Up 4 hours"), + ] + + +@router.get("/monitoring/services", response_model=List[ServiceStatus]) +async def get_service_status(): + """Prueft den Status aller Services (Health-Checks).""" + import random + + services_to_check = [ + ("Backend API", "http://localhost:8000/api/consent/health"), + ("Consent Service", "http://consent-service:8081/health"), + ("School Service", "http://school-service:8084/health"), + ("Klausur Service", "http://klausur-service:8086/health"), + ] + + results = [] + for name, url in services_to_check: + status = "healthy" + response_time = random.randint(15, 150) + + # Versuche echten Health-Check fuer Backend + if "localhost:8000" in url: + try: + import httpx + async with httpx.AsyncClient() as client: + start = datetime.now() + response = await client.get(url, timeout=5) + response_time = int((datetime.now() - start).total_seconds() * 1000) + status = "healthy" if response.status_code == 200 else "unhealthy" + except: + status = "healthy" # Assume healthy if we're running + + results.append(ServiceStatus( + name=name, + url=url, + status=status, + response_time_ms=response_time, + last_check=datetime.now().isoformat() + )) + + return results diff --git a/backend/security_test_api.py b/backend/security_test_api.py new file mode 100644 index 0000000..6809e3e --- /dev/null +++ b/backend/security_test_api.py @@ -0,0 +1,659 @@ +""" +Security Test API + +Provides endpoints for the interactive Security Test Wizard. +Allows testing of DevSecOps security scanning with educational feedback. +""" + +from __future__ import annotations + +import asyncio +import time +import os +from datetime import datetime +from typing import List, Optional + +import httpx +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + + +# ============================================== +# Data Models +# ============================================== + + +class TestResult(BaseModel): + """Result of a single test.""" + + name: str + description: str + expected: str + actual: str + status: str + duration_ms: float = 0.0 + error_message: Optional[str] = None + + +class ArchitectureContext(BaseModel): + """Architecture context for a test category.""" + + layer: str + services: List[str] + dependencies: List[str] + data_flow: List[str] + + +class TestCategoryResult(BaseModel): + """Result of a test category.""" + + category: str + display_name: str + description: str + why_important: str + architecture_context: Optional[ArchitectureContext] = None + tests: List[TestResult] + passed: int + failed: int + total: int + duration_ms: float + + +class FullTestResults(BaseModel): + """Full test results for all categories.""" + + timestamp: str + categories: List[TestCategoryResult] + total_passed: int + total_failed: int + total_tests: int + duration_ms: float + + +# ============================================== +# Educational Content +# ============================================== + +EDUCATION_CONTENT = { + "sast": { + "display_name": "SAST - Static Analysis", + "description": "Statische Code-Analyse mit Semgrep", + "why_important": """ +SAST (Static Application Security Testing) analysiert den Quellcode +OHNE ihn auszufuehren. Es findet Schwachstellen fruehzeitig. + +Semgrep findet: +- SQL Injection Patterns +- XSS Vulnerabilities +- Hardcoded Credentials +- Insecure Crypto Usage +- Path Traversal Risks + +Vorteile: +- Schnell (Minuten, nicht Stunden) +- Findet Fehler VOR dem Deployment +- Integriert in CI/CD Pipeline +- Keine laufende Anwendung noetig +""", + "architecture": ArchitectureContext( + layer="service", + services=["backend"], + dependencies=["semgrep", "Git Repository"], + data_flow=["Source Code", "Semgrep Scanner", "Findings", "Dashboard"], + ), + }, + "sca": { + "display_name": "SCA - Dependency Scanning", + "description": "Software Composition Analysis mit Trivy/Grype", + "why_important": """ +SCA (Software Composition Analysis) prueft Abhaengigkeiten +auf bekannte Schwachstellen (CVEs). + +Gescannt werden: +- Python packages (requirements.txt) +- Node modules (package.json) +- Go modules (go.mod) +- Container Images + +Bekannte Angriffe durch Abhaengigkeiten: +- Log4Shell (CVE-2021-44228) - Log4j +- NPM Supply Chain Attacks +- PyPI Typosquatting + +Ohne SCA: Unsichtbare Risiken in Third-Party Code +""", + "architecture": ArchitectureContext( + layer="service", + services=["backend"], + dependencies=["trivy", "grype", "CVE Database"], + data_flow=["Dependencies", "Scanner", "CVE Lookup", "Report"], + ), + }, + "secrets": { + "display_name": "Secret Detection", + "description": "Erkennung von Secrets im Code mit Gitleaks", + "why_important": """ +Gitleaks findet versehentlich eingecheckte Secrets: + +Gesucht wird nach: +- API Keys (AWS, GCP, Azure, etc.) +- Database Credentials +- Private SSH Keys +- OAuth Tokens +- JWT Secrets + +Risiken ohne Secret Detection: +- Kompromittierte Cloud-Accounts +- Datenlecks durch DB-Zugriff +- Laterale Bewegung im Netzwerk +- Reputationsschaden + +Git History: Einmal gepusht, schwer zu entfernen! +""", + "architecture": ArchitectureContext( + layer="service", + services=["backend"], + dependencies=["gitleaks", "Git Repository"], + data_flow=["Git History", "Pattern Matching", "Findings"], + ), + }, + "sbom": { + "display_name": "SBOM Generation", + "description": "Software Bill of Materials mit Syft", + "why_important": """ +SBOM (Software Bill of Materials) ist eine vollstaendige +Inventarliste aller Software-Komponenten. + +Warum SBOM wichtig ist: +- US Executive Order 14028 (2021) +- EU Cyber Resilience Act +- Supply Chain Transparency + +Inhalt eines SBOM: +- Alle Abhaengigkeiten mit Versionen +- Lizenzen (GPL, MIT, Apache, etc.) +- Bekannte Vulnerabilities +- Paketherkunft + +Bei Zero-Day: Schnell pruefen wer betroffen ist +""", + "architecture": ArchitectureContext( + layer="service", + services=["backend"], + dependencies=["syft", "cyclonedx"], + data_flow=["Container/Code", "Syft Analysis", "CycloneDX SBOM"], + ), + }, + "api-health": { + "display_name": "Security API Status", + "description": "Verfuegbarkeit der Security Endpunkte", + "why_important": """ +Die Security API steuert alle Sicherheitsscans: + +Endpunkte: +- /api/security/scan - Scan starten +- /api/security/findings - Ergebnisse abrufen +- /api/security/sbom - SBOM generieren +- /api/security/dashboard - Uebersicht + +Verfuegbarkeit kritisch fuer: +- CI/CD Pipeline Integration +- Automatisierte Security Gates +- Compliance Reporting +- Incident Response +""", + "architecture": ArchitectureContext( + layer="api", + services=["backend"], + dependencies=["PostgreSQL", "Scanner Tools"], + data_flow=["API Request", "Scanner Dispatch", "Result Storage"], + ), + }, +} + + +# ============================================== +# Test Runner +# ============================================== + + +class SecurityTestRunner: + """Runs security scanning tests.""" + + def __init__(self): + self.backend_url = os.getenv("BACKEND_URL", "http://localhost:8000") + + async def test_api_health(self) -> TestCategoryResult: + """Test Security API availability.""" + tests: List[TestResult] = [] + start_time = time.time() + + async with httpx.AsyncClient(timeout=10.0) as client: + # Test Security Dashboard + test_start = time.time() + try: + response = await client.get(f"{self.backend_url}/api/security/dashboard") + tests.append( + TestResult( + name="Security Dashboard", + description="GET /api/security/dashboard", + expected="Status 200", + actual=f"Status {response.status_code}", + status="passed" if response.status_code in [200, 401, 403] else "failed", + duration_ms=(time.time() - test_start) * 1000, + ) + ) + except Exception as e: + tests.append( + TestResult( + name="Security Dashboard", + description="GET /api/security/dashboard", + expected="Status 200", + actual="Nicht erreichbar", + status="failed", + duration_ms=(time.time() - test_start) * 1000, + error_message=str(e), + ) + ) + + # Test Findings Endpoint + test_start = time.time() + try: + response = await client.get(f"{self.backend_url}/api/security/findings") + tests.append( + TestResult( + name="Security Findings", + description="GET /api/security/findings", + expected="Findings-Liste", + actual=f"Status {response.status_code}", + status="passed" if response.status_code in [200, 401, 403, 404] else "failed", + duration_ms=(time.time() - test_start) * 1000, + ) + ) + except Exception as e: + tests.append( + TestResult( + name="Security Findings", + description="GET /api/security/findings", + expected="Findings-Liste", + actual="Fehler", + status="failed", + duration_ms=(time.time() - test_start) * 1000, + error_message=str(e), + ) + ) + + passed = sum(1 for t in tests if t.status == "passed") + content = EDUCATION_CONTENT["api-health"] + return TestCategoryResult( + category="api-health", + display_name=content["display_name"], + description=content["description"], + why_important=content["why_important"], + architecture_context=content["architecture"], + tests=tests, + passed=passed, + failed=len(tests) - passed, + total=len(tests), + duration_ms=(time.time() - start_time) * 1000, + ) + + async def test_sast(self) -> TestCategoryResult: + """Test SAST scanning.""" + tests: List[TestResult] = [] + start_time = time.time() + + # Check if Semgrep is available + test_start = time.time() + try: + import subprocess + result = subprocess.run(["which", "semgrep"], capture_output=True, timeout=5) + semgrep_installed = result.returncode == 0 + + tests.append( + TestResult( + name="Semgrep Installation", + description="Prueft ob Semgrep installiert ist", + expected="Semgrep verfuegbar", + actual="Installiert" if semgrep_installed else "Nicht gefunden", + status="passed" if semgrep_installed else "skipped", + duration_ms=(time.time() - test_start) * 1000, + ) + ) + except Exception as e: + tests.append( + TestResult( + name="Semgrep Installation", + description="Prueft ob Semgrep installiert ist", + expected="Semgrep verfuegbar", + actual="Pruefung fehlgeschlagen", + status="skipped", + duration_ms=(time.time() - test_start) * 1000, + error_message=str(e), + ) + ) + + # SAST patterns + sast_patterns = [ + "SQL Injection Detection", + "XSS Prevention", + "Hardcoded Secrets", + "Insecure Crypto", + ] + + for pattern in sast_patterns: + test_start = time.time() + tests.append( + TestResult( + name=f"Pattern: {pattern}", + description=f"Semgrep Rule fuer {pattern}", + expected="Rule konfiguriert", + actual="Verfuegbar", + status="passed", + duration_ms=(time.time() - test_start) * 1000, + ) + ) + + passed = sum(1 for t in tests if t.status == "passed") + content = EDUCATION_CONTENT["sast"] + return TestCategoryResult( + category="sast", + display_name=content["display_name"], + description=content["description"], + why_important=content["why_important"], + architecture_context=content["architecture"], + tests=tests, + passed=passed, + failed=len(tests) - passed, + total=len(tests), + duration_ms=(time.time() - start_time) * 1000, + ) + + async def test_sca(self) -> TestCategoryResult: + """Test SCA scanning.""" + tests: List[TestResult] = [] + start_time = time.time() + + # Check scanners + scanners = [("trivy", "Trivy"), ("grype", "Grype")] + + for cmd, name in scanners: + test_start = time.time() + try: + import subprocess + result = subprocess.run(["which", cmd], capture_output=True, timeout=5) + installed = result.returncode == 0 + + tests.append( + TestResult( + name=f"{name} Installation", + description=f"Prueft ob {name} installiert ist", + expected=f"{name} verfuegbar", + actual="Installiert" if installed else "Nicht gefunden", + status="passed" if installed else "skipped", + duration_ms=(time.time() - test_start) * 1000, + ) + ) + except Exception as e: + tests.append( + TestResult( + name=f"{name} Installation", + description=f"Prueft ob {name} installiert ist", + expected=f"{name} verfuegbar", + actual="Pruefung fehlgeschlagen", + status="skipped", + duration_ms=(time.time() - test_start) * 1000, + error_message=str(e), + ) + ) + + # Check dependency files + dep_files = [ + ("requirements.txt", "Python"), + ("package.json", "Node.js"), + ("go.mod", "Go"), + ] + + for filename, lang in dep_files: + test_start = time.time() + tests.append( + TestResult( + name=f"{lang} Dependencies", + description=f"Abhaengigkeiten in {filename}", + expected="Scanbar", + actual="Konfiguriert", + status="passed", + duration_ms=(time.time() - test_start) * 1000, + ) + ) + + passed = sum(1 for t in tests if t.status == "passed") + content = EDUCATION_CONTENT["sca"] + return TestCategoryResult( + category="sca", + display_name=content["display_name"], + description=content["description"], + why_important=content["why_important"], + architecture_context=content["architecture"], + tests=tests, + passed=passed, + failed=len(tests) - passed, + total=len(tests), + duration_ms=(time.time() - start_time) * 1000, + ) + + async def test_secrets(self) -> TestCategoryResult: + """Test secret detection.""" + tests: List[TestResult] = [] + start_time = time.time() + + # Check Gitleaks + test_start = time.time() + try: + import subprocess + result = subprocess.run(["which", "gitleaks"], capture_output=True, timeout=5) + installed = result.returncode == 0 + + tests.append( + TestResult( + name="Gitleaks Installation", + description="Prueft ob Gitleaks installiert ist", + expected="Gitleaks verfuegbar", + actual="Installiert" if installed else "Nicht gefunden", + status="passed" if installed else "skipped", + duration_ms=(time.time() - test_start) * 1000, + ) + ) + except Exception as e: + tests.append( + TestResult( + name="Gitleaks Installation", + description="Prueft ob Gitleaks installiert ist", + expected="Gitleaks verfuegbar", + actual="Pruefung fehlgeschlagen", + status="skipped", + duration_ms=(time.time() - test_start) * 1000, + error_message=str(e), + ) + ) + + # Secret patterns + secret_patterns = [ + "AWS Credentials", + "Google Cloud Keys", + "Database Passwords", + "JWT Secrets", + "SSH Private Keys", + ] + + for pattern in secret_patterns: + test_start = time.time() + tests.append( + TestResult( + name=f"Erkennung: {pattern}", + description=f"Pattern fuer {pattern}", + expected="Pattern aktiv", + actual="Konfiguriert", + status="passed", + duration_ms=(time.time() - test_start) * 1000, + ) + ) + + passed = sum(1 for t in tests if t.status == "passed") + content = EDUCATION_CONTENT["secrets"] + return TestCategoryResult( + category="secrets", + display_name=content["display_name"], + description=content["description"], + why_important=content["why_important"], + architecture_context=content["architecture"], + tests=tests, + passed=passed, + failed=len(tests) - passed, + total=len(tests), + duration_ms=(time.time() - start_time) * 1000, + ) + + async def test_sbom(self) -> TestCategoryResult: + """Test SBOM generation.""" + tests: List[TestResult] = [] + start_time = time.time() + + # Check Syft + test_start = time.time() + try: + import subprocess + result = subprocess.run(["which", "syft"], capture_output=True, timeout=5) + installed = result.returncode == 0 + + tests.append( + TestResult( + name="Syft Installation", + description="Prueft ob Syft installiert ist", + expected="Syft verfuegbar", + actual="Installiert" if installed else "Nicht gefunden", + status="passed" if installed else "skipped", + duration_ms=(time.time() - test_start) * 1000, + ) + ) + except Exception as e: + tests.append( + TestResult( + name="Syft Installation", + description="Prueft ob Syft installiert ist", + expected="Syft verfuegbar", + actual="Pruefung fehlgeschlagen", + status="skipped", + duration_ms=(time.time() - test_start) * 1000, + error_message=str(e), + ) + ) + + # SBOM formats + sbom_formats = ["CycloneDX", "SPDX"] + for fmt in sbom_formats: + test_start = time.time() + tests.append( + TestResult( + name=f"Format: {fmt}", + description=f"SBOM im {fmt} Format", + expected=f"{fmt} unterstuetzt", + actual="Verfuegbar", + status="passed", + duration_ms=(time.time() - test_start) * 1000, + ) + ) + + passed = sum(1 for t in tests if t.status == "passed") + content = EDUCATION_CONTENT["sbom"] + return TestCategoryResult( + category="sbom", + display_name=content["display_name"], + description=content["description"], + why_important=content["why_important"], + architecture_context=content["architecture"], + tests=tests, + passed=passed, + failed=len(tests) - passed, + total=len(tests), + duration_ms=(time.time() - start_time) * 1000, + ) + + async def run_all(self) -> FullTestResults: + """Run all security tests.""" + start_time = time.time() + + categories = await asyncio.gather( + self.test_api_health(), + self.test_sast(), + self.test_sca(), + self.test_secrets(), + self.test_sbom(), + ) + + total_passed = sum(c.passed for c in categories) + total_failed = sum(c.failed for c in categories) + total_tests = sum(c.total for c in categories) + + return FullTestResults( + timestamp=datetime.now().isoformat(), + categories=list(categories), + total_passed=total_passed, + total_failed=total_failed, + total_tests=total_tests, + duration_ms=(time.time() - start_time) * 1000, + ) + + +# ============================================== +# API Router +# ============================================== + +router = APIRouter(prefix="/api/admin/security-tests", tags=["security-tests"]) + +test_runner = SecurityTestRunner() + + +@router.post("/api-health", response_model=TestCategoryResult) +async def test_api_health(): + return await test_runner.test_api_health() + + +@router.post("/sast", response_model=TestCategoryResult) +async def test_sast(): + return await test_runner.test_sast() + + +@router.post("/sca", response_model=TestCategoryResult) +async def test_sca(): + return await test_runner.test_sca() + + +@router.post("/secrets", response_model=TestCategoryResult) +async def test_secrets(): + return await test_runner.test_secrets() + + +@router.post("/sbom", response_model=TestCategoryResult) +async def test_sbom(): + return await test_runner.test_sbom() + + +@router.post("/run-all", response_model=FullTestResults) +async def run_all_tests(): + return await test_runner.run_all() + + +@router.get("/education/{category}") +async def get_education_content(category: str): + if category not in EDUCATION_CONTENT: + raise HTTPException(status_code=404, detail=f"Category '{category}' not found") + return EDUCATION_CONTENT[category] + + +@router.get("/categories") +async def list_categories(): + return [ + {"id": key, "display_name": value["display_name"], "description": value["description"]} + for key, value in EDUCATION_CONTENT.items() + ] diff --git a/backend/services/__init__.py b/backend/services/__init__.py new file mode 100644 index 0000000..8d932cc --- /dev/null +++ b/backend/services/__init__.py @@ -0,0 +1,22 @@ +# Backend Services Module +# Shared services for PDF generation, file processing, and more + +# PDFService requires WeasyPrint which needs system libraries (libgobject, etc.) +# Make import optional for environments without these dependencies (e.g., CI) +try: + from .pdf_service import PDFService + _pdf_available = True +except (ImportError, OSError) as e: + PDFService = None # type: ignore + _pdf_available = False + +# FileProcessor requires OpenCV which needs libGL.so.1 +# Make import optional for CI environments +try: + from .file_processor import FileProcessor + _file_processor_available = True +except (ImportError, OSError) as e: + FileProcessor = None # type: ignore + _file_processor_available = False + +__all__ = ["PDFService", "FileProcessor"] diff --git a/backend/services/file_processor.py b/backend/services/file_processor.py new file mode 100644 index 0000000..438c220 --- /dev/null +++ b/backend/services/file_processor.py @@ -0,0 +1,563 @@ +""" +File Processor Service - Dokumentenverarbeitung für BreakPilot. + +Shared Service für: +- OCR (Optical Character Recognition) für Handschrift und gedruckten Text +- PDF-Parsing und Textextraktion +- Bildverarbeitung und -optimierung +- DOCX/DOC Textextraktion + +Verwendet: +- PaddleOCR für deutsche Handschrift +- PyMuPDF für PDF-Verarbeitung +- python-docx für DOCX-Dateien +- OpenCV für Bildvorverarbeitung +""" + +import logging +import os +import io +import base64 +from pathlib import Path +from typing import Optional, List, Dict, Any, Tuple, Union +from dataclasses import dataclass +from enum import Enum + +import cv2 +import numpy as np +from PIL import Image + +logger = logging.getLogger(__name__) + + +class FileType(str, Enum): + """Unterstützte Dateitypen.""" + PDF = "pdf" + IMAGE = "image" + DOCX = "docx" + DOC = "doc" + TXT = "txt" + UNKNOWN = "unknown" + + +class ProcessingMode(str, Enum): + """Verarbeitungsmodi.""" + OCR_HANDWRITING = "ocr_handwriting" # Handschrifterkennung + OCR_PRINTED = "ocr_printed" # Gedruckter Text + TEXT_EXTRACT = "text_extract" # Textextraktion (PDF/DOCX) + MIXED = "mixed" # Kombiniert OCR + Textextraktion + + +@dataclass +class ProcessedRegion: + """Ein erkannter Textbereich.""" + text: str + confidence: float + bbox: Tuple[int, int, int, int] # x1, y1, x2, y2 + page: int = 1 + + +@dataclass +class ProcessingResult: + """Ergebnis der Dokumentenverarbeitung.""" + text: str + confidence: float + regions: List[ProcessedRegion] + page_count: int + file_type: FileType + processing_mode: ProcessingMode + metadata: Dict[str, Any] + + +class FileProcessor: + """ + Zentrale Dokumentenverarbeitung für BreakPilot. + + Unterstützt: + - Handschrifterkennung (OCR) für Klausuren + - Textextraktion aus PDFs + - DOCX/DOC Verarbeitung + - Bildvorverarbeitung für bessere OCR-Ergebnisse + """ + + def __init__(self, ocr_lang: str = "de", use_gpu: bool = False): + """ + Initialisiert den File Processor. + + Args: + ocr_lang: Sprache für OCR (default: "de" für Deutsch) + use_gpu: GPU für OCR nutzen (beschleunigt Verarbeitung) + """ + self.ocr_lang = ocr_lang + self.use_gpu = use_gpu + self._ocr_engine = None + + logger.info(f"FileProcessor initialized (lang={ocr_lang}, gpu={use_gpu})") + + @property + def ocr_engine(self): + """Lazy-Loading des OCR-Engines.""" + if self._ocr_engine is None: + self._ocr_engine = self._init_ocr_engine() + return self._ocr_engine + + def _init_ocr_engine(self): + """Initialisiert PaddleOCR oder Fallback.""" + try: + from paddleocr import PaddleOCR + return PaddleOCR( + use_angle_cls=True, + lang='german', # Deutsch + use_gpu=self.use_gpu, + show_log=False + ) + except ImportError: + logger.warning("PaddleOCR nicht installiert - verwende Fallback") + return None + + def detect_file_type(self, file_path: str = None, file_bytes: bytes = None) -> FileType: + """ + Erkennt den Dateityp. + + Args: + file_path: Pfad zur Datei + file_bytes: Dateiinhalt als Bytes + + Returns: + FileType enum + """ + if file_path: + ext = Path(file_path).suffix.lower() + if ext == ".pdf": + return FileType.PDF + elif ext in [".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".gif"]: + return FileType.IMAGE + elif ext == ".docx": + return FileType.DOCX + elif ext == ".doc": + return FileType.DOC + elif ext == ".txt": + return FileType.TXT + + if file_bytes: + # Magic number detection + if file_bytes[:4] == b'%PDF': + return FileType.PDF + elif file_bytes[:8] == b'\x89PNG\r\n\x1a\n': + return FileType.IMAGE + elif file_bytes[:2] in [b'\xff\xd8', b'BM']: # JPEG, BMP + return FileType.IMAGE + elif file_bytes[:4] == b'PK\x03\x04': # ZIP (DOCX) + return FileType.DOCX + + return FileType.UNKNOWN + + def process( + self, + file_path: str = None, + file_bytes: bytes = None, + mode: ProcessingMode = ProcessingMode.MIXED + ) -> ProcessingResult: + """ + Verarbeitet ein Dokument. + + Args: + file_path: Pfad zur Datei + file_bytes: Dateiinhalt als Bytes + mode: Verarbeitungsmodus + + Returns: + ProcessingResult mit extrahiertem Text und Metadaten + """ + if not file_path and not file_bytes: + raise ValueError("Entweder file_path oder file_bytes muss angegeben werden") + + file_type = self.detect_file_type(file_path, file_bytes) + logger.info(f"Processing file of type: {file_type}") + + if file_type == FileType.PDF: + return self._process_pdf(file_path, file_bytes, mode) + elif file_type == FileType.IMAGE: + return self._process_image(file_path, file_bytes, mode) + elif file_type == FileType.DOCX: + return self._process_docx(file_path, file_bytes) + elif file_type == FileType.TXT: + return self._process_txt(file_path, file_bytes) + else: + raise ValueError(f"Nicht unterstützter Dateityp: {file_type}") + + def _process_pdf( + self, + file_path: str = None, + file_bytes: bytes = None, + mode: ProcessingMode = ProcessingMode.MIXED + ) -> ProcessingResult: + """Verarbeitet PDF-Dateien.""" + try: + import fitz # PyMuPDF + except ImportError: + logger.warning("PyMuPDF nicht installiert - versuche Fallback") + # Fallback: PDF als Bild behandeln + return self._process_image(file_path, file_bytes, mode) + + if file_bytes: + doc = fitz.open(stream=file_bytes, filetype="pdf") + else: + doc = fitz.open(file_path) + + all_text = [] + all_regions = [] + total_confidence = 0.0 + region_count = 0 + + for page_num, page in enumerate(doc, start=1): + # Erst versuchen Text direkt zu extrahieren + page_text = page.get_text() + + if page_text.strip() and mode != ProcessingMode.OCR_HANDWRITING: + # PDF enthält Text (nicht nur Bilder) + all_text.append(page_text) + all_regions.append(ProcessedRegion( + text=page_text, + confidence=1.0, + bbox=(0, 0, int(page.rect.width), int(page.rect.height)), + page=page_num + )) + total_confidence += 1.0 + region_count += 1 + else: + # Seite als Bild rendern und OCR anwenden + pix = page.get_pixmap(matrix=fitz.Matrix(2, 2)) # 2x Auflösung + img_bytes = pix.tobytes("png") + img = Image.open(io.BytesIO(img_bytes)) + + ocr_result = self._ocr_image(img) + all_text.append(ocr_result["text"]) + + for region in ocr_result["regions"]: + region.page = page_num + all_regions.append(region) + total_confidence += region.confidence + region_count += 1 + + doc.close() + + avg_confidence = total_confidence / region_count if region_count > 0 else 0.0 + + return ProcessingResult( + text="\n\n".join(all_text), + confidence=avg_confidence, + regions=all_regions, + page_count=len(doc) if hasattr(doc, '__len__') else 1, + file_type=FileType.PDF, + processing_mode=mode, + metadata={"source": file_path or "bytes"} + ) + + def _process_image( + self, + file_path: str = None, + file_bytes: bytes = None, + mode: ProcessingMode = ProcessingMode.MIXED + ) -> ProcessingResult: + """Verarbeitet Bilddateien.""" + if file_bytes: + img = Image.open(io.BytesIO(file_bytes)) + else: + img = Image.open(file_path) + + # Bildvorverarbeitung + processed_img = self._preprocess_image(img) + + # OCR + ocr_result = self._ocr_image(processed_img) + + return ProcessingResult( + text=ocr_result["text"], + confidence=ocr_result["confidence"], + regions=ocr_result["regions"], + page_count=1, + file_type=FileType.IMAGE, + processing_mode=mode, + metadata={ + "source": file_path or "bytes", + "image_size": img.size + } + ) + + def _process_docx( + self, + file_path: str = None, + file_bytes: bytes = None + ) -> ProcessingResult: + """Verarbeitet DOCX-Dateien.""" + try: + from docx import Document + except ImportError: + raise ImportError("python-docx ist nicht installiert") + + if file_bytes: + doc = Document(io.BytesIO(file_bytes)) + else: + doc = Document(file_path) + + paragraphs = [] + for para in doc.paragraphs: + if para.text.strip(): + paragraphs.append(para.text) + + # Auch Tabellen extrahieren + for table in doc.tables: + for row in table.rows: + row_text = " | ".join(cell.text for cell in row.cells) + if row_text.strip(): + paragraphs.append(row_text) + + text = "\n\n".join(paragraphs) + + return ProcessingResult( + text=text, + confidence=1.0, # Direkte Textextraktion + regions=[ProcessedRegion( + text=text, + confidence=1.0, + bbox=(0, 0, 0, 0), + page=1 + )], + page_count=1, + file_type=FileType.DOCX, + processing_mode=ProcessingMode.TEXT_EXTRACT, + metadata={"source": file_path or "bytes"} + ) + + def _process_txt( + self, + file_path: str = None, + file_bytes: bytes = None + ) -> ProcessingResult: + """Verarbeitet Textdateien.""" + if file_bytes: + text = file_bytes.decode('utf-8', errors='ignore') + else: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + text = f.read() + + return ProcessingResult( + text=text, + confidence=1.0, + regions=[ProcessedRegion( + text=text, + confidence=1.0, + bbox=(0, 0, 0, 0), + page=1 + )], + page_count=1, + file_type=FileType.TXT, + processing_mode=ProcessingMode.TEXT_EXTRACT, + metadata={"source": file_path or "bytes"} + ) + + def _preprocess_image(self, img: Image.Image) -> Image.Image: + """ + Vorverarbeitung des Bildes für bessere OCR-Ergebnisse. + + - Konvertierung zu Graustufen + - Kontrastverstärkung + - Rauschunterdrückung + - Binarisierung + """ + # PIL zu OpenCV + cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR) + + # Zu Graustufen konvertieren + gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY) + + # Rauschunterdrückung + denoised = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21) + + # Kontrastverstärkung (CLAHE) + clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) + enhanced = clahe.apply(denoised) + + # Adaptive Binarisierung + binary = cv2.adaptiveThreshold( + enhanced, + 255, + cv2.ADAPTIVE_THRESH_GAUSSIAN_C, + cv2.THRESH_BINARY, + 11, + 2 + ) + + # Zurück zu PIL + return Image.fromarray(binary) + + def _ocr_image(self, img: Image.Image) -> Dict[str, Any]: + """ + Führt OCR auf einem Bild aus. + + Returns: + Dict mit text, confidence und regions + """ + if self.ocr_engine is None: + # Fallback wenn kein OCR-Engine verfügbar + return { + "text": "[OCR nicht verfügbar - bitte PaddleOCR installieren]", + "confidence": 0.0, + "regions": [] + } + + # PIL zu numpy array + img_array = np.array(img) + + # Wenn Graustufen, zu RGB konvertieren (PaddleOCR erwartet RGB) + if len(img_array.shape) == 2: + img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB) + + # OCR ausführen + result = self.ocr_engine.ocr(img_array, cls=True) + + if not result or not result[0]: + return {"text": "", "confidence": 0.0, "regions": []} + + all_text = [] + all_regions = [] + total_confidence = 0.0 + + for line in result[0]: + bbox_points = line[0] # [[x1,y1], [x2,y2], [x3,y3], [x4,y4]] + text, confidence = line[1] + + # Bounding Box zu x1, y1, x2, y2 konvertieren + x_coords = [p[0] for p in bbox_points] + y_coords = [p[1] for p in bbox_points] + bbox = ( + int(min(x_coords)), + int(min(y_coords)), + int(max(x_coords)), + int(max(y_coords)) + ) + + all_text.append(text) + all_regions.append(ProcessedRegion( + text=text, + confidence=confidence, + bbox=bbox + )) + total_confidence += confidence + + avg_confidence = total_confidence / len(all_regions) if all_regions else 0.0 + + return { + "text": "\n".join(all_text), + "confidence": avg_confidence, + "regions": all_regions + } + + def extract_handwriting_regions( + self, + img: Image.Image, + min_area: int = 500 + ) -> List[Dict[str, Any]]: + """ + Erkennt und extrahiert handschriftliche Bereiche aus einem Bild. + + Nützlich für Klausuren mit gedruckten Fragen und handschriftlichen Antworten. + + Args: + img: Eingabebild + min_area: Minimale Fläche für erkannte Regionen + + Returns: + Liste von Regionen mit Koordinaten und erkanntem Text + """ + # Bildvorverarbeitung + cv_img = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR) + gray = cv2.cvtColor(cv_img, cv2.COLOR_BGR2GRAY) + + # Kanten erkennen + edges = cv2.Canny(gray, 50, 150) + + # Morphologische Operationen zum Verbinden + kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5)) + dilated = cv2.dilate(edges, kernel, iterations=2) + + # Konturen finden + contours, _ = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + regions = [] + for contour in contours: + area = cv2.contourArea(contour) + if area < min_area: + continue + + x, y, w, h = cv2.boundingRect(contour) + + # Region ausschneiden + region_img = img.crop((x, y, x + w, y + h)) + + # OCR auf Region anwenden + ocr_result = self._ocr_image(region_img) + + regions.append({ + "bbox": (x, y, x + w, y + h), + "area": area, + "text": ocr_result["text"], + "confidence": ocr_result["confidence"] + }) + + # Nach Y-Position sortieren (oben nach unten) + regions.sort(key=lambda r: r["bbox"][1]) + + return regions + + +# Singleton-Instanz +_file_processor: Optional[FileProcessor] = None + + +def get_file_processor() -> FileProcessor: + """Gibt Singleton-Instanz des File Processors zurück.""" + global _file_processor + if _file_processor is None: + _file_processor = FileProcessor() + return _file_processor + + +# Convenience functions +def process_file( + file_path: str = None, + file_bytes: bytes = None, + mode: ProcessingMode = ProcessingMode.MIXED +) -> ProcessingResult: + """ + Convenience function zum Verarbeiten einer Datei. + + Args: + file_path: Pfad zur Datei + file_bytes: Dateiinhalt als Bytes + mode: Verarbeitungsmodus + + Returns: + ProcessingResult + """ + processor = get_file_processor() + return processor.process(file_path, file_bytes, mode) + + +def extract_text_from_pdf(file_path: str = None, file_bytes: bytes = None) -> str: + """Extrahiert Text aus einer PDF-Datei.""" + result = process_file(file_path, file_bytes, ProcessingMode.TEXT_EXTRACT) + return result.text + + +def ocr_image(file_path: str = None, file_bytes: bytes = None) -> str: + """Führt OCR auf einem Bild aus.""" + result = process_file(file_path, file_bytes, ProcessingMode.OCR_PRINTED) + return result.text + + +def ocr_handwriting(file_path: str = None, file_bytes: bytes = None) -> str: + """Führt Handschrift-OCR auf einem Bild aus.""" + result = process_file(file_path, file_bytes, ProcessingMode.OCR_HANDWRITING) + return result.text diff --git a/backend/services/pdf_service.py b/backend/services/pdf_service.py new file mode 100644 index 0000000..9559964 --- /dev/null +++ b/backend/services/pdf_service.py @@ -0,0 +1,916 @@ +""" +PDF Service - Zentrale PDF-Generierung für BreakPilot. + +Shared Service für: +- Letters (Elternbriefe) +- Zeugnisse (Schulzeugnisse) +- Correction (Korrektur-Übersichten) + +Verwendet WeasyPrint für PDF-Rendering und Jinja2 für Templates. +""" + +import logging +import os +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Optional, List +from dataclasses import dataclass + +from jinja2 import Environment, FileSystemLoader, select_autoescape +from weasyprint import HTML, CSS +from weasyprint.text.fonts import FontConfiguration + +logger = logging.getLogger(__name__) + +# Template directory +TEMPLATES_DIR = Path(__file__).parent.parent / "templates" / "pdf" + + +@dataclass +class SchoolInfo: + """Schulinformationen für Header.""" + name: str + address: str + phone: str + email: str + logo_path: Optional[str] = None + website: Optional[str] = None + principal: Optional[str] = None + + +@dataclass +class LetterData: + """Daten für Elternbrief-PDF.""" + recipient_name: str + recipient_address: str + student_name: str + student_class: str + subject: str + content: str + date: str + teacher_name: str + teacher_title: Optional[str] = None + school_info: Optional[SchoolInfo] = None + letter_type: str = "general" # general, halbjahr, fehlzeiten, elternabend, lob + tone: str = "professional" + legal_references: Optional[List[Dict[str, str]]] = None + gfk_principles_applied: Optional[List[str]] = None + + +@dataclass +class CertificateData: + """Daten für Zeugnis-PDF.""" + student_name: str + student_birthdate: str + student_class: str + school_year: str + certificate_type: str # halbjahr, jahres, abschluss + subjects: List[Dict[str, Any]] # [{name, grade, note}] + attendance: Dict[str, int] # {days_absent, days_excused, days_unexcused} + remarks: Optional[str] = None + class_teacher: str = "" + principal: str = "" + school_info: Optional[SchoolInfo] = None + issue_date: str = "" + social_behavior: Optional[str] = None # A, B, C, D + work_behavior: Optional[str] = None # A, B, C, D + + +@dataclass +class StudentInfo: + """Schülerinformationen für Korrektur-PDFs.""" + student_id: str + name: str + class_name: str + + +@dataclass +class CorrectionData: + """Daten für Korrektur-Übersicht PDF.""" + student: StudentInfo + exam_title: str + subject: str + date: str + max_points: int + achieved_points: int + grade: str + percentage: float + corrections: List[Dict[str, Any]] # [{question, answer, points, feedback}] + teacher_notes: str = "" + ai_feedback: str = "" + grade_distribution: Optional[Dict[str, int]] = None # {note: anzahl} + class_average: Optional[float] = None + + +class PDFService: + """ + Zentrale PDF-Generierung für BreakPilot. + + Unterstützt: + - Elternbriefe mit GFK-Prinzipien und rechtlichen Referenzen + - Schulzeugnisse (Halbjahr, Jahres, Abschluss) + - Korrektur-Übersichten für Klausuren + """ + + def __init__(self, templates_dir: Optional[Path] = None): + """ + Initialisiert den PDF-Service. + + Args: + templates_dir: Optionaler Pfad zu Templates (Standard: backend/templates/pdf) + """ + self.templates_dir = templates_dir or TEMPLATES_DIR + + # Ensure templates directory exists + self.templates_dir.mkdir(parents=True, exist_ok=True) + + # Initialize Jinja2 environment + self.jinja_env = Environment( + loader=FileSystemLoader(str(self.templates_dir)), + autoescape=select_autoescape(['html', 'xml']), + trim_blocks=True, + lstrip_blocks=True + ) + + # Add custom filters + self.jinja_env.filters['date_format'] = self._date_format + self.jinja_env.filters['grade_color'] = self._grade_color + + # Font configuration for WeasyPrint + self.font_config = FontConfiguration() + + logger.info(f"PDFService initialized with templates from {self.templates_dir}") + + @staticmethod + def _date_format(value: str, format_str: str = "%d.%m.%Y") -> str: + """Formatiert Datum für deutsche Darstellung.""" + if not value: + return "" + try: + dt = datetime.fromisoformat(value.replace("Z", "+00:00")) + return dt.strftime(format_str) + except (ValueError, AttributeError): + return value + + @staticmethod + def _grade_color(grade: str) -> str: + """Gibt Farbe basierend auf Note zurück.""" + grade_colors = { + "1": "#27ae60", # Grün + "2": "#2ecc71", # Hellgrün + "3": "#f1c40f", # Gelb + "4": "#e67e22", # Orange + "5": "#e74c3c", # Rot + "6": "#c0392b", # Dunkelrot + "A": "#27ae60", + "B": "#2ecc71", + "C": "#f1c40f", + "D": "#e74c3c", + } + return grade_colors.get(str(grade), "#333333") + + def _get_base_css(self) -> str: + """Gibt Basis-CSS für alle PDFs zurück.""" + return """ + @page { + size: A4; + margin: 2cm 2.5cm; + @top-right { + content: counter(page) " / " counter(pages); + font-size: 9pt; + color: #666; + } + } + + body { + font-family: 'DejaVu Sans', 'Liberation Sans', Arial, sans-serif; + font-size: 11pt; + line-height: 1.5; + color: #333; + } + + h1, h2, h3 { + font-weight: bold; + margin-top: 1em; + margin-bottom: 0.5em; + } + + h1 { font-size: 16pt; } + h2 { font-size: 14pt; } + h3 { font-size: 12pt; } + + .header { + border-bottom: 2px solid #2c3e50; + padding-bottom: 15px; + margin-bottom: 20px; + } + + .school-name { + font-size: 18pt; + font-weight: bold; + color: #2c3e50; + } + + .school-info { + font-size: 9pt; + color: #666; + } + + .letter-date { + text-align: right; + margin-bottom: 20px; + } + + .recipient { + margin-bottom: 30px; + } + + .subject { + font-weight: bold; + margin-bottom: 20px; + } + + .content { + text-align: justify; + margin-bottom: 30px; + } + + .signature { + margin-top: 40px; + } + + .legal-references { + font-size: 9pt; + color: #666; + border-top: 1px solid #ddd; + margin-top: 30px; + padding-top: 10px; + } + + .gfk-badge { + display: inline-block; + background: #e8f5e9; + color: #27ae60; + font-size: 8pt; + padding: 2px 8px; + border-radius: 10px; + margin-right: 5px; + } + + /* Zeugnis-Styles */ + .certificate-header { + text-align: center; + margin-bottom: 30px; + } + + .certificate-title { + font-size: 20pt; + font-weight: bold; + margin-bottom: 10px; + } + + .student-info { + margin-bottom: 20px; + padding: 15px; + background: #f9f9f9; + border-radius: 5px; + } + + .grades-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 20px; + } + + .grades-table th, + .grades-table td { + border: 1px solid #ddd; + padding: 8px 12px; + text-align: left; + } + + .grades-table th { + background: #2c3e50; + color: white; + } + + .grades-table tr:nth-child(even) { + background: #f9f9f9; + } + + .grade-cell { + text-align: center; + font-weight: bold; + font-size: 12pt; + } + + .attendance-box { + background: #fff3cd; + padding: 15px; + border-radius: 5px; + margin-bottom: 20px; + } + + .signatures-row { + display: flex; + justify-content: space-between; + margin-top: 50px; + } + + .signature-block { + text-align: center; + width: 40%; + } + + .signature-line { + border-top: 1px solid #333; + margin-top: 40px; + padding-top: 5px; + } + + /* Korrektur-Styles */ + .exam-header { + background: #2c3e50; + color: white; + padding: 15px; + margin-bottom: 20px; + } + + .result-box { + background: #e8f5e9; + padding: 20px; + text-align: center; + margin-bottom: 20px; + border-radius: 5px; + } + + .result-grade { + font-size: 36pt; + font-weight: bold; + } + + .result-points { + font-size: 14pt; + color: #666; + } + + .corrections-list { + margin-bottom: 20px; + } + + .correction-item { + border: 1px solid #ddd; + padding: 15px; + margin-bottom: 10px; + border-radius: 5px; + } + + .correction-question { + font-weight: bold; + margin-bottom: 5px; + } + + .correction-feedback { + background: #fff8e1; + padding: 10px; + margin-top: 10px; + border-left: 3px solid #ffc107; + font-size: 10pt; + } + + .stats-table { + width: 100%; + margin-top: 20px; + } + + .stats-table td { + padding: 5px 10px; + } + """ + + def generate_letter_pdf(self, data: LetterData) -> bytes: + """ + Generiert PDF für Elternbrief. + + Args: + data: LetterData mit allen Briefinformationen + + Returns: + PDF als bytes + """ + logger.info(f"Generating letter PDF for student: {data.student_name}") + + template = self._get_letter_template() + html_content = template.render( + data=data, + generated_at=datetime.now().strftime("%d.%m.%Y %H:%M") + ) + + css = CSS(string=self._get_base_css(), font_config=self.font_config) + pdf_bytes = HTML(string=html_content).write_pdf( + stylesheets=[css], + font_config=self.font_config + ) + + logger.info(f"Letter PDF generated: {len(pdf_bytes)} bytes") + return pdf_bytes + + def generate_certificate_pdf(self, data: CertificateData) -> bytes: + """ + Generiert PDF für Schulzeugnis. + + Args: + data: CertificateData mit allen Zeugnisinformationen + + Returns: + PDF als bytes + """ + logger.info(f"Generating certificate PDF for: {data.student_name}") + + template = self._get_certificate_template() + html_content = template.render( + data=data, + generated_at=datetime.now().strftime("%d.%m.%Y %H:%M") + ) + + css = CSS(string=self._get_base_css(), font_config=self.font_config) + pdf_bytes = HTML(string=html_content).write_pdf( + stylesheets=[css], + font_config=self.font_config + ) + + logger.info(f"Certificate PDF generated: {len(pdf_bytes)} bytes") + return pdf_bytes + + def generate_correction_pdf(self, data: CorrectionData) -> bytes: + """ + Generiert PDF für Korrektur-Übersicht. + + Args: + data: CorrectionData mit allen Korrekturinformationen + + Returns: + PDF als bytes + """ + logger.info(f"Generating correction PDF for: {data.student.name}") + + template = self._get_correction_template() + html_content = template.render( + data=data, + generated_at=datetime.now().strftime("%d.%m.%Y %H:%M") + ) + + css = CSS(string=self._get_base_css(), font_config=self.font_config) + pdf_bytes = HTML(string=html_content).write_pdf( + stylesheets=[css], + font_config=self.font_config + ) + + logger.info(f"Correction PDF generated: {len(pdf_bytes)} bytes") + return pdf_bytes + + def _get_letter_template(self): + """Gibt Letter-Template zurück (inline falls Datei nicht existiert).""" + template_path = self.templates_dir / "letter.html" + if template_path.exists(): + return self.jinja_env.get_template("letter.html") + + # Inline-Template als Fallback + return self.jinja_env.from_string(self._get_letter_template_html()) + + def _get_certificate_template(self): + """Gibt Certificate-Template zurück.""" + template_path = self.templates_dir / "certificate.html" + if template_path.exists(): + return self.jinja_env.get_template("certificate.html") + + return self.jinja_env.from_string(self._get_certificate_template_html()) + + def _get_correction_template(self): + """Gibt Correction-Template zurück.""" + template_path = self.templates_dir / "correction.html" + if template_path.exists(): + return self.jinja_env.get_template("correction.html") + + return self.jinja_env.from_string(self._get_correction_template_html()) + + @staticmethod + def _get_letter_template_html() -> str: + """Inline HTML-Template für Elternbriefe.""" + return """ + + + + + {{ data.subject }} + + +
            + {% if data.school_info %} +
            {{ data.school_info.name }}
            +
            + {{ data.school_info.address }}
            + Tel: {{ data.school_info.phone }} | E-Mail: {{ data.school_info.email }} + {% if data.school_info.website %} | {{ data.school_info.website }}{% endif %} +
            + {% else %} +
            Schule
            + {% endif %} +
            + +
            + {{ data.date }} +
            + +
            + {{ data.recipient_name }}
            + {{ data.recipient_address | replace('\\n', '
            ') | safe }} +
            + +
            + Betreff: {{ data.subject }} +
            + +
            + Schüler/in: {{ data.student_name }} | Klasse: {{ data.student_class }} +
            + +
            + {{ data.content | replace('\\n', '
            ') | safe }} +
            + + {% if data.gfk_principles_applied %} +
            + {% for principle in data.gfk_principles_applied %} + ✓ {{ principle }} + {% endfor %} +
            + {% endif %} + +
            +

            Mit freundlichen Grüßen

            +

            + {{ data.teacher_name }} + {% if data.teacher_title %}
            {{ data.teacher_title }}{% endif %} +

            +
            + + {% if data.legal_references %} + + {% endif %} + +
            + Erstellt mit BreakPilot | {{ generated_at }} +
            + + +""" + + @staticmethod + def _get_certificate_template_html() -> str: + """Inline HTML-Template für Zeugnisse.""" + return """ + + + + + Zeugnis - {{ data.student_name }} + + +
            + {% if data.school_info %} +
            {{ data.school_info.name }}
            + {% endif %} +
            + {% if data.certificate_type == 'halbjahr' %} + Halbjahreszeugnis + {% elif data.certificate_type == 'jahres' %} + Jahreszeugnis + {% else %} + Abschlusszeugnis + {% endif %} +
            +
            Schuljahr {{ data.school_year }}
            +
            + +
            + + + + + + + + + +
            Name: {{ data.student_name }}Geburtsdatum: {{ data.student_birthdate }}
            Klasse: {{ data.student_class }} 
            +
            + +

            Leistungen

            + + + + + + + + + + {% for subject in data.subjects %} + + + + + + {% endfor %} + +
            FachNotePunkte
            {{ subject.name }} + {{ subject.grade }} + {{ subject.points | default('-') }}
            + + {% if data.social_behavior or data.work_behavior %} +

            Verhalten

            + + {% if data.social_behavior %} + + + + + {% endif %} + {% if data.work_behavior %} + + + + + {% endif %} +
            Sozialverhalten{{ data.social_behavior }}
            Arbeitsverhalten{{ data.work_behavior }}
            + {% endif %} + +
            + Versäumte Tage: {{ data.attendance.days_absent | default(0) }} + (davon entschuldigt: {{ data.attendance.days_excused | default(0) }}, + unentschuldigt: {{ data.attendance.days_unexcused | default(0) }}) +
            + + {% if data.remarks %} +
            + Bemerkungen:
            + {{ data.remarks }} +
            + {% endif %} + +
            + Ausgestellt am: {{ data.issue_date }} +
            + +
            +
            +
            {{ data.class_teacher }}
            +
            Klassenlehrer/in
            +
            +
            +
            {{ data.principal }}
            +
            Schulleiter/in
            +
            +
            + +
            +
            Siegel der Schule
            +
            + + +""" + + @staticmethod + def _get_correction_template_html() -> str: + """Inline HTML-Template für Korrektur-Übersichten.""" + return """ + + + + + Korrektur - {{ data.exam_title }} + + +
            +

            {{ data.exam_title }}

            +
            {{ data.subject }} | {{ data.date }}
            +
            + +
            + {{ data.student.name }} | Klasse {{ data.student.class_name }} +
            + +
            +
            + Note: {{ data.grade }} +
            +
            + {{ data.achieved_points }} von {{ data.max_points }} Punkten + ({{ data.percentage | round(1) }}%) +
            +
            + +

            Detaillierte Auswertung

            +
            + {% for item in data.corrections %} +
            +
            + {{ item.question }} +
            + {% if item.answer %} +
            + Antwort: {{ item.answer }} +
            + {% endif %} +
            + Punkte: {{ item.points }} +
            + {% if item.feedback %} +
            + {{ item.feedback }} +
            + {% endif %} +
            + {% endfor %} +
            + + {% if data.teacher_notes %} +
            + Lehrerkommentar:
            + {{ data.teacher_notes }} +
            + {% endif %} + + {% if data.ai_feedback %} +
            + KI-Feedback:
            + {{ data.ai_feedback }} +
            + {% endif %} + + {% if data.class_average or data.grade_distribution %} +

            Klassenstatistik

            + + {% if data.class_average %} + + + + + {% endif %} + {% if data.grade_distribution %} + + + + + {% endif %} +
            Klassendurchschnitt:{{ data.class_average }}
            Notenverteilung: + {% for grade, count in data.grade_distribution.items() %} + Note {{ grade }}: {{ count }}x{% if not loop.last %}, {% endif %} + {% endfor %} +
            + {% endif %} + +
            +

            Datum: {{ data.date }}

            +
            + +
            + Erstellt mit BreakPilot | {{ generated_at }} +
            + + +""" + + +# Convenience functions for direct usage +_pdf_service: Optional[PDFService] = None + + +def get_pdf_service() -> PDFService: + """Gibt Singleton-Instanz des PDF-Service zurück.""" + global _pdf_service + if _pdf_service is None: + _pdf_service = PDFService() + return _pdf_service + + +def generate_letter_pdf(data: Dict[str, Any]) -> bytes: + """ + Convenience function zum Generieren eines Elternbrief-PDFs. + + Args: + data: Dict mit allen Briefdaten + + Returns: + PDF als bytes + """ + service = get_pdf_service() + + # Convert dict to LetterData + school_info = None + if data.get("school_info"): + school_info = SchoolInfo(**data["school_info"]) + + letter_data = LetterData( + recipient_name=data.get("recipient_name", ""), + recipient_address=data.get("recipient_address", ""), + student_name=data.get("student_name", ""), + student_class=data.get("student_class", ""), + subject=data.get("subject", ""), + content=data.get("content", ""), + date=data.get("date", datetime.now().strftime("%d.%m.%Y")), + teacher_name=data.get("teacher_name", ""), + teacher_title=data.get("teacher_title"), + school_info=school_info, + letter_type=data.get("letter_type", "general"), + tone=data.get("tone", "professional"), + legal_references=data.get("legal_references"), + gfk_principles_applied=data.get("gfk_principles_applied") + ) + + return service.generate_letter_pdf(letter_data) + + +def generate_certificate_pdf(data: Dict[str, Any]) -> bytes: + """ + Convenience function zum Generieren eines Zeugnis-PDFs. + + Args: + data: Dict mit allen Zeugnisdaten + + Returns: + PDF als bytes + """ + service = get_pdf_service() + + school_info = None + if data.get("school_info"): + school_info = SchoolInfo(**data["school_info"]) + + cert_data = CertificateData( + student_name=data.get("student_name", ""), + student_birthdate=data.get("student_birthdate", ""), + student_class=data.get("student_class", ""), + school_year=data.get("school_year", ""), + certificate_type=data.get("certificate_type", "halbjahr"), + subjects=data.get("subjects", []), + attendance=data.get("attendance", {"days_absent": 0, "days_excused": 0, "days_unexcused": 0}), + remarks=data.get("remarks"), + class_teacher=data.get("class_teacher", ""), + principal=data.get("principal", ""), + school_info=school_info, + issue_date=data.get("issue_date", datetime.now().strftime("%d.%m.%Y")), + social_behavior=data.get("social_behavior"), + work_behavior=data.get("work_behavior") + ) + + return service.generate_certificate_pdf(cert_data) + + +def generate_correction_pdf(data: Dict[str, Any]) -> bytes: + """ + Convenience function zum Generieren eines Korrektur-PDFs. + + Args: + data: Dict mit allen Korrekturdaten + + Returns: + PDF als bytes + """ + service = get_pdf_service() + + # Create StudentInfo from dict + student = StudentInfo( + student_id=data.get("student_id", "unknown"), + name=data.get("student_name", data.get("name", "")), + class_name=data.get("student_class", data.get("class_name", "")) + ) + + # Calculate percentage if not provided + max_points = data.get("max_points", data.get("total_points", 0)) + achieved_points = data.get("achieved_points", 0) + percentage = data.get("percentage", (achieved_points / max_points * 100) if max_points > 0 else 0.0) + + correction_data = CorrectionData( + student=student, + exam_title=data.get("exam_title", ""), + subject=data.get("subject", ""), + date=data.get("date", data.get("exam_date", "")), + max_points=max_points, + achieved_points=achieved_points, + grade=data.get("grade", ""), + percentage=percentage, + corrections=data.get("corrections", []), + teacher_notes=data.get("teacher_notes", data.get("teacher_comment", "")), + ai_feedback=data.get("ai_feedback", ""), + grade_distribution=data.get("grade_distribution"), + class_average=data.get("class_average") + ) + + return service.generate_correction_pdf(correction_data) diff --git a/backend/session/__init__.py b/backend/session/__init__.py new file mode 100644 index 0000000..82df0db --- /dev/null +++ b/backend/session/__init__.py @@ -0,0 +1,52 @@ +""" +Session Management Module for BreakPilot + +Hybrid session storage using Valkey (Redis-fork) for fast lookups +and PostgreSQL for persistence and DSGVO audit trail. + +Components: +- session_store.py: Hybrid Valkey + PostgreSQL session storage +- session_middleware.py: FastAPI middleware for session-based auth +- rbac_middleware.py: User type and permission checking +""" + +from .session_store import ( + SessionStore, + Session, + UserType, + get_session_store, +) +from .session_middleware import ( + get_current_session, + require_session, + session_middleware, +) +from .rbac_middleware import ( + require_user_type, + require_permission, + require_any_permission, + require_employee, + require_customer, + EMPLOYEE_PERMISSIONS, + CUSTOMER_PERMISSIONS, +) + +__all__ = [ + # Session Store + "SessionStore", + "Session", + "UserType", + "get_session_store", + # Session Middleware + "get_current_session", + "require_session", + "session_middleware", + # RBAC Middleware + "require_user_type", + "require_permission", + "require_any_permission", + "require_employee", + "require_customer", + "EMPLOYEE_PERMISSIONS", + "CUSTOMER_PERMISSIONS", +] diff --git a/backend/session/cleanup_job.py b/backend/session/cleanup_job.py new file mode 100644 index 0000000..899c353 --- /dev/null +++ b/backend/session/cleanup_job.py @@ -0,0 +1,141 @@ +""" +Session Cleanup Job + +Removes expired sessions from PostgreSQL. +Valkey handles its own expiry via TTL. + +This job should be run periodically (e.g., via cron or APScheduler). + +Usage: + # Run directly + python -m session.cleanup_job + + # Or import and call + from session.cleanup_job import run_cleanup + + await run_cleanup() +""" + +import asyncio +import logging +import os +from datetime import datetime, timezone + +logger = logging.getLogger(__name__) + + +async def run_cleanup(): + """Run session cleanup job.""" + from .session_store import get_session_store + + logger.info("Starting session cleanup job...") + + try: + store = await get_session_store() + count = await store.cleanup_expired_sessions() + logger.info(f"Session cleanup completed: removed {count} expired sessions") + return count + except Exception as e: + logger.error(f"Session cleanup failed: {e}") + raise + + +async def run_cleanup_with_pg(): + """ + Run cleanup directly with PostgreSQL connection. + + Useful when session store is not initialized. + """ + database_url = os.environ.get("DATABASE_URL") + if not database_url: + logger.warning("DATABASE_URL not set, skipping cleanup") + return 0 + + try: + import asyncpg + + conn = await asyncpg.connect(database_url) + try: + # Delete sessions expired more than 7 days ago + result = await conn.execute(""" + DELETE FROM user_sessions + WHERE expires_at < NOW() - INTERVAL '7 days' + """) + count = int(result.split()[-1]) if result else 0 + logger.info(f"Session cleanup completed: removed {count} expired sessions") + return count + finally: + await conn.close() + except Exception as e: + logger.error(f"Session cleanup failed: {e}") + return 0 + + +def setup_scheduler(): + """ + Setup APScheduler for periodic cleanup. + + Runs cleanup every 6 hours. + """ + try: + from apscheduler.schedulers.asyncio import AsyncIOScheduler + from apscheduler.triggers.interval import IntervalTrigger + + scheduler = AsyncIOScheduler() + + scheduler.add_job( + run_cleanup, + trigger=IntervalTrigger(hours=6), + id="session_cleanup", + name="Session Cleanup Job", + replace_existing=True, + ) + + scheduler.start() + logger.info("Session cleanup scheduler started (runs every 6 hours)") + + return scheduler + + except ImportError: + logger.warning("APScheduler not installed, cleanup job not scheduled") + return None + + +def register_with_fastapi(app): + """ + Register cleanup job with FastAPI app lifecycle. + + Usage: + from session.cleanup_job import register_with_fastapi + register_with_fastapi(app) + """ + from contextlib import asynccontextmanager + + scheduler = None + + @asynccontextmanager + async def lifespan(app): + nonlocal scheduler + # Startup + scheduler = setup_scheduler() + # Run initial cleanup + asyncio.create_task(run_cleanup()) + + yield + + # Shutdown + if scheduler: + scheduler.shutdown() + + return lifespan + + +if __name__ == "__main__": + # Configure logging + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + + # Run cleanup + asyncio.run(run_cleanup_with_pg()) diff --git a/backend/session/protected_routes.py b/backend/session/protected_routes.py new file mode 100644 index 0000000..adc28eb --- /dev/null +++ b/backend/session/protected_routes.py @@ -0,0 +1,389 @@ +""" +Protected Routes Example + +Shows how to structure routes under /api/protected with session-based auth. + +Route structure: + /api/auth/ - Public (login, register, logout) + /api/public/ - Public (health, docs) + /api/protected/ - Authenticated (all users) + /api/protected/employee/ - Employees only + /api/protected/customer/ - Customers only + /api/admin/ - Admins only +""" + +from fastapi import APIRouter, Depends, HTTPException +from typing import List, Optional + +from .session_store import Session, UserType +from .session_middleware import get_current_session, get_optional_session +from .rbac_middleware import ( + require_employee, + require_customer, + require_permission, + require_role, + require_any_role, +) + +# ============================================= +# Router Setup +# ============================================= + +# Protected routes - require authentication +protected_router = APIRouter(prefix="/api/protected", tags=["Protected"]) + +# Employee-only routes +employee_router = APIRouter(prefix="/api/protected/employee", tags=["Employee"]) + +# Customer-only routes +customer_router = APIRouter(prefix="/api/protected/customer", tags=["Customer"]) + +# Admin routes +admin_router = APIRouter(prefix="/api/admin", tags=["Admin"]) + + +# ============================================= +# Protected Routes (All Authenticated Users) +# ============================================= + +@protected_router.get("/profile") +async def get_profile(session: Session = Depends(get_current_session)): + """Get current user's profile.""" + return { + "user_id": session.user_id, + "email": session.email, + "user_type": session.user_type.value, + "roles": session.roles, + "permissions": session.permissions, + "tenant_id": session.tenant_id, + } + + +@protected_router.get("/notifications") +async def get_notifications(session: Session = Depends(get_current_session)): + """Get user's notifications.""" + # TODO: Implement actual notification fetching + return { + "notifications": [], + "unread_count": 0, + } + + +@protected_router.post("/logout") +async def logout(session: Session = Depends(get_current_session)): + """Logout current session.""" + from .session_store import get_session_store + + store = await get_session_store() + await store.revoke_session(session.session_id) + + return {"message": "Logged out successfully"} + + +@protected_router.post("/logout-all") +async def logout_all(session: Session = Depends(get_current_session)): + """Logout from all devices.""" + from .session_store import get_session_store + + store = await get_session_store() + count = await store.revoke_all_user_sessions(session.user_id) + + return {"message": f"Logged out from {count} sessions"} + + +@protected_router.get("/sessions") +async def get_active_sessions(session: Session = Depends(get_current_session)): + """Get all active sessions for current user.""" + from .session_store import get_session_store + + store = await get_session_store() + sessions = await store.get_active_sessions(session.user_id) + + return { + "sessions": [ + { + "session_id": s.session_id, + "ip_address": s.ip_address, + "user_agent": s.user_agent, + "created_at": s.created_at.isoformat() if s.created_at else None, + "last_activity_at": s.last_activity_at.isoformat() if s.last_activity_at else None, + "is_current": s.session_id == session.session_id, + } + for s in sessions + ] + } + + +# ============================================= +# Employee Routes +# ============================================= + +@employee_router.get("/dashboard") +async def employee_dashboard(session: Session = Depends(require_employee)): + """Employee dashboard with overview data.""" + return { + "user_type": "employee", + "email": session.email, + "roles": session.roles, + "widgets": [ + {"type": "today_classes", "title": "Heutige Stunden"}, + {"type": "pending_corrections", "title": "Ausstehende Korrekturen"}, + {"type": "absent_students", "title": "Abwesende Schueler"}, + ], + } + + +@employee_router.get("/grades") +async def get_grades( + class_id: Optional[str] = None, + session: Session = Depends(require_permission("grades:read")) +): + """Get grades (employee only, requires grades:read permission).""" + # TODO: Implement actual grade fetching + return { + "grades": [], + "class_id": class_id, + } + + +@employee_router.post("/grades") +async def create_grade( + grade_data: dict, + session: Session = Depends(require_permission("grades:write")) +): + """Create a new grade (requires grades:write permission).""" + # TODO: Implement grade creation + return {"message": "Grade created"} + + +@employee_router.get("/attendance") +async def get_attendance( + date: Optional[str] = None, + class_id: Optional[str] = None, + session: Session = Depends(require_permission("attendance:read")) +): + """Get attendance records.""" + return { + "attendance": [], + "date": date, + "class_id": class_id, + } + + +@employee_router.post("/attendance") +async def mark_attendance( + attendance_data: dict, + session: Session = Depends(require_permission("attendance:write")) +): + """Mark student attendance.""" + return {"message": "Attendance recorded"} + + +@employee_router.get("/students") +async def get_students( + class_id: Optional[str] = None, + session: Session = Depends(require_permission("students:read")) +): + """Get student list.""" + return { + "students": [], + "class_id": class_id, + } + + +@employee_router.get("/corrections") +async def get_corrections( + session: Session = Depends(require_permission("corrections:read")) +): + """Get pending corrections.""" + return { + "corrections": [], + "pending_count": 0, + } + + +# ============================================= +# Customer Routes +# ============================================= + +@customer_router.get("/dashboard") +async def customer_dashboard(session: Session = Depends(require_customer)): + """Customer dashboard.""" + return { + "user_type": "customer", + "email": session.email, + "widgets": [ + {"type": "my_children", "title": "Meine Kinder"}, + {"type": "upcoming_meetings", "title": "Anstehende Termine"}, + {"type": "recent_grades", "title": "Aktuelle Noten"}, + ], + } + + +@customer_router.get("/my-children") +async def get_my_children( + session: Session = Depends(require_permission("children:read")) +): + """Get parent's children.""" + # TODO: Implement actual children fetching + return { + "children": [], + } + + +@customer_router.get("/my-grades") +async def get_my_grades( + session: Session = Depends(require_permission("own_grades:read")) +): + """Get student's own grades.""" + return { + "grades": [], + "average": None, + } + + +@customer_router.get("/my-attendance") +async def get_my_attendance( + session: Session = Depends(require_permission("own_attendance:read")) +): + """Get student's own attendance.""" + return { + "attendance_records": [], + "absence_count": 0, + } + + +@customer_router.get("/children/{child_id}/grades") +async def get_child_grades( + child_id: str, + session: Session = Depends(require_permission("children:grades:read")) +): + """Get child's grades (for parents).""" + # TODO: Verify parent-child relationship + return { + "child_id": child_id, + "grades": [], + } + + +@customer_router.get("/appointments") +async def get_appointments(session: Session = Depends(require_customer)): + """Get upcoming appointments/meetings.""" + return { + "appointments": [], + } + + +@customer_router.post("/appointments/{slot_id}/book") +async def book_appointment( + slot_id: str, + session: Session = Depends(require_permission("meetings:join")) +): + """Book a parent meeting slot.""" + return { + "message": "Appointment booked", + "slot_id": slot_id, + } + + +# ============================================= +# Admin Routes +# ============================================= + +@admin_router.get("/users") +async def list_users( + page: int = 1, + limit: int = 50, + session: Session = Depends(require_permission("users:read")) +): + """List all users (admin only).""" + return { + "users": [], + "total": 0, + "page": page, + "limit": limit, + } + + +@admin_router.get("/users/{user_id}") +async def get_user( + user_id: str, + session: Session = Depends(require_permission("users:read")) +): + """Get user details.""" + return { + "user_id": user_id, + "email": None, + "roles": [], + } + + +@admin_router.put("/users/{user_id}/roles") +async def update_user_roles( + user_id: str, + roles: List[str], + session: Session = Depends(require_permission("users:manage")) +): + """Update user roles (admin only).""" + return { + "message": "Roles updated", + "user_id": user_id, + "roles": roles, + } + + +@admin_router.get("/audit-log") +async def get_audit_log( + page: int = 1, + limit: int = 100, + session: Session = Depends(require_permission("audit:read")) +): + """Get audit log entries.""" + return { + "entries": [], + "total": 0, + "page": page, + "limit": limit, + } + + +@admin_router.get("/rbac/roles") +async def list_roles( + session: Session = Depends(require_permission("rbac:read")) +): + """List all RBAC roles.""" + return { + "roles": [], + } + + +@admin_router.get("/rbac/permissions") +async def list_permissions( + session: Session = Depends(require_permission("rbac:read")) +): + """List all permissions.""" + from .rbac_middleware import EMPLOYEE_PERMISSIONS, CUSTOMER_PERMISSIONS, ADMIN_PERMISSIONS + + return { + "employee_permissions": EMPLOYEE_PERMISSIONS, + "customer_permissions": CUSTOMER_PERMISSIONS, + "admin_permissions": ADMIN_PERMISSIONS, + } + + +# ============================================= +# Router Registration Helper +# ============================================= + +def register_protected_routes(app): + """ + Register all protected route routers with FastAPI app. + + Usage: + from session.protected_routes import register_protected_routes + register_protected_routes(app) + """ + app.include_router(protected_router) + app.include_router(employee_router) + app.include_router(customer_router) + app.include_router(admin_router) diff --git a/backend/session/rbac_middleware.py b/backend/session/rbac_middleware.py new file mode 100644 index 0000000..b8cb5ff --- /dev/null +++ b/backend/session/rbac_middleware.py @@ -0,0 +1,428 @@ +""" +RBAC Middleware for Session-Based Authentication + +Provides user type checking (Employee vs. Customer) and +permission-based access control. + +Employee roles: Teacher-Rollen, Admin-Rollen +Customer roles: parent, student, user + +Usage: + @app.get("/api/protected/employee/grades") + async def get_grades(session: Session = Depends(require_employee)): + return {"grades": [...]} + + @app.get("/api/protected/endpoint") + async def protected(session: Session = Depends(require_permission("grades:read"))): + return {"data": [...]} +""" + +import logging +from typing import List, Callable +from functools import wraps + +from fastapi import HTTPException, Depends + +from .session_store import Session, UserType +from .session_middleware import get_current_session + +logger = logging.getLogger(__name__) + + +# ============================================= +# Permission Constants +# ============================================= + +EMPLOYEE_PERMISSIONS = [ + # Grades & Attendance + "grades:read", + "grades:write", + "attendance:read", + "attendance:write", + # Student Management + "students:read", + "students:write", + # Reports & Consent + "reports:generate", + "consent:admin", + # Corrections + "corrections:read", + "corrections:write", + # Classes + "classes:read", + "classes:write", + # Timetable + "timetable:read", + "timetable:write", + # Substitutions + "substitutions:read", + "substitutions:write", + # Parent Communication + "parent_communication:read", + "parent_communication:write", +] + +CUSTOMER_PERMISSIONS = [ + # Own Data Access + "own_data:read", + "own_grades:read", + "own_attendance:read", + # Consent Management + "consent:manage", + # Meetings & Communication + "meetings:join", + "messages:read", + "messages:write", + # Children (for parents) + "children:read", + "children:grades:read", + "children:attendance:read", +] + +ADMIN_PERMISSIONS = [ + # User Management + "users:read", + "users:write", + "users:manage", + # RBAC Management + "rbac:read", + "rbac:write", + # Audit & Logs + "audit:read", + # System Settings + "settings:read", + "settings:write", + # DSR Management + "dsr:read", + "dsr:process", +] + +# Roles that indicate employee user type +EMPLOYEE_ROLES = { + "admin", + "schul_admin", + "schulleitung", + "pruefungsvorsitz", + "klassenlehrer", + "fachlehrer", + "sekretariat", + "erstkorrektor", + "zweitkorrektor", + "drittkorrektor", + "teacher_assistant", + "teacher", + "lehrer", + "data_protection_officer", +} + +# Roles that indicate customer user type +CUSTOMER_ROLES = { + "parent", + "student", + "user", + "guardian", +} + + +# ============================================= +# User Type Dependencies +# ============================================= + +async def require_employee(session: Session = Depends(get_current_session)) -> Session: + """ + Require user to be an employee (internal staff). + + Usage: + @app.get("/api/protected/employee/grades") + async def employee_only(session: Session = Depends(require_employee)): + return {"grades": [...]} + """ + if not session.is_employee(): + raise HTTPException( + status_code=403, + detail="Employee access required" + ) + return session + + +async def require_customer(session: Session = Depends(get_current_session)) -> Session: + """ + Require user to be a customer (external user). + + Usage: + @app.get("/api/protected/customer/my-grades") + async def customer_only(session: Session = Depends(require_customer)): + return {"my_grades": [...]} + """ + if not session.is_customer(): + raise HTTPException( + status_code=403, + detail="Customer access required" + ) + return session + + +def require_user_type(user_type: UserType): + """ + Factory for user type dependency. + + Usage: + @app.get("/api/protected/endpoint") + async def endpoint(session: Session = Depends(require_user_type(UserType.EMPLOYEE))): + return {"data": [...]} + """ + async def user_type_checker(session: Session = Depends(get_current_session)) -> Session: + if session.user_type != user_type: + raise HTTPException( + status_code=403, + detail=f"User type '{user_type.value}' required" + ) + return session + + return user_type_checker + + +# ============================================= +# Permission Dependencies +# ============================================= + +def require_permission(permission: str): + """ + Factory for permission-based access control. + + Usage: + @app.get("/api/protected/grades") + async def get_grades(session: Session = Depends(require_permission("grades:read"))): + return {"grades": [...]} + """ + async def permission_checker(session: Session = Depends(get_current_session)) -> Session: + if not session.has_permission(permission): + logger.warning( + f"Permission denied: user {session.user_id} lacks '{permission}'" + ) + raise HTTPException( + status_code=403, + detail=f"Permission '{permission}' required" + ) + return session + + return permission_checker + + +def require_any_permission(permissions: List[str]): + """ + Require user to have at least one of the specified permissions. + + Usage: + @app.get("/api/protected/data") + async def get_data( + session: Session = Depends(require_any_permission(["data:read", "admin"])) + ): + return {"data": [...]} + """ + async def any_permission_checker(session: Session = Depends(get_current_session)) -> Session: + if not session.has_any_permission(permissions): + raise HTTPException( + status_code=403, + detail=f"One of permissions {permissions} required" + ) + return session + + return any_permission_checker + + +def require_all_permissions(permissions: List[str]): + """ + Require user to have all specified permissions. + + Usage: + @app.get("/api/protected/sensitive") + async def sensitive( + session: Session = Depends(require_all_permissions(["grades:read", "grades:write"])) + ): + return {"data": [...]} + """ + async def all_permissions_checker(session: Session = Depends(get_current_session)) -> Session: + if not session.has_all_permissions(permissions): + missing = [p for p in permissions if not session.has_permission(p)] + raise HTTPException( + status_code=403, + detail=f"Missing permissions: {missing}" + ) + return session + + return all_permissions_checker + + +def require_role(role: str): + """ + Factory for role-based access control. + + Usage: + @app.get("/api/admin/users") + async def admin_users(session: Session = Depends(require_role("admin"))): + return {"users": [...]} + """ + async def role_checker(session: Session = Depends(get_current_session)) -> Session: + if not session.has_role(role): + raise HTTPException( + status_code=403, + detail=f"Role '{role}' required" + ) + return session + + return role_checker + + +def require_any_role(roles: List[str]): + """ + Require user to have at least one of the specified roles. + + Usage: + @app.get("/api/management/data") + async def management( + session: Session = Depends(require_any_role(["admin", "schulleitung"])) + ): + return {"data": [...]} + """ + async def any_role_checker(session: Session = Depends(get_current_session)) -> Session: + if not any(session.has_role(role) for role in roles): + raise HTTPException( + status_code=403, + detail=f"One of roles {roles} required" + ) + return session + + return any_role_checker + + +# ============================================= +# Tenant Isolation +# ============================================= + +def require_same_tenant(tenant_id_param: str = "tenant_id"): + """ + Ensure user can only access data within their tenant. + + Usage: + @app.get("/api/protected/school/{tenant_id}/data") + async def school_data( + tenant_id: str, + session: Session = Depends(require_same_tenant("tenant_id")) + ): + return {"data": [...]} + """ + async def tenant_checker( + session: Session = Depends(get_current_session), + **kwargs + ) -> Session: + request_tenant = kwargs.get(tenant_id_param) + if request_tenant and session.tenant_id != request_tenant: + # Check if user is super admin (can access all tenants) + if not session.has_role("super_admin"): + raise HTTPException( + status_code=403, + detail="Access denied to this tenant" + ) + return session + + return tenant_checker + + +# ============================================= +# Utility Functions +# ============================================= + +def determine_user_type(roles: List[str]) -> UserType: + """ + Determine user type based on roles. + + Employee roles take precedence over customer roles. + """ + role_set = set(roles) + + # Check for employee roles + if role_set & EMPLOYEE_ROLES: + return UserType.EMPLOYEE + + # Check for customer roles + if role_set & CUSTOMER_ROLES: + return UserType.CUSTOMER + + # Default to customer + return UserType.CUSTOMER + + +def get_permissions_for_roles(roles: List[str], user_type: UserType) -> List[str]: + """ + Get permissions based on roles and user type. + + This is a basic implementation - in production, you'd query + the RBAC database for role-permission mappings. + """ + permissions = set() + + # Base permissions based on user type + if user_type == UserType.EMPLOYEE: + permissions.update(EMPLOYEE_PERMISSIONS) + else: + permissions.update(CUSTOMER_PERMISSIONS) + + # Admin permissions + role_set = set(roles) + admin_roles = {"admin", "schul_admin", "super_admin", "data_protection_officer"} + if role_set & admin_roles: + permissions.update(ADMIN_PERMISSIONS) + + return list(permissions) + + +def check_resource_ownership( + session: Session, + resource_user_id: str, + allow_admin: bool = True +) -> bool: + """ + Check if user owns a resource or is admin. + + Usage: + if not check_resource_ownership(session, grade.student_id): + raise HTTPException(403, "Access denied") + """ + # User owns the resource + if session.user_id == resource_user_id: + return True + + # Admin can access all + if allow_admin and (session.has_role("admin") or session.has_role("super_admin")): + return True + + return False + + +def check_parent_child_access( + session: Session, + student_id: str, + parent_student_ids: List[str] +) -> bool: + """ + Check if parent has access to student's data. + + Usage: + parent_children = get_parent_children(session.user_id) + if not check_parent_child_access(session, student_id, parent_children): + raise HTTPException(403, "Access denied") + """ + # User is the student + if session.user_id == student_id: + return True + + # User is parent of student + if student_id in parent_student_ids: + return True + + # Employee can access (with appropriate permissions) + if session.is_employee() and session.has_permission("students:read"): + return True + + return False diff --git a/backend/session/session_middleware.py b/backend/session/session_middleware.py new file mode 100644 index 0000000..4ac4292 --- /dev/null +++ b/backend/session/session_middleware.py @@ -0,0 +1,240 @@ +""" +Session Middleware for FastAPI + +Provides session-based authentication as an alternative to JWT. +Sessions are stored in Valkey with PostgreSQL fallback. + +Usage: + @app.get("/api/protected/profile") + async def get_profile(session: Session = Depends(get_current_session)): + return {"user_id": session.user_id} +""" + +import os +import logging +from typing import Optional, Dict, Any, Callable +from functools import wraps + +from fastapi import Request, HTTPException, Depends +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import Response + +from .session_store import Session, SessionStore, get_session_store + +logger = logging.getLogger(__name__) + + +class SessionMiddleware(BaseHTTPMiddleware): + """ + Middleware that extracts session from request and adds to request.state. + + Session can be provided via: + 1. Authorization header: Bearer + 2. Cookie: session_id= + """ + + def __init__(self, app, session_cookie_name: str = "session_id"): + super().__init__(app) + self.session_cookie_name = session_cookie_name + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + """Extract session and add to request state.""" + session_id = self._extract_session_id(request) + + if session_id: + try: + store = await get_session_store() + session = await store.get_session(session_id) + request.state.session = session + except Exception as e: + logger.error(f"Failed to load session: {e}") + request.state.session = None + else: + request.state.session = None + + response = await call_next(request) + return response + + def _extract_session_id(self, request: Request) -> Optional[str]: + """Extract session ID from request.""" + # Try Authorization header first + auth_header = request.headers.get("authorization", "") + if auth_header.startswith("Bearer "): + return auth_header.split(" ")[1] + + # Try cookie + return request.cookies.get(self.session_cookie_name) + + +def session_middleware(app, session_cookie_name: str = "session_id"): + """Factory function to add session middleware to app.""" + return SessionMiddleware(app, session_cookie_name) + + +async def get_current_session(request: Request) -> Session: + """ + FastAPI dependency to get current session. + + Raises 401 if no valid session found. + + Usage: + @app.get("/api/protected/endpoint") + async def protected(session: Session = Depends(get_current_session)): + return {"user_id": session.user_id} + """ + # Check if middleware added session to state + session = getattr(request.state, "session", None) + if session: + return session + + # Middleware might not be installed, try manual extraction + session_id = _extract_session_id_from_request(request) + if not session_id: + # Check for development mode bypass + environment = os.environ.get("ENVIRONMENT", "development") + if environment == "development": + # Return demo session in development + return _get_demo_session() + raise HTTPException(status_code=401, detail="Authentication required") + + try: + store = await get_session_store() + session = await store.get_session(session_id) + + if not session: + raise HTTPException(status_code=401, detail="Invalid or expired session") + + return session + except HTTPException: + raise + except Exception as e: + logger.error(f"Session validation failed: {e}") + raise HTTPException(status_code=401, detail="Session validation failed") + + +async def get_optional_session(request: Request) -> Optional[Session]: + """ + FastAPI dependency to get current session if present. + + Returns None if no session (doesn't raise exception). + Useful for endpoints that behave differently for logged in users. + + Usage: + @app.get("/api/public/endpoint") + async def public(session: Optional[Session] = Depends(get_optional_session)): + if session: + return {"message": f"Hello, {session.email}"} + return {"message": "Hello, anonymous"} + """ + try: + return await get_current_session(request) + except HTTPException: + return None + + +def require_session(func: Callable) -> Callable: + """ + Decorator to require valid session for an endpoint. + + Alternative to using Depends(get_current_session). + + Usage: + @app.get("/api/protected/endpoint") + @require_session + async def protected(request: Request): + session = request.state.session + return {"user_id": session.user_id} + """ + @wraps(func) + async def wrapper(request: Request, *args, **kwargs): + session = await get_current_session(request) + request.state.session = session + return await func(request, *args, **kwargs) + return wrapper + + +def _extract_session_id_from_request(request: Request) -> Optional[str]: + """Extract session ID from request headers or cookies.""" + # Try Authorization header + auth_header = request.headers.get("authorization", "") + if auth_header.startswith("Bearer "): + return auth_header.split(" ")[1] + + # Try X-Session-ID header + session_header = request.headers.get("x-session-id") + if session_header: + return session_header + + # Try cookie + return request.cookies.get("session_id") + + +def _get_demo_session() -> Session: + """Get demo session for development mode.""" + from .session_store import UserType + + return Session( + session_id="demo-session-id", + user_id="10000000-0000-0000-0000-000000000024", + email="demo@breakpilot.app", + user_type=UserType.EMPLOYEE, + roles=["admin", "schul_admin", "teacher"], + permissions=[ + "grades:read", "grades:write", + "attendance:read", "attendance:write", + "students:read", "students:write", + "reports:generate", "consent:admin", + "own_data:read", "users:manage", + ], + tenant_id="a0000000-0000-0000-0000-000000000001", + ip_address="127.0.0.1", + user_agent="Development", + ) + + +class SessionAuthBackend: + """ + Authentication backend for Starlette AuthenticationMiddleware. + + Alternative way to integrate session auth. + """ + + async def authenticate(self, request: Request): + """Authenticate request using session.""" + from starlette.authentication import ( + AuthCredentials, BaseUser, UnauthenticatedUser + ) + + session_id = _extract_session_id_from_request(request) + if not session_id: + return AuthCredentials([]), UnauthenticatedUser() + + try: + store = await get_session_store() + session = await store.get_session(session_id) + + if not session: + return AuthCredentials([]), UnauthenticatedUser() + + return AuthCredentials(session.permissions), SessionUser(session) + except Exception: + return AuthCredentials([]), UnauthenticatedUser() + + +class SessionUser: + """User object compatible with Starlette authentication.""" + + def __init__(self, session: Session): + self.session = session + + @property + def is_authenticated(self) -> bool: + return True + + @property + def display_name(self) -> str: + return self.session.email + + @property + def identity(self) -> str: + return self.session.user_id diff --git a/backend/session/session_store.py b/backend/session/session_store.py new file mode 100644 index 0000000..f52d7bf --- /dev/null +++ b/backend/session/session_store.py @@ -0,0 +1,550 @@ +""" +Hybrid Session Store: Valkey + PostgreSQL + +Architecture: +- Valkey: Fast session cache with 24-hour TTL +- PostgreSQL: Persistent storage and DSGVO audit trail +- Graceful fallback: If Valkey is down, fall back to PostgreSQL + +Session data model: +{ + "session_id": "uuid", + "user_id": "uuid", + "email": "string", + "user_type": "employee|customer", + "roles": ["role1", "role2"], + "permissions": ["perm1", "perm2"], + "tenant_id": "school-uuid", + "ip_address": "string", + "user_agent": "string", + "created_at": "timestamp", + "last_activity_at": "timestamp" +} +""" + +import os +import json +import hashlib +import logging +from datetime import datetime, timezone, timedelta +from typing import Optional, Dict, Any, List +from dataclasses import dataclass, asdict, field +from enum import Enum +import asyncio + +logger = logging.getLogger(__name__) + + +class UserType(str, Enum): + """User type distinction for RBAC.""" + EMPLOYEE = "employee" # Internal staff (teachers, admins) + CUSTOMER = "customer" # External users (parents, students) + + +@dataclass +class Session: + """Session data model.""" + session_id: str + user_id: str + email: str + user_type: UserType + roles: List[str] = field(default_factory=list) + permissions: List[str] = field(default_factory=list) + tenant_id: Optional[str] = None + ip_address: Optional[str] = None + user_agent: Optional[str] = None + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + last_activity_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return { + "session_id": self.session_id, + "user_id": self.user_id, + "email": self.email, + "user_type": self.user_type.value if isinstance(self.user_type, UserType) else self.user_type, + "roles": self.roles, + "permissions": self.permissions, + "tenant_id": self.tenant_id, + "ip_address": self.ip_address, + "user_agent": self.user_agent, + "created_at": self.created_at.isoformat() if self.created_at else None, + "last_activity_at": self.last_activity_at.isoformat() if self.last_activity_at else None, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "Session": + """Create Session from dictionary.""" + user_type = data.get("user_type", "customer") + if isinstance(user_type, str): + user_type = UserType(user_type) + + created_at = data.get("created_at") + if isinstance(created_at, str): + created_at = datetime.fromisoformat(created_at.replace("Z", "+00:00")) + + last_activity_at = data.get("last_activity_at") + if isinstance(last_activity_at, str): + last_activity_at = datetime.fromisoformat(last_activity_at.replace("Z", "+00:00")) + + return cls( + session_id=data["session_id"], + user_id=data["user_id"], + email=data.get("email", ""), + user_type=user_type, + roles=data.get("roles", []), + permissions=data.get("permissions", []), + tenant_id=data.get("tenant_id"), + ip_address=data.get("ip_address"), + user_agent=data.get("user_agent"), + created_at=created_at or datetime.now(timezone.utc), + last_activity_at=last_activity_at or datetime.now(timezone.utc), + ) + + def has_permission(self, permission: str) -> bool: + """Check if session has a specific permission.""" + return permission in self.permissions + + def has_any_permission(self, permissions: List[str]) -> bool: + """Check if session has any of the specified permissions.""" + return any(p in self.permissions for p in permissions) + + def has_all_permissions(self, permissions: List[str]) -> bool: + """Check if session has all specified permissions.""" + return all(p in self.permissions for p in permissions) + + def has_role(self, role: str) -> bool: + """Check if session has a specific role.""" + return role in self.roles + + def is_employee(self) -> bool: + """Check if user is an employee (internal staff).""" + return self.user_type == UserType.EMPLOYEE + + def is_customer(self) -> bool: + """Check if user is a customer (external user).""" + return self.user_type == UserType.CUSTOMER + + +class SessionStore: + """ + Hybrid session store using Valkey and PostgreSQL. + + Valkey: Primary storage with 24h TTL for fast lookups + PostgreSQL: Persistent backup and audit trail + """ + + def __init__( + self, + valkey_url: Optional[str] = None, + database_url: Optional[str] = None, + session_ttl_hours: int = 24, + ): + self.valkey_url = valkey_url or os.environ.get("VALKEY_URL", "redis://localhost:6379") + self.database_url = database_url or os.environ.get("DATABASE_URL") + self.session_ttl = timedelta(hours=session_ttl_hours) + self.session_ttl_seconds = session_ttl_hours * 3600 + + self._valkey_client = None + self._pg_pool = None + self._valkey_available = True + + async def connect(self): + """Initialize connections to Valkey and PostgreSQL.""" + await self._connect_valkey() + await self._connect_postgres() + + async def _connect_valkey(self): + """Connect to Valkey (Redis-compatible).""" + try: + import redis.asyncio as redis + self._valkey_client = redis.from_url( + self.valkey_url, + encoding="utf-8", + decode_responses=True, + ) + # Test connection + await self._valkey_client.ping() + self._valkey_available = True + logger.info("Connected to Valkey session cache") + except ImportError: + logger.warning("redis package not installed, Valkey unavailable") + self._valkey_available = False + except Exception as e: + logger.warning(f"Valkey connection failed, falling back to PostgreSQL: {e}") + self._valkey_available = False + + async def _connect_postgres(self): + """Connect to PostgreSQL.""" + if not self.database_url: + logger.warning("DATABASE_URL not set, PostgreSQL unavailable") + return + + try: + import asyncpg + self._pg_pool = await asyncpg.create_pool( + self.database_url, + min_size=2, + max_size=10, + ) + logger.info("Connected to PostgreSQL session store") + except ImportError: + logger.warning("asyncpg package not installed") + except Exception as e: + logger.error(f"PostgreSQL connection failed: {e}") + + async def close(self): + """Close all connections.""" + if self._valkey_client: + await self._valkey_client.close() + if self._pg_pool: + await self._pg_pool.close() + + def _get_valkey_key(self, session_id: str) -> str: + """Generate Valkey key for session.""" + return f"session:{session_id}" + + def _hash_token(self, token: str) -> str: + """Hash token for PostgreSQL storage.""" + return hashlib.sha256(token.encode()).hexdigest() + + async def create_session( + self, + user_id: str, + email: str, + user_type: UserType, + roles: List[str], + permissions: List[str], + tenant_id: Optional[str] = None, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, + ) -> Session: + """ + Create a new session. + + Stores in both Valkey (with TTL) and PostgreSQL (persistent). + Returns the session with generated session_id. + """ + import uuid + + session = Session( + session_id=str(uuid.uuid4()), + user_id=user_id, + email=email, + user_type=user_type, + roles=roles, + permissions=permissions, + tenant_id=tenant_id, + ip_address=ip_address, + user_agent=user_agent, + ) + + # Store in Valkey (primary) + if self._valkey_available and self._valkey_client: + try: + key = self._get_valkey_key(session.session_id) + await self._valkey_client.setex( + key, + self.session_ttl_seconds, + json.dumps(session.to_dict()), + ) + except Exception as e: + logger.error(f"Failed to store session in Valkey: {e}") + self._valkey_available = False + + # Store in PostgreSQL (backup + audit) + if self._pg_pool: + try: + async with self._pg_pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO user_sessions ( + id, user_id, token_hash, email, user_type, roles, + permissions, tenant_id, ip_address, user_agent, + expires_at, created_at, last_activity_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + """, + session.session_id, + session.user_id, + self._hash_token(session.session_id), + session.email, + session.user_type.value, + json.dumps(session.roles), + json.dumps(session.permissions), + session.tenant_id, + session.ip_address, + session.user_agent, + datetime.now(timezone.utc) + self.session_ttl, + session.created_at, + session.last_activity_at, + ) + except Exception as e: + logger.error(f"Failed to store session in PostgreSQL: {e}") + + return session + + async def get_session(self, session_id: str) -> Optional[Session]: + """ + Get session by ID. + + Tries Valkey first (fast), falls back to PostgreSQL. + """ + # Try Valkey first + if self._valkey_available and self._valkey_client: + try: + key = self._get_valkey_key(session_id) + data = await self._valkey_client.get(key) + if data: + session = Session.from_dict(json.loads(data)) + # Update last activity + await self._update_last_activity(session_id) + return session + except Exception as e: + logger.warning(f"Valkey lookup failed, trying PostgreSQL: {e}") + self._valkey_available = False + + # Fall back to PostgreSQL + if self._pg_pool: + try: + async with self._pg_pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT id, user_id, email, user_type, roles, permissions, + tenant_id, ip_address, user_agent, created_at, last_activity_at + FROM user_sessions + WHERE id = $1 + AND revoked_at IS NULL + AND expires_at > NOW() + """, + session_id, + ) + if row: + session = Session( + session_id=str(row["id"]), + user_id=str(row["user_id"]), + email=row["email"] or "", + user_type=UserType(row["user_type"]) if row["user_type"] else UserType.CUSTOMER, + roles=json.loads(row["roles"]) if row["roles"] else [], + permissions=json.loads(row["permissions"]) if row["permissions"] else [], + tenant_id=str(row["tenant_id"]) if row["tenant_id"] else None, + ip_address=row["ip_address"], + user_agent=row["user_agent"], + created_at=row["created_at"], + last_activity_at=row["last_activity_at"], + ) + + # Re-cache in Valkey if it's back up + await self._cache_in_valkey(session) + + return session + except Exception as e: + logger.error(f"PostgreSQL session lookup failed: {e}") + + return None + + async def _update_last_activity(self, session_id: str): + """Update last activity timestamp.""" + now = datetime.now(timezone.utc) + + # Update Valkey TTL + if self._valkey_available and self._valkey_client: + try: + key = self._get_valkey_key(session_id) + # Refresh TTL + await self._valkey_client.expire(key, self.session_ttl_seconds) + except Exception: + pass + + # Update PostgreSQL + if self._pg_pool: + try: + async with self._pg_pool.acquire() as conn: + await conn.execute( + """ + UPDATE user_sessions + SET last_activity_at = $1, expires_at = $2 + WHERE id = $3 + """, + now, + now + self.session_ttl, + session_id, + ) + except Exception: + pass + + async def _cache_in_valkey(self, session: Session): + """Re-cache session in Valkey after PostgreSQL fallback.""" + if self._valkey_available and self._valkey_client: + try: + key = self._get_valkey_key(session.session_id) + await self._valkey_client.setex( + key, + self.session_ttl_seconds, + json.dumps(session.to_dict()), + ) + except Exception: + pass + + async def revoke_session(self, session_id: str) -> bool: + """ + Revoke a session (logout). + + Removes from Valkey and marks as revoked in PostgreSQL. + """ + success = False + + # Remove from Valkey + if self._valkey_available and self._valkey_client: + try: + key = self._get_valkey_key(session_id) + await self._valkey_client.delete(key) + success = True + except Exception as e: + logger.error(f"Failed to revoke session in Valkey: {e}") + + # Mark as revoked in PostgreSQL + if self._pg_pool: + try: + async with self._pg_pool.acquire() as conn: + await conn.execute( + """ + UPDATE user_sessions + SET revoked_at = NOW() + WHERE id = $1 + """, + session_id, + ) + success = True + except Exception as e: + logger.error(f"Failed to revoke session in PostgreSQL: {e}") + + return success + + async def revoke_all_user_sessions(self, user_id: str) -> int: + """ + Revoke all sessions for a user (force logout from all devices). + + Returns the number of sessions revoked. + """ + count = 0 + + # Get all session IDs for user from PostgreSQL + if self._pg_pool: + try: + async with self._pg_pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT id FROM user_sessions + WHERE user_id = $1 + AND revoked_at IS NULL + AND expires_at > NOW() + """, + user_id, + ) + + session_ids = [str(row["id"]) for row in rows] + + # Revoke in PostgreSQL + result = await conn.execute( + """ + UPDATE user_sessions + SET revoked_at = NOW() + WHERE user_id = $1 + AND revoked_at IS NULL + """, + user_id, + ) + count = int(result.split()[-1]) if result else 0 + + # Remove from Valkey + if self._valkey_available and self._valkey_client: + for session_id in session_ids: + try: + key = self._get_valkey_key(session_id) + await self._valkey_client.delete(key) + except Exception: + pass + except Exception as e: + logger.error(f"Failed to revoke all user sessions: {e}") + + return count + + async def get_active_sessions(self, user_id: str) -> List[Session]: + """Get all active sessions for a user.""" + sessions = [] + + if self._pg_pool: + try: + async with self._pg_pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT id, user_id, email, user_type, roles, permissions, + tenant_id, ip_address, user_agent, created_at, last_activity_at + FROM user_sessions + WHERE user_id = $1 + AND revoked_at IS NULL + AND expires_at > NOW() + ORDER BY last_activity_at DESC + """, + user_id, + ) + + for row in rows: + sessions.append(Session( + session_id=str(row["id"]), + user_id=str(row["user_id"]), + email=row["email"] or "", + user_type=UserType(row["user_type"]) if row["user_type"] else UserType.CUSTOMER, + roles=json.loads(row["roles"]) if row["roles"] else [], + permissions=json.loads(row["permissions"]) if row["permissions"] else [], + tenant_id=str(row["tenant_id"]) if row["tenant_id"] else None, + ip_address=row["ip_address"], + user_agent=row["user_agent"], + created_at=row["created_at"], + last_activity_at=row["last_activity_at"], + )) + except Exception as e: + logger.error(f"Failed to get active sessions: {e}") + + return sessions + + async def cleanup_expired_sessions(self) -> int: + """ + Clean up expired sessions from PostgreSQL. + + This is meant to be called by a background job. + Returns the number of sessions cleaned up. + """ + count = 0 + + if self._pg_pool: + try: + async with self._pg_pool.acquire() as conn: + result = await conn.execute( + """ + DELETE FROM user_sessions + WHERE expires_at < NOW() - INTERVAL '7 days' + """ + ) + count = int(result.split()[-1]) if result else 0 + logger.info(f"Cleaned up {count} expired sessions") + except Exception as e: + logger.error(f"Session cleanup failed: {e}") + + return count + + +# Global session store instance +_session_store: Optional[SessionStore] = None + + +async def get_session_store() -> SessionStore: + """Get or create the global session store instance.""" + global _session_store + + if _session_store is None: + ttl_hours = int(os.environ.get("SESSION_TTL_HOURS", "24")) + _session_store = SessionStore(session_ttl_hours=ttl_hours) + await _session_store.connect() + + return _session_store diff --git a/backend/state_engine/__init__.py b/backend/state_engine/__init__.py new file mode 100644 index 0000000..bdd7732 --- /dev/null +++ b/backend/state_engine/__init__.py @@ -0,0 +1,43 @@ +""" +State Engine - Herzstück des BreakPilot Begleiter-Modus. + +Komponenten: +1. Schuljahres-State-Machine (Phasen) +2. Antizipations-Engine (Regeln + Vorschläge) +3. TeacherContext (Aggregierter Kontext) +""" + +from .models import ( + SchoolYearPhase, + PhaseInfo, + TeacherContext, + ClassSummary, + Event, + TeacherStats, + Milestone, + PHASE_INFO, + get_phase_info +) +from .rules import Rule, Suggestion, SuggestionPriority, RULES +from .engine import AnticipationEngine, PhaseService + +__all__ = [ + # Models + "SchoolYearPhase", + "PhaseInfo", + "TeacherContext", + "ClassSummary", + "Event", + "TeacherStats", + "Milestone", + "PHASE_INFO", + "get_phase_info", + # Rules + "Rule", + "Suggestion", + "SuggestionPriority", + "RULES", + # Engine + "AnticipationEngine", + "PhaseService", +] diff --git a/backend/state_engine/engine.py b/backend/state_engine/engine.py new file mode 100644 index 0000000..2c35d5b --- /dev/null +++ b/backend/state_engine/engine.py @@ -0,0 +1,367 @@ +""" +State Engine - Antizipations-Engine und Phasen-Service. + +Komponenten: +- AnticipationEngine: Evaluiert Regeln und generiert Vorschläge +- PhaseService: Verwaltet Phasen-Übergänge +""" + +import logging +from datetime import datetime +from typing import List, Dict, Any, Optional, Callable +from dataclasses import dataclass + +from .models import ( + SchoolYearPhase, + TeacherContext, + PhaseInfo, + get_phase_info, + PHASE_INFO +) +from .rules import Rule, Suggestion, SuggestionPriority, RULES + +logger = logging.getLogger(__name__) + + +# ============================================================================ +# Phasen-Übergänge +# ============================================================================ + +@dataclass +class PhaseTransition: + """Definition eines erlaubten Phasen-Übergangs.""" + from_phase: SchoolYearPhase + to_phase: SchoolYearPhase + condition: Callable[[TeacherContext], bool] + auto_trigger: bool = False # Automatisch wenn Condition erfüllt + + +# Vordefinierte Übergänge +VALID_TRANSITIONS: List[PhaseTransition] = [ + # Onboarding → SchoolYearStart + PhaseTransition( + from_phase=SchoolYearPhase.ONBOARDING, + to_phase=SchoolYearPhase.SCHOOL_YEAR_START, + condition=lambda ctx: ( + ctx.has_completed_milestone("school_select") and + ctx.has_completed_milestone("consent_accept") and + ctx.has_completed_milestone("profile_complete") + ), + auto_trigger=True, + ), + + # SchoolYearStart → TeachingSetup + PhaseTransition( + from_phase=SchoolYearPhase.SCHOOL_YEAR_START, + to_phase=SchoolYearPhase.TEACHING_SETUP, + condition=lambda ctx: ( + len(ctx.classes) > 0 and + ctx.has_completed_milestone("add_students") + ), + auto_trigger=True, + ), + + # TeachingSetup → Performance1 + PhaseTransition( + from_phase=SchoolYearPhase.TEACHING_SETUP, + to_phase=SchoolYearPhase.PERFORMANCE_1, + condition=lambda ctx: ( + ctx.weeks_since_start >= 6 and + ctx.has_learning_units() + ), + auto_trigger=False, # Manueller Übergang + ), + + # Performance1 → SemesterEnd + PhaseTransition( + from_phase=SchoolYearPhase.PERFORMANCE_1, + to_phase=SchoolYearPhase.SEMESTER_END, + condition=lambda ctx: ( + (ctx.is_in_month(1) or ctx.is_in_month(2)) and + ctx.has_completed_milestone("enter_grades") + ), + auto_trigger=True, + ), + + # SemesterEnd → Teaching2 + PhaseTransition( + from_phase=SchoolYearPhase.SEMESTER_END, + to_phase=SchoolYearPhase.TEACHING_2, + condition=lambda ctx: ( + ctx.has_completed_milestone("generate_certificates") + ), + auto_trigger=True, + ), + + # Teaching2 → Performance2 + PhaseTransition( + from_phase=SchoolYearPhase.TEACHING_2, + to_phase=SchoolYearPhase.PERFORMANCE_2, + condition=lambda ctx: ( + ctx.weeks_since_start >= 30 or + ctx.is_in_month(4) or ctx.is_in_month(5) + ), + auto_trigger=False, + ), + + # Performance2 → YearEnd + PhaseTransition( + from_phase=SchoolYearPhase.PERFORMANCE_2, + to_phase=SchoolYearPhase.YEAR_END, + condition=lambda ctx: ( + (ctx.is_in_month(6) or ctx.is_in_month(7)) and + ctx.has_completed_milestone("enter_final_grades") + ), + auto_trigger=True, + ), + + # YearEnd → Archived + PhaseTransition( + from_phase=SchoolYearPhase.YEAR_END, + to_phase=SchoolYearPhase.ARCHIVED, + condition=lambda ctx: ( + ctx.has_completed_milestone("generate_final_certificates") and + ctx.has_completed_milestone("archive_year") + ), + auto_trigger=True, + ), +] + + +class PhaseService: + """ + Verwaltet Schuljahres-Phasen und deren Übergänge. + + Funktionen: + - Phasen-Status abrufen + - Automatische Übergänge prüfen + - Manuelle Übergänge durchführen + """ + + def __init__(self, transitions: List[PhaseTransition] = None): + self.transitions = transitions or VALID_TRANSITIONS + + def get_current_phase_info(self, phase: SchoolYearPhase) -> PhaseInfo: + """Gibt Metadaten zur aktuellen Phase zurück.""" + return get_phase_info(phase) + + def get_all_phases(self) -> List[Dict[str, Any]]: + """Gibt alle Phasen mit Metadaten zurück.""" + return [ + { + "phase": info.phase.value, + "display_name": info.display_name, + "description": info.description, + "typical_months": info.typical_months, + } + for info in PHASE_INFO.values() + ] + + def check_and_transition(self, ctx: TeacherContext) -> Optional[SchoolYearPhase]: + """ + Prüft ob ein automatischer Phasen-Übergang möglich ist. + + Args: + ctx: Aktueller TeacherContext + + Returns: + Neue Phase wenn Übergang stattfand, sonst None + """ + current_phase = ctx.current_phase + + for transition in self.transitions: + if (transition.from_phase == current_phase and + transition.auto_trigger and + transition.condition(ctx)): + + logger.info( + f"Auto-transition from {current_phase} to {transition.to_phase} " + f"for teacher {ctx.teacher_id}" + ) + return transition.to_phase + + return None + + def can_transition_to(self, ctx: TeacherContext, target_phase: SchoolYearPhase) -> bool: + """ + Prüft ob ein Übergang zu einer bestimmten Phase möglich ist. + + Args: + ctx: Aktueller TeacherContext + target_phase: Zielphase + + Returns: + True wenn Übergang erlaubt + """ + for transition in self.transitions: + if (transition.from_phase == ctx.current_phase and + transition.to_phase == target_phase): + return transition.condition(ctx) + return False + + def get_next_phase(self, current: SchoolYearPhase) -> Optional[SchoolYearPhase]: + """Gibt die nächste Phase in der Sequenz zurück.""" + phase_order = [ + SchoolYearPhase.ONBOARDING, + SchoolYearPhase.SCHOOL_YEAR_START, + SchoolYearPhase.TEACHING_SETUP, + SchoolYearPhase.PERFORMANCE_1, + SchoolYearPhase.SEMESTER_END, + SchoolYearPhase.TEACHING_2, + SchoolYearPhase.PERFORMANCE_2, + SchoolYearPhase.YEAR_END, + SchoolYearPhase.ARCHIVED, + ] + + try: + idx = phase_order.index(current) + if idx < len(phase_order) - 1: + return phase_order[idx + 1] + except ValueError: + pass + + return None + + def get_progress_percentage(self, ctx: TeacherContext) -> float: + """ + Berechnet Fortschritt in der aktuellen Phase. + + Returns: + Prozent (0-100) + """ + phase_info = get_phase_info(ctx.current_phase) + required = set(phase_info.required_actions) + completed = set(ctx.completed_milestones) + + if not required: + return 100.0 + + done = len(required.intersection(completed)) + return (done / len(required)) * 100 + + +class AnticipationEngine: + """ + Evaluiert Antizipations-Regeln und generiert priorisierte Vorschläge. + + Die Engine: + - Wendet alle aktiven Regeln auf den TeacherContext an + - Priorisiert Vorschläge nach Dringlichkeit + - Limitiert auf max. 5 Vorschläge + """ + + def __init__(self, rules: List[Rule] = None, max_suggestions: int = 5): + self.rules = rules or RULES + self.max_suggestions = max_suggestions + + def get_suggestions(self, ctx: TeacherContext) -> List[Suggestion]: + """ + Evaluiert alle Regeln und gibt priorisierte Vorschläge zurück. + + Args: + ctx: TeacherContext mit allen relevanten Informationen + + Returns: + Liste von max. 5 Vorschlägen, sortiert nach Priorität + """ + suggestions = [] + + for rule in self.rules: + try: + suggestion = rule.evaluate(ctx) + if suggestion: + suggestions.append(suggestion) + except Exception as e: + logger.warning(f"Rule {rule.id} evaluation failed: {e}") + + return self._prioritize(suggestions) + + def _prioritize(self, suggestions: List[Suggestion]) -> List[Suggestion]: + """ + Sortiert Vorschläge nach Priorität und limitiert. + + Reihenfolge: + 1. URGENT (1) + 2. HIGH (2) + 3. MEDIUM (3) + 4. LOW (4) + """ + sorted_suggestions = sorted( + suggestions, + key=lambda s: s.priority.value + ) + return sorted_suggestions[:self.max_suggestions] + + def get_top_suggestion(self, ctx: TeacherContext) -> Optional[Suggestion]: + """ + Gibt den wichtigsten Vorschlag zurück. + + Args: + ctx: TeacherContext + + Returns: + Wichtigster Vorschlag oder None + """ + suggestions = self.get_suggestions(ctx) + return suggestions[0] if suggestions else None + + def get_suggestions_by_category( + self, + ctx: TeacherContext + ) -> Dict[str, List[Suggestion]]: + """ + Gruppiert Vorschläge nach Kategorie. + + Returns: + Dict mit Kategorien als Keys und Listen von Vorschlägen + """ + suggestions = self.get_suggestions(ctx) + by_category: Dict[str, List[Suggestion]] = {} + + for suggestion in suggestions: + if suggestion.category not in by_category: + by_category[suggestion.category] = [] + by_category[suggestion.category].append(suggestion) + + return by_category + + def count_by_priority(self, ctx: TeacherContext) -> Dict[str, int]: + """ + Zählt Vorschläge nach Priorität. + + Returns: + Dict mit Prioritäten und Anzahlen + """ + suggestions = self.get_suggestions(ctx) + counts = { + "urgent": 0, + "high": 0, + "medium": 0, + "low": 0, + } + + for s in suggestions: + if s.priority == SuggestionPriority.URGENT: + counts["urgent"] += 1 + elif s.priority == SuggestionPriority.HIGH: + counts["high"] += 1 + elif s.priority == SuggestionPriority.MEDIUM: + counts["medium"] += 1 + else: + counts["low"] += 1 + + return counts + + +# ============================================================================ +# Factory Functions +# ============================================================================ + +def create_anticipation_engine(custom_rules: List[Rule] = None) -> AnticipationEngine: + """Erstellt eine neue AnticipationEngine.""" + return AnticipationEngine(rules=custom_rules) + + +def create_phase_service(custom_transitions: List[PhaseTransition] = None) -> PhaseService: + """Erstellt einen neuen PhaseService.""" + return PhaseService(transitions=custom_transitions) diff --git a/backend/state_engine/models.py b/backend/state_engine/models.py new file mode 100644 index 0000000..a337c1d --- /dev/null +++ b/backend/state_engine/models.py @@ -0,0 +1,317 @@ +""" +State Engine Models - Datenstrukturen für das Phasen-Management. + +Definiert: +- SchoolYearPhase: Die 9 Phasen des Schuljahres +- TeacherContext: Aggregierter Kontext für Antizipation +- Event, Milestone, Stats: Unterstützende Modelle +""" + +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from typing import List, Optional, Dict, Any +import uuid + + +class SchoolYearPhase(str, Enum): + """Die 9 Phasen eines Schuljahres.""" + + # Phase 1: Schuljahresbeginn (Aug/Sep) + ONBOARDING = "onboarding" + # Neue Lehrer, Schulsuche, Grundkonfiguration + + # Phase 2: Schuljahresstart (Sep/Okt) + SCHOOL_YEAR_START = "school_year_start" + # Klassen anlegen, Stundenplan, erste Einheiten + + # Phase 3: Unterrichtsaufbau (Okt/Nov) + TEACHING_SETUP = "teaching_setup" + # Lerneinheiten, Materialien, Elternkommunikation + + # Phase 4: Leistungsphase 1 (Nov/Dez) + PERFORMANCE_1 = "performance_1" + # Klausuren, Korrektur, erste Noten + + # Phase 5: Halbjahresabschluss (Jan/Feb) + SEMESTER_END = "semester_end" + # Halbjahreszeugnisse, Konferenzen, Elterngespräche + + # Phase 6: 2. Halbjahr Unterricht (Feb/Apr) + TEACHING_2 = "teaching_2" + # Wiederholung von Phase 3 + + # Phase 7: Leistungsphase 2 (Apr/Jun) + PERFORMANCE_2 = "performance_2" + # Klausuren, Korrektur, finale Noten + + # Phase 8: Jahresabschluss (Jun/Jul) + YEAR_END = "year_end" + # Abschlusszeugnisse, Versetzung, Archivierung + + # Phase 9: Archiviert + ARCHIVED = "archived" + # Schuljahr abgeschlossen + + +@dataclass +class PhaseInfo: + """Metadaten zu einer Phase.""" + phase: SchoolYearPhase + display_name: str + description: str + typical_months: List[int] # 1-12 + expected_duration_weeks: int + required_actions: List[str] + optional_actions: List[str] + + +# Phasen-Definitionen mit Metadaten +PHASE_INFO: Dict[SchoolYearPhase, PhaseInfo] = { + SchoolYearPhase.ONBOARDING: PhaseInfo( + phase=SchoolYearPhase.ONBOARDING, + display_name="Onboarding", + description="Willkommen bei BreakPilot! Richte dein Schuljahr ein.", + typical_months=[8, 9], + expected_duration_weeks=2, + required_actions=["school_select", "consent_accept", "profile_complete"], + optional_actions=["import_previous_year"], + ), + SchoolYearPhase.SCHOOL_YEAR_START: PhaseInfo( + phase=SchoolYearPhase.SCHOOL_YEAR_START, + display_name="Schuljahresstart", + description="Lege deine Klassen und den Stundenplan an.", + typical_months=[9, 10], + expected_duration_weeks=3, + required_actions=["create_classes", "add_students", "create_timetable"], + optional_actions=["import_students_csv", "invite_parents"], + ), + SchoolYearPhase.TEACHING_SETUP: PhaseInfo( + phase=SchoolYearPhase.TEACHING_SETUP, + display_name="Unterrichtsaufbau", + description="Erstelle Lerneinheiten und Materialien.", + typical_months=[10, 11], + expected_duration_weeks=4, + required_actions=["create_learning_units"], + optional_actions=["generate_worksheets", "prepare_parent_meeting"], + ), + SchoolYearPhase.PERFORMANCE_1: PhaseInfo( + phase=SchoolYearPhase.PERFORMANCE_1, + display_name="Leistungsphase 1", + description="Erste Klausuren und Bewertungen.", + typical_months=[11, 12], + expected_duration_weeks=6, + required_actions=["schedule_exams", "enter_grades"], + optional_actions=["use_correction_module", "generate_feedback"], + ), + SchoolYearPhase.SEMESTER_END: PhaseInfo( + phase=SchoolYearPhase.SEMESTER_END, + display_name="Halbjahresabschluss", + description="Halbjahreszeugnisse und Konferenzen.", + typical_months=[1, 2], + expected_duration_weeks=3, + required_actions=["complete_grades", "generate_certificates"], + optional_actions=["parent_conferences", "archive_semester"], + ), + SchoolYearPhase.TEACHING_2: PhaseInfo( + phase=SchoolYearPhase.TEACHING_2, + display_name="2. Halbjahr", + description="Weiterführender Unterricht im 2. Halbjahr.", + typical_months=[2, 3, 4], + expected_duration_weeks=8, + required_actions=["update_learning_units"], + optional_actions=["generate_worksheets"], + ), + SchoolYearPhase.PERFORMANCE_2: PhaseInfo( + phase=SchoolYearPhase.PERFORMANCE_2, + display_name="Leistungsphase 2", + description="Finale Klausuren und Bewertungen.", + typical_months=[4, 5, 6], + expected_duration_weeks=8, + required_actions=["schedule_exams", "enter_final_grades"], + optional_actions=["use_correction_module"], + ), + SchoolYearPhase.YEAR_END: PhaseInfo( + phase=SchoolYearPhase.YEAR_END, + display_name="Jahresabschluss", + description="Abschlusszeugnisse und Versetzung.", + typical_months=[6, 7], + expected_duration_weeks=3, + required_actions=["complete_all_grades", "generate_final_certificates"], + optional_actions=["archive_year", "export_data"], + ), + SchoolYearPhase.ARCHIVED: PhaseInfo( + phase=SchoolYearPhase.ARCHIVED, + display_name="Archiviert", + description="Das Schuljahr ist abgeschlossen.", + typical_months=[7, 8], + expected_duration_weeks=0, + required_actions=[], + optional_actions=["view_archive"], + ), +} + + +def get_phase_info(phase: SchoolYearPhase) -> PhaseInfo: + """Gibt Metadaten für eine Phase zurück.""" + return PHASE_INFO.get(phase, PHASE_INFO[SchoolYearPhase.ONBOARDING]) + + +@dataclass +class ClassSummary: + """Zusammenfassung einer Klasse.""" + class_id: str + name: str + grade_level: int + student_count: int + subject: str + + +@dataclass +class Event: + """Ein anstehendes Ereignis.""" + type: str # "exam", "parent_meeting", "deadline" + title: str + date: datetime + in_days: int + class_id: Optional[str] = None + priority: str = "medium" # "high", "medium", "low" + + +@dataclass +class Milestone: + """Ein erreichter Meilenstein.""" + milestone: str + completed_at: datetime + + +@dataclass +class TeacherStats: + """Statistiken eines Lehrers.""" + learning_units_created: int = 0 + exams_scheduled: int = 0 + exams_graded: int = 0 + grades_entered: int = 0 + parent_messages_count: int = 0 + avg_response_time_hours: float = 0.0 + unanswered_messages: int = 0 + + +@dataclass +class TeacherContext: + """ + Aggregierter Kontext für einen Lehrer. + + Enthält alle relevanten Informationen für die Antizipations-Engine: + - Identifikation + - Schulkontext + - Zeitlicher Kontext + - Klassen und Schüler + - Termine und Events + - Fortschritt + - Statistiken + """ + # Identifikation + teacher_id: str + school_id: str + school_year_id: str + + # Schulkontext + federal_state: str = "niedersachsen" # Bundesland + school_type: str = "gymnasium" # Schulform + + # Zeitlicher Kontext + school_year_start: datetime = field(default_factory=datetime.now) + current_phase: SchoolYearPhase = SchoolYearPhase.ONBOARDING + phase_entered_at: datetime = field(default_factory=datetime.now) + weeks_since_start: int = 0 + days_in_phase: int = 0 + + # Klassen und Schüler + classes: List[ClassSummary] = field(default_factory=list) + total_students: int = 0 + + # Termine und Events + upcoming_events: List[Event] = field(default_factory=list) + overdue_actions: List[Dict[str, Any]] = field(default_factory=list) + + # Fortschritt + completed_milestones: List[str] = field(default_factory=list) + pending_milestones: List[str] = field(default_factory=list) + + # Statistiken + stats: TeacherStats = field(default_factory=TeacherStats) + + def has_completed_milestone(self, milestone: str) -> bool: + """Prüft ob ein Meilenstein erreicht wurde.""" + return milestone in self.completed_milestones + + def has_learning_units(self) -> bool: + """Prüft ob Lerneinheiten erstellt wurden.""" + return self.stats.learning_units_created > 0 + + def is_in_month(self, month: int) -> bool: + """Prüft ob aktueller Monat übereinstimmt.""" + return datetime.now().month == month + + def get_next_deadline(self) -> Optional[Event]: + """Gibt die nächste Deadline zurück.""" + for e in self.upcoming_events: + if e.type == "deadline": + return e + return None + + def get_next_exam(self) -> Optional[Event]: + """Gibt die nächste Klausur zurück.""" + for e in self.upcoming_events: + if e.type == "exam" and e.in_days > 0: + return e + return None + + def to_dict(self) -> Dict[str, Any]: + """Konvertiert zu Dictionary.""" + return { + "teacher_id": self.teacher_id, + "school_id": self.school_id, + "school_year_id": self.school_year_id, + "federal_state": self.federal_state, + "school_type": self.school_type, + "school_year_start": self.school_year_start.isoformat(), + "current_phase": self.current_phase.value, + "phase_entered_at": self.phase_entered_at.isoformat(), + "weeks_since_start": self.weeks_since_start, + "days_in_phase": self.days_in_phase, + "classes": [ + { + "class_id": c.class_id, + "name": c.name, + "grade_level": c.grade_level, + "student_count": c.student_count, + "subject": c.subject, + } + for c in self.classes + ], + "total_students": self.total_students, + "upcoming_events": [ + { + "type": e.type, + "title": e.title, + "date": e.date.isoformat(), + "in_days": e.in_days, + "class_id": e.class_id, + "priority": e.priority, + } + for e in self.upcoming_events + ], + "completed_milestones": self.completed_milestones, + "pending_milestones": self.pending_milestones, + "stats": { + "learning_units_created": self.stats.learning_units_created, + "exams_scheduled": self.stats.exams_scheduled, + "exams_graded": self.stats.exams_graded, + "grades_entered": self.stats.grades_entered, + "parent_messages_count": self.stats.parent_messages_count, + "avg_response_time_hours": self.stats.avg_response_time_hours, + "unanswered_messages": self.stats.unanswered_messages, + }, + } diff --git a/backend/state_engine/rules.py b/backend/state_engine/rules.py new file mode 100644 index 0000000..76d7233 --- /dev/null +++ b/backend/state_engine/rules.py @@ -0,0 +1,484 @@ +""" +State Engine Rules - Antizipations-Regeln für proaktive Vorschläge. + +Definiert: +- Suggestion: Ein Vorschlag mit Priorität und Aktion +- Rule: Eine Regel die auf TeacherContext angewendet wird +- RULES: Vordefinierte Regeln (15+) +""" + +from dataclasses import dataclass +from enum import Enum +from typing import Callable, List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from .models import TeacherContext + + +class SuggestionPriority(Enum): + """Priorität eines Vorschlags.""" + URGENT = 1 # Sofort erforderlich + HIGH = 2 # Heute/Diese Woche + MEDIUM = 3 # Diese Woche/Bald + LOW = 4 # Irgendwann + + +@dataclass +class Suggestion: + """Ein Vorschlag für den Lehrer.""" + id: str + title: str + description: str + action_type: str # "navigate", "create", "remind" + action_target: str # URL oder Action-ID + priority: SuggestionPriority + category: str # "classes", "grades", "communication", etc. + icon: str # Material Icon Name + estimated_time: int # Minuten + + def to_dict(self): + return { + "id": self.id, + "title": self.title, + "description": self.description, + "action_type": self.action_type, + "action_target": self.action_target, + "priority": self.priority.name, + "priority_value": self.priority.value, + "category": self.category, + "icon": self.icon, + "estimated_time": self.estimated_time, + } + + +@dataclass +class Rule: + """Eine Antizipations-Regel.""" + id: str + name: str + condition: Callable[['TeacherContext'], bool] + suggestion_generator: Callable[['TeacherContext'], Suggestion] + applies_to_phases: List[str] # Leere Liste = alle Phasen + + def evaluate(self, ctx: 'TeacherContext') -> Optional[Suggestion]: + """ + Evaluiert die Regel gegen den Kontext. + + Returns: + Suggestion wenn Regel zutrifft, sonst None + """ + # Prüfe Phasen-Einschränkung + if self.applies_to_phases: + if ctx.current_phase.value not in self.applies_to_phases: + return None + + # Prüfe Condition + try: + if not self.condition(ctx): + return None + except Exception: + return None + + return self.suggestion_generator(ctx) + + +# ============================================================================ +# Helper Functions für Regeln +# ============================================================================ + +def _get_parent_meeting_days(ctx: 'TeacherContext') -> int: + """Gibt Tage bis zum nächsten Elternabend zurück.""" + for e in ctx.upcoming_events: + if e.type == "parent_meeting": + return e.in_days + return 999 + + +def _get_next_exam_days(ctx: 'TeacherContext') -> int: + """Gibt Tage bis zur nächsten Klausur zurück.""" + for e in ctx.upcoming_events: + if e.type == "exam" and e.in_days > 0: + return e.in_days + return 999 + + +def _get_uncorrected_exams(ctx: 'TeacherContext') -> int: + """Gibt Anzahl unkorrigierter Klausuren zurück.""" + return ctx.stats.exams_scheduled - ctx.stats.exams_graded + + +# ============================================================================ +# Vordefinierte Regeln (15+) +# ============================================================================ + +RULES: List[Rule] = [ + # ═══════════════════════════════════════════════════════════════════════ + # ONBOARDING / SCHULJAHRESSTART + # ═══════════════════════════════════════════════════════════════════════ + + Rule( + id="no_classes", + name="Keine Klassen angelegt", + condition=lambda ctx: len(ctx.classes) == 0, + suggestion_generator=lambda ctx: Suggestion( + id="create_first_class", + title="Erste Klasse anlegen", + description="Lege deine erste Klasse an, um loszulegen.", + action_type="navigate", + action_target="/studio/school", + priority=SuggestionPriority.URGENT, + category="classes", + icon="group_add", + estimated_time=5, + ), + applies_to_phases=["onboarding", "school_year_start"], + ), + + Rule( + id="no_students", + name="Klassen ohne Schüler", + condition=lambda ctx: len(ctx.classes) > 0 and ctx.total_students == 0, + suggestion_generator=lambda ctx: Suggestion( + id="add_students", + title="Schüler hinzufügen", + description=f"Deine {len(ctx.classes)} Klasse(n) haben noch keine Schüler.", + action_type="navigate", + action_target="/studio/school", + priority=SuggestionPriority.HIGH, + category="classes", + icon="person_add", + estimated_time=10, + ), + applies_to_phases=["school_year_start"], + ), + + Rule( + id="consent_missing", + name="Einwilligung ausstehend", + condition=lambda ctx: not ctx.has_completed_milestone("consent_accept"), + suggestion_generator=lambda ctx: Suggestion( + id="accept_consent", + title="Datenschutz-Einwilligung", + description="Bitte akzeptiere die Datenschutzbestimmungen.", + action_type="navigate", + action_target="/studio/consent", + priority=SuggestionPriority.URGENT, + category="settings", + icon="security", + estimated_time=2, + ), + applies_to_phases=["onboarding"], + ), + + Rule( + id="profile_incomplete", + name="Profil unvollständig", + condition=lambda ctx: not ctx.has_completed_milestone("profile_complete"), + suggestion_generator=lambda ctx: Suggestion( + id="complete_profile", + title="Profil vervollständigen", + description="Vervollständige dein Profil für bessere Personalisierung.", + action_type="navigate", + action_target="/studio/profile", + priority=SuggestionPriority.HIGH, + category="settings", + icon="account_circle", + estimated_time=5, + ), + applies_to_phases=["onboarding", "school_year_start"], + ), + + # ═══════════════════════════════════════════════════════════════════════ + # UNTERRICHT + # ═══════════════════════════════════════════════════════════════════════ + + Rule( + id="no_learning_units", + name="Keine Lerneinheiten", + condition=lambda ctx: ( + ctx.current_phase.value in ["teaching_setup", "teaching_2"] and + ctx.stats.learning_units_created == 0 + ), + suggestion_generator=lambda ctx: Suggestion( + id="create_learning_unit", + title="Erste Lerneinheit erstellen", + description="Erstelle Lerneinheiten für deine Klassen.", + action_type="navigate", + action_target="/studio/worksheets", + priority=SuggestionPriority.HIGH, + category="teaching", + icon="auto_stories", + estimated_time=15, + ), + applies_to_phases=["teaching_setup", "teaching_2"], + ), + + Rule( + id="few_learning_units", + name="Wenige Lerneinheiten", + condition=lambda ctx: ( + ctx.current_phase.value in ["teaching_setup", "teaching_2"] and + 0 < ctx.stats.learning_units_created < 3 + ), + suggestion_generator=lambda ctx: Suggestion( + id="create_more_units", + title="Weitere Lerneinheiten erstellen", + description=f"Du hast {ctx.stats.learning_units_created} Lerneinheit(en). Erstelle mehr für abwechslungsreichen Unterricht.", + action_type="navigate", + action_target="/studio/worksheets", + priority=SuggestionPriority.MEDIUM, + category="teaching", + icon="library_add", + estimated_time=15, + ), + applies_to_phases=["teaching_setup", "teaching_2"], + ), + + # ═══════════════════════════════════════════════════════════════════════ + # ELTERNKOMMUNIKATION + # ═══════════════════════════════════════════════════════════════════════ + + Rule( + id="parent_meeting_upcoming", + name="Elternabend steht bevor", + condition=lambda ctx: any( + e.type == "parent_meeting" and e.in_days <= 14 + for e in ctx.upcoming_events + ), + suggestion_generator=lambda ctx: Suggestion( + id="prepare_parent_meeting", + title="Elternabend vorbereiten", + description=f"In {_get_parent_meeting_days(ctx)} Tagen ist Elternabend.", + action_type="navigate", + action_target="/studio/letters", + priority=SuggestionPriority.HIGH, + category="communication", + icon="groups", + estimated_time=30, + ), + applies_to_phases=[], # Alle Phasen + ), + + Rule( + id="unanswered_parent_messages", + name="Unbeantwortete Elternnachrichten", + condition=lambda ctx: ctx.stats.unanswered_messages > 0, + suggestion_generator=lambda ctx: Suggestion( + id="answer_messages", + title="Elternnachrichten beantworten", + description=f"{ctx.stats.unanswered_messages} Nachricht(en) warten auf Antwort.", + action_type="navigate", + action_target="/studio/messenger", + priority=SuggestionPriority.HIGH if ctx.stats.unanswered_messages > 3 else SuggestionPriority.MEDIUM, + category="communication", + icon="mail", + estimated_time=15, + ), + applies_to_phases=[], # Alle Phasen + ), + + Rule( + id="no_parent_contact", + name="Keine Elternkontakte", + condition=lambda ctx: ( + ctx.total_students > 0 and + ctx.stats.parent_messages_count == 0 and + ctx.weeks_since_start >= 4 + ), + suggestion_generator=lambda ctx: Suggestion( + id="start_parent_communication", + title="Elternkommunikation starten", + description="Nimm Kontakt mit den Eltern auf.", + action_type="navigate", + action_target="/studio/letters", + priority=SuggestionPriority.MEDIUM, + category="communication", + icon="family_restroom", + estimated_time=20, + ), + applies_to_phases=["teaching_setup", "teaching_2"], + ), + + # ═══════════════════════════════════════════════════════════════════════ + # LEISTUNG / KLAUSUREN + # ═══════════════════════════════════════════════════════════════════════ + + Rule( + id="exam_in_7_days", + name="Klausur in 7 Tagen", + condition=lambda ctx: any( + e.type == "exam" and 0 < e.in_days <= 7 + for e in ctx.upcoming_events + ), + suggestion_generator=lambda ctx: Suggestion( + id="prepare_exam", + title="Klausur vorbereiten", + description=f"Klausur in {_get_next_exam_days(ctx)} Tagen.", + action_type="navigate", + action_target="/studio/worksheets", + priority=SuggestionPriority.URGENT, + category="exams", + icon="quiz", + estimated_time=60, + ), + applies_to_phases=["performance_1", "performance_2"], + ), + + Rule( + id="exam_needs_correction", + name="Klausur wartet auf Korrektur", + condition=lambda ctx: _get_uncorrected_exams(ctx) > 0, + suggestion_generator=lambda ctx: Suggestion( + id="correct_exam", + title="Klausur korrigieren", + description=f"{_get_uncorrected_exams(ctx)} Klausur(en) warten auf Korrektur.", + action_type="navigate", + action_target="/studio/correction", + priority=SuggestionPriority.URGENT, + category="exams", + icon="grading", + estimated_time=120, + ), + applies_to_phases=["performance_1", "performance_2"], + ), + + Rule( + id="no_exams_scheduled", + name="Keine Klausuren geplant", + condition=lambda ctx: ( + ctx.current_phase.value in ["performance_1", "performance_2"] and + ctx.stats.exams_scheduled == 0 + ), + suggestion_generator=lambda ctx: Suggestion( + id="schedule_exams", + title="Klausuren planen", + description="Plane die Klausuren für diese Leistungsphase.", + action_type="navigate", + action_target="/studio/school", + priority=SuggestionPriority.HIGH, + category="exams", + icon="event", + estimated_time=15, + ), + applies_to_phases=["performance_1", "performance_2"], + ), + + # ═══════════════════════════════════════════════════════════════════════ + # NOTEN / ZEUGNISSE + # ═══════════════════════════════════════════════════════════════════════ + + Rule( + id="grades_missing", + name="Noten fehlen", + condition=lambda ctx: ( + ctx.current_phase.value in ["semester_end", "year_end"] and + not ctx.has_completed_milestone("complete_grades") + ), + suggestion_generator=lambda ctx: Suggestion( + id="enter_grades", + title="Noten eintragen", + description="Trage alle Noten für die Zeugnisse ein.", + action_type="navigate", + action_target="/studio/gradebook", + priority=SuggestionPriority.URGENT, + category="grades", + icon="calculate", + estimated_time=30, + ), + applies_to_phases=["semester_end", "year_end"], + ), + + Rule( + id="certificates_due", + name="Zeugnisse erstellen", + condition=lambda ctx: ( + ctx.current_phase.value in ["semester_end", "year_end"] and + ctx.has_completed_milestone("complete_grades") and + not ctx.has_completed_milestone("generate_certificates") + ), + suggestion_generator=lambda ctx: Suggestion( + id="generate_certificates", + title="Zeugnisse erstellen", + description="Alle Noten sind eingetragen. Erstelle jetzt die Zeugnisse.", + action_type="navigate", + action_target="/studio/certificates", + priority=SuggestionPriority.HIGH, + category="certificates", + icon="description", + estimated_time=45, + ), + applies_to_phases=["semester_end", "year_end"], + ), + + Rule( + id="gradebook_empty", + name="Notenbuch leer", + condition=lambda ctx: ( + ctx.stats.grades_entered == 0 and + ctx.weeks_since_start >= 6 + ), + suggestion_generator=lambda ctx: Suggestion( + id="start_gradebook", + title="Notenbuch nutzen", + description="Beginne mit der Notenverwaltung im Notenbuch.", + action_type="navigate", + action_target="/studio/gradebook", + priority=SuggestionPriority.MEDIUM, + category="grades", + icon="grade", + estimated_time=10, + ), + applies_to_phases=["teaching_setup", "performance_1", "teaching_2", "performance_2"], + ), + + # ═══════════════════════════════════════════════════════════════════════ + # ALLGEMEINE ERINNERUNGEN + # ═══════════════════════════════════════════════════════════════════════ + + Rule( + id="long_time_inactive", + name="Lange inaktiv", + condition=lambda ctx: ctx.days_in_phase > 14 and len(ctx.completed_milestones) == 0, + suggestion_generator=lambda ctx: Suggestion( + id="get_started", + title="Loslegen", + description="Du bist schon eine Weile in dieser Phase. Lass uns starten!", + action_type="navigate", + action_target="/studio", + priority=SuggestionPriority.HIGH, + category="general", + icon="rocket_launch", + estimated_time=5, + ), + applies_to_phases=[], # Alle Phasen + ), + + Rule( + id="deadline_approaching", + name="Deadline naht", + condition=lambda ctx: any( + e.type == "deadline" and 0 < e.in_days <= 3 + for e in ctx.upcoming_events + ), + suggestion_generator=lambda ctx: Suggestion( + id="check_deadline", + title="Deadline beachten", + description=f"Eine Deadline in {ctx.get_next_deadline().in_days if ctx.get_next_deadline() else 0} Tagen.", + action_type="navigate", + action_target="/studio", + priority=SuggestionPriority.URGENT, + category="general", + icon="alarm", + estimated_time=5, + ), + applies_to_phases=[], + ), +] + + +def get_rules_for_phase(phase: str) -> List[Rule]: + """Gibt alle Regeln für eine bestimmte Phase zurück.""" + return [ + rule for rule in RULES + if not rule.applies_to_phases or phase in rule.applies_to_phases + ] diff --git a/backend/state_engine_api.py b/backend/state_engine_api.py new file mode 100644 index 0000000..fe669d6 --- /dev/null +++ b/backend/state_engine_api.py @@ -0,0 +1,583 @@ +""" +State Engine API - REST API für Begleiter-Modus. + +Endpoints: +- GET /api/state/context - TeacherContext abrufen +- GET /api/state/suggestions - Vorschläge abrufen +- GET /api/state/dashboard - Dashboard-Daten +- POST /api/state/milestone - Meilenstein abschließen +- POST /api/state/transition - Phasen-Übergang +""" + +import logging +import uuid +from datetime import datetime, timedelta +from typing import Dict, Any, List, Optional + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel, Field + +from state_engine import ( + AnticipationEngine, + PhaseService, + TeacherContext, + SchoolYearPhase, + ClassSummary, + Event, + TeacherStats, + get_phase_info, + PHASE_INFO +) + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/state", + tags=["state-engine"], +) + +# Singleton instances +_engine = AnticipationEngine() +_phase_service = PhaseService() + + +# ============================================================================ +# In-Memory Storage (später durch DB ersetzen) +# ============================================================================ + +# Simulierter Lehrer-Kontext (in Produktion aus DB) +_teacher_contexts: Dict[str, TeacherContext] = {} +_milestones: Dict[str, List[str]] = {} # teacher_id -> milestones + + +# ============================================================================ +# Pydantic Models +# ============================================================================ + +class MilestoneRequest(BaseModel): + """Request zum Abschließen eines Meilensteins.""" + milestone: str = Field(..., description="Name des Meilensteins") + + +class TransitionRequest(BaseModel): + """Request für Phasen-Übergang.""" + target_phase: str = Field(..., description="Zielphase") + + +class ContextResponse(BaseModel): + """Response mit TeacherContext.""" + context: Dict[str, Any] + phase_info: Dict[str, Any] + + +class SuggestionsResponse(BaseModel): + """Response mit Vorschlägen.""" + suggestions: List[Dict[str, Any]] + current_phase: str + phase_display_name: str + priority_counts: Dict[str, int] + + +class DashboardResponse(BaseModel): + """Response mit Dashboard-Daten.""" + context: Dict[str, Any] + suggestions: List[Dict[str, Any]] + stats: Dict[str, Any] + upcoming_events: List[Dict[str, Any]] + progress: Dict[str, Any] + phases: List[Dict[str, Any]] + + +# ============================================================================ +# Helper Functions +# ============================================================================ + +def _get_or_create_context(teacher_id: str) -> TeacherContext: + """ + Holt oder erstellt TeacherContext. + + In Produktion würde dies aus der Datenbank geladen. + """ + if teacher_id not in _teacher_contexts: + # Erstelle Demo-Kontext + now = datetime.now() + school_year_start = datetime(now.year if now.month >= 8 else now.year - 1, 8, 1) + weeks_since_start = (now - school_year_start).days // 7 + + # Bestimme Phase basierend auf Monat + month = now.month + if month in [8, 9]: + phase = SchoolYearPhase.SCHOOL_YEAR_START + elif month in [10, 11]: + phase = SchoolYearPhase.TEACHING_SETUP + elif month == 12: + phase = SchoolYearPhase.PERFORMANCE_1 + elif month in [1, 2]: + phase = SchoolYearPhase.SEMESTER_END + elif month in [3, 4]: + phase = SchoolYearPhase.TEACHING_2 + elif month in [5, 6]: + phase = SchoolYearPhase.PERFORMANCE_2 + else: + phase = SchoolYearPhase.YEAR_END + + _teacher_contexts[teacher_id] = TeacherContext( + teacher_id=teacher_id, + school_id=str(uuid.uuid4()), + school_year_id=str(uuid.uuid4()), + federal_state="niedersachsen", + school_type="gymnasium", + school_year_start=school_year_start, + current_phase=phase, + phase_entered_at=now - timedelta(days=7), + weeks_since_start=weeks_since_start, + days_in_phase=7, + classes=[], + total_students=0, + upcoming_events=[], + completed_milestones=_milestones.get(teacher_id, []), + pending_milestones=[], + stats=TeacherStats(), + ) + + return _teacher_contexts[teacher_id] + + +def _update_context_from_services(ctx: TeacherContext) -> TeacherContext: + """ + Aktualisiert Kontext mit Daten aus anderen Services. + + In Produktion würde dies von school-service, gradebook etc. laden. + """ + # Simulierte Daten - in Produktion API-Calls + # Hier könnten wir den Kontext mit echten Daten anreichern + + # Berechne days_in_phase + ctx.days_in_phase = (datetime.now() - ctx.phase_entered_at).days + + # Lade abgeschlossene Meilensteine + ctx.completed_milestones = _milestones.get(ctx.teacher_id, []) + + # Berechne pending milestones + phase_info = get_phase_info(ctx.current_phase) + ctx.pending_milestones = [ + m for m in phase_info.required_actions + if m not in ctx.completed_milestones + ] + + return ctx + + +def _get_phase_display_name(phase: str) -> str: + """Gibt Display-Name für Phase zurück.""" + try: + return get_phase_info(SchoolYearPhase(phase)).display_name + except (ValueError, KeyError): + return phase + + +# ============================================================================ +# API Endpoints +# ============================================================================ + +@router.get("/context", response_model=ContextResponse) +async def get_teacher_context(teacher_id: str = Query("demo-teacher")): + """ + Gibt den aggregierten TeacherContext zurück. + + Enthält alle relevanten Informationen für: + - Phasen-Anzeige + - Antizipations-Engine + - Dashboard + """ + ctx = _get_or_create_context(teacher_id) + ctx = _update_context_from_services(ctx) + + phase_info = get_phase_info(ctx.current_phase) + + return ContextResponse( + context=ctx.to_dict(), + phase_info={ + "phase": phase_info.phase.value, + "display_name": phase_info.display_name, + "description": phase_info.description, + "typical_months": phase_info.typical_months, + "required_actions": phase_info.required_actions, + "optional_actions": phase_info.optional_actions, + } + ) + + +@router.get("/phase") +async def get_current_phase(teacher_id: str = Query("demo-teacher")): + """ + Gibt die aktuelle Phase mit Details zurück. + """ + ctx = _get_or_create_context(teacher_id) + phase_info = get_phase_info(ctx.current_phase) + + return { + "current_phase": ctx.current_phase.value, + "phase_info": { + "display_name": phase_info.display_name, + "description": phase_info.description, + "expected_duration_weeks": phase_info.expected_duration_weeks, + }, + "days_in_phase": ctx.days_in_phase, + "progress": _phase_service.get_progress_percentage(ctx), + } + + +@router.get("/phases") +async def get_all_phases(): + """ + Gibt alle Phasen mit Metadaten zurück. + + Nützlich für die Phasen-Anzeige im Dashboard. + """ + return { + "phases": _phase_service.get_all_phases() + } + + +@router.get("/suggestions", response_model=SuggestionsResponse) +async def get_suggestions(teacher_id: str = Query("demo-teacher")): + """ + Gibt Vorschläge basierend auf dem aktuellen Kontext zurück. + + Die Vorschläge sind priorisiert und auf max. 5 limitiert. + """ + ctx = _get_or_create_context(teacher_id) + ctx = _update_context_from_services(ctx) + + suggestions = _engine.get_suggestions(ctx) + priority_counts = _engine.count_by_priority(ctx) + + return SuggestionsResponse( + suggestions=[s.to_dict() for s in suggestions], + current_phase=ctx.current_phase.value, + phase_display_name=_get_phase_display_name(ctx.current_phase.value), + priority_counts=priority_counts, + ) + + +@router.get("/suggestions/top") +async def get_top_suggestion(teacher_id: str = Query("demo-teacher")): + """ + Gibt den wichtigsten einzelnen Vorschlag zurück. + """ + ctx = _get_or_create_context(teacher_id) + ctx = _update_context_from_services(ctx) + + suggestion = _engine.get_top_suggestion(ctx) + + if not suggestion: + return { + "suggestion": None, + "message": "Alles erledigt! Keine offenen Aufgaben." + } + + return { + "suggestion": suggestion.to_dict() + } + + +@router.get("/dashboard", response_model=DashboardResponse) +async def get_dashboard_data(teacher_id: str = Query("demo-teacher")): + """ + Gibt alle Daten für das Begleiter-Dashboard zurück. + + Kombiniert: + - TeacherContext + - Vorschläge + - Statistiken + - Termine + - Fortschritt + """ + ctx = _get_or_create_context(teacher_id) + ctx = _update_context_from_services(ctx) + + suggestions = _engine.get_suggestions(ctx) + phase_info = get_phase_info(ctx.current_phase) + + # Berechne Fortschritt + required = set(phase_info.required_actions) + completed = set(ctx.completed_milestones) + completed_in_phase = len(required.intersection(completed)) + + # Alle Phasen für Anzeige + all_phases = [] + phase_order = [ + SchoolYearPhase.ONBOARDING, + SchoolYearPhase.SCHOOL_YEAR_START, + SchoolYearPhase.TEACHING_SETUP, + SchoolYearPhase.PERFORMANCE_1, + SchoolYearPhase.SEMESTER_END, + SchoolYearPhase.TEACHING_2, + SchoolYearPhase.PERFORMANCE_2, + SchoolYearPhase.YEAR_END, + ] + + current_idx = phase_order.index(ctx.current_phase) if ctx.current_phase in phase_order else 0 + + for i, phase in enumerate(phase_order): + info = get_phase_info(phase) + all_phases.append({ + "phase": phase.value, + "display_name": info.display_name, + "short_name": info.display_name[:10], + "is_current": phase == ctx.current_phase, + "is_completed": i < current_idx, + "is_future": i > current_idx, + }) + + return DashboardResponse( + context={ + "current_phase": ctx.current_phase.value, + "phase_display_name": phase_info.display_name, + "phase_description": phase_info.description, + "weeks_since_start": ctx.weeks_since_start, + "days_in_phase": ctx.days_in_phase, + "federal_state": ctx.federal_state, + "school_type": ctx.school_type, + }, + suggestions=[s.to_dict() for s in suggestions], + stats={ + "learning_units_created": ctx.stats.learning_units_created, + "exams_scheduled": ctx.stats.exams_scheduled, + "exams_graded": ctx.stats.exams_graded, + "grades_entered": ctx.stats.grades_entered, + "classes_count": len(ctx.classes), + "students_count": ctx.total_students, + }, + upcoming_events=[ + { + "type": e.type, + "title": e.title, + "date": e.date.isoformat(), + "in_days": e.in_days, + "priority": e.priority, + } + for e in ctx.upcoming_events[:5] + ], + progress={ + "completed": completed_in_phase, + "total": len(required), + "percentage": (completed_in_phase / len(required) * 100) if required else 100, + "milestones_completed": list(completed.intersection(required)), + "milestones_pending": list(required - completed), + }, + phases=all_phases, + ) + + +@router.post("/milestone") +async def complete_milestone( + request: MilestoneRequest, + teacher_id: str = Query("demo-teacher") +): + """ + Markiert einen Meilenstein als erledigt. + + Prüft automatisch ob ein Phasen-Übergang möglich ist. + """ + milestone = request.milestone + + # Speichere Meilenstein + if teacher_id not in _milestones: + _milestones[teacher_id] = [] + + if milestone not in _milestones[teacher_id]: + _milestones[teacher_id].append(milestone) + logger.info(f"Milestone '{milestone}' completed for teacher {teacher_id}") + + # Aktualisiere Kontext + ctx = _get_or_create_context(teacher_id) + ctx.completed_milestones = _milestones[teacher_id] + _teacher_contexts[teacher_id] = ctx + + # Prüfe automatischen Phasen-Übergang + new_phase = _phase_service.check_and_transition(ctx) + + if new_phase: + ctx.current_phase = new_phase + ctx.phase_entered_at = datetime.now() + ctx.days_in_phase = 0 + _teacher_contexts[teacher_id] = ctx + logger.info(f"Auto-transitioned to {new_phase} for teacher {teacher_id}") + + return { + "success": True, + "milestone": milestone, + "new_phase": new_phase.value if new_phase else None, + "current_phase": ctx.current_phase.value, + "completed_milestones": ctx.completed_milestones, + } + + +@router.post("/transition") +async def transition_phase( + request: TransitionRequest, + teacher_id: str = Query("demo-teacher") +): + """ + Führt einen manuellen Phasen-Übergang durch. + """ + try: + target_phase = SchoolYearPhase(request.target_phase) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"Ungültige Phase: {request.target_phase}" + ) + + ctx = _get_or_create_context(teacher_id) + + # Prüfe ob Übergang erlaubt + if not _phase_service.can_transition_to(ctx, target_phase): + raise HTTPException( + status_code=400, + detail=f"Übergang von {ctx.current_phase.value} zu {target_phase.value} nicht erlaubt" + ) + + # Führe Übergang durch + old_phase = ctx.current_phase + ctx.current_phase = target_phase + ctx.phase_entered_at = datetime.now() + ctx.days_in_phase = 0 + _teacher_contexts[teacher_id] = ctx + + logger.info(f"Manual transition from {old_phase} to {target_phase} for teacher {teacher_id}") + + return { + "success": True, + "old_phase": old_phase.value, + "new_phase": target_phase.value, + "phase_info": get_phase_info(target_phase).__dict__, + } + + +@router.get("/next-phase") +async def get_next_phase(teacher_id: str = Query("demo-teacher")): + """ + Gibt die nächste Phase und Anforderungen zurück. + """ + ctx = _get_or_create_context(teacher_id) + next_phase = _phase_service.get_next_phase(ctx.current_phase) + + if not next_phase: + return { + "next_phase": None, + "message": "Letzte Phase erreicht" + } + + can_transition = _phase_service.can_transition_to(ctx, next_phase) + next_info = get_phase_info(next_phase) + current_info = get_phase_info(ctx.current_phase) + + # Fehlende Anforderungen + missing = [ + m for m in current_info.required_actions + if m not in ctx.completed_milestones + ] + + return { + "current_phase": ctx.current_phase.value, + "next_phase": next_phase.value, + "next_phase_info": { + "display_name": next_info.display_name, + "description": next_info.description, + }, + "can_transition": can_transition, + "missing_requirements": missing, + } + + +# ============================================================================ +# Demo Data Endpoints (nur für Entwicklung) +# ============================================================================ + +@router.post("/demo/add-class") +async def demo_add_class( + name: str = Query(...), + grade_level: int = Query(...), + student_count: int = Query(25), + teacher_id: str = Query("demo-teacher") +): + """Demo: Fügt eine Klasse zum Kontext hinzu.""" + ctx = _get_or_create_context(teacher_id) + + ctx.classes.append(ClassSummary( + class_id=str(uuid.uuid4()), + name=name, + grade_level=grade_level, + student_count=student_count, + subject="Deutsch" + )) + ctx.total_students += student_count + + _teacher_contexts[teacher_id] = ctx + + return {"success": True, "classes": len(ctx.classes)} + + +@router.post("/demo/add-event") +async def demo_add_event( + event_type: str = Query(...), + title: str = Query(...), + in_days: int = Query(...), + teacher_id: str = Query("demo-teacher") +): + """Demo: Fügt ein Event zum Kontext hinzu.""" + ctx = _get_or_create_context(teacher_id) + + ctx.upcoming_events.append(Event( + type=event_type, + title=title, + date=datetime.now() + timedelta(days=in_days), + in_days=in_days, + priority="high" if in_days <= 3 else "medium" + )) + + _teacher_contexts[teacher_id] = ctx + + return {"success": True, "events": len(ctx.upcoming_events)} + + +@router.post("/demo/update-stats") +async def demo_update_stats( + learning_units: int = Query(0), + exams_scheduled: int = Query(0), + exams_graded: int = Query(0), + grades_entered: int = Query(0), + unanswered_messages: int = Query(0), + teacher_id: str = Query("demo-teacher") +): + """Demo: Aktualisiert Statistiken.""" + ctx = _get_or_create_context(teacher_id) + + if learning_units: + ctx.stats.learning_units_created = learning_units + if exams_scheduled: + ctx.stats.exams_scheduled = exams_scheduled + if exams_graded: + ctx.stats.exams_graded = exams_graded + if grades_entered: + ctx.stats.grades_entered = grades_entered + if unanswered_messages: + ctx.stats.unanswered_messages = unanswered_messages + + _teacher_contexts[teacher_id] = ctx + + return {"success": True, "stats": ctx.stats.__dict__} + + +@router.post("/demo/reset") +async def demo_reset(teacher_id: str = Query("demo-teacher")): + """Demo: Setzt den Kontext zurück.""" + if teacher_id in _teacher_contexts: + del _teacher_contexts[teacher_id] + if teacher_id in _milestones: + del _milestones[teacher_id] + + return {"success": True, "message": "Kontext zurückgesetzt"} diff --git a/backend/system_api.py b/backend/system_api.py new file mode 100644 index 0000000..01f7ac6 --- /dev/null +++ b/backend/system_api.py @@ -0,0 +1,66 @@ +""" +System API endpoints for health checks and system information. + +Provides: +- /health - Basic health check +- /api/v1/system/local-ip - Local network IP for QR-code mobile upload +""" + +import os +import socket +from fastapi import APIRouter + +router = APIRouter(tags=["System"]) + + +@router.get("/health") +async def health_check(): + """ + Basic health check endpoint. + + Returns healthy status and service name. + """ + return { + "status": "healthy", + "service": "breakpilot-backend" + } + + +@router.get("/api/v1/system/local-ip") +async def get_local_ip(): + """ + Return the local network IP address. + + Used for QR-code generation for mobile PDF upload. + Mobile devices can't reach localhost, so we need the actual network IP. + + Priority: + 1. LOCAL_NETWORK_IP environment variable (explicit configuration) + 2. Auto-detection via socket connection + 3. Fallback to default 192.168.178.157 + """ + # Check environment variable first + env_ip = os.getenv("LOCAL_NETWORK_IP") + if env_ip: + return {"ip": env_ip} + + # Try to auto-detect + try: + # Create a socket to an external address to determine local IP + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(0.1) + # Connect to a public DNS server (doesn't actually send anything) + s.connect(("8.8.8.8", 80)) + local_ip = s.getsockname()[0] + s.close() + + # Validate it's a private IP + if (local_ip.startswith("192.168.") or + local_ip.startswith("10.") or + (local_ip.startswith("172.") and 16 <= int(local_ip.split('.')[1]) <= 31)): + return {"ip": local_ip} + except Exception: + pass + + # Fallback to default + return {"ip": "192.168.178.157"} diff --git a/backend/teacher_dashboard_api.py b/backend/teacher_dashboard_api.py new file mode 100644 index 0000000..04c217b --- /dev/null +++ b/backend/teacher_dashboard_api.py @@ -0,0 +1,951 @@ +# ============================================== +# Breakpilot Drive - Teacher Dashboard API +# ============================================== +# Lehrer-Dashboard fuer Unit-Zuweisung und Analytics: +# - Units zu Klassen zuweisen +# - Schueler-Fortschritt einsehen +# - Klassen-Analytics +# - H5P und PDF Content verwalten +# - Unit-Einstellungen pro Klasse + +from fastapi import APIRouter, HTTPException, Query, Depends, Request +from pydantic import BaseModel, Field +from typing import List, Optional, Dict, Any +from datetime import datetime, timedelta +from enum import Enum +import uuid +import os +import logging +import httpx + +logger = logging.getLogger(__name__) + +# Feature flags +USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true" +REQUIRE_AUTH = os.getenv("TEACHER_REQUIRE_AUTH", "true").lower() == "true" +SCHOOL_SERVICE_URL = os.getenv("SCHOOL_SERVICE_URL", "http://school-service:8084") + +router = APIRouter(prefix="/api/teacher", tags=["Teacher Dashboard"]) + + +# ============================================== +# Pydantic Models +# ============================================== + +class UnitAssignmentStatus(str, Enum): + """Status of a unit assignment""" + DRAFT = "draft" + ACTIVE = "active" + COMPLETED = "completed" + ARCHIVED = "archived" + + +class TeacherControlSettings(BaseModel): + """Unit settings that teachers can configure""" + allow_skip: bool = True + allow_replay: bool = True + max_time_per_stop_sec: int = 90 + show_hints: bool = True + require_precheck: bool = True + require_postcheck: bool = True + + +class AssignUnitRequest(BaseModel): + """Request to assign a unit to a class""" + unit_id: str + class_id: str + due_date: Optional[datetime] = None + settings: Optional[TeacherControlSettings] = None + notes: Optional[str] = None + + +class UnitAssignment(BaseModel): + """Unit assignment record""" + assignment_id: str + unit_id: str + class_id: str + teacher_id: str + status: UnitAssignmentStatus + settings: TeacherControlSettings + due_date: Optional[datetime] = None + notes: Optional[str] = None + created_at: datetime + updated_at: datetime + + +class StudentUnitProgress(BaseModel): + """Progress of a single student on a unit""" + student_id: str + student_name: str + session_id: Optional[str] = None + status: str # "not_started", "in_progress", "completed" + completion_rate: float = 0.0 + precheck_score: Optional[float] = None + postcheck_score: Optional[float] = None + learning_gain: Optional[float] = None + time_spent_minutes: int = 0 + last_activity: Optional[datetime] = None + current_stop: Optional[str] = None + stops_completed: int = 0 + total_stops: int = 0 + + +class ClassUnitProgress(BaseModel): + """Overall progress of a class on a unit""" + assignment_id: str + unit_id: str + unit_title: str + class_id: str + class_name: str + total_students: int + started_count: int + completed_count: int + avg_completion_rate: float + avg_precheck_score: Optional[float] = None + avg_postcheck_score: Optional[float] = None + avg_learning_gain: Optional[float] = None + avg_time_minutes: float + students: List[StudentUnitProgress] + + +class MisconceptionReport(BaseModel): + """Report of detected misconceptions""" + concept_id: str + concept_label: str + misconception: str + affected_students: List[str] + frequency: int + unit_id: str + stop_id: str + + +class ClassAnalyticsSummary(BaseModel): + """Summary analytics for a class""" + class_id: str + class_name: str + total_units_assigned: int + units_completed: int + active_units: int + avg_completion_rate: float + avg_learning_gain: Optional[float] + total_time_hours: float + top_performers: List[str] + struggling_students: List[str] + common_misconceptions: List[MisconceptionReport] + + +class ContentResource(BaseModel): + """Generated content resource""" + resource_type: str # "h5p", "pdf", "worksheet" + title: str + url: str + generated_at: datetime + unit_id: str + + +# ============================================== +# Auth Dependency +# ============================================== + +async def get_current_teacher(request: Request) -> Dict[str, Any]: + """Get current teacher from JWT token.""" + if not REQUIRE_AUTH: + # Dev mode: return demo teacher + return { + "user_id": "e9484ad9-32ee-4f2b-a4e1-d182e02ccf20", + "email": "demo@breakpilot.app", + "role": "teacher", + "name": "Demo Lehrer" + } + + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Missing authorization token") + + try: + import jwt + token = auth_header[7:] + secret = os.getenv("JWT_SECRET", "dev-secret-key") + payload = jwt.decode(token, secret, algorithms=["HS256"]) + + if payload.get("role") not in ["teacher", "admin"]: + raise HTTPException(status_code=403, detail="Teacher or admin role required") + + return payload + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="Token expired") + except jwt.InvalidTokenError: + raise HTTPException(status_code=401, detail="Invalid token") + + +# ============================================== +# Database Integration +# ============================================== + +_teacher_db = None + +async def get_teacher_database(): + """Get teacher database instance with lazy initialization.""" + global _teacher_db + if not USE_DATABASE: + return None + if _teacher_db is None: + try: + from unit.database import get_teacher_db + _teacher_db = await get_teacher_db() + logger.info("Teacher database initialized") + except ImportError: + logger.warning("Teacher database module not available") + except Exception as e: + logger.warning(f"Teacher database not available: {e}") + return _teacher_db + + +# ============================================== +# School Service Integration +# ============================================== + +async def get_classes_for_teacher(teacher_id: str) -> List[Dict[str, Any]]: + """Get classes assigned to a teacher from school service.""" + async with httpx.AsyncClient(timeout=10.0) as client: + try: + response = await client.get( + f"{SCHOOL_SERVICE_URL}/api/v1/school/classes", + headers={"X-Teacher-ID": teacher_id} + ) + if response.status_code == 200: + return response.json() + except Exception as e: + logger.error(f"Failed to get classes from school service: {e}") + return [] + + +async def get_students_in_class(class_id: str) -> List[Dict[str, Any]]: + """Get students in a class from school service.""" + async with httpx.AsyncClient(timeout=10.0) as client: + try: + response = await client.get( + f"{SCHOOL_SERVICE_URL}/api/v1/school/classes/{class_id}/students" + ) + if response.status_code == 200: + return response.json() + except Exception as e: + logger.error(f"Failed to get students from school service: {e}") + return [] + + +# ============================================== +# In-Memory Storage (Fallback) +# ============================================== + +_assignments_store: Dict[str, Dict[str, Any]] = {} + + +# ============================================== +# API Endpoints - Unit Assignment +# ============================================== + +@router.post("/assignments", response_model=UnitAssignment) +async def assign_unit_to_class( + request_data: AssignUnitRequest, + teacher: Dict[str, Any] = Depends(get_current_teacher) +) -> UnitAssignment: + """ + Assign a unit to a class. + + Creates an assignment that allows students in the class to play the unit. + Teacher can configure settings like skip, replay, time limits. + """ + assignment_id = str(uuid.uuid4()) + now = datetime.utcnow() + + settings = request_data.settings or TeacherControlSettings() + + assignment = { + "assignment_id": assignment_id, + "unit_id": request_data.unit_id, + "class_id": request_data.class_id, + "teacher_id": teacher["user_id"], + "status": UnitAssignmentStatus.ACTIVE, + "settings": settings.model_dump(), + "due_date": request_data.due_date, + "notes": request_data.notes, + "created_at": now, + "updated_at": now, + } + + db = await get_teacher_database() + if db: + try: + await db.create_assignment(assignment) + except Exception as e: + logger.error(f"Failed to store assignment: {e}") + + # Fallback: store in memory + _assignments_store[assignment_id] = assignment + + logger.info(f"Unit {request_data.unit_id} assigned to class {request_data.class_id}") + + return UnitAssignment( + assignment_id=assignment_id, + unit_id=request_data.unit_id, + class_id=request_data.class_id, + teacher_id=teacher["user_id"], + status=UnitAssignmentStatus.ACTIVE, + settings=settings, + due_date=request_data.due_date, + notes=request_data.notes, + created_at=now, + updated_at=now, + ) + + +@router.get("/assignments", response_model=List[UnitAssignment]) +async def list_assignments( + class_id: Optional[str] = Query(None, description="Filter by class"), + status: Optional[UnitAssignmentStatus] = Query(None, description="Filter by status"), + teacher: Dict[str, Any] = Depends(get_current_teacher) +) -> List[UnitAssignment]: + """ + List all unit assignments for the teacher. + + Optionally filter by class or status. + """ + db = await get_teacher_database() + assignments = [] + + if db: + try: + assignments = await db.list_assignments( + teacher_id=teacher["user_id"], + class_id=class_id, + status=status.value if status else None + ) + except Exception as e: + logger.error(f"Failed to list assignments: {e}") + + if not assignments: + # Fallback: filter in-memory store + for assignment in _assignments_store.values(): + if assignment["teacher_id"] != teacher["user_id"]: + continue + if class_id and assignment["class_id"] != class_id: + continue + if status and assignment["status"] != status.value: + continue + assignments.append(assignment) + + return [ + UnitAssignment( + assignment_id=a["assignment_id"], + unit_id=a["unit_id"], + class_id=a["class_id"], + teacher_id=a["teacher_id"], + status=a["status"], + settings=TeacherControlSettings(**a["settings"]), + due_date=a.get("due_date"), + notes=a.get("notes"), + created_at=a["created_at"], + updated_at=a["updated_at"], + ) + for a in assignments + ] + + +@router.get("/assignments/{assignment_id}", response_model=UnitAssignment) +async def get_assignment( + assignment_id: str, + teacher: Dict[str, Any] = Depends(get_current_teacher) +) -> UnitAssignment: + """Get details of a specific assignment.""" + db = await get_teacher_database() + + if db: + try: + assignment = await db.get_assignment(assignment_id) + if assignment and assignment["teacher_id"] == teacher["user_id"]: + return UnitAssignment( + assignment_id=assignment["assignment_id"], + unit_id=assignment["unit_id"], + class_id=assignment["class_id"], + teacher_id=assignment["teacher_id"], + status=assignment["status"], + settings=TeacherControlSettings(**assignment["settings"]), + due_date=assignment.get("due_date"), + notes=assignment.get("notes"), + created_at=assignment["created_at"], + updated_at=assignment["updated_at"], + ) + except Exception as e: + logger.error(f"Failed to get assignment: {e}") + + # Fallback + if assignment_id in _assignments_store: + a = _assignments_store[assignment_id] + if a["teacher_id"] == teacher["user_id"]: + return UnitAssignment( + assignment_id=a["assignment_id"], + unit_id=a["unit_id"], + class_id=a["class_id"], + teacher_id=a["teacher_id"], + status=a["status"], + settings=TeacherControlSettings(**a["settings"]), + due_date=a.get("due_date"), + notes=a.get("notes"), + created_at=a["created_at"], + updated_at=a["updated_at"], + ) + + raise HTTPException(status_code=404, detail="Assignment not found") + + +@router.put("/assignments/{assignment_id}") +async def update_assignment( + assignment_id: str, + settings: Optional[TeacherControlSettings] = None, + status: Optional[UnitAssignmentStatus] = None, + due_date: Optional[datetime] = None, + notes: Optional[str] = None, + teacher: Dict[str, Any] = Depends(get_current_teacher) +) -> UnitAssignment: + """Update assignment settings or status.""" + db = await get_teacher_database() + assignment = None + + if db: + try: + assignment = await db.get_assignment(assignment_id) + except Exception as e: + logger.error(f"Failed to get assignment: {e}") + + if not assignment and assignment_id in _assignments_store: + assignment = _assignments_store[assignment_id] + + if not assignment or assignment["teacher_id"] != teacher["user_id"]: + raise HTTPException(status_code=404, detail="Assignment not found") + + # Update fields + if settings: + assignment["settings"] = settings.model_dump() + if status: + assignment["status"] = status.value + if due_date: + assignment["due_date"] = due_date + if notes is not None: + assignment["notes"] = notes + assignment["updated_at"] = datetime.utcnow() + + if db: + try: + await db.update_assignment(assignment) + except Exception as e: + logger.error(f"Failed to update assignment: {e}") + + _assignments_store[assignment_id] = assignment + + return UnitAssignment( + assignment_id=assignment["assignment_id"], + unit_id=assignment["unit_id"], + class_id=assignment["class_id"], + teacher_id=assignment["teacher_id"], + status=assignment["status"], + settings=TeacherControlSettings(**assignment["settings"]), + due_date=assignment.get("due_date"), + notes=assignment.get("notes"), + created_at=assignment["created_at"], + updated_at=assignment["updated_at"], + ) + + +@router.delete("/assignments/{assignment_id}") +async def delete_assignment( + assignment_id: str, + teacher: Dict[str, Any] = Depends(get_current_teacher) +) -> Dict[str, str]: + """Delete/archive an assignment.""" + db = await get_teacher_database() + + if db: + try: + assignment = await db.get_assignment(assignment_id) + if assignment and assignment["teacher_id"] == teacher["user_id"]: + await db.delete_assignment(assignment_id) + if assignment_id in _assignments_store: + del _assignments_store[assignment_id] + return {"status": "deleted", "assignment_id": assignment_id} + except Exception as e: + logger.error(f"Failed to delete assignment: {e}") + + if assignment_id in _assignments_store: + a = _assignments_store[assignment_id] + if a["teacher_id"] == teacher["user_id"]: + del _assignments_store[assignment_id] + return {"status": "deleted", "assignment_id": assignment_id} + + raise HTTPException(status_code=404, detail="Assignment not found") + + +# ============================================== +# API Endpoints - Progress & Analytics +# ============================================== + +@router.get("/assignments/{assignment_id}/progress", response_model=ClassUnitProgress) +async def get_assignment_progress( + assignment_id: str, + teacher: Dict[str, Any] = Depends(get_current_teacher) +) -> ClassUnitProgress: + """ + Get detailed progress for an assignment. + + Shows each student's status, scores, and time spent. + """ + db = await get_teacher_database() + assignment = None + + if db: + try: + assignment = await db.get_assignment(assignment_id) + except Exception as e: + logger.error(f"Failed to get assignment: {e}") + + if not assignment and assignment_id in _assignments_store: + assignment = _assignments_store[assignment_id] + + if not assignment or assignment["teacher_id"] != teacher["user_id"]: + raise HTTPException(status_code=404, detail="Assignment not found") + + # Get students in class + students = await get_students_in_class(assignment["class_id"]) + + # Get progress for each student + student_progress = [] + total_completion = 0.0 + total_precheck = 0.0 + total_postcheck = 0.0 + total_time = 0 + precheck_count = 0 + postcheck_count = 0 + started = 0 + completed = 0 + + for student in students: + student_id = student.get("id", student.get("student_id")) + progress = StudentUnitProgress( + student_id=student_id, + student_name=student.get("name", f"Student {student_id[:8]}"), + status="not_started", + completion_rate=0.0, + stops_completed=0, + total_stops=0, + ) + + if db: + try: + session_data = await db.get_student_unit_session( + student_id=student_id, + unit_id=assignment["unit_id"] + ) + if session_data: + progress.session_id = session_data.get("session_id") + progress.status = "completed" if session_data.get("completed_at") else "in_progress" + progress.completion_rate = session_data.get("completion_rate", 0.0) + progress.precheck_score = session_data.get("precheck_score") + progress.postcheck_score = session_data.get("postcheck_score") + progress.time_spent_minutes = session_data.get("duration_seconds", 0) // 60 + progress.last_activity = session_data.get("updated_at") + progress.stops_completed = session_data.get("stops_completed", 0) + progress.total_stops = session_data.get("total_stops", 0) + + if progress.precheck_score is not None and progress.postcheck_score is not None: + progress.learning_gain = progress.postcheck_score - progress.precheck_score + + # Aggregate stats + total_completion += progress.completion_rate + total_time += progress.time_spent_minutes + if progress.precheck_score is not None: + total_precheck += progress.precheck_score + precheck_count += 1 + if progress.postcheck_score is not None: + total_postcheck += progress.postcheck_score + postcheck_count += 1 + if progress.status != "not_started": + started += 1 + if progress.status == "completed": + completed += 1 + except Exception as e: + logger.error(f"Failed to get student progress: {e}") + + student_progress.append(progress) + + total_students = len(students) or 1 # Avoid division by zero + + return ClassUnitProgress( + assignment_id=assignment_id, + unit_id=assignment["unit_id"], + unit_title=f"Unit {assignment['unit_id']}", # Would load from unit definition + class_id=assignment["class_id"], + class_name=f"Class {assignment['class_id'][:8]}", # Would load from school service + total_students=len(students), + started_count=started, + completed_count=completed, + avg_completion_rate=total_completion / total_students, + avg_precheck_score=total_precheck / precheck_count if precheck_count > 0 else None, + avg_postcheck_score=total_postcheck / postcheck_count if postcheck_count > 0 else None, + avg_learning_gain=(total_postcheck / postcheck_count - total_precheck / precheck_count) + if precheck_count > 0 and postcheck_count > 0 else None, + avg_time_minutes=total_time / started if started > 0 else 0, + students=student_progress, + ) + + +@router.get("/classes/{class_id}/analytics", response_model=ClassAnalyticsSummary) +async def get_class_analytics( + class_id: str, + teacher: Dict[str, Any] = Depends(get_current_teacher) +) -> ClassAnalyticsSummary: + """ + Get summary analytics for a class. + + Includes all unit assignments, overall progress, and common misconceptions. + """ + db = await get_teacher_database() + + # Get all assignments for this class + assignments = [] + if db: + try: + assignments = await db.list_assignments( + teacher_id=teacher["user_id"], + class_id=class_id + ) + except Exception as e: + logger.error(f"Failed to list assignments: {e}") + + if not assignments: + assignments = [ + a for a in _assignments_store.values() + if a["class_id"] == class_id and a["teacher_id"] == teacher["user_id"] + ] + + total_units = len(assignments) + completed_units = sum(1 for a in assignments if a.get("status") == "completed") + active_units = sum(1 for a in assignments if a.get("status") == "active") + + # Aggregate student performance + students = await get_students_in_class(class_id) + student_scores = {} + misconceptions = [] + + if db: + try: + for student in students: + student_id = student.get("id", student.get("student_id")) + analytics = await db.get_student_analytics(student_id) + if analytics: + student_scores[student_id] = { + "name": student.get("name", student_id[:8]), + "avg_score": analytics.get("avg_postcheck_score", 0), + "total_time": analytics.get("total_time_minutes", 0), + } + + # Get common misconceptions + misconceptions_data = await db.get_class_misconceptions(class_id) + for m in misconceptions_data: + misconceptions.append(MisconceptionReport( + concept_id=m["concept_id"], + concept_label=m["concept_label"], + misconception=m["misconception"], + affected_students=m["affected_students"], + frequency=m["frequency"], + unit_id=m["unit_id"], + stop_id=m["stop_id"], + )) + except Exception as e: + logger.error(f"Failed to aggregate analytics: {e}") + + # Identify top and struggling students + sorted_students = sorted( + student_scores.items(), + key=lambda x: x[1]["avg_score"], + reverse=True + ) + top_performers = [s[1]["name"] for s in sorted_students[:3]] + struggling_students = [s[1]["name"] for s in sorted_students[-3:] if s[1]["avg_score"] < 0.6] + + total_time = sum(s["total_time"] for s in student_scores.values()) + avg_scores = [s["avg_score"] for s in student_scores.values() if s["avg_score"] > 0] + avg_completion = sum(avg_scores) / len(avg_scores) if avg_scores else 0 + + return ClassAnalyticsSummary( + class_id=class_id, + class_name=f"Klasse {class_id[:8]}", + total_units_assigned=total_units, + units_completed=completed_units, + active_units=active_units, + avg_completion_rate=avg_completion, + avg_learning_gain=None, # Would calculate from pre/post scores + total_time_hours=total_time / 60, + top_performers=top_performers, + struggling_students=struggling_students, + common_misconceptions=misconceptions[:5], + ) + + +@router.get("/students/{student_id}/progress") +async def get_student_progress( + student_id: str, + teacher: Dict[str, Any] = Depends(get_current_teacher) +) -> Dict[str, Any]: + """ + Get detailed progress for a specific student. + + Shows all units attempted and their performance. + """ + db = await get_teacher_database() + + if db: + try: + progress = await db.get_student_full_progress(student_id) + return progress + except Exception as e: + logger.error(f"Failed to get student progress: {e}") + + return { + "student_id": student_id, + "units_attempted": 0, + "units_completed": 0, + "avg_score": 0.0, + "total_time_minutes": 0, + "sessions": [], + } + + +# ============================================== +# API Endpoints - Content Resources +# ============================================== + +@router.get("/assignments/{assignment_id}/resources", response_model=List[ContentResource]) +async def get_assignment_resources( + assignment_id: str, + teacher: Dict[str, Any] = Depends(get_current_teacher), + request: Request = None +) -> List[ContentResource]: + """ + Get generated content resources for an assignment. + + Returns links to H5P activities and PDF worksheets. + """ + db = await get_teacher_database() + assignment = None + + if db: + try: + assignment = await db.get_assignment(assignment_id) + except Exception as e: + logger.error(f"Failed to get assignment: {e}") + + if not assignment and assignment_id in _assignments_store: + assignment = _assignments_store[assignment_id] + + if not assignment or assignment["teacher_id"] != teacher["user_id"]: + raise HTTPException(status_code=404, detail="Assignment not found") + + unit_id = assignment["unit_id"] + base_url = str(request.base_url).rstrip("/") if request else "http://localhost:8000" + + resources = [ + ContentResource( + resource_type="h5p", + title=f"{unit_id} - H5P Aktivitaeten", + url=f"{base_url}/api/units/content/{unit_id}/h5p", + generated_at=datetime.utcnow(), + unit_id=unit_id, + ), + ContentResource( + resource_type="worksheet", + title=f"{unit_id} - Arbeitsblatt (HTML)", + url=f"{base_url}/api/units/content/{unit_id}/worksheet", + generated_at=datetime.utcnow(), + unit_id=unit_id, + ), + ContentResource( + resource_type="pdf", + title=f"{unit_id} - Arbeitsblatt (PDF)", + url=f"{base_url}/api/units/content/{unit_id}/worksheet.pdf", + generated_at=datetime.utcnow(), + unit_id=unit_id, + ), + ] + + return resources + + +@router.post("/assignments/{assignment_id}/regenerate-content") +async def regenerate_content( + assignment_id: str, + resource_type: str = Query("all", description="h5p, pdf, or all"), + teacher: Dict[str, Any] = Depends(get_current_teacher) +) -> Dict[str, Any]: + """ + Trigger regeneration of content resources. + + Useful after updating unit definitions. + """ + db = await get_teacher_database() + assignment = None + + if db: + try: + assignment = await db.get_assignment(assignment_id) + except Exception as e: + logger.error(f"Failed to get assignment: {e}") + + if not assignment and assignment_id in _assignments_store: + assignment = _assignments_store[assignment_id] + + if not assignment or assignment["teacher_id"] != teacher["user_id"]: + raise HTTPException(status_code=404, detail="Assignment not found") + + # In production, this would trigger async job to regenerate content + logger.info(f"Content regeneration triggered for {assignment['unit_id']}: {resource_type}") + + return { + "status": "queued", + "assignment_id": assignment_id, + "unit_id": assignment["unit_id"], + "resource_type": resource_type, + "message": "Content regeneration has been queued", + } + + +# ============================================== +# API Endpoints - Available Units +# ============================================== + +@router.get("/units/available") +async def list_available_units( + grade: Optional[str] = Query(None, description="Filter by grade level"), + template: Optional[str] = Query(None, description="Filter by template type"), + locale: str = Query("de-DE", description="Locale"), + teacher: Dict[str, Any] = Depends(get_current_teacher) +) -> List[Dict[str, Any]]: + """ + List all available units for assignment. + + Teachers see all published units matching their criteria. + """ + db = await get_teacher_database() + + if db: + try: + units = await db.list_available_units( + grade=grade, + template=template, + locale=locale + ) + return units + except Exception as e: + logger.error(f"Failed to list units: {e}") + + # Fallback: return demo units + return [ + { + "unit_id": "bio_eye_lightpath_v1", + "title": "Auge - Lichtstrahl-Flug", + "template": "flight_path", + "grade_band": ["5", "6", "7"], + "duration_minutes": 8, + "difficulty": "base", + "description": "Reise durch das Auge und folge dem Lichtstrahl", + "learning_objectives": [ + "Verstehen des Lichtwegs durch das Auge", + "Funktionen der Augenbestandteile benennen", + ], + }, + { + "unit_id": "math_pizza_equivalence_v1", + "title": "Pizza-Boxenstopp - Brueche und Prozent", + "template": "station_loop", + "grade_band": ["5", "6"], + "duration_minutes": 10, + "difficulty": "base", + "description": "Entdecke die Verbindung zwischen Bruechen, Dezimalzahlen und Prozent", + "learning_objectives": [ + "Brueche in Prozent umrechnen", + "Aequivalenzen erkennen", + ], + }, + ] + + +# ============================================== +# API Endpoints - Dashboard Overview +# ============================================== + +@router.get("/dashboard") +async def get_dashboard( + teacher: Dict[str, Any] = Depends(get_current_teacher) +) -> Dict[str, Any]: + """ + Get teacher dashboard overview. + + Summary of all classes, active assignments, and alerts. + """ + db = await get_teacher_database() + + # Get teacher's classes + classes = await get_classes_for_teacher(teacher["user_id"]) + + # Get all active assignments + active_assignments = [] + if db: + try: + active_assignments = await db.list_assignments( + teacher_id=teacher["user_id"], + status="active" + ) + except Exception as e: + logger.error(f"Failed to list assignments: {e}") + + if not active_assignments: + active_assignments = [ + a for a in _assignments_store.values() + if a["teacher_id"] == teacher["user_id"] and a.get("status") == "active" + ] + + # Calculate alerts (students falling behind, due dates, etc.) + alerts = [] + for assignment in active_assignments: + if assignment.get("due_date") and assignment["due_date"] < datetime.utcnow() + timedelta(days=2): + alerts.append({ + "type": "due_soon", + "assignment_id": assignment["assignment_id"], + "message": f"Zuweisung endet in weniger als 2 Tagen", + }) + + return { + "teacher": { + "id": teacher["user_id"], + "name": teacher.get("name", "Lehrer"), + "email": teacher.get("email"), + }, + "classes": len(classes), + "active_assignments": len(active_assignments), + "total_students": sum(c.get("student_count", 0) for c in classes), + "alerts": alerts, + "recent_activity": [], # Would load recent session completions + } + + +@router.get("/health") +async def health_check() -> Dict[str, Any]: + """Health check for teacher dashboard API.""" + db = await get_teacher_database() + db_status = "connected" if db else "in-memory" + + return { + "status": "healthy", + "service": "teacher-dashboard", + "database": db_status, + "auth_required": REQUIRE_AUTH, + } diff --git a/backend/templates/gdpr/gdpr_export.html b/backend/templates/gdpr/gdpr_export.html new file mode 100644 index 0000000..a8ba1e9 --- /dev/null +++ b/backend/templates/gdpr/gdpr_export.html @@ -0,0 +1,517 @@ + + + + + DSGVO Datenauskunft - BreakPilot + + + + +
            +
            + +
            + Erstellungsdatum: {{ export_date }}
            + Dokument-ID: {{ document_id }} +
            +
            +
            Auskunft uber gespeicherte personenbezogene Daten
            +
            Gemaß Art. 15 DSGVO (Datenschutz-Grundverordnung)
            +
            + + +
            +

            1. Ihre personlichen Daten

            + +
            +
            Benutzer-ID:
            +
            {{ user.id }}
            + +
            E-Mail-Adresse:
            +
            {{ user.email }}
            + + {% if user.name %} +
            Name:
            +
            {{ user.name }}
            + {% endif %} + +
            Registriert am:
            +
            {{ user.created_at | format_datetime }}
            + + {% if user.last_login %} +
            Letzter Login:
            +
            {{ user.last_login | format_datetime }}
            + {% endif %} + +
            Kontostatus:
            +
            + {% if user.account_status == 'active' %} + Aktiv + {% elif user.account_status == 'suspended' %} + Gesperrt + {% else %} + {{ user.account_status }} + {% endif %} +
            +
            +
            + + +
            +

            2. Einwilligungen & Zustimmungen

            + + {% if consents %} + + + + + + + + + + + + {% for consent in consents %} + + + + + + + + {% endfor %} + +
            DokumentVersionStatusZugestimmt amWiderrufen am
            {{ consent.document_name }}{{ consent.version }} + {% if consent.consented %} + Zugestimmt + {% else %} + Widerrufen + {% endif %} + {{ consent.consented_at | format_datetime }}{{ consent.withdrawn_at | format_datetime if consent.withdrawn_at else '-' }}
            + {% else %} +
            Keine Einwilligungen vorhanden.
            + {% endif %} +
            + + +
            +

            3. Cookie-Praferenzen

            + + {% if cookie_consents %} + + + + + + + + + + + {% for cookie in cookie_consents %} + + + + + + + {% endfor %} + +
            KategorieStatusAktualisiert amBeschreibung
            {{ cookie.category }} + {% if cookie.consented %} + Akzeptiert + {% else %} + Abgelehnt + {% endif %} + {{ cookie.updated_at | format_datetime }}{{ cookie.description | default('-', true) }}
            + {% else %} +
            Keine Cookie-Praferenzen gespeichert.
            + {% endif %} +
            + + +
            +

            4. Aktivitatsprotokoll

            + +
            + Die folgenden Aktivitaten wurden in Ihrem Konto protokolliert. + IP-Adressen werden nach 4 Wochen automatisch anonymisiert. +
            + + {% if audit_logs %} + + + + + + + + + + + {% for log in audit_logs[:50] %} + + + + + + + {% endfor %} + +
            DatumAktionIP-AdresseDetails
            {{ log.created_at | format_datetime }}{{ log.action | translate_action }}{{ log.ip_address | default('Anonymisiert', true) }}{{ log.details | default('-', true) }}
            + {% if audit_logs | length > 50 %} +
            + Es werden die letzten 50 Einträge angezeigt. + Insgesamt {{ audit_logs | length }} Aktivitaten protokolliert. +
            + {% endif %} + {% else %} +
            Kein Aktivitatsprotokoll vorhanden.
            + {% endif %} +
            + + +
            +

            5. Datenkategorien & Loschfristen

            + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
            DatenkategorieBeschreibungLoschfrist
            StammdatenName, E-Mail-Adresse, KontoinformationenAccount-Loschung + 30 Tage
            EinwilligungenConsent-Entscheidungen, Dokumentversionen3 Jahre nach Widerruf
            IP-AdressenTechnische Protokollierung bei Aktionen4 Wochen
            Session-DatenLogin-Tokens, SitzungsinformationenNach Sitzungsende
            Audit-LogProtokoll aller datenschutzrelevanten Aktionen3 Jahre (personenbezogen)
            Analytics (Opt-in)Nutzungsstatistiken, falls zugestimmt26 Monate
            Marketing (Opt-in)Werbe-Identifier, falls zugestimmt12 Monate
            +
            + + +
            +

            6. Ihre Rechte nach DSGVO

            + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
            RechtArtikelBeschreibung
            AuskunftsrechtArt. 15Sie haben das Recht, Auskunft uber Ihre gespeicherten Daten zu erhalten (dieses Dokument).
            BerichtigungsrechtArt. 16Sie konnen die Berichtigung unrichtiger Daten verlangen.
            LoschungsrechtArt. 17Sie konnen die Loschung Ihrer Daten verlangen ("Recht auf Vergessenwerden").
            EinschrankungsrechtArt. 18Sie konnen die Einschrankung der Verarbeitung verlangen.
            DatenubertragbarkeitArt. 20Sie konnen Ihre Daten in einem maschinenlesbaren Format erhalten.
            WiderspruchsrechtArt. 21Sie konnen der Verarbeitung Ihrer Daten widersprechen.
            +
            + + + + + diff --git a/backend/templates/pdf/certificate.html b/backend/templates/pdf/certificate.html new file mode 100644 index 0000000..3462a92 --- /dev/null +++ b/backend/templates/pdf/certificate.html @@ -0,0 +1,115 @@ + + + + + Zeugnis - {{ data.student_name }} + + +
            + {% if data.school_info %} +
            {{ data.school_info.name }}
            + {% endif %} +
            + {% if data.certificate_type == 'halbjahr' %} + Halbjahreszeugnis + {% elif data.certificate_type == 'jahres' %} + Jahreszeugnis + {% elif data.certificate_type == 'abschluss' %} + Abschlusszeugnis + {% else %} + Zeugnis + {% endif %} +
            +
            Schuljahr {{ data.school_year }}
            +
            + +
            + + + + + + + + + +
            Name: {{ data.student_name }}Geburtsdatum: {{ data.student_birthdate }}
            Klasse: {{ data.student_class }} 
            +
            + +

            Leistungen

            + + + + + + + + + + {% for subject in data.subjects %} + + + + + + {% endfor %} + +
            FachNotePunkte
            {{ subject.name }} + {{ subject.grade }} + {{ subject.points | default('-') }}
            + + {% if data.social_behavior or data.work_behavior %} +

            Verhalten

            + + {% if data.social_behavior %} + + + + + {% endif %} + {% if data.work_behavior %} + + + + + {% endif %} +
            Sozialverhalten{{ data.social_behavior }}
            Arbeitsverhalten{{ data.work_behavior }}
            + {% endif %} + +
            + Versäumte Tage: {{ data.attendance.days_absent | default(0) }} + (davon entschuldigt: {{ data.attendance.days_excused | default(0) }}, + unentschuldigt: {{ data.attendance.days_unexcused | default(0) }}) +
            + + {% if data.remarks %} +
            + Bemerkungen:
            + {{ data.remarks }} +
            + {% endif %} + +
            + Ausgestellt am: {{ data.issue_date }} +
            + +
            +
            +
            {{ data.class_teacher }}
            +
            Klassenlehrer/in
            +
            +
            +
            {{ data.principal }}
            +
            Schulleiter/in
            +
            +
            + +
            +
            Siegel der Schule
            +
            + +
            + Erstellt mit BreakPilot | {{ generated_at }} +
            + + diff --git a/backend/templates/pdf/correction.html b/backend/templates/pdf/correction.html new file mode 100644 index 0000000..c132e1c --- /dev/null +++ b/backend/templates/pdf/correction.html @@ -0,0 +1,90 @@ + + + + + Korrektur - {{ data.exam_title }} + + +
            +

            {{ data.exam_title }}

            +
            {{ data.subject }} | {{ data.date }}
            +
            + +
            + {{ data.student.name }} | Klasse {{ data.student.class_name }} +
            + +
            +
            + Note: {{ data.grade }} +
            +
            + {{ data.achieved_points }} von {{ data.max_points }} Punkten + {% if data.max_points > 0 %} + ({{ data.percentage | round(1) }}%) + {% endif %} +
            +
            + +

            Detaillierte Auswertung

            +
            + {% for item in data.corrections %} +
            +
            + Aufgabe {{ loop.index }}: {{ item.question }} +
            +
            + Punkte: {{ item.points }} +
            + {% if item.feedback %} +
            + {{ item.feedback }} +
            + {% endif %} +
            + {% endfor %} +
            + + {% if data.teacher_notes %} +
            + Lehrerkommentar:
            + {{ data.teacher_notes }} +
            + {% endif %} + + {% if data.ai_feedback %} +
            + KI-Feedback:
            + {{ data.ai_feedback }} +
            + {% endif %} + +

            Klassenstatistik

            + + {% if data.class_average %} + + + + + {% endif %} + {% if data.grade_distribution %} + + + + + {% endif %} +
            Klassendurchschnitt:{{ data.class_average }}
            Notenverteilung: + {% for grade, count in data.grade_distribution.items() %} + Note {{ grade }}: {{ count }}x{% if not loop.last %}, {% endif %} + {% endfor %} +
            + +
            +

            Datum: {{ data.date }}

            +
            + +
            + Erstellt mit BreakPilot | {{ generated_at }} +
            + + diff --git a/backend/templates/pdf/letter.html b/backend/templates/pdf/letter.html new file mode 100644 index 0000000..7e5e6a8 --- /dev/null +++ b/backend/templates/pdf/letter.html @@ -0,0 +1,73 @@ + + + + + {{ data.subject }} + + +
            + {% if data.school_info %} +
            {{ data.school_info.name }}
            +
            + {{ data.school_info.address }}
            + Tel: {{ data.school_info.phone }} | E-Mail: {{ data.school_info.email }} + {% if data.school_info.website %} | {{ data.school_info.website }}{% endif %} +
            + {% else %} +
            Schule
            + {% endif %} +
            + +
            + {{ data.date }} +
            + +
            + {{ data.recipient_name }}
            + {{ data.recipient_address | replace('\n', '
            ') | safe }} +
            + +
            + Betreff: {{ data.subject }} +
            + +
            + Schüler/in: {{ data.student_name }} | Klasse: {{ data.student_class }} +
            + +
            + {{ data.content | replace('\n', '
            ') | safe }} +
            + + {% if data.gfk_principles_applied %} +
            + {% for principle in data.gfk_principles_applied %} + GFK: {{ principle }} + {% endfor %} +
            + {% endif %} + +
            +

            Mit freundlichen Grüßen

            +

            + {{ data.teacher_name }} + {% if data.teacher_title %}
            {{ data.teacher_title }}{% endif %} +

            +
            + + {% if data.legal_references %} + + {% endif %} + +
            + Erstellt mit BreakPilot | {{ generated_at }} +
            + + diff --git a/backend/test_api_comparison.py b/backend/test_api_comparison.py new file mode 100644 index 0000000..38744b8 --- /dev/null +++ b/backend/test_api_comparison.py @@ -0,0 +1,180 @@ +""" +A/B Test: Claude vs OpenAI Vision APIs + +Compares both APIs side-by-side on the same worksheet. +""" + +from pathlib import Path +import json +import time +from ai_processor import _analyze_with_claude, _analyze_with_openai + +def compare_apis(filename: str): + """ + Test both APIs and compare results. + + Args: + filename: Name of file in ~/Arbeitsblaetter/Eingang/ + """ + eingang_dir = Path.home() / "Arbeitsblaetter" / "Eingang" + bereinigt_dir = Path.home() / "Arbeitsblaetter" / "Bereinigt" + + input_path = eingang_dir / filename + + if not input_path.exists(): + print(f"❌ File not found: {input_path}") + return + + print("\n" + "=" * 70) + print("🔬 A/B TEST: Claude vs OpenAI Vision APIs") + print("=" * 70) + print(f"File: {filename}\n") + + results = {} + + # Test Claude + print("🟣 Testing Claude 3.5 Sonnet...") + print("-" * 70) + try: + start = time.time() + claude_result = _analyze_with_claude(input_path) + claude_time = time.time() - start + + claude_data = json.loads(claude_result.read_text(encoding='utf-8')) + + print(f"✅ Claude completed in {claude_time:.2f}s") + print(f" Title: {claude_data.get('title')}") + print(f" Subject: {claude_data.get('subject')}") + print(f" Grade: {claude_data.get('grade_level')}") + + # Count elements + layout = claude_data.get('layout', {}) + text_regions = layout.get('text_regions', []) + diagrams = layout.get('diagram_elements', []) + handwriting = claude_data.get('handwriting_regions', []) + + print(f" Text regions: {len(text_regions)}") + print(f" Diagrams: {len(diagrams)}") + print(f" Handwriting regions: {len(handwriting)}") + + if handwriting: + print(f"\n 🖊️ Handwriting detected:") + for i, hw in enumerate(handwriting[:2], 1): + print(f" {i}. {hw.get('type')} ({hw.get('color_hint')})") + print(f" Text: '{hw.get('text', '')[:60]}...'") + + results['claude'] = { + 'time': claude_time, + 'data': claude_data, + 'file': claude_result + } + + # Rename to _analyse_claude.json + claude_comparison_path = bereinigt_dir / f"{input_path.stem}_analyse_claude.json" + claude_result.rename(claude_comparison_path) + results['claude']['file'] = claude_comparison_path + + except Exception as e: + print(f"❌ Claude failed: {e}") + results['claude'] = {'error': str(e)} + + print() + + # Test OpenAI + print("🔵 Testing OpenAI GPT-4o-mini...") + print("-" * 70) + try: + start = time.time() + openai_result = _analyze_with_openai(input_path) + openai_time = time.time() - start + + openai_data = json.loads(openai_result.read_text(encoding='utf-8')) + + print(f"✅ OpenAI completed in {openai_time:.2f}s") + print(f" Title: {openai_data.get('title')}") + print(f" Subject: {openai_data.get('subject')}") + print(f" Grade: {openai_data.get('grade_level')}") + + # Count elements + layout = openai_data.get('layout', {}) + text_regions = layout.get('text_regions', []) + diagrams = layout.get('diagram_elements', []) + handwriting = openai_data.get('handwriting_regions', []) + + print(f" Text regions: {len(text_regions)}") + print(f" Diagrams: {len(diagrams)}") + print(f" Handwriting regions: {len(handwriting)}") + + if handwriting: + print(f"\n 🖊️ Handwriting detected:") + for i, hw in enumerate(handwriting[:2], 1): + print(f" {i}. {hw.get('type')} ({hw.get('color_hint')})") + print(f" Text: '{hw.get('text', '')[:60]}...'") + + results['openai'] = { + 'time': openai_time, + 'data': openai_data, + 'file': openai_result + } + + # Rename to _analyse_openai.json + openai_comparison_path = bereinigt_dir / f"{input_path.stem}_analyse_openai.json" + openai_result.rename(openai_comparison_path) + results['openai']['file'] = openai_comparison_path + + except Exception as e: + print(f"❌ OpenAI failed: {e}") + results['openai'] = {'error': str(e)} + + # Comparison + print("\n" + "=" * 70) + print("📊 COMPARISON") + print("=" * 70) + + if 'claude' in results and 'openai' in results: + if 'error' not in results['claude'] and 'error' not in results['openai']: + claude_time = results['claude']['time'] + openai_time = results['openai']['time'] + + print(f"⏱️ Speed:") + print(f" Claude: {claude_time:.2f}s") + print(f" OpenAI: {openai_time:.2f}s") + + if claude_time < openai_time: + print(f" → Claude is {openai_time/claude_time:.1f}x faster") + else: + print(f" → OpenAI is {claude_time/openai_time:.1f}x faster") + + # Compare canonical text length + claude_text = results['claude']['data'].get('canonical_text', '') + openai_text = results['openai']['data'].get('canonical_text', '') + + print(f"\n📝 Canonical Text Length:") + print(f" Claude: {len(claude_text)} chars") + print(f" OpenAI: {len(openai_text)} chars") + + # Compare handwriting detection + claude_hw = results['claude']['data'].get('handwriting_regions', []) + openai_hw = results['openai']['data'].get('handwriting_regions', []) + + print(f"\n🖊️ Handwriting Regions:") + print(f" Claude: {len(claude_hw)} regions") + print(f" OpenAI: {len(openai_hw)} regions") + + print(f"\n📁 Comparison files saved:") + print(f" {results['claude']['file'].name}") + print(f" {results['openai']['file'].name}") + + print("\n" + "=" * 70) + print("✅ A/B TEST COMPLETE") + print("=" * 70) + print("\nReview the JSON files to compare quality and accuracy.\n") + +if __name__ == "__main__": + import sys + + filename = "2025-12-10_Handschrift.JPG" + if len(sys.argv) > 1: + filename = sys.argv[1] + + compare_apis(filename) diff --git a/backend/test_cleaning.py b/backend/test_cleaning.py new file mode 100644 index 0000000..5d2b30a --- /dev/null +++ b/backend/test_cleaning.py @@ -0,0 +1,109 @@ +""" +Test script for worksheet cleaning pipeline +""" + +from pathlib import Path +import json +import sys + +# Import functions from ai_processor +from ai_processor import analyze_scan_structure_with_ai, remove_handwriting_from_scan + +def test_worksheet_cleaning(filename: str): + """Test the complete cleaning pipeline""" + + # Paths + eingang_dir = Path.home() / "Arbeitsblaetter" / "Eingang" + bereinigt_dir = Path.home() / "Arbeitsblaetter" / "Bereinigt" + + input_path = eingang_dir / filename + + if not input_path.exists(): + print(f"❌ Error: File not found: {input_path}") + return False + + print(f"\n{'='*60}") + print(f"🧪 TESTING WORKSHEET CLEANING PIPELINE") + print(f"{'='*60}") + print(f"Input file: {filename}") + print(f"{'='*60}\n") + + # Stage 1: AI Analysis + print("📊 Stage 1: AI Analysis (Enhanced)") + print("-" * 60) + try: + analysis_path = analyze_scan_structure_with_ai(input_path) + print(f"✅ Analysis completed: {analysis_path.name}") + + # Load and display analysis + analysis_data = json.loads(analysis_path.read_text(encoding='utf-8')) + + print(f"\n📋 Analysis Results:") + print(f" - Title: {analysis_data.get('title')}") + print(f" - Subject: {analysis_data.get('subject')}") + print(f" - Grade Level: {analysis_data.get('grade_level')}") + + # Layout info + layout = analysis_data.get('layout', {}) + text_regions = layout.get('text_regions', []) + diagram_elements = layout.get('diagram_elements', []) + print(f" - Text regions: {len(text_regions)}") + print(f" - Diagram elements: {len(diagram_elements)}") + + # Handwriting info + hw_regions = analysis_data.get('handwriting_regions', []) + print(f" - Handwriting regions: {len(hw_regions)}") + + if hw_regions: + print(f"\n 🖊️ Handwriting detected:") + for i, hw in enumerate(hw_regions[:3], 1): # Show first 3 + print(f" {i}. Type: {hw.get('type')}, Color: {hw.get('color_hint')}") + print(f" Text: '{hw.get('text', '')[:50]}...'") + + print() + + except Exception as e: + print(f"❌ Analysis failed: {e}") + import traceback + traceback.print_exc() + return False + + # Stage 2: Image Cleaning + print("🧹 Stage 2: Image Cleaning (OpenCV + AI)") + print("-" * 60) + try: + cleaned_path = remove_handwriting_from_scan(input_path) + print(f"✅ Cleaning completed: {cleaned_path.name}") + + # Check file size + original_size = input_path.stat().st_size / 1024 + cleaned_size = cleaned_path.stat().st_size / 1024 + print(f" - Original size: {original_size:.1f} KB") + print(f" - Cleaned size: {cleaned_size:.1f} KB") + + except Exception as e: + print(f"❌ Cleaning failed: {e}") + import traceback + traceback.print_exc() + return False + + # Summary + print(f"\n{'='*60}") + print("✅ TEST COMPLETED SUCCESSFULLY") + print(f"{'='*60}") + print(f"\n📂 Output files in: {bereinigt_dir}") + print(f" - {input_path.stem}_analyse.json") + print(f" - {input_path.stem}_clean.jpg") + print() + + return True + +if __name__ == "__main__": + # Test with Handschrift.JPG + filename = "2025-12-10_Handschrift.JPG" + + if len(sys.argv) > 1: + filename = sys.argv[1] + + success = test_worksheet_cleaning(filename) + sys.exit(0 if success else 1) diff --git a/backend/test_environment_config.py b/backend/test_environment_config.py new file mode 100644 index 0000000..0a1929f --- /dev/null +++ b/backend/test_environment_config.py @@ -0,0 +1,325 @@ +""" +Tests for Dev/Staging/Prod environment configuration. + +Tests the environment files, Docker Compose configurations, and helper scripts +to ensure proper environment separation and functionality. + +Usage: + cd backend && pytest test_environment_config.py -v +""" + +import os +import subprocess +from pathlib import Path +from typing import Dict, List +import pytest + + +# Project root directory +PROJECT_ROOT = Path(__file__).parent.parent + + +class TestEnvironmentFiles: + """Test environment configuration files exist and have correct content.""" + + def test_env_dev_exists(self): + """Test that .env.dev file exists.""" + env_file = PROJECT_ROOT / ".env.dev" + assert env_file.exists(), f".env.dev should exist at {env_file}" + + def test_env_staging_exists(self): + """Test that .env.staging file exists.""" + env_file = PROJECT_ROOT / ".env.staging" + assert env_file.exists(), f".env.staging should exist at {env_file}" + + def test_env_example_exists(self): + """Test that .env.example file exists.""" + env_file = PROJECT_ROOT / ".env.example" + assert env_file.exists(), f".env.example should exist at {env_file}" + + def test_env_dev_content(self): + """Test that .env.dev has correct environment settings.""" + env_file = PROJECT_ROOT / ".env.dev" + if not env_file.exists(): + pytest.skip(".env.dev not found") + + content = env_file.read_text() + + # Required settings for development + assert "ENVIRONMENT=development" in content, "ENVIRONMENT should be development" + assert "COMPOSE_PROJECT_NAME=breakpilot-dev" in content, "Project name should be breakpilot-dev" + assert "POSTGRES_DB=breakpilot_dev" in content, "Database should be breakpilot_dev" + assert "DEBUG=true" in content, "DEBUG should be true in development" + + def test_env_staging_content(self): + """Test that .env.staging has correct environment settings.""" + env_file = PROJECT_ROOT / ".env.staging" + if not env_file.exists(): + pytest.skip(".env.staging not found") + + content = env_file.read_text() + + # Required settings for staging + assert "ENVIRONMENT=staging" in content, "ENVIRONMENT should be staging" + assert "COMPOSE_PROJECT_NAME=breakpilot-staging" in content, "Project name should be breakpilot-staging" + assert "POSTGRES_DB=breakpilot_staging" in content, "Database should be breakpilot_staging" + assert "DEBUG=false" in content, "DEBUG should be false in staging" + + def test_env_files_have_different_databases(self): + """Test that dev and staging use different database names.""" + env_dev = PROJECT_ROOT / ".env.dev" + env_staging = PROJECT_ROOT / ".env.staging" + + if not (env_dev.exists() and env_staging.exists()): + pytest.skip("Environment files not found") + + dev_content = env_dev.read_text() + staging_content = env_staging.read_text() + + # Extract database names + dev_db = None + staging_db = None + for line in dev_content.split("\n"): + if line.startswith("POSTGRES_DB="): + dev_db = line.split("=")[1].strip() + for line in staging_content.split("\n"): + if line.startswith("POSTGRES_DB="): + staging_db = line.split("=")[1].strip() + + assert dev_db is not None, "POSTGRES_DB not found in .env.dev" + assert staging_db is not None, "POSTGRES_DB not found in .env.staging" + assert dev_db != staging_db, f"Dev and staging should use different databases, both use {dev_db}" + + +class TestDockerComposeFiles: + """Test Docker Compose configuration files.""" + + def test_docker_compose_yml_exists(self): + """Test that main docker-compose.yml exists.""" + compose_file = PROJECT_ROOT / "docker-compose.yml" + assert compose_file.exists(), "docker-compose.yml should exist" + + def test_docker_compose_override_exists(self): + """Test that docker-compose.override.yml exists for dev.""" + compose_file = PROJECT_ROOT / "docker-compose.override.yml" + assert compose_file.exists(), "docker-compose.override.yml should exist for development" + + def test_docker_compose_staging_exists(self): + """Test that docker-compose.staging.yml exists.""" + compose_file = PROJECT_ROOT / "docker-compose.staging.yml" + assert compose_file.exists(), "docker-compose.staging.yml should exist" + + def test_docker_compose_override_valid_yaml(self): + """Test that docker-compose.override.yml has valid syntax.""" + compose_file = PROJECT_ROOT / "docker-compose.override.yml" + if not compose_file.exists(): + pytest.skip("docker-compose.override.yml not found") + + result = subprocess.run( + ["docker", "compose", "-f", "docker-compose.yml", "-f", "docker-compose.override.yml", "config"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"docker-compose.override.yml syntax error: {result.stderr}" + + def test_docker_compose_staging_valid_yaml(self): + """Test that docker-compose.staging.yml has valid syntax.""" + compose_file = PROJECT_ROOT / "docker-compose.staging.yml" + if not compose_file.exists(): + pytest.skip("docker-compose.staging.yml not found") + + result = subprocess.run( + ["docker", "compose", "-f", "docker-compose.yml", "-f", "docker-compose.staging.yml", "config"], + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + ) + assert result.returncode == 0, f"docker-compose.staging.yml syntax error: {result.stderr}" + + +class TestHelperScripts: + """Test helper scripts for environment management.""" + + def test_env_switch_script_exists(self): + """Test that env-switch.sh script exists.""" + script = PROJECT_ROOT / "scripts" / "env-switch.sh" + assert script.exists(), "scripts/env-switch.sh should exist" + + def test_env_switch_script_executable(self): + """Test that env-switch.sh is executable.""" + script = PROJECT_ROOT / "scripts" / "env-switch.sh" + if not script.exists(): + pytest.skip("env-switch.sh not found") + assert os.access(script, os.X_OK), "env-switch.sh should be executable" + + def test_start_script_exists(self): + """Test that start.sh script exists.""" + script = PROJECT_ROOT / "scripts" / "start.sh" + assert script.exists(), "scripts/start.sh should exist" + + def test_start_script_executable(self): + """Test that start.sh is executable.""" + script = PROJECT_ROOT / "scripts" / "start.sh" + if not script.exists(): + pytest.skip("start.sh not found") + assert os.access(script, os.X_OK), "start.sh should be executable" + + def test_stop_script_exists(self): + """Test that stop.sh script exists.""" + script = PROJECT_ROOT / "scripts" / "stop.sh" + assert script.exists(), "scripts/stop.sh should exist" + + def test_stop_script_executable(self): + """Test that stop.sh is executable.""" + script = PROJECT_ROOT / "scripts" / "stop.sh" + if not script.exists(): + pytest.skip("stop.sh not found") + assert os.access(script, os.X_OK), "stop.sh should be executable" + + def test_promote_script_exists(self): + """Test that promote.sh script exists.""" + script = PROJECT_ROOT / "scripts" / "promote.sh" + assert script.exists(), "scripts/promote.sh should exist" + + def test_promote_script_executable(self): + """Test that promote.sh is executable.""" + script = PROJECT_ROOT / "scripts" / "promote.sh" + if not script.exists(): + pytest.skip("promote.sh not found") + assert os.access(script, os.X_OK), "promote.sh should be executable" + + def test_status_script_exists(self): + """Test that status.sh script exists.""" + script = PROJECT_ROOT / "scripts" / "status.sh" + assert script.exists(), "scripts/status.sh should exist" + + def test_status_script_executable(self): + """Test that status.sh is executable.""" + script = PROJECT_ROOT / "scripts" / "status.sh" + if not script.exists(): + pytest.skip("status.sh not found") + assert os.access(script, os.X_OK), "status.sh should be executable" + + +class TestGitIgnore: + """Test .gitignore configuration for environment files.""" + + def test_gitignore_exists(self): + """Test that .gitignore exists.""" + gitignore = PROJECT_ROOT / ".gitignore" + assert gitignore.exists(), ".gitignore should exist" + + def test_gitignore_excludes_env(self): + """Test that .gitignore excludes .env file.""" + gitignore = PROJECT_ROOT / ".gitignore" + if not gitignore.exists(): + pytest.skip(".gitignore not found") + + content = gitignore.read_text() + # Check for .env exclusion pattern + assert ".env" in content, ".gitignore should exclude .env" + + def test_gitignore_includes_env_dev(self): + """Test that .gitignore includes .env.dev (not excluded).""" + gitignore = PROJECT_ROOT / ".gitignore" + if not gitignore.exists(): + pytest.skip(".gitignore not found") + + content = gitignore.read_text() + # Check for .env.dev inclusion pattern (negation) + assert "!.env.dev" in content, ".gitignore should include .env.dev" + + def test_gitignore_includes_env_staging(self): + """Test that .gitignore includes .env.staging (not excluded).""" + gitignore = PROJECT_ROOT / ".gitignore" + if not gitignore.exists(): + pytest.skip(".gitignore not found") + + content = gitignore.read_text() + # Check for .env.staging inclusion pattern (negation) + assert "!.env.staging" in content, ".gitignore should include .env.staging" + + +class TestDocumentation: + """Test that environment documentation exists.""" + + def test_environments_md_exists(self): + """Test that environments.md documentation exists.""" + doc = PROJECT_ROOT / "docs" / "architecture" / "environments.md" + assert doc.exists(), "docs/architecture/environments.md should exist" + + def test_environment_setup_guide_exists(self): + """Test that environment-setup.md guide exists.""" + doc = PROJECT_ROOT / "docs" / "guides" / "environment-setup.md" + assert doc.exists(), "docs/guides/environment-setup.md should exist" + + def test_test_environment_setup_script_exists(self): + """Test that test-environment-setup.sh script exists.""" + script = PROJECT_ROOT / "scripts" / "test-environment-setup.sh" + assert script.exists(), "scripts/test-environment-setup.sh should exist" + + +class TestEnvironmentIsolation: + """Test environment isolation settings.""" + + @pytest.fixture + def env_configs(self) -> Dict[str, Dict[str, str]]: + """Parse environment files into dictionaries.""" + configs = {} + for env_name in ["dev", "staging"]: + env_file = PROJECT_ROOT / f".env.{env_name}" + if not env_file.exists(): + continue + config = {} + for line in env_file.read_text().split("\n"): + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, value = line.split("=", 1) + config[key.strip()] = value.strip() + configs[env_name] = config + return configs + + def test_unique_compose_project_names(self, env_configs): + """Test that each environment has a unique COMPOSE_PROJECT_NAME.""" + if len(env_configs) < 2: + pytest.skip("Need at least 2 environment files") + + project_names = [ + config.get("COMPOSE_PROJECT_NAME") + for config in env_configs.values() + if config.get("COMPOSE_PROJECT_NAME") + ] + assert len(project_names) == len(set(project_names)), "COMPOSE_PROJECT_NAME should be unique per environment" + + def test_unique_database_names(self, env_configs): + """Test that each environment uses a unique database name.""" + if len(env_configs) < 2: + pytest.skip("Need at least 2 environment files") + + db_names = [ + config.get("POSTGRES_DB") + for config in env_configs.values() + if config.get("POSTGRES_DB") + ] + assert len(db_names) == len(set(db_names)), "POSTGRES_DB should be unique per environment" + + def test_debug_disabled_in_staging(self, env_configs): + """Test that DEBUG is disabled in staging environment.""" + if "staging" not in env_configs: + pytest.skip(".env.staging not found") + + debug_value = env_configs["staging"].get("DEBUG", "").lower() + assert debug_value == "false", "DEBUG should be false in staging" + + def test_debug_enabled_in_dev(self, env_configs): + """Test that DEBUG is enabled in dev environment.""" + if "dev" not in env_configs: + pytest.skip(".env.dev not found") + + debug_value = env_configs["dev"].get("DEBUG", "").lower() + assert debug_value == "true", "DEBUG should be true in development" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..deb8498 --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +# Tests for BreakPilot Backend diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..2688a37 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,129 @@ +""" +Pytest configuration for backend tests. + +This file is loaded BEFORE any test modules are imported, +which allows us to set environment variables that are checked at import time. + +CI Environment Variables: +- CI=true: Running in CI environment (auto-detected) +- SKIP_INTEGRATION_TESTS=true: Skip tests that require external services +- SKIP_INTEGRATION_TESTS=false: Run integration tests with Docker Compose services +- SKIP_DB_TESTS=true: Skip tests that require PostgreSQL +- SKIP_WEASYPRINT_TESTS=true: Skip tests that require WeasyPrint + +Integration Test Environment: +When SKIP_INTEGRATION_TESTS=false, the tests will use the Docker Compose test environment: +- PostgreSQL: postgres-test:5432 (inside Docker network) +- Valkey/Redis: valkey-test:6379 +- Consent Service: consent-service-test:8081 +- Backend: backend-test:8000 +""" + +import os +import sys +from pathlib import Path +import pytest + +# Add backend directory to Python path so that modules like classroom_engine +# can be imported correctly during test collection +backend_dir = Path(__file__).parent.parent +if str(backend_dir) not in sys.path: + sys.path.insert(0, str(backend_dir)) + +# Detect CI environment +IS_CI = os.environ.get("CI", "").lower() in ("true", "1", "woodpecker") + +# ============================================================================= +# Integration Test Environment Detection +# ============================================================================= + +# Check if we should run integration tests (SKIP_INTEGRATION_TESTS=false means run them) +IS_INTEGRATION_ENV = os.environ.get("SKIP_INTEGRATION_TESTS", "").lower() == "false" + +if IS_INTEGRATION_ENV: + # Use Docker Compose test container URLs when in integration mode + # These URLs work inside the Docker network (container names as hostnames) + os.environ.setdefault("DATABASE_URL", + "postgresql://breakpilot:breakpilot_test@postgres-test:5432/breakpilot_test") + os.environ.setdefault("CONSENT_SERVICE_URL", + "http://consent-service-test:8081") + os.environ.setdefault("VALKEY_URL", + "redis://valkey-test:6379") + os.environ.setdefault("REDIS_URL", + "redis://valkey-test:6379") + os.environ.setdefault("SMTP_HOST", "mailpit-test") + os.environ.setdefault("SMTP_PORT", "1025") + print("[conftest.py] Integration test environment detected - using Docker Compose services") +else: + # Set DATABASE_URL before any modules are imported + # This prevents RuntimeError from rbac_api.py during test collection + if "DATABASE_URL" not in os.environ: + os.environ["DATABASE_URL"] = "postgresql://test:test@localhost:5432/test_db" + +# ============================================================================= +# Standard Test Configuration +# ============================================================================= + +# Set other required environment variables for testing +os.environ.setdefault("ENVIRONMENT", "testing") +os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only") + +# Teacher Dashboard API - disable auth for testing +os.environ.setdefault("TEACHER_REQUIRE_AUTH", "false") + +# Disable database for unit tests (use in-memory fallbacks) +os.environ.setdefault("GAME_USE_DATABASE", "false") + +# In CI, auto-enable skips for tests that require external services +# UNLESS we're explicitly running integration tests +if IS_CI and not IS_INTEGRATION_ENV: + os.environ.setdefault("SKIP_INTEGRATION_TESTS", "true") + os.environ.setdefault("SKIP_DB_TESTS", "true") + os.environ.setdefault("SKIP_WEASYPRINT_TESTS", "true") + + +def pytest_configure(config): + """Register custom markers and configure pytest.""" + config.addinivalue_line( + "markers", "integration: marks tests as integration tests (require external services)" + ) + config.addinivalue_line( + "markers", "requires_postgres: marks tests that require PostgreSQL database" + ) + config.addinivalue_line( + "markers", "requires_weasyprint: marks tests that require WeasyPrint system libraries" + ) + + +def pytest_collection_modifyitems(config, items): + """ + Automatically skip tests based on markers and environment. + + This runs after test collection and can skip tests based on: + - Environment variables (SKIP_INTEGRATION_TESTS, SKIP_DB_TESTS, etc.) + - Marker presence (integration, requires_postgres, requires_weasyprint) + """ + skip_integration = os.environ.get("SKIP_INTEGRATION_TESTS", "").lower() in ("true", "1") + skip_db = os.environ.get("SKIP_DB_TESTS", "").lower() in ("true", "1") + skip_weasyprint = os.environ.get("SKIP_WEASYPRINT_TESTS", "").lower() in ("true", "1") + + skip_integration_marker = pytest.mark.skip(reason="Skipped: SKIP_INTEGRATION_TESTS=true") + skip_db_marker = pytest.mark.skip(reason="Skipped: SKIP_DB_TESTS=true (no PostgreSQL in CI)") + skip_weasyprint_marker = pytest.mark.skip(reason="Skipped: SKIP_WEASYPRINT_TESTS=true (no libgobject in CI)") + + for item in items: + # Skip integration tests + if skip_integration and "integration" in item.keywords: + item.add_marker(skip_integration_marker) + + # Skip tests requiring PostgreSQL + if skip_db and "requires_postgres" in item.keywords: + item.add_marker(skip_db_marker) + + # Skip tests requiring WeasyPrint + if skip_weasyprint and "requires_weasyprint" in item.keywords: + item.add_marker(skip_weasyprint_marker) + + # Auto-detect test_integration folder and skip + if skip_integration and "test_integration" in str(item.fspath): + item.add_marker(skip_integration_marker) diff --git a/backend/tests/test_abitur_docs_api.py b/backend/tests/test_abitur_docs_api.py new file mode 100644 index 0000000..5b1e7f4 --- /dev/null +++ b/backend/tests/test_abitur_docs_api.py @@ -0,0 +1,397 @@ +""" +Tests fuer die Abitur-Docs API + +Tests fuer: +- NiBiS Dateinamen-Erkennung +- Dokumenten-Metadaten-Verwaltung +- ZIP-Import +- Status-Workflow +- Enum-Endpunkte +""" + +import pytest +from unittest.mock import MagicMock, patch +from datetime import datetime + +# Import des zu testenden Moduls +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from abitur_docs_api import ( + router, + AbiturDokument, + VerarbeitungsStatus, + DocumentMetadata, + RecognitionResult, + Bundesland, + AbiturFach, + Anforderungsniveau, + DokumentTyp, + parse_nibis_filename, + documents_db, +) + + +class TestNiBiSFilenameRecognition: + """Tests fuer die automatische NiBiS Dateinamen-Erkennung.""" + + def test_parse_deutsch_ea_aufgabe(self): + """Deutsch eA Aufgabe I sollte erkannt werden.""" + result = parse_nibis_filename("2025_Deutsch_eA_I.pdf") + assert result.confidence > 0.5 + assert result.extracted.get("jahr") == 2025 + assert result.extracted.get("fach") == "deutsch" + assert result.extracted.get("niveau") == "eA" + assert result.extracted.get("aufgaben_nummer") == "I" + + def test_parse_deutsch_ea_ewh(self): + """Deutsch eA Erwartungshorizont sollte erkannt werden.""" + result = parse_nibis_filename("2025_Deutsch_eA_I_EWH.pdf") + assert result.confidence > 0.5 + assert result.extracted.get("typ") == "erwartungshorizont" + + def test_parse_englisch_ga(self): + """Englisch gA sollte erkannt werden.""" + result = parse_nibis_filename("2025_Englisch_gA_II.pdf") + assert result.extracted.get("fach") == "englisch" + assert result.extracted.get("niveau") == "gA" + assert result.extracted.get("aufgaben_nummer") == "II" + + def test_parse_mathematik(self): + """Mathematik eA sollte erkannt werden.""" + result = parse_nibis_filename("2025_Mathematik_eA_III.pdf") + assert result.extracted.get("fach") == "mathematik" + + def test_parse_with_hoerverstehen(self): + """Hoerverstehen sollte erkannt werden.""" + result = parse_nibis_filename("2025_Englisch_eA_Hoerverstehen.pdf") + assert result.extracted.get("typ") == "hoerverstehen" + + def test_parse_with_sprachmittlung(self): + """Sprachmittlung sollte erkannt werden.""" + result = parse_nibis_filename("2025_Spanisch_gA_Sprachmittlung.pdf") + assert result.extracted.get("typ") == "sprachmittlung" + + def test_parse_deckblatt(self): + """Deckblatt sollte erkannt werden.""" + result = parse_nibis_filename("2025_Geschichte_eA_Deckblatt.pdf") + assert result.extracted.get("typ") == "deckblatt" + + def test_parse_unknown_format(self): + """Unbekanntes Format sollte niedrige Confidence haben.""" + result = parse_nibis_filename("random_file.pdf") + assert result.confidence < 0.3 + + def test_parse_year_extraction(self): + """Jahr sollte aus dem Dateinamen extrahiert werden.""" + result_2024 = parse_nibis_filename("2024_Deutsch_eA_I.pdf") + result_2025 = parse_nibis_filename("2025_Deutsch_eA_I.pdf") + assert result_2024.extracted.get("jahr") == 2024 + assert result_2025.extracted.get("jahr") == 2025 + + def test_parse_case_insensitive(self): + """Erkennung sollte case-insensitive sein.""" + result_upper = parse_nibis_filename("2025_DEUTSCH_EA_I.pdf") + result_lower = parse_nibis_filename("2025_deutsch_ea_i.pdf") + assert result_upper.extracted.get("fach") == "deutsch" + assert result_lower.extracted.get("fach") == "deutsch" + + +class TestVerarbeitungsStatus: + """Tests fuer den Dokument-Status-Workflow.""" + + def test_pending_status(self): + """Pending Status sollte existieren.""" + assert VerarbeitungsStatus.PENDING.value == "pending" + + def test_recognized_status(self): + """Recognized Status sollte existieren.""" + assert VerarbeitungsStatus.RECOGNIZED.value == "recognized" + + def test_confirmed_status(self): + """Confirmed Status sollte existieren.""" + assert VerarbeitungsStatus.CONFIRMED.value == "confirmed" + + def test_indexed_status(self): + """Indexed Status sollte existieren.""" + assert VerarbeitungsStatus.INDEXED.value == "indexed" + + def test_error_status(self): + """Error Status sollte existieren.""" + assert VerarbeitungsStatus.ERROR.value == "error" + + def test_status_workflow_order(self): + """Status-Workflow sollte die richtige Reihenfolge haben.""" + statuses = list(VerarbeitungsStatus) + expected_order = [ + VerarbeitungsStatus.PENDING, + VerarbeitungsStatus.PROCESSING, + VerarbeitungsStatus.RECOGNIZED, + VerarbeitungsStatus.CONFIRMED, + VerarbeitungsStatus.INDEXED, + VerarbeitungsStatus.ERROR, + ] + assert statuses == expected_order + + +class TestBundesland: + """Tests fuer die Bundesland-Enumeration.""" + + def test_niedersachsen(self): + """Niedersachsen sollte existieren.""" + assert Bundesland.NIEDERSACHSEN.value == "niedersachsen" + + def test_nrw(self): + """Nordrhein-Westfalen sollte existieren.""" + assert Bundesland.NORDRHEIN_WESTFALEN.value == "nordrhein_westfalen" + + def test_bayern(self): + """Bayern sollte existieren.""" + assert Bundesland.BAYERN.value == "bayern" + + def test_all_bundeslaender_present(self): + """Alle wichtigen Bundeslaender sollten vorhanden sein.""" + bundeslaender = [b.value for b in Bundesland] + assert "niedersachsen" in bundeslaender + assert "nordrhein_westfalen" in bundeslaender + assert "bayern" in bundeslaender + assert "baden_wuerttemberg" in bundeslaender + + +class TestAbiturFach: + """Tests fuer die Fach-Enumeration.""" + + def test_deutsch(self): + """Deutsch sollte existieren.""" + assert AbiturFach.DEUTSCH.value == "deutsch" + + def test_mathematik(self): + """Mathematik sollte existieren.""" + assert AbiturFach.MATHEMATIK.value == "mathematik" + + def test_englisch(self): + """Englisch sollte existieren.""" + assert AbiturFach.ENGLISCH.value == "englisch" + + def test_sprachen_present(self): + """Alle Hauptsprachen sollten vorhanden sein.""" + faecher = [f.value for f in AbiturFach] + assert "deutsch" in faecher + assert "englisch" in faecher + assert "franzoesisch" in faecher + assert "spanisch" in faecher + + def test_naturwissenschaften_present(self): + """Naturwissenschaften sollten vorhanden sein.""" + faecher = [f.value for f in AbiturFach] + assert "biologie" in faecher + assert "chemie" in faecher + assert "physik" in faecher + + +class TestAnforderungsniveau: + """Tests fuer die Anforderungsniveau-Enumeration.""" + + def test_ea(self): + """eA (erhoehtes Anforderungsniveau) sollte existieren.""" + assert Anforderungsniveau.EA.value == "eA" + + def test_ga(self): + """gA (grundlegendes Anforderungsniveau) sollte existieren.""" + assert Anforderungsniveau.GA.value == "gA" + + +class TestDokumentTyp: + """Tests fuer die Dokumenttyp-Enumeration.""" + + def test_aufgabe(self): + """Aufgabe sollte existieren.""" + assert DokumentTyp.AUFGABE.value == "aufgabe" + + def test_erwartungshorizont(self): + """Erwartungshorizont sollte existieren.""" + assert DokumentTyp.ERWARTUNGSHORIZONT.value == "erwartungshorizont" + + def test_all_types_present(self): + """Alle Dokumenttypen sollten vorhanden sein.""" + typen = [t.value for t in DokumentTyp] + assert "aufgabe" in typen + assert "erwartungshorizont" in typen + assert "deckblatt" in typen + assert "hoerverstehen" in typen + assert "sprachmittlung" in typen + + +class TestDocumentMetadata: + """Tests fuer die Dokumenten-Metadaten.""" + + def test_create_metadata(self): + """Metadaten sollten erstellt werden koennen.""" + metadata = DocumentMetadata( + jahr=2025, + bundesland="niedersachsen", + fach="deutsch", + niveau="eA", + dokument_typ="aufgabe", + aufgaben_nummer="I" + ) + assert metadata.jahr == 2025 + assert metadata.fach == "deutsch" + assert metadata.niveau == "eA" + + def test_optional_fields(self): + """Optionale Felder sollten None sein koennen.""" + metadata = DocumentMetadata( + jahr=None, + bundesland=None, + fach=None, + niveau=None, + dokument_typ=None, + aufgaben_nummer=None + ) + assert metadata.jahr is None + assert metadata.fach is None + + +class TestAbiturDokument: + """Tests fuer das AbiturDokument-Modell.""" + + def test_create_document(self): + """Ein Dokument sollte erstellt werden koennen.""" + # Use internal AbiturDokument dataclass with correct field names + doc = AbiturDokument( + id="doc-123", + dateiname="doc-123.pdf", + original_dateiname="2025_Deutsch_eA_I.pdf", + bundesland=Bundesland.NIEDERSACHSEN, + fach=AbiturFach.DEUTSCH, + jahr=2025, + niveau=Anforderungsniveau.EA, + typ=DokumentTyp.AUFGABE, + aufgaben_nummer="I", + status=VerarbeitungsStatus.PENDING, + confidence=0.85, + file_path="/data/docs/doc-123.pdf", + file_size=1024, + indexed=False, + vector_ids=[], + created_at=datetime.now(), + updated_at=datetime.now() + ) + assert doc.id == "doc-123" + assert doc.status == VerarbeitungsStatus.PENDING + + def test_document_with_recognition_result(self): + """Ein Dokument mit Erkennungsergebnis sollte erstellt werden koennen.""" + # Test that RecognitionResult works with extracted property + recognition = parse_nibis_filename("2025_Deutsch_eA_I.pdf") + assert recognition.confidence > 0.5 + assert recognition.extracted.get("fach") == "deutsch" + + doc = AbiturDokument( + id="doc-456", + dateiname="doc-456.pdf", + original_dateiname="2025_Deutsch_eA_I.pdf", + bundesland=Bundesland.NIEDERSACHSEN, + fach=AbiturFach.DEUTSCH, + jahr=2025, + niveau=Anforderungsniveau.EA, + typ=DokumentTyp.AUFGABE, + aufgaben_nummer="I", + status=VerarbeitungsStatus.RECOGNIZED, + confidence=recognition.confidence, + file_path="/data/docs/doc-456.pdf", + file_size=1024, + indexed=False, + vector_ids=[], + created_at=datetime.now(), + updated_at=datetime.now() + ) + assert doc.confidence > 0.5 + + +class TestRecognitionResult: + """Tests fuer das Erkennungsergebnis.""" + + def test_create_recognition_result(self): + """Ein Erkennungsergebnis sollte erstellt werden koennen.""" + result = parse_nibis_filename("2025_Deutsch_eA_I.pdf") + assert result.confidence > 0.5 + assert result.method == "filename_pattern" + assert result.extracted.get("jahr") == 2025 + assert result.extracted.get("fach") == "deutsch" + + def test_confidence_range(self): + """Confidence sollte zwischen 0 und 1 liegen.""" + result_low = parse_nibis_filename("random_file.pdf") + result_high = parse_nibis_filename("2025_Deutsch_eA_I_EWH.pdf") + assert result_low.confidence >= 0.0 + assert result_high.confidence <= 1.0 + + +class TestDocumentsDB: + """Tests fuer die In-Memory Datenbank.""" + + def setup_method(self): + """Setup vor jedem Test - leere die DB.""" + documents_db.clear() + + def test_empty_db(self): + """Eine leere DB sollte leer sein.""" + assert len(documents_db) == 0 + + def test_add_document_to_db(self): + """Ein Dokument sollte zur DB hinzugefuegt werden koennen.""" + doc = AbiturDokument( + id="test-1", + dateiname="test-1.pdf", + original_dateiname="test.pdf", + bundesland=Bundesland.NIEDERSACHSEN, + fach=AbiturFach.DEUTSCH, + jahr=2025, + niveau=Anforderungsniveau.EA, + typ=DokumentTyp.AUFGABE, + aufgaben_nummer="I", + status=VerarbeitungsStatus.PENDING, + confidence=0.85, + file_path="/data/test.pdf", + file_size=1024, + indexed=False, + vector_ids=[], + created_at=datetime.now(), + updated_at=datetime.now() + ) + documents_db["test-1"] = doc + assert "test-1" in documents_db + assert documents_db["test-1"].original_dateiname == "test.pdf" + + +class TestFilenamePatterns: + """Tests fuer verschiedene Dateinamen-Muster.""" + + def test_pattern_with_underscore(self): + """Unterstrich-Trennzeichen sollten erkannt werden.""" + result = parse_nibis_filename("2025_Biologie_eA_II.pdf") + assert result.extracted.get("fach") == "biologie" + + def test_pattern_with_aufgabe_nummer(self): + """Roemische Zahlen fuer Aufgaben sollten erkannt werden.""" + for num in ["I", "II", "III", "IV"]: + result = parse_nibis_filename(f"2025_Deutsch_eA_{num}.pdf") + assert result.extracted.get("aufgaben_nummer") == num + + def test_pattern_ewh_suffix(self): + """EWH-Suffix sollte als Erwartungshorizont erkannt werden.""" + result = parse_nibis_filename("2025_Deutsch_eA_I_EWH.pdf") + assert result.extracted.get("typ") == "erwartungshorizont" + + def test_pattern_without_aufgabe_nummer(self): + """Dateien ohne Aufgaben-Nummer sollten auch erkannt werden.""" + result = parse_nibis_filename("2025_Deutsch_eA.pdf") + assert result.extracted.get("jahr") == 2025 + assert result.extracted.get("fach") == "deutsch" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/backend/tests/test_alerts_agent/__init__.py b/backend/tests/test_alerts_agent/__init__.py new file mode 100644 index 0000000..bf424af --- /dev/null +++ b/backend/tests/test_alerts_agent/__init__.py @@ -0,0 +1 @@ +"""Tests für Alerts Agent.""" diff --git a/backend/tests/test_alerts_agent/conftest.py b/backend/tests/test_alerts_agent/conftest.py new file mode 100644 index 0000000..9e10b86 --- /dev/null +++ b/backend/tests/test_alerts_agent/conftest.py @@ -0,0 +1,106 @@ +""" +Pytest Fixtures für Alerts Agent Tests. + +Stellt eine SQLite In-Memory Datenbank für Tests bereit. +Verwendet StaticPool damit alle Connections dieselbe DB sehen. +""" +import pytest +from sqlalchemy import create_engine, event +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool +from fastapi import FastAPI +from fastapi.testclient import TestClient + +# Import der Basis und Modelle - WICHTIG: Modelle müssen vor create_all importiert werden +from classroom_engine.database import Base +# Import aller Modelle damit sie bei Base registriert werden +from alerts_agent.db import models as alerts_models # noqa: F401 +from alerts_agent.api.routes import router + + +# SQLite In-Memory für Tests mit StaticPool (dieselbe Connection für alle) +SQLALCHEMY_TEST_DATABASE_URL = "sqlite:///:memory:" + +test_engine = create_engine( + SQLALCHEMY_TEST_DATABASE_URL, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, # Wichtig: Gleiche DB für alle Connections +) + + +@event.listens_for(test_engine, "connect") +def set_sqlite_pragma(dbapi_connection, connection_record): + """SQLite Foreign Key Constraints aktivieren.""" + cursor = dbapi_connection.cursor() + cursor.execute("PRAGMA foreign_keys=ON") + cursor.close() + + +TestSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=test_engine) + + +@pytest.fixture(scope="function") +def test_db(): + """ + Erstellt eine frische Test-Datenbank für jeden Test. + """ + # Tabellen erstellen + Base.metadata.create_all(bind=test_engine) + + db = TestSessionLocal() + try: + yield db + finally: + db.close() + # Tabellen nach dem Test löschen + Base.metadata.drop_all(bind=test_engine) + + +def override_get_db(): + """Test-Database-Dependency - verwendet dieselbe Engine.""" + db = TestSessionLocal() + try: + yield db + finally: + db.close() + + +@pytest.fixture(scope="function") +def client(test_db): + """ + TestClient mit überschriebener Datenbank-Dependency. + """ + from alerts_agent.db.database import get_db + + app = FastAPI() + app.include_router(router, prefix="/api") + + # Dependency Override + app.dependency_overrides[get_db] = override_get_db + + with TestClient(app) as test_client: + yield test_client + + # Cleanup + app.dependency_overrides.clear() + + +@pytest.fixture +def sample_alert_data(): + """Beispieldaten für Alert-Tests.""" + return { + "title": "Neue Inklusions-Richtlinie", + "url": "https://example.com/inklusion", + "snippet": "Das Kultusministerium hat neue Richtlinien...", + "topic_label": "Inklusion Bayern", + } + + +@pytest.fixture +def sample_feedback_data(): + """Beispieldaten für Feedback-Tests.""" + return { + "is_relevant": True, + "reason": "Sehr relevant für Schulen", + "tags": ["wichtig", "inklusion"], + } diff --git a/backend/tests/test_alerts_agent/test_alert_item.py b/backend/tests/test_alerts_agent/test_alert_item.py new file mode 100644 index 0000000..cc7eb5c --- /dev/null +++ b/backend/tests/test_alerts_agent/test_alert_item.py @@ -0,0 +1,183 @@ +""" +Tests für AlertItem Model. +""" + +import pytest +from datetime import datetime + +from alerts_agent.models.alert_item import AlertItem, AlertSource, AlertStatus + + +class TestAlertItemCreation: + """Tests für AlertItem Erstellung.""" + + def test_create_minimal_alert(self): + """Test minimale Alert-Erstellung.""" + alert = AlertItem(title="Test Alert", url="https://example.com/article") + + assert alert.title == "Test Alert" + assert alert.url == "https://example.com/article" + assert alert.id is not None + assert len(alert.id) == 36 # UUID format + assert alert.status == AlertStatus.NEW + assert alert.source == AlertSource.GOOGLE_ALERTS_RSS + + def test_create_full_alert(self): + """Test vollständige Alert-Erstellung.""" + alert = AlertItem( + source=AlertSource.GOOGLE_ALERTS_EMAIL, + topic_label="Inklusion Bayern", + title="Neue Inklusions-Richtlinie", + url="https://example.com/inklusion", + snippet="Die neue Richtlinie für inklusive Bildung...", + lang="de", + published_at=datetime(2024, 1, 15, 10, 30), + ) + + assert alert.source == AlertSource.GOOGLE_ALERTS_EMAIL + assert alert.topic_label == "Inklusion Bayern" + assert alert.lang == "de" + assert alert.published_at.year == 2024 + + def test_url_hash_generated(self): + """Test dass URL Hash automatisch generiert wird.""" + alert = AlertItem( + title="Test", + url="https://example.com/test" + ) + + assert alert.url_hash is not None + assert len(alert.url_hash) == 16 # 16 hex chars + + def test_canonical_url_generated(self): + """Test dass kanonische URL generiert wird.""" + alert = AlertItem( + title="Test", + url="https://EXAMPLE.com/path/" + ) + + # Sollte lowercase und ohne trailing slash sein + assert alert.canonical_url == "https://example.com/path" + + +class TestURLNormalization: + """Tests für URL Normalisierung.""" + + def test_remove_tracking_params(self): + """Test Entfernung von Tracking-Parametern.""" + alert = AlertItem( + title="Test", + url="https://example.com/article?utm_source=google&utm_medium=email&id=123" + ) + + # utm_source und utm_medium sollten entfernt werden, id bleibt + assert "utm_source" not in alert.canonical_url + assert "utm_medium" not in alert.canonical_url + assert "id=123" in alert.canonical_url + + def test_lowercase_domain(self): + """Test Domain wird lowercase.""" + alert = AlertItem( + title="Test", + url="https://WWW.EXAMPLE.COM/Article" + ) + + assert "www.example.com" in alert.canonical_url + + def test_remove_fragment(self): + """Test Fragment wird entfernt.""" + alert = AlertItem( + title="Test", + url="https://example.com/article#section1" + ) + + assert "#" not in alert.canonical_url + + def test_same_url_same_hash(self): + """Test gleiche URL produziert gleichen Hash.""" + alert1 = AlertItem(title="Test", url="https://example.com/test") + alert2 = AlertItem(title="Test", url="https://example.com/test") + + assert alert1.url_hash == alert2.url_hash + + def test_different_url_different_hash(self): + """Test verschiedene URLs produzieren verschiedene Hashes.""" + alert1 = AlertItem(title="Test", url="https://example.com/test1") + alert2 = AlertItem(title="Test", url="https://example.com/test2") + + assert alert1.url_hash != alert2.url_hash + + +class TestAlertSerialization: + """Tests für Serialisierung.""" + + def test_to_dict(self): + """Test Konvertierung zu Dictionary.""" + alert = AlertItem( + title="Test Alert", + url="https://example.com", + topic_label="Test Topic", + ) + + data = alert.to_dict() + + assert data["title"] == "Test Alert" + assert data["url"] == "https://example.com" + assert data["topic_label"] == "Test Topic" + assert data["source"] == "google_alerts_rss" + assert data["status"] == "new" + + def test_from_dict(self): + """Test Erstellung aus Dictionary.""" + data = { + "id": "test-id-123", + "title": "Test Alert", + "url": "https://example.com", + "source": "google_alerts_email", + "status": "scored", + "relevance_score": 0.85, + } + + alert = AlertItem.from_dict(data) + + assert alert.id == "test-id-123" + assert alert.title == "Test Alert" + assert alert.source == AlertSource.GOOGLE_ALERTS_EMAIL + assert alert.status == AlertStatus.SCORED + assert alert.relevance_score == 0.85 + + def test_round_trip(self): + """Test Serialisierung und Deserialisierung.""" + original = AlertItem( + title="Round Trip Test", + url="https://example.com/roundtrip", + topic_label="Testing", + relevance_score=0.75, + relevance_decision="KEEP", + ) + + data = original.to_dict() + restored = AlertItem.from_dict(data) + + assert restored.title == original.title + assert restored.url == original.url + assert restored.relevance_score == original.relevance_score + + +class TestAlertStatus: + """Tests für Alert Status.""" + + def test_status_enum_values(self): + """Test Status Enum Werte.""" + assert AlertStatus.NEW.value == "new" + assert AlertStatus.PROCESSED.value == "processed" + assert AlertStatus.DUPLICATE.value == "duplicate" + assert AlertStatus.SCORED.value == "scored" + assert AlertStatus.REVIEWED.value == "reviewed" + assert AlertStatus.ARCHIVED.value == "archived" + + def test_source_enum_values(self): + """Test Source Enum Werte.""" + assert AlertSource.GOOGLE_ALERTS_RSS.value == "google_alerts_rss" + assert AlertSource.GOOGLE_ALERTS_EMAIL.value == "google_alerts_email" + assert AlertSource.MANUAL.value == "manual" diff --git a/backend/tests/test_alerts_agent/test_api_routes.py b/backend/tests/test_alerts_agent/test_api_routes.py new file mode 100644 index 0000000..e30dd4c --- /dev/null +++ b/backend/tests/test_alerts_agent/test_api_routes.py @@ -0,0 +1,594 @@ +""" +Tests für Alerts Agent API Routes. + +Testet alle Endpoints: ingest, run, inbox, feedback, profile, stats. +""" + +import pytest +from datetime import datetime +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from alerts_agent.api.routes import router, _alerts_store, _profile_store +from alerts_agent.models.alert_item import AlertStatus + + +# Test App erstellen +app = FastAPI() +app.include_router(router, prefix="/api") + + +class TestIngestEndpoint: + """Tests für POST /alerts/ingest.""" + + def setup_method(self): + """Setup für jeden Test.""" + _alerts_store.clear() + _profile_store.clear() + self.client = TestClient(app) + + def test_ingest_minimal(self): + """Test minimaler Alert-Import.""" + response = self.client.post( + "/api/alerts/ingest", + json={ + "title": "Test Alert", + "url": "https://example.com/test", + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "created" + assert "id" in data + assert len(data["id"]) == 36 # UUID + + def test_ingest_full(self): + """Test vollständiger Alert-Import.""" + response = self.client.post( + "/api/alerts/ingest", + json={ + "title": "Neue Inklusions-Richtlinie", + "url": "https://example.com/inklusion", + "snippet": "Das Kultusministerium hat neue Richtlinien...", + "topic_label": "Inklusion Bayern", + "published_at": "2024-01-15T10:30:00", + }, + ) + + assert response.status_code == 200 + data = response.json() + assert "Inklusions-Richtlinie" in data["message"] + + def test_ingest_stores_alert(self): + """Test dass Alert gespeichert wird.""" + response = self.client.post( + "/api/alerts/ingest", + json={ + "title": "Stored Alert", + "url": "https://example.com/stored", + }, + ) + + alert_id = response.json()["id"] + assert alert_id in _alerts_store + assert _alerts_store[alert_id].title == "Stored Alert" + + def test_ingest_validation_missing_title(self): + """Test Validierung: Titel fehlt.""" + response = self.client.post( + "/api/alerts/ingest", + json={ + "url": "https://example.com/test", + }, + ) + assert response.status_code == 422 + + def test_ingest_validation_missing_url(self): + """Test Validierung: URL fehlt.""" + response = self.client.post( + "/api/alerts/ingest", + json={ + "title": "Test", + }, + ) + assert response.status_code == 422 + + def test_ingest_validation_empty_title(self): + """Test Validierung: Leerer Titel.""" + response = self.client.post( + "/api/alerts/ingest", + json={ + "title": "", + "url": "https://example.com", + }, + ) + assert response.status_code == 422 + + +class TestRunEndpoint: + """Tests für POST /alerts/run.""" + + def setup_method(self): + """Setup für jeden Test.""" + _alerts_store.clear() + _profile_store.clear() + self.client = TestClient(app) + + def test_run_empty(self): + """Test Scoring ohne Alerts.""" + response = self.client.post( + "/api/alerts/run", + json={"limit": 10}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["processed"] == 0 + assert data["keep"] == 0 + assert data["drop"] == 0 + + def test_run_scores_alerts(self): + """Test Scoring bewertet Alerts.""" + # Alerts importieren + self.client.post("/api/alerts/ingest", json={ + "title": "Inklusion in Schulen", + "url": "https://example.com/1", + }) + self.client.post("/api/alerts/ingest", json={ + "title": "Stellenanzeige Lehrer", + "url": "https://example.com/2", + }) + + # Scoring starten + response = self.client.post( + "/api/alerts/run", + json={"limit": 10}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["processed"] == 2 + assert data["keep"] + data["drop"] + data["review"] == 2 + + def test_run_keyword_scoring_keep(self): + """Test Keyword-Scoring: Priorität → KEEP.""" + # Explizit "Datenschutz Schule" als Snippet für besseren Match + self.client.post("/api/alerts/ingest", json={ + "title": "Neue Datenschutz-Regelung für Schulen", + "url": "https://example.com/datenschutz", + "snippet": "Datenschutz Schule DSGVO Regelung", + }) + + response = self.client.post("/api/alerts/run", json={"limit": 10}) + data = response.json() + + # Sollte als KEEP oder REVIEW bewertet werden (nicht DROP) + assert data["drop"] == 0 + assert data["keep"] + data["review"] == 1 + + def test_run_keyword_scoring_drop(self): + """Test Keyword-Scoring: Ausschluss → DROP.""" + self.client.post("/api/alerts/ingest", json={ + "title": "Stellenanzeige: Schulleiter gesucht", + "url": "https://example.com/job", + }) + + response = self.client.post("/api/alerts/run", json={"limit": 10}) + data = response.json() + + assert data["drop"] == 1 + assert data["keep"] == 0 + + def test_run_skip_scored(self): + """Test bereits bewertete werden übersprungen.""" + self.client.post("/api/alerts/ingest", json={ + "title": "Test Alert", + "url": "https://example.com/test", + }) + + # Erstes Scoring + self.client.post("/api/alerts/run", json={"limit": 10}) + + # Zweites Scoring mit skip_scored=true + response = self.client.post( + "/api/alerts/run", + json={"limit": 10, "skip_scored": True}, + ) + data = response.json() + + assert data["processed"] == 0 + + def test_run_rescore(self): + """Test Re-Scoring mit skip_scored=false.""" + self.client.post("/api/alerts/ingest", json={ + "title": "Test Alert", + "url": "https://example.com/test", + }) + + # Erstes Scoring + self.client.post("/api/alerts/run", json={"limit": 10}) + + # Zweites Scoring mit skip_scored=false + response = self.client.post( + "/api/alerts/run", + json={"limit": 10, "skip_scored": False}, + ) + data = response.json() + + assert data["processed"] == 1 + + def test_run_limit(self): + """Test Limit Parameter.""" + # 5 Alerts importieren + for i in range(5): + self.client.post("/api/alerts/ingest", json={ + "title": f"Alert {i}", + "url": f"https://example.com/{i}", + }) + + # Nur 2 scoren + response = self.client.post( + "/api/alerts/run", + json={"limit": 2}, + ) + data = response.json() + + assert data["processed"] == 2 + + def test_run_returns_duration(self): + """Test Duration wird zurückgegeben.""" + response = self.client.post("/api/alerts/run", json={"limit": 10}) + data = response.json() + + assert "duration_ms" in data + assert isinstance(data["duration_ms"], int) + + +class TestInboxEndpoint: + """Tests für GET /alerts/inbox.""" + + def setup_method(self): + """Setup für jeden Test.""" + _alerts_store.clear() + _profile_store.clear() + self.client = TestClient(app) + + def _create_and_score_alerts(self): + """Helfer: Erstelle und score Test-Alerts.""" + # KEEP Alert + self.client.post("/api/alerts/ingest", json={ + "title": "Inklusion Regelung", + "url": "https://example.com/keep", + }) + # DROP Alert + self.client.post("/api/alerts/ingest", json={ + "title": "Stellenanzeige", + "url": "https://example.com/drop", + }) + # Scoring + self.client.post("/api/alerts/run", json={"limit": 10}) + + def test_inbox_empty(self): + """Test leere Inbox.""" + response = self.client.get("/api/alerts/inbox") + + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + assert data["total"] == 0 + + def test_inbox_shows_keep_and_review(self): + """Test Inbox zeigt KEEP und REVIEW.""" + self._create_and_score_alerts() + + response = self.client.get("/api/alerts/inbox") + data = response.json() + + # Nur KEEP sollte angezeigt werden (Stellenanzeige ist DROP) + assert data["total"] == 1 + assert data["items"][0]["relevance_decision"] == "KEEP" + + def test_inbox_filter_by_decision(self): + """Test Inbox Filter nach Decision.""" + self._create_and_score_alerts() + + # Nur DROP + response = self.client.get("/api/alerts/inbox?decision=DROP") + data = response.json() + + assert data["total"] == 1 + assert data["items"][0]["relevance_decision"] == "DROP" + + def test_inbox_pagination(self): + """Test Inbox Pagination.""" + # 5 KEEP Alerts + for i in range(5): + self.client.post("/api/alerts/ingest", json={ + "title": f"Inklusion Alert {i}", + "url": f"https://example.com/{i}", + }) + self.client.post("/api/alerts/run", json={"limit": 10}) + + # Erste Seite + response = self.client.get("/api/alerts/inbox?page=1&page_size=2") + data = response.json() + + assert data["total"] == 5 + assert len(data["items"]) == 2 + assert data["page"] == 1 + assert data["page_size"] == 2 + + # Zweite Seite + response = self.client.get("/api/alerts/inbox?page=2&page_size=2") + data = response.json() + + assert len(data["items"]) == 2 + + def test_inbox_item_fields(self): + """Test Inbox Items haben alle Felder.""" + self.client.post("/api/alerts/ingest", json={ + "title": "Test Alert", + "url": "https://example.com/test", + "snippet": "Test snippet", + "topic_label": "Test Topic", + }) + self.client.post("/api/alerts/run", json={"limit": 10}) + + response = self.client.get("/api/alerts/inbox?decision=REVIEW") + data = response.json() + + if data["items"]: + item = data["items"][0] + assert "id" in item + assert "title" in item + assert "url" in item + assert "snippet" in item + assert "topic_label" in item + assert "relevance_score" in item + assert "relevance_decision" in item + assert "status" in item + + +class TestFeedbackEndpoint: + """Tests für POST /alerts/feedback.""" + + def setup_method(self): + """Setup für jeden Test.""" + _alerts_store.clear() + _profile_store.clear() + self.client = TestClient(app) + + def _create_alert(self): + """Helfer: Erstelle Test-Alert.""" + response = self.client.post("/api/alerts/ingest", json={ + "title": "Test Alert", + "url": "https://example.com/test", + }) + return response.json()["id"] + + def test_feedback_positive(self): + """Test positives Feedback.""" + alert_id = self._create_alert() + + response = self.client.post( + "/api/alerts/feedback", + json={ + "alert_id": alert_id, + "is_relevant": True, + "reason": "Sehr relevant", + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["profile_updated"] is True + + def test_feedback_negative(self): + """Test negatives Feedback.""" + alert_id = self._create_alert() + + response = self.client.post( + "/api/alerts/feedback", + json={ + "alert_id": alert_id, + "is_relevant": False, + "reason": "Werbung", + }, + ) + + assert response.status_code == 200 + assert response.json()["success"] is True + + def test_feedback_updates_alert_status(self): + """Test Feedback aktualisiert Alert-Status.""" + alert_id = self._create_alert() + + self.client.post("/api/alerts/feedback", json={ + "alert_id": alert_id, + "is_relevant": True, + }) + + assert _alerts_store[alert_id].status == AlertStatus.REVIEWED + + def test_feedback_updates_profile(self): + """Test Feedback aktualisiert Profil.""" + alert_id = self._create_alert() + + # Positives Feedback + self.client.post("/api/alerts/feedback", json={ + "alert_id": alert_id, + "is_relevant": True, + "reason": "Wichtig", + }) + + profile = _profile_store.get("default") + assert profile is not None + assert profile.total_kept == 1 + assert len(profile.positive_examples) == 1 + + def test_feedback_not_found(self): + """Test Feedback für nicht existierenden Alert.""" + response = self.client.post( + "/api/alerts/feedback", + json={ + "alert_id": "non-existent-id", + "is_relevant": True, + }, + ) + + assert response.status_code == 404 + + def test_feedback_with_tags(self): + """Test Feedback mit Tags.""" + alert_id = self._create_alert() + + response = self.client.post( + "/api/alerts/feedback", + json={ + "alert_id": alert_id, + "is_relevant": True, + "tags": ["wichtig", "inklusion"], + }, + ) + + assert response.status_code == 200 + + +class TestProfileEndpoint: + """Tests für GET/PUT /alerts/profile.""" + + def setup_method(self): + """Setup für jeden Test.""" + _alerts_store.clear() + _profile_store.clear() + self.client = TestClient(app) + + def test_get_profile_default(self): + """Test Default-Profil abrufen.""" + response = self.client.get("/api/alerts/profile") + + assert response.status_code == 200 + data = response.json() + assert "id" in data + assert "priorities" in data + assert "exclusions" in data + assert len(data["priorities"]) > 0 # Default hat Prioritäten + + def test_get_profile_creates_default(self): + """Test Profil wird automatisch erstellt.""" + assert "default" not in _profile_store + + self.client.get("/api/alerts/profile") + + assert "default" in _profile_store + + def test_update_profile_priorities(self): + """Test Prioritäten aktualisieren.""" + response = self.client.put( + "/api/alerts/profile", + json={ + "priorities": [ + {"label": "Neue Priorität", "weight": 0.9}, + {"label": "Zweite Priorität", "weight": 0.7}, + ], + }, + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["priorities"]) == 2 + assert data["priorities"][0]["label"] == "Neue Priorität" + + def test_update_profile_exclusions(self): + """Test Ausschlüsse aktualisieren.""" + response = self.client.put( + "/api/alerts/profile", + json={ + "exclusions": ["Spam", "Werbung", "Newsletter"], + }, + ) + + assert response.status_code == 200 + data = response.json() + assert "Spam" in data["exclusions"] + assert len(data["exclusions"]) == 3 + + def test_update_profile_policies(self): + """Test Policies aktualisieren.""" + response = self.client.put( + "/api/alerts/profile", + json={ + "policies": { + "max_age_days": 14, + "prefer_german_sources": True, + }, + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["policies"]["max_age_days"] == 14 + + def test_profile_stats(self): + """Test Profil enthält Statistiken.""" + response = self.client.get("/api/alerts/profile") + data = response.json() + + assert "total_scored" in data + assert "total_kept" in data + assert "total_dropped" in data + + +class TestStatsEndpoint: + """Tests für GET /alerts/stats.""" + + def setup_method(self): + """Setup für jeden Test.""" + _alerts_store.clear() + _profile_store.clear() + self.client = TestClient(app) + + def test_stats_empty(self): + """Test Stats ohne Alerts.""" + response = self.client.get("/api/alerts/stats") + + assert response.status_code == 200 + data = response.json() + assert data["total_alerts"] == 0 + + def test_stats_with_alerts(self): + """Test Stats mit Alerts.""" + # Alerts erstellen und scoren + self.client.post("/api/alerts/ingest", json={ + "title": "Inklusion", + "url": "https://example.com/1", + }) + self.client.post("/api/alerts/ingest", json={ + "title": "Stellenanzeige", + "url": "https://example.com/2", + }) + self.client.post("/api/alerts/run", json={"limit": 10}) + + response = self.client.get("/api/alerts/stats") + data = response.json() + + assert data["total_alerts"] == 2 + assert "by_status" in data + assert "by_decision" in data + assert "scored" in data["by_status"] + + def test_stats_avg_score(self): + """Test Durchschnittlicher Score.""" + self.client.post("/api/alerts/ingest", json={ + "title": "Test", + "url": "https://example.com/1", + }) + self.client.post("/api/alerts/run", json={"limit": 10}) + + response = self.client.get("/api/alerts/stats") + data = response.json() + + assert "avg_score" in data + assert data["avg_score"] is not None diff --git a/backend/tests/test_alerts_agent/test_dedup.py b/backend/tests/test_alerts_agent/test_dedup.py new file mode 100644 index 0000000..8b0c72f --- /dev/null +++ b/backend/tests/test_alerts_agent/test_dedup.py @@ -0,0 +1,224 @@ +""" +Tests für Deduplication Module. +""" + +import pytest + +from alerts_agent.processing.dedup import ( + compute_simhash, + hamming_distance, + are_similar, + find_duplicates, + exact_url_duplicates, +) +from alerts_agent.models.alert_item import AlertItem + + +class TestSimHash: + """Tests für SimHash Berechnung.""" + + def test_compute_simhash_returns_hex(self): + """Test SimHash gibt Hex-String zurück.""" + text = "Dies ist ein Test für SimHash Berechnung" + result = compute_simhash(text) + + assert isinstance(result, str) + assert len(result) == 16 + # Prüfe dass es gültiges Hex ist + int(result, 16) + + def test_empty_text_returns_zeros(self): + """Test leerer Text gibt Null-Hash.""" + assert compute_simhash("") == "0" * 16 + assert compute_simhash(None) == "0" * 16 + + def test_identical_texts_same_hash(self): + """Test identische Texte haben gleichen Hash.""" + text = "Inklusion in bayerischen Schulen wird verstärkt" + hash1 = compute_simhash(text) + hash2 = compute_simhash(text) + + assert hash1 == hash2 + + def test_similar_texts_similar_hash(self): + """Test ähnliche Texte haben ähnlichen Hash.""" + text1 = "Inklusion in bayerischen Schulen wird verstärkt" + text2 = "Inklusion in bayerischen Schulen wurde verstärkt" + + hash1 = compute_simhash(text1) + hash2 = compute_simhash(text2) + + # Ähnliche Texte sollten geringe Hamming-Distanz haben + distance = hamming_distance(hash1, hash2) + assert distance < 20 # Relativ ähnlich + + def test_different_texts_different_hash(self): + """Test verschiedene Texte haben verschiedenen Hash.""" + text1 = "Inklusion in bayerischen Schulen" + text2 = "Fußball Bundesliga Spieltag" + + hash1 = compute_simhash(text1) + hash2 = compute_simhash(text2) + + assert hash1 != hash2 + + def test_stopwords_ignored(self): + """Test Stoppwörter werden ignoriert.""" + text1 = "Die neue Regelung für Inklusion" + text2 = "Eine neue Regelung für die Inklusion" + + hash1 = compute_simhash(text1) + hash2 = compute_simhash(text2) + + # Trotz unterschiedlicher Stoppwörter ähnlich + distance = hamming_distance(hash1, hash2) + assert distance < 10 + + +class TestHammingDistance: + """Tests für Hamming-Distanz.""" + + def test_identical_hashes_zero_distance(self): + """Test identische Hashes haben Distanz 0.""" + hash1 = "abcdef0123456789" + hash2 = "abcdef0123456789" + + assert hamming_distance(hash1, hash2) == 0 + + def test_completely_different_max_distance(self): + """Test komplett verschiedene Hashes haben max Distanz.""" + hash1 = "0000000000000000" + hash2 = "ffffffffffffffff" + + assert hamming_distance(hash1, hash2) == 64 + + def test_one_bit_difference(self): + """Test ein Bit Unterschied.""" + hash1 = "0000000000000000" + hash2 = "0000000000000001" + + assert hamming_distance(hash1, hash2) == 1 + + def test_invalid_hash_returns_max(self): + """Test ungültiger Hash gibt maximale Distanz.""" + assert hamming_distance("", "abc") == 64 + assert hamming_distance("invalid", "abc") == 64 + + def test_symmetric(self): + """Test Hamming-Distanz ist symmetrisch.""" + hash1 = "abcd1234abcd1234" + hash2 = "1234abcd1234abcd" + + assert hamming_distance(hash1, hash2) == hamming_distance(hash2, hash1) + + +class TestAreSimilar: + """Tests für Ähnlichkeitsprüfung.""" + + def test_identical_are_similar(self): + """Test identische Hashes sind ähnlich.""" + hash1 = "abcdef0123456789" + assert are_similar(hash1, hash1) + + def test_threshold_respected(self): + """Test Schwellenwert wird respektiert.""" + hash1 = "0000000000000000" + hash2 = "0000000000000003" # 2 Bits unterschiedlich + + assert are_similar(hash1, hash2, threshold=5) + assert are_similar(hash1, hash2, threshold=2) + assert not are_similar(hash1, hash2, threshold=1) + + +class TestFindDuplicates: + """Tests für Duplikat-Erkennung.""" + + def test_no_duplicates(self): + """Test keine Duplikate wenn alle verschieden.""" + items = [ + AlertItem(title="Unique 1", url="https://example.com/1"), + AlertItem(title="Unique 2", url="https://example.com/2"), + ] + # Setze verschiedene Hashes + items[0].content_hash = "0000000000000000" + items[1].content_hash = "ffffffffffffffff" + + duplicates = find_duplicates(items) + assert len(duplicates) == 0 + + def test_finds_duplicates(self): + """Test findet Duplikate mit ähnlichen Hashes.""" + items = [ + AlertItem(title="Original", url="https://example.com/1"), + AlertItem(title="Duplicate", url="https://example.com/2"), + AlertItem(title="Different", url="https://example.com/3"), + ] + # Setze ähnliche Hashes für die ersten beiden + items[0].content_hash = "0000000000000000" + items[1].content_hash = "0000000000000001" # 1 Bit unterschiedlich + items[2].content_hash = "ffffffffffffffff" # Komplett anders + + duplicates = find_duplicates(items, threshold=3) + + # Beide sollten im gleichen Cluster sein + assert len(duplicates) == 2 + assert duplicates[items[0].id] == duplicates[items[1].id] + + def test_empty_list(self): + """Test leere Liste.""" + duplicates = find_duplicates([]) + assert len(duplicates) == 0 + + def test_items_without_hash_skipped(self): + """Test Items ohne Hash werden übersprungen.""" + items = [ + AlertItem(title="No Hash", url="https://example.com/1"), + ] + # content_hash bleibt None + + duplicates = find_duplicates(items) + assert len(duplicates) == 0 + + +class TestExactUrlDuplicates: + """Tests für exakte URL Duplikate.""" + + def test_finds_exact_duplicates(self): + """Test findet exakte URL Duplikate.""" + items = [ + AlertItem(title="First", url="https://example.com/article"), + AlertItem(title="Second", url="https://example.com/article"), # Duplikat + AlertItem(title="Third", url="https://example.com/other"), + ] + + duplicates = exact_url_duplicates(items) + + assert len(duplicates) == 1 + assert items[1].id in duplicates + assert items[0].id not in duplicates # Original, nicht Duplikat + + def test_no_duplicates(self): + """Test keine Duplikate bei verschiedenen URLs.""" + items = [ + AlertItem(title="First", url="https://example.com/1"), + AlertItem(title="Second", url="https://example.com/2"), + ] + + duplicates = exact_url_duplicates(items) + assert len(duplicates) == 0 + + def test_multiple_duplicates(self): + """Test mehrere Duplikate der gleichen URL.""" + items = [ + AlertItem(title="First", url="https://example.com/same"), + AlertItem(title="Second", url="https://example.com/same"), + AlertItem(title="Third", url="https://example.com/same"), + ] + + duplicates = exact_url_duplicates(items) + + # Zweites und drittes sollten als Duplikate markiert sein + assert len(duplicates) == 2 + assert items[0].id not in duplicates + assert items[1].id in duplicates + assert items[2].id in duplicates diff --git a/backend/tests/test_alerts_agent/test_feedback_learning.py b/backend/tests/test_alerts_agent/test_feedback_learning.py new file mode 100644 index 0000000..1ab2d82 --- /dev/null +++ b/backend/tests/test_alerts_agent/test_feedback_learning.py @@ -0,0 +1,262 @@ +""" +Tests für den Feedback-Learning-Mechanismus. + +Testet wie das System aus Nutzer-Feedback lernt und das Profil anpasst. +""" + +import pytest +from datetime import datetime + +from alerts_agent.models.relevance_profile import RelevanceProfile, PriorityItem +from alerts_agent.models.alert_item import AlertItem + + +class TestFeedbackLearning: + """Tests für den Feedback-Learning-Mechanismus.""" + + def test_positive_feedback_adds_example(self): + """Test positives Feedback fügt Beispiel hinzu.""" + profile = RelevanceProfile() + + profile.update_from_feedback( + alert_title="Wichtiger Artikel zur Inklusion", + alert_url="https://example.com/inklusion", + is_relevant=True, + reason="Sehr relevant für meine Arbeit", + ) + + assert len(profile.positive_examples) == 1 + assert profile.positive_examples[0]["title"] == "Wichtiger Artikel zur Inklusion" + assert profile.positive_examples[0]["reason"] == "Sehr relevant für meine Arbeit" + + def test_negative_feedback_adds_example(self): + """Test negatives Feedback fügt Beispiel hinzu.""" + profile = RelevanceProfile() + + profile.update_from_feedback( + alert_title="Stellenanzeige Lehrer", + alert_url="https://example.com/job", + is_relevant=False, + reason="Nur Werbung", + ) + + assert len(profile.negative_examples) == 1 + assert profile.negative_examples[0]["title"] == "Stellenanzeige Lehrer" + + def test_feedback_updates_counters(self): + """Test Feedback aktualisiert Zähler.""" + profile = RelevanceProfile() + + # 3 positive, 2 negative + for i in range(3): + profile.update_from_feedback(f"Good {i}", f"url{i}", True) + for i in range(2): + profile.update_from_feedback(f"Bad {i}", f"url{i}", False) + + assert profile.total_scored == 5 + assert profile.total_kept == 3 + assert profile.total_dropped == 2 + + def test_examples_limited_to_20(self): + """Test Beispiele werden auf 20 begrenzt.""" + profile = RelevanceProfile() + + # 25 Beispiele hinzufügen + for i in range(25): + profile.update_from_feedback( + f"Example {i}", + f"https://example.com/{i}", + is_relevant=True, + ) + + assert len(profile.positive_examples) == 20 + # Die neuesten sollten behalten werden + titles = [ex["title"] for ex in profile.positive_examples] + assert "Example 24" in titles + assert "Example 0" not in titles # Ältestes sollte weg sein + + def test_examples_in_prompt_context(self): + """Test Beispiele erscheinen im Prompt-Kontext.""" + profile = RelevanceProfile() + + profile.update_from_feedback( + "Relevanter Artikel", + "https://example.com/good", + is_relevant=True, + reason="Wichtig", + ) + profile.update_from_feedback( + "Irrelevanter Artikel", + "https://example.com/bad", + is_relevant=False, + reason="Spam", + ) + + context = profile.get_prompt_context() + + assert "Relevanter Artikel" in context + assert "Irrelevanter Artikel" in context + assert "relevante Alerts" in context + assert "irrelevante Alerts" in context + + +class TestProfileEvolution: + """Tests für die Evolution des Profils über Zeit.""" + + def test_profile_learns_from_feedback_pattern(self): + """Test Profil lernt aus Feedback-Mustern.""" + profile = RelevanceProfile() + + # Simuliere Feedback-Muster: Inklusions-Artikel sind relevant + inklusion_articles = [ + ("Neue Inklusions-Verordnung", "https://example.com/1"), + ("Inklusion in Bayern verstärkt", "https://example.com/2"), + ("Förderbedarf: Neue Richtlinien", "https://example.com/3"), + ] + + for title, url in inklusion_articles: + profile.update_from_feedback(title, url, is_relevant=True, reason="Inklusion") + + # Simuliere irrelevante Artikel + spam_articles = [ + ("Newsletter Dezember", "https://example.com/spam1"), + ("Pressemitteilung", "https://example.com/spam2"), + ] + + for title, url in spam_articles: + profile.update_from_feedback(title, url, is_relevant=False, reason="Spam") + + # Prompt-Kontext sollte die Muster reflektieren + context = profile.get_prompt_context() + + # Alle positiven Beispiele sollten Inklusions-bezogen sein + for title, _ in inklusion_articles: + assert title in context + + # Negative Beispiele sollten auch vorhanden sein + for title, _ in spam_articles: + assert title in context + + def test_profile_statistics_reflect_decisions(self): + """Test Profil-Statistiken reflektieren Entscheidungen.""" + profile = RelevanceProfile() + + # 70% relevant, 30% irrelevant + for i in range(70): + profile.update_from_feedback(f"Good {i}", f"url{i}", True) + for i in range(30): + profile.update_from_feedback(f"Bad {i}", f"url{i}", False) + + assert profile.total_scored == 100 + assert profile.total_kept == 70 + assert profile.total_dropped == 30 + + # Keep-Rate sollte 70% sein + keep_rate = profile.total_kept / profile.total_scored + assert keep_rate == 0.7 + + +class TestFeedbackWithPriorities: + """Tests für Feedback in Kombination mit Prioritäten.""" + + def test_priority_keywords_in_feedback(self): + """Test Feedback-Beispiele ergänzen Prioritäts-Keywords.""" + profile = RelevanceProfile() + profile.add_priority( + "Inklusion", + weight=0.9, + keywords=["Förderbedarf", "inklusiv"], + ) + + # Feedback mit zusätzlichem Kontext + profile.update_from_feedback( + "Nachteilsausgleich für Schüler mit Förderbedarf", + "https://example.com/nachteilsausgleich", + is_relevant=True, + reason="Nachteilsausgleich ist wichtig für Inklusion", + ) + + # Das Feedback-Beispiel sollte im Kontext erscheinen + context = profile.get_prompt_context() + assert "Nachteilsausgleich" in context + + def test_exclusion_patterns_from_feedback(self): + """Test Ausschlüsse werden durch Feedback-Muster erkannt.""" + profile = RelevanceProfile() + + # Mehrere Stellenanzeigen als irrelevant markieren + for i in range(5): + profile.update_from_feedback( + f"Stellenanzeige: Position {i}", + f"https://example.com/job{i}", + is_relevant=False, + reason="Stellenanzeige", + ) + + # Das Muster sollte in negativen Beispielen sichtbar sein + assert len(profile.negative_examples) == 5 + assert all("Stellenanzeige" in ex["title"] for ex in profile.negative_examples) + + +class TestDefaultProfileFeedback: + """Tests für Feedback mit dem Default-Bildungsprofil.""" + + def test_default_profile_with_feedback(self): + """Test Default-Profil kann Feedback verarbeiten.""" + profile = RelevanceProfile.create_default_education_profile() + + # Starte mit Default-Werten + initial_examples = len(profile.positive_examples) + + # Füge Feedback hinzu + profile.update_from_feedback( + "Datenschutz an Schulen: Neue DSGVO-Richtlinien", + "https://example.com/dsgvo", + is_relevant=True, + reason="DSGVO-relevant", + ) + + assert len(profile.positive_examples) == initial_examples + 1 + assert profile.total_kept == 1 + + def test_default_priorities_preserved_after_feedback(self): + """Test Default-Prioritäten bleiben nach Feedback erhalten.""" + profile = RelevanceProfile.create_default_education_profile() + original_priorities = len(profile.priorities) + + # Feedback sollte Prioritäten nicht ändern + profile.update_from_feedback("Test", "https://test.com", True) + + assert len(profile.priorities) == original_priorities + + +class TestFeedbackTimestamps: + """Tests für Feedback-Zeitstempel.""" + + def test_feedback_has_timestamp(self): + """Test Feedback-Beispiele haben Zeitstempel.""" + profile = RelevanceProfile() + + profile.update_from_feedback( + "Test Article", + "https://example.com", + is_relevant=True, + ) + + example = profile.positive_examples[0] + assert "added_at" in example + # Sollte ein ISO-Format Datum sein + datetime.fromisoformat(example["added_at"]) + + def test_profile_updated_at_changes(self): + """Test updated_at ändert sich nach Feedback.""" + profile = RelevanceProfile() + original_updated = profile.updated_at + + # Kurz warten und Feedback geben + import time + time.sleep(0.01) + + profile.update_from_feedback("Test", "https://test.com", True) + + assert profile.updated_at > original_updated diff --git a/backend/tests/test_alerts_agent/test_relevance_profile.py b/backend/tests/test_alerts_agent/test_relevance_profile.py new file mode 100644 index 0000000..87ac1ae --- /dev/null +++ b/backend/tests/test_alerts_agent/test_relevance_profile.py @@ -0,0 +1,296 @@ +""" +Tests für RelevanceProfile Model. +""" + +import pytest +from datetime import datetime + +from alerts_agent.models.relevance_profile import RelevanceProfile, PriorityItem + + +class TestPriorityItem: + """Tests für PriorityItem.""" + + def test_create_minimal(self): + """Test minimale Erstellung.""" + item = PriorityItem(label="Test Topic") + + assert item.label == "Test Topic" + assert item.weight == 0.5 # Default + assert item.keywords == [] + assert item.description is None + + def test_create_full(self): + """Test vollständige Erstellung.""" + item = PriorityItem( + label="Inklusion", + weight=0.9, + keywords=["inklusiv", "Förderbedarf"], + description="Inklusive Bildung in Schulen", + ) + + assert item.label == "Inklusion" + assert item.weight == 0.9 + assert "inklusiv" in item.keywords + assert item.description is not None + + def test_to_dict(self): + """Test Serialisierung.""" + item = PriorityItem(label="Test", weight=0.8, keywords=["kw1", "kw2"]) + data = item.to_dict() + + assert data["label"] == "Test" + assert data["weight"] == 0.8 + assert data["keywords"] == ["kw1", "kw2"] + + def test_from_dict(self): + """Test Deserialisierung.""" + data = {"label": "Test", "weight": 0.7, "keywords": ["test"]} + item = PriorityItem.from_dict(data) + + assert item.label == "Test" + assert item.weight == 0.7 + + +class TestRelevanceProfile: + """Tests für RelevanceProfile.""" + + def test_create_empty(self): + """Test leeres Profil.""" + profile = RelevanceProfile() + + assert profile.id is not None + assert profile.priorities == [] + assert profile.exclusions == [] + assert profile.positive_examples == [] + assert profile.negative_examples == [] + + def test_add_priority(self): + """Test Priorität hinzufügen.""" + profile = RelevanceProfile() + profile.add_priority("Datenschutz", weight=0.85) + + assert len(profile.priorities) == 1 + assert profile.priorities[0].label == "Datenschutz" + assert profile.priorities[0].weight == 0.85 + + def test_add_exclusion(self): + """Test Ausschluss hinzufügen.""" + profile = RelevanceProfile() + profile.add_exclusion("Stellenanzeige") + profile.add_exclusion("Werbung") + + assert len(profile.exclusions) == 2 + assert "Stellenanzeige" in profile.exclusions + assert "Werbung" in profile.exclusions + + def test_no_duplicate_exclusions(self): + """Test keine doppelten Ausschlüsse.""" + profile = RelevanceProfile() + profile.add_exclusion("Test") + profile.add_exclusion("Test") + + assert len(profile.exclusions) == 1 + + def test_add_positive_example(self): + """Test positives Beispiel hinzufügen.""" + profile = RelevanceProfile() + profile.add_positive_example( + title="Gutes Beispiel", + url="https://example.com", + reason="Relevant für Thema X", + ) + + assert len(profile.positive_examples) == 1 + assert profile.positive_examples[0]["title"] == "Gutes Beispiel" + assert profile.positive_examples[0]["reason"] == "Relevant für Thema X" + + def test_add_negative_example(self): + """Test negatives Beispiel hinzufügen.""" + profile = RelevanceProfile() + profile.add_negative_example( + title="Schlechtes Beispiel", + url="https://example.com", + reason="Werbung", + ) + + assert len(profile.negative_examples) == 1 + + def test_examples_limited_to_20(self): + """Test Beispiele auf 20 begrenzt.""" + profile = RelevanceProfile() + + for i in range(25): + profile.add_positive_example( + title=f"Example {i}", + url=f"https://example.com/{i}", + ) + + assert len(profile.positive_examples) == 20 + # Sollte die neuesten behalten + assert "Example 24" in profile.positive_examples[-1]["title"] + + def test_update_from_feedback_positive(self): + """Test Feedback Update (positiv).""" + profile = RelevanceProfile() + profile.update_from_feedback( + alert_title="Relevant Article", + alert_url="https://example.com", + is_relevant=True, + reason="Sehr relevant", + ) + + assert len(profile.positive_examples) == 1 + assert profile.total_kept == 1 + assert profile.total_scored == 1 + + def test_update_from_feedback_negative(self): + """Test Feedback Update (negativ).""" + profile = RelevanceProfile() + profile.update_from_feedback( + alert_title="Irrelevant Article", + alert_url="https://example.com", + is_relevant=False, + reason="Werbung", + ) + + assert len(profile.negative_examples) == 1 + assert profile.total_dropped == 1 + assert profile.total_scored == 1 + + +class TestPromptContext: + """Tests für Prompt-Kontext Generierung.""" + + def test_empty_profile_prompt(self): + """Test Prompt für leeres Profil.""" + profile = RelevanceProfile() + context = profile.get_prompt_context() + + assert "Relevanzprofil" in context + # Leeres Profil hat keine Prioritäten/Ausschlüsse + assert "Prioritäten" not in context + + def test_priorities_in_prompt(self): + """Test Prioritäten im Prompt.""" + profile = RelevanceProfile() + profile.add_priority("Inklusion", weight=0.9, description="Sehr wichtig") + + context = profile.get_prompt_context() + + assert "Inklusion" in context + assert "Sehr wichtig" in context + + def test_exclusions_in_prompt(self): + """Test Ausschlüsse im Prompt.""" + profile = RelevanceProfile() + profile.add_exclusion("Stellenanzeige") + profile.add_exclusion("Werbung") + + context = profile.get_prompt_context() + + assert "Stellenanzeige" in context + assert "Werbung" in context + assert "Ausschlüsse" in context + + def test_examples_in_prompt(self): + """Test Beispiele im Prompt.""" + profile = RelevanceProfile() + profile.add_positive_example( + title="Gutes Beispiel", + url="https://example.com", + reason="Relevant", + ) + + context = profile.get_prompt_context() + + assert "Gutes Beispiel" in context + assert "relevante Alerts" in context + + +class TestDefaultEducationProfile: + """Tests für das Standard-Bildungsprofil.""" + + def test_create_default_profile(self): + """Test Default-Profil Erstellung.""" + profile = RelevanceProfile.create_default_education_profile() + + assert len(profile.priorities) > 0 + assert len(profile.exclusions) > 0 + assert len(profile.policies) > 0 + + def test_default_priorities(self): + """Test Default-Prioritäten enthalten Bildungsthemen.""" + profile = RelevanceProfile.create_default_education_profile() + + labels = [p.label for p in profile.priorities] + + assert "Inklusion" in labels + assert "Datenschutz Schule" in labels + assert "Schulrecht Bayern" in labels + + def test_default_exclusions(self): + """Test Default-Ausschlüsse.""" + profile = RelevanceProfile.create_default_education_profile() + + assert "Stellenanzeige" in profile.exclusions + assert "Werbung" in profile.exclusions + + def test_default_policies(self): + """Test Default-Policies.""" + profile = RelevanceProfile.create_default_education_profile() + + assert profile.policies.get("prefer_german_sources") is True + assert "max_age_days" in profile.policies + + +class TestSerialization: + """Tests für Serialisierung.""" + + def test_to_dict(self): + """Test Konvertierung zu Dict.""" + profile = RelevanceProfile() + profile.add_priority("Test", weight=0.7) + profile.add_exclusion("Exclude") + + data = profile.to_dict() + + assert "id" in data + assert len(data["priorities"]) == 1 + assert "Exclude" in data["exclusions"] + assert "created_at" in data + + def test_from_dict(self): + """Test Erstellung aus Dict.""" + data = { + "id": "test-id", + "priorities": [{"label": "Test", "weight": 0.8, "keywords": [], "description": None}], + "exclusions": ["Exclude"], + "positive_examples": [], + "negative_examples": [], + "policies": {"key": "value"}, + "created_at": "2024-01-15T10:00:00", + "updated_at": "2024-01-15T10:00:00", + "total_scored": 100, + "total_kept": 60, + "total_dropped": 40, + "accuracy_estimate": None, + } + + profile = RelevanceProfile.from_dict(data) + + assert profile.id == "test-id" + assert len(profile.priorities) == 1 + assert profile.total_scored == 100 + + def test_round_trip(self): + """Test Serialisierung/Deserialisierung Roundtrip.""" + original = RelevanceProfile.create_default_education_profile() + original.add_positive_example("Test", "https://test.com") + + data = original.to_dict() + restored = RelevanceProfile.from_dict(data) + + assert restored.id == original.id + assert len(restored.priorities) == len(original.priorities) + assert len(restored.positive_examples) == len(original.positive_examples) diff --git a/backend/tests/test_alerts_agent/test_relevance_scorer.py b/backend/tests/test_alerts_agent/test_relevance_scorer.py new file mode 100644 index 0000000..0062206 --- /dev/null +++ b/backend/tests/test_alerts_agent/test_relevance_scorer.py @@ -0,0 +1,403 @@ +""" +Tests für RelevanceScorer. + +Testet sowohl die LLM-Integration als auch das Response-Parsing. +""" + +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from datetime import datetime + +from alerts_agent.processing.relevance_scorer import ( + RelevanceScorer, + RelevanceDecision, + ScoringResult, + RELEVANCE_SYSTEM_PROMPT, +) +from alerts_agent.models.alert_item import AlertItem, AlertStatus +from alerts_agent.models.relevance_profile import RelevanceProfile + + +class TestScoringResult: + """Tests für ScoringResult Dataclass.""" + + def test_create_result(self): + """Test ScoringResult Erstellung.""" + result = ScoringResult( + alert_id="test-123", + score=0.85, + decision=RelevanceDecision.KEEP, + reason_codes=["matches_priority"], + summary="Relevant für Inklusion", + ) + + assert result.alert_id == "test-123" + assert result.score == 0.85 + assert result.decision == RelevanceDecision.KEEP + + def test_result_to_dict(self): + """Test Serialisierung.""" + result = ScoringResult( + alert_id="test-123", + score=0.5, + decision=RelevanceDecision.REVIEW, + ) + data = result.to_dict() + + assert data["alert_id"] == "test-123" + assert data["decision"] == "REVIEW" + assert "scored_at" in data + + def test_decision_enum(self): + """Test RelevanceDecision Enum.""" + assert RelevanceDecision.KEEP.value == "KEEP" + assert RelevanceDecision.DROP.value == "DROP" + assert RelevanceDecision.REVIEW.value == "REVIEW" + + +class TestRelevanceScorerInit: + """Tests für RelevanceScorer Initialisierung.""" + + def test_default_config(self): + """Test Default-Konfiguration.""" + scorer = RelevanceScorer() + + assert scorer.gateway_url == "http://localhost:8000/llm" + assert scorer.model == "breakpilot-teacher-8b" + assert scorer.keep_threshold == 0.7 + assert scorer.drop_threshold == 0.4 + + def test_custom_config(self): + """Test Custom-Konfiguration.""" + scorer = RelevanceScorer( + gateway_url="http://custom:8080/llm", + api_key="test-key", + model="custom-model", + timeout=60, + ) + + assert scorer.gateway_url == "http://custom:8080/llm" + assert scorer.api_key == "test-key" + assert scorer.model == "custom-model" + assert scorer.timeout == 60 + + +class TestPromptBuilding: + """Tests für Prompt-Erstellung.""" + + def test_build_user_prompt(self): + """Test User-Prompt Erstellung.""" + scorer = RelevanceScorer() + alert = AlertItem( + title="Neue Inklusions-Richtlinie", + url="https://example.com/inklusion", + snippet="Das Kultusministerium hat...", + topic_label="Inklusion Bayern", + ) + + prompt = scorer._build_user_prompt(alert) + + assert "Neue Inklusions-Richtlinie" in prompt + assert "Inklusion Bayern" in prompt + assert "https://example.com/inklusion" in prompt + assert "Kultusministerium" in prompt + + def test_build_user_prompt_long_snippet(self): + """Test Snippet wird gekürzt.""" + scorer = RelevanceScorer() + alert = AlertItem( + title="Test", + url="https://example.com", + snippet="x" * 1000, # Langer Snippet + ) + + prompt = scorer._build_user_prompt(alert) + + # Sollte auf 500 Zeichen + "..." gekürzt sein + assert "..." in prompt + assert len(prompt) < 1000 + + def test_build_system_prompt_without_profile(self): + """Test System-Prompt ohne Profil.""" + scorer = RelevanceScorer() + prompt = scorer._build_system_prompt(None) + + assert "Relevanz-Filter" in prompt + assert "KEEP" in prompt + assert "DROP" in prompt + assert "JSON" in prompt + + def test_build_system_prompt_with_profile(self): + """Test System-Prompt mit Profil.""" + scorer = RelevanceScorer() + profile = RelevanceProfile() + profile.add_priority("Inklusion", weight=0.9) + profile.add_exclusion("Stellenanzeige") + + prompt = scorer._build_system_prompt(profile) + + assert "Relevanzprofil" in prompt + assert "Inklusion" in prompt + assert "Stellenanzeige" in prompt + + +class TestResponseParsing: + """Tests für LLM Response Parsing.""" + + def test_parse_valid_json(self): + """Test Parse gültiges JSON.""" + scorer = RelevanceScorer() + response = '''{"score": 0.85, "decision": "KEEP", "reason_codes": ["matches_priority"], "summary": "Relevant"}''' + + result = scorer._parse_response(response, "test-id") + + assert result.score == 0.85 + assert result.decision == RelevanceDecision.KEEP + assert "matches_priority" in result.reason_codes + assert result.summary == "Relevant" + + def test_parse_json_in_markdown(self): + """Test Parse JSON in Markdown Code-Block.""" + scorer = RelevanceScorer() + response = '''Hier ist meine Bewertung: +```json +{"score": 0.3, "decision": "DROP", "reason_codes": ["exclusion"]} +``` +''' + result = scorer._parse_response(response, "test-id") + + assert result.score == 0.3 + assert result.decision == RelevanceDecision.DROP + + def test_parse_invalid_json(self): + """Test Parse ungültiges JSON.""" + scorer = RelevanceScorer() + response = "Das ist kein JSON" + + result = scorer._parse_response(response, "test-id") + + assert result.score == 0.5 # Default + assert result.decision == RelevanceDecision.REVIEW + # Error reason code (could be "parse_error" or "error") + assert any(code in result.reason_codes for code in ["parse_error", "error"]) + + def test_parse_score_clamping(self): + """Test Score wird auf 0-1 begrenzt.""" + scorer = RelevanceScorer() + + # Score > 1 + result = scorer._parse_response('{"score": 1.5, "decision": "KEEP"}', "test") + assert result.score == 1.0 + + # Score < 0 + result = scorer._parse_response('{"score": -0.5, "decision": "DROP"}', "test") + assert result.score == 0.0 + + def test_parse_invalid_decision_fallback(self): + """Test Fallback bei ungültiger Decision.""" + scorer = RelevanceScorer() + + # Hoher Score → KEEP + result = scorer._parse_response('{"score": 0.9, "decision": "INVALID"}', "test") + assert result.decision == RelevanceDecision.KEEP + + # Niedriger Score → DROP + result = scorer._parse_response('{"score": 0.1, "decision": "INVALID"}', "test") + assert result.decision == RelevanceDecision.DROP + + # Mittlerer Score → REVIEW + result = scorer._parse_response('{"score": 0.5, "decision": "INVALID"}', "test") + assert result.decision == RelevanceDecision.REVIEW + + +class TestScoreAlert: + """Tests für score_alert Methode.""" + + @pytest.mark.asyncio + async def test_score_alert_success(self): + """Test erfolgreiches Scoring.""" + scorer = RelevanceScorer(api_key="test-key") + + alert = AlertItem( + title="Inklusion in Bayern", + url="https://example.com", + ) + + # Mock HTTP Response + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "choices": [{ + "message": { + "content": '{"score": 0.9, "decision": "KEEP", "reason_codes": ["priority"], "summary": "Relevant"}' + } + }] + } + + with patch.object(scorer, "_get_client") as mock_get_client: + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_get_client.return_value = mock_client + + result = await scorer.score_alert(alert) + + assert result.score == 0.9 + assert result.decision == RelevanceDecision.KEEP + assert alert.relevance_score == 0.9 + assert alert.status == AlertStatus.SCORED + + @pytest.mark.asyncio + async def test_score_alert_http_error(self): + """Test HTTP Error Handling.""" + import httpx + + scorer = RelevanceScorer(api_key="test-key") + alert = AlertItem(title="Test", url="https://example.com") + + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.text = "Internal Server Error" + + with patch.object(scorer, "_get_client") as mock_get_client: + mock_client = AsyncMock() + mock_client.post.side_effect = httpx.HTTPStatusError( + "Error", request=MagicMock(), response=mock_response + ) + mock_get_client.return_value = mock_client + + result = await scorer.score_alert(alert) + + assert result.decision == RelevanceDecision.REVIEW + assert "gateway_error" in result.reason_codes + assert result.error is not None + + @pytest.mark.asyncio + async def test_score_alert_with_profile(self): + """Test Scoring mit Profil.""" + scorer = RelevanceScorer(api_key="test-key") + alert = AlertItem(title="Test", url="https://example.com") + profile = RelevanceProfile() + profile.add_priority("Test Topic", weight=0.9) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "choices": [{"message": {"content": '{"score": 0.8, "decision": "KEEP"}'}}] + } + + with patch.object(scorer, "_get_client") as mock_get_client: + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_get_client.return_value = mock_client + + result = await scorer.score_alert(alert, profile=profile) + + # Prüfe dass Profil im Request verwendet wurde + call_args = mock_client.post.call_args + request_body = call_args[1]["json"] + system_prompt = request_body["messages"][0]["content"] + assert "Test Topic" in system_prompt + + +class TestScoreBatch: + """Tests für score_batch Methode.""" + + @pytest.mark.asyncio + async def test_score_batch(self): + """Test Batch-Scoring.""" + scorer = RelevanceScorer(api_key="test-key") + + alerts = [ + AlertItem(title="Alert 1", url="https://example.com/1"), + AlertItem(title="Alert 2", url="https://example.com/2"), + ] + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "choices": [{"message": {"content": '{"score": 0.7, "decision": "KEEP"}'}}] + } + + with patch.object(scorer, "_get_client") as mock_get_client: + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_get_client.return_value = mock_client + + results = await scorer.score_batch(alerts) + + assert len(results) == 2 + assert all(r.decision == RelevanceDecision.KEEP for r in results) + + @pytest.mark.asyncio + async def test_score_batch_skip_scored(self): + """Test Batch-Scoring überspringt bereits bewertete.""" + scorer = RelevanceScorer(api_key="test-key") + + alert1 = AlertItem(title="New", url="https://example.com/1") + alert2 = AlertItem(title="Scored", url="https://example.com/2") + alert2.status = AlertStatus.SCORED + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "choices": [{"message": {"content": '{"score": 0.5, "decision": "REVIEW"}'}}] + } + + with patch.object(scorer, "_get_client") as mock_get_client: + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_get_client.return_value = mock_client + + results = await scorer.score_batch([alert1, alert2], skip_scored=True) + + assert len(results) == 1 + + +class TestScorerStats: + """Tests für Scorer Statistiken.""" + + def test_get_stats(self): + """Test Stats Berechnung.""" + scorer = RelevanceScorer() + + results = [ + ScoringResult("1", 0.9, RelevanceDecision.KEEP), + ScoringResult("2", 0.8, RelevanceDecision.KEEP), + ScoringResult("3", 0.2, RelevanceDecision.DROP), + ScoringResult("4", 0.5, RelevanceDecision.REVIEW), + ScoringResult("5", 0.5, RelevanceDecision.REVIEW, error="Test Error"), + ] + + stats = scorer.get_stats(results) + + assert stats["total"] == 5 + assert stats["keep"] == 2 + assert stats["drop"] == 1 + assert stats["review"] == 2 + assert stats["errors"] == 1 + assert stats["keep_rate"] == 0.4 + assert stats["avg_score"] == pytest.approx(0.58, rel=0.01) + + def test_get_stats_empty(self): + """Test Stats für leere Liste.""" + scorer = RelevanceScorer() + stats = scorer.get_stats([]) + + assert stats["total"] == 0 + + +class TestScorerClose: + """Tests für Scorer Cleanup.""" + + @pytest.mark.asyncio + async def test_close(self): + """Test Close schließt Client.""" + scorer = RelevanceScorer() + + # Erstelle Client + await scorer._get_client() + assert scorer._client is not None + + # Close + await scorer.close() + assert scorer._client is None diff --git a/backend/tests/test_alerts_module.py b/backend/tests/test_alerts_module.py new file mode 100644 index 0000000..9ebe71e --- /dev/null +++ b/backend/tests/test_alerts_module.py @@ -0,0 +1,172 @@ +""" +Unit Tests for Alerts Frontend Module + +Tests for the refactored alerts frontend components: +- alerts_css.py (CSS styles) +- alerts_html.py (HTML template) +- alerts_js.py (JavaScript) +- alerts.py (AlertsModule class) +""" +import pytest + +import sys +sys.path.insert(0, '..') + +from frontend.modules.alerts_css import get_alerts_css +from frontend.modules.alerts_html import get_alerts_html +from frontend.modules.alerts_js import get_alerts_js +from frontend.modules.alerts import AlertsModule + + +class TestAlertsCss: + """Test CSS styles""" + + def test_get_alerts_css_returns_string(self): + """Test that get_alerts_css returns a string""" + result = get_alerts_css() + assert isinstance(result, str) + assert len(result) > 0 + + def test_css_contains_panel_styles(self): + """Test that CSS contains panel styles""" + css = get_alerts_css() + assert ".panel-alerts" in css + assert ".alerts-header" in css + + def test_css_contains_inbox_styles(self): + """Test that CSS contains inbox styles""" + css = get_alerts_css() + assert "inbox" in css.lower() or "alert" in css.lower() + + def test_css_contains_layout_classes(self): + """Test that CSS contains layout classes""" + css = get_alerts_css() + assert "display:" in css + assert "flex" in css or "grid" in css + + +class TestAlertsHtml: + """Test HTML template""" + + def test_get_alerts_html_returns_string(self): + """Test that get_alerts_html returns a string""" + result = get_alerts_html() + assert isinstance(result, str) + assert len(result) > 0 + + def test_html_contains_panel_element(self): + """Test that HTML contains panel element""" + html = get_alerts_html() + assert "panel-alerts" in html + assert "id=" in html + + def test_html_contains_header(self): + """Test that HTML contains header section""" + html = get_alerts_html() + assert "alerts-header" in html + + def test_html_is_valid_structure(self): + """Test that HTML has valid structure""" + html = get_alerts_html() + assert "" in html + + +class TestAlertsJs: + """Test JavaScript""" + + def test_get_alerts_js_returns_string(self): + """Test that get_alerts_js returns a string""" + result = get_alerts_js() + assert isinstance(result, str) + assert len(result) > 0 + + def test_js_contains_functions(self): + """Test that JS contains function definitions""" + js = get_alerts_js() + assert "function" in js or "=>" in js or "const" in js + + def test_js_contains_event_handlers(self): + """Test that JS contains event handling code""" + js = get_alerts_js() + # Should have some event handling + has_events = any(x in js for x in ['addEventListener', 'onclick', 'click', 'event']) + assert has_events or len(js) > 100 + + +class TestAlertsModule: + """Test AlertsModule class""" + + def test_module_has_name(self): + """Test that module has name attribute""" + assert hasattr(AlertsModule, 'name') + assert AlertsModule.name == "alerts" + + def test_module_has_display_name(self): + """Test that module has display_name attribute""" + assert hasattr(AlertsModule, 'display_name') + assert AlertsModule.display_name == "Alerts Agent" + + def test_module_has_icon(self): + """Test that module has icon attribute""" + assert hasattr(AlertsModule, 'icon') + assert AlertsModule.icon == "notification" + + def test_get_css_method(self): + """Test get_css method""" + css = AlertsModule.get_css() + assert isinstance(css, str) + assert len(css) > 0 + + def test_get_html_method(self): + """Test get_html method""" + html = AlertsModule.get_html() + assert isinstance(html, str) + assert len(html) > 0 + + def test_get_js_method(self): + """Test get_js method""" + js = AlertsModule.get_js() + assert isinstance(js, str) + assert len(js) > 0 + + def test_render_method(self): + """Test render method returns dict with all components""" + result = AlertsModule.render() + assert isinstance(result, dict) + assert "css" in result + assert "html" in result + assert "js" in result + + def test_render_components_match_methods(self): + """Test that render components match individual methods""" + result = AlertsModule.render() + assert result["css"] == AlertsModule.get_css() + assert result["html"] == AlertsModule.get_html() + assert result["js"] == AlertsModule.get_js() + + +class TestAlertsModuleIntegration: + """Integration tests""" + + def test_css_html_js_sizes_reasonable(self): + """Test that component sizes are reasonable""" + css = AlertsModule.get_css() + html = AlertsModule.get_html() + js = AlertsModule.get_js() + + # Each component should have substantial content + assert len(css) > 1000, "CSS seems too small" + assert len(html) > 500, "HTML seems too small" + assert len(js) > 1000, "JS seems too small" + + def test_module_backwards_compatible(self): + """Test that module maintains backwards compatibility""" + # Should be able to import module class + from frontend.modules.alerts import AlertsModule + + # Should have all expected methods + assert callable(AlertsModule.get_css) + assert callable(AlertsModule.get_html) + assert callable(AlertsModule.get_js) + assert callable(AlertsModule.render) diff --git a/backend/tests/test_alerts_repository.py b/backend/tests/test_alerts_repository.py new file mode 100644 index 0000000..ae443e4 --- /dev/null +++ b/backend/tests/test_alerts_repository.py @@ -0,0 +1,466 @@ +""" +Tests für Alerts Agent Repository. + +Testet CRUD-Operationen für Topics, Items, Rules und Profiles. +""" +import pytest +from datetime import datetime +from unittest.mock import MagicMock, patch + +# Test mit In-Memory SQLite für Isolation +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from alerts_agent.db.models import ( + AlertTopicDB, AlertItemDB, AlertRuleDB, AlertProfileDB, + AlertSourceEnum, AlertStatusEnum, RelevanceDecisionEnum, + FeedTypeEnum, RuleActionEnum +) +from alerts_agent.db.repository import ( + TopicRepository, AlertItemRepository, RuleRepository, ProfileRepository +) + + +# Nutze classroom_engine Base für konsistente Schemas +from classroom_engine.database import Base + + +@pytest.fixture +def db_session(): + """Erstellt eine In-Memory SQLite-Session für Tests.""" + engine = create_engine("sqlite:///:memory:", echo=False) + Base.metadata.create_all(engine) + SessionLocal = sessionmaker(bind=engine) + session = SessionLocal() + yield session + session.close() + + +# ============================================================================= +# TOPIC REPOSITORY TESTS +# ============================================================================= + +class TestTopicRepository: + """Tests für TopicRepository.""" + + def test_create_topic(self, db_session): + """Test: Topic erstellen.""" + repo = TopicRepository(db_session) + + topic = repo.create( + name="Test Topic", + feed_url="https://example.com/feed", + feed_type="rss", + description="Test Description", + ) + + assert topic.id is not None + assert topic.name == "Test Topic" + assert topic.feed_url == "https://example.com/feed" + assert topic.feed_type == FeedTypeEnum.RSS + assert topic.is_active is True + + def test_get_topic_by_id(self, db_session): + """Test: Topic nach ID abrufen.""" + repo = TopicRepository(db_session) + + created = repo.create(name="Find Me") + found = repo.get_by_id(created.id) + + assert found is not None + assert found.name == "Find Me" + + def test_get_topic_not_found(self, db_session): + """Test: Topic nicht gefunden.""" + repo = TopicRepository(db_session) + + found = repo.get_by_id("nonexistent-id") + assert found is None + + def test_update_topic(self, db_session): + """Test: Topic aktualisieren.""" + repo = TopicRepository(db_session) + + topic = repo.create(name="Original Name") + updated = repo.update(topic.id, name="Updated Name", is_active=False) + + assert updated.name == "Updated Name" + assert updated.is_active is False + + def test_delete_topic(self, db_session): + """Test: Topic löschen.""" + repo = TopicRepository(db_session) + + topic = repo.create(name="To Delete") + result = repo.delete(topic.id) + + assert result is True + assert repo.get_by_id(topic.id) is None + + def test_get_all_topics(self, db_session): + """Test: Alle Topics abrufen.""" + repo = TopicRepository(db_session) + + repo.create(name="Topic 1") + repo.create(name="Topic 2") + repo.create(name="Topic 3") + + topics = repo.get_all() + assert len(topics) == 3 + + def test_get_active_topics(self, db_session): + """Test: Nur aktive Topics abrufen.""" + repo = TopicRepository(db_session) + + repo.create(name="Active 1") + repo.create(name="Active 2") + inactive = repo.create(name="Inactive") + repo.update(inactive.id, is_active=False) + + active_topics = repo.get_all(is_active=True) + assert len(active_topics) == 2 + + +# ============================================================================= +# ALERT ITEM REPOSITORY TESTS +# ============================================================================= + +class TestAlertItemRepository: + """Tests für AlertItemRepository.""" + + @pytest.fixture + def topic_id(self, db_session): + """Erstellt ein Test-Topic und gibt die ID zurück.""" + topic_repo = TopicRepository(db_session) + topic = topic_repo.create(name="Test Topic") + return topic.id + + def test_create_alert(self, db_session, topic_id): + """Test: Alert erstellen.""" + repo = AlertItemRepository(db_session) + + alert = repo.create( + topic_id=topic_id, + title="Test Alert", + url="https://example.com/article", + snippet="Test snippet content", + ) + + assert alert.id is not None + assert alert.title == "Test Alert" + assert alert.url_hash is not None + assert alert.status == AlertStatusEnum.NEW + + def test_create_if_not_exists_creates(self, db_session, topic_id): + """Test: Alert erstellen wenn nicht existiert.""" + repo = AlertItemRepository(db_session) + + alert = repo.create_if_not_exists( + topic_id=topic_id, + title="New Alert", + url="https://example.com/new", + ) + + assert alert is not None + assert alert.title == "New Alert" + + def test_create_if_not_exists_duplicate(self, db_session, topic_id): + """Test: Duplikat wird nicht erstellt.""" + repo = AlertItemRepository(db_session) + + url = "https://example.com/duplicate" + first = repo.create_if_not_exists(topic_id=topic_id, title="First", url=url) + second = repo.create_if_not_exists(topic_id=topic_id, title="Second", url=url) + + assert first is not None + assert second is None # Duplikat + + def test_update_scoring(self, db_session, topic_id): + """Test: Scoring aktualisieren.""" + repo = AlertItemRepository(db_session) + + alert = repo.create(topic_id=topic_id, title="To Score", url="https://example.com/score") + + updated = repo.update_scoring( + alert_id=alert.id, + score=0.85, + decision="KEEP", + reasons=["relevant"], + summary="Important article", + model="test-model", + ) + + assert updated.relevance_score == 0.85 + assert updated.relevance_decision == RelevanceDecisionEnum.KEEP + assert updated.status == AlertStatusEnum.SCORED + + def test_get_inbox(self, db_session, topic_id): + """Test: Inbox abrufen.""" + repo = AlertItemRepository(db_session) + + # Erstelle Alerts mit verschiedenen Decisions + alert1 = repo.create(topic_id=topic_id, title="Keep Alert", url="https://example.com/1") + repo.update_scoring(alert1.id, 0.9, "KEEP", [], None, None) + + alert2 = repo.create(topic_id=topic_id, title="Drop Alert", url="https://example.com/2") + repo.update_scoring(alert2.id, 0.1, "DROP", [], None, None) + + alert3 = repo.create(topic_id=topic_id, title="Review Alert", url="https://example.com/3") + repo.update_scoring(alert3.id, 0.5, "REVIEW", [], None, None) + + # Default-Inbox (KEEP + REVIEW) + inbox = repo.get_inbox() + assert len(inbox) == 2 # KEEP und REVIEW + + # Nur KEEP + keep_only = repo.get_inbox(decision="KEEP") + assert len(keep_only) == 1 + + def test_get_unscored(self, db_session, topic_id): + """Test: Unbewertete Alerts abrufen.""" + repo = AlertItemRepository(db_session) + + # Erstelle neue Alerts + repo.create(topic_id=topic_id, title="Unscored 1", url="https://example.com/u1") + repo.create(topic_id=topic_id, title="Unscored 2", url="https://example.com/u2") + + # Einen bewerten + alert3 = repo.create(topic_id=topic_id, title="Scored", url="https://example.com/s1") + repo.update_scoring(alert3.id, 0.5, "REVIEW", [], None, None) + + unscored = repo.get_unscored() + assert len(unscored) == 2 + + def test_mark_reviewed(self, db_session, topic_id): + """Test: Alert als reviewed markieren.""" + repo = AlertItemRepository(db_session) + + alert = repo.create(topic_id=topic_id, title="To Review", url="https://example.com/review") + + reviewed = repo.mark_reviewed( + alert_id=alert.id, + is_relevant=True, + notes="Good article", + tags=["important"], + ) + + assert reviewed.status == AlertStatusEnum.REVIEWED + assert reviewed.user_marked_relevant is True + assert reviewed.user_notes == "Good article" + assert "important" in reviewed.user_tags + + +# ============================================================================= +# RULE REPOSITORY TESTS +# ============================================================================= + +class TestRuleRepository: + """Tests für RuleRepository.""" + + def test_create_rule(self, db_session): + """Test: Regel erstellen.""" + repo = RuleRepository(db_session) + + rule = repo.create( + name="Test Rule", + conditions=[{"field": "title", "op": "contains", "value": "test"}], + action_type="keep", + priority=10, + ) + + assert rule.id is not None + assert rule.name == "Test Rule" + assert rule.priority == 10 + assert rule.is_active is True + + def test_get_active_rules_ordered(self, db_session): + """Test: Aktive Regeln nach Priorität sortiert.""" + repo = RuleRepository(db_session) + + repo.create(name="Low Priority", conditions=[], priority=1) + repo.create(name="High Priority", conditions=[], priority=100) + repo.create(name="Medium Priority", conditions=[], priority=50) + + rules = repo.get_active() + assert len(rules) == 3 + assert rules[0].name == "High Priority" + assert rules[1].name == "Medium Priority" + assert rules[2].name == "Low Priority" + + def test_update_rule(self, db_session): + """Test: Regel aktualisieren.""" + repo = RuleRepository(db_session) + + rule = repo.create(name="Original", conditions=[], action_type="keep") + updated = repo.update(rule.id, name="Updated", action_type="drop") + + assert updated.name == "Updated" + assert updated.action_type == RuleActionEnum.DROP + + def test_increment_match_count(self, db_session): + """Test: Match-Counter erhöhen.""" + repo = RuleRepository(db_session) + + rule = repo.create(name="Matcher", conditions=[]) + assert rule.match_count == 0 + + repo.increment_match_count(rule.id) + repo.increment_match_count(rule.id) + + updated = repo.get_by_id(rule.id) + assert updated.match_count == 2 + assert updated.last_matched_at is not None + + +# ============================================================================= +# PROFILE REPOSITORY TESTS +# ============================================================================= + +class TestProfileRepository: + """Tests für ProfileRepository.""" + + def test_create_default_profile(self, db_session): + """Test: Default-Profil erstellen.""" + repo = ProfileRepository(db_session) + + profile = repo.create_default_education_profile() + + assert profile.id is not None + assert len(profile.priorities) > 0 + assert "Inklusion" in [p["label"] for p in profile.priorities] + + def test_get_or_create(self, db_session): + """Test: Get-or-Create Pattern.""" + repo = ProfileRepository(db_session) + + # Erstes Mal erstellt + profile1 = repo.get_or_create(user_id="user-123") + assert profile1 is not None + + # Zweites Mal holt existierendes + profile2 = repo.get_or_create(user_id="user-123") + assert profile2.id == profile1.id + + def test_update_priorities(self, db_session): + """Test: Prioritäten aktualisieren.""" + repo = ProfileRepository(db_session) + + profile = repo.get_or_create() + new_priorities = [ + {"label": "New Priority", "weight": 0.9, "keywords": ["test"]} + ] + + updated = repo.update_priorities(profile.id, new_priorities) + assert len(updated.priorities) == 1 + assert updated.priorities[0]["label"] == "New Priority" + + def test_add_feedback_positive(self, db_session): + """Test: Positives Feedback hinzufügen.""" + repo = ProfileRepository(db_session) + + profile = repo.get_or_create() + initial_kept = profile.total_kept + + repo.add_feedback( + profile_id=profile.id, + title="Relevant Article", + url="https://example.com/relevant", + is_relevant=True, + reason="Very informative", + ) + + updated = repo.get_by_id(profile.id) + assert updated.total_kept == initial_kept + 1 + assert len(updated.positive_examples) == 1 + + def test_add_feedback_negative(self, db_session): + """Test: Negatives Feedback hinzufügen.""" + repo = ProfileRepository(db_session) + + profile = repo.get_or_create() + initial_dropped = profile.total_dropped + + repo.add_feedback( + profile_id=profile.id, + title="Irrelevant Article", + url="https://example.com/irrelevant", + is_relevant=False, + reason="Off-topic", + ) + + updated = repo.get_by_id(profile.id) + assert updated.total_dropped == initial_dropped + 1 + assert len(updated.negative_examples) == 1 + + def test_feedback_limits_examples(self, db_session): + """Test: Beispiele werden auf 20 begrenzt.""" + repo = ProfileRepository(db_session) + + profile = repo.get_or_create() + + # Füge 25 positive Beispiele hinzu + for i in range(25): + repo.add_feedback( + profile_id=profile.id, + title=f"Article {i}", + url=f"https://example.com/{i}", + is_relevant=True, + ) + + updated = repo.get_by_id(profile.id) + assert len(updated.positive_examples) == 20 # Max 20 + + +# ============================================================================= +# INTEGRATION TESTS +# ============================================================================= + +class TestRepositoryIntegration: + """Integration Tests für Repository-Zusammenspiel.""" + + def test_topic_with_alerts_cascade_delete(self, db_session): + """Test: Topic-Löschung löscht auch zugehörige Alerts.""" + topic_repo = TopicRepository(db_session) + alert_repo = AlertItemRepository(db_session) + + # Erstelle Topic mit Alerts + topic = topic_repo.create(name="To Delete") + alert_repo.create(topic_id=topic.id, title="Alert 1", url="https://example.com/1") + alert_repo.create(topic_id=topic.id, title="Alert 2", url="https://example.com/2") + + # Prüfe dass Alerts existieren + alerts = alert_repo.get_by_topic(topic.id) + assert len(alerts) == 2 + + # Lösche Topic + topic_repo.delete(topic.id) + + # Alerts sollten auch gelöscht sein (CASCADE) + alerts_after = alert_repo.get_by_topic(topic.id) + assert len(alerts_after) == 0 + + def test_scoring_workflow(self, db_session): + """Test: Kompletter Scoring-Workflow.""" + topic_repo = TopicRepository(db_session) + alert_repo = AlertItemRepository(db_session) + profile_repo = ProfileRepository(db_session) + + # Setup + topic = topic_repo.create(name="Workflow Test") + profile = profile_repo.create_default_education_profile() + + # Alerts erstellen + alert1 = alert_repo.create(topic_id=topic.id, title="Inklusion im Unterricht", url="https://example.com/a1") + alert2 = alert_repo.create(topic_id=topic.id, title="Stellenanzeige Lehrer", url="https://example.com/a2") + alert3 = alert_repo.create(topic_id=topic.id, title="Neutral News", url="https://example.com/a3") + + # Scoring simulieren + alert_repo.update_scoring(alert1.id, 0.85, "KEEP", ["priority_match"], "Relevant", "test") + alert_repo.update_scoring(alert2.id, 0.1, "DROP", ["exclusion_match"], None, "test") + alert_repo.update_scoring(alert3.id, 0.5, "REVIEW", [], None, "test") + + # Stats prüfen + by_decision = alert_repo.count_by_decision(topic.id) + assert by_decision.get("KEEP", 0) == 1 + assert by_decision.get("DROP", 0) == 1 + assert by_decision.get("REVIEW", 0) == 1 diff --git a/backend/tests/test_alerts_topics_api.py b/backend/tests/test_alerts_topics_api.py new file mode 100644 index 0000000..55cab6a --- /dev/null +++ b/backend/tests/test_alerts_topics_api.py @@ -0,0 +1,435 @@ +""" +Tests für Alerts Agent Topics API. + +Testet CRUD-Operationen für Topics über die REST-API. +""" +import pytest +from fastapi.testclient import TestClient +from unittest.mock import patch, AsyncMock + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +# WICHTIG: Models ZUERST importieren damit sie bei Base registriert werden +from alerts_agent.db.models import ( + AlertTopicDB, AlertItemDB, AlertRuleDB, AlertProfileDB, +) +# Dann Base importieren (hat jetzt die Models in metadata) +from classroom_engine.database import Base +from alerts_agent.db import get_db +from alerts_agent.api.topics import router + + +# Test-Client Setup +from fastapi import FastAPI + +app = FastAPI() +app.include_router(router, prefix="/api/alerts") + + +@pytest.fixture(scope="function") +def db_engine(): + """Erstellt eine In-Memory SQLite-Engine mit Threading-Support.""" + # StaticPool stellt sicher, dass alle Connections die gleiche DB nutzen + # check_same_thread=False erlaubt Cross-Thread-Zugriff (für TestClient) + engine = create_engine( + "sqlite:///:memory:", + echo=False, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + # Alle Tables erstellen + Base.metadata.create_all(engine) + yield engine + engine.dispose() + + +@pytest.fixture(scope="function") +def db_session(db_engine): + """Erstellt eine Session für Tests.""" + SessionLocal = sessionmaker(bind=db_engine) + session = SessionLocal() + yield session + session.rollback() + session.close() + + +@pytest.fixture(scope="function") +def client(db_session): + """Erstellt einen Test-Client mit überschriebener DB-Dependency.""" + def override_get_db(): + try: + yield db_session + finally: + db_session.rollback() + + app.dependency_overrides[get_db] = override_get_db + with TestClient(app) as test_client: + yield test_client + app.dependency_overrides.clear() + + +# ============================================================================= +# CREATE TESTS +# ============================================================================= + +class TestCreateTopic: + """Tests für POST /api/alerts/topics""" + + def test_create_topic_minimal(self, client): + """Test: Topic mit minimalen Daten erstellen.""" + response = client.post( + "/api/alerts/topics", + json={"name": "Test Topic"}, + ) + + assert response.status_code == 201 + data = response.json() + assert data["name"] == "Test Topic" + assert data["is_active"] is True + assert data["feed_type"] == "rss" + assert data["fetch_interval_minutes"] == 60 + + def test_create_topic_full(self, client): + """Test: Topic mit allen Feldern erstellen.""" + response = client.post( + "/api/alerts/topics", + json={ + "name": "Vollständiges Topic", + "description": "Eine Beschreibung", + "feed_url": "https://example.com/feed.rss", + "feed_type": "rss", + "fetch_interval_minutes": 30, + "is_active": False, + }, + ) + + assert response.status_code == 201 + data = response.json() + assert data["name"] == "Vollständiges Topic" + assert data["description"] == "Eine Beschreibung" + assert data["feed_url"] == "https://example.com/feed.rss" + assert data["fetch_interval_minutes"] == 30 + assert data["is_active"] is False + + def test_create_topic_empty_name_fails(self, client): + """Test: Leerer Name führt zu Fehler.""" + response = client.post( + "/api/alerts/topics", + json={"name": ""}, + ) + + assert response.status_code == 422 # Validation Error + + def test_create_topic_invalid_interval(self, client): + """Test: Ungültiges Fetch-Intervall führt zu Fehler.""" + response = client.post( + "/api/alerts/topics", + json={ + "name": "Test", + "fetch_interval_minutes": 1, # < 5 ist ungültig + }, + ) + + assert response.status_code == 422 + + +# ============================================================================= +# READ TESTS +# ============================================================================= + +class TestReadTopics: + """Tests für GET /api/alerts/topics""" + + def test_list_topics_empty(self, client): + """Test: Leere Topic-Liste.""" + response = client.get("/api/alerts/topics") + + assert response.status_code == 200 + data = response.json() + assert data["topics"] == [] + assert data["total"] == 0 + + def test_list_topics(self, client): + """Test: Topics auflisten.""" + # Erstelle Topics + client.post("/api/alerts/topics", json={"name": "Topic 1"}) + client.post("/api/alerts/topics", json={"name": "Topic 2"}) + client.post("/api/alerts/topics", json={"name": "Topic 3"}) + + response = client.get("/api/alerts/topics") + + assert response.status_code == 200 + data = response.json() + assert data["total"] == 3 + assert len(data["topics"]) == 3 + + def test_list_topics_filter_active(self, client): + """Test: Nur aktive Topics auflisten.""" + # Erstelle aktives und inaktives Topic + client.post("/api/alerts/topics", json={"name": "Aktiv", "is_active": True}) + client.post("/api/alerts/topics", json={"name": "Inaktiv", "is_active": False}) + + response = client.get("/api/alerts/topics?is_active=true") + + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["topics"][0]["name"] == "Aktiv" + + def test_get_topic_by_id(self, client): + """Test: Topic nach ID abrufen.""" + # Erstelle Topic + create_response = client.post( + "/api/alerts/topics", + json={"name": "Find Me"}, + ) + topic_id = create_response.json()["id"] + + # Abrufen + response = client.get(f"/api/alerts/topics/{topic_id}") + + assert response.status_code == 200 + assert response.json()["name"] == "Find Me" + + def test_get_topic_not_found(self, client): + """Test: Topic nicht gefunden.""" + response = client.get("/api/alerts/topics/nonexistent-id") + + assert response.status_code == 404 + + +# ============================================================================= +# UPDATE TESTS +# ============================================================================= + +class TestUpdateTopic: + """Tests für PUT /api/alerts/topics/{id}""" + + def test_update_topic_name(self, client): + """Test: Topic-Namen aktualisieren.""" + # Erstelle Topic + create_response = client.post( + "/api/alerts/topics", + json={"name": "Original"}, + ) + topic_id = create_response.json()["id"] + + # Update + response = client.put( + f"/api/alerts/topics/{topic_id}", + json={"name": "Updated"}, + ) + + assert response.status_code == 200 + assert response.json()["name"] == "Updated" + + def test_update_topic_partial(self, client): + """Test: Partielles Update.""" + # Erstelle Topic + create_response = client.post( + "/api/alerts/topics", + json={ + "name": "Original", + "description": "Desc", + "is_active": True, + }, + ) + topic_id = create_response.json()["id"] + + # Nur is_active ändern + response = client.put( + f"/api/alerts/topics/{topic_id}", + json={"is_active": False}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["name"] == "Original" # Unverändert + assert data["description"] == "Desc" # Unverändert + assert data["is_active"] is False # Geändert + + def test_update_topic_not_found(self, client): + """Test: Update für nicht existierendes Topic.""" + response = client.put( + "/api/alerts/topics/nonexistent-id", + json={"name": "New Name"}, + ) + + assert response.status_code == 404 + + def test_update_topic_empty_fails(self, client): + """Test: Update ohne Änderungen führt zu Fehler.""" + # Erstelle Topic + create_response = client.post( + "/api/alerts/topics", + json={"name": "Test"}, + ) + topic_id = create_response.json()["id"] + + # Leeres Update + response = client.put( + f"/api/alerts/topics/{topic_id}", + json={}, + ) + + assert response.status_code == 400 + + +# ============================================================================= +# DELETE TESTS +# ============================================================================= + +class TestDeleteTopic: + """Tests für DELETE /api/alerts/topics/{id}""" + + def test_delete_topic(self, client): + """Test: Topic löschen.""" + # Erstelle Topic + create_response = client.post( + "/api/alerts/topics", + json={"name": "To Delete"}, + ) + topic_id = create_response.json()["id"] + + # Löschen + response = client.delete(f"/api/alerts/topics/{topic_id}") + assert response.status_code == 204 + + # Prüfen, dass es weg ist + get_response = client.get(f"/api/alerts/topics/{topic_id}") + assert get_response.status_code == 404 + + def test_delete_topic_not_found(self, client): + """Test: Löschen eines nicht existierenden Topics.""" + response = client.delete("/api/alerts/topics/nonexistent-id") + + assert response.status_code == 404 + + +# ============================================================================= +# STATS TESTS +# ============================================================================= + +class TestTopicStats: + """Tests für GET /api/alerts/topics/{id}/stats""" + + def test_get_topic_stats(self, client): + """Test: Topic-Statistiken abrufen.""" + # Erstelle Topic + create_response = client.post( + "/api/alerts/topics", + json={"name": "Stats Test"}, + ) + topic_id = create_response.json()["id"] + + # Stats abrufen + response = client.get(f"/api/alerts/topics/{topic_id}/stats") + + assert response.status_code == 200 + data = response.json() + assert data["topic_id"] == topic_id + assert data["name"] == "Stats Test" + assert data["total_alerts"] == 0 + + def test_get_stats_not_found(self, client): + """Test: Stats für nicht existierendes Topic.""" + response = client.get("/api/alerts/topics/nonexistent-id/stats") + + assert response.status_code == 404 + + +# ============================================================================= +# ACTIVATION TESTS +# ============================================================================= + +class TestTopicActivation: + """Tests für Topic-Aktivierung/-Deaktivierung""" + + def test_activate_topic(self, client): + """Test: Topic aktivieren.""" + # Erstelle inaktives Topic + create_response = client.post( + "/api/alerts/topics", + json={"name": "Inactive", "is_active": False}, + ) + topic_id = create_response.json()["id"] + + # Aktivieren + response = client.post(f"/api/alerts/topics/{topic_id}/activate") + + assert response.status_code == 200 + assert response.json()["is_active"] is True + + def test_deactivate_topic(self, client): + """Test: Topic deaktivieren.""" + # Erstelle aktives Topic + create_response = client.post( + "/api/alerts/topics", + json={"name": "Active", "is_active": True}, + ) + topic_id = create_response.json()["id"] + + # Deaktivieren + response = client.post(f"/api/alerts/topics/{topic_id}/deactivate") + + assert response.status_code == 200 + assert response.json()["is_active"] is False + + +# ============================================================================= +# FETCH TESTS +# ============================================================================= + +class TestTopicFetch: + """Tests für POST /api/alerts/topics/{id}/fetch""" + + def test_fetch_topic_no_url(self, client): + """Test: Fetch ohne Feed-URL führt zu Fehler.""" + # Erstelle Topic ohne URL + create_response = client.post( + "/api/alerts/topics", + json={"name": "No URL"}, + ) + topic_id = create_response.json()["id"] + + # Fetch versuchen + response = client.post(f"/api/alerts/topics/{topic_id}/fetch") + + assert response.status_code == 400 + assert "Feed-URL" in response.json()["detail"] + + def test_fetch_topic_not_found(self, client): + """Test: Fetch für nicht existierendes Topic.""" + response = client.post("/api/alerts/topics/nonexistent-id/fetch") + + assert response.status_code == 404 + + @patch("alerts_agent.ingestion.rss_fetcher.fetch_and_store_feed", new_callable=AsyncMock) + def test_fetch_topic_success(self, mock_fetch, client): + """Test: Erfolgreiches Fetchen.""" + # Mock Setup - async function braucht AsyncMock + mock_fetch.return_value = { + "new_items": 5, + "duplicates_skipped": 2, + } + + # Erstelle Topic mit URL + create_response = client.post( + "/api/alerts/topics", + json={ + "name": "With URL", + "feed_url": "https://example.com/feed.rss", + }, + ) + topic_id = create_response.json()["id"] + + # Fetch ausführen + response = client.post(f"/api/alerts/topics/{topic_id}/fetch") + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["new_items"] == 5 + assert data["duplicates_skipped"] == 2 diff --git a/backend/tests/test_certificates_api.py b/backend/tests/test_certificates_api.py new file mode 100644 index 0000000..3892767 --- /dev/null +++ b/backend/tests/test_certificates_api.py @@ -0,0 +1,400 @@ +""" +Tests für die Certificates API. + +Testet: +- CRUD-Operationen für Zeugnisse +- PDF-Export +- Workflow (Draft -> Review -> Approved -> Issued) +- Notenstatistiken + +Note: Some tests require WeasyPrint which needs system libraries. +""" + +import pytest +from fastapi.testclient import TestClient +from unittest.mock import patch, AsyncMock +import sys +import os + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Check if WeasyPrint is available (required for PDF endpoints) +try: + import weasyprint + WEASYPRINT_AVAILABLE = True +except (ImportError, OSError): + WEASYPRINT_AVAILABLE = False + + +class TestCertificatesAPIImport: + """Tests für Certificates API Import.""" + + def test_import_certificates_api(self): + """Test that certificates_api can be imported.""" + from certificates_api import router + assert router is not None + + def test_import_enums(self): + """Test that enums can be imported.""" + from certificates_api import CertificateType, CertificateStatus, BehaviorGrade + assert CertificateType is not None + assert CertificateStatus is not None + assert BehaviorGrade is not None + + def test_import_models(self): + """Test that Pydantic models can be imported.""" + from certificates_api import ( + CertificateCreateRequest, + CertificateUpdateRequest, + CertificateResponse, + SubjectGrade, + AttendanceInfo + ) + assert CertificateCreateRequest is not None + assert SubjectGrade is not None + + +class TestCertificateTypes: + """Tests für Zeugnistypen.""" + + def test_certificate_types_values(self): + """Test that all certificate types have correct values.""" + from certificates_api import CertificateType + + expected_types = ["halbjahr", "jahres", "abschluss", "abgang", "uebergang"] + actual_types = [t.value for t in CertificateType] + + for expected in expected_types: + assert expected in actual_types + + def test_certificate_status_values(self): + """Test that all statuses have correct values.""" + from certificates_api import CertificateStatus + + expected_statuses = ["draft", "review", "approved", "issued", "archived"] + actual_statuses = [s.value for s in CertificateStatus] + + for expected in expected_statuses: + assert expected in actual_statuses + + +class TestSubjectGrade: + """Tests für SubjectGrade Model.""" + + def test_create_subject_grade(self): + """Test creating a subject grade.""" + from certificates_api import SubjectGrade + + grade = SubjectGrade( + name="Mathematik", + grade="2", + points=11, + note="Gute Mitarbeit" + ) + + assert grade.name == "Mathematik" + assert grade.grade == "2" + assert grade.points == 11 + + def test_create_subject_grade_minimal(self): + """Test creating a minimal subject grade.""" + from certificates_api import SubjectGrade + + grade = SubjectGrade(name="Deutsch", grade="1") + + assert grade.name == "Deutsch" + assert grade.grade == "1" + assert grade.points is None + + +class TestAttendanceInfo: + """Tests für AttendanceInfo Model.""" + + def test_create_attendance_info(self): + """Test creating attendance info.""" + from certificates_api import AttendanceInfo + + attendance = AttendanceInfo( + days_absent=10, + days_excused=8, + days_unexcused=2 + ) + + assert attendance.days_absent == 10 + assert attendance.days_excused == 8 + assert attendance.days_unexcused == 2 + + def test_default_attendance_values(self): + """Test default attendance values.""" + from certificates_api import AttendanceInfo + + attendance = AttendanceInfo() + + assert attendance.days_absent == 0 + assert attendance.days_excused == 0 + assert attendance.days_unexcused == 0 + + +class TestCertificateCreateRequest: + """Tests für CertificateCreateRequest Model.""" + + def test_create_certificate_request(self): + """Test creating a certificate request.""" + from certificates_api import ( + CertificateCreateRequest, + CertificateType, + SubjectGrade, + AttendanceInfo + ) + + request = CertificateCreateRequest( + student_id="student-123", + student_name="Max Mustermann", + student_birthdate="15.05.2010", + student_class="5a", + school_year="2024/2025", + certificate_type=CertificateType.HALBJAHR, + subjects=[ + SubjectGrade(name="Deutsch", grade="2"), + SubjectGrade(name="Mathematik", grade="2"), + ], + attendance=AttendanceInfo(days_absent=5, days_excused=5), + class_teacher="Frau Schmidt", + principal="Herr Direktor" + ) + + assert request.student_name == "Max Mustermann" + assert request.certificate_type == CertificateType.HALBJAHR + assert len(request.subjects) == 2 + + +class TestHelperFunctions: + """Tests für Helper-Funktionen.""" + + def test_calculate_average(self): + """Test average calculation.""" + from certificates_api import _calculate_average + + subjects = [ + {"name": "Deutsch", "grade": "2"}, + {"name": "Mathe", "grade": "3"}, + {"name": "Englisch", "grade": "1"} + ] + + avg = _calculate_average(subjects) + assert avg == 2.0 + + def test_calculate_average_empty(self): + """Test average calculation with empty list.""" + from certificates_api import _calculate_average + + avg = _calculate_average([]) + assert avg is None + + def test_calculate_average_non_numeric(self): + """Test average calculation with non-numeric grades.""" + from certificates_api import _calculate_average + + subjects = [ + {"name": "Deutsch", "grade": "A"}, + {"name": "Mathe", "grade": "B"} + ] + + avg = _calculate_average(subjects) + assert avg is None + + def test_get_type_label(self): + """Test type label function.""" + from certificates_api import _get_type_label, CertificateType + + assert "Halbjahres" in _get_type_label(CertificateType.HALBJAHR) + assert "Jahres" in _get_type_label(CertificateType.JAHRES) + assert "Abschluss" in _get_type_label(CertificateType.ABSCHLUSS) + + +@pytest.mark.skipif( + not WEASYPRINT_AVAILABLE, + reason="WeasyPrint not available (requires system libraries)" +) +class TestCertificatesAPIEndpoints: + """Integration tests für Certificates API Endpoints.""" + + @pytest.fixture + def client(self): + """Create test client.""" + try: + from main import app + return TestClient(app) + except ImportError: + pytest.skip("main.py not available for testing") + + @pytest.fixture + def sample_certificate_data(self): + """Sample certificate data for tests.""" + return { + "student_id": "student-test-123", + "student_name": "Test Schüler", + "student_birthdate": "01.01.2012", + "student_class": "5a", + "school_year": "2024/2025", + "certificate_type": "halbjahr", + "subjects": [ + {"name": "Deutsch", "grade": "2"}, + {"name": "Mathematik", "grade": "3"}, + {"name": "Englisch", "grade": "2"} + ], + "attendance": { + "days_absent": 5, + "days_excused": 4, + "days_unexcused": 1 + }, + "class_teacher": "Frau Test", + "principal": "Herr Direktor" + } + + def test_create_certificate(self, client, sample_certificate_data): + """Test creating a new certificate.""" + if not client: + pytest.skip("Client not available") + + response = client.post("/api/certificates/", json=sample_certificate_data) + + assert response.status_code == 200 + data = response.json() + assert data["student_name"] == sample_certificate_data["student_name"] + assert data["status"] == "draft" + assert "id" in data + assert data["average_grade"] is not None + + def test_get_certificate(self, client, sample_certificate_data): + """Test getting a certificate by ID.""" + if not client: + pytest.skip("Client not available") + + # First create a certificate + create_response = client.post("/api/certificates/", json=sample_certificate_data) + cert_id = create_response.json()["id"] + + # Then get it + response = client.get(f"/api/certificates/{cert_id}") + + assert response.status_code == 200 + data = response.json() + assert data["id"] == cert_id + + def test_update_certificate(self, client, sample_certificate_data): + """Test updating a certificate.""" + if not client: + pytest.skip("Client not available") + + # Create certificate + create_response = client.post("/api/certificates/", json=sample_certificate_data) + cert_id = create_response.json()["id"] + + # Update it + update_data = {"remarks": "Versetzung in Klasse 6a"} + response = client.put(f"/api/certificates/{cert_id}", json=update_data) + + assert response.status_code == 200 + data = response.json() + assert data["remarks"] == "Versetzung in Klasse 6a" + + def test_delete_certificate(self, client, sample_certificate_data): + """Test deleting a certificate.""" + if not client: + pytest.skip("Client not available") + + # Create certificate + create_response = client.post("/api/certificates/", json=sample_certificate_data) + cert_id = create_response.json()["id"] + + # Delete it + response = client.delete(f"/api/certificates/{cert_id}") + assert response.status_code == 200 + + # Verify it's deleted + get_response = client.get(f"/api/certificates/{cert_id}") + assert get_response.status_code == 404 + + def test_export_pdf(self, client, sample_certificate_data): + """Test PDF export.""" + if not client: + pytest.skip("Client not available") + + # Create certificate + create_response = client.post("/api/certificates/", json=sample_certificate_data) + cert_id = create_response.json()["id"] + + # Export as PDF + response = client.post(f"/api/certificates/{cert_id}/export-pdf") + + assert response.status_code == 200 + assert response.headers["content-type"] == "application/pdf" + assert b"%PDF" in response.content[:10] + + def test_certificate_workflow(self, client, sample_certificate_data): + """Test complete certificate workflow.""" + if not client: + pytest.skip("Client not available") + + # 1. Create (draft) + create_response = client.post("/api/certificates/", json=sample_certificate_data) + cert_id = create_response.json()["id"] + assert create_response.json()["status"] == "draft" + + # 2. Submit for review + review_response = client.post(f"/api/certificates/{cert_id}/submit-review") + assert review_response.status_code == 200 + assert review_response.json()["status"] == "review" + + # 3. Approve + approve_response = client.post(f"/api/certificates/{cert_id}/approve") + assert approve_response.status_code == 200 + assert approve_response.json()["status"] == "approved" + + # 4. Issue + issue_response = client.post(f"/api/certificates/{cert_id}/issue") + assert issue_response.status_code == 200 + assert issue_response.json()["status"] == "issued" + + # 5. Cannot update after issued + update_response = client.put(f"/api/certificates/{cert_id}", json={"remarks": "Test"}) + assert update_response.status_code == 400 + + def test_get_certificate_types(self, client): + """Test getting available certificate types.""" + if not client: + pytest.skip("Client not available") + + response = client.get("/api/certificates/types") + + assert response.status_code == 200 + data = response.json() + assert "types" in data + assert len(data["types"]) >= 5 + + def test_get_behavior_grades(self, client): + """Test getting available behavior grades.""" + if not client: + pytest.skip("Client not available") + + response = client.get("/api/certificates/behavior-grades") + + assert response.status_code == 200 + data = response.json() + assert "grades" in data + assert len(data["grades"]) == 4 + + def test_get_nonexistent_certificate(self, client): + """Test getting a certificate that doesn't exist.""" + if not client: + pytest.skip("Client not available") + + response = client.get("/api/certificates/nonexistent-id") + assert response.status_code == 404 + + +# Run tests if executed directly +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/backend/tests/test_classroom_api.py b/backend/tests/test_classroom_api.py new file mode 100644 index 0000000..6b76249 --- /dev/null +++ b/backend/tests/test_classroom_api.py @@ -0,0 +1,1203 @@ +""" +Tests für Classroom Engine und API. + +Testet: +- LessonSession Model +- LessonStateMachine (FSM) +- PhaseTimer (inkl. Pause) +- SuggestionEngine +- REST API Endpoints + +Note: Some tests (Analytics, Reflections) require a running PostgreSQL database. +""" + +import pytest +from datetime import datetime, timedelta +from fastapi.testclient import TestClient +from unittest.mock import patch + +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Marker for tests requiring PostgreSQL +# These tests will be skipped in CI (via conftest.py) or when DB is unavailable +requires_postgres = pytest.mark.requires_postgres + +from main import app +from classroom_engine import ( + LessonPhase, + LessonSession, + LessonStateMachine, + PhaseTimer, + SuggestionEngine, + LESSON_PHASES, + get_default_durations, +) + +client = TestClient(app) + + +# ==================== MODEL TESTS ==================== + + +class TestLessonPhase: + """Tests für LessonPhase Enum.""" + + def test_all_phases_defined(self): + """Testet dass alle 7 Phasen definiert sind.""" + phases = list(LessonPhase) + assert len(phases) == 7 + assert LessonPhase.NOT_STARTED in phases + assert LessonPhase.EINSTIEG in phases + assert LessonPhase.ERARBEITUNG in phases + assert LessonPhase.SICHERUNG in phases + assert LessonPhase.TRANSFER in phases + assert LessonPhase.REFLEXION in phases + assert LessonPhase.ENDED in phases + + def test_phase_values(self): + """Testet Phase-Werte.""" + assert LessonPhase.NOT_STARTED.value == "not_started" + assert LessonPhase.EINSTIEG.value == "einstieg" + assert LessonPhase.ENDED.value == "ended" + + +class TestLessonSession: + """Tests für LessonSession Model.""" + + def test_create_session(self): + """Testet Erstellung einer Session.""" + session = LessonSession( + session_id="test-123", + teacher_id="teacher-1", + class_id="7a", + subject="Mathematik" + ) + + assert session.session_id == "test-123" + assert session.teacher_id == "teacher-1" + assert session.current_phase == LessonPhase.NOT_STARTED + assert session.is_paused == False + assert session.total_paused_seconds == 0 + + def test_default_durations(self): + """Testet Standard-Phasendauern.""" + durations = get_default_durations() + assert durations["einstieg"] == 8 + assert durations["erarbeitung"] == 20 # 20 Minuten Hauptarbeitsphase + assert durations["sicherung"] == 10 + assert durations["transfer"] == 7 + assert durations["reflexion"] == 5 + + def test_to_dict(self): + """Testet Serialisierung zu Dictionary.""" + session = LessonSession( + session_id="test-123", + teacher_id="teacher-1", + class_id="7a", + subject="Mathematik", + topic="Bruchrechnung" + ) + + data = session.to_dict() + assert data["session_id"] == "test-123" + assert data["subject"] == "Mathematik" + assert data["topic"] == "Bruchrechnung" + assert data["is_paused"] == False + assert data["total_paused_seconds"] == 0 + + +# ==================== STATE MACHINE TESTS ==================== + + +class TestLessonStateMachine: + """Tests für LessonStateMachine.""" + + def test_initial_phase_transition(self): + """Testet Übergang von NOT_STARTED zu EINSTIEG.""" + fsm = LessonStateMachine() + session = LessonSession( + session_id="test", + teacher_id="t1", + class_id="7a", + subject="Deutsch" + ) + + # Start lesson + session = fsm.transition(session, LessonPhase.EINSTIEG) + + assert session.current_phase == LessonPhase.EINSTIEG + assert session.phase_started_at is not None + assert session.lesson_started_at is not None + + def test_full_lesson_flow(self): + """Testet kompletten Unterrichtsablauf.""" + fsm = LessonStateMachine() + session = LessonSession( + session_id="test", + teacher_id="t1", + class_id="7a", + subject="Mathematik" + ) + + # Durchlaufe alle Phasen + phases = [ + LessonPhase.EINSTIEG, + LessonPhase.ERARBEITUNG, + LessonPhase.SICHERUNG, + LessonPhase.TRANSFER, + LessonPhase.REFLEXION, + LessonPhase.ENDED + ] + + for phase in phases: + session = fsm.transition(session, phase) + assert session.current_phase == phase + + # Am Ende sollte Stunde beendet sein + assert fsm.is_lesson_ended(session) + assert not fsm.is_lesson_active(session) + + def test_next_phase(self): + """Testet next_phase Funktion.""" + fsm = LessonStateMachine() + + assert fsm.next_phase(LessonPhase.NOT_STARTED) == LessonPhase.EINSTIEG + assert fsm.next_phase(LessonPhase.EINSTIEG) == LessonPhase.ERARBEITUNG + assert fsm.next_phase(LessonPhase.REFLEXION) == LessonPhase.ENDED + assert fsm.next_phase(LessonPhase.ENDED) is None + + def test_invalid_transition(self): + """Testet dass ungültige Übergänge blockiert werden.""" + fsm = LessonStateMachine() + + # Von NOT_STARTED kann man nicht direkt zu ERARBEITUNG + assert not fsm.can_transition(LessonPhase.NOT_STARTED, LessonPhase.ERARBEITUNG) + + # Von EINSTIEG kann man nicht zu SICHERUNG springen (muss durch ERARBEITUNG) + assert not fsm.can_transition(LessonPhase.EINSTIEG, LessonPhase.SICHERUNG) + + def test_phase_history(self): + """Testet Phase-History.""" + fsm = LessonStateMachine() + session = LessonSession( + session_id="test", + teacher_id="t1", + class_id="7a", + subject="Deutsch" + ) + + session = fsm.transition(session, LessonPhase.EINSTIEG) + session = fsm.transition(session, LessonPhase.ERARBEITUNG) + + # History enthält 1 Eintrag: EINSTIEG wurde abgeschlossen + assert len(session.phase_history) == 1 + assert session.phase_history[0]["phase"] == "einstieg" + + # Eine weitere Phase durchlaufen + session = fsm.transition(session, LessonPhase.SICHERUNG) + assert len(session.phase_history) == 2 + assert session.phase_history[1]["phase"] == "erarbeitung" + + +# ==================== TIMER TESTS ==================== + + +class TestPhaseTimer: + """Tests für PhaseTimer.""" + + def test_remaining_seconds(self): + """Testet verbleibende Zeit Berechnung.""" + timer = PhaseTimer() + session = LessonSession( + session_id="test", + teacher_id="t1", + class_id="7a", + subject="Deutsch" + ) + + # Starte Phase + session.current_phase = LessonPhase.EINSTIEG + session.phase_started_at = datetime.utcnow() + + remaining = timer.get_remaining_seconds(session) + # Sollte nahe an 8 Minuten (480 Sekunden) sein + assert 475 <= remaining <= 480 + + def test_pause_stops_timer(self): + """Testet dass Pause den Timer anhält.""" + timer = PhaseTimer() + session = LessonSession( + session_id="test", + teacher_id="t1", + class_id="7a", + subject="Deutsch" + ) + + # Starte Phase vor 2 Minuten + session.current_phase = LessonPhase.EINSTIEG + session.phase_started_at = datetime.utcnow() - timedelta(minutes=2) + + # Ohne Pause: ca. 6 Minuten übrig (8 Min Gesamt - 2 Min verstrichene Zeit) + remaining_no_pause = timer.get_remaining_seconds(session) + assert 355 <= remaining_no_pause <= 365 # ca. 6 Minuten + + # Wenn wir vor 1 Minute pausiert haben und immer noch pausiert sind: + session.is_paused = True + session.pause_started_at = datetime.utcnow() - timedelta(minutes=1) + + # Jetzt sollten wir ca. 7 Minuten übrig haben (8 Min - 1 Min effektive Zeit) + # Weil die Pause-Zeit (1 Min) von der verstrichenen Zeit abgezogen wird + remaining_with_pause = timer.get_remaining_seconds(session) + + # Mit Pause sollte mehr Zeit übrig sein als ohne + assert remaining_with_pause > remaining_no_pause + + def test_total_paused_seconds(self): + """Testet kumulative Pausenzeit.""" + timer = PhaseTimer() + session = LessonSession( + session_id="test", + teacher_id="t1", + class_id="7a", + subject="Deutsch" + ) + + # Starte Phase vor 5 Minuten + session.current_phase = LessonPhase.EINSTIEG + session.phase_started_at = datetime.utcnow() - timedelta(minutes=5) + session.total_paused_seconds = 60 # 1 Minute Pause + + # Sollte 4 Minuten effektiv verstrichene Zeit haben + elapsed = timer.get_elapsed_seconds(session) + assert 235 <= elapsed <= 245 # ca. 4 Minuten + + def test_warning_threshold(self): + """Testet Warnung bei 2 Minuten vor Ende.""" + timer = PhaseTimer() + session = LessonSession( + session_id="test", + teacher_id="t1", + class_id="7a", + subject="Deutsch" + ) + + # Phase mit weniger als 2 Minuten übrig + session.current_phase = LessonPhase.EINSTIEG + session.phase_started_at = datetime.utcnow() - timedelta(minutes=7) + + assert timer.is_warning(session) + + def test_overtime(self): + """Testet Overtime Erkennung.""" + timer = PhaseTimer() + session = LessonSession( + session_id="test", + teacher_id="t1", + class_id="7a", + subject="Deutsch" + ) + + # Phase mit abgelaufener Zeit + session.current_phase = LessonPhase.EINSTIEG + session.phase_started_at = datetime.utcnow() - timedelta(minutes=10) + + assert timer.is_overtime(session) + assert timer.get_overtime_seconds(session) > 0 + + def test_phase_status_includes_is_paused(self): + """Testet dass phase_status is_paused enthält.""" + timer = PhaseTimer() + session = LessonSession( + session_id="test", + teacher_id="t1", + class_id="7a", + subject="Deutsch" + ) + session.current_phase = LessonPhase.EINSTIEG + session.phase_started_at = datetime.utcnow() + session.is_paused = True + + status = timer.get_phase_status(session) + assert "is_paused" in status + assert status["is_paused"] == True + + +# ==================== SUGGESTION ENGINE TESTS ==================== + + +class TestSuggestionEngine: + """Tests für SuggestionEngine.""" + + def test_suggestions_for_einstieg(self): + """Testet Vorschläge für Einstiegsphase.""" + engine = SuggestionEngine() + session = LessonSession( + session_id="test", + teacher_id="t1", + class_id="7a", + subject="Deutsch" + ) + session.current_phase = LessonPhase.EINSTIEG + + suggestions = engine.get_suggestions(session, limit=3) + assert len(suggestions) <= 3 + # Jeder Vorschlag hat einen title und description + assert all(s.title and s.description for s in suggestions) + + def test_suggestions_for_each_phase(self): + """Testet dass jede Phase Vorschläge hat.""" + engine = SuggestionEngine() + + active_phases = [ + LessonPhase.EINSTIEG, + LessonPhase.ERARBEITUNG, + LessonPhase.SICHERUNG, + LessonPhase.TRANSFER, + LessonPhase.REFLEXION + ] + + for phase in active_phases: + session = LessonSession( + session_id="test", + teacher_id="t1", + class_id="7a", + subject="Deutsch" + ) + session.current_phase = phase + + suggestions = engine.get_suggestions(session, limit=5) + assert len(suggestions) > 0, f"Keine Vorschläge für {phase.value}" + + def test_subject_specific_suggestions(self): + """Testet fachspezifische Vorschlaege (Feature f18).""" + engine = SuggestionEngine() + + # Mathematik-Session + math_session = LessonSession( + session_id="test-math", + teacher_id="t1", + class_id="7a", + subject="Mathematik" + ) + math_session.current_phase = LessonPhase.EINSTIEG + + math_suggestions = engine.get_suggestions(math_session, limit=5) + assert len(math_suggestions) > 0 + + # Mindestens ein Mathe-Vorschlag sollte dabei sein + has_math_specific = any( + s.subjects and "mathematik" in s.subjects + for s in math_suggestions + ) + assert has_math_specific, "Keine Mathe-spezifischen Vorschlaege gefunden" + + # Informatik-Session + info_session = LessonSession( + session_id="test-info", + teacher_id="t1", + class_id="10b", + subject="Informatik" + ) + info_session.current_phase = LessonPhase.ERARBEITUNG + + info_suggestions = engine.get_suggestions(info_session, limit=5) + has_info_specific = any( + s.subjects and "informatik" in s.subjects + for s in info_suggestions + ) + assert has_info_specific, "Keine Informatik-spezifischen Vorschlaege gefunden" + + def test_suggestions_response_includes_subject_info(self): + """Testet dass Response Fach-Info enthaelt (Feature f18).""" + engine = SuggestionEngine() + session = LessonSession( + session_id="test", + teacher_id="t1", + class_id="7a", + subject="Deutsch" + ) + session.current_phase = LessonPhase.EINSTIEG + + response = engine.get_suggestions_response(session, limit=3) + assert "subject" in response + assert "subject_specific_available" in response + assert response["subject"] == "Deutsch" + + +# ==================== API TESTS ==================== + + +class TestClassroomAPI: + """Tests für Classroom REST API.""" + + def test_create_session(self): + """Testet Session-Erstellung via API.""" + response = client.post("/api/classroom/sessions", json={ + "teacher_id": "test-teacher", + "class_id": "7a", + "subject": "Mathematik", + "topic": "Bruchrechnung" + }) + + assert response.status_code == 200 + data = response.json() + assert data["subject"] == "Mathematik" + assert data["current_phase"] == "not_started" + assert data["is_paused"] == False + + def test_start_session(self): + """Testet Stunden-Start via API.""" + # Session erstellen + create_resp = client.post("/api/classroom/sessions", json={ + "teacher_id": "test-teacher", + "class_id": "7b", + "subject": "Deutsch" + }) + session_id = create_resp.json()["session_id"] + + # Stunde starten + start_resp = client.post(f"/api/classroom/sessions/{session_id}/start") + + assert start_resp.status_code == 200 + data = start_resp.json() + assert data["current_phase"] == "einstieg" + assert data["is_active"] == True + + def test_next_phase(self): + """Testet Phasenwechsel via API.""" + # Session erstellen und starten + create_resp = client.post("/api/classroom/sessions", json={ + "teacher_id": "test-teacher", + "class_id": "7c", + "subject": "Englisch" + }) + session_id = create_resp.json()["session_id"] + client.post(f"/api/classroom/sessions/{session_id}/start") + + # Nächste Phase + next_resp = client.post(f"/api/classroom/sessions/{session_id}/next-phase") + + assert next_resp.status_code == 200 + assert next_resp.json()["current_phase"] == "erarbeitung" + + def test_pause_toggle(self): + """Testet Pause-Toggle via API.""" + # Session erstellen und starten + create_resp = client.post("/api/classroom/sessions", json={ + "teacher_id": "test-teacher", + "class_id": "7d", + "subject": "Geschichte" + }) + session_id = create_resp.json()["session_id"] + client.post(f"/api/classroom/sessions/{session_id}/start") + + # Pausieren + pause_resp = client.post(f"/api/classroom/sessions/{session_id}/pause") + assert pause_resp.status_code == 200 + assert pause_resp.json()["is_paused"] == True + + # Fortsetzen + resume_resp = client.post(f"/api/classroom/sessions/{session_id}/pause") + assert resume_resp.status_code == 200 + assert resume_resp.json()["is_paused"] == False + + def test_extend_phase(self): + """Testet Phase-Verlängerung via API.""" + # Session erstellen und starten + create_resp = client.post("/api/classroom/sessions", json={ + "teacher_id": "test-teacher", + "class_id": "7e", + "subject": "Biologie" + }) + session_id = create_resp.json()["session_id"] + start_resp = client.post(f"/api/classroom/sessions/{session_id}/start") + + # Ursprüngliche Timer-Zeit merken + initial_total = start_resp.json()["timer"]["total_seconds"] + + # Phase um 5 Minuten verlängern + extend_resp = client.post( + f"/api/classroom/sessions/{session_id}/extend", + json={"minutes": 5} + ) + + assert extend_resp.status_code == 200 + new_total = extend_resp.json()["timer"]["total_seconds"] + assert new_total == initial_total + 300 # +5 Minuten = +300 Sekunden + + def test_get_timer(self): + """Testet Timer-Abruf via API.""" + # Session erstellen und starten + create_resp = client.post("/api/classroom/sessions", json={ + "teacher_id": "test-teacher", + "class_id": "7f", + "subject": "Physik" + }) + session_id = create_resp.json()["session_id"] + client.post(f"/api/classroom/sessions/{session_id}/start") + + # Timer abrufen + timer_resp = client.get(f"/api/classroom/sessions/{session_id}/timer") + + assert timer_resp.status_code == 200 + data = timer_resp.json() + assert "remaining_seconds" in data + assert "percentage" in data + assert "is_paused" in data + assert data["is_paused"] == False + + def test_get_suggestions(self): + """Testet Vorschläge-Abruf via API.""" + # Session erstellen und starten + create_resp = client.post("/api/classroom/sessions", json={ + "teacher_id": "test-teacher", + "class_id": "7g", + "subject": "Chemie" + }) + session_id = create_resp.json()["session_id"] + client.post(f"/api/classroom/sessions/{session_id}/start") + + # Vorschläge abrufen + suggestions_resp = client.get( + f"/api/classroom/sessions/{session_id}/suggestions", + params={"limit": 3} + ) + + assert suggestions_resp.status_code == 200 + data = suggestions_resp.json() + assert "suggestions" in data + assert "current_phase" in data + assert len(data["suggestions"]) <= 3 + + def test_list_phases(self): + """Testet Phasen-Liste via API.""" + response = client.get("/api/classroom/phases") + + assert response.status_code == 200 + data = response.json() + assert "phases" in data + assert len(data["phases"]) == 5 # 5 aktive Phasen + + def test_pause_inactive_session_fails(self): + """Testet dass Pause bei nicht-aktiver Session fehlschlägt.""" + # Session erstellen, aber nicht starten + create_resp = client.post("/api/classroom/sessions", json={ + "teacher_id": "test-teacher", + "class_id": "7h", + "subject": "Kunst" + }) + session_id = create_resp.json()["session_id"] + + # Pausieren sollte fehlschlagen + pause_resp = client.post(f"/api/classroom/sessions/{session_id}/pause") + assert pause_resp.status_code == 400 + + def test_extend_inactive_session_fails(self): + """Testet dass Extend bei nicht-aktiver Session fehlschlägt.""" + # Session erstellen, aber nicht starten + create_resp = client.post("/api/classroom/sessions", json={ + "teacher_id": "test-teacher", + "class_id": "7i", + "subject": "Musik" + }) + session_id = create_resp.json()["session_id"] + + # Extend sollte fehlschlagen + extend_resp = client.post( + f"/api/classroom/sessions/{session_id}/extend", + json={"minutes": 5} + ) + assert extend_resp.status_code == 400 + + +# ==================== HOMEWORK API TESTS (Feature f20) ==================== + + +class TestHomeworkAPI: + """Tests fuer Homework REST API (Feature f20).""" + + def test_create_homework(self): + """Testet Hausaufgaben-Erstellung.""" + response = client.post("/api/classroom/homework", json={ + "teacher_id": "test-teacher", + "class_id": "7a", + "subject": "Mathematik", + "title": "Aufgabe 5 auf Seite 42", + "description": "Alle Teilaufgaben bearbeiten" + }) + + assert response.status_code == 201 + data = response.json() + assert data["title"] == "Aufgabe 5 auf Seite 42" + assert data["status"] == "assigned" + assert data["class_id"] == "7a" + + def test_create_homework_with_due_date(self): + """Testet Hausaufgaben mit Faelligkeitsdatum.""" + response = client.post("/api/classroom/homework", json={ + "teacher_id": "test-teacher", + "class_id": "7a", + "subject": "Deutsch", + "title": "Aufsatz schreiben", + "due_date": "2026-01-20T23:59:00" + }) + + assert response.status_code == 201 + data = response.json() + assert data["due_date"] is not None + assert "2026-01-20" in data["due_date"] + + def test_list_homework_by_teacher(self): + """Testet Auflisten von Hausaufgaben nach Lehrer.""" + # Erst Hausaufgabe erstellen + client.post("/api/classroom/homework", json={ + "teacher_id": "list-test-teacher", + "class_id": "8b", + "subject": "Englisch", + "title": "Vocabulary Unit 5" + }) + + # Dann Liste abrufen + response = client.get("/api/classroom/homework?teacher_id=list-test-teacher") + assert response.status_code == 200 + data = response.json() + assert "homework" in data + assert data["total"] >= 1 + + def test_update_homework_status(self): + """Testet Status-Update einer Hausaufgabe.""" + # Hausaufgabe erstellen + create_resp = client.post("/api/classroom/homework", json={ + "teacher_id": "status-test-teacher", + "class_id": "9c", + "subject": "Physik", + "title": "Experiment durchfuehren" + }) + homework_id = create_resp.json()["homework_id"] + + # Status aktualisieren + response = client.patch(f"/api/classroom/homework/{homework_id}/status?status=completed") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "completed" + + def test_delete_homework(self): + """Testet Loeschen einer Hausaufgabe.""" + # Hausaufgabe erstellen + create_resp = client.post("/api/classroom/homework", json={ + "teacher_id": "delete-test-teacher", + "class_id": "10d", + "subject": "Chemie", + "title": "Formel auswendig lernen" + }) + homework_id = create_resp.json()["homework_id"] + + # Loeschen + response = client.delete(f"/api/classroom/homework/{homework_id}") + assert response.status_code == 200 + assert response.json()["status"] == "deleted" + + def test_homework_not_found(self): + """Testet 404 bei nicht existierender Hausaufgabe.""" + response = client.get("/api/classroom/homework/nonexistent-id") + assert response.status_code == 404 + + +# ==================== MATERIALS API TESTS (Feature f19) ==================== + + +class TestMaterialsAPI: + """Tests fuer Materials REST API (Feature f19).""" + + def test_create_material(self): + """Testet Material-Erstellung.""" + response = client.post("/api/classroom/materials", json={ + "teacher_id": "test-teacher", + "title": "Arbeitsblatt Bruchrechnung", + "material_type": "worksheet", + "url": "https://example.com/ab-bruch.pdf", + "description": "Uebungsaufgaben zu Bruechen", + "phase": "erarbeitung", + "subject": "Mathematik", + "tags": ["bruchrechnung", "uebung"] + }) + + assert response.status_code == 201 + data = response.json() + assert data["title"] == "Arbeitsblatt Bruchrechnung" + assert data["material_type"] == "worksheet" + assert data["phase"] == "erarbeitung" + + def test_create_video_material(self): + """Testet Video-Material-Erstellung.""" + response = client.post("/api/classroom/materials", json={ + "teacher_id": "test-teacher", + "title": "Erklaervideo Photosynthese", + "material_type": "video", + "url": "https://youtube.com/watch?v=abc123", + "phase": "einstieg", + "subject": "Biologie" + }) + + assert response.status_code == 201 + data = response.json() + assert data["material_type"] == "video" + + def test_list_materials_by_teacher(self): + """Testet Auflisten von Materialien nach Lehrer.""" + # Erst Material erstellen + client.post("/api/classroom/materials", json={ + "teacher_id": "list-test-teacher", + "title": "Test-Praesentation", + "material_type": "presentation", + "phase": "sicherung" + }) + + # Dann Liste abrufen + response = client.get("/api/classroom/materials?teacher_id=list-test-teacher") + assert response.status_code == 200 + data = response.json() + assert "materials" in data + assert data["total"] >= 1 + + def test_get_materials_by_phase(self): + """Testet Abrufen von Materialien nach Phase.""" + # Material fuer Phase erstellen + client.post("/api/classroom/materials", json={ + "teacher_id": "phase-test-teacher", + "title": "Einstiegs-Material", + "material_type": "link", + "phase": "einstieg" + }) + + # Nach Phase filtern + response = client.get("/api/classroom/materials/by-phase/einstieg?teacher_id=phase-test-teacher") + assert response.status_code == 200 + data = response.json() + assert all(m["phase"] == "einstieg" for m in data["materials"]) + + def test_update_material(self): + """Testet Material-Update.""" + # Material erstellen + create_resp = client.post("/api/classroom/materials", json={ + "teacher_id": "update-test-teacher", + "title": "Original Titel", + "material_type": "document" + }) + material_id = create_resp.json()["material_id"] + + # Aktualisieren + response = client.put(f"/api/classroom/materials/{material_id}", json={ + "title": "Aktualisierter Titel", + "is_public": True + }) + assert response.status_code == 200 + data = response.json() + assert data["title"] == "Aktualisierter Titel" + assert data["is_public"] == True + + def test_attach_material_to_session(self): + """Testet Material-Session-Verknuepfung.""" + # Session erstellen + session_resp = client.post("/api/classroom/sessions", json={ + "teacher_id": "attach-test-teacher", + "class_id": "10a", + "subject": "Geschichte" + }) + session_id = session_resp.json()["session_id"] + + # Material erstellen + material_resp = client.post("/api/classroom/materials", json={ + "teacher_id": "attach-test-teacher", + "title": "Quellentext", + "material_type": "document" + }) + material_id = material_resp.json()["material_id"] + + # Verknuepfen + response = client.post(f"/api/classroom/materials/{material_id}/attach/{session_id}") + assert response.status_code == 200 + data = response.json() + assert data["session_id"] == session_id + assert data["usage_count"] == 1 + + def test_delete_material(self): + """Testet Material-Loeschung.""" + # Material erstellen + create_resp = client.post("/api/classroom/materials", json={ + "teacher_id": "delete-test-teacher", + "title": "Zu loeschendes Material", + "material_type": "other" + }) + material_id = create_resp.json()["material_id"] + + # Loeschen + response = client.delete(f"/api/classroom/materials/{material_id}") + assert response.status_code == 200 + assert response.json()["status"] == "deleted" + + def test_material_not_found(self): + """Testet 404 bei nicht existierendem Material.""" + response = client.get("/api/classroom/materials/nonexistent-id") + assert response.status_code == 404 + + +# ==================== ANALYTICS TESTS (Phase 5) ==================== + + +@requires_postgres +class TestAnalyticsAPI: + """Tests fuer Analytics API (Phase 5). + + Requires PostgreSQL for storing and querying analytics data. + """ + + def test_get_teacher_analytics(self): + """Testet Lehrer-Analytics Endpoint.""" + response = client.get("/api/classroom/analytics/teacher/demo-teacher?days=30") + # Kann 503 sein wenn DB nicht verfuegbar, oder 200 mit Daten + assert response.status_code in [200, 503] + + if response.status_code == 200: + data = response.json() + assert "teacher_id" in data + assert "total_sessions" in data + assert "avg_phase_durations" in data + + def test_get_phase_trends(self): + """Testet Phase-Trends Endpoint.""" + response = client.get("/api/classroom/analytics/phase-trends/demo-teacher/erarbeitung?limit=10") + assert response.status_code in [200, 503] + + if response.status_code == 200: + data = response.json() + assert "phase" in data + assert data["phase"] == "erarbeitung" + assert "data_points" in data + + def test_get_phase_trends_invalid_phase(self): + """Testet Phase-Trends mit ungueltiger Phase.""" + response = client.get("/api/classroom/analytics/phase-trends/demo-teacher/invalid_phase") + assert response.status_code == 400 + + def test_get_overtime_analysis(self): + """Testet Overtime-Analyse Endpoint.""" + response = client.get("/api/classroom/analytics/overtime/demo-teacher?limit=20") + assert response.status_code in [200, 503] + + if response.status_code == 200: + data = response.json() + assert "phases" in data + # Alle 5 Phasen sollten vorhanden sein + for phase in ["einstieg", "erarbeitung", "sicherung", "transfer", "reflexion"]: + assert phase in data["phases"] + + +@requires_postgres +class TestReflectionAPI: + """Tests fuer Reflection API (Phase 5). + + Requires PostgreSQL for persisting reflections. + """ + + def test_create_reflection(self): + """Testet Reflection-Erstellung.""" + # Erst Session erstellen + session_resp = client.post("/api/classroom/sessions", json={ + "teacher_id": "reflection-test-teacher", + "class_id": "test-class", + "subject": "Philosophie" + }) + session_id = session_resp.json()["session_id"] + + # Session starten und beenden fuer realistische Reflection + client.post(f"/api/classroom/sessions/{session_id}/start") + client.post(f"/api/classroom/sessions/{session_id}/end") + + # Reflection erstellen + response = client.post("/api/classroom/reflections", json={ + "session_id": session_id, + "teacher_id": "reflection-test-teacher", + "notes": "Die Stunde lief gut, Schueler waren aktiv.", + "overall_rating": 4, + "what_worked": ["Gruppendiskussion", "Visualisierung"], + "improvements": ["Mehr Zeit fuer Einstieg"], + "notes_for_next_lesson": "Wiederholung einplanen" + }) + + # DB muss verfuegbar sein + if response.status_code == 201: + data = response.json() + assert data["session_id"] == session_id + assert data["notes"] == "Die Stunde lief gut, Schueler waren aktiv." + assert data["overall_rating"] == 4 + assert "Gruppendiskussion" in data["what_worked"] + + def test_get_reflection_by_session(self): + """Testet Abruf einer Reflection nach Session.""" + # Session und Reflection erstellen + session_resp = client.post("/api/classroom/sessions", json={ + "teacher_id": "get-reflection-teacher", + "class_id": "test-class", + "subject": "Kunst" + }) + session_id = session_resp.json()["session_id"] + + client.post(f"/api/classroom/sessions/{session_id}/start") + client.post(f"/api/classroom/sessions/{session_id}/end") + + create_resp = client.post("/api/classroom/reflections", json={ + "session_id": session_id, + "teacher_id": "get-reflection-teacher", + "notes": "Kreative Stunde" + }) + + if create_resp.status_code == 201: + # Reflection abrufen + response = client.get(f"/api/classroom/reflections/session/{session_id}") + assert response.status_code == 200 + assert response.json()["notes"] == "Kreative Stunde" + + def test_update_reflection(self): + """Testet Reflection-Update.""" + # Session und Reflection erstellen + session_resp = client.post("/api/classroom/sessions", json={ + "teacher_id": "update-reflection-teacher", + "class_id": "test-class", + "subject": "Musik" + }) + session_id = session_resp.json()["session_id"] + + client.post(f"/api/classroom/sessions/{session_id}/start") + client.post(f"/api/classroom/sessions/{session_id}/end") + + create_resp = client.post("/api/classroom/reflections", json={ + "session_id": session_id, + "teacher_id": "update-reflection-teacher", + "notes": "Urspruengliche Notizen" + }) + + if create_resp.status_code == 201: + reflection_id = create_resp.json()["reflection_id"] + + # Update + response = client.put( + f"/api/classroom/reflections/{reflection_id}?teacher_id=update-reflection-teacher", + json={ + "notes": "Aktualisierte Notizen", + "overall_rating": 5 + } + ) + assert response.status_code == 200 + assert response.json()["notes"] == "Aktualisierte Notizen" + assert response.json()["overall_rating"] == 5 + + def test_delete_reflection(self): + """Testet Reflection-Loeschung.""" + # Session und Reflection erstellen + session_resp = client.post("/api/classroom/sessions", json={ + "teacher_id": "delete-reflection-teacher", + "class_id": "test-class", + "subject": "Sport" + }) + session_id = session_resp.json()["session_id"] + + client.post(f"/api/classroom/sessions/{session_id}/start") + client.post(f"/api/classroom/sessions/{session_id}/end") + + create_resp = client.post("/api/classroom/reflections", json={ + "session_id": session_id, + "teacher_id": "delete-reflection-teacher", + "notes": "Zu loeschende Notizen" + }) + + if create_resp.status_code == 201: + reflection_id = create_resp.json()["reflection_id"] + + # Loeschen + response = client.delete( + f"/api/classroom/reflections/{reflection_id}?teacher_id=delete-reflection-teacher" + ) + assert response.status_code == 200 + assert response.json()["success"] is True + + def test_reflection_not_found(self): + """Testet 404 bei nicht existierender Reflection.""" + response = client.get("/api/classroom/reflections/session/nonexistent-session-id") + # 503 wenn DB nicht verfuegbar, 404 wenn nicht gefunden + assert response.status_code in [404, 503] + + def test_duplicate_reflection_rejected(self): + """Testet dass doppelte Reflections abgelehnt werden.""" + # Session erstellen + session_resp = client.post("/api/classroom/sessions", json={ + "teacher_id": "duplicate-reflection-teacher", + "class_id": "test-class", + "subject": "Ethik" + }) + session_id = session_resp.json()["session_id"] + + client.post(f"/api/classroom/sessions/{session_id}/start") + client.post(f"/api/classroom/sessions/{session_id}/end") + + # Erste Reflection erstellen + first_resp = client.post("/api/classroom/reflections", json={ + "session_id": session_id, + "teacher_id": "duplicate-reflection-teacher", + "notes": "Erste Reflection" + }) + + if first_resp.status_code == 201: + # Zweite Reflection sollte abgelehnt werden + second_resp = client.post("/api/classroom/reflections", json={ + "session_id": session_id, + "teacher_id": "duplicate-reflection-teacher", + "notes": "Zweite Reflection" + }) + assert second_resp.status_code == 409 # Conflict + + +# ==================== WEBSOCKET TESTS (Phase 6) ==================== + + +class TestWebSocketStatus: + """Tests fuer WebSocket Status Endpoint.""" + + def test_ws_status_endpoint(self): + """Testet den WebSocket Status-Endpoint.""" + response = client.get("/api/classroom/ws/status") + assert response.status_code == 200 + data = response.json() + assert "active_sessions" in data + assert "sessions" in data + assert "broadcast_task_running" in data + assert isinstance(data["active_sessions"], int) + assert isinstance(data["sessions"], list) + + +class TestWebSocketConnection: + """Tests fuer WebSocket-Verbindungen. + + Hinweis: Vollstaendige WebSocket-Tests erfordern asyncio. + Diese Tests pruefen die grundlegende Funktionalitaet. + """ + + def test_ws_invalid_session_rejected(self): + """Testet dass WebSocket mit ungültiger Session abgelehnt wird.""" + # TestClient unterstuetzt WebSocket-Tests mit context manager + with pytest.raises(Exception): + # Sollte fehlschlagen da Session nicht existiert + with client.websocket_connect("/api/classroom/ws/invalid-session-123"): + pass + + def test_ws_ended_session_rejected(self): + """Testet dass WebSocket bei beendeter Session abgelehnt wird.""" + # Session erstellen und beenden + session_resp = client.post("/api/classroom/sessions", json={ + "teacher_id": "ws-test-teacher", + "class_id": "test-class", + "subject": "WebSocket Test" + }) + session_id = session_resp.json()["session_id"] + + client.post(f"/api/classroom/sessions/{session_id}/start") + client.post(f"/api/classroom/sessions/{session_id}/end") + + # WebSocket zu beendeter Session sollte fehlschlagen + with pytest.raises(Exception): + with client.websocket_connect(f"/api/classroom/ws/{session_id}"): + pass + + def test_ws_connection_to_active_session(self): + """Testet WebSocket-Verbindung zu aktiver Session.""" + # Session erstellen und starten + session_resp = client.post("/api/classroom/sessions", json={ + "teacher_id": "ws-active-teacher", + "class_id": "test-class", + "subject": "WebSocket Active Test" + }) + session_id = session_resp.json()["session_id"] + + client.post(f"/api/classroom/sessions/{session_id}/start") + + # WebSocket verbinden + try: + with client.websocket_connect(f"/api/classroom/ws/{session_id}") as websocket: + # Initiale Nachricht empfangen + data = websocket.receive_json() + assert data["type"] == "connected" + assert "data" in data + assert data["data"]["session_id"] == session_id + assert "timer" in data["data"] + assert "client_count" in data["data"] + + # Ping senden + websocket.send_json({"type": "ping"}) + pong = websocket.receive_json() + assert pong["type"] == "pong" + except Exception as e: + # WebSocket kann fehlschlagen wenn Event-Loop nicht verfuegbar + pytest.skip(f"WebSocket test skipped: {e}") + + # Aufraeumen + client.post(f"/api/classroom/sessions/{session_id}/end") + + def test_ws_timer_request(self): + """Testet manuellen Timer-Request ueber WebSocket.""" + # Session erstellen und starten + session_resp = client.post("/api/classroom/sessions", json={ + "teacher_id": "ws-timer-teacher", + "class_id": "test-class", + "subject": "WebSocket Timer Test" + }) + session_id = session_resp.json()["session_id"] + + client.post(f"/api/classroom/sessions/{session_id}/start") + + try: + with client.websocket_connect(f"/api/classroom/ws/{session_id}") as websocket: + # Initiale Nachricht empfangen + initial = websocket.receive_json() + assert initial["type"] == "connected" + + # Timer anfordern + websocket.send_json({"type": "get_timer"}) + timer_data = websocket.receive_json() + assert timer_data["type"] == "timer_update" + assert "data" in timer_data + assert "remaining_seconds" in timer_data["data"] + except Exception as e: + pytest.skip(f"WebSocket test skipped: {e}") + + # Aufraeumen + client.post(f"/api/classroom/sessions/{session_id}/end") + + def test_ws_invalid_json_handled(self): + """Testet dass ungültiges JSON korrekt behandelt wird.""" + # Session erstellen und starten + session_resp = client.post("/api/classroom/sessions", json={ + "teacher_id": "ws-json-teacher", + "class_id": "test-class", + "subject": "WebSocket JSON Test" + }) + session_id = session_resp.json()["session_id"] + + client.post(f"/api/classroom/sessions/{session_id}/start") + + try: + with client.websocket_connect(f"/api/classroom/ws/{session_id}") as websocket: + # Initiale Nachricht empfangen + websocket.receive_json() + + # Ungültiges JSON senden + websocket.send_text("not valid json {{{") + error = websocket.receive_json() + assert error["type"] == "error" + assert "Invalid JSON" in error["data"]["message"] + except Exception as e: + pytest.skip(f"WebSocket test skipped: {e}") + + # Aufraeumen + client.post(f"/api/classroom/sessions/{session_id}/end") diff --git a/backend/tests/test_comparison.py b/backend/tests/test_comparison.py new file mode 100644 index 0000000..9690431 --- /dev/null +++ b/backend/tests/test_comparison.py @@ -0,0 +1,377 @@ +""" +Tests fuer LLM Comparison Route. +""" + +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from datetime import datetime + + +class TestComparisonModels: + """Tests fuer die Pydantic Models.""" + + def test_comparison_request_defaults(self): + """Test ComparisonRequest mit Default-Werten.""" + from llm_gateway.routes.comparison import ComparisonRequest + + req = ComparisonRequest(prompt="Test prompt") + + assert req.prompt == "Test prompt" + assert req.system_prompt is None + assert req.enable_openai is True + assert req.enable_claude is True + assert req.enable_selfhosted_tavily is True + assert req.enable_selfhosted_edusearch is True + assert req.selfhosted_model == "llama3.2:3b" + assert req.temperature == 0.7 + assert req.top_p == 0.9 + assert req.max_tokens == 2048 + assert req.search_results_count == 5 + + def test_comparison_request_custom_values(self): + """Test ComparisonRequest mit benutzerdefinierten Werten.""" + from llm_gateway.routes.comparison import ComparisonRequest + + req = ComparisonRequest( + prompt="Custom prompt", + system_prompt="Du bist ein Experte", + enable_openai=False, + enable_claude=True, + enable_selfhosted_tavily=False, + enable_selfhosted_edusearch=True, + selfhosted_model="llama3.1:8b", + temperature=0.5, + top_p=0.8, + max_tokens=1024, + search_results_count=10, + edu_search_filters={"language": ["de"], "doc_type": ["Lehrplan"]}, + ) + + assert req.prompt == "Custom prompt" + assert req.system_prompt == "Du bist ein Experte" + assert req.enable_openai is False + assert req.selfhosted_model == "llama3.1:8b" + assert req.temperature == 0.5 + assert req.edu_search_filters == {"language": ["de"], "doc_type": ["Lehrplan"]} + + def test_llm_response_model(self): + """Test LLMResponse Model.""" + from llm_gateway.routes.comparison import LLMResponse + + response = LLMResponse( + provider="openai", + model="gpt-4o-mini", + response="Test response", + latency_ms=500, + tokens_used=100, + ) + + assert response.provider == "openai" + assert response.model == "gpt-4o-mini" + assert response.response == "Test response" + assert response.latency_ms == 500 + assert response.tokens_used == 100 + assert response.error is None + assert response.search_results is None + + def test_llm_response_with_error(self): + """Test LLMResponse mit Fehler.""" + from llm_gateway.routes.comparison import LLMResponse + + response = LLMResponse( + provider="claude", + model="claude-3-5-sonnet", + response="", + latency_ms=100, + error="API Key nicht konfiguriert", + ) + + assert response.error == "API Key nicht konfiguriert" + assert response.response == "" + + def test_llm_response_with_search_results(self): + """Test LLMResponse mit Suchergebnissen.""" + from llm_gateway.routes.comparison import LLMResponse + + search_results = [ + {"title": "Lehrplan Mathe", "url": "https://example.com", "content": "..."}, + {"title": "Bildungsstandards", "url": "https://kmk.org", "content": "..."}, + ] + + response = LLMResponse( + provider="selfhosted_edusearch", + model="llama3.2:3b", + response="Antwort mit Quellen", + latency_ms=2000, + search_results=search_results, + ) + + assert len(response.search_results) == 2 + assert response.search_results[0]["title"] == "Lehrplan Mathe" + + +class TestComparisonResponse: + """Tests fuer ComparisonResponse.""" + + def test_comparison_response_structure(self): + """Test ComparisonResponse Struktur.""" + from llm_gateway.routes.comparison import ComparisonResponse, LLMResponse + + responses = [ + LLMResponse( + provider="openai", + model="gpt-4o-mini", + response="OpenAI Antwort", + latency_ms=400, + ), + LLMResponse( + provider="claude", + model="claude-3-5-sonnet", + response="Claude Antwort", + latency_ms=600, + ), + ] + + result = ComparisonResponse( + comparison_id="cmp-test123", + prompt="Was ist 1+1?", + system_prompt="Du bist ein Mathe-Lehrer", + responses=responses, + ) + + assert result.comparison_id == "cmp-test123" + assert result.prompt == "Was ist 1+1?" + assert result.system_prompt == "Du bist ein Mathe-Lehrer" + assert len(result.responses) == 2 + assert result.responses[0].provider == "openai" + assert result.responses[1].provider == "claude" + + +class TestSystemPromptStore: + """Tests fuer System Prompt Management.""" + + def test_default_system_prompts_exist(self): + """Test dass Standard-Prompts existieren.""" + from llm_gateway.routes.comparison import _system_prompts_store + + assert "default" in _system_prompts_store + assert "curriculum" in _system_prompts_store + assert "worksheet" in _system_prompts_store + + def test_default_prompt_structure(self): + """Test Struktur der Standard-Prompts.""" + from llm_gateway.routes.comparison import _system_prompts_store + + default = _system_prompts_store["default"] + assert "id" in default + assert "name" in default + assert "prompt" in default + assert "created_at" in default + + assert default["id"] == "default" + assert "Lehrer" in default["prompt"] or "Assistent" in default["prompt"] + + +class TestSearchFunctions: + """Tests fuer Such-Funktionen.""" + + @pytest.mark.asyncio + async def test_search_tavily_no_api_key(self): + """Test Tavily Suche ohne API Key.""" + from llm_gateway.routes.comparison import _search_tavily + + with patch.dict("os.environ", {}, clear=True): + results = await _search_tavily("test query", 5) + assert results == [] + + @pytest.mark.asyncio + async def test_search_edusearch_connection_error(self): + """Test EduSearch bei Verbindungsfehler.""" + from llm_gateway.routes.comparison import _search_edusearch + + with patch("httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.__aenter__ = AsyncMock(return_value=mock_instance) + mock_instance.__aexit__ = AsyncMock(return_value=None) + mock_instance.post = AsyncMock(side_effect=Exception("Connection refused")) + mock_client.return_value = mock_instance + + results = await _search_edusearch("test query", 5) + assert results == [] + + +class TestLLMCalls: + """Tests fuer LLM-Aufrufe.""" + + @pytest.mark.asyncio + async def test_call_openai_no_api_key(self): + """Test OpenAI Aufruf ohne API Key.""" + from llm_gateway.routes.comparison import _call_openai + + with patch.dict("os.environ", {}, clear=True): + result = await _call_openai("Test prompt", None) + + assert result.provider == "openai" + assert result.error is not None + assert "OPENAI_API_KEY" in result.error + + @pytest.mark.asyncio + async def test_call_claude_no_api_key(self): + """Test Claude Aufruf ohne API Key.""" + from llm_gateway.routes.comparison import _call_claude + + with patch.dict("os.environ", {}, clear=True): + result = await _call_claude("Test prompt", None) + + assert result.provider == "claude" + assert result.error is not None + assert "ANTHROPIC_API_KEY" in result.error + + +class TestComparisonEndpoints: + """Integration Tests fuer die API Endpoints.""" + + @pytest.fixture + def mock_verify_api_key(self): + """Mock fuer API Key Verifizierung.""" + with patch("llm_gateway.routes.comparison.verify_api_key") as mock: + mock.return_value = "test-user" + yield mock + + @pytest.mark.asyncio + async def test_list_system_prompts(self, mock_verify_api_key): + """Test GET /comparison/prompts.""" + from llm_gateway.routes.comparison import list_system_prompts + + result = await list_system_prompts(_="test-user") + + assert "prompts" in result + assert len(result["prompts"]) >= 3 # default, curriculum, worksheet + + @pytest.mark.asyncio + async def test_get_system_prompt(self, mock_verify_api_key): + """Test GET /comparison/prompts/{prompt_id}.""" + from llm_gateway.routes.comparison import get_system_prompt + + result = await get_system_prompt("default", _="test-user") + + assert result["id"] == "default" + assert "name" in result + assert "prompt" in result + + @pytest.mark.asyncio + async def test_get_system_prompt_not_found(self, mock_verify_api_key): + """Test GET /comparison/prompts/{prompt_id} mit unbekannter ID.""" + from fastapi import HTTPException + from llm_gateway.routes.comparison import get_system_prompt + + with pytest.raises(HTTPException) as exc_info: + await get_system_prompt("nonexistent-id", _="test-user") + + assert exc_info.value.status_code == 404 + + @pytest.mark.asyncio + async def test_get_comparison_history(self, mock_verify_api_key): + """Test GET /comparison/history.""" + from llm_gateway.routes.comparison import get_comparison_history + + result = await get_comparison_history(limit=10, _="test-user") + + assert "comparisons" in result + assert isinstance(result["comparisons"], list) + + +class TestProviderMapping: + """Tests fuer Provider-Label und -Color Mapping.""" + + def test_provider_labels(self): + """Test dass alle Provider Labels haben.""" + provider_labels = { + "openai": "OpenAI GPT-4o-mini", + "claude": "Claude 3.5 Sonnet", + "selfhosted_tavily": "Self-hosted + Tavily", + "selfhosted_edusearch": "Self-hosted + EduSearch", + } + + for key, expected in provider_labels.items(): + assert key in ["openai", "claude", "selfhosted_tavily", "selfhosted_edusearch"] + + +class TestParameterValidation: + """Tests fuer Parameter-Validierung.""" + + def test_temperature_range(self): + """Test Temperature Bereich 0-2.""" + from llm_gateway.routes.comparison import ComparisonRequest + from pydantic import ValidationError + + # Gueltige Werte + req = ComparisonRequest(prompt="test", temperature=0.0) + assert req.temperature == 0.0 + + req = ComparisonRequest(prompt="test", temperature=2.0) + assert req.temperature == 2.0 + + # Ungueltige Werte + with pytest.raises(ValidationError): + ComparisonRequest(prompt="test", temperature=-0.1) + + with pytest.raises(ValidationError): + ComparisonRequest(prompt="test", temperature=2.1) + + def test_top_p_range(self): + """Test Top-P Bereich 0-1.""" + from llm_gateway.routes.comparison import ComparisonRequest + from pydantic import ValidationError + + # Gueltige Werte + req = ComparisonRequest(prompt="test", top_p=0.0) + assert req.top_p == 0.0 + + req = ComparisonRequest(prompt="test", top_p=1.0) + assert req.top_p == 1.0 + + # Ungueltige Werte + with pytest.raises(ValidationError): + ComparisonRequest(prompt="test", top_p=-0.1) + + with pytest.raises(ValidationError): + ComparisonRequest(prompt="test", top_p=1.1) + + def test_max_tokens_range(self): + """Test Max Tokens Bereich 1-8192.""" + from llm_gateway.routes.comparison import ComparisonRequest + from pydantic import ValidationError + + # Gueltige Werte + req = ComparisonRequest(prompt="test", max_tokens=1) + assert req.max_tokens == 1 + + req = ComparisonRequest(prompt="test", max_tokens=8192) + assert req.max_tokens == 8192 + + # Ungueltige Werte + with pytest.raises(ValidationError): + ComparisonRequest(prompt="test", max_tokens=0) + + with pytest.raises(ValidationError): + ComparisonRequest(prompt="test", max_tokens=8193) + + def test_search_results_count_range(self): + """Test Search Results Count Bereich 1-20.""" + from llm_gateway.routes.comparison import ComparisonRequest + from pydantic import ValidationError + + # Gueltige Werte + req = ComparisonRequest(prompt="test", search_results_count=1) + assert req.search_results_count == 1 + + req = ComparisonRequest(prompt="test", search_results_count=20) + assert req.search_results_count == 20 + + # Ungueltige Werte + with pytest.raises(ValidationError): + ComparisonRequest(prompt="test", search_results_count=0) + + with pytest.raises(ValidationError): + ComparisonRequest(prompt="test", search_results_count=21) diff --git a/backend/tests/test_compliance_ai.py b/backend/tests/test_compliance_ai.py new file mode 100644 index 0000000..3e288d8 --- /dev/null +++ b/backend/tests/test_compliance_ai.py @@ -0,0 +1,429 @@ +""" +Tests for Compliance AI Integration (Sprint 4). + +Tests the AI-powered compliance features: +- Requirement interpretation +- Control suggestions +- Risk assessment +- Gap analysis +""" + +import pytest +import asyncio +from unittest.mock import patch, AsyncMock + +# Import the services +from compliance.services.llm_provider import ( + LLMProvider, LLMConfig, LLMProviderType, + AnthropicProvider, SelfHostedProvider, MockProvider, + get_llm_provider, LLMResponse +) +from compliance.services.ai_compliance_assistant import ( + AIComplianceAssistant, + RequirementInterpretation, + ControlSuggestion, + RiskAssessment, + GapAnalysis +) + + +# ============================================================================ +# LLM Provider Tests +# ============================================================================ + +class TestMockProvider: + """Test the MockProvider for testing scenarios.""" + + @pytest.mark.asyncio + async def test_mock_provider_basic(self): + """Test basic mock provider functionality.""" + config = LLMConfig(provider_type=LLMProviderType.MOCK) + provider = MockProvider(config) + + response = await provider.complete("Test prompt") + + assert response.content is not None + assert response.provider == "mock" + assert response.model == "mock-model" + + @pytest.mark.asyncio + async def test_mock_provider_custom_responses(self): + """Test mock provider with custom responses.""" + config = LLMConfig(provider_type=LLMProviderType.MOCK) + provider = MockProvider(config) + + # Set custom responses + provider.set_responses([ + "First response", + "Second response" + ]) + + resp1 = await provider.complete("Prompt 1") + resp2 = await provider.complete("Prompt 2") + + assert resp1.content == "First response" + assert resp2.content == "Second response" + + @pytest.mark.asyncio + async def test_mock_provider_batch(self): + """Test batch processing with mock provider.""" + config = LLMConfig(provider_type=LLMProviderType.MOCK) + provider = MockProvider(config) + + prompts = ["Prompt 1", "Prompt 2", "Prompt 3"] + responses = await provider.batch_complete(prompts) + + assert len(responses) == 3 + for resp in responses: + assert resp.provider == "mock" + + +class TestLLMProviderFactory: + """Test the LLM provider factory function.""" + + def test_factory_mock_provider(self): + """Test factory creates mock provider when configured.""" + config = LLMConfig(provider_type=LLMProviderType.MOCK) + provider = get_llm_provider(config) + + assert isinstance(provider, MockProvider) + assert provider.provider_name == "mock" + + def test_factory_anthropic_without_key(self): + """Test factory falls back to mock when API key is missing.""" + config = LLMConfig( + provider_type=LLMProviderType.ANTHROPIC, + api_key=None + ) + provider = get_llm_provider(config) + + # Should fall back to mock + assert isinstance(provider, MockProvider) + + def test_factory_self_hosted_without_url(self): + """Test factory falls back to mock when URL is missing.""" + config = LLMConfig( + provider_type=LLMProviderType.SELF_HOSTED, + base_url=None + ) + provider = get_llm_provider(config) + + # Should fall back to mock + assert isinstance(provider, MockProvider) + + +# ============================================================================ +# AI Compliance Assistant Tests +# ============================================================================ + +class TestAIComplianceAssistant: + """Test the AI Compliance Assistant.""" + + @pytest.fixture + def mock_provider(self): + """Create a mock provider with predefined responses.""" + config = LLMConfig(provider_type=LLMProviderType.MOCK) + provider = MockProvider(config) + + # Set up responses for different test scenarios + provider.set_responses([ + # Interpretation response + '''{ + "summary": "Die Anforderung betrifft Datenverschlüsselung", + "applicability": "Gilt für alle Module die PII verarbeiten", + "technical_measures": ["AES-256 Verschlüsselung", "TLS 1.3"], + "affected_modules": ["consent-service", "klausur-service"], + "risk_level": "high", + "implementation_hints": ["Verwende SOPS", "Aktiviere TLS"] + }''', + # Control suggestion response + '''{ + "controls": [ + { + "control_id": "PRIV-042", + "domain": "priv", + "title": "Verschlüsselung personenbezogener Daten", + "description": "Alle PII müssen verschlüsselt sein", + "pass_criteria": "100% der PII sind AES-256 verschlüsselt", + "implementation_guidance": "Verwende SOPS mit Age-Keys", + "is_automated": true, + "automation_tool": "SOPS", + "priority": "high" + } + ] + }''', + # Risk assessment response + '''{ + "overall_risk": "high", + "risk_factors": [ + { + "factor": "Verarbeitet personenbezogene Daten", + "severity": "high", + "likelihood": "high" + } + ], + "recommendations": ["Verschlüsselung implementieren"], + "compliance_gaps": ["Fehlende Verschlüsselung"] + }''', + # Gap analysis response + '''{ + "coverage_level": "partial", + "covered_aspects": ["Verschlüsselung in Transit"], + "missing_coverage": ["Verschlüsselung at Rest"], + "suggested_actions": ["Implementiere Disk-Encryption"] + }''' + ]) + + return provider + + @pytest.fixture + def assistant(self, mock_provider): + """Create an AI assistant with mock provider.""" + return AIComplianceAssistant(llm_provider=mock_provider) + + @pytest.mark.asyncio + async def test_interpret_requirement(self, assistant): + """Test requirement interpretation.""" + result = await assistant.interpret_requirement( + requirement_id="req-123", + article="Art. 32", + title="Sicherheit der Verarbeitung", + requirement_text="Der Verantwortliche muss geeignete Maßnahmen treffen...", + regulation_code="GDPR", + regulation_name="DSGVO" + ) + + assert isinstance(result, RequirementInterpretation) + assert result.requirement_id == "req-123" + assert result.summary is not None + assert len(result.technical_measures) > 0 + assert len(result.affected_modules) > 0 + assert result.risk_level in ["low", "medium", "high", "critical"] + assert result.confidence_score > 0 + + @pytest.mark.asyncio + async def test_suggest_controls(self, mock_provider): + """Test control suggestions.""" + # Set up mock with control suggestion response + mock_provider.set_responses(['''{ + "controls": [ + { + "control_id": "PRIV-042", + "domain": "priv", + "title": "Verschlüsselung personenbezogener Daten", + "description": "Alle PII müssen verschlüsselt sein", + "pass_criteria": "100% der PII sind AES-256 verschlüsselt", + "implementation_guidance": "Verwende SOPS mit Age-Keys", + "is_automated": true, + "automation_tool": "SOPS", + "priority": "high" + } + ] + }''']) + assistant = AIComplianceAssistant(llm_provider=mock_provider) + + suggestions = await assistant.suggest_controls( + requirement_title="Verschlüsselung der Verarbeitung", + requirement_text="Personenbezogene Daten müssen verschlüsselt werden", + regulation_name="DSGVO", + affected_modules=["consent-service"] + ) + + assert isinstance(suggestions, list) + assert len(suggestions) > 0 + + control = suggestions[0] + assert isinstance(control, ControlSuggestion) + assert control.control_id is not None + assert control.domain in ["priv", "iam", "sdlc", "crypto", "ops", "ai", "cra", "gov", "aud"] + assert control.title is not None + assert control.pass_criteria is not None + + @pytest.mark.asyncio + async def test_assess_module_risk(self, mock_provider): + """Test module risk assessment.""" + # Set up mock with risk assessment response + mock_provider.set_responses(['''{ + "overall_risk": "high", + "risk_factors": [ + { + "factor": "Verarbeitet personenbezogene Daten", + "severity": "high", + "likelihood": "high" + } + ], + "recommendations": ["Verschlüsselung implementieren"], + "compliance_gaps": ["Fehlende Verschlüsselung"] + }''']) + assistant = AIComplianceAssistant(llm_provider=mock_provider) + + result = await assistant.assess_module_risk( + module_name="consent-service", + service_type="backend", + description="Verwaltet Einwilligungen", + processes_pii=True, + ai_components=False, + criticality="critical", + data_categories=["consent_records", "personal_data"], + regulations=[{"code": "GDPR", "relevance": "critical"}] + ) + + assert isinstance(result, RiskAssessment) + assert result.module_name == "consent-service" + assert result.overall_risk in ["low", "medium", "high", "critical"] + assert len(result.risk_factors) > 0 + assert len(result.recommendations) > 0 + assert result.confidence_score > 0 + + @pytest.mark.asyncio + async def test_analyze_gap(self, assistant): + """Test gap analysis.""" + result = await assistant.analyze_gap( + requirement_id="req-456", + requirement_title="Verschlüsselung", + requirement_text="Daten müssen verschlüsselt sein", + regulation_code="GDPR", + existing_controls=[ + {"control_id": "PRIV-001", "title": "TLS 1.3", "status": "pass"} + ] + ) + + assert isinstance(result, GapAnalysis) + assert result.requirement_id == "req-456" + assert result.coverage_level in ["full", "partial", "none", "unknown"] + assert len(result.existing_controls) > 0 + + @pytest.mark.asyncio + async def test_batch_interpret(self, assistant): + """Test batch requirement interpretation.""" + requirements = [ + { + "id": "req-1", + "article": "Art. 32", + "title": "Sicherheit", + "requirement_text": "Sicherheitsmaßnahmen", + "regulation_code": "GDPR", + "regulation_name": "DSGVO" + }, + { + "id": "req-2", + "article": "Art. 33", + "title": "Meldung", + "requirement_text": "Meldung von Datenpannen", + "regulation_code": "GDPR", + "regulation_name": "DSGVO" + } + ] + + results = await assistant.batch_interpret_requirements( + requirements=requirements, + rate_limit=0.1 # Fast for testing + ) + + assert len(results) == 2 + for result in results: + assert isinstance(result, RequirementInterpretation) + + +class TestJSONParsing: + """Test JSON parsing from LLM responses.""" + + @pytest.fixture + def assistant(self): + """Create assistant for testing.""" + config = LLMConfig(provider_type=LLMProviderType.MOCK) + provider = MockProvider(config) + return AIComplianceAssistant(llm_provider=provider) + + def test_parse_clean_json(self, assistant): + """Test parsing clean JSON response.""" + content = '{"key": "value", "list": [1, 2, 3]}' + result = assistant._parse_json_response(content) + + assert result == {"key": "value", "list": [1, 2, 3]} + + def test_parse_json_with_markdown(self, assistant): + """Test parsing JSON wrapped in markdown code blocks.""" + content = '''```json + {"key": "value"} + ```''' + result = assistant._parse_json_response(content) + + assert result == {"key": "value"} + + def test_parse_json_with_text(self, assistant): + """Test extracting JSON from text response.""" + content = ''' + Here is the analysis: + {"key": "value", "nested": {"a": 1}} + That's the result. + ''' + result = assistant._parse_json_response(content) + + assert result == {"key": "value", "nested": {"a": 1}} + + def test_parse_invalid_json(self, assistant): + """Test handling of invalid JSON.""" + content = "This is not JSON at all" + result = assistant._parse_json_response(content) + + assert result == {} + + +# ============================================================================ +# Integration Test Markers +# ============================================================================ + +@pytest.mark.integration +@pytest.mark.skipif( + True, # Skip by default, run with --integration flag + reason="Requires API key or running LLM service" +) +class TestRealLLMIntegration: + """Integration tests with real LLM providers (requires API keys).""" + + @pytest.mark.asyncio + async def test_anthropic_integration(self): + """Test with real Anthropic API (requires ANTHROPIC_API_KEY).""" + import os + api_key = os.getenv("ANTHROPIC_API_KEY") + if not api_key: + pytest.skip("ANTHROPIC_API_KEY not set") + + config = LLMConfig( + provider_type=LLMProviderType.ANTHROPIC, + api_key=api_key, + model="claude-sonnet-4-20250514" + ) + provider = AnthropicProvider(config) + + response = await provider.complete("Sage Hallo auf Deutsch.") + + assert response.content is not None + assert "hallo" in response.content.lower() or "guten" in response.content.lower() + + @pytest.mark.asyncio + async def test_self_hosted_integration(self): + """Test with self-hosted LLM (requires running Ollama/vLLM).""" + import os + base_url = os.getenv("SELF_HOSTED_LLM_URL", "http://localhost:11434") + + config = LLMConfig( + provider_type=LLMProviderType.SELF_HOSTED, + base_url=base_url, + model="llama3.1:8b" + ) + provider = SelfHostedProvider(config) + + response = await provider.complete("Sage Hallo auf Deutsch.") + + assert response.content is not None + + +# ============================================================================ +# Run Tests +# ============================================================================ + +if __name__ == "__main__": + # Run with: python -m pytest tests/test_compliance_ai.py -v + pytest.main([__file__, "-v"]) diff --git a/backend/tests/test_compliance_api.py b/backend/tests/test_compliance_api.py new file mode 100644 index 0000000..12a8f17 --- /dev/null +++ b/backend/tests/test_compliance_api.py @@ -0,0 +1,618 @@ +""" +Tests for Compliance API endpoints. + +Tests cover: +- GET /api/v1/compliance/regulations +- GET /api/v1/compliance/requirements (with pagination) +- GET /api/v1/compliance/controls +- GET /api/v1/compliance/dashboard +- POST /api/v1/compliance/evidence/collect +- GET /api/v1/compliance/evidence/ci-status +""" + +import pytest +from datetime import datetime, timedelta +from unittest.mock import MagicMock, patch +from fastapi.testclient import TestClient + +# Test with in-memory SQLite for isolation +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from classroom_engine.database import Base, get_db +from compliance.api.routes import router +from compliance.db.models import ( + RegulationDB, RequirementDB, ControlDB, EvidenceDB, + RegulationTypeEnum, ControlDomainEnum, ControlStatusEnum, EvidenceStatusEnum, ControlTypeEnum +) +from compliance.db.repository import ( + RegulationRepository, RequirementRepository, ControlRepository, EvidenceRepository +) + +# FastAPI app setup for testing +from fastapi import FastAPI + +app = FastAPI() +app.include_router(router, prefix="/api/v1") + + +@pytest.fixture +def db_session(): + """Create in-memory SQLite session for tests.""" + # Use StaticPool to ensure single connection for SQLite in-memory + # This is critical because SQLite :memory: DBs are connection-specific + engine = create_engine( + "sqlite:///:memory:", + echo=False, + connect_args={"check_same_thread": False}, + poolclass=StaticPool + ) + # Ensure all compliance models are imported and registered with Base + # before creating tables (import order matters for SQLAlchemy metadata) + from compliance.db import models as compliance_models # noqa: F401 + from classroom_engine import db_models as classroom_models # noqa: F401 + Base.metadata.create_all(engine) + SessionLocal = sessionmaker(bind=engine) + session = SessionLocal() + yield session + session.close() + + +@pytest.fixture +def client(db_session): + """Create test client with DB override.""" + def override_get_db(): + try: + yield db_session + finally: + pass + + app.dependency_overrides[get_db] = override_get_db + client = TestClient(app) + yield client + app.dependency_overrides.clear() + + +@pytest.fixture +def sample_regulation(db_session): + """Create a sample regulation for testing.""" + repo = RegulationRepository(db_session) + return repo.create( + code="GDPR", + name="General Data Protection Regulation", + regulation_type=RegulationTypeEnum.EU_REGULATION, + full_name="Regulation (EU) 2016/679", + description="EU data protection regulation", + ) + + +@pytest.fixture +def sample_requirement(db_session, sample_regulation): + """Create a sample requirement for testing.""" + repo = RequirementRepository(db_session) + return repo.create( + regulation_id=sample_regulation.id, + article="Art. 32", + title="Security of processing", + description="Test requirement", + requirement_text="The controller shall implement appropriate technical measures...", + is_applicable=True, + priority=1, + ) + + +@pytest.fixture +def sample_control(db_session): + """Create a sample control for testing.""" + repo = ControlRepository(db_session) + return repo.create( + control_id="CRYPTO-001", + title="TLS 1.3 Encryption", + description="All external communication uses TLS 1.3", + domain=ControlDomainEnum.CRYPTO, + control_type=ControlTypeEnum.PREVENTIVE, + pass_criteria="All connections use TLS 1.3", + ) + + +# ============================================================================ +# Regulations Tests +# ============================================================================ + +class TestRegulationsAPI: + """Tests for regulations endpoints.""" + + def test_list_regulations_empty(self, client, db_session): + """Test listing regulations when database is empty.""" + response = client.get("/api/v1/compliance/regulations") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 0 + assert data["regulations"] == [] + + def test_list_regulations_with_data(self, client, db_session, sample_regulation): + """Test listing regulations with data.""" + response = client.get("/api/v1/compliance/regulations") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert len(data["regulations"]) == 1 + assert data["regulations"][0]["code"] == "GDPR" + assert data["regulations"][0]["name"] == "General Data Protection Regulation" + + def test_list_regulations_filter_by_type(self, client, db_session): + """Test filtering regulations by type.""" + # Create regulations of different types + repo = RegulationRepository(db_session) + repo.create( + code="GDPR", + name="GDPR", + regulation_type=RegulationTypeEnum.EU_REGULATION, + ) + repo.create( + code="BSI-TR", + name="BSI Technical Guideline", + regulation_type=RegulationTypeEnum.BSI_STANDARD, + ) + + # Filter by EU_REGULATION + response = client.get("/api/v1/compliance/regulations?regulation_type=eu_regulation") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["regulations"][0]["code"] == "GDPR" + + def test_list_regulations_filter_by_active(self, client, db_session): + """Test filtering regulations by active status.""" + repo = RegulationRepository(db_session) + active = repo.create(code="ACTIVE", name="Active Reg", regulation_type=RegulationTypeEnum.EU_REGULATION) + inactive = repo.create(code="INACTIVE", name="Inactive Reg", regulation_type=RegulationTypeEnum.EU_REGULATION) + repo.update(inactive.id, is_active=False) + + # Get only active + response = client.get("/api/v1/compliance/regulations?is_active=true") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert data["regulations"][0]["code"] == "ACTIVE" + + def test_get_regulation_by_code(self, client, db_session, sample_regulation): + """Test getting specific regulation by code.""" + response = client.get("/api/v1/compliance/regulations/GDPR") + assert response.status_code == 200 + data = response.json() + assert data["code"] == "GDPR" + assert data["name"] == "General Data Protection Regulation" + assert "requirement_count" in data + + def test_get_regulation_not_found(self, client, db_session): + """Test getting non-existent regulation.""" + response = client.get("/api/v1/compliance/regulations/NONEXISTENT") + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + + +# ============================================================================ +# Requirements Tests +# ============================================================================ + +class TestRequirementsAPI: + """Tests for requirements endpoints.""" + + def test_list_requirements_paginated_empty(self, client, db_session): + """Test paginated requirements with empty database.""" + response = client.get("/api/v1/compliance/requirements") + assert response.status_code == 200 + data = response.json() + assert data["pagination"]["total"] == 0 + assert data["data"] == [] + + def test_list_requirements_paginated_with_data(self, client, db_session, sample_regulation, sample_requirement): + """Test paginated requirements with data.""" + response = client.get("/api/v1/compliance/requirements") + assert response.status_code == 200 + data = response.json() + assert data["pagination"]["total"] == 1 + assert len(data["data"]) == 1 + assert data["data"][0]["article"] == "Art. 32" + assert data["data"][0]["title"] == "Security of processing" + + def test_list_requirements_pagination_parameters(self, client, db_session, sample_regulation): + """Test pagination parameters.""" + # Create 5 requirements + repo = RequirementRepository(db_session) + for i in range(5): + repo.create( + regulation_id=sample_regulation.id, + article=f"Art. {i}", + title=f"Requirement {i}", + is_applicable=True, + ) + + # Test page size + response = client.get("/api/v1/compliance/requirements?page=1&page_size=2") + assert response.status_code == 200 + data = response.json() + assert len(data["data"]) == 2 + assert data["pagination"]["page"] == 1 + assert data["pagination"]["page_size"] == 2 + assert data["pagination"]["total"] == 5 + assert data["pagination"]["total_pages"] == 3 + assert data["pagination"]["has_next"] is True + assert data["pagination"]["has_prev"] is False + + # Test page 2 + response = client.get("/api/v1/compliance/requirements?page=2&page_size=2") + data = response.json() + assert data["pagination"]["page"] == 2 + assert data["pagination"]["has_next"] is True + assert data["pagination"]["has_prev"] is True + + def test_list_requirements_filter_by_regulation(self, client, db_session): + """Test filtering requirements by regulation code.""" + # Create two regulations with requirements + repo_reg = RegulationRepository(db_session) + repo_req = RequirementRepository(db_session) + + gdpr = repo_reg.create(code="GDPR", name="GDPR", regulation_type=RegulationTypeEnum.EU_REGULATION) + bsi = repo_reg.create(code="BSI", name="BSI", regulation_type=RegulationTypeEnum.BSI_STANDARD) + + repo_req.create(regulation_id=gdpr.id, article="Art. 1", title="GDPR Req") + repo_req.create(regulation_id=bsi.id, article="T.1", title="BSI Req") + + # Filter by GDPR + response = client.get("/api/v1/compliance/requirements?regulation_code=GDPR") + data = response.json() + assert data["pagination"]["total"] == 1 + assert data["data"][0]["title"] == "GDPR Req" + + def test_list_requirements_filter_by_applicable(self, client, db_session, sample_regulation): + """Test filtering by applicability.""" + repo = RequirementRepository(db_session) + applicable = repo.create( + regulation_id=sample_regulation.id, + article="Art. 1", + title="Applicable", + is_applicable=True, + ) + not_applicable = repo.create( + regulation_id=sample_regulation.id, + article="Art. 2", + title="Not Applicable", + is_applicable=False, + ) + + # Get only applicable + response = client.get("/api/v1/compliance/requirements?is_applicable=true") + data = response.json() + assert data["pagination"]["total"] == 1 + assert data["data"][0]["title"] == "Applicable" + + def test_list_requirements_search(self, client, db_session, sample_regulation): + """Test search functionality.""" + repo = RequirementRepository(db_session) + repo.create( + regulation_id=sample_regulation.id, + article="Art. 1", + title="Security of processing", + description="Encryption requirements", + ) + repo.create( + regulation_id=sample_regulation.id, + article="Art. 2", + title="Data minimization", + description="Minimize data collection", + ) + + # Search for "security" + response = client.get("/api/v1/compliance/requirements?search=security") + data = response.json() + assert data["pagination"]["total"] == 1 + assert "security" in data["data"][0]["title"].lower() + + def test_get_requirement_by_id(self, client, db_session, sample_requirement): + """Test getting specific requirement by ID.""" + response = client.get(f"/api/v1/compliance/requirements/{sample_requirement.id}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == sample_requirement.id + assert data["article"] == "Art. 32" + + def test_get_requirement_not_found(self, client, db_session): + """Test getting non-existent requirement.""" + response = client.get("/api/v1/compliance/requirements/nonexistent-id") + assert response.status_code == 404 + + +# ============================================================================ +# Controls Tests +# ============================================================================ + +class TestControlsAPI: + """Tests for controls endpoints.""" + + def test_list_controls_empty(self, client, db_session): + """Test listing controls with empty database.""" + response = client.get("/api/v1/compliance/controls") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 0 + assert data["controls"] == [] + + def test_list_controls_with_data(self, client, db_session, sample_control): + """Test listing controls with data.""" + response = client.get("/api/v1/compliance/controls") + assert response.status_code == 200 + data = response.json() + assert data["total"] == 1 + assert len(data["controls"]) == 1 + assert data["controls"][0]["control_id"] == "CRYPTO-001" + + def test_list_controls_filter_by_domain(self, client, db_session): + """Test filtering controls by domain.""" + repo = ControlRepository(db_session) + repo.create( + control_id="CRYPTO-001", + title="Crypto Control", + domain=ControlDomainEnum.CRYPTO, + control_type=ControlTypeEnum.PREVENTIVE, + pass_criteria="Test criteria", + ) + repo.create( + control_id="IAM-001", + title="IAM Control", + domain=ControlDomainEnum.IAM, + control_type=ControlTypeEnum.DETECTIVE, + pass_criteria="Test criteria", + ) + + response = client.get("/api/v1/compliance/controls?domain=crypto") + data = response.json() + assert data["total"] == 1 + assert data["controls"][0]["control_id"] == "CRYPTO-001" + + def test_list_controls_filter_by_status(self, client, db_session): + """Test filtering controls by status.""" + repo = ControlRepository(db_session) + control1 = repo.create( + control_id="PASS-001", + title="Passing Control", + domain=ControlDomainEnum.CRYPTO, + control_type=ControlTypeEnum.PREVENTIVE, + pass_criteria="Test criteria", + ) + # Update status after creation + control1.status = ControlStatusEnum.PASS + db_session.commit() + control2 = repo.create( + control_id="FAIL-001", + title="Failing Control", + domain=ControlDomainEnum.CRYPTO, + control_type=ControlTypeEnum.DETECTIVE, + pass_criteria="Test criteria", + ) + control2.status = ControlStatusEnum.FAIL + db_session.commit() + + response = client.get("/api/v1/compliance/controls?status=pass") + data = response.json() + assert data["total"] == 1 + assert data["controls"][0]["control_id"] == "PASS-001" + + +# ============================================================================ +# Dashboard Tests +# ============================================================================ + +class TestDashboardAPI: + """Tests for dashboard endpoint.""" + + def test_dashboard_empty(self, client, db_session): + """Test dashboard with empty database.""" + response = client.get("/api/v1/compliance/dashboard") + assert response.status_code == 200 + data = response.json() + assert data["compliance_score"] == 0 + assert data["total_regulations"] == 0 + assert data["total_requirements"] == 0 + assert data["total_controls"] == 0 + + def test_dashboard_with_data(self, client, db_session, sample_regulation, sample_requirement, sample_control): + """Test dashboard with data.""" + response = client.get("/api/v1/compliance/dashboard") + assert response.status_code == 200 + data = response.json() + + # Check basic counts + assert data["total_regulations"] > 0 + assert data["total_requirements"] > 0 + assert data["total_controls"] > 0 + + # Check compliance score calculation + assert 0 <= data["compliance_score"] <= 100 + + # Check structure + assert "controls_by_status" in data + assert "controls_by_domain" in data + assert "evidence_by_status" in data + assert "risks_by_level" in data + + def test_dashboard_compliance_score_calculation(self, client, db_session): + """Test compliance score is calculated correctly.""" + repo = ControlRepository(db_session) + + # Create controls with different statuses + c1 = repo.create(control_id="PASS-1", title="Pass 1", domain=ControlDomainEnum.CRYPTO, control_type=ControlTypeEnum.PREVENTIVE, pass_criteria="Test") + c1.status = ControlStatusEnum.PASS + c2 = repo.create(control_id="PASS-2", title="Pass 2", domain=ControlDomainEnum.CRYPTO, control_type=ControlTypeEnum.PREVENTIVE, pass_criteria="Test") + c2.status = ControlStatusEnum.PASS + c3 = repo.create(control_id="PARTIAL-1", title="Partial", domain=ControlDomainEnum.CRYPTO, control_type=ControlTypeEnum.DETECTIVE, pass_criteria="Test") + c3.status = ControlStatusEnum.PARTIAL + c4 = repo.create(control_id="FAIL-1", title="Fail", domain=ControlDomainEnum.CRYPTO, control_type=ControlTypeEnum.CORRECTIVE, pass_criteria="Test") + c4.status = ControlStatusEnum.FAIL + db_session.commit() + + response = client.get("/api/v1/compliance/dashboard") + data = response.json() + + # Score = (2 pass + 0.5 * 1 partial) / 4 total = 2.5 / 4 = 62.5% + expected_score = ((2 + 0.5) / 4) * 100 + assert data["compliance_score"] == round(expected_score, 1) + + +# ============================================================================ +# Evidence Collection Tests +# ============================================================================ + +class TestEvidenceCollectionAPI: + """Tests for evidence collection endpoints.""" + + def test_collect_evidence_missing_source(self, client, db_session): + """Test evidence collection without source parameter.""" + response = client.post("/api/v1/compliance/evidence/collect") + assert response.status_code == 422 # Missing required parameter + + def test_collect_evidence_invalid_source(self, client, db_session): + """Test evidence collection with invalid source.""" + response = client.post("/api/v1/compliance/evidence/collect?source=invalid_source") + assert response.status_code == 400 + assert "Unknown source" in response.json()["detail"] + + def test_collect_evidence_control_not_found(self, client, db_session): + """Test evidence collection when control doesn't exist.""" + response = client.post("/api/v1/compliance/evidence/collect?source=sast") + # Should return 404 because control SDLC-001 doesn't exist + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + + def test_collect_evidence_sast(self, client, db_session): + """Test SAST evidence collection.""" + # First create the control + repo = ControlRepository(db_session) + control = repo.create( + control_id="SDLC-001", + title="SAST Scanning", + domain=ControlDomainEnum.SDLC, + control_type=ControlTypeEnum.DETECTIVE, + pass_criteria="No critical vulnerabilities", + ) + control.status = ControlStatusEnum.PASS + db_session.commit() + + report_data = { + "findings": [ + {"severity": "high", "rule": "sql-injection"}, + ], + "summary": {"total": 1, "high": 1} + } + + response = client.post( + "/api/v1/compliance/evidence/collect?source=sast&ci_job_id=12345", + json=report_data + ) + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "evidence_id" in data + + def test_collect_evidence_dependency_scan(self, client, db_session): + """Test dependency scan evidence collection.""" + repo = ControlRepository(db_session) + repo.create( + control_id="SDLC-002", + title="Dependency Scanning", + domain=ControlDomainEnum.SDLC, + control_type=ControlTypeEnum.DETECTIVE, + pass_criteria="No critical vulnerabilities", + ) + + report_data = { + "vulnerabilities": [], + "summary": {"total": 0, "critical": 0} + } + + response = client.post( + "/api/v1/compliance/evidence/collect?source=dependency_scan", + json=report_data + ) + assert response.status_code == 200 + + def test_collect_evidence_with_ci_metadata(self, client, db_session): + """Test evidence collection with CI/CD metadata.""" + repo = ControlRepository(db_session) + repo.create( + control_id="SDLC-001", + title="SAST Scanning", + domain=ControlDomainEnum.SDLC, + control_type=ControlTypeEnum.DETECTIVE, + pass_criteria="No critical vulnerabilities", + ) + + response = client.post( + "/api/v1/compliance/evidence/collect" + "?source=sast" + "&ci_job_id=job-123" + "&ci_job_url=https://github.com/actions/runs/123", + json={"findings": []} + ) + assert response.status_code == 200 + + +class TestEvidenceStatusAPI: + """Tests for CI evidence status endpoint.""" + + def test_ci_status_empty(self, client, db_session): + """Test CI status with no evidence.""" + response = client.get("/api/v1/compliance/evidence/ci-status") + assert response.status_code == 200 + data = response.json() + assert "controls" in data or "message" in data + + def test_ci_status_with_evidence(self, client, db_session): + """Test CI status with evidence.""" + # Create control and evidence + ctrl_repo = ControlRepository(db_session) + evidence_repo = EvidenceRepository(db_session) + + control = ctrl_repo.create( + control_id="SDLC-001", + title="SAST", + domain=ControlDomainEnum.SDLC, + control_type=ControlTypeEnum.DETECTIVE, + pass_criteria="No critical vulnerabilities", + ) + + evidence_repo.create( + control_id=control.control_id, # Use control_id string, not UUID + evidence_type="report", + title="CI Pipeline Evidence", + source="ci_pipeline", + ci_job_id="123", + ) + + response = client.get("/api/v1/compliance/evidence/ci-status") + assert response.status_code == 200 + + def test_ci_status_filter_by_control(self, client, db_session): + """Test filtering CI status by control ID.""" + ctrl_repo = ControlRepository(db_session) + evidence_repo = EvidenceRepository(db_session) + + control1 = ctrl_repo.create(control_id="SDLC-001", title="SAST", domain=ControlDomainEnum.SDLC, control_type=ControlTypeEnum.DETECTIVE, pass_criteria="Test") + control2 = ctrl_repo.create(control_id="SDLC-002", title="Deps", domain=ControlDomainEnum.SDLC, control_type=ControlTypeEnum.DETECTIVE, pass_criteria="Test") + + evidence_repo.create(control_id=control1.control_id, evidence_type="report", title="Evidence 1", source="ci_pipeline") + evidence_repo.create(control_id=control2.control_id, evidence_type="report", title="Evidence 2", source="ci_pipeline") + + response = client.get("/api/v1/compliance/evidence/ci-status?control_id=SDLC-001") + assert response.status_code == 200 + + def test_ci_status_days_filter(self, client, db_session): + """Test filtering CI status by days.""" + response = client.get("/api/v1/compliance/evidence/ci-status?days=7") + assert response.status_code == 200 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/backend/tests/test_compliance_pdf_extractor.py b/backend/tests/test_compliance_pdf_extractor.py new file mode 100644 index 0000000..cc12fcf --- /dev/null +++ b/backend/tests/test_compliance_pdf_extractor.py @@ -0,0 +1,476 @@ +""" +Tests for Compliance PDF Extractor. + +Tests cover: +- BSIPDFExtractor.extract_from_file() +- Aspect categorization +- Requirement level detection (MUSS/SOLL/KANN) +- Text parsing and pattern matching +""" + +import pytest +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch, mock_open +import sys + +# Mock fitz if not available +try: + import fitz +except ImportError: + fitz = MagicMock() + sys.modules['fitz'] = fitz + +from compliance.services.pdf_extractor import ( + BSIPDFExtractor, + BSIAspect, + RequirementLevel, + AspectCategory, +) + + +@pytest.fixture +def extractor(): + """Create a BSIPDFExtractor instance.""" + with patch("compliance.services.pdf_extractor.fitz", MagicMock()): + return BSIPDFExtractor() + + +@pytest.fixture +def mock_pdf(): + """Create a mock PDF document.""" + mock_doc = MagicMock() + mock_doc.__len__ = MagicMock(return_value=1) # 1 page + mock_page = MagicMock() + mock_page.get_text = MagicMock(return_value=""" + 4.2.1 Authentifizierung + + O.Auth_1: Sichere Passwörter + Die Anwendung MUSS starke Passwörter erzwingen. + Passwörter MÜSSEN mindestens 8 Zeichen lang sein. + + O.Auth_2: Multi-Faktor-Authentifizierung + Die Anwendung SOLL Multi-Faktor-Authentifizierung unterstützen. + """) + mock_doc.__getitem__ = MagicMock(return_value=mock_page) + return mock_doc + + +# ============================================================================ +# BSIPDFExtractor Tests +# ============================================================================ + +class TestBSIPDFExtractor: + """Tests for BSIPDFExtractor.""" + + @patch("compliance.services.pdf_extractor.fitz", MagicMock()) + def test_extractor_initialization(self): + """Test that extractor can be initialized.""" + extractor = BSIPDFExtractor() + assert extractor is not None + assert extractor.logger is not None + + def test_extractor_requires_pymupdf(self): + """Test that extractor raises error if PyMuPDF not available.""" + with patch("compliance.services.pdf_extractor.fitz", None): + with pytest.raises(ImportError) as excinfo: + BSIPDFExtractor() + assert "PyMuPDF" in str(excinfo.value) + + def test_extract_from_nonexistent_file(self, extractor): + """Test extraction from non-existent file raises error.""" + with pytest.raises(FileNotFoundError): + extractor.extract_from_file("/nonexistent/file.pdf") + + @patch("compliance.services.pdf_extractor.fitz") + def test_extract_from_file_basic(self, mock_fitz, extractor, mock_pdf): + """Test basic PDF extraction.""" + mock_fitz.open = MagicMock(return_value=mock_pdf) + + # Create a temporary PDF file + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: + tmp_path = tmp.name + + try: + aspects = extractor.extract_from_file(tmp_path) + assert isinstance(aspects, list) + # Should extract aspects from the mock PDF + finally: + Path(tmp_path).unlink(missing_ok=True) + + @patch("compliance.services.pdf_extractor.fitz") + def test_extract_from_file_with_source_name(self, mock_fitz, extractor, mock_pdf): + """Test extraction with custom source name.""" + mock_fitz.open = MagicMock(return_value=mock_pdf) + + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: + tmp_path = tmp.name + + try: + aspects = extractor.extract_from_file(tmp_path, source_name="BSI-TR-03161-2") + # Should use provided source name + if aspects: + assert aspects[0].source_document == "BSI-TR-03161-2" + finally: + Path(tmp_path).unlink(missing_ok=True) + + +# ============================================================================ +# Categorization Tests +# ============================================================================ + +class TestAspectCategorization: + """Tests for aspect categorization.""" + + def test_category_map_authentication(self, extractor): + """Test authentication category detection.""" + category_map = extractor.CATEGORY_MAP + assert category_map.get("O.Auth") == AspectCategory.AUTHENTICATION + + def test_category_map_cryptography(self, extractor): + """Test cryptography category detection.""" + category_map = extractor.CATEGORY_MAP + assert category_map.get("O.Cryp") == AspectCategory.CRYPTOGRAPHY + assert category_map.get("O.Crypto") == AspectCategory.CRYPTOGRAPHY + + def test_category_map_session_management(self, extractor): + """Test session management category detection.""" + category_map = extractor.CATEGORY_MAP + assert category_map.get("O.Sess") == AspectCategory.SESSION_MANAGEMENT + + def test_category_map_input_validation(self, extractor): + """Test input validation category detection.""" + category_map = extractor.CATEGORY_MAP + assert category_map.get("O.Input") == AspectCategory.INPUT_VALIDATION + + def test_category_map_sql_injection(self, extractor): + """Test SQL injection category detection.""" + category_map = extractor.CATEGORY_MAP + assert category_map.get("O.SQL") == AspectCategory.SQL_INJECTION + + def test_category_map_test_aspect(self, extractor): + """Test that T.* aspects are categorized as test aspects.""" + category_map = extractor.CATEGORY_MAP + assert category_map.get("T.") == AspectCategory.TEST_ASPECT + + def test_category_keywords_authentication(self, extractor): + """Test authentication keywords are present.""" + keywords = extractor.CATEGORY_KEYWORDS[AspectCategory.AUTHENTICATION] + assert "authentication" in keywords + assert "login" in keywords + assert "password" in keywords or "passwort" in keywords + assert "oauth" in keywords + + def test_category_keywords_cryptography(self, extractor): + """Test cryptography keywords are present.""" + keywords = extractor.CATEGORY_KEYWORDS[AspectCategory.CRYPTOGRAPHY] + assert "encryption" in keywords or "verschlüsselung" in keywords + assert "tls" in keywords + assert "aes" in keywords or "rsa" in keywords + + def test_categorize_by_aspect_id(self, extractor): + """Test categorization based on aspect ID prefix.""" + # Test various aspect ID patterns + test_cases = [ + ("O.Auth_1", AspectCategory.AUTHENTICATION), + ("O.Crypto_2", AspectCategory.CRYPTOGRAPHY), + ("O.Sess_3", AspectCategory.SESSION_MANAGEMENT), + ("O.Input_4", AspectCategory.INPUT_VALIDATION), + ("T.Auth_1", AspectCategory.TEST_ASPECT), + ] + + for aspect_id, expected_category in test_cases: + # Find matching prefix in category map + for prefix, category in extractor.CATEGORY_MAP.items(): + if aspect_id.startswith(prefix): + assert category == expected_category + break + + +# ============================================================================ +# Requirement Level Tests +# ============================================================================ + +class TestRequirementLevelDetection: + """Tests for requirement level detection (MUSS/SOLL/KANN).""" + + def test_requirement_level_enum(self): + """Test RequirementLevel enum values.""" + assert RequirementLevel.MUSS.value == "MUSS" + assert RequirementLevel.SOLL.value == "SOLL" + assert RequirementLevel.KANN.value == "KANN" + assert RequirementLevel.DARF_NICHT.value == "DARF NICHT" + + def test_requirement_pattern_muss(self, extractor): + """Test MUSS pattern detection.""" + import re + pattern = extractor.PATTERNS["requirement"] + + # Test uppercase MUSS + text_upper = "Die Anwendung MUSS sichere Passwörter verwenden." + matches = re.findall(pattern, text_upper) + assert len(matches) > 0 + assert matches[0].upper() == "MUSS" + + # Test lowercase muss + text_lower = "Das System muss verschlüsselt sein." + matches = re.findall(pattern, text_lower) + assert len(matches) > 0 + assert matches[0].upper() == "MUSS" + + # Note: Pattern does not match conjugations like "müssen" + # since BSI-TR documents use "MUSS" or "muss" as requirement markers + + def test_requirement_pattern_soll(self, extractor): + """Test SOLL pattern detection.""" + import re + pattern = extractor.PATTERNS["requirement"] + + test_texts = [ + "Die Anwendung SOLL MFA unterstützen.", + "Das System soll Logging implementieren.", + ] + + for text in test_texts: + matches = re.findall(pattern, text) + assert len(matches) > 0 + assert matches[0].upper() == "SOLL" + + def test_requirement_pattern_kann(self, extractor): + """Test KANN pattern detection.""" + import re + pattern = extractor.PATTERNS["requirement"] + + test_texts = [ + "Die Anwendung KANN biometrische Auth anbieten.", + "Das System kann zusätzliche Features haben.", + ] + + for text in test_texts: + matches = re.findall(pattern, text) + assert len(matches) > 0 + assert matches[0].upper() == "KANN" + + def test_requirement_pattern_darf_nicht(self, extractor): + """Test DARF NICHT pattern detection.""" + import re + pattern = extractor.PATTERNS["requirement"] + + test_texts = [ + "Die Anwendung DARF NICHT Passwörter im Klartext speichern.", + "Das System darf nicht unverschlüsselt kommunizieren.", + ] + + for text in test_texts: + matches = re.findall(pattern, text, re.IGNORECASE) + assert len(matches) > 0 + + +# ============================================================================ +# Pattern Matching Tests +# ============================================================================ + +class TestPatternMatching: + """Tests for regex pattern matching.""" + + def test_aspect_id_pattern(self, extractor): + """Test aspect ID pattern matching.""" + import re + pattern = extractor.PATTERNS["aspect_id"] + + test_cases = [ + ("O.Auth_1", True), + ("O.Crypto_23", True), + ("T.Network_5", True), + ("O.Session_100", True), + ("InvalidID", False), + ("O.Auth", False), # Missing number + ] + + for text, should_match in test_cases: + match = re.search(pattern, text) + if should_match: + assert match is not None, f"Pattern should match: {text}" + else: + assert match is None, f"Pattern should not match: {text}" + + def test_section_pattern(self, extractor): + """Test section number pattern matching.""" + import re + pattern = extractor.PATTERNS["section"] + + test_cases = [ + ("4.2.1", True), + ("1.0", True), + ("10.5.3", True), + ("invalid", False), + ] + + for text, should_match in test_cases: + match = re.search(pattern, text) + if should_match: + assert match is not None, f"Pattern should match: {text}" + + def test_section_aspect_pattern(self, extractor): + """Test section-based aspect pattern.""" + import re + pattern = extractor.PATTERNS["section_aspect"] + + test_cases = [ + "Prüfaspekt 4.2.1", + "Pruefaspekt 10.5", + "Anforderung 3.1.2", + ] + + for text in test_cases: + match = re.search(pattern, text) + assert match is not None, f"Pattern should match: {text}" + assert match.group(1) is not None # Should capture section number + + +# ============================================================================ +# BSIAspect Model Tests +# ============================================================================ + +class TestBSIAspectModel: + """Tests for BSIAspect data model.""" + + def test_bsi_aspect_creation(self): + """Test creating a BSIAspect instance.""" + aspect = BSIAspect( + aspect_id="O.Auth_1", + title="Sichere Passwörter", + full_text="Die Anwendung MUSS starke Passwörter erzwingen.", + category=AspectCategory.AUTHENTICATION, + page_number=10, + section="4.2.1", + requirement_level=RequirementLevel.MUSS, + source_document="BSI-TR-03161-2", + ) + + assert aspect.aspect_id == "O.Auth_1" + assert aspect.title == "Sichere Passwörter" + assert aspect.category == AspectCategory.AUTHENTICATION + assert aspect.requirement_level == RequirementLevel.MUSS + assert aspect.page_number == 10 + + def test_bsi_aspect_with_optional_fields(self): + """Test BSIAspect with optional fields.""" + aspect = BSIAspect( + aspect_id="O.Auth_1", + title="Test", + full_text="Test text", + category=AspectCategory.AUTHENTICATION, + page_number=1, + section="1.0", + requirement_level=RequirementLevel.MUSS, + source_document="Test", + context_before="Context before", + context_after="Context after", + related_aspects=["O.Auth_2", "O.Auth_3"], + keywords=["password", "authentication"], + ) + + assert aspect.context_before == "Context before" + assert aspect.context_after == "Context after" + assert len(aspect.related_aspects) == 2 + assert "password" in aspect.keywords + + +# ============================================================================ +# Text Extraction Tests +# ============================================================================ + +class TestTextExtraction: + """Tests for text extraction logic.""" + + @patch("compliance.services.pdf_extractor.fitz") + def test_extract_aspects_from_text_with_ids(self, mock_fitz, extractor): + """Test extracting aspects that have explicit IDs.""" + text = """ + 4.2 Authentifizierung + + O.Auth_1: Sichere Passwörter + Die Anwendung MUSS starke Passwörter erzwingen. + + O.Auth_2: Multi-Faktor + Die Anwendung SOLL MFA unterstützen. + """ + + # Extract aspects from text + aspects = extractor._extract_aspects_from_text( + text=text, + page_num=1, + source_document="Test" + ) + + # Should find at least the aspects + assert isinstance(aspects, list) + + def test_extract_multiple_requirement_levels(self, extractor): + """Test extracting text with multiple requirement levels.""" + text = """ + Das System MUSS verschlüsselt sein. + Es SOLL Logging aktivieren. + Es KANN zusätzliche Features haben. + Es DARF NICHT Passwörter speichern. + """ + + import re + pattern = extractor.PATTERNS["requirement"] + matches = re.findall(pattern, text, re.IGNORECASE) + + # Should find all 4 requirement levels + assert len(matches) >= 4 + + +# ============================================================================ +# Integration Tests +# ============================================================================ + +class TestPDFExtractionIntegration: + """Integration tests for complete PDF extraction workflow.""" + + @patch("compliance.services.pdf_extractor.fitz") + def test_complete_extraction_workflow(self, mock_fitz, extractor): + """Test complete extraction from PDF to aspects.""" + # Create mock PDF with realistic content + mock_doc = MagicMock() + mock_doc.__len__ = MagicMock(return_value=2) # 2 pages + + page1 = MagicMock() + page1.get_text = MagicMock(return_value=""" + 4.2.1 Authentifizierung + + O.Auth_1: Sichere Passwörter + Die Anwendung MUSS starke Passwörter mit mindestens 8 Zeichen erzwingen. + """) + + page2 = MagicMock() + page2.get_text = MagicMock(return_value=""" + 4.2.2 Session Management + + O.Sess_1: Session Timeout + Die Anwendung SOLL nach 15 Minuten Inaktivität die Session beenden. + """) + + mock_doc.__getitem__ = MagicMock(side_effect=[page1, page2]) + mock_fitz.open = MagicMock(return_value=mock_doc) + + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: + tmp_path = tmp.name + + try: + aspects = extractor.extract_from_file(tmp_path, source_name="BSI-TR-03161-2") + + # Verify extraction worked + assert isinstance(aspects, list) + + # PDF was closed + mock_doc.close.assert_called_once() + finally: + Path(tmp_path).unlink(missing_ok=True) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/backend/tests/test_compliance_repository.py b/backend/tests/test_compliance_repository.py new file mode 100644 index 0000000..9cc2c8f --- /dev/null +++ b/backend/tests/test_compliance_repository.py @@ -0,0 +1,686 @@ +""" +Tests for Compliance Repository Layer. + +Tests cover: +- RequirementRepository.get_paginated() +- ControlRepository CRUD operations +- EvidenceRepository.create() +- RegulationRepository operations +- Eager loading and relationships +""" + +import pytest +from datetime import datetime, timedelta +from unittest.mock import MagicMock + +# Test with in-memory SQLite for isolation +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from classroom_engine.database import Base +from compliance.db.models import ( + RegulationDB, RequirementDB, ControlDB, EvidenceDB, ControlMappingDB, + RegulationTypeEnum, ControlDomainEnum, ControlStatusEnum, EvidenceStatusEnum, ControlTypeEnum +) +from compliance.db.repository import ( + RegulationRepository, + RequirementRepository, + ControlRepository, + EvidenceRepository, + ControlMappingRepository, +) + + +@pytest.fixture +def db_session(): + """Create in-memory SQLite session for tests.""" + # Use check_same_thread=False for SQLite in tests + engine = create_engine( + "sqlite:///:memory:", + echo=False, + connect_args={"check_same_thread": False} + ) + Base.metadata.create_all(engine) + SessionLocal = sessionmaker(bind=engine) + session = SessionLocal() + yield session + session.close() + + +@pytest.fixture +def sample_regulation(db_session): + """Create a sample regulation.""" + repo = RegulationRepository(db_session) + return repo.create( + code="GDPR", + name="General Data Protection Regulation", + regulation_type=RegulationTypeEnum.EU_REGULATION, + description="EU data protection law", + ) + + +@pytest.fixture +def sample_control(db_session): + """Create a sample control.""" + repo = ControlRepository(db_session) + return repo.create( + control_id="CRYPTO-001", + title="TLS Encryption", + domain=ControlDomainEnum.CRYPTO, + control_type=ControlTypeEnum.PREVENTIVE, + pass_criteria="All connections use TLS 1.3", + description="Enforce TLS 1.3 for all external communication", + ) + + +# ============================================================================ +# RegulationRepository Tests +# ============================================================================ + +class TestRegulationRepository: + """Tests for RegulationRepository.""" + + def test_create_regulation(self, db_session): + """Test creating a regulation.""" + repo = RegulationRepository(db_session) + + regulation = repo.create( + code="GDPR", + name="General Data Protection Regulation", + regulation_type=RegulationTypeEnum.EU_REGULATION, + full_name="Regulation (EU) 2016/679", + description="EU data protection regulation", + ) + + assert regulation.id is not None + assert regulation.code == "GDPR" + assert regulation.name == "General Data Protection Regulation" + assert regulation.regulation_type == RegulationTypeEnum.EU_REGULATION + assert regulation.is_active is True + + def test_get_regulation_by_id(self, db_session, sample_regulation): + """Test getting regulation by ID.""" + repo = RegulationRepository(db_session) + + found = repo.get_by_id(sample_regulation.id) + assert found is not None + assert found.id == sample_regulation.id + assert found.code == "GDPR" + + def test_get_regulation_by_id_not_found(self, db_session): + """Test getting non-existent regulation.""" + repo = RegulationRepository(db_session) + found = repo.get_by_id("nonexistent-id") + assert found is None + + def test_get_regulation_by_code(self, db_session, sample_regulation): + """Test getting regulation by code.""" + repo = RegulationRepository(db_session) + + found = repo.get_by_code("GDPR") + assert found is not None + assert found.code == "GDPR" + + def test_get_all_regulations(self, db_session): + """Test getting all regulations.""" + repo = RegulationRepository(db_session) + + repo.create(code="GDPR", name="GDPR", regulation_type=RegulationTypeEnum.EU_REGULATION) + repo.create(code="AI-ACT", name="AI Act", regulation_type=RegulationTypeEnum.EU_REGULATION) + repo.create(code="BSI-TR", name="BSI", regulation_type=RegulationTypeEnum.BSI_STANDARD) + + all_regs = repo.get_all() + assert len(all_regs) == 3 + + def test_get_regulations_filter_by_type(self, db_session): + """Test filtering regulations by type.""" + repo = RegulationRepository(db_session) + + repo.create(code="GDPR", name="GDPR", regulation_type=RegulationTypeEnum.EU_REGULATION) + repo.create(code="BSI-TR", name="BSI", regulation_type=RegulationTypeEnum.BSI_STANDARD) + + eu_regs = repo.get_all(regulation_type=RegulationTypeEnum.EU_REGULATION) + assert len(eu_regs) == 1 + assert eu_regs[0].code == "GDPR" + + def test_get_regulations_filter_by_active(self, db_session): + """Test filtering regulations by active status.""" + repo = RegulationRepository(db_session) + + active = repo.create(code="ACTIVE", name="Active", regulation_type=RegulationTypeEnum.EU_REGULATION) + inactive = repo.create(code="INACTIVE", name="Inactive", regulation_type=RegulationTypeEnum.EU_REGULATION) + repo.update(inactive.id, is_active=False) + + active_regs = repo.get_all(is_active=True) + assert len(active_regs) == 1 + assert active_regs[0].code == "ACTIVE" + + def test_update_regulation(self, db_session, sample_regulation): + """Test updating a regulation.""" + repo = RegulationRepository(db_session) + + updated = repo.update( + sample_regulation.id, + name="Updated Name", + is_active=False, + ) + + assert updated is not None + assert updated.name == "Updated Name" + assert updated.is_active is False + + def test_delete_regulation(self, db_session, sample_regulation): + """Test deleting a regulation.""" + repo = RegulationRepository(db_session) + + result = repo.delete(sample_regulation.id) + assert result is True + + found = repo.get_by_id(sample_regulation.id) + assert found is None + + def test_delete_nonexistent_regulation(self, db_session): + """Test deleting non-existent regulation.""" + repo = RegulationRepository(db_session) + result = repo.delete("nonexistent-id") + assert result is False + + def test_get_active_regulations(self, db_session): + """Test getting only active regulations.""" + repo = RegulationRepository(db_session) + + repo.create(code="ACTIVE1", name="Active 1", regulation_type=RegulationTypeEnum.EU_REGULATION) + repo.create(code="ACTIVE2", name="Active 2", regulation_type=RegulationTypeEnum.EU_REGULATION) + inactive = repo.create(code="INACTIVE", name="Inactive", regulation_type=RegulationTypeEnum.EU_REGULATION) + repo.update(inactive.id, is_active=False) + + active_regs = repo.get_active() + assert len(active_regs) == 2 + + def test_count_regulations(self, db_session): + """Test counting regulations.""" + repo = RegulationRepository(db_session) + + repo.create(code="REG1", name="Reg 1", regulation_type=RegulationTypeEnum.EU_REGULATION) + repo.create(code="REG2", name="Reg 2", regulation_type=RegulationTypeEnum.EU_REGULATION) + + count = repo.count() + assert count == 2 + + +# ============================================================================ +# RequirementRepository Tests +# ============================================================================ + +class TestRequirementRepository: + """Tests for RequirementRepository.""" + + def test_create_requirement(self, db_session, sample_regulation): + """Test creating a requirement.""" + repo = RequirementRepository(db_session) + + requirement = repo.create( + regulation_id=sample_regulation.id, + article="Art. 32", + title="Security of processing", + description="Implement appropriate technical measures", + requirement_text="The controller shall implement appropriate technical and organizational measures...", + is_applicable=True, + priority=1, + ) + + assert requirement.id is not None + assert requirement.article == "Art. 32" + assert requirement.title == "Security of processing" + assert requirement.is_applicable is True + + def test_get_requirement_by_id(self, db_session, sample_regulation): + """Test getting requirement by ID.""" + repo = RequirementRepository(db_session) + + created = repo.create( + regulation_id=sample_regulation.id, + article="Art. 32", + title="Security", + is_applicable=True, + ) + + found = repo.get_by_id(created.id) + assert found is not None + assert found.id == created.id + + def test_get_requirements_by_regulation(self, db_session, sample_regulation): + """Test getting requirements by regulation.""" + repo = RequirementRepository(db_session) + + repo.create(regulation_id=sample_regulation.id, article="Art. 1", title="Req 1", is_applicable=True) + repo.create(regulation_id=sample_regulation.id, article="Art. 2", title="Req 2", is_applicable=True) + + requirements = repo.get_by_regulation(sample_regulation.id) + assert len(requirements) == 2 + + def test_get_requirements_filter_by_applicable(self, db_session, sample_regulation): + """Test filtering requirements by applicability.""" + repo = RequirementRepository(db_session) + + repo.create(regulation_id=sample_regulation.id, article="Art. 1", title="Applicable", is_applicable=True) + repo.create(regulation_id=sample_regulation.id, article="Art. 2", title="Not Applicable", is_applicable=False) + + applicable = repo.get_by_regulation(sample_regulation.id, is_applicable=True) + assert len(applicable) == 1 + assert applicable[0].title == "Applicable" + + def test_get_requirements_paginated_basic(self, db_session, sample_regulation): + """Test basic pagination of requirements.""" + repo = RequirementRepository(db_session) + + # Create 10 requirements + for i in range(10): + repo.create( + regulation_id=sample_regulation.id, + article=f"Art. {i}", + title=f"Requirement {i}", + is_applicable=True, + ) + + # Get first page + items, total = repo.get_paginated(page=1, page_size=5) + assert len(items) == 5 + assert total == 10 + + # Get second page + items, total = repo.get_paginated(page=2, page_size=5) + assert len(items) == 5 + assert total == 10 + + def test_get_requirements_paginated_filter_by_regulation(self, db_session): + """Test pagination with regulation filter.""" + repo_reg = RegulationRepository(db_session) + repo_req = RequirementRepository(db_session) + + gdpr = repo_reg.create(code="GDPR", name="GDPR", regulation_type=RegulationTypeEnum.EU_REGULATION) + bsi = repo_reg.create(code="BSI", name="BSI", regulation_type=RegulationTypeEnum.BSI_STANDARD) + + repo_req.create(regulation_id=gdpr.id, article="Art. 1", title="GDPR Req") + repo_req.create(regulation_id=bsi.id, article="T.1", title="BSI Req") + + # Filter by GDPR + items, total = repo_req.get_paginated(regulation_code="GDPR") + assert total == 1 + assert items[0].title == "GDPR Req" + + def test_get_requirements_paginated_filter_by_status(self, db_session, sample_regulation): + """Test pagination with status filter.""" + repo = RequirementRepository(db_session) + + # Create requirements with different statuses by updating the model directly + req1 = repo.create(regulation_id=sample_regulation.id, article="Art. 1", title="Implemented") + req2 = repo.create(regulation_id=sample_regulation.id, article="Art. 2", title="Planned") + + # Update statuses via the database model + req1.implementation_status = "implemented" + req2.implementation_status = "planned" + db_session.commit() + + # Filter by implemented + items, total = repo.get_paginated(status="implemented") + assert total == 1 + assert items[0].title == "Implemented" + + def test_get_requirements_paginated_search(self, db_session, sample_regulation): + """Test pagination with search.""" + repo = RequirementRepository(db_session) + + repo.create(regulation_id=sample_regulation.id, article="Art. 1", title="Security of processing") + repo.create(regulation_id=sample_regulation.id, article="Art. 2", title="Data minimization") + + # Search for "security" + items, total = repo.get_paginated(search="security") + assert total == 1 + assert "security" in items[0].title.lower() + + def test_update_requirement(self, db_session, sample_regulation): + """Test updating a requirement.""" + repo = RequirementRepository(db_session) + + requirement = repo.create( + regulation_id=sample_regulation.id, + article="Art. 32", + title="Original", + is_applicable=True, + ) + + # Update via model directly (RequirementRepository doesn't have update method) + requirement.title = "Updated Title" + requirement.implementation_status = "implemented" + db_session.commit() + db_session.refresh(requirement) + + assert requirement.title == "Updated Title" + assert requirement.implementation_status == "implemented" + + +# ============================================================================ +# ControlRepository Tests +# ============================================================================ + +class TestControlRepository: + """Tests for ControlRepository CRUD operations.""" + + def test_create_control(self, db_session): + """Test creating a control.""" + repo = ControlRepository(db_session) + + control = repo.create( + control_id="CRYPTO-001", + title="TLS 1.3 Encryption", + domain=ControlDomainEnum.CRYPTO, + control_type=ControlTypeEnum.PREVENTIVE, + pass_criteria="All external communication uses TLS 1.3", + description="Enforce TLS 1.3 for all connections", + is_automated=True, + automation_tool="NGINX", + ) + + assert control.id is not None + assert control.control_id == "CRYPTO-001" + assert control.domain == ControlDomainEnum.CRYPTO + assert control.is_automated is True + + def test_get_control_by_id(self, db_session, sample_control): + """Test getting control by UUID.""" + repo = ControlRepository(db_session) + + found = repo.get_by_id(sample_control.id) + assert found is not None + assert found.id == sample_control.id + + def test_get_control_by_control_id(self, db_session, sample_control): + """Test getting control by control_id.""" + repo = ControlRepository(db_session) + + found = repo.get_by_control_id("CRYPTO-001") + assert found is not None + assert found.control_id == "CRYPTO-001" + + def test_get_all_controls(self, db_session): + """Test getting all controls.""" + repo = ControlRepository(db_session) + + repo.create(control_id="CRYPTO-001", title="Crypto", domain=ControlDomainEnum.CRYPTO, control_type=ControlTypeEnum.PREVENTIVE, pass_criteria="Pass") + repo.create(control_id="IAM-001", title="IAM", domain=ControlDomainEnum.IAM, control_type=ControlTypeEnum.PREVENTIVE, pass_criteria="Pass") + + all_controls = repo.get_all() + assert len(all_controls) == 2 + + def test_get_controls_filter_by_domain(self, db_session): + """Test filtering controls by domain.""" + repo = ControlRepository(db_session) + + repo.create(control_id="CRYPTO-001", title="Crypto", domain=ControlDomainEnum.CRYPTO, control_type=ControlTypeEnum.PREVENTIVE, pass_criteria="Pass") + repo.create(control_id="IAM-001", title="IAM", domain=ControlDomainEnum.IAM, control_type=ControlTypeEnum.PREVENTIVE, pass_criteria="Pass") + + crypto_controls = repo.get_all(domain=ControlDomainEnum.CRYPTO) + assert len(crypto_controls) == 1 + assert crypto_controls[0].control_id == "CRYPTO-001" + + def test_get_controls_filter_by_status(self, db_session): + """Test filtering controls by status.""" + repo = ControlRepository(db_session) + + pass_ctrl = repo.create(control_id="PASS-001", title="Pass", domain=ControlDomainEnum.CRYPTO, control_type=ControlTypeEnum.PREVENTIVE, pass_criteria="Pass") + fail_ctrl = repo.create(control_id="FAIL-001", title="Fail", domain=ControlDomainEnum.CRYPTO, control_type=ControlTypeEnum.PREVENTIVE, pass_criteria="Pass") + + # Use update_status method with control_id (not UUID) + repo.update_status("PASS-001", ControlStatusEnum.PASS) + repo.update_status("FAIL-001", ControlStatusEnum.FAIL) + + passing_controls = repo.get_all(status=ControlStatusEnum.PASS) + assert len(passing_controls) == 1 + assert passing_controls[0].control_id == "PASS-001" + + def test_get_controls_filter_by_automated(self, db_session): + """Test filtering controls by automation.""" + repo = ControlRepository(db_session) + + repo.create(control_id="AUTO-001", title="Automated", domain=ControlDomainEnum.CRYPTO, control_type=ControlTypeEnum.PREVENTIVE, pass_criteria="Pass", is_automated=True) + repo.create(control_id="MANUAL-001", title="Manual", domain=ControlDomainEnum.CRYPTO, control_type=ControlTypeEnum.PREVENTIVE, pass_criteria="Pass", is_automated=False) + + automated = repo.get_all(is_automated=True) + assert len(automated) == 1 + assert automated[0].control_id == "AUTO-001" + + def test_update_control(self, db_session, sample_control): + """Test updating a control status.""" + repo = ControlRepository(db_session) + + updated = repo.update_status( + sample_control.control_id, + ControlStatusEnum.PASS, + status_notes="Implemented via NGINX config", + ) + + assert updated is not None + assert updated.status == ControlStatusEnum.PASS + assert updated.status_notes == "Implemented via NGINX config" + + def test_delete_control(self, db_session, sample_control): + """Test deleting a control (via model).""" + repo = ControlRepository(db_session) + + # Delete via database directly (ControlRepository doesn't have delete method) + db_session.delete(sample_control) + db_session.commit() + + found = repo.get_by_id(sample_control.id) + assert found is None + + def test_get_statistics(self, db_session): + """Test getting control statistics.""" + repo = ControlRepository(db_session) + + # Create controls with different statuses + ctrl1 = repo.create(control_id="PASS-1", title="Pass 1", domain=ControlDomainEnum.CRYPTO, control_type=ControlTypeEnum.PREVENTIVE, pass_criteria="Pass") + ctrl2 = repo.create(control_id="PASS-2", title="Pass 2", domain=ControlDomainEnum.CRYPTO, control_type=ControlTypeEnum.PREVENTIVE, pass_criteria="Pass") + ctrl3 = repo.create(control_id="PARTIAL-1", title="Partial", domain=ControlDomainEnum.CRYPTO, control_type=ControlTypeEnum.PREVENTIVE, pass_criteria="Pass") + ctrl4 = repo.create(control_id="FAIL-1", title="Fail", domain=ControlDomainEnum.CRYPTO, control_type=ControlTypeEnum.PREVENTIVE, pass_criteria="Pass") + + repo.update_status("PASS-1", ControlStatusEnum.PASS) + repo.update_status("PASS-2", ControlStatusEnum.PASS) + repo.update_status("PARTIAL-1", ControlStatusEnum.PARTIAL) + repo.update_status("FAIL-1", ControlStatusEnum.FAIL) + + stats = repo.get_statistics() + + assert stats["total"] == 4 + # Check if keys exist, they might be None or status values + by_status = stats["by_status"] + assert by_status.get("pass", 0) == 2 + assert by_status.get("partial", 0) == 1 + assert by_status.get("fail", 0) == 1 + + # Score = (2 pass + 0.5 * 1 partial) / 4 = 62.5% + expected_score = ((2 + 0.5) / 4) * 100 + assert stats["compliance_score"] == round(expected_score, 1) + + +# ============================================================================ +# EvidenceRepository Tests +# ============================================================================ + +class TestEvidenceRepository: + """Tests for EvidenceRepository.create().""" + + def test_create_evidence(self, db_session, sample_control): + """Test creating evidence.""" + repo = EvidenceRepository(db_session) + + evidence = repo.create( + control_id=sample_control.control_id, + evidence_type="report", + title="SAST Report", + description="Semgrep scan results", + artifact_path="/path/to/report.json", + artifact_hash="abc123", + source="ci_pipeline", + ci_job_id="job-123", + ) + + assert evidence.id is not None + assert evidence.title == "SAST Report" + assert evidence.source == "ci_pipeline" + assert evidence.ci_job_id == "job-123" + + def test_create_evidence_control_not_found(self, db_session): + """Test creating evidence for non-existent control raises error.""" + repo = EvidenceRepository(db_session) + + with pytest.raises(ValueError) as excinfo: + repo.create( + control_id="NONEXISTENT-001", + evidence_type="report", + title="Test", + ) + assert "not found" in str(excinfo.value).lower() + + def test_get_evidence_by_id(self, db_session, sample_control): + """Test getting evidence by ID.""" + repo = EvidenceRepository(db_session) + + created = repo.create( + control_id=sample_control.control_id, + evidence_type="report", + title="Test Evidence", + ) + + found = repo.get_by_id(created.id) + assert found is not None + assert found.id == created.id + + def test_get_evidence_by_control(self, db_session, sample_control): + """Test getting evidence by control.""" + repo = EvidenceRepository(db_session) + + repo.create(control_id=sample_control.control_id, evidence_type="report", title="Evidence 1") + repo.create(control_id=sample_control.control_id, evidence_type="report", title="Evidence 2") + + evidence_list = repo.get_by_control(sample_control.control_id) + assert len(evidence_list) == 2 + + def test_get_evidence_filter_by_status(self, db_session, sample_control): + """Test filtering evidence by status.""" + repo = EvidenceRepository(db_session) + + valid = repo.create(control_id=sample_control.control_id, evidence_type="report", title="Valid") + expired = repo.create(control_id=sample_control.control_id, evidence_type="report", title="Expired") + + repo.update_status(valid.id, EvidenceStatusEnum.VALID) + repo.update_status(expired.id, EvidenceStatusEnum.EXPIRED) + + valid_evidence = repo.get_by_control(sample_control.control_id, status=EvidenceStatusEnum.VALID) + assert len(valid_evidence) == 1 + assert valid_evidence[0].title == "Valid" + + def test_create_evidence_with_ci_metadata(self, db_session, sample_control): + """Test creating evidence with CI/CD metadata.""" + repo = EvidenceRepository(db_session) + + evidence = repo.create( + control_id=sample_control.control_id, + evidence_type="sast_report", + title="Semgrep Scan", + description="Static analysis results", + source="ci_pipeline", + ci_job_id="github-actions-123", + artifact_hash="sha256:abc123", + mime_type="application/json", + ) + + assert evidence.source == "ci_pipeline" + assert evidence.ci_job_id == "github-actions-123" + assert evidence.mime_type == "application/json" + + +# ============================================================================ +# ControlMappingRepository Tests +# ============================================================================ + +class TestControlMappingRepository: + """Tests for requirement-control mappings.""" + + def test_create_mapping(self, db_session, sample_regulation, sample_control): + """Test creating a requirement-control mapping.""" + req_repo = RequirementRepository(db_session) + mapping_repo = ControlMappingRepository(db_session) + + requirement = req_repo.create( + regulation_id=sample_regulation.id, + article="Art. 32", + title="Security", + is_applicable=True, + ) + + mapping = mapping_repo.create( + requirement_id=requirement.id, + control_id=sample_control.control_id, + coverage_level="full", + notes="Fully covered by TLS encryption", + ) + + assert mapping.id is not None + assert mapping.requirement_id == requirement.id + assert mapping.coverage_level == "full" + + def test_create_mapping_control_not_found(self, db_session, sample_regulation): + """Test creating mapping with non-existent control raises error.""" + req_repo = RequirementRepository(db_session) + mapping_repo = ControlMappingRepository(db_session) + + requirement = req_repo.create( + regulation_id=sample_regulation.id, + article="Art. 32", + title="Security", + is_applicable=True, + ) + + with pytest.raises(ValueError) as excinfo: + mapping_repo.create( + requirement_id=requirement.id, + control_id="NONEXISTENT-001", + ) + assert "not found" in str(excinfo.value).lower() + + def test_get_mappings_by_requirement(self, db_session, sample_regulation, sample_control): + """Test getting mappings by requirement.""" + req_repo = RequirementRepository(db_session) + mapping_repo = ControlMappingRepository(db_session) + + requirement = req_repo.create( + regulation_id=sample_regulation.id, + article="Art. 32", + title="Security", + is_applicable=True, + ) + + mapping_repo.create(requirement_id=requirement.id, control_id=sample_control.control_id) + + mappings = mapping_repo.get_by_requirement(requirement.id) + assert len(mappings) == 1 + + def test_get_mappings_by_control(self, db_session, sample_regulation, sample_control): + """Test getting mappings by control.""" + req_repo = RequirementRepository(db_session) + mapping_repo = ControlMappingRepository(db_session) + + req1 = req_repo.create(regulation_id=sample_regulation.id, article="Art. 1", title="Req 1", is_applicable=True) + req2 = req_repo.create(regulation_id=sample_regulation.id, article="Art. 2", title="Req 2", is_applicable=True) + + mapping_repo.create(requirement_id=req1.id, control_id=sample_control.control_id) + mapping_repo.create(requirement_id=req2.id, control_id=sample_control.control_id) + + mappings = mapping_repo.get_by_control(sample_control.id) + assert len(mappings) == 2 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/backend/tests/test_consent_client.py b/backend/tests/test_consent_client.py new file mode 100644 index 0000000..c8c6af4 --- /dev/null +++ b/backend/tests/test_consent_client.py @@ -0,0 +1,407 @@ +""" +Tests für den Consent Client +""" + +import pytest +import jwt +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, patch, MagicMock +import sys +import os + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from consent_client import ( + generate_jwt_token, + generate_demo_token, + DocumentType, + ConsentStatus, + DocumentVersion, + ConsentClient, + JWT_SECRET, +) + + +class TestJWTTokenGeneration: + """Tests für JWT Token Generierung""" + + def test_generate_jwt_token_default(self): + """Test JWT generation with default values""" + token = generate_jwt_token() + + assert token is not None + assert isinstance(token, str) + assert len(token) > 0 + + # Decode and verify + decoded = jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + assert "user_id" in decoded + assert decoded["email"] == "demo@breakpilot.app" + assert decoded["role"] == "user" + assert "exp" in decoded + assert "iat" in decoded + + def test_generate_jwt_token_custom_values(self): + """Test JWT generation with custom values""" + user_id = "test-user-123" + email = "test@example.com" + role = "admin" + + token = generate_jwt_token( + user_id=user_id, + email=email, + role=role, + expires_hours=48 + ) + + decoded = jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + assert decoded["user_id"] == user_id + assert decoded["email"] == email + assert decoded["role"] == role + + def test_generate_jwt_token_expiration(self): + """Test that token expiration is set correctly""" + token = generate_jwt_token(expires_hours=1) + decoded = jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + + exp = datetime.utcfromtimestamp(decoded["exp"]) + now = datetime.utcnow() + + # Should expire in approximately 1 hour + time_diff = exp - now + assert time_diff.total_seconds() > 3500 # At least 58 minutes + assert time_diff.total_seconds() < 3700 # At most 62 minutes + + def test_generate_demo_token(self): + """Test demo token generation""" + token = generate_demo_token() + + decoded = jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + assert decoded["user_id"].startswith("demo-user-") + assert decoded["email"] == "demo@breakpilot.app" + assert decoded["role"] == "user" + + def test_tokens_are_unique(self): + """Test that generated tokens are unique""" + tokens = [generate_demo_token() for _ in range(10)] + assert len(set(tokens)) == 10 # All tokens should be unique + + def test_jwt_token_signature(self): + """Test that token signature is valid""" + token = generate_jwt_token() + + # Should not raise exception + jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + + # Should raise exception with wrong secret + with pytest.raises(jwt.InvalidSignatureError): + jwt.decode(token, "wrong-secret", algorithms=["HS256"]) + + +class TestDocumentType: + """Tests für DocumentType Enum""" + + def test_document_types(self): + """Test all document types exist""" + assert DocumentType.TERMS.value == "terms" + assert DocumentType.PRIVACY.value == "privacy" + assert DocumentType.COOKIES.value == "cookies" + assert DocumentType.COMMUNITY.value == "community" + + def test_document_type_is_string(self): + """Test that document types can be used as strings""" + assert str(DocumentType.TERMS) == "DocumentType.TERMS" + assert DocumentType.TERMS.value == "terms" + + +class TestConsentStatus: + """Tests für ConsentStatus Dataclass""" + + def test_consent_status_basic(self): + """Test basic ConsentStatus creation""" + status = ConsentStatus(has_consent=True) + + assert status.has_consent is True + assert status.current_version_id is None + assert status.consented_version is None + assert status.needs_update is False + assert status.consented_at is None + + def test_consent_status_full(self): + """Test ConsentStatus with all fields""" + status = ConsentStatus( + has_consent=True, + current_version_id="version-123", + consented_version="1.0.0", + needs_update=False, + consented_at="2024-01-01T00:00:00Z" + ) + + assert status.has_consent is True + assert status.current_version_id == "version-123" + assert status.consented_version == "1.0.0" + assert status.needs_update is False + assert status.consented_at == "2024-01-01T00:00:00Z" + + +class TestDocumentVersion: + """Tests für DocumentVersion Dataclass""" + + def test_document_version_creation(self): + """Test DocumentVersion creation""" + version = DocumentVersion( + id="doc-version-123", + document_id="doc-123", + version="1.0.0", + language="de", + title="Test Document", + content="

            Test content

            ", + summary="Test summary" + ) + + assert version.id == "doc-version-123" + assert version.document_id == "doc-123" + assert version.version == "1.0.0" + assert version.language == "de" + assert version.title == "Test Document" + assert version.content == "

            Test content

            " + assert version.summary == "Test summary" + + +class TestConsentClient: + """Tests für ConsentClient""" + + def test_client_initialization(self): + """Test client initialization""" + client = ConsentClient() + # In Docker: consent-service:8081, locally: localhost:8081 + assert client.base_url in ("http://localhost:8081", "http://consent-service:8081") + assert "/api/v1" in client.api_url + + def test_client_custom_url(self): + """Test client with custom URL""" + client = ConsentClient(base_url="https://custom.example.com/") + assert client.base_url == "https://custom.example.com" + assert client.api_url == "https://custom.example.com/api/v1" + + def test_get_headers(self): + """Test header generation""" + client = ConsentClient() + token = "test-token-123" + + headers = client._get_headers(token) + + assert headers["Authorization"] == "Bearer test-token-123" + assert headers["Content-Type"] == "application/json" + + +class TestConsentClientAsync: + """Async tests für ConsentClient""" + + @pytest.mark.asyncio + async def test_check_consent_success(self): + """Test successful consent check""" + client = ConsentClient() + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "has_consent": True, + "current_version_id": "version-123", + "consented_version": "1.0.0", + "needs_update": False, + "consented_at": "2024-01-01T00:00:00Z" + } + + with patch("httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.get.return_value = mock_response + mock_instance.__aenter__.return_value = mock_instance + mock_instance.__aexit__.return_value = None + mock_client.return_value = mock_instance + + status = await client.check_consent( + jwt_token="test-token", + document_type=DocumentType.TERMS + ) + + assert status.has_consent is True + assert status.current_version_id == "version-123" + + @pytest.mark.asyncio + async def test_check_consent_not_found(self): + """Test consent check when user has no consent""" + client = ConsentClient() + + mock_response = MagicMock() + mock_response.status_code = 404 + + with patch("httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.get.return_value = mock_response + mock_instance.__aenter__.return_value = mock_instance + mock_instance.__aexit__.return_value = None + mock_client.return_value = mock_instance + + status = await client.check_consent( + jwt_token="test-token", + document_type=DocumentType.TERMS + ) + + assert status.has_consent is False + assert status.needs_update is True + + @pytest.mark.asyncio + async def test_check_consent_connection_error(self): + """Test consent check when service is unavailable""" + import httpx + + client = ConsentClient() + + with patch("httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.get.side_effect = httpx.RequestError("Connection error") + mock_instance.__aenter__.return_value = mock_instance + mock_instance.__aexit__.return_value = None + mock_client.return_value = mock_instance + + status = await client.check_consent( + jwt_token="test-token", + document_type=DocumentType.TERMS + ) + + # Should not block user when service is unavailable + assert status.has_consent is True + assert status.needs_update is False + + @pytest.mark.asyncio + async def test_health_check_success(self): + """Test successful health check""" + client = ConsentClient() + + mock_response = MagicMock() + mock_response.status_code = 200 + + with patch("httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.get.return_value = mock_response + mock_instance.__aenter__.return_value = mock_instance + mock_instance.__aexit__.return_value = None + mock_client.return_value = mock_instance + + is_healthy = await client.health_check() + assert is_healthy is True + + @pytest.mark.asyncio + async def test_health_check_failure(self): + """Test failed health check""" + import httpx + + client = ConsentClient() + + with patch("httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.get.side_effect = httpx.RequestError("Connection refused") + mock_instance.__aenter__.return_value = mock_instance + mock_instance.__aexit__.return_value = None + mock_client.return_value = mock_instance + + is_healthy = await client.health_check() + assert is_healthy is False + + @pytest.mark.asyncio + async def test_give_consent_success(self): + """Test successful consent submission""" + client = ConsentClient() + + mock_response = MagicMock() + mock_response.status_code = 201 + + with patch("httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.post.return_value = mock_response + mock_instance.__aenter__.return_value = mock_instance + mock_instance.__aexit__.return_value = None + mock_client.return_value = mock_instance + + success = await client.give_consent( + jwt_token="test-token", + document_type="terms", + version_id="version-123", + consented=True + ) + + assert success is True + + @pytest.mark.asyncio + async def test_give_consent_failure(self): + """Test failed consent submission""" + client = ConsentClient() + + mock_response = MagicMock() + mock_response.status_code = 400 + + with patch("httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.post.return_value = mock_response + mock_instance.__aenter__.return_value = mock_instance + mock_instance.__aexit__.return_value = None + mock_client.return_value = mock_instance + + success = await client.give_consent( + jwt_token="test-token", + document_type="terms", + version_id="version-123", + consented=True + ) + + assert success is False + + +class TestValidation: + """Tests für Validierungslogik""" + + def test_valid_document_types(self): + """Test that only valid document types are accepted""" + valid_types = ["terms", "privacy", "cookies", "community"] + + for doc_type in DocumentType: + assert doc_type.value in valid_types + + def test_jwt_expiration_validation(self): + """Test that expired tokens are rejected""" + # Create token that expired 1 hour ago + expired_payload = { + "user_id": "test-user", + "email": "test@example.com", + "role": "user", + "exp": datetime.utcnow() - timedelta(hours=1), + "iat": datetime.utcnow() - timedelta(hours=2), + } + + expired_token = jwt.encode(expired_payload, JWT_SECRET, algorithm="HS256") + + with pytest.raises(jwt.ExpiredSignatureError): + jwt.decode(expired_token, JWT_SECRET, algorithms=["HS256"]) + + +# Performance Tests +class TestPerformance: + """Performance tests""" + + def test_token_generation_performance(self): + """Test that token generation is fast""" + import time + + start = time.time() + for _ in range(100): + generate_jwt_token() + elapsed = time.time() - start + + # Should generate 100 tokens in less than 1 second + assert elapsed < 1.0, f"Token generation too slow: {elapsed}s for 100 tokens" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/backend/tests/test_correction_api.py b/backend/tests/test_correction_api.py new file mode 100644 index 0000000..a7a0020 --- /dev/null +++ b/backend/tests/test_correction_api.py @@ -0,0 +1,543 @@ +""" +Tests für Correction API. + +Testet den Korrektur-Workflow: +- Upload +- OCR +- Analyse +- Export +""" + +import pytest +import io +from unittest.mock import patch, MagicMock +from fastapi.testclient import TestClient + +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from main import app + +client = TestClient(app) + + +class TestCorrectionCreate: + """Tests für Korrektur-Erstellung.""" + + def test_create_correction_success(self): + """Testet erfolgreiche Erstellung.""" + response = client.post( + "/api/corrections/", + json={ + "student_id": "student-001", + "student_name": "Max Mustermann", + "class_name": "7a", + "exam_title": "Mathematik Test 1", + "subject": "Mathematik", + "max_points": 50.0 + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["correction"]["student_name"] == "Max Mustermann" + assert data["correction"]["status"] == "uploaded" + assert data["correction"]["max_points"] == 50.0 + + def test_create_correction_default_points(self): + """Testet Erstellung mit Standard-Punktzahl.""" + response = client.post( + "/api/corrections/", + json={ + "student_id": "student-002", + "student_name": "Anna Schmidt", + "class_name": "7a", + "exam_title": "Deutsch Aufsatz", + "subject": "Deutsch" + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["correction"]["max_points"] == 100.0 + + +class TestCorrectionUpload: + """Tests für Datei-Upload.""" + + def _create_correction(self): + """Hilfsmethode zum Erstellen einer Korrektur.""" + response = client.post( + "/api/corrections/", + json={ + "student_id": "student-test", + "student_name": "Test Student", + "class_name": "Test", + "exam_title": "Test Exam", + "subject": "Test" + } + ) + return response.json()["correction"]["id"] + + def test_upload_pdf_success(self): + """Testet PDF-Upload.""" + correction_id = self._create_correction() + + # Erstelle Mock-PDF + pdf_content = b"%PDF-1.4 test content" + files = {"file": ("test.pdf", io.BytesIO(pdf_content), "application/pdf")} + + with patch("correction_api._process_ocr"): + response = client.post( + f"/api/corrections/{correction_id}/upload", + files=files + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["correction"]["file_path"] is not None + + def test_upload_image_success(self): + """Testet Bild-Upload.""" + correction_id = self._create_correction() + + # Erstelle Mock-PNG + png_content = b"\x89PNG\r\n\x1a\n test content" + files = {"file": ("test.png", io.BytesIO(png_content), "image/png")} + + with patch("correction_api._process_ocr"): + response = client.post( + f"/api/corrections/{correction_id}/upload", + files=files + ) + + assert response.status_code == 200 + + def test_upload_invalid_format(self): + """Testet Ablehnung ungültiger Formate.""" + correction_id = self._create_correction() + + files = {"file": ("test.txt", io.BytesIO(b"text"), "text/plain")} + + response = client.post( + f"/api/corrections/{correction_id}/upload", + files=files + ) + + assert response.status_code == 400 + assert "Ungültiges Dateiformat" in response.json()["detail"] + + def test_upload_not_found(self): + """Testet Upload für nicht existierende Korrektur.""" + files = {"file": ("test.pdf", io.BytesIO(b"content"), "application/pdf")} + + response = client.post( + "/api/corrections/nonexistent/upload", + files=files + ) + + assert response.status_code == 404 + + +class TestCorrectionRetrieval: + """Tests für Korrektur-Abruf.""" + + def test_get_correction(self): + """Testet Abrufen einer Korrektur.""" + # Erstelle Korrektur + create_response = client.post( + "/api/corrections/", + json={ + "student_id": "get-test", + "student_name": "Get Test", + "class_name": "Test", + "exam_title": "Test", + "subject": "Test" + } + ) + correction_id = create_response.json()["correction"]["id"] + + # Rufe ab + response = client.get(f"/api/corrections/{correction_id}") + + assert response.status_code == 200 + data = response.json() + assert data["correction"]["id"] == correction_id + + def test_get_correction_not_found(self): + """Testet Fehler bei nicht vorhandener Korrektur.""" + response = client.get("/api/corrections/nonexistent") + assert response.status_code == 404 + + def test_list_corrections(self): + """Testet Auflisten von Korrekturen.""" + # Erstelle einige Korrekturen + for i in range(3): + client.post( + "/api/corrections/", + json={ + "student_id": f"list-{i}", + "student_name": f"Student {i}", + "class_name": "ListTest", + "exam_title": "Test", + "subject": "Test" + } + ) + + response = client.get("/api/corrections/?class_name=ListTest") + + assert response.status_code == 200 + data = response.json() + assert data["total"] >= 3 + + def test_list_corrections_filter_status(self): + """Testet Filterung nach Status.""" + response = client.get("/api/corrections/?status=completed") + + assert response.status_code == 200 + data = response.json() + # Alle zurückgegebenen Korrekturen sollten "completed" Status haben + for c in data["corrections"]: + if c.get("status"): # Falls vorhanden + assert c["status"] == "completed" + + +class TestCorrectionAnalysis: + """Tests für Korrektur-Analyse.""" + + def _create_correction_with_text(self): + """Erstellt Korrektur mit OCR-Text.""" + response = client.post( + "/api/corrections/", + json={ + "student_id": "analyze-test", + "student_name": "Analyze Test", + "class_name": "Test", + "exam_title": "Test", + "subject": "Mathematik", + "max_points": 100.0 + } + ) + correction_id = response.json()["correction"]["id"] + + # Simuliere OCR-Ergebnis durch direktes Setzen + from correction_api import _corrections, CorrectionStatus + correction = _corrections[correction_id] + correction.extracted_text = """ + Aufgabe 1: Die Antwort ist 42. + + Aufgabe 2: Hier ist meine Lösung für die Gleichung. + + Aufgabe 3: Das Ergebnis beträgt 15. + """ + correction.status = CorrectionStatus.OCR_COMPLETE + _corrections[correction_id] = correction + + return correction_id + + def test_analyze_correction(self): + """Testet Analyse einer Korrektur.""" + correction_id = self._create_correction_with_text() + + response = client.post( + f"/api/corrections/{correction_id}/analyze" + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert len(data["evaluations"]) > 0 + assert "suggested_grade" in data + assert "ai_feedback" in data + + def test_analyze_with_expected_answers(self): + """Testet Analyse mit Musterlösung.""" + correction_id = self._create_correction_with_text() + + expected = { + "1": "42", + "2": "Gleichung", + "3": "15" + } + + response = client.post( + f"/api/corrections/{correction_id}/analyze", + json=expected + ) + + assert response.status_code == 200 + data = response.json() + # Mit passender Musterlösung sollten einige Antworten korrekt sein + correct_count = sum(1 for e in data["evaluations"] if e["is_correct"]) + assert correct_count > 0 + + def test_analyze_wrong_status(self): + """Testet Analyse bei falschem Status.""" + # Neue Korrektur ohne OCR + response = client.post( + "/api/corrections/", + json={ + "student_id": "wrong-status", + "student_name": "Wrong Status", + "class_name": "Test", + "exam_title": "Test", + "subject": "Test" + } + ) + correction_id = response.json()["correction"]["id"] + + # Analyse ohne vorherige OCR + response = client.post(f"/api/corrections/{correction_id}/analyze") + + assert response.status_code == 400 + + +class TestCorrectionUpdate: + """Tests für Korrektur-Aktualisierung.""" + + def test_update_correction(self): + """Testet Aktualisierung einer Korrektur.""" + # Erstelle Korrektur + response = client.post( + "/api/corrections/", + json={ + "student_id": "update-test", + "student_name": "Update Test", + "class_name": "Test", + "exam_title": "Test", + "subject": "Test" + } + ) + correction_id = response.json()["correction"]["id"] + + # Aktualisiere + response = client.put( + f"/api/corrections/{correction_id}", + json={ + "grade": "2", + "total_points": 85.0, + "teacher_notes": "Gute Arbeit!" + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["correction"]["grade"] == "2" + assert data["correction"]["total_points"] == 85.0 + assert data["correction"]["teacher_notes"] == "Gute Arbeit!" + + def test_complete_correction(self): + """Testet Abschluss einer Korrektur.""" + # Erstelle Korrektur + response = client.post( + "/api/corrections/", + json={ + "student_id": "complete-test", + "student_name": "Complete Test", + "class_name": "Test", + "exam_title": "Test", + "subject": "Test" + } + ) + correction_id = response.json()["correction"]["id"] + + # Schließe ab + response = client.post(f"/api/corrections/{correction_id}/complete") + + assert response.status_code == 200 + data = response.json() + assert data["correction"]["status"] == "completed" + + +class TestCorrectionDelete: + """Tests für Korrektur-Löschung.""" + + def test_delete_correction(self): + """Testet Löschen einer Korrektur.""" + # Erstelle Korrektur + response = client.post( + "/api/corrections/", + json={ + "student_id": "delete-test", + "student_name": "Delete Test", + "class_name": "Test", + "exam_title": "Test", + "subject": "Test" + } + ) + correction_id = response.json()["correction"]["id"] + + # Lösche + response = client.delete(f"/api/corrections/{correction_id}") + + assert response.status_code == 200 + assert response.json()["status"] == "deleted" + + # Prüfe dass gelöscht + response = client.get(f"/api/corrections/{correction_id}") + assert response.status_code == 404 + + def test_delete_not_found(self): + """Testet Fehler beim Löschen nicht existierender Korrektur.""" + response = client.delete("/api/corrections/nonexistent") + assert response.status_code == 404 + + +class TestClassSummary: + """Tests für Klassen-Zusammenfassung.""" + + def _create_completed_corrections(self, class_name: str, count: int): + """Erstellt abgeschlossene Korrekturen für eine Klasse.""" + from correction_api import _corrections, CorrectionStatus + import uuid + from datetime import datetime + + for i in range(count): + response = client.post( + "/api/corrections/", + json={ + "student_id": f"summary-{i}", + "student_name": f"Student {i}", + "class_name": class_name, + "exam_title": "Summary Test", + "subject": "Test", + "max_points": 100.0 + } + ) + correction_id = response.json()["correction"]["id"] + + # Setze als completed mit Punkten + correction = _corrections[correction_id] + correction.status = CorrectionStatus.COMPLETED + correction.total_points = 70 + i * 5 # 70, 75, 80, ... + correction.percentage = correction.total_points + correction.grade = str(3 - i // 2) # Verschiedene Noten + _corrections[correction_id] = correction + + def test_class_summary(self): + """Testet Klassen-Zusammenfassung.""" + class_name = "SummaryTestClass" + self._create_completed_corrections(class_name, 3) + + response = client.get(f"/api/corrections/class/{class_name}/summary") + + assert response.status_code == 200 + data = response.json() + assert data["class_name"] == class_name + assert data["total_students"] == 3 + assert "average_percentage" in data + assert "grade_distribution" in data + assert len(data["corrections"]) == 3 + + def test_class_summary_empty(self): + """Testet Zusammenfassung für leere Klasse.""" + response = client.get("/api/corrections/class/EmptyClass/summary") + + assert response.status_code == 200 + data = response.json() + assert data["total_students"] == 0 + assert data["average_percentage"] == 0 + + +class TestGradeCalculation: + """Tests für Notenberechnung.""" + + def test_grade_1(self): + """Testet Note 1 (>=92%).""" + from correction_api import _calculate_grade + assert _calculate_grade(92) == "1" + assert _calculate_grade(100) == "1" + + def test_grade_2(self): + """Testet Note 2 (81-91%).""" + from correction_api import _calculate_grade + assert _calculate_grade(81) == "2" + assert _calculate_grade(91) == "2" + + def test_grade_3(self): + """Testet Note 3 (67-80%).""" + from correction_api import _calculate_grade + assert _calculate_grade(67) == "3" + assert _calculate_grade(80) == "3" + + def test_grade_4(self): + """Testet Note 4 (50-66%).""" + from correction_api import _calculate_grade + assert _calculate_grade(50) == "4" + assert _calculate_grade(66) == "4" + + def test_grade_5(self): + """Testet Note 5 (30-49%).""" + from correction_api import _calculate_grade + assert _calculate_grade(30) == "5" + assert _calculate_grade(49) == "5" + + def test_grade_6(self): + """Testet Note 6 (<30%).""" + from correction_api import _calculate_grade + assert _calculate_grade(29) == "6" + assert _calculate_grade(0) == "6" + + +class TestOCRRetry: + """Tests für OCR-Wiederholung.""" + + def test_retry_ocr(self): + """Testet OCR-Wiederholung.""" + # Erstelle Korrektur mit Datei + response = client.post( + "/api/corrections/", + json={ + "student_id": "retry-test", + "student_name": "Retry Test", + "class_name": "Test", + "exam_title": "Test", + "subject": "Test" + } + ) + correction_id = response.json()["correction"]["id"] + + # Setze file_path manuell (simuliert Upload) + from correction_api import _corrections + import tempfile + import os + + # Erstelle temp file + fd, path = tempfile.mkstemp(suffix=".pdf") + os.write(fd, b"%PDF-1.4 test") + os.close(fd) + + correction = _corrections[correction_id] + correction.file_path = path + _corrections[correction_id] = correction + + # Retry OCR + with patch("correction_api._process_ocr"): + response = client.post(f"/api/corrections/{correction_id}/ocr/retry") + + assert response.status_code == 200 + + # Cleanup + os.remove(path) + + def test_retry_ocr_no_file(self): + """Testet Fehler bei OCR-Retry ohne Datei.""" + response = client.post( + "/api/corrections/", + json={ + "student_id": "retry-no-file", + "student_name": "No File", + "class_name": "Test", + "exam_title": "Test", + "subject": "Test" + } + ) + correction_id = response.json()["correction"]["id"] + + response = client.post(f"/api/corrections/{correction_id}/ocr/retry") + + assert response.status_code == 400 + assert "Keine Datei" in response.json()["detail"] diff --git a/backend/tests/test_customer_frontend.py b/backend/tests/test_customer_frontend.py new file mode 100644 index 0000000..8d08f75 --- /dev/null +++ b/backend/tests/test_customer_frontend.py @@ -0,0 +1,213 @@ +""" +Tests fuer das BreakPilot Customer Portal (customer.py) + +Testet die neuen schlanken Kundenportal-Routen und -Dateien. + +Erstellt: 2024-12-16 +""" +import pytest +import sys +import os +from pathlib import Path + +# Add parent directory to path for imports +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Pfade zu den Customer Portal Dateien +FRONTEND_DIR = Path(__file__).parent.parent / "frontend" +CUSTOMER_CSS = FRONTEND_DIR / "static" / "css" / "customer.css" +CUSTOMER_JS = FRONTEND_DIR / "static" / "js" / "customer.js" +CUSTOMER_HTML = FRONTEND_DIR / "templates" / "customer.html" + + +class TestCustomerPortalFiles: + """Tests fuer die Customer Portal Dateistruktur.""" + + def test_customer_html_exists(self): + """Testet, dass das Customer HTML-Template existiert.""" + assert CUSTOMER_HTML.exists(), f"Customer HTML nicht gefunden: {CUSTOMER_HTML}" + + def test_customer_css_exists(self): + """Testet, dass die Customer CSS-Datei existiert.""" + assert CUSTOMER_CSS.exists(), f"Customer CSS nicht gefunden: {CUSTOMER_CSS}" + + def test_customer_js_exists(self): + """Testet, dass die Customer JS-Datei existiert.""" + assert CUSTOMER_JS.exists(), f"Customer JS nicht gefunden: {CUSTOMER_JS}" + + def test_customer_py_exists(self): + """Testet, dass das Customer Python-Modul existiert.""" + customer_py = FRONTEND_DIR / "customer.py" + assert customer_py.exists(), f"Customer Python nicht gefunden: {customer_py}" + + +class TestCustomerHTMLStructure: + """Tests fuer die Customer Portal HTML-Struktur.""" + + @pytest.fixture + def customer_html(self): + """Laedt den HTML-Inhalt aus dem Template.""" + return CUSTOMER_HTML.read_text(encoding="utf-8") + + def test_html_has_doctype(self, customer_html): + """Testet, dass das HTML-Dokument einen DOCTYPE hat.""" + assert customer_html.strip().startswith(""), \ + "Customer HTML muss mit DOCTYPE beginnen" + + def test_html_has_german_language(self, customer_html): + """Testet, dass das HTML-Dokument auf Deutsch eingestellt ist.""" + assert 'lang="de"' in customer_html, \ + "Customer HTML sollte lang='de' haben" + + def test_html_references_css(self, customer_html): + """Testet, dass das HTML die Customer CSS-Datei referenziert.""" + assert '/static/css/customer.css' in customer_html, \ + "Customer HTML muss CSS-Datei referenzieren" + + def test_html_references_js(self, customer_html): + """Testet, dass das HTML die Customer JS-Datei referenziert.""" + assert '/static/js/customer.js' in customer_html, \ + "Customer HTML muss JS-Datei referenzieren" + + def test_html_has_login_modal(self, customer_html): + """Testet, dass das Login-Modal existiert.""" + assert 'login-modal' in customer_html, \ + "Customer HTML muss Login-Modal enthalten" + + def test_html_has_consents_modal(self, customer_html): + """Testet, dass das Consents-Modal existiert.""" + assert 'consents-modal' in customer_html, \ + "Customer HTML muss Consents-Modal enthalten" + + def test_html_has_export_modal(self, customer_html): + """Testet, dass das Export-Modal (GDPR) existiert.""" + assert 'export-modal' in customer_html, \ + "Customer HTML muss Export-Modal fuer GDPR enthalten" + + def test_html_has_legal_modal(self, customer_html): + """Testet, dass das Legal Documents Modal existiert.""" + assert 'legal-modal' in customer_html, \ + "Customer HTML muss Legal-Modal enthalten" + + def test_html_has_theme_toggle(self, customer_html): + """Testet, dass ein Theme-Toggle existiert.""" + assert 'theme' in customer_html.lower(), \ + "Customer HTML sollte Theme-Funktionalitaet haben" + + +class TestCustomerCSSTheme: + """Tests fuer die Customer Portal CSS-Themes.""" + + @pytest.fixture + def customer_css(self): + """Laedt den CSS-Inhalt.""" + return CUSTOMER_CSS.read_text(encoding="utf-8") + + def test_css_has_root_variables(self, customer_css): + """Testet, dass CSS Custom Properties (Variablen) definiert sind.""" + assert ':root' in customer_css, \ + "Customer CSS sollte :root CSS-Variablen haben" + + def test_css_has_dark_theme(self, customer_css): + """Testet, dass ein Dark-Theme definiert ist.""" + assert 'data-theme="dark"' in customer_css or '[data-theme="dark"]' in customer_css, \ + "Customer CSS sollte Dark-Theme unterstuetzen" + + def test_css_has_primary_color(self, customer_css): + """Testet, dass eine Primary-Color Variable existiert.""" + assert '--bp-primary' in customer_css or 'primary' in customer_css.lower(), \ + "Customer CSS sollte Primary-Color definieren" + + +class TestCustomerJSFunctions: + """Tests fuer die Customer Portal JavaScript-Funktionen.""" + + @pytest.fixture + def customer_js(self): + """Laedt den JS-Inhalt.""" + return CUSTOMER_JS.read_text(encoding="utf-8") + + def test_js_has_consent_service_url(self, customer_js): + """Testet, dass die Consent Service URL definiert ist.""" + assert 'CONSENT_SERVICE_URL' in customer_js, \ + "Customer JS muss CONSENT_SERVICE_URL definieren" + + def test_js_has_login_function(self, customer_js): + """Testet, dass eine Login-Funktion existiert.""" + assert 'handleLogin' in customer_js or 'login' in customer_js.lower(), \ + "Customer JS muss Login-Funktion haben" + + def test_js_has_auth_check(self, customer_js): + """Testet, dass ein Auth-Check existiert.""" + assert 'checkAuth' in customer_js or 'auth' in customer_js.lower(), \ + "Customer JS muss Auth-Check haben" + + def test_js_has_theme_toggle(self, customer_js): + """Testet, dass Theme-Toggle Funktion existiert.""" + assert 'theme' in customer_js.lower(), \ + "Customer JS sollte Theme-Funktionalitaet haben" + + def test_js_has_consent_functions(self, customer_js): + """Testet, dass Consent-Funktionen existieren.""" + assert 'consent' in customer_js.lower(), \ + "Customer JS muss Consent-Funktionen haben" + + def test_js_has_export_function(self, customer_js): + """Testet, dass eine Export-Funktion (GDPR) existiert.""" + assert 'export' in customer_js.lower(), \ + "Customer JS sollte Export-Funktion haben" + + +class TestCustomerRouter: + """Tests fuer die Customer Portal Router-Registrierung.""" + + def test_customer_router_in_main_py(self): + """Testet, dass customer_router in main.py importiert wird.""" + main_py = Path(__file__).parent.parent / "main.py" + main_content = main_py.read_text(encoding="utf-8") + + assert 'from frontend.customer import router as customer_router' in main_content, \ + "main.py muss customer_router importieren" + + def test_customer_router_included(self): + """Testet, dass customer_router in main.py eingebunden wird.""" + main_py = Path(__file__).parent.parent / "main.py" + main_content = main_py.read_text(encoding="utf-8") + + assert 'app.include_router(customer_router)' in main_content, \ + "main.py muss customer_router einbinden" + + def test_customer_routes_defined(self): + """Testet, dass die Customer-Routen korrekt definiert sind.""" + from frontend.customer import router as customer_router + + routes = [r.path for r in customer_router.routes] + assert '/customer' in routes, "Route /customer muss definiert sein" + assert '/account' in routes, "Route /account muss definiert sein" + assert '/mein-konto' in routes, "Route /mein-konto muss definiert sein" + + +class TestCustomerPortalResponsiveness: + """Tests fuer die Customer Portal Responsiveness.""" + + @pytest.fixture + def customer_css(self): + """Laedt den CSS-Inhalt.""" + return CUSTOMER_CSS.read_text(encoding="utf-8") + + def test_css_has_media_queries(self, customer_css): + """Testet, dass Media Queries fuer Responsiveness vorhanden sind.""" + assert '@media' in customer_css, \ + "Customer CSS sollte Media Queries haben" + + def test_css_has_mobile_breakpoint(self, customer_css): + """Testet, dass ein Mobile-Breakpoint definiert ist.""" + # Typische Mobile-Breakpoints: 768px, 640px, 480px + mobile_breakpoints = ['768px', '640px', '480px', '767px'] + has_mobile = any(bp in customer_css for bp in mobile_breakpoints) + assert has_mobile, \ + "Customer CSS sollte Mobile-Breakpoint haben" + + +if __name__ == '__main__': + pytest.main([__file__, '-v']) diff --git a/backend/tests/test_design_system.py b/backend/tests/test_design_system.py new file mode 100644 index 0000000..e01843e --- /dev/null +++ b/backend/tests/test_design_system.py @@ -0,0 +1,253 @@ +""" +Tests fuer das Design-System (Light/Dark Mode, CSS-Variablen). + +Testet: +- CSS-Variablen-Definitionen +- Theme-Switching (Light/Dark Mode) +- Footer-Struktur mit Legal-Links +- Design-Konsistenz +""" + +import pytest +import re +from pathlib import Path + + +class TestCSSVariables: + """Tests fuer CSS-Variablen im Design-System.""" + + @pytest.fixture + def studio_css(self): + """Laedt CSS-Variablen fuer Tests. + + Nach dem Refactoring sind die Variablen in modules/base/variables.css. + Fallback auf studio.css fuer Abwaertskompatibilitaet. + """ + # Primaer: Modularisierte Variablen-Datei + variables_path = Path(__file__).parent.parent / "frontend" / "static" / "css" / "modules" / "base" / "variables.css" + if variables_path.exists(): + return variables_path.read_text() + + # Fallback: Legacy studio.css + css_path = Path(__file__).parent.parent / "frontend" / "static" / "css" / "studio.css" + if css_path.exists(): + return css_path.read_text() + return None + + @pytest.fixture + def base_py(self): + """Laedt base.py fuer Tests.""" + base_path = Path(__file__).parent.parent / "frontend" / "components" / "base.py" + if base_path.exists(): + return base_path.read_text() + return None + + def test_dark_mode_primary_color_is_teal(self, studio_css): + """Test: Dark Mode Primary ist Teal (#0f766e).""" + if studio_css is None: + pytest.skip("studio.css nicht gefunden") + + # Suche nach --bp-primary in :root (Dark Mode) + root_match = re.search(r':root\s*\{([^}]+)\}', studio_css, re.DOTALL) + assert root_match is not None, ":root Block nicht gefunden" + + root_content = root_match.group(1) + assert "--bp-primary: #0f766e" in root_content, "Dark Mode Primary sollte Teal (#0f766e) sein" + + def test_dark_mode_accent_color_is_lime_green(self, studio_css): + """Test: Dark Mode Accent ist Lime Green (#22c55e).""" + if studio_css is None: + pytest.skip("studio.css nicht gefunden") + + root_match = re.search(r':root\s*\{([^}]+)\}', studio_css, re.DOTALL) + assert root_match is not None, ":root Block nicht gefunden" + + root_content = root_match.group(1) + assert "--bp-accent: #22c55e" in root_content, "Dark Mode Accent sollte Lime Green (#22c55e) sein" + + def test_light_mode_primary_color_is_sky_blue(self, studio_css): + """Test: Light Mode Primary ist Sky Blue (#0ea5e9).""" + if studio_css is None: + pytest.skip("studio.css nicht gefunden") + + # Suche nach [data-theme="light"] Block + light_match = re.search(r'\[data-theme="light"\]\s*\{([^}]+)\}', studio_css, re.DOTALL) + assert light_match is not None, "[data-theme='light'] Block nicht gefunden" + + light_content = light_match.group(1) + assert "--bp-primary: #0ea5e9" in light_content, "Light Mode Primary sollte Sky Blue (#0ea5e9) sein" + + def test_light_mode_accent_color_is_fuchsia(self, studio_css): + """Test: Light Mode Accent ist Fuchsia (#d946ef).""" + if studio_css is None: + pytest.skip("studio.css nicht gefunden") + + light_match = re.search(r'\[data-theme="light"\]\s*\{([^}]+)\}', studio_css, re.DOTALL) + assert light_match is not None, "[data-theme='light'] Block nicht gefunden" + + light_content = light_match.group(1) + assert "--bp-accent: #d946ef" in light_content, "Light Mode Accent sollte Fuchsia (#d946ef) sein" + + def test_no_hardcoded_material_design_grays(self): + """Test: Keine hardcodierten Material Design Grays (#E0E0E0, #F5F5F5, #F8F8F8).""" + # Pruefe alle CSS-Dateien im modules-Verzeichnis + css_base = Path(__file__).parent.parent / "frontend" / "static" / "css" + modules_dir = css_base / "modules" + + if not modules_dir.exists(): + # Fallback: Pruefe nur studio.css + css_path = css_base / "studio.css" + if not css_path.exists(): + pytest.skip("Keine CSS-Dateien gefunden") + css_files = [css_path] + else: + css_files = list(modules_dir.rglob("*.css")) + + # Diese sollten durch CSS-Variablen ersetzt sein + # Ausnahme: In Kommentaren oder Variable-Definitionen + problem_colors = ['#E0E0E0', '#F5F5F5', '#F8F8F8'] + + for css_file in css_files: + lines = css_file.read_text().split('\n') + for line_num, line in enumerate(lines, 1): + # Ueberspringe Kommentare + if line.strip().startswith('/*') or line.strip().startswith('*') or line.strip().startswith('//'): + continue + # Ueberspringe Variable-Definitionen im Light-Mode Block + if '--bp-' in line: + continue + + for color in problem_colors: + if color in line.upper(): + pytest.fail(f"Hardcodierte Farbe {color} gefunden in {css_file.name}:{line_num}: {line.strip()}") + + def test_light_mode_uses_slate_colors(self, studio_css): + """Test: Light Mode verwendet Slate-Farbpalette.""" + if studio_css is None: + pytest.skip("studio.css nicht gefunden") + + light_match = re.search(r'\[data-theme="light"\]\s*\{([^}]+)\}', studio_css, re.DOTALL) + assert light_match is not None, "[data-theme='light'] Block nicht gefunden" + + light_content = light_match.group(1) + # Slate-50 fuer Background + assert "#f8fafc" in light_content.lower(), "Light Mode sollte Slate-50 (#f8fafc) verwenden" + # Slate-200 fuer Border + assert "#e2e8f0" in light_content.lower(), "Light Mode sollte Slate-200 (#e2e8f0) fuer Border verwenden" + + def test_base_py_has_website_design_light_mode(self, base_py): + """Test: base.py verwendet Website Design fuer Light Mode.""" + if base_py is None: + pytest.skip("base.py nicht gefunden") + + # Pruefe auf Website Design Kommentar + assert "Website Design" in base_py, "base.py sollte Website Design verwenden" + assert "Sky Blue" in base_py or "#0ea5e9" in base_py, "base.py sollte Sky Blue fuer Light Mode verwenden" + assert "Fuchsia" in base_py or "#d946ef" in base_py, "base.py sollte Fuchsia fuer Light Mode verwenden" + + +class TestFooterStructure: + """Tests fuer Footer-Struktur mit Legal-Links.""" + + @pytest.fixture + def studio_html(self): + """Laedt studio.html fuer Tests.""" + html_path = Path(__file__).parent.parent / "frontend" / "templates" / "studio.html" + if html_path.exists(): + return html_path.read_text() + return None + + @pytest.fixture + def base_py(self): + """Laedt base.py fuer Tests.""" + base_path = Path(__file__).parent.parent / "frontend" / "components" / "base.py" + if base_path.exists(): + return base_path.read_text() + return None + + def test_footer_has_impressum_link(self, studio_html): + """Test: Footer enthaelt Impressum-Link.""" + if studio_html is None: + pytest.skip("studio.html nicht gefunden") + + assert "Impressum" in studio_html, "Footer sollte Impressum-Link enthalten" + + def test_footer_has_agb_link(self, studio_html): + """Test: Footer enthaelt AGB-Link.""" + if studio_html is None: + pytest.skip("studio.html nicht gefunden") + + assert "AGB" in studio_html, "Footer sollte AGB-Link enthalten" + + def test_footer_has_datenschutz_link(self, studio_html): + """Test: Footer enthaelt Datenschutz-Link.""" + if studio_html is None: + pytest.skip("studio.html nicht gefunden") + + assert "Datenschutz" in studio_html, "Footer sollte Datenschutz-Link enthalten" + + def test_footer_has_cookies_link(self, studio_html): + """Test: Footer enthaelt Cookies-Link.""" + if studio_html is None: + pytest.skip("studio.html nicht gefunden") + + assert "Cookies" in studio_html, "Footer sollte Cookies-Link enthalten" + + def test_footer_has_deine_rechte_link(self, studio_html): + """Test: Footer enthaelt Deine Rechte (GDPR)-Link.""" + if studio_html is None: + pytest.skip("studio.html nicht gefunden") + + assert "Deine Rechte" in studio_html, "Footer sollte 'Deine Rechte' (GDPR)-Link enthalten" + + def test_footer_has_einstellungen_link(self, studio_html): + """Test: Footer enthaelt Einstellungen-Link.""" + if studio_html is None: + pytest.skip("studio.html nicht gefunden") + + assert "Einstellungen" in studio_html, "Footer sollte Einstellungen-Link enthalten" + + def test_base_py_footer_has_all_links(self, base_py): + """Test: base.py Footer enthaelt alle erforderlichen Links.""" + if base_py is None: + pytest.skip("base.py nicht gefunden") + + required_links = ["Impressum", "AGB", "Datenschutz", "Cookies", "Deine Rechte", "Einstellungen"] + for link in required_links: + assert link in base_py, f"base.py Footer sollte '{link}'-Link enthalten" + + +class TestThemeSwitching: + """Tests fuer Theme-Switching Funktionalitaet.""" + + @pytest.fixture + def studio_js(self): + """Laedt studio.js fuer Tests.""" + js_path = Path(__file__).parent.parent / "frontend" / "static" / "js" / "studio.js" + if js_path.exists(): + return js_path.read_text() + return None + + def test_theme_toggle_function_exists(self, studio_js): + """Test: Theme-Toggle Funktion existiert.""" + if studio_js is None: + pytest.skip("studio.js nicht gefunden") + + assert "initThemeToggle" in studio_js or "theme-toggle" in studio_js, \ + "Theme-Toggle Funktionalitaet sollte existieren" + + def test_theme_saved_to_localstorage(self, studio_js): + """Test: Theme wird in localStorage gespeichert.""" + if studio_js is None: + pytest.skip("studio.js nicht gefunden") + + assert "localStorage" in studio_js, "Theme sollte in localStorage gespeichert werden" + assert "bp-theme" in studio_js or "bp_theme" in studio_js, \ + "Theme-Key sollte 'bp-theme' oder 'bp_theme' sein" + + def test_data_theme_attribute_used(self, studio_js): + """Test: data-theme Attribut wird verwendet.""" + if studio_js is None: + pytest.skip("studio.js nicht gefunden") + + assert "data-theme" in studio_js, "data-theme Attribut sollte fuer Theme-Switching verwendet werden" diff --git a/backend/tests/test_dsms_webui.py b/backend/tests/test_dsms_webui.py new file mode 100644 index 0000000..ec96919 --- /dev/null +++ b/backend/tests/test_dsms_webui.py @@ -0,0 +1,376 @@ +""" +Unit Tests für DSMS WebUI Funktionalität +Tests für die WebUI-bezogenen Endpoints und Datenstrukturen +""" + +import pytest +import json +import hashlib +from unittest.mock import AsyncMock, patch, MagicMock +from fastapi.testclient import TestClient + + +# ==================== DSMS WebUI API Response Tests ==================== + +class TestDsmsWebUINodeInfo: + """Tests für die Node-Info API die vom WebUI verwendet wird""" + + def test_node_info_response_structure(self): + """Test: Node-Info Response hat alle WebUI-relevanten Felder""" + # Diese Struktur wird vom WebUI erwartet + expected_fields = [ + "node_id", + "protocol_version", + "agent_version", + "repo_size", + "storage_max", + "num_objects", + "addresses" + ] + + # Mock response wie sie vom DSMS Gateway kommt + mock_response = { + "node_id": "QmTestNodeId123", + "protocol_version": "ipfs/0.1.0", + "agent_version": "kubo/0.24.0", + "repo_size": 1048576, + "storage_max": 10737418240, + "num_objects": 42, + "addresses": ["/ip4/127.0.0.1/tcp/4001"] + } + + for field in expected_fields: + assert field in mock_response, f"Feld {field} fehlt in der Response" + + def test_node_info_formats_repo_size(self): + """Test: Repo-Größe wird korrekt formatiert""" + def format_bytes(size_bytes): + """Formatiert Bytes in lesbare Einheiten (wie im WebUI)""" + if size_bytes is None: + return "N/A" + for unit in ['B', 'KB', 'MB', 'GB']: + if size_bytes < 1024: + return f"{size_bytes:.1f} {unit}" + size_bytes /= 1024 + return f"{size_bytes:.1f} TB" + + assert format_bytes(1024) == "1.0 KB" + assert format_bytes(1048576) == "1.0 MB" + assert format_bytes(1073741824) == "1.0 GB" + assert format_bytes(None) == "N/A" + + +class TestDsmsWebUIDocumentList: + """Tests für die Dokumentenlisten-API die vom WebUI verwendet wird""" + + def test_document_list_response_structure(self): + """Test: Document List Response hat alle WebUI-relevanten Felder""" + mock_response = { + "documents": [ + { + "cid": "QmTestCid123", + "metadata": { + "document_type": "legal_document", + "document_id": "privacy-policy", + "version": "1.0", + "created_at": "2024-01-01T00:00:00" + }, + "filename": "datenschutz.html" + } + ], + "total": 1 + } + + assert "documents" in mock_response + assert "total" in mock_response + assert mock_response["total"] == len(mock_response["documents"]) + + doc = mock_response["documents"][0] + assert "cid" in doc + assert "metadata" in doc + + def test_document_list_empty(self): + """Test: Leere Dokumentenliste wird korrekt behandelt""" + mock_response = { + "documents": [], + "total": 0 + } + + assert mock_response["total"] == 0 + assert len(mock_response["documents"]) == 0 + + +class TestDsmsWebUIVerification: + """Tests für die Verifizierungs-API die vom WebUI verwendet wird""" + + def test_verify_response_valid_integrity(self): + """Test: Verifizierungs-Response bei gültiger Integrität""" + content = "Test content" + checksum = hashlib.sha256(content.encode('utf-8')).hexdigest() + + mock_response = { + "cid": "QmTestCid123", + "exists": True, + "integrity_valid": True, + "metadata": { + "document_type": "legal_document", + "checksum": checksum + }, + "stored_checksum": checksum, + "calculated_checksum": checksum, + "verified_at": "2024-01-01T10:00:00" + } + + assert mock_response["exists"] is True + assert mock_response["integrity_valid"] is True + assert mock_response["stored_checksum"] == mock_response["calculated_checksum"] + + def test_verify_response_invalid_integrity(self): + """Test: Verifizierungs-Response bei ungültiger Integrität""" + mock_response = { + "cid": "QmTestCid123", + "exists": True, + "integrity_valid": False, + "metadata": { + "document_type": "legal_document" + }, + "stored_checksum": "fake_checksum", + "calculated_checksum": "real_checksum", + "verified_at": "2024-01-01T10:00:00" + } + + assert mock_response["exists"] is True + assert mock_response["integrity_valid"] is False + assert mock_response["stored_checksum"] != mock_response["calculated_checksum"] + + def test_verify_response_not_found(self): + """Test: Verifizierungs-Response wenn Dokument nicht existiert""" + mock_response = { + "cid": "QmNonExistent", + "exists": False, + "error": "Dokument nicht gefunden", + "verified_at": "2024-01-01T10:00:00" + } + + assert mock_response["exists"] is False + assert "error" in mock_response + + +class TestDsmsWebUIUpload: + """Tests für die Upload-API die vom WebUI verwendet wird""" + + def test_upload_response_structure(self): + """Test: Upload Response hat alle WebUI-relevanten Felder""" + mock_response = { + "cid": "QmNewDocCid123", + "size": 1024, + "metadata": { + "document_type": "legal_document", + "document_id": None, + "version": None, + "language": "de", + "created_at": "2024-01-01T10:00:00", + "checksum": "abc123def456", + "encrypted": False + }, + "gateway_url": "http://dsms-node:8080/ipfs/QmNewDocCid123", + "timestamp": "2024-01-01T10:00:00" + } + + assert "cid" in mock_response + assert "size" in mock_response + assert "gateway_url" in mock_response + assert mock_response["cid"].startswith("Qm") + + def test_checksum_calculation(self): + """Test: Checksum wird korrekt berechnet (wie im Gateway)""" + content = b"Test document content" + expected_checksum = hashlib.sha256(content).hexdigest() + + # Simuliert die Checksum-Berechnung wie im DSMS Gateway + calculated = hashlib.sha256(content).hexdigest() + + assert calculated == expected_checksum + assert len(calculated) == 64 # SHA-256 hat immer 64 Hex-Zeichen + + +class TestDsmsWebUIHealthCheck: + """Tests für den Health Check der vom WebUI verwendet wird""" + + def test_health_response_online(self): + """Test: Health Response wenn Node online ist""" + mock_response = { + "status": "healthy", + "ipfs_connected": True, + "timestamp": "2024-01-01T10:00:00" + } + + assert mock_response["status"] == "healthy" + assert mock_response["ipfs_connected"] is True + + def test_health_response_offline(self): + """Test: Health Response wenn Node offline ist""" + mock_response = { + "status": "degraded", + "ipfs_connected": False, + "timestamp": "2024-01-01T10:00:00" + } + + assert mock_response["status"] == "degraded" + assert mock_response["ipfs_connected"] is False + + +class TestDsmsWebUIDataTransformation: + """Tests für Daten-Transformationen die das WebUI durchführt""" + + def test_format_timestamp(self): + """Test: ISO-Timestamp wird für Anzeige formatiert""" + def format_timestamp(iso_string): + """Formatiert ISO-Timestamp für die Anzeige""" + from datetime import datetime + try: + dt = datetime.fromisoformat(iso_string.replace('Z', '+00:00')) + return dt.strftime("%d.%m.%Y %H:%M") + except: + return iso_string + + assert format_timestamp("2024-01-15T10:30:00") == "15.01.2024 10:30" + assert format_timestamp("invalid") == "invalid" + + def test_truncate_cid(self): + """Test: CID wird für Anzeige gekürzt""" + def truncate_cid(cid, max_length=20): + """Kürzt CID für die Anzeige""" + if len(cid) <= max_length: + return cid + return cid[:8] + "..." + cid[-8:] + + long_cid = "QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG" + truncated = truncate_cid(long_cid) + + assert len(truncated) < len(long_cid) + assert truncated.startswith("Qm") + assert "..." in truncated + + def test_status_badge_class(self): + """Test: Status-Badge-Klasse wird korrekt ermittelt""" + def get_status_badge_class(status): + """Gibt die CSS-Klasse für den Status zurück""" + status_classes = { + "healthy": "success", + "degraded": "warning", + "offline": "danger", + True: "success", + False: "danger" + } + return status_classes.get(status, "secondary") + + assert get_status_badge_class("healthy") == "success" + assert get_status_badge_class("degraded") == "warning" + assert get_status_badge_class(True) == "success" + assert get_status_badge_class(False) == "danger" + assert get_status_badge_class("unknown") == "secondary" + + +class TestDsmsWebUIErrorHandling: + """Tests für die Fehlerbehandlung im WebUI""" + + def test_network_error_message(self): + """Test: Netzwerkfehler wird benutzerfreundlich angezeigt""" + def get_error_message(error_type, details=None): + """Gibt benutzerfreundliche Fehlermeldung zurück""" + messages = { + "network": "DSMS Node ist nicht erreichbar. Bitte überprüfen Sie die Verbindung.", + "auth": "Authentifizierung fehlgeschlagen. Bitte erneut anmelden.", + "not_found": "Dokument nicht gefunden.", + "upload": f"Upload fehlgeschlagen: {details}" if details else "Upload fehlgeschlagen.", + "unknown": "Ein unbekannter Fehler ist aufgetreten." + } + return messages.get(error_type, messages["unknown"]) + + assert "nicht erreichbar" in get_error_message("network") + assert "nicht gefunden" in get_error_message("not_found") + assert "Test Error" in get_error_message("upload", "Test Error") + + def test_validation_cid_format(self): + """Test: CID-Format wird validiert""" + def is_valid_cid(cid): + """Prüft ob CID ein gültiges Format hat""" + if not cid: + return False + # CIDv0 beginnt mit Qm und hat 46 Zeichen + if cid.startswith("Qm") and len(cid) == 46: + return True + # CIDv1 beginnt mit b und ist base32 encoded + if cid.startswith("b") and len(cid) > 40: + return True + return False + + assert is_valid_cid("QmYwAPJzv5CZsnA625s3Xf2nemtYgPpHdWEz79ojWnPbdG") is True + assert is_valid_cid("bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi") is True + assert is_valid_cid("invalid") is False + assert is_valid_cid("") is False + assert is_valid_cid(None) is False + + +class TestDsmsWebUIIntegration: + """Integrationstests für WebUI-Workflows""" + + def test_upload_and_verify_workflow(self): + """Test: Upload und anschließende Verifizierung""" + # Simuliert den Upload-Workflow + upload_content = b"Test document for verification" + expected_checksum = hashlib.sha256(upload_content).hexdigest() + + # Upload Response + upload_response = { + "cid": "QmNewDoc123456789012345678901234567890123456", + "size": len(upload_content), + "metadata": { + "checksum": expected_checksum + } + } + + # Verifizierung + verify_response = { + "cid": upload_response["cid"], + "exists": True, + "integrity_valid": True, + "stored_checksum": expected_checksum, + "calculated_checksum": expected_checksum + } + + assert verify_response["integrity_valid"] is True + assert verify_response["stored_checksum"] == upload_response["metadata"]["checksum"] + + def test_node_status_determines_ui_state(self): + """Test: Node-Status bestimmt UI-Zustand""" + def get_ui_state(health_response): + """Ermittelt UI-Zustand basierend auf Health Check""" + if health_response.get("ipfs_connected"): + return { + "status": "online", + "upload_enabled": True, + "explore_enabled": True, + "message": None + } + else: + return { + "status": "offline", + "upload_enabled": False, + "explore_enabled": False, + "message": "DSMS Node ist nicht erreichbar" + } + + online_state = get_ui_state({"ipfs_connected": True, "status": "healthy"}) + assert online_state["upload_enabled"] is True + + offline_state = get_ui_state({"ipfs_connected": False, "status": "degraded"}) + assert offline_state["upload_enabled"] is False + assert offline_state["message"] is not None + + +# ==================== Run Tests ==================== + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/backend/tests/test_dsr_api.py b/backend/tests/test_dsr_api.py new file mode 100644 index 0000000..1b6de38 --- /dev/null +++ b/backend/tests/test_dsr_api.py @@ -0,0 +1,423 @@ +""" +Tests für die DSR (Data Subject Request) API + +Testet die Proxy-Endpoints für Betroffenenanfragen. +""" + +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from fastapi.testclient import TestClient +import httpx + + +class TestDSRUserAPI: + """Tests für User-Endpoints der DSR API.""" + + def test_create_dsr_request_body(self): + """Test: CreateDSRRequest Model Validierung.""" + from dsr_api import CreateDSRRequest + + # Valide Anfrage + req = CreateDSRRequest( + request_type="access", + requester_email="test@example.com", + requester_name="Max Mustermann" + ) + assert req.request_type == "access" + assert req.requester_email == "test@example.com" + + # Minimal-Anfrage + req_minimal = CreateDSRRequest( + request_type="erasure" + ) + assert req_minimal.request_type == "erasure" + assert req_minimal.requester_email is None + + def test_valid_request_types(self): + """Test: Alle DSGVO-Anfragetypen.""" + valid_types = ["access", "rectification", "erasure", "restriction", "portability"] + + for req_type in valid_types: + from dsr_api import CreateDSRRequest + req = CreateDSRRequest(request_type=req_type) + assert req.request_type == req_type + + @pytest.mark.asyncio + async def test_proxy_request_success(self): + """Test: Erfolgreiche Proxy-Anfrage.""" + from dsr_api import proxy_request + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = b'{"success": true}' + mock_response.json.return_value = {"success": True} + + with patch("httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.get = AsyncMock(return_value=mock_response) + mock_client.return_value.__aenter__.return_value = mock_instance + + result = await proxy_request("GET", "/dsr", "test-token") + assert result == {"success": True} + + @pytest.mark.asyncio + async def test_proxy_request_error(self): + """Test: Fehler bei Proxy-Anfrage.""" + from dsr_api import proxy_request + from fastapi import HTTPException + + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.content = b'{"error": "Not found"}' + mock_response.json.return_value = {"error": "Not found"} + + with patch("httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.get = AsyncMock(return_value=mock_response) + mock_client.return_value.__aenter__.return_value = mock_instance + + with pytest.raises(HTTPException) as exc_info: + await proxy_request("GET", "/dsr/invalid-id", "test-token") + + assert exc_info.value.status_code == 404 + + @pytest.mark.asyncio + async def test_proxy_request_service_unavailable(self): + """Test: Consent Service nicht erreichbar.""" + from dsr_api import proxy_request + from fastapi import HTTPException + + with patch("httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.get = AsyncMock(side_effect=httpx.RequestError("Connection failed")) + mock_client.return_value.__aenter__.return_value = mock_instance + + with pytest.raises(HTTPException) as exc_info: + await proxy_request("GET", "/dsr", "test-token") + + assert exc_info.value.status_code == 503 + + def test_get_token_valid(self): + """Test: Token-Extraktion aus Header.""" + from dsr_api import get_token + + token = get_token("Bearer valid-jwt-token") + assert token == "valid-jwt-token" + + def test_get_token_invalid(self): + """Test: Ungültiger Authorization Header.""" + from dsr_api import get_token + from fastapi import HTTPException + + with pytest.raises(HTTPException) as exc_info: + get_token(None) + assert exc_info.value.status_code == 401 + + with pytest.raises(HTTPException) as exc_info: + get_token("InvalidHeader") + assert exc_info.value.status_code == 401 + + +class TestDSRAdminAPI: + """Tests für Admin-Endpoints der DSR API.""" + + def test_create_dsr_admin_request_body(self): + """Test: Admin CreateDSRRequest Model.""" + from dsr_admin_api import CreateDSRRequest + + req = CreateDSRRequest( + request_type="erasure", + requester_email="user@example.com", + requester_name="Test User", + priority="high", + source="admin_panel" + ) + + assert req.request_type == "erasure" + assert req.requester_email == "user@example.com" + assert req.priority == "high" + assert req.source == "admin_panel" + + def test_update_dsr_request_body(self): + """Test: UpdateDSRRequest Model.""" + from dsr_admin_api import UpdateDSRRequest + + req = UpdateDSRRequest( + status="processing", + priority="expedited", + processing_notes="Daten werden zusammengestellt" + ) + + assert req.status == "processing" + assert req.priority == "expedited" + assert req.processing_notes == "Daten werden zusammengestellt" + + def test_verify_identity_request_body(self): + """Test: VerifyIdentityRequest Model.""" + from dsr_admin_api import VerifyIdentityRequest + + req = VerifyIdentityRequest(method="id_card") + assert req.method == "id_card" + + req2 = VerifyIdentityRequest(method="video_call") + assert req2.method == "video_call" + + def test_extend_deadline_request_body(self): + """Test: ExtendDeadlineRequest Model.""" + from dsr_admin_api import ExtendDeadlineRequest + + req = ExtendDeadlineRequest( + reason="Komplexität der Anfrage", + days=60 + ) + + assert req.reason == "Komplexität der Anfrage" + assert req.days == 60 + + def test_complete_dsr_request_body(self): + """Test: CompleteDSRRequest Model.""" + from dsr_admin_api import CompleteDSRRequest + + req = CompleteDSRRequest( + summary="Alle Daten wurden bereitgestellt.", + result_data={"files": ["export.json"]} + ) + + assert req.summary == "Alle Daten wurden bereitgestellt." + assert "files" in req.result_data + + def test_reject_dsr_request_body(self): + """Test: RejectDSRRequest Model.""" + from dsr_admin_api import RejectDSRRequest + + req = RejectDSRRequest( + reason="Daten werden für Rechtsstreitigkeiten benötigt", + legal_basis="Art. 17(3)e" + ) + + assert req.reason == "Daten werden für Rechtsstreitigkeiten benötigt" + assert req.legal_basis == "Art. 17(3)e" + + def test_send_communication_request_body(self): + """Test: SendCommunicationRequest Model.""" + from dsr_admin_api import SendCommunicationRequest + + req = SendCommunicationRequest( + communication_type="dsr_processing_started", + template_version_id="uuid-123", + variables={"custom_field": "value"} + ) + + assert req.communication_type == "dsr_processing_started" + assert req.template_version_id == "uuid-123" + + def test_update_exception_check_request_body(self): + """Test: UpdateExceptionCheckRequest Model.""" + from dsr_admin_api import UpdateExceptionCheckRequest + + req = UpdateExceptionCheckRequest( + applies=True, + notes="Laufende Rechtsstreitigkeiten" + ) + + assert req.applies is True + assert req.notes == "Laufende Rechtsstreitigkeiten" + + def test_create_template_version_request_body(self): + """Test: CreateTemplateVersionRequest Model.""" + from dsr_admin_api import CreateTemplateVersionRequest + + req = CreateTemplateVersionRequest( + version="1.1.0", + language="de", + subject="Eingangsbestätigung", + body_html="

            Inhalt

            ", + body_text="Inhalt" + ) + + assert req.version == "1.1.0" + assert req.language == "de" + assert req.subject == "Eingangsbestätigung" + + def test_get_admin_token_from_header(self): + """Test: Admin-Token aus Header extrahieren.""" + from dsr_admin_api import get_admin_token + + # Mit Bearer Token + token = get_admin_token("Bearer admin-jwt-token") + assert token == "admin-jwt-token" + + def test_get_admin_token_fallback(self): + """Test: Admin-Token Fallback für Entwicklung.""" + from dsr_admin_api import get_admin_token + + # Ohne Header - generiert Dev-Token + token = get_admin_token(None) + assert token is not None + assert len(token) > 0 + + +class TestDSRRequestTypes: + """Tests für DSR-Anfragetypen und DSGVO-Artikel.""" + + def test_access_request_art_15(self): + """Test: Auskunftsrecht (Art. 15 DSGVO).""" + # 30 Tage Frist + expected_deadline_days = 30 + assert expected_deadline_days == 30 + + def test_rectification_request_art_16(self): + """Test: Berichtigungsrecht (Art. 16 DSGVO).""" + # 14 Tage empfohlen + expected_deadline_days = 14 + assert expected_deadline_days == 14 + + def test_erasure_request_art_17(self): + """Test: Löschungsrecht (Art. 17 DSGVO).""" + # 14 Tage empfohlen + expected_deadline_days = 14 + assert expected_deadline_days == 14 + + def test_restriction_request_art_18(self): + """Test: Einschränkungsrecht (Art. 18 DSGVO).""" + # 14 Tage empfohlen + expected_deadline_days = 14 + assert expected_deadline_days == 14 + + def test_portability_request_art_20(self): + """Test: Datenübertragbarkeit (Art. 20 DSGVO).""" + # 30 Tage Frist + expected_deadline_days = 30 + assert expected_deadline_days == 30 + + +class TestDSRStatusWorkflow: + """Tests für den DSR Status-Workflow.""" + + def test_valid_status_values(self): + """Test: Alle gültigen Status-Werte.""" + valid_statuses = [ + "intake", + "identity_verification", + "processing", + "completed", + "rejected", + "cancelled" + ] + + for status in valid_statuses: + assert status in valid_statuses + + def test_status_transition_intake(self): + """Test: Erlaubte Übergänge von 'intake'.""" + allowed_from_intake = [ + "identity_verification", + "processing", + "rejected", + "cancelled" + ] + + # Completed ist NICHT direkt von intake erlaubt + assert "completed" not in allowed_from_intake + + def test_status_transition_processing(self): + """Test: Erlaubte Übergänge von 'processing'.""" + allowed_from_processing = [ + "completed", + "rejected", + "cancelled" + ] + + # Zurück zu intake ist NICHT erlaubt + assert "intake" not in allowed_from_processing + + def test_terminal_states(self): + """Test: Endstatus ohne weitere Übergänge.""" + terminal_states = ["completed", "rejected", "cancelled"] + + for state in terminal_states: + # Von Endstatus keine Übergänge möglich + assert state in terminal_states + + +class TestDSRExceptionChecks: + """Tests für Art. 17(3) Ausnahmeprüfungen.""" + + def test_exception_types_art_17_3(self): + """Test: Alle Ausnahmen nach Art. 17(3) DSGVO.""" + exceptions = { + "art_17_3_a": "Meinungs- und Informationsfreiheit", + "art_17_3_b": "Rechtliche Verpflichtung", + "art_17_3_c": "Öffentliches Interesse im Gesundheitsbereich", + "art_17_3_d": "Archivzwecke, wissenschaftliche/historische Forschung", + "art_17_3_e": "Geltendmachung von Rechtsansprüchen" + } + + assert len(exceptions) == 5 + + for code, description in exceptions.items(): + assert code.startswith("art_17_3_") + assert len(description) > 0 + + def test_rejection_legal_bases(self): + """Test: Rechtsgrundlagen für Ablehnung.""" + legal_bases = [ + "Art. 17(3)a", + "Art. 17(3)b", + "Art. 17(3)c", + "Art. 17(3)d", + "Art. 17(3)e", + "Art. 12(5)" # Offensichtlich unbegründet/exzessiv + ] + + assert len(legal_bases) == 6 + assert "Art. 12(5)" in legal_bases + + +class TestDSRTemplates: + """Tests für DSR-Vorlagen.""" + + def test_template_types(self): + """Test: Alle erwarteten Vorlagen-Typen.""" + expected_templates = [ + "dsr_receipt_access", + "dsr_receipt_rectification", + "dsr_receipt_erasure", + "dsr_receipt_restriction", + "dsr_receipt_portability", + "dsr_identity_request", + "dsr_processing_started", + "dsr_processing_update", + "dsr_clarification_request", + "dsr_completed_access", + "dsr_completed_rectification", + "dsr_completed_erasure", + "dsr_completed_restriction", + "dsr_completed_portability", + "dsr_restriction_lifted", + "dsr_rejected_identity", + "dsr_rejected_exception", + "dsr_rejected_unfounded", + "dsr_deadline_warning" + ] + + assert len(expected_templates) == 19 + + def test_template_variables(self): + """Test: Standard Template-Variablen.""" + variables = [ + "{{requester_name}}", + "{{requester_email}}", + "{{request_number}}", + "{{request_type_de}}", + "{{request_date}}", + "{{deadline_date}}", + "{{company_name}}", + "{{dpo_name}}", + "{{dpo_email}}", + "{{portal_url}}" + ] + + for var in variables: + assert var.startswith("{{") + assert var.endswith("}}") diff --git a/backend/tests/test_edu_search_seeds.py b/backend/tests/test_edu_search_seeds.py new file mode 100644 index 0000000..728e211 --- /dev/null +++ b/backend/tests/test_edu_search_seeds.py @@ -0,0 +1,589 @@ +""" +Tests for EduSearch Seeds API. + +Tests cover: +- CRUD operations for seeds +- Category management +- Bulk import functionality +- Statistics endpoint +- Error handling +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from fastapi import HTTPException +from fastapi.testclient import TestClient +import uuid +from datetime import datetime + + +class AsyncContextManagerMock: + """Helper class to create proper async context managers for testing.""" + + def __init__(self, return_value): + self.return_value = return_value + + async def __aenter__(self): + return self.return_value + + async def __aexit__(self, exc_type, exc_val, exc_tb): + return None + + +def create_mock_pool_and_conn(): + """Helper to create properly configured mock pool and connection for async tests.""" + mock_pool = MagicMock() + mock_conn = AsyncMock() + + # Configure pool.acquire() to return an async context manager + mock_pool.acquire.return_value = AsyncContextManagerMock(mock_conn) + + return mock_pool, mock_conn + + +def mock_db_pool_patch(mock_pool): + """Create a patch for get_db_pool that returns mock_pool.""" + async def _mock_get_pool(): + return mock_pool + return patch("llm_gateway.routes.edu_search_seeds.get_db_pool", new=_mock_get_pool) + + +class TestSeedModels: + """Test Pydantic models for seeds.""" + + def test_seed_base_valid(self): + """Test SeedBase with valid data.""" + from llm_gateway.routes.edu_search_seeds import SeedBase + + seed = SeedBase( + url="https://www.kmk.org", + name="KMK", + description="Test description", + trust_boost=0.95, + enabled=True + ) + assert seed.url == "https://www.kmk.org" + assert seed.name == "KMK" + assert seed.trust_boost == 0.95 + + def test_seed_base_defaults(self): + """Test SeedBase default values.""" + from llm_gateway.routes.edu_search_seeds import SeedBase + + seed = SeedBase( + url="https://test.de", + name="Test" + ) + assert seed.description is None + assert seed.trust_boost == 0.5 + assert seed.enabled is True + assert seed.source_type == "GOV" + assert seed.scope == "FEDERAL" + + def test_seed_create_model(self): + """Test SeedCreate model.""" + from llm_gateway.routes.edu_search_seeds import SeedCreate + + seed = SeedCreate( + url="https://www.test.de", + name="Test Seed", + category_name="federal" + ) + assert seed.url == "https://www.test.de" + assert seed.category_name == "federal" + + def test_seed_update_model(self): + """Test SeedUpdate with partial data.""" + from llm_gateway.routes.edu_search_seeds import SeedUpdate + + seed = SeedUpdate(enabled=False) + assert seed.enabled is False + assert seed.name is None + assert seed.url is None + + +class TestCategoryResponse: + """Test category response models.""" + + def test_category_response(self): + """Test CategoryResponse model.""" + from llm_gateway.routes.edu_search_seeds import CategoryResponse + + cat = CategoryResponse( + id="550e8400-e29b-41d4-a716-446655440000", + name="federal", + display_name="Bundesebene", + description="KMK, BMBF", + icon="icon", + sort_order=0, + is_active=True + ) + assert cat.name == "federal" + assert cat.display_name == "Bundesebene" + + +class TestDatabaseConnection: + """Test database connection handling.""" + + @pytest.mark.asyncio + async def test_get_db_pool_creates_pool(self): + """Test that get_db_pool creates a connection pool.""" + from llm_gateway.routes.edu_search_seeds import get_db_pool + import llm_gateway.routes.edu_search_seeds as module + + mock_pool = MagicMock() + + # Reset global pool + module._pool = None + + with patch("llm_gateway.routes.edu_search_seeds.asyncpg.create_pool", + new=AsyncMock(return_value=mock_pool)) as mock_create: + with patch.dict("os.environ", {"DATABASE_URL": "postgresql://test:test@localhost/test"}): + pool = await get_db_pool() + mock_create.assert_called_once() + assert pool == mock_pool + + # Cleanup + module._pool = None + + @pytest.mark.asyncio + async def test_get_db_pool_reuses_existing(self): + """Test that get_db_pool reuses existing pool.""" + from llm_gateway.routes.edu_search_seeds import get_db_pool + + import llm_gateway.routes.edu_search_seeds as module + mock_pool = MagicMock() + module._pool = mock_pool + + pool = await get_db_pool() + assert pool == mock_pool + + # Cleanup + module._pool = None + + +class TestListCategories: + """Test list_categories endpoint.""" + + @pytest.mark.asyncio + async def test_list_categories_success(self): + """Test successful category listing.""" + from llm_gateway.routes.edu_search_seeds import list_categories + + mock_pool, mock_conn = create_mock_pool_and_conn() + mock_conn.fetch.return_value = [ + { + "id": uuid.uuid4(), + "name": "federal", + "display_name": "Bundesebene", + "description": "Test", + "icon": "icon", + "sort_order": 0, + "is_active": True, + "created_at": "2024-01-01T00:00:00Z" + } + ] + + with mock_db_pool_patch(mock_pool): + result = await list_categories() + + assert len(result) == 1 + assert result[0].name == "federal" + + @pytest.mark.asyncio + async def test_list_categories_empty(self): + """Test category listing with no categories.""" + from llm_gateway.routes.edu_search_seeds import list_categories + + mock_pool, mock_conn = create_mock_pool_and_conn() + mock_conn.fetch.return_value = [] + + with mock_db_pool_patch(mock_pool): + result = await list_categories() + + assert result == [] + + +class TestGetSeed: + """Test get_seed endpoint.""" + + @pytest.mark.asyncio + async def test_get_seed_found(self): + """Test getting existing seed.""" + from llm_gateway.routes.edu_search_seeds import get_seed + + seed_id = str(uuid.uuid4()) + mock_pool, mock_conn = create_mock_pool_and_conn() + mock_conn.fetchrow.return_value = { + "id": seed_id, + "url": "https://test.de", + "name": "Test", + "description": None, + "category": "federal", + "category_display_name": "Bundesebene", + "source_type": "GOV", + "scope": "FEDERAL", + "state": None, + "trust_boost": 0.5, + "enabled": True, + "crawl_depth": 2, + "crawl_frequency": "weekly", + "last_crawled_at": None, + "last_crawl_status": None, + "last_crawl_docs": 0, + "total_documents": 0, + "created_at": datetime.now(), + "updated_at": datetime.now() + } + + with mock_db_pool_patch(mock_pool): + result = await get_seed(seed_id) + + assert result.url == "https://test.de" + assert result.category == "federal" + + @pytest.mark.asyncio + async def test_get_seed_not_found(self): + """Test getting non-existing seed returns 404.""" + from llm_gateway.routes.edu_search_seeds import get_seed + + seed_id = str(uuid.uuid4()) + mock_pool, mock_conn = create_mock_pool_and_conn() + mock_conn.fetchrow.return_value = None + + with mock_db_pool_patch(mock_pool): + with pytest.raises(HTTPException) as exc_info: + await get_seed(seed_id) + + assert exc_info.value.status_code == 404 + + +class TestCreateSeed: + """Test create_seed endpoint.""" + + @pytest.mark.asyncio + async def test_create_seed_success(self): + """Test successful seed creation.""" + from llm_gateway.routes.edu_search_seeds import create_seed, SeedCreate + + new_seed = SeedCreate( + url="https://new-seed.de", + name="New Seed", + description="Test seed", + trust_boost=0.8 + ) + + mock_pool, mock_conn = create_mock_pool_and_conn() + new_id = uuid.uuid4() + now = datetime.now() + # Mock for fetchval (category lookup - returns None since no category) + mock_conn.fetchval.return_value = None + # Mock for fetchrow (insert returning) + mock_conn.fetchrow.return_value = { + "id": new_id, + "created_at": now, + "updated_at": now + } + + with mock_db_pool_patch(mock_pool): + result = await create_seed(new_seed) + + assert result.id == str(new_id) + assert result.url == "https://new-seed.de" + + @pytest.mark.asyncio + async def test_create_seed_duplicate_url(self): + """Test creating seed with duplicate URL fails with 409.""" + from llm_gateway.routes.edu_search_seeds import create_seed, SeedCreate + import asyncpg + + new_seed = SeedCreate( + url="https://duplicate.de", + name="Duplicate" + ) + + mock_pool, mock_conn = create_mock_pool_and_conn() + mock_conn.fetchval.return_value = None # No category + mock_conn.fetchrow.side_effect = asyncpg.UniqueViolationError("duplicate key") + + with mock_db_pool_patch(mock_pool): + with pytest.raises(HTTPException) as exc_info: + await create_seed(new_seed) + + assert exc_info.value.status_code == 409 + assert "existiert bereits" in exc_info.value.detail + + +class TestUpdateSeed: + """Test update_seed endpoint.""" + + @pytest.mark.asyncio + async def test_update_seed_success(self): + """Test successful seed update.""" + from llm_gateway.routes.edu_search_seeds import update_seed, SeedUpdate + + seed_id = str(uuid.uuid4()) + update_data = SeedUpdate(name="Updated Name") + + mock_pool, mock_conn = create_mock_pool_and_conn() + now = datetime.now() + # Mock for execute (update) - returns non-zero + mock_conn.execute.return_value = "UPDATE 1" + # Mock for fetchval (check if update succeeded) + mock_conn.fetchval.return_value = seed_id + # Mock for fetchrow (get_seed after update) + mock_conn.fetchrow.return_value = { + "id": seed_id, + "url": "https://test.de", + "name": "Updated Name", + "description": None, + "category": "federal", + "category_display_name": "Bundesebene", + "source_type": "GOV", + "scope": "FEDERAL", + "state": None, + "trust_boost": 0.5, + "enabled": True, + "crawl_depth": 2, + "crawl_frequency": "weekly", + "last_crawled_at": None, + "last_crawl_status": None, + "last_crawl_docs": 0, + "total_documents": 0, + "created_at": now, + "updated_at": now + } + + with mock_db_pool_patch(mock_pool): + result = await update_seed(seed_id, update_data) + + assert result.name == "Updated Name" + + @pytest.mark.asyncio + async def test_update_seed_not_found(self): + """Test updating non-existing seed returns 404.""" + from llm_gateway.routes.edu_search_seeds import update_seed, SeedUpdate + + seed_id = str(uuid.uuid4()) + update_data = SeedUpdate(name="Updated") + + mock_pool, mock_conn = create_mock_pool_and_conn() + # UPDATE uses fetchrow with RETURNING id - returns None for not found + mock_conn.fetchrow.return_value = None + + with mock_db_pool_patch(mock_pool): + with pytest.raises(HTTPException) as exc_info: + await update_seed(seed_id, update_data) + + assert exc_info.value.status_code == 404 + + @pytest.mark.asyncio + async def test_update_seed_empty_update(self): + """Test update with no fields returns 400.""" + from llm_gateway.routes.edu_search_seeds import update_seed, SeedUpdate + + seed_id = str(uuid.uuid4()) + update_data = SeedUpdate() + + mock_pool, mock_conn = create_mock_pool_and_conn() + + with mock_db_pool_patch(mock_pool): + with pytest.raises(HTTPException) as exc_info: + await update_seed(seed_id, update_data) + + assert exc_info.value.status_code == 400 + + +class TestDeleteSeed: + """Test delete_seed endpoint.""" + + @pytest.mark.asyncio + async def test_delete_seed_success(self): + """Test successful seed deletion.""" + from llm_gateway.routes.edu_search_seeds import delete_seed + + seed_id = str(uuid.uuid4()) + mock_pool, mock_conn = create_mock_pool_and_conn() + mock_conn.execute.return_value = "DELETE 1" + + with mock_db_pool_patch(mock_pool): + result = await delete_seed(seed_id) + + assert result["status"] == "deleted" + assert result["id"] == seed_id + + @pytest.mark.asyncio + async def test_delete_seed_not_found(self): + """Test deleting non-existing seed returns 404.""" + from llm_gateway.routes.edu_search_seeds import delete_seed + + seed_id = str(uuid.uuid4()) + mock_pool, mock_conn = create_mock_pool_and_conn() + mock_conn.execute.return_value = "DELETE 0" + + with mock_db_pool_patch(mock_pool): + with pytest.raises(HTTPException) as exc_info: + await delete_seed(seed_id) + + assert exc_info.value.status_code == 404 + + +class TestBulkImport: + """Test bulk_import_seeds endpoint.""" + + @pytest.mark.asyncio + async def test_bulk_import_success(self): + """Test successful bulk import.""" + from llm_gateway.routes.edu_search_seeds import bulk_import_seeds, BulkImportRequest, SeedCreate + + seeds = [ + SeedCreate(url="https://test1.de", name="Test 1", category_name="federal"), + SeedCreate(url="https://test2.de", name="Test 2", category_name="states") + ] + request = BulkImportRequest(seeds=seeds) + + mock_pool, mock_conn = create_mock_pool_and_conn() + + # Mock category pre-fetch + mock_conn.fetch.return_value = [ + {"id": uuid.uuid4(), "name": "federal"}, + {"id": uuid.uuid4(), "name": "states"} + ] + # Mock inserts - ON CONFLICT DO NOTHING, no exception + mock_conn.execute.return_value = "INSERT 0 1" + + with mock_db_pool_patch(mock_pool): + result = await bulk_import_seeds(request) + + assert result.imported == 2 + assert result.skipped == 0 + + @pytest.mark.asyncio + async def test_bulk_import_with_errors(self): + """Test bulk import handles errors gracefully.""" + from llm_gateway.routes.edu_search_seeds import bulk_import_seeds, BulkImportRequest, SeedCreate + + seeds = [SeedCreate(url="https://error.de", name="Error Seed")] + request = BulkImportRequest(seeds=seeds) + + mock_pool, mock_conn = create_mock_pool_and_conn() + mock_conn.fetch.return_value = [] # No categories + mock_conn.execute.side_effect = Exception("Database error") + + with mock_db_pool_patch(mock_pool): + result = await bulk_import_seeds(request) + + assert result.imported == 0 + assert len(result.errors) == 1 + + +class TestGetStats: + """Test get_stats endpoint.""" + + @pytest.mark.asyncio + async def test_get_stats_success(self): + """Test successful stats retrieval.""" + from llm_gateway.routes.edu_search_seeds import get_stats + + mock_pool, mock_conn = create_mock_pool_and_conn() + # Mock for multiple fetchval calls + mock_conn.fetchval.side_effect = [56, 52, 1000, None] # total, enabled, total_docs, last_crawl + # Mock for fetch calls (by_category, by_state) + mock_conn.fetch.side_effect = [ + [{"name": "federal", "count": 5}], # per category + [{"state": "BY", "count": 5}] # per state + ] + + with mock_db_pool_patch(mock_pool): + result = await get_stats() + + assert result.total_seeds == 56 + assert result.enabled_seeds == 52 + assert result.total_documents == 1000 + + +class TestExportForCrawler: + """Test export_seeds_for_crawler endpoint.""" + + @pytest.mark.asyncio + async def test_export_for_crawler_success(self): + """Test successful crawler export.""" + from llm_gateway.routes.edu_search_seeds import export_seeds_for_crawler + + mock_pool, mock_conn = create_mock_pool_and_conn() + mock_conn.fetch.return_value = [ + { + "url": "https://test.de", + "trust_boost": 0.9, + "source_type": "GOV", + "scope": "FEDERAL", + "state": None, + "crawl_depth": 2, + "category": "federal" + } + ] + + with mock_db_pool_patch(mock_pool): + result = await export_seeds_for_crawler() + + assert "seeds" in result + assert len(result["seeds"]) == 1 + assert "exported_at" in result + assert result["total"] == 1 + assert result["seeds"][0]["trust"] == 0.9 + + +class TestEdgeCases: + """Test edge cases and error handling.""" + + @pytest.mark.asyncio + async def test_invalid_uuid_format(self): + """Test handling of invalid UUID format.""" + from llm_gateway.routes.edu_search_seeds import get_seed + import asyncpg + + mock_pool, mock_conn = create_mock_pool_and_conn() + + # asyncpg raises DataError for invalid UUID + mock_conn.fetchrow.side_effect = asyncpg.DataError("invalid UUID") + + with mock_db_pool_patch(mock_pool): + with pytest.raises(asyncpg.DataError): + await get_seed("not-a-uuid") + + @pytest.mark.asyncio + async def test_database_connection_error(self): + """Test handling of database connection errors.""" + from llm_gateway.routes.edu_search_seeds import list_categories + + async def _failing_get_pool(): + raise Exception("Connection failed") + + with patch("llm_gateway.routes.edu_search_seeds.get_db_pool", new=_failing_get_pool): + with pytest.raises(Exception) as exc_info: + await list_categories() + + assert "Connection failed" in str(exc_info.value) + + def test_trust_boost_validation(self): + """Test trust_boost must be between 0 and 1.""" + from llm_gateway.routes.edu_search_seeds import SeedBase + from pydantic import ValidationError + + # Valid values + seed = SeedBase(url="https://test.de", name="Test", trust_boost=0.5) + assert seed.trust_boost == 0.5 + + # Edge values + seed_min = SeedBase(url="https://test.de", name="Test", trust_boost=0.0) + assert seed_min.trust_boost == 0.0 + + seed_max = SeedBase(url="https://test.de", name="Test", trust_boost=1.0) + assert seed_max.trust_boost == 1.0 + + # Invalid values + with pytest.raises(ValidationError): + SeedBase(url="https://test.de", name="Test", trust_boost=1.5) + + with pytest.raises(ValidationError): + SeedBase(url="https://test.de", name="Test", trust_boost=-0.1) diff --git a/backend/tests/test_email_service.py b/backend/tests/test_email_service.py new file mode 100644 index 0000000..17588b5 --- /dev/null +++ b/backend/tests/test_email_service.py @@ -0,0 +1,190 @@ +""" +Tests fuer den Email-Service. + +Testet: +- EmailService Klasse +- SMTP Verbindung (mit Mock) +- Messenger-Benachrichtigungen +- Jitsi-Einladungen +""" + +import pytest +from unittest.mock import patch, MagicMock + +from email_service import EmailService, EmailResult + + +class TestEmailService: + """Tests fuer die EmailService Klasse.""" + + def test_init_default_values(self): + """Test: Service wird mit Default-Werten initialisiert.""" + service = EmailService() + + # Default ist 'mailpit' in Docker, 'localhost' lokal - beides akzeptieren + assert service.host in ("localhost", "mailpit") + assert service.port == 1025 + # from_name kann "BreakPilot" oder "BreakPilot Dev" sein (dev environment) + assert service.from_name in ("BreakPilot", "BreakPilot Dev") + assert service.from_addr == "noreply@breakpilot.app" + + def test_init_custom_values(self): + """Test: Service wird mit benutzerdefinierten Werten initialisiert.""" + service = EmailService( + host="smtp.example.com", + port=587, + username="user", + password="pass", + from_name="Custom", + from_addr="custom@example.com", + use_tls=True + ) + + assert service.host == "smtp.example.com" + assert service.port == 587 + assert service.username == "user" + assert service.password == "pass" + assert service.from_name == "Custom" + assert service.from_addr == "custom@example.com" + assert service.use_tls is True + + @patch('email_service.smtplib.SMTP') + def test_send_email_success(self, mock_smtp): + """Test: Email wird erfolgreich gesendet.""" + mock_instance = MagicMock() + mock_smtp.return_value.__enter__ = MagicMock(return_value=mock_instance) + mock_smtp.return_value.__exit__ = MagicMock(return_value=False) + + service = EmailService() + result = service.send_email( + to_email="test@example.com", + subject="Test", + body_text="Test Body" + ) + + assert result.success is True + assert result.recipient == "test@example.com" + assert result.sent_at is not None + + @patch('email_service.smtplib.SMTP') + def test_send_email_with_html(self, mock_smtp): + """Test: Email mit HTML wird gesendet.""" + mock_instance = MagicMock() + mock_smtp.return_value.__enter__ = MagicMock(return_value=mock_instance) + mock_smtp.return_value.__exit__ = MagicMock(return_value=False) + + service = EmailService() + result = service.send_email( + to_email="test@example.com", + subject="Test", + body_text="Plain text", + body_html="

            HTML

            " + ) + + assert result.success is True + + @patch('email_service.smtplib.SMTP') + def test_send_email_failure(self, mock_smtp): + """Test: Fehler beim Email-Versand wird behandelt.""" + import smtplib + mock_smtp.side_effect = smtplib.SMTPException("Connection failed") + + service = EmailService() + result = service.send_email( + to_email="test@example.com", + subject="Test", + body_text="Test Body" + ) + + assert result.success is False + assert result.error is not None + assert "SMTP" in result.error + + @patch('email_service.smtplib.SMTP') + def test_send_messenger_notification(self, mock_smtp): + """Test: Messenger-Benachrichtigung wird gesendet.""" + mock_instance = MagicMock() + mock_smtp.return_value.__enter__ = MagicMock(return_value=mock_instance) + mock_smtp.return_value.__exit__ = MagicMock(return_value=False) + + service = EmailService() + result = service.send_messenger_notification( + to_email="parent@example.com", + to_name="Max Mustermann", + sender_name="Frau Lehrerin", + message_content="Bitte bringen Sie morgen das Buch mit." + ) + + assert result.success is True + assert result.recipient == "parent@example.com" + + @patch('email_service.smtplib.SMTP') + def test_send_jitsi_invitation(self, mock_smtp): + """Test: Jitsi-Einladung wird gesendet.""" + mock_instance = MagicMock() + mock_smtp.return_value.__enter__ = MagicMock(return_value=mock_instance) + mock_smtp.return_value.__exit__ = MagicMock(return_value=False) + + service = EmailService() + result = service.send_jitsi_invitation( + to_email="parent@example.com", + to_name="Max Mustermann", + organizer_name="Frau Lehrerin", + meeting_title="Elterngespraech", + meeting_date="20. Dezember 2024", + meeting_time="14:00 Uhr", + jitsi_url="https://meet.jit.si/BreakPilot-test123" + ) + + assert result.success is True + assert result.recipient == "parent@example.com" + + @patch('email_service.smtplib.SMTP') + def test_send_jitsi_invitation_with_additional_info(self, mock_smtp): + """Test: Jitsi-Einladung mit zusaetzlichen Infos wird gesendet.""" + mock_instance = MagicMock() + mock_smtp.return_value.__enter__ = MagicMock(return_value=mock_instance) + mock_smtp.return_value.__exit__ = MagicMock(return_value=False) + + service = EmailService() + result = service.send_jitsi_invitation( + to_email="parent@example.com", + to_name="Max Mustermann", + organizer_name="Frau Lehrerin", + meeting_title="Elterngespraech", + meeting_date="20. Dezember 2024", + meeting_time="14:00 Uhr", + jitsi_url="https://meet.jit.si/BreakPilot-test123", + additional_info="Bitte bereiten Sie die Zeugnismappe vor." + ) + + assert result.success is True + + +class TestEmailResult: + """Tests fuer das EmailResult Dataclass.""" + + def test_success_result(self): + """Test: Erfolgreiche Email-Antwort.""" + result = EmailResult( + success=True, + message_id="msg-123", + recipient="test@example.com", + sent_at="2024-12-18T10:00:00" + ) + + assert result.success is True + assert result.message_id == "msg-123" + assert result.error is None + + def test_failure_result(self): + """Test: Fehlgeschlagene Email-Antwort.""" + result = EmailResult( + success=False, + error="Connection refused", + recipient="test@example.com" + ) + + assert result.success is False + assert result.error == "Connection refused" + assert result.sent_at is None diff --git a/backend/tests/test_frontend_integration.py b/backend/tests/test_frontend_integration.py new file mode 100644 index 0000000..d034e18 --- /dev/null +++ b/backend/tests/test_frontend_integration.py @@ -0,0 +1,437 @@ +""" +E2E-Tests für Frontend-Module Integration. + +Testet die Verbindung zwischen Frontend-Modulen und ihren APIs: +- Worksheets Frontend → Worksheets API +- Correction Frontend → Corrections API +- Letters Frontend → Letters API +- Companion Frontend → State Engine API +""" + +import pytest +from fastapi.testclient import TestClient +import sys +import json + +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from main import app + +client = TestClient(app) + + +class TestWorksheetsIntegration: + """E2E-Tests für Worksheets Frontend → API Integration.""" + + def test_generate_mc_endpoint(self): + """Testet MC-Generierung wie Frontend sie aufruft.""" + response = client.post( + "/api/worksheets/generate/multiple-choice", + json={ + "source_text": "Die Fotosynthese ist ein Prozess, bei dem Pflanzen Lichtenergie nutzen, um aus Kohlendioxid und Wasser Zucker und Sauerstoff herzustellen. Dieser Prozess findet in den Chloroplasten statt.", + "num_questions": 3, + "difficulty": "medium", + "topic": "Fotosynthese", + "subject": "Biologie" + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "content" in data + assert data["content"]["content_type"] == "multiple_choice" + assert "questions" in data["content"]["data"] + assert len(data["content"]["data"]["questions"]) >= 1 + + def test_generate_cloze_endpoint(self): + """Testet Lückentext-Generierung.""" + response = client.post( + "/api/worksheets/generate/cloze", + json={ + "source_text": "Berlin ist die Hauptstadt von Deutschland. Die Stadt hat etwa 3,6 Millionen Einwohner und liegt an der Spree.", + "num_gaps": 3, + "gap_type": "word", + "hint_type": "first_letter" + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["content"]["content_type"] == "cloze" + + def test_generate_mindmap_endpoint(self): + """Testet Mindmap-Generierung.""" + response = client.post( + "/api/worksheets/generate/mindmap", + json={ + "source_text": "Das Mittelalter war eine Epoche der europäischen Geschichte. Es begann etwa 500 n. Chr. und endete um 1500. Wichtige Aspekte waren das Lehnswesen, die Kirche und das Rittertum.", + "max_branches": 4, + "depth": 2 + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["content"]["content_type"] == "mindmap" + # API returns 'mermaid' key (not 'mermaid_code') + assert "mermaid" in data["content"]["data"] or "mermaid_code" in data["content"]["data"] + + def test_generate_quiz_endpoint(self): + """Testet Quiz-Generierung.""" + response = client.post( + "/api/worksheets/generate/quiz", + json={ + "source_text": "Der Wasserkreislauf beschreibt die kontinuierliche Bewegung des Wassers auf der Erde. Wasser verdunstet, bildet Wolken und fällt als Niederschlag.", + "num_items": 3, + "quiz_types": ["true_false"] + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert data["content"]["content_type"] == "quiz" + + def test_generate_batch_endpoint(self): + """Testet Batch-Generierung mehrerer Typen.""" + response = client.post( + "/api/worksheets/generate/batch", + json={ + "source_text": "Python ist eine Programmiersprache. Sie wurde 1991 von Guido van Rossum entwickelt. Python nutzt dynamische Typisierung.", + "content_types": ["multiple_choice", "cloze"] + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert len(data["contents"]) == 2 + + +class TestCorrectionsIntegration: + """E2E-Tests für Correction Frontend → API Integration.""" + + def test_create_correction_workflow(self): + """Testet kompletten Korrektur-Workflow.""" + # Step 1: Korrektur erstellen + create_response = client.post( + "/api/corrections/", + json={ + "student_id": "test-student-e2e", + "student_name": "Test Schueler", + "class_name": "10a", + "exam_title": "E2E-Testklausur", + "subject": "Mathematik", + "max_points": 100 + } + ) + + assert create_response.status_code == 200 + create_data = create_response.json() + assert create_data["success"] is True + correction_id = create_data["correction"]["id"] + + # Step 2: Status abrufen + get_response = client.get(f"/api/corrections/{correction_id}") + assert get_response.status_code == 200 + get_data = get_response.json() + assert get_data["correction"]["status"] == "uploaded" + + # Step 3: Korrektur aktualisieren (simuliert Review) + update_response = client.put( + f"/api/corrections/{correction_id}", + json={ + "total_points": 85, + "grade": "2", + "teacher_notes": "Gute Arbeit!", + "status": "reviewing" + } + ) + + assert update_response.status_code == 200 + update_data = update_response.json() + assert update_data["correction"]["total_points"] == 85 + assert update_data["correction"]["grade"] == "2" + + # Step 4: Abschließen + complete_response = client.post(f"/api/corrections/{correction_id}/complete") + assert complete_response.status_code == 200 + + # Step 5: Finalen Status prüfen + final_response = client.get(f"/api/corrections/{correction_id}") + assert final_response.status_code == 200 + assert final_response.json()["correction"]["status"] == "completed" + + # Cleanup + client.delete(f"/api/corrections/{correction_id}") + + def test_list_corrections(self): + """Testet Korrektur-Liste.""" + response = client.get("/api/corrections/") + assert response.status_code == 200 + data = response.json() + assert "corrections" in data + assert "total" in data + + +class TestLettersIntegration: + """E2E-Tests für Letters Frontend → API Integration.""" + + def test_create_letter_workflow(self): + """Testet kompletten Brief-Workflow.""" + # Step 1: Brief erstellen + create_response = client.post( + "/api/letters/", + json={ + "recipient_name": "Familie Testmann", + "recipient_address": "Teststr. 1", + "student_name": "Max Testmann", + "student_class": "7a", + "subject": "E2E-Testbrief", + "content": "Sehr geehrte Eltern, dies ist ein Testbrief.", + "letter_type": "general", + "tone": "professional", + "teacher_name": "Frau Test", + "teacher_title": "Klassenlehrerin" + } + ) + + assert create_response.status_code == 200 + letter_data = create_response.json() + letter_id = letter_data["id"] + assert letter_data["status"] == "draft" + + # Step 2: Brief abrufen + get_response = client.get(f"/api/letters/{letter_id}") + assert get_response.status_code == 200 + assert get_response.json()["student_name"] == "Max Testmann" + + # Step 3: Brief aktualisieren + update_response = client.put( + f"/api/letters/{letter_id}", + json={ + "content": "Sehr geehrte Eltern, dies ist ein aktualisierter Testbrief." + } + ) + assert update_response.status_code == 200 + + # Cleanup + client.delete(f"/api/letters/{letter_id}") + + def test_improve_letter_content(self): + """Testet GFK-Verbesserung.""" + response = client.post( + "/api/letters/improve", + json={ + "content": "Ihr Kind muss sich verbessern. Es ist immer zu spät.", + "communication_type": "general", + "tone": "professional" + } + ) + + assert response.status_code == 200 + data = response.json() + assert "improved_content" in data + assert "gfk_score" in data + assert "changes" in data + + def test_list_letter_types(self): + """Testet Abruf der Brieftypen.""" + response = client.get("/api/letters/types") + assert response.status_code == 200 + data = response.json() + assert "types" in data + assert len(data["types"]) >= 5 + + def test_list_letter_tones(self): + """Testet Abruf der Tonalitäten.""" + response = client.get("/api/letters/tones") + assert response.status_code == 200 + data = response.json() + assert "tones" in data + assert len(data["tones"]) >= 4 + + +class TestStateEngineIntegration: + """E2E-Tests für Companion Frontend → State Engine API Integration.""" + + def test_get_dashboard(self): + """Testet Dashboard-Abruf wie Companion-Mode.""" + response = client.get("/api/state/dashboard?teacher_id=e2e-test-teacher") + + assert response.status_code == 200 + data = response.json() + assert "context" in data + assert "suggestions" in data + assert "stats" in data + assert "progress" in data + assert "phases" in data + + def test_get_suggestions(self): + """Testet Vorschläge-Abruf.""" + response = client.get("/api/state/suggestions?teacher_id=e2e-test-teacher") + + assert response.status_code == 200 + data = response.json() + assert "suggestions" in data + assert "current_phase" in data + assert "priority_counts" in data + + def test_complete_milestone_workflow(self): + """Testet Meilenstein-Abschluss-Workflow.""" + teacher_id = "e2e-milestone-test" + + # Meilenstein abschließen + response = client.post( + f"/api/state/milestone?teacher_id={teacher_id}", + json={"milestone": "e2e_test_milestone"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is True + assert "e2e_test_milestone" in data["completed_milestones"] + + # Prüfen ob Meilenstein gespeichert + context_response = client.get(f"/api/state/context?teacher_id={teacher_id}") + assert context_response.status_code == 200 + context = context_response.json()["context"] + assert "e2e_test_milestone" in context["completed_milestones"] + + def test_phase_transition(self): + """Testet Phasenübergang.""" + teacher_id = "e2e-transition-test" + + # Erst alle Onboarding-Meilensteine abschließen + for milestone in ["school_select", "consent_accept", "profile_complete"]: + resp = client.post( + f"/api/state/milestone?teacher_id={teacher_id}", + json={"milestone": milestone} + ) + # Verify milestones are being accepted + assert resp.status_code == 200 + + # Transition zu school_year_start sollte möglich sein + response = client.post( + f"/api/state/transition?teacher_id={teacher_id}", + json={"target_phase": "school_year_start"} + ) + + # Accept both 200 (success) and 400 (if conditions not met due to test isolation) + # In production, milestones persist; in tests, context may be reset between requests + if response.status_code == 200: + data = response.json() + assert data["success"] is True + else: + # Document test environment limitation + assert response.status_code == 400 + # Verify it's a condition-related rejection, not a server error + data = response.json() + assert "detail" in data + + def test_invalid_phase_transition(self): + """Testet ungültigen Phasenübergang.""" + response = client.post( + "/api/state/transition?teacher_id=e2e-invalid-test", + json={"target_phase": "archived"} + ) + + # Sollte 400 zurückgeben da direkter Sprung zu archived nicht erlaubt + assert response.status_code == 400 + + +class TestCrossModuleIntegration: + """Tests für modul-übergreifende Funktionalität.""" + + def test_studio_html_renders(self): + """Testet dass Studio HTML alle Module enthält.""" + response = client.get("/studio") + + assert response.status_code == 200 + html = response.text + + # Prüfe ob alle Panel-IDs vorhanden + assert "panel-worksheets" in html + assert "panel-correction" in html + assert "panel-letters" in html + + def test_all_apis_accessible(self): + """Testet dass alle APIs erreichbar sind.""" + endpoints = [ + ("/api/worksheets/generate/multiple-choice", "POST"), + ("/api/corrections/", "GET"), + ("/api/letters/", "GET"), + ("/api/state/phases", "GET"), + ] + + for endpoint, method in endpoints: + if method == "GET": + response = client.get(endpoint) + else: + response = client.post(endpoint, json={ + "source_text": "Test", + "num_questions": 1 + }) + + # Sollte nicht 404 oder 500 sein + assert response.status_code in [200, 400, 422], f"{endpoint} returned {response.status_code}" + + +class TestExportFunctionality: + """Tests für Export-Funktionen aller Module.""" + + def test_corrections_pdf_export_endpoint_exists(self): + """Testet dass PDF-Export Endpoint existiert.""" + # Erst Korrektur erstellen + create_response = client.post( + "/api/corrections/", + json={ + "student_id": "pdf-test", + "student_name": "PDF Test", + "class_name": "10a", + "exam_title": "PDF-Test", + "subject": "Test", + "max_points": 100 + } + ) + correction_id = create_response.json()["correction"]["id"] + + # PDF-Export versuchen + response = client.get(f"/api/corrections/{correction_id}/export-pdf") + + # 500 ist ok wenn PDF-Service nicht verfügbar, aber Endpoint existiert + assert response.status_code in [200, 500] + + # Cleanup + client.delete(f"/api/corrections/{correction_id}") + + def test_letters_pdf_export_endpoint_exists(self): + """Testet dass Letters PDF-Export existiert.""" + response = client.post( + "/api/letters/export-pdf", + json={ + "letter_data": { + "recipient_name": "Test", + "recipient_address": "Test", + "student_name": "Test", + "student_class": "Test", + "subject": "Test", + "content": "Test", + "letter_type": "general", + "tone": "professional", + "teacher_name": "Test", + "teacher_title": "Test" + } + } + ) + + # 500 ist ok wenn PDF-Service nicht verfügbar + assert response.status_code in [200, 500] + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/backend/tests/test_gdpr_api.py b/backend/tests/test_gdpr_api.py new file mode 100644 index 0000000..8d47ec6 --- /dev/null +++ b/backend/tests/test_gdpr_api.py @@ -0,0 +1,287 @@ +""" +Tests für die GDPR API Endpoints +""" + +import pytest +from fastapi.testclient import TestClient +from unittest.mock import patch, AsyncMock, MagicMock +import sys +import os + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +class TestDataCategories: + """Tests für Datenkategorien-Endpoint""" + + def test_data_categories_structure(self): + """Test that data categories have correct structure""" + # Import the data categories from the GDPR API + try: + from gdpr_api import DATA_CATEGORIES + except ImportError: + pytest.skip("gdpr_api module not available") + + assert "essential" in DATA_CATEGORIES + assert "optional" in DATA_CATEGORIES + + # Check essential categories + for category in DATA_CATEGORIES["essential"]: + assert "name" in category + assert "description" in category + assert "retention_days" in category + assert "legal_basis" in category + + # Check optional categories + for category in DATA_CATEGORIES["optional"]: + assert "name" in category + assert "description" in category + assert "retention_days" in category + assert "cookie_category" in category + + def test_retention_days_values(self): + """Test that retention days are reasonable""" + try: + from gdpr_api import DATA_CATEGORIES + except ImportError: + pytest.skip("gdpr_api module not available") + + all_categories = DATA_CATEGORIES["essential"] + DATA_CATEGORIES["optional"] + + for category in all_categories: + retention = category.get("retention_days") + if retention is not None and isinstance(retention, int): + assert retention > 0, f"Retention for {category['name']} should be positive" + assert retention <= 3650, f"Retention for {category['name']} shouldn't exceed 10 years" + + +class TestGDPRCompliance: + """Tests für GDPR Compliance""" + + def test_gdpr_rights_covered(self): + """Test that all GDPR rights are addressable""" + gdpr_rights = { + "art_15": "Right of access", # Auskunftsrecht + "art_16": "Right to rectification", # Berichtigungsrecht + "art_17": "Right to erasure", # Löschungsrecht + "art_18": "Right to restriction", # Einschränkungsrecht + "art_20": "Right to portability", # Datenübertragbarkeit + "art_21": "Right to object", # Widerspruchsrecht + } + + # These should all be implementable via the consent service + for article, right in gdpr_rights.items(): + assert right is not None, f"GDPR {article} ({right}) should be covered" + + def test_mandatory_documents(self): + """Test that mandatory legal documents are defined""" + mandatory_docs = ["terms", "privacy"] + + for doc in mandatory_docs: + assert doc in mandatory_docs, f"Document {doc} should be mandatory" + + def test_cookie_categories_defined(self): + """Test that cookie categories follow GDPR requirements""" + expected_categories = ["necessary", "functional", "analytics", "marketing"] + + # Necessary cookies must be allowed without consent + assert "necessary" in expected_categories + + # Optional categories require consent + optional = [c for c in expected_categories if c != "necessary"] + assert len(optional) > 0 + + +class TestRetentionPolicies: + """Tests für Löschfristen""" + + def test_session_data_retention(self): + """Test that session data has short retention""" + session_retention_days = 1 # Expected: 24 hours max + assert session_retention_days <= 7, "Session data should be retained for max 7 days" + + def test_audit_log_retention(self): + """Test audit log retention complies with legal requirements""" + # Audit logs must be kept for compliance but not indefinitely + audit_retention_days = 1095 # 3 years + assert audit_retention_days >= 365, "Audit logs should be kept for at least 1 year" + assert audit_retention_days <= 3650, "Audit logs shouldn't be kept more than 10 years" + + def test_consent_record_retention(self): + """Test that consent records are kept long enough for proof""" + # § 7a UWG requires proof of consent for 3 years + consent_retention_days = 1095 + assert consent_retention_days >= 1095, "Consent records must be kept for at least 3 years" + + def test_ip_address_retention(self): + """Test IP address retention is minimized""" + ip_retention_days = 28 # 4 weeks + assert ip_retention_days <= 90, "IP addresses should not be stored longer than 90 days" + + +class TestDataMinimization: + """Tests für Datensparsamkeit""" + + def test_password_not_stored_plain(self): + """Test that passwords are never stored in plain text""" + # This is a design requirement test + assert True, "Passwords must be hashed with bcrypt" + + def test_unnecessary_data_not_collected(self): + """Test that only necessary data is collected""" + # User model should only contain necessary fields + required_fields = ["id", "email", "password_hash", "created_at"] + optional_fields = ["name", "role"] + + # No excessive personal data + forbidden_fields = ["ssn", "credit_card", "date_of_birth", "address"] + + for field in forbidden_fields: + assert field not in required_fields, f"Field {field} should not be required" + + +class TestAnonymization: + """Tests für Anonymisierung""" + + def test_ip_anonymization(self): + """Test IP address anonymization logic""" + def anonymize_ip(ip: str) -> str: + """Anonymize IPv4 by zeroing last octet""" + parts = ip.split(".") + if len(parts) == 4: + parts[3] = "0" + return ".".join(parts) + return ip + + test_cases = [ + ("192.168.1.100", "192.168.1.0"), + ("10.0.0.1", "10.0.0.0"), + ("172.16.255.255", "172.16.255.0"), + ] + + for original, expected in test_cases: + assert anonymize_ip(original) == expected + + def test_user_data_anonymization(self): + """Test user data anonymization for deleted accounts""" + def anonymize_user_data(user_data: dict) -> dict: + """Anonymize user data while keeping audit trail""" + anonymized = user_data.copy() + anonymized["email"] = f"deleted-{user_data['id']}@anonymized.local" + anonymized["name"] = None + anonymized["password_hash"] = None + return anonymized + + original = { + "id": "123", + "email": "real@example.com", + "name": "John Doe", + "password_hash": "bcrypt_hash" + } + + anonymized = anonymize_user_data(original) + + assert "@anonymized.local" in anonymized["email"] + assert anonymized["name"] is None + assert anonymized["password_hash"] is None + assert anonymized["id"] == original["id"] # ID preserved for audit + + +class TestExportFormat: + """Tests für Datenexport-Format""" + + def test_export_includes_all_user_data(self): + """Test that export includes all required data sections""" + required_sections = [ + "user", # Personal data + "consents", # Consent history + "cookie_consents", # Cookie preferences + "audit_log", # Activity log + "exported_at", # Export timestamp + ] + + # Mock export response + mock_export = { + "user": {}, + "consents": [], + "cookie_consents": [], + "audit_log": [], + "exported_at": "2024-01-01T00:00:00Z" + } + + for section in required_sections: + assert section in mock_export, f"Export must include {section}" + + def test_export_is_machine_readable(self): + """Test that export can be provided in machine-readable format""" + import json + + mock_data = { + "user": {"email": "test@example.com"}, + "exported_at": "2024-01-01T00:00:00Z" + } + + # Should be valid JSON + json_str = json.dumps(mock_data) + parsed = json.loads(json_str) + + assert parsed == mock_data + + +class TestConsentValidation: + """Tests für Consent-Validierung""" + + def test_consent_requires_version_id(self): + """Test that consent requires a specific document version""" + consent_request = { + "document_type": "terms", + "version_id": "version-123", + "consented": True + } + + assert "version_id" in consent_request + assert consent_request["version_id"] is not None + + def test_consent_tracks_ip_and_timestamp(self): + """Test that consent tracks IP and timestamp for proof""" + consent_record = { + "user_id": "user-123", + "document_version_id": "version-123", + "consented": True, + "ip_address": "192.168.1.1", + "consented_at": "2024-01-01T00:00:00Z" + } + + assert "ip_address" in consent_record + assert "consented_at" in consent_record + + def test_withdrawal_is_possible(self): + """Test that consent can be withdrawn (Art. 7(3) GDPR)""" + # Withdrawal should be as easy as giving consent + withdraw_request = { + "consent_id": "consent-123" + } + + assert "consent_id" in withdraw_request + + +class TestSecurityHeaders: + """Tests für Sicherheits-Header""" + + def test_required_security_headers(self): + """Test that API responses include security headers""" + required_headers = [ + "X-Content-Type-Options", # nosniff + "X-Frame-Options", # DENY or SAMEORIGIN + "Content-Security-Policy", # CSP + "X-XSS-Protection", # Legacy but useful + ] + + # These should be set by the application + for header in required_headers: + assert header is not None, f"Header {header} should be set" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/backend/tests/test_gdpr_ui.py b/backend/tests/test_gdpr_ui.py new file mode 100644 index 0000000..a2df76a --- /dev/null +++ b/backend/tests/test_gdpr_ui.py @@ -0,0 +1,252 @@ +""" +Tests fuer die GDPR UI-Funktionalitaet (Art. 15-21). + +Testet: +- GDPR-Rechte im Legal Modal (Art. 15-21) +- JavaScript-Funktionen fuer GDPR-Anfragen +- Consent Manager Integration +""" + +import pytest +import re +from pathlib import Path + + +class TestGDPRUIStructure: + """Tests fuer GDPR UI-Struktur im Legal Modal.""" + + @pytest.fixture + def studio_html(self): + """Laedt studio.html fuer Tests.""" + html_path = Path(__file__).parent.parent / "frontend" / "templates" / "studio.html" + if html_path.exists(): + return html_path.read_text() + return None + + def test_gdpr_section_exists(self, studio_html): + """Test: GDPR-Bereich existiert im Legal Modal.""" + if studio_html is None: + pytest.skip("studio.html nicht gefunden") + + assert 'id="legal-gdpr"' in studio_html, "GDPR-Bereich sollte im Legal Modal existieren" + + def test_gdpr_section_has_title(self, studio_html): + """Test: GDPR-Bereich hat Titel mit Art. 15-21 Referenz.""" + if studio_html is None: + pytest.skip("studio.html nicht gefunden") + + assert "Art. 15-21" in studio_html, "GDPR-Bereich sollte Art. 15-21 im Titel erwaehnen" + + def test_art15_auskunftsrecht_exists(self, studio_html): + """Test: Art. 15 (Auskunftsrecht) ist vorhanden.""" + if studio_html is None: + pytest.skip("studio.html nicht gefunden") + + assert "Art. 15" in studio_html, "Art. 15 (Auskunftsrecht) sollte vorhanden sein" + assert "Auskunft" in studio_html, "Auskunftsrecht-Button sollte vorhanden sein" + + def test_art16_berichtigung_exists(self, studio_html): + """Test: Art. 16 (Berichtigung) ist vorhanden.""" + if studio_html is None: + pytest.skip("studio.html nicht gefunden") + + assert "Art. 16" in studio_html, "Art. 16 (Berichtigung) sollte vorhanden sein" + assert "Berichtigung" in studio_html, "Berichtigung-Button sollte vorhanden sein" + + def test_art17_loeschung_exists(self, studio_html): + """Test: Art. 17 (Loeschung) ist vorhanden.""" + if studio_html is None: + pytest.skip("studio.html nicht gefunden") + + assert "Art. 17" in studio_html, "Art. 17 (Loeschung) sollte vorhanden sein" + assert "Löschung" in studio_html, "Loeschung-Button sollte vorhanden sein" + + def test_art18_einschraenkung_exists(self, studio_html): + """Test: Art. 18 (Einschraenkung der Verarbeitung) ist vorhanden.""" + if studio_html is None: + pytest.skip("studio.html nicht gefunden") + + assert "Art. 18" in studio_html, "Art. 18 (Einschraenkung) sollte vorhanden sein" + assert "Einschränkung" in studio_html, "Einschraenkung-Button sollte vorhanden sein" + + def test_art19_mitteilungspflicht_exists(self, studio_html): + """Test: Art. 19 (Mitteilungspflicht) ist vorhanden.""" + if studio_html is None: + pytest.skip("studio.html nicht gefunden") + + assert "Art. 19" in studio_html, "Art. 19 (Mitteilungspflicht) sollte vorhanden sein" + assert "Mitteilung" in studio_html, "Mitteilungspflicht sollte erwaehnt werden" + + def test_art20_datenuebertragbarkeit_exists(self, studio_html): + """Test: Art. 20 (Datenuebertragbarkeit) ist vorhanden.""" + if studio_html is None: + pytest.skip("studio.html nicht gefunden") + + assert "Art. 20" in studio_html, "Art. 20 (Datenuebertragbarkeit) sollte vorhanden sein" + assert "Datenübertragbarkeit" in studio_html or "exportieren" in studio_html.lower(), \ + "Datenexport-Button sollte vorhanden sein" + + def test_art21_widerspruch_exists(self, studio_html): + """Test: Art. 21 (Widerspruchsrecht) ist vorhanden.""" + if studio_html is None: + pytest.skip("studio.html nicht gefunden") + + assert "Art. 21" in studio_html, "Art. 21 (Widerspruchsrecht) sollte vorhanden sein" + assert "Widerspruch" in studio_html, "Widerspruch-Button sollte vorhanden sein" + + def test_consent_manager_section_exists(self, studio_html): + """Test: Consent Manager Bereich existiert.""" + if studio_html is None: + pytest.skip("studio.html nicht gefunden") + + assert "Einwilligungen verwalten" in studio_html, \ + "Consent Manager Bereich sollte vorhanden sein" + + +class TestGDPRJavaScriptFunctions: + """Tests fuer GDPR JavaScript-Funktionen.""" + + @pytest.fixture + def studio_js(self): + """Laedt studio.js fuer Tests.""" + js_path = Path(__file__).parent.parent / "frontend" / "static" / "js" / "studio.js" + if js_path.exists(): + return js_path.read_text() + return None + + def test_request_data_export_function_exists(self, studio_js): + """Test: requestDataExport Funktion existiert.""" + if studio_js is None: + pytest.skip("studio.js nicht gefunden") + + assert "function requestDataExport" in studio_js or "async function requestDataExport" in studio_js, \ + "requestDataExport Funktion sollte existieren" + + def test_request_data_correction_function_exists(self, studio_js): + """Test: requestDataCorrection Funktion existiert.""" + if studio_js is None: + pytest.skip("studio.js nicht gefunden") + + assert "function requestDataCorrection" in studio_js or "async function requestDataCorrection" in studio_js, \ + "requestDataCorrection Funktion (Art. 16) sollte existieren" + + def test_request_data_deletion_function_exists(self, studio_js): + """Test: requestDataDeletion Funktion existiert.""" + if studio_js is None: + pytest.skip("studio.js nicht gefunden") + + assert "function requestDataDeletion" in studio_js or "async function requestDataDeletion" in studio_js, \ + "requestDataDeletion Funktion (Art. 17) sollte existieren" + + def test_request_processing_restriction_function_exists(self, studio_js): + """Test: requestProcessingRestriction Funktion existiert.""" + if studio_js is None: + pytest.skip("studio.js nicht gefunden") + + assert "function requestProcessingRestriction" in studio_js or \ + "async function requestProcessingRestriction" in studio_js, \ + "requestProcessingRestriction Funktion (Art. 18) sollte existieren" + + def test_request_data_download_function_exists(self, studio_js): + """Test: requestDataDownload Funktion existiert.""" + if studio_js is None: + pytest.skip("studio.js nicht gefunden") + + assert "function requestDataDownload" in studio_js or "async function requestDataDownload" in studio_js, \ + "requestDataDownload Funktion (Art. 20) sollte existieren" + + def test_request_processing_objection_function_exists(self, studio_js): + """Test: requestProcessingObjection Funktion existiert.""" + if studio_js is None: + pytest.skip("studio.js nicht gefunden") + + assert "function requestProcessingObjection" in studio_js or \ + "async function requestProcessingObjection" in studio_js, \ + "requestProcessingObjection Funktion (Art. 21) sollte existieren" + + def test_show_consent_manager_function_exists(self, studio_js): + """Test: showConsentManager Funktion existiert.""" + if studio_js is None: + pytest.skip("studio.js nicht gefunden") + + assert "function showConsentManager" in studio_js, \ + "showConsentManager Funktion sollte existieren" + + def test_open_settings_modal_function_exists(self, studio_js): + """Test: openSettingsModal Funktion existiert.""" + if studio_js is None: + pytest.skip("studio.js nicht gefunden") + + assert "function openSettingsModal" in studio_js, \ + "openSettingsModal Funktion sollte existieren" + + def test_open_legal_modal_function_exists(self, studio_js): + """Test: openLegalModal Funktion existiert.""" + if studio_js is None: + pytest.skip("studio.js nicht gefunden") + + assert "function openLegalModal" in studio_js, \ + "openLegalModal Funktion sollte existieren" + + def test_gdpr_functions_have_user_feedback(self, studio_js): + """Test: GDPR-Funktionen geben Benutzer-Feedback (alert/confirm).""" + if studio_js is None: + pytest.skip("studio.js nicht gefunden") + + # Suche nach GDPR Functions Block + gdpr_match = re.search(r'// GDPR FUNCTIONS.*?// Load saved cookie', studio_js, re.DOTALL) + if gdpr_match: + gdpr_content = gdpr_match.group(0) + assert "alert(" in gdpr_content or "confirm(" in gdpr_content, \ + "GDPR-Funktionen sollten Benutzer-Feedback geben" + + def test_deletion_requires_confirmation(self, studio_js): + """Test: Loeschung erfordert Bestaetigung.""" + if studio_js is None: + pytest.skip("studio.js nicht gefunden") + + # Suche nach requestDataDeletion Funktion + deletion_match = re.search(r'function requestDataDeletion.*?\}', studio_js, re.DOTALL) + if deletion_match: + deletion_content = deletion_match.group(0) + assert "confirm(" in deletion_content, \ + "Datenlöschung sollte Bestaetigung erfordern" + + +class TestGDPRActions: + """Tests fuer GDPR-Action Buttons im HTML.""" + + @pytest.fixture + def studio_html(self): + """Laedt studio.html fuer Tests.""" + html_path = Path(__file__).parent.parent / "frontend" / "templates" / "studio.html" + if html_path.exists(): + return html_path.read_text() + return None + + def test_gdpr_actions_container_exists(self, studio_html): + """Test: GDPR-Actions Container existiert.""" + if studio_html is None: + pytest.skip("studio.html nicht gefunden") + + assert 'class="gdpr-actions"' in studio_html, "GDPR-Actions Container sollte existieren" + + def test_gdpr_action_items_exist(self, studio_html): + """Test: Mindestens 6 GDPR-Action Items existieren (Art. 15-21, ohne Art. 19 als Button).""" + if studio_html is None: + pytest.skip("studio.html nicht gefunden") + + gdpr_action_count = studio_html.count('class="gdpr-action"') + assert gdpr_action_count >= 6, \ + f"Mindestens 6 GDPR-Action Items sollten existieren, gefunden: {gdpr_action_count}" + + def test_deletion_button_has_danger_class(self, studio_html): + """Test: Loeschung-Button hat btn-danger Klasse.""" + if studio_html is None: + pytest.skip("studio.html nicht gefunden") + + # Suche nach Loeschung-Button + assert 'onclick="requestDataDeletion()"' in studio_html, "Loeschung-Button sollte existieren" + # Der Button sollte btn-danger haben + assert 'btn-danger' in studio_html and 'requestDataDeletion' in studio_html, \ + "Loeschung-Button sollte btn-danger Klasse haben" diff --git a/backend/tests/test_infra/__init__.py b/backend/tests/test_infra/__init__.py new file mode 100644 index 0000000..8ee2b5c --- /dev/null +++ b/backend/tests/test_infra/__init__.py @@ -0,0 +1 @@ +"""Tests for infrastructure management module.""" diff --git a/backend/tests/test_infra/test_vast_client.py b/backend/tests/test_infra/test_vast_client.py new file mode 100644 index 0000000..a8e2470 --- /dev/null +++ b/backend/tests/test_infra/test_vast_client.py @@ -0,0 +1,547 @@ +""" +Tests fuer VastAIClient. + +Testet den vast.ai REST API Client. +""" + +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from datetime import datetime, timezone + +from infra.vast_client import ( + VastAIClient, + InstanceInfo, + InstanceStatus, + AccountInfo, +) + + +class TestInstanceStatus: + """Tests fuer InstanceStatus Enum.""" + + def test_status_values(self): + """Test alle Status-Werte.""" + assert InstanceStatus.RUNNING.value == "running" + assert InstanceStatus.STOPPED.value == "stopped" + assert InstanceStatus.EXITED.value == "exited" + assert InstanceStatus.LOADING.value == "loading" + assert InstanceStatus.SCHEDULING.value == "scheduling" + assert InstanceStatus.CREATING.value == "creating" + assert InstanceStatus.UNKNOWN.value == "unknown" + + +class TestInstanceInfo: + """Tests fuer InstanceInfo Dataclass.""" + + def test_create_basic(self): + """Test grundlegende Erstellung.""" + info = InstanceInfo( + id=12345, + status=InstanceStatus.RUNNING, + ) + assert info.id == 12345 + assert info.status == InstanceStatus.RUNNING + assert info.gpu_name is None + assert info.num_gpus == 1 + + def test_from_api_response_running(self): + """Test Parse von API Response (running).""" + api_data = { + "id": 67890, + "actual_status": "running", + "machine_id": 111, + "gpu_name": "RTX 3090", + "num_gpus": 1, + "gpu_ram": 24.0, + "cpu_ram": 64.0, + "disk_space": 200.0, + "dph_total": 0.45, + "public_ipaddr": "192.168.1.100", + "ports": { + "8001/tcp": [{"HostIp": "0.0.0.0", "HostPort": "12345"}], + }, + "label": "llm-server", + "start_date": 1702900800, # Unix timestamp + } + + info = InstanceInfo.from_api_response(api_data) + + assert info.id == 67890 + assert info.status == InstanceStatus.RUNNING + assert info.gpu_name == "RTX 3090" + assert info.dph_total == 0.45 + assert info.public_ipaddr == "192.168.1.100" + assert info.label == "llm-server" + + def test_from_api_response_stopped(self): + """Test Parse von API Response (gestoppt).""" + api_data = { + "id": 11111, + "actual_status": "exited", + } + + info = InstanceInfo.from_api_response(api_data) + + assert info.status == InstanceStatus.EXITED + + def test_from_api_response_unknown_status(self): + """Test Parse von unbekanntem Status.""" + api_data = { + "id": 22222, + "actual_status": "weird_status", + } + + info = InstanceInfo.from_api_response(api_data) + + assert info.status == InstanceStatus.UNKNOWN + + def test_get_endpoint_url_with_port_mapping(self): + """Test Endpoint URL mit Port-Mapping.""" + info = InstanceInfo( + id=12345, + status=InstanceStatus.RUNNING, + public_ipaddr="10.0.0.1", + ports={ + "8001/tcp": [{"HostIp": "0.0.0.0", "HostPort": "54321"}], + }, + ) + + url = info.get_endpoint_url(8001) + assert url == "http://10.0.0.1:54321" + + def test_get_endpoint_url_fallback(self): + """Test Endpoint URL Fallback ohne Port-Mapping.""" + info = InstanceInfo( + id=12345, + status=InstanceStatus.RUNNING, + public_ipaddr="10.0.0.2", + ports={}, + ) + + url = info.get_endpoint_url(8001) + assert url == "http://10.0.0.2:8001" + + def test_get_endpoint_url_no_ip(self): + """Test Endpoint URL ohne IP.""" + info = InstanceInfo( + id=12345, + status=InstanceStatus.RUNNING, + public_ipaddr=None, + ) + + url = info.get_endpoint_url(8001) + assert url is None + + def test_to_dict(self): + """Test Serialisierung.""" + info = InstanceInfo( + id=12345, + status=InstanceStatus.RUNNING, + gpu_name="RTX 4090", + dph_total=0.75, + ) + + data = info.to_dict() + + assert data["id"] == 12345 + assert data["status"] == "running" + assert data["gpu_name"] == "RTX 4090" + assert data["dph_total"] == 0.75 + + +class TestAccountInfo: + """Tests fuer AccountInfo Dataclass.""" + + def test_create_basic(self): + """Test grundlegende Erstellung.""" + info = AccountInfo( + credit=25.50, + balance=0.0, + total_spend=10.25, + username="testuser", + email="test@example.com", + has_billing=True, + ) + assert info.credit == 25.50 + assert info.balance == 0.0 + assert info.total_spend == 10.25 + assert info.username == "testuser" + assert info.email == "test@example.com" + assert info.has_billing is True + + def test_from_api_response_complete(self): + """Test Parse von vollstaendiger API Response.""" + api_data = { + "credit": 23.87674017153, + "balance": 0.0, + "total_spend": -1.1732598284700013, # API gibt negativ zurueck + "username": "benjamin", + "email": "benjamin@example.com", + "has_billing": True, + } + + info = AccountInfo.from_api_response(api_data) + + assert info.credit == 23.87674017153 + assert info.balance == 0.0 + assert info.total_spend == 1.1732598284700013 # Sollte positiv sein (abs) + assert info.username == "benjamin" + assert info.email == "benjamin@example.com" + assert info.has_billing is True + + def test_from_api_response_minimal(self): + """Test Parse von minimaler API Response.""" + api_data = {} + + info = AccountInfo.from_api_response(api_data) + + assert info.credit == 0.0 + assert info.balance == 0.0 + assert info.total_spend == 0.0 + assert info.username == "" + assert info.email == "" + assert info.has_billing is False + + def test_from_api_response_partial(self): + """Test Parse von teilweiser API Response.""" + api_data = { + "credit": 50.0, + "username": "partial_user", + } + + info = AccountInfo.from_api_response(api_data) + + assert info.credit == 50.0 + assert info.username == "partial_user" + assert info.email == "" + assert info.total_spend == 0.0 + + def test_to_dict(self): + """Test Serialisierung zu Dictionary.""" + info = AccountInfo( + credit=100.0, + balance=5.0, + total_spend=25.0, + username="dictuser", + email="dict@test.com", + has_billing=True, + ) + + data = info.to_dict() + + assert data["credit"] == 100.0 + assert data["balance"] == 5.0 + assert data["total_spend"] == 25.0 + assert data["username"] == "dictuser" + assert data["email"] == "dict@test.com" + assert data["has_billing"] is True + + def test_total_spend_negative_to_positive(self): + """Test dass negative total_spend Werte positiv werden.""" + api_data = { + "total_spend": -99.99, + } + + info = AccountInfo.from_api_response(api_data) + + assert info.total_spend == 99.99 + + +class TestVastAIClient: + """Tests fuer VastAIClient.""" + + def test_init(self): + """Test Client Initialisierung.""" + client = VastAIClient(api_key="test-key", timeout=60.0) + + assert client.api_key == "test-key" + assert client.timeout == 60.0 + assert client._client is None + + def test_build_url(self): + """Test URL Building.""" + client = VastAIClient(api_key="my-api-key") + + url = client._build_url("/instances/") + assert url == "https://console.vast.ai/api/v0/instances/?api_key=my-api-key" + + url2 = client._build_url("/instances/?foo=bar") + assert url2 == "https://console.vast.ai/api/v0/instances/?foo=bar&api_key=my-api-key" + + @pytest.mark.asyncio + async def test_list_instances(self): + """Test Liste aller Instanzen.""" + client = VastAIClient(api_key="test-key") + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "instances": [ + {"id": 1, "actual_status": "running", "gpu_name": "RTX 3090"}, + {"id": 2, "actual_status": "exited"}, # exited statt stopped + ] + } + mock_response.raise_for_status = MagicMock() + + with patch.object(client, "_get_client") as mock_get: + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_get.return_value = mock_client + + instances = await client.list_instances() + + assert len(instances) == 2 + assert instances[0].id == 1 + assert instances[0].status == InstanceStatus.RUNNING + assert instances[1].id == 2 + assert instances[1].status == InstanceStatus.EXITED + + @pytest.mark.asyncio + async def test_get_instance(self): + """Test einzelne Instanz abrufen.""" + client = VastAIClient(api_key="test-key") + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "instances": [ + {"id": 12345, "actual_status": "running", "dph_total": 0.5} + ] + } + mock_response.raise_for_status = MagicMock() + + with patch.object(client, "_get_client") as mock_get: + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_get.return_value = mock_client + + instance = await client.get_instance(12345) + + assert instance is not None + assert instance.id == 12345 + assert instance.status == InstanceStatus.RUNNING + assert instance.dph_total == 0.5 + + @pytest.mark.asyncio + async def test_get_instance_not_found(self): + """Test Instanz nicht gefunden.""" + import httpx + + client = VastAIClient(api_key="test-key") + + mock_response = MagicMock() + mock_response.status_code = 404 + + with patch.object(client, "_get_client") as mock_get: + mock_client = AsyncMock() + mock_client.get.side_effect = httpx.HTTPStatusError( + "Not Found", request=MagicMock(), response=mock_response + ) + mock_get.return_value = mock_client + + instance = await client.get_instance(99999) + + assert instance is None + + @pytest.mark.asyncio + async def test_start_instance(self): + """Test Instanz starten.""" + client = VastAIClient(api_key="test-key") + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status = MagicMock() + + with patch.object(client, "_get_client") as mock_get: + mock_client = AsyncMock() + mock_client.put.return_value = mock_response + mock_get.return_value = mock_client + + success = await client.start_instance(12345) + + assert success is True + mock_client.put.assert_called_once() + call_args = mock_client.put.call_args + assert call_args[1]["json"] == {"state": "running"} + + @pytest.mark.asyncio + async def test_stop_instance(self): + """Test Instanz stoppen.""" + client = VastAIClient(api_key="test-key") + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status = MagicMock() + + with patch.object(client, "_get_client") as mock_get: + mock_client = AsyncMock() + mock_client.put.return_value = mock_response + mock_get.return_value = mock_client + + success = await client.stop_instance(12345) + + assert success is True + call_args = mock_client.put.call_args + assert call_args[1]["json"] == {"state": "stopped"} + + @pytest.mark.asyncio + async def test_stop_instance_failure(self): + """Test Instanz stoppen fehlschlaegt.""" + import httpx + + client = VastAIClient(api_key="test-key") + + mock_response = MagicMock() + mock_response.status_code = 500 + + with patch.object(client, "_get_client") as mock_get: + mock_client = AsyncMock() + mock_client.put.side_effect = httpx.HTTPStatusError( + "Error", request=MagicMock(), response=mock_response + ) + mock_get.return_value = mock_client + + success = await client.stop_instance(12345) + + assert success is False + + @pytest.mark.asyncio + async def test_destroy_instance(self): + """Test Instanz loeschen.""" + client = VastAIClient(api_key="test-key") + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status = MagicMock() + + with patch.object(client, "_get_client") as mock_get: + mock_client = AsyncMock() + mock_client.delete.return_value = mock_response + mock_get.return_value = mock_client + + success = await client.destroy_instance(12345) + + assert success is True + mock_client.delete.assert_called_once() + + @pytest.mark.asyncio + async def test_set_label(self): + """Test Label setzen.""" + client = VastAIClient(api_key="test-key") + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.raise_for_status = MagicMock() + + with patch.object(client, "_get_client") as mock_get: + mock_client = AsyncMock() + mock_client.put.return_value = mock_response + mock_get.return_value = mock_client + + success = await client.set_label(12345, "my-label") + + assert success is True + call_args = mock_client.put.call_args + assert call_args[1]["json"] == {"label": "my-label"} + + @pytest.mark.asyncio + async def test_close(self): + """Test Client schliessen.""" + client = VastAIClient(api_key="test-key") + + # Erstelle Client + await client._get_client() + assert client._client is not None + + # Schliesse + await client.close() + assert client._client is None + + @pytest.mark.asyncio + async def test_get_account_info_success(self): + """Test Account-Info erfolgreich abrufen.""" + client = VastAIClient(api_key="test-key") + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "credit": 23.87674017153, + "balance": 0.0, + "total_spend": -1.1732598284700013, + "username": "testuser", + "email": "test@vast.ai", + "has_billing": True, + } + mock_response.raise_for_status = MagicMock() + + with patch.object(client, "_get_client") as mock_get: + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_get.return_value = mock_client + + account = await client.get_account_info() + + assert account is not None + assert account.credit == 23.87674017153 + assert account.total_spend == 1.1732598284700013 # abs() + assert account.username == "testuser" + assert account.email == "test@vast.ai" + assert account.has_billing is True + + @pytest.mark.asyncio + async def test_get_account_info_api_error(self): + """Test Account-Info bei API-Fehler.""" + import httpx + + client = VastAIClient(api_key="test-key") + + mock_response = MagicMock() + mock_response.status_code = 401 + + with patch.object(client, "_get_client") as mock_get: + mock_client = AsyncMock() + mock_client.get.side_effect = httpx.HTTPStatusError( + "Unauthorized", request=MagicMock(), response=mock_response + ) + mock_get.return_value = mock_client + + account = await client.get_account_info() + + assert account is None + + @pytest.mark.asyncio + async def test_get_account_info_network_error(self): + """Test Account-Info bei Netzwerk-Fehler.""" + client = VastAIClient(api_key="test-key") + + with patch.object(client, "_get_client") as mock_get: + mock_client = AsyncMock() + mock_client.get.side_effect = Exception("Network error") + mock_get.return_value = mock_client + + account = await client.get_account_info() + + assert account is None + + @pytest.mark.asyncio + async def test_get_account_info_url(self): + """Test dass get_account_info den korrekten Endpoint aufruft.""" + client = VastAIClient(api_key="my-test-key") + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"credit": 10.0} + mock_response.raise_for_status = MagicMock() + + with patch.object(client, "_get_client") as mock_get: + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_get.return_value = mock_client + + await client.get_account_info() + + # Pruefe dass /users/current/ aufgerufen wurde + call_args = mock_client.get.call_args + called_url = call_args[0][0] + assert "/users/current/" in called_url + assert "api_key=my-test-key" in called_url diff --git a/backend/tests/test_infra/test_vast_power.py b/backend/tests/test_infra/test_vast_power.py new file mode 100644 index 0000000..dca68f7 --- /dev/null +++ b/backend/tests/test_infra/test_vast_power.py @@ -0,0 +1,510 @@ +""" +Tests fuer vast.ai Power Control API. + +Testet die FastAPI Endpoints fuer Start/Stop/Status. +""" + +import json +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from datetime import datetime, timezone +from pathlib import Path +import tempfile +import os + +# Setze ENV vor jedem Import +os.environ["VAST_API_KEY"] = "test-api-key" +os.environ["VAST_INSTANCE_ID"] = "12345" +os.environ["CONTROL_API_KEY"] = "test-control-key" + +from fastapi.testclient import TestClient +from fastapi import FastAPI + + +class TestVastState: + """Tests fuer VastState Klasse.""" + + def test_load_empty_state(self): + """Test leerer State wird erstellt.""" + with tempfile.TemporaryDirectory() as tmpdir: + state_path = Path(tmpdir) / "state.json" + os.environ["VAST_STATE_PATH"] = str(state_path) + + # Importiere nach ENV-Setup + from infra.vast_power import VastState + + state = VastState(path=state_path) + + assert state.get("desired_state") is None + assert state.get("total_runtime_seconds") == 0 + + def test_set_and_get(self): + """Test Wert setzen und lesen.""" + with tempfile.TemporaryDirectory() as tmpdir: + state_path = Path(tmpdir) / "state.json" + + from infra.vast_power import VastState + + state = VastState(path=state_path) + state.set("desired_state", "RUNNING") + + assert state.get("desired_state") == "RUNNING" + assert state_path.exists() + + def test_record_activity(self): + """Test Aktivitaet aufzeichnen.""" + with tempfile.TemporaryDirectory() as tmpdir: + state_path = Path(tmpdir) / "state.json" + + from infra.vast_power import VastState + + state = VastState(path=state_path) + state.record_activity() + + last = state.get_last_activity() + assert last is not None + assert isinstance(last, datetime) + + def test_record_start_stop_calculates_cost(self): + """Test Start/Stop berechnet Kosten.""" + with tempfile.TemporaryDirectory() as tmpdir: + state_path = Path(tmpdir) / "state.json" + + from infra.vast_power import VastState + + state = VastState(path=state_path) + + # Simuliere Start + state.record_start() + assert state.get("desired_state") == "RUNNING" + + # Simuliere Stop mit Kosten ($0.50/h) + state.record_stop(dph_total=0.5) + assert state.get("desired_state") == "STOPPED" + assert state.get("total_runtime_seconds") > 0 + + +class TestAuditLog: + """Tests fuer Audit Logging.""" + + def test_audit_log_writes(self): + """Test Audit Log schreibt Eintraege.""" + with tempfile.TemporaryDirectory() as tmpdir: + audit_path = Path(tmpdir) / "audit.log" + + # Importiere und patche AUDIT_PATH direkt + import infra.vast_power as vp + original_path = vp.AUDIT_PATH + vp.AUDIT_PATH = audit_path + + try: + vp.audit_log("test_event", actor="test_user", meta={"key": "value"}) + + assert audit_path.exists() + content = audit_path.read_text() + entry = json.loads(content.strip()) + + assert entry["event"] == "test_event" + assert entry["actor"] == "test_user" + assert entry["meta"]["key"] == "value" + finally: + vp.AUDIT_PATH = original_path + + +class TestPowerEndpointsAuth: + """Tests fuer Authentifizierung der Power Endpoints.""" + + def test_require_control_key_no_key_configured(self): + """Test Fehler wenn CONTROL_API_KEY nicht gesetzt.""" + import infra.vast_power as vp + from fastapi import HTTPException + + # Temporaer CONTROL_API_KEY leeren + original = vp.CONTROL_API_KEY + vp.CONTROL_API_KEY = None + + try: + with pytest.raises(HTTPException) as exc_info: + vp.require_control_key("any-key") + assert exc_info.value.status_code == 500 + assert "not configured" in str(exc_info.value.detail) + finally: + vp.CONTROL_API_KEY = original + + def test_require_control_key_wrong_key(self): + """Test 401 bei falschem Key.""" + import infra.vast_power as vp + from fastapi import HTTPException + + # Setze gueltigen CONTROL_API_KEY + original = vp.CONTROL_API_KEY + vp.CONTROL_API_KEY = "correct-key" + + try: + with pytest.raises(HTTPException) as exc_info: + vp.require_control_key("wrong-key") + assert exc_info.value.status_code == 401 + finally: + vp.CONTROL_API_KEY = original + + def test_require_control_key_valid(self): + """Test kein Fehler bei korrektem Key.""" + import infra.vast_power as vp + + # Setze gueltigen CONTROL_API_KEY + original = vp.CONTROL_API_KEY + vp.CONTROL_API_KEY = "my-secret-key" + + try: + # Sollte keine Exception werfen + result = vp.require_control_key("my-secret-key") + assert result is None # Dependency gibt nichts zurueck + finally: + vp.CONTROL_API_KEY = original + + def test_require_control_key_none_provided(self): + """Test 401 wenn kein Key im Header.""" + import infra.vast_power as vp + from fastapi import HTTPException + + original = vp.CONTROL_API_KEY + vp.CONTROL_API_KEY = "valid-key" + + try: + with pytest.raises(HTTPException) as exc_info: + vp.require_control_key(None) + assert exc_info.value.status_code == 401 + finally: + vp.CONTROL_API_KEY = original + + +class TestStatusEndpoint: + """Tests fuer den Status Endpoint.""" + + def test_status_response_model(self): + """Test VastStatusResponse Model Validierung.""" + from infra.vast_power import VastStatusResponse + + # Unconfigured response + resp = VastStatusResponse(status="unconfigured", message="Not configured") + assert resp.status == "unconfigured" + assert resp.instance_id is None + + # Running response + resp = VastStatusResponse( + instance_id=12345, + status="running", + gpu_name="RTX 3090", + dph_total=0.45, + endpoint_base_url="http://10.0.0.1:8001", + auto_shutdown_in_minutes=25, + ) + assert resp.instance_id == 12345 + assert resp.status == "running" + assert resp.gpu_name == "RTX 3090" + + def test_status_returns_instance_info(self): + """Test Status gibt korrektes Modell zurueck.""" + from infra.vast_client import InstanceInfo, InstanceStatus + from infra.vast_power import VastStatusResponse + + # Simuliere was der Endpoint zurueckgibt + mock_instance = InstanceInfo( + id=12345, + status=InstanceStatus.RUNNING, + gpu_name="RTX 3090", + dph_total=0.45, + public_ipaddr="10.0.0.1", + ) + + # Baue Response wie der Endpoint es tun wuerde + endpoint = mock_instance.get_endpoint_url(8001) + response = VastStatusResponse( + instance_id=mock_instance.id, + status=mock_instance.status.value, + gpu_name=mock_instance.gpu_name, + dph_total=mock_instance.dph_total, + endpoint_base_url=endpoint, + ) + + assert response.instance_id == 12345 + assert response.status == "running" + assert response.gpu_name == "RTX 3090" + assert response.dph_total == 0.45 + + +class TestActivityEndpoint: + """Tests fuer den Activity Endpoint.""" + + def test_record_activity_updates_state(self): + """Test Activity wird im State aufgezeichnet.""" + with tempfile.TemporaryDirectory() as tmpdir: + from infra.vast_power import VastState + + state_path = Path(tmpdir) / "state.json" + state = VastState(path=state_path) + + # Keine Aktivitaet vorher + assert state.get_last_activity() is None + + # Aktivitaet aufzeichnen + state.record_activity() + + # Jetzt sollte Aktivitaet vorhanden sein + last = state.get_last_activity() + assert last is not None + assert isinstance(last, datetime) + + +class TestCostsEndpoint: + """Tests fuer den Costs Endpoint.""" + + def test_costs_response_model(self): + """Test CostStatsResponse Model.""" + from infra.vast_power import CostStatsResponse + + resp = CostStatsResponse( + total_runtime_hours=2.5, + total_cost_usd=1.25, + sessions_count=3, + avg_session_minutes=50.0, + ) + + assert resp.total_runtime_hours == 2.5 + assert resp.total_cost_usd == 1.25 + assert resp.sessions_count == 3 + + +class TestAuditEndpoint: + """Tests fuer den Audit Log Endpoint.""" + + def test_audit_entries_parsed(self): + """Test Audit Log Eintraege werden geparst.""" + with tempfile.TemporaryDirectory() as tmpdir: + audit_path = Path(tmpdir) / "audit.log" + + # Schreibe Test-Eintraege + entries = [ + '{"ts": "2024-01-15T10:00:00Z", "event": "power_on", "actor": "admin", "meta": {}}', + '{"ts": "2024-01-15T11:00:00Z", "event": "power_off", "actor": "admin", "meta": {}}', + ] + audit_path.write_text("\n".join(entries)) + + # Lese und parse + lines = audit_path.read_text().strip().split("\n") + parsed = [json.loads(line) for line in lines] + + assert len(parsed) == 2 + assert parsed[0]["event"] == "power_on" + assert parsed[1]["event"] == "power_off" + + +class TestRequestModels: + """Tests fuer Request/Response Models.""" + + def test_power_on_request_defaults(self): + """Test PowerOnRequest Defaults.""" + from infra.vast_power import PowerOnRequest + + req = PowerOnRequest() + assert req.wait_for_health is True + assert req.health_path == "/health" + assert req.health_port == 8001 + + def test_power_on_request_custom(self): + """Test PowerOnRequest Custom Werte.""" + from infra.vast_power import PowerOnRequest + + req = PowerOnRequest( + wait_for_health=False, + health_path="/v1/models", + health_port=8000, + ) + assert req.wait_for_health is False + assert req.health_path == "/v1/models" + assert req.health_port == 8000 + + def test_vast_status_response(self): + """Test VastStatusResponse Model.""" + from infra.vast_power import VastStatusResponse + + resp = VastStatusResponse( + instance_id=12345, + status="running", + gpu_name="RTX 3090", + dph_total=0.5, + ) + + assert resp.instance_id == 12345 + assert resp.status == "running" + assert resp.auto_shutdown_in_minutes is None + + def test_power_off_response(self): + """Test PowerOffResponse Model.""" + from infra.vast_power import PowerOffResponse + + resp = PowerOffResponse( + status="stopped", + session_runtime_minutes=30.5, + session_cost_usd=0.25, + ) + + assert resp.status == "stopped" + assert resp.session_runtime_minutes == 30.5 + assert resp.session_cost_usd == 0.25 + + def test_vast_status_response_with_budget(self): + """Test VastStatusResponse mit Budget-Feldern.""" + from infra.vast_power import VastStatusResponse + + resp = VastStatusResponse( + instance_id=12345, + status="running", + gpu_name="RTX 3090", + dph_total=0.186, + account_credit=23.86, + account_total_spend=1.19, + session_runtime_minutes=120.5, + session_cost_usd=0.37, + ) + + assert resp.instance_id == 12345 + assert resp.status == "running" + assert resp.account_credit == 23.86 + assert resp.account_total_spend == 1.19 + assert resp.session_runtime_minutes == 120.5 + assert resp.session_cost_usd == 0.37 + + def test_vast_status_response_budget_none(self): + """Test VastStatusResponse ohne Budget (API nicht erreichbar).""" + from infra.vast_power import VastStatusResponse + + resp = VastStatusResponse( + instance_id=12345, + status="running", + account_credit=None, + account_total_spend=None, + session_runtime_minutes=None, + session_cost_usd=None, + ) + + assert resp.account_credit is None + assert resp.account_total_spend is None + assert resp.session_runtime_minutes is None + assert resp.session_cost_usd is None + + +class TestSessionCostCalculation: + """Tests fuer Session-Kosten Berechnung.""" + + def test_session_cost_calculation_basic(self): + """Test grundlegende Session-Kosten Berechnung.""" + # Formel: (runtime_minutes / 60) * dph_total + runtime_minutes = 60.0 # 1 Stunde + dph_total = 0.186 # $0.186/h + + session_cost = (runtime_minutes / 60) * dph_total + + assert abs(session_cost - 0.186) < 0.001 + + def test_session_cost_calculation_partial_hour(self): + """Test Session-Kosten fuer halbe Stunde.""" + runtime_minutes = 30.0 # 30 min + dph_total = 0.5 # $0.50/h + + session_cost = (runtime_minutes / 60) * dph_total + + assert abs(session_cost - 0.25) < 0.001 # $0.25 + + def test_session_cost_calculation_multi_hour(self): + """Test Session-Kosten fuer mehrere Stunden.""" + runtime_minutes = 240.0 # 4 Stunden + dph_total = 0.186 # $0.186/h + + session_cost = (runtime_minutes / 60) * dph_total + + assert abs(session_cost - 0.744) < 0.001 # $0.744 + + def test_session_cost_zero_runtime(self): + """Test Session-Kosten bei null Laufzeit.""" + runtime_minutes = 0.0 + dph_total = 0.5 + + session_cost = (runtime_minutes / 60) * dph_total + + assert session_cost == 0.0 + + def test_session_cost_zero_dph(self): + """Test Session-Kosten bei null Stundensatz (sollte nie passieren).""" + runtime_minutes = 60.0 + dph_total = 0.0 + + session_cost = (runtime_minutes / 60) * dph_total + + assert session_cost == 0.0 + + +class TestBudgetWarningLevels: + """Tests fuer Budget-Warnlevel (UI verwendet diese).""" + + def test_budget_critical_threshold(self): + """Test Budget unter $5 ist kritisch (rot).""" + credit = 4.99 + assert credit < 5 # Kritisch + + def test_budget_warning_threshold(self): + """Test Budget zwischen $5 und $15 ist Warnung (orange).""" + credit = 10.0 + assert credit >= 5 and credit < 15 # Warnung + + def test_budget_ok_threshold(self): + """Test Budget ueber $15 ist OK (gruen).""" + credit = 23.86 + assert credit >= 15 # OK + + +class TestSessionRecoveryAfterRestart: + """Tests fuer Session-Recovery nach Container-Neustart.""" + + def test_state_without_last_start(self): + """Test State ohne last_start (nach Neustart).""" + with tempfile.TemporaryDirectory() as tmpdir: + from infra.vast_power import VastState + + state_path = Path(tmpdir) / "state.json" + state = VastState(path=state_path) + + # Kein last_start sollte None sein + assert state.get("last_start") is None + + def test_state_preserves_last_start(self): + """Test State speichert last_start korrekt.""" + with tempfile.TemporaryDirectory() as tmpdir: + from infra.vast_power import VastState + + state_path = Path(tmpdir) / "state.json" + state = VastState(path=state_path) + + # Setze last_start + test_time = "2025-12-16T10:00:00+00:00" + state.set("last_start", test_time) + + # Erstelle neuen State-Objekt (simuliert Neustart) + state2 = VastState(path=state_path) + + assert state2.get("last_start") == test_time + + def test_state_uses_instance_start_date(self): + """Test dass Instance start_date verwendet werden kann.""" + from infra.vast_client import InstanceInfo, InstanceStatus + from datetime import datetime, timezone + + # Simuliere Instance mit start_date + instance = InstanceInfo( + id=12345, + status=InstanceStatus.RUNNING, + started_at=datetime(2025, 12, 16, 10, 0, 0, tzinfo=timezone.utc), + ) + + assert instance.started_at is not None + assert instance.started_at.isoformat() == "2025-12-16T10:00:00+00:00" diff --git a/backend/tests/test_integration/__init__.py b/backend/tests/test_integration/__init__.py new file mode 100644 index 0000000..44dcd75 --- /dev/null +++ b/backend/tests/test_integration/__init__.py @@ -0,0 +1,16 @@ +""" +Integration tests that require external services. + +These tests run in the Woodpecker CI integration pipeline +(.woodpecker/integration.yml) which provides: +- PostgreSQL database +- Valkey/Redis cache + +To run locally: + docker compose -f docker-compose.test.yml up -d postgres-test valkey-test + export DATABASE_URL=postgresql://breakpilot:breakpilot_test@localhost:55432/breakpilot_test + export VALKEY_URL=redis://localhost:56379 + export SKIP_INTEGRATION_TESTS=false + pytest tests/test_integration/ -v + docker compose -f docker-compose.test.yml down -v +""" diff --git a/backend/tests/test_integration/test_db_connection.py b/backend/tests/test_integration/test_db_connection.py new file mode 100644 index 0000000..cfe408c --- /dev/null +++ b/backend/tests/test_integration/test_db_connection.py @@ -0,0 +1,186 @@ +""" +Integration tests for database and cache connectivity. + +These tests verify that the CI pipeline can connect to: +- PostgreSQL database +- Valkey/Redis cache + +Run with: pytest tests/test_integration/test_db_connection.py -v +""" + +import os +import pytest + + +@pytest.mark.integration +def test_database_connection(): + """Test that we can connect to PostgreSQL.""" + import psycopg2 + + db_url = os.environ.get("DATABASE_URL") + assert db_url is not None, "DATABASE_URL not set" + + # Parse connection parameters from URL + # Format: postgresql://user:password@host:port/dbname + conn = psycopg2.connect(db_url) + try: + cur = conn.cursor() + cur.execute("SELECT 1") + result = cur.fetchone() + assert result[0] == 1, "Database query returned unexpected result" + + # Test database version + cur.execute("SELECT version()") + version = cur.fetchone()[0] + assert "PostgreSQL" in version, f"Unexpected database: {version}" + print(f"Connected to: {version.split(',')[0]}") + + finally: + conn.close() + + +@pytest.mark.integration +def test_database_can_create_table(): + """Test that we can create and drop tables.""" + import psycopg2 + + db_url = os.environ.get("DATABASE_URL") + assert db_url is not None, "DATABASE_URL not set" + + conn = psycopg2.connect(db_url) + conn.autocommit = True + try: + cur = conn.cursor() + + # Create test table + cur.execute(""" + CREATE TABLE IF NOT EXISTS _ci_test_table ( + id SERIAL PRIMARY KEY, + name VARCHAR(100), + created_at TIMESTAMP DEFAULT NOW() + ) + """) + + # Insert test data + cur.execute( + "INSERT INTO _ci_test_table (name) VALUES (%s) RETURNING id", + ("integration_test",) + ) + inserted_id = cur.fetchone()[0] + assert inserted_id is not None, "Insert failed" + + # Read back + cur.execute("SELECT name FROM _ci_test_table WHERE id = %s", (inserted_id,)) + name = cur.fetchone()[0] + assert name == "integration_test", f"Read back failed: {name}" + + # Cleanup + cur.execute("DROP TABLE IF EXISTS _ci_test_table") + + finally: + conn.close() + + +@pytest.mark.integration +def test_valkey_connection(): + """Test that we can connect to Valkey/Redis.""" + import redis + + valkey_url = os.environ.get("VALKEY_URL") or os.environ.get("REDIS_URL") + assert valkey_url is not None, "VALKEY_URL or REDIS_URL not set" + + r = redis.from_url(valkey_url) + try: + # Test ping + assert r.ping() is True, "Valkey ping failed" + + # Test set/get + test_key = "_ci_test_key" + test_value = "integration_test_value" + + r.set(test_key, test_value) + result = r.get(test_key) + assert result == test_value.encode(), f"Get returned: {result}" + + # Cleanup + r.delete(test_key) + assert r.get(test_key) is None, "Delete failed" + + # Get server info + info = r.info("server") + server_version = info.get("redis_version", "unknown") + print(f"Connected to Valkey/Redis version: {server_version}") + + finally: + r.close() + + +@pytest.mark.integration +def test_valkey_can_store_json(): + """Test that Valkey can store and retrieve JSON data.""" + import redis + import json + + valkey_url = os.environ.get("VALKEY_URL") or os.environ.get("REDIS_URL") + assert valkey_url is not None, "VALKEY_URL or REDIS_URL not set" + + r = redis.from_url(valkey_url) + try: + test_key = "_ci_test_json" + test_data = { + "user_id": "test-123", + "session": {"active": True, "created": "2025-01-01"}, + "scores": [85, 90, 78] + } + + # Store as JSON + r.set(test_key, json.dumps(test_data)) + + # Retrieve and parse + result = r.get(test_key) + parsed = json.loads(result) + + assert parsed["user_id"] == "test-123" + assert parsed["session"]["active"] is True + assert parsed["scores"] == [85, 90, 78] + + # Cleanup + r.delete(test_key) + + finally: + r.close() + + +@pytest.mark.integration +def test_valkey_expiration(): + """Test that Valkey TTL/expiration works.""" + import redis + import time + + valkey_url = os.environ.get("VALKEY_URL") or os.environ.get("REDIS_URL") + assert valkey_url is not None, "VALKEY_URL or REDIS_URL not set" + + r = redis.from_url(valkey_url) + try: + test_key = "_ci_test_expiry" + + # Set with 2 second TTL + r.setex(test_key, 2, "temporary_value") + + # Should exist immediately + assert r.get(test_key) is not None, "Key should exist" + + # Check TTL + ttl = r.ttl(test_key) + assert 0 < ttl <= 2, f"TTL should be 1-2, got {ttl}" + + # Wait for expiration + time.sleep(3) + + # Should be gone + assert r.get(test_key) is None, "Key should have expired" + + finally: + # Cleanup (in case test failed before expiration) + r.delete(test_key) + r.close() diff --git a/backend/tests/test_integration/test_edu_search_seeds_integration.py b/backend/tests/test_integration/test_edu_search_seeds_integration.py new file mode 100644 index 0000000..5dd57fb --- /dev/null +++ b/backend/tests/test_integration/test_edu_search_seeds_integration.py @@ -0,0 +1,352 @@ +""" +Integration Tests for EduSearch Seeds API. + +These tests require a running PostgreSQL database and test the full +request-response cycle through the FastAPI application. + +Run with: pytest tests/test_integration/test_edu_search_seeds_integration.py -v +""" + +import pytest +import httpx +import os +import uuid +from typing import Generator + +# Test configuration +API_BASE = os.environ.get("TEST_API_BASE", "http://localhost:8082") +SKIP_INTEGRATION = os.environ.get("SKIP_INTEGRATION_TESTS", "false").lower() == "true" + +# Check if server is reachable +def _check_server_available(): + """Check if the API server is reachable.""" + if SKIP_INTEGRATION: + return False + try: + with httpx.Client(timeout=2.0) as client: + client.get(f"{API_BASE}/health") + return True + except (httpx.ConnectError, httpx.TimeoutException): + return False + +SERVER_AVAILABLE = _check_server_available() + +pytestmark = pytest.mark.skipif( + not SERVER_AVAILABLE, + reason=f"Integration tests skipped (server at {API_BASE} not available)" +) + + +@pytest.fixture +def api_client() -> Generator[httpx.Client, None, None]: + """Create HTTP client for API calls.""" + with httpx.Client(base_url=API_BASE, timeout=30.0) as client: + yield client + + +@pytest.fixture +def async_api_client(): + """Create async HTTP client for API calls.""" + return httpx.AsyncClient(base_url=API_BASE, timeout=30.0) + + +class TestHealthEndpoint: + """Basic connectivity tests.""" + + def test_api_is_reachable(self, api_client: httpx.Client): + """Test that the API is reachable.""" + response = api_client.get("/health") + assert response.status_code == 200 + + +class TestCategoriesIntegration: + """Integration tests for categories endpoint.""" + + def test_list_categories_returns_default_categories(self, api_client: httpx.Client): + """Test that default categories are returned.""" + response = api_client.get("/v1/edu-search/categories") + assert response.status_code == 200 + + data = response.json() + assert "categories" in data + + # Check for expected default categories + category_names = [c["name"] for c in data["categories"]] + expected = ["federal", "states", "science", "portals"] + for expected_cat in expected: + assert expected_cat in category_names, f"Missing category: {expected_cat}" + + +class TestSeedsWorkflow: + """Integration tests for complete seeds workflow.""" + + @pytest.fixture + def test_seed_url(self): + """Generate unique URL for test seed.""" + return f"https://test-seed-{uuid.uuid4().hex[:8]}.de" + + def test_create_read_update_delete_seed(self, api_client: httpx.Client, test_seed_url: str): + """Test complete CRUD workflow for a seed.""" + # CREATE + create_response = api_client.post( + "/v1/edu-search/seeds", + json={ + "url": test_seed_url, + "name": "Integration Test Seed", + "description": "Created by integration test", + "trust_boost": 0.75, + "enabled": True + } + ) + assert create_response.status_code == 200 + create_data = create_response.json() + assert create_data["status"] == "created" + seed_id = create_data["id"] + + try: + # READ + get_response = api_client.get(f"/v1/edu-search/seeds/{seed_id}") + assert get_response.status_code == 200 + seed_data = get_response.json() + assert seed_data["url"] == test_seed_url + assert seed_data["name"] == "Integration Test Seed" + assert seed_data["trust_boost"] == 0.75 + + # UPDATE + update_response = api_client.put( + f"/v1/edu-search/seeds/{seed_id}", + json={ + "name": "Updated Test Seed", + "enabled": False + } + ) + assert update_response.status_code == 200 + + # Verify update + verify_response = api_client.get(f"/v1/edu-search/seeds/{seed_id}") + assert verify_response.status_code == 200 + updated_data = verify_response.json() + assert updated_data["name"] == "Updated Test Seed" + assert updated_data["enabled"] is False + + finally: + # DELETE (cleanup) + delete_response = api_client.delete(f"/v1/edu-search/seeds/{seed_id}") + assert delete_response.status_code == 200 + + # Verify deletion + verify_delete = api_client.get(f"/v1/edu-search/seeds/{seed_id}") + assert verify_delete.status_code == 404 + + def test_list_seeds_with_filters(self, api_client: httpx.Client, test_seed_url: str): + """Test listing seeds with various filters.""" + # Create a test seed first + create_response = api_client.post( + "/v1/edu-search/seeds", + json={ + "url": test_seed_url, + "name": "Filter Test Seed", + "enabled": True + } + ) + assert create_response.status_code == 200 + seed_id = create_response.json()["id"] + + try: + # List all seeds + list_response = api_client.get("/v1/edu-search/seeds") + assert list_response.status_code == 200 + assert "seeds" in list_response.json() + assert "total" in list_response.json() + + # List with enabled filter + enabled_response = api_client.get("/v1/edu-search/seeds?enabled=true") + assert enabled_response.status_code == 200 + + # List with pagination + paginated_response = api_client.get("/v1/edu-search/seeds?limit=10&offset=0") + assert paginated_response.status_code == 200 + assert paginated_response.json()["limit"] == 10 + + finally: + api_client.delete(f"/v1/edu-search/seeds/{seed_id}") + + def test_duplicate_url_rejected(self, api_client: httpx.Client, test_seed_url: str): + """Test that duplicate URLs are rejected.""" + # Create first seed + first_response = api_client.post( + "/v1/edu-search/seeds", + json={"url": test_seed_url, "name": "First Seed"} + ) + assert first_response.status_code == 200 + seed_id = first_response.json()["id"] + + try: + # Try to create duplicate + duplicate_response = api_client.post( + "/v1/edu-search/seeds", + json={"url": test_seed_url, "name": "Duplicate Seed"} + ) + assert duplicate_response.status_code == 400 + assert "existiert bereits" in duplicate_response.json()["detail"] + + finally: + api_client.delete(f"/v1/edu-search/seeds/{seed_id}") + + +class TestBulkImportIntegration: + """Integration tests for bulk import functionality.""" + + def test_bulk_import_multiple_seeds(self, api_client: httpx.Client): + """Test importing multiple seeds at once.""" + unique_suffix = uuid.uuid4().hex[:8] + seeds_to_import = [ + {"url": f"https://bulk-test-1-{unique_suffix}.de", "name": "Bulk Test 1", "category": "federal"}, + {"url": f"https://bulk-test-2-{unique_suffix}.de", "name": "Bulk Test 2", "category": "states"}, + {"url": f"https://bulk-test-3-{unique_suffix}.de", "name": "Bulk Test 3", "category": "science"} + ] + + response = api_client.post( + "/v1/edu-search/seeds/bulk-import", + json={"seeds": seeds_to_import} + ) + assert response.status_code == 200 + + data = response.json() + assert data["status"] == "imported" + assert data["imported"] == 3 + assert data["skipped"] == 0 + + # Cleanup - find and delete imported seeds + for seed in seeds_to_import: + list_response = api_client.get("/v1/edu-search/seeds") + for s in list_response.json()["seeds"]: + if s["url"] == seed["url"]: + api_client.delete(f"/v1/edu-search/seeds/{s['id']}") + + def test_bulk_import_skips_duplicates(self, api_client: httpx.Client): + """Test that bulk import skips existing URLs.""" + unique_url = f"https://bulk-dup-test-{uuid.uuid4().hex[:8]}.de" + + # First import + first_response = api_client.post( + "/v1/edu-search/seeds/bulk-import", + json={"seeds": [{"url": unique_url, "name": "First Import"}]} + ) + assert first_response.status_code == 200 + assert first_response.json()["imported"] == 1 + + try: + # Second import with same URL + second_response = api_client.post( + "/v1/edu-search/seeds/bulk-import", + json={"seeds": [{"url": unique_url, "name": "Duplicate Import"}]} + ) + assert second_response.status_code == 200 + assert second_response.json()["imported"] == 0 + assert second_response.json()["skipped"] == 1 + + finally: + # Cleanup + list_response = api_client.get("/v1/edu-search/seeds") + for s in list_response.json()["seeds"]: + if s["url"] == unique_url: + api_client.delete(f"/v1/edu-search/seeds/{s['id']}") + + +class TestStatsIntegration: + """Integration tests for statistics endpoint.""" + + def test_get_stats_returns_valid_structure(self, api_client: httpx.Client): + """Test that stats endpoint returns expected structure.""" + response = api_client.get("/v1/edu-search/stats") + assert response.status_code == 200 + + data = response.json() + assert "total_seeds" in data + assert "enabled_seeds" in data + assert "avg_trust_boost" in data + assert "seeds_per_category" in data + + # Verify types + assert isinstance(data["total_seeds"], int) + assert isinstance(data["enabled_seeds"], int) + assert isinstance(data["avg_trust_boost"], (int, float)) + assert isinstance(data["seeds_per_category"], dict) + + +class TestExportForCrawlerIntegration: + """Integration tests for crawler export endpoint.""" + + def test_export_returns_valid_format(self, api_client: httpx.Client): + """Test that export endpoint returns crawler-compatible format.""" + response = api_client.get("/v1/edu-search/seeds/export/for-crawler") + assert response.status_code == 200 + + data = response.json() + assert "seeds" in data + assert "generated_at" in data + assert "total" in data + + # Verify seed format + if data["seeds"]: + seed = data["seeds"][0] + assert "url" in seed + assert "name" in seed + assert "trust_boost" in seed + assert "crawl_depth" in seed + + def test_export_only_includes_enabled_seeds(self, api_client: httpx.Client): + """Test that export only includes enabled seeds.""" + unique_url = f"https://export-test-{uuid.uuid4().hex[:8]}.de" + + # Create disabled seed + create_response = api_client.post( + "/v1/edu-search/seeds", + json={"url": unique_url, "name": "Disabled Seed", "enabled": False} + ) + assert create_response.status_code == 200 + seed_id = create_response.json()["id"] + + try: + export_response = api_client.get("/v1/edu-search/seeds/export/for-crawler") + assert export_response.status_code == 200 + + # Verify disabled seed is not in export + exported_urls = [s["url"] for s in export_response.json()["seeds"]] + assert unique_url not in exported_urls + + finally: + api_client.delete(f"/v1/edu-search/seeds/{seed_id}") + + +class TestErrorHandling: + """Integration tests for error handling.""" + + def test_get_nonexistent_seed_returns_404(self, api_client: httpx.Client): + """Test that getting non-existent seed returns 404.""" + fake_id = str(uuid.uuid4()) + response = api_client.get(f"/v1/edu-search/seeds/{fake_id}") + assert response.status_code == 404 + + def test_invalid_uuid_returns_400(self, api_client: httpx.Client): + """Test that invalid UUID returns 400.""" + response = api_client.get("/v1/edu-search/seeds/not-a-uuid") + assert response.status_code == 400 + + def test_create_seed_with_missing_required_fields(self, api_client: httpx.Client): + """Test that missing required fields returns 422.""" + response = api_client.post( + "/v1/edu-search/seeds", + json={"name": "Missing URL"} # url is required + ) + assert response.status_code == 422 + + def test_create_seed_with_invalid_url(self, api_client: httpx.Client): + """Test that invalid URL format is rejected.""" + response = api_client.post( + "/v1/edu-search/seeds", + json={"url": "not-a-valid-url", "name": "Invalid URL"} + ) + # Should be 422 (validation error) or 400 + assert response.status_code in [400, 422] diff --git a/backend/tests/test_integration/test_librechat_tavily.py b/backend/tests/test_integration/test_librechat_tavily.py new file mode 100644 index 0000000..6fb90f2 --- /dev/null +++ b/backend/tests/test_integration/test_librechat_tavily.py @@ -0,0 +1,301 @@ +""" +Integration Tests für LibreChat + Tavily Web Search. + +Diese Tests prüfen: +1. Tavily API Konnektivität +2. LibreChat Container Health +3. End-to-End Web Search Flow +""" + +import os +import pytest +import httpx +from unittest.mock import patch, AsyncMock + +# Test-Konfiguration +TAVILY_API_KEY = os.getenv("TAVILY_API_KEY", "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") +LIBRECHAT_URL = os.getenv("LIBRECHAT_URL", "http://localhost:3080") +TAVILY_API_URL = "https://api.tavily.com" + + +class TestTavilyAPIConnectivity: + """Tests für direkte Tavily API Verbindung.""" + + @pytest.mark.asyncio + async def test_tavily_api_health(self): + """Test: Tavily API ist erreichbar und antwortet.""" + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{TAVILY_API_URL}/search", + json={ + "api_key": TAVILY_API_KEY, + "query": "test query", + "max_results": 1 + } + ) + + assert response.status_code == 200 + data = response.json() + assert "results" in data + assert "query" in data + + @pytest.mark.asyncio + async def test_tavily_search_returns_results(self): + """Test: Tavily gibt Suchergebnisse zurück.""" + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{TAVILY_API_URL}/search", + json={ + "api_key": TAVILY_API_KEY, + "query": "LibreChat AI chat platform", + "max_results": 3 + } + ) + + assert response.status_code == 200 + data = response.json() + + # Prüfe Struktur + assert "results" in data + assert len(data["results"]) > 0 + + # Prüfe erstes Ergebnis + first_result = data["results"][0] + assert "url" in first_result + assert "title" in first_result + assert "content" in first_result + assert "score" in first_result + + @pytest.mark.asyncio + async def test_tavily_invalid_api_key(self): + """Test: Tavily gibt Fehler bei ungültigem API Key.""" + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{TAVILY_API_URL}/search", + json={ + "api_key": "invalid-key-12345", + "query": "test", + "max_results": 1 + } + ) + + # Sollte 401 oder 403 zurückgeben + assert response.status_code in [401, 403, 400] + + @pytest.mark.asyncio + async def test_tavily_search_depth_basic(self): + """Test: Tavily basic search depth funktioniert.""" + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{TAVILY_API_URL}/search", + json={ + "api_key": TAVILY_API_KEY, + "query": "Python programming", + "search_depth": "basic", + "max_results": 2 + } + ) + + assert response.status_code == 200 + data = response.json() + assert "response_time" in data + + @pytest.mark.asyncio + async def test_tavily_german_query(self): + """Test: Tavily kann deutsche Suchanfragen verarbeiten.""" + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{TAVILY_API_URL}/search", + json={ + "api_key": TAVILY_API_KEY, + "query": "Datenschutz Schulen Deutschland", + "max_results": 3 + } + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["results"]) > 0 + + +class TestLibreChatHealth: + """Tests für LibreChat Container Health.""" + + @pytest.mark.asyncio + async def test_librechat_api_health(self): + """Test: LibreChat API ist erreichbar.""" + async with httpx.AsyncClient(timeout=10.0) as client: + try: + response = await client.get(f"{LIBRECHAT_URL}/api/health") + # LibreChat hat keinen /api/health, aber / sollte funktionieren + if response.status_code == 404: + response = await client.get(f"{LIBRECHAT_URL}/") + + assert response.status_code in [200, 301, 302] + except httpx.ConnectError: + pytest.skip("LibreChat Container nicht erreichbar") + + @pytest.mark.asyncio + async def test_librechat_frontend_loads(self): + """Test: LibreChat Frontend lädt.""" + async with httpx.AsyncClient(timeout=10.0) as client: + try: + response = await client.get(f"{LIBRECHAT_URL}/") + + assert response.status_code in [200, 301, 302] + # Prüfe ob HTML zurückkommt + if response.status_code == 200: + assert "html" in response.headers.get("content-type", "").lower() or \ + " 30, \ + f"Tavily API Key zu kurz: {len(TAVILY_API_KEY)} Zeichen" + + def test_tavily_api_key_not_placeholder(self): + """Test: Tavily API Key ist kein Platzhalter.""" + placeholders = [ + "your-tavily-api-key", + "TAVILY_API_KEY", + "tvly-xxx", + "tvly-placeholder", + ] + assert TAVILY_API_KEY not in placeholders, \ + "Tavily API Key ist noch ein Platzhalter" + + +class TestBreakPilotTavilyIntegration: + """Tests für BreakPilot Backend Tavily Integration.""" + + @pytest.mark.asyncio + async def test_breakpilot_tool_gateway_available(self): + """Test: BreakPilot Tool Gateway ist verfügbar.""" + from llm_gateway.services.tool_gateway import ToolGateway, ToolGatewayConfig + + config = ToolGatewayConfig(tavily_api_key=TAVILY_API_KEY) + gateway = ToolGateway(config) + + assert gateway.tavily_available is True + + @pytest.mark.asyncio + async def test_breakpilot_pii_redaction_before_tavily(self): + """Test: PII wird vor Tavily-Anfragen redaktiert.""" + from llm_gateway.services.pii_detector import PIIDetector + + detector = PIIDetector() + + # Text mit PII + query_with_pii = "Suche Informationen über max.mustermann@schule.de in Klasse 5a" + + result = detector.redact(query_with_pii) + + # PII sollte redaktiert sein + assert "max.mustermann@schule.de" not in result.redacted_text + assert result.pii_found is True + assert len(result.matches) > 0 + # E-Mail sollte als [EMAIL_REDACTED] redaktiert sein + assert "[EMAIL_REDACTED]" in result.redacted_text + + @pytest.mark.asyncio + async def test_breakpilot_tavily_search_with_pii_protection(self): + """Test: Tavily Search mit PII-Schutz funktioniert.""" + from llm_gateway.services.tool_gateway import ToolGateway, ToolGatewayConfig + + config = ToolGatewayConfig(tavily_api_key=TAVILY_API_KEY) + gateway = ToolGateway(config) + + # Suche mit PII (wird automatisch redaktiert) + result = await gateway.search( + query="Datenschutz Email hans.mueller@example.com", + max_results=2 + ) + + # Wichtig: PII-Schutz hat funktioniert + assert result is not None + assert result.pii_detected is True + assert "email" in result.pii_types + assert result.redacted_query is not None + assert "hans.mueller@example.com" not in result.redacted_query + assert "[EMAIL_REDACTED]" in result.redacted_query + # Ergebnisse sind optional - die redaktierte Query kann leer sein + assert result.results is not None # Liste existiert (kann leer sein) + + +class TestEndToEndFlow: + """End-to-End Tests für den kompletten Flow.""" + + @pytest.mark.asyncio + async def test_complete_search_flow(self): + """Test: Kompletter Such-Flow von Anfrage bis Ergebnis.""" + # 1. Tavily API direkt + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{TAVILY_API_URL}/search", + json={ + "api_key": TAVILY_API_KEY, + "query": "Schulrecht Deutschland aktuelle Änderungen", + "max_results": 3, + "search_depth": "basic" + } + ) + + assert response.status_code == 200 + data = response.json() + + # Validiere Ergebnisse + assert len(data["results"]) > 0 + + for result in data["results"]: + assert "url" in result + assert "title" in result + assert result["url"].startswith("http") + + @pytest.mark.asyncio + async def test_search_response_time(self): + """Test: Tavily antwortet in akzeptabler Zeit.""" + import time + + async with httpx.AsyncClient(timeout=30.0) as client: + start = time.time() + + response = await client.post( + f"{TAVILY_API_URL}/search", + json={ + "api_key": TAVILY_API_KEY, + "query": "test query", + "max_results": 3 + } + ) + + elapsed = time.time() - start + + assert response.status_code == 200 + # Sollte unter 10 Sekunden antworten + assert elapsed < 10.0, f"Tavily Antwortzeit zu lang: {elapsed:.2f}s" + + +# Fixtures für gemeinsame Test-Ressourcen +@pytest.fixture +def tavily_api_key(): + """Fixture für Tavily API Key.""" + return TAVILY_API_KEY + + +@pytest.fixture +def librechat_url(): + """Fixture für LibreChat URL.""" + return LIBRECHAT_URL diff --git a/backend/tests/test_jitsi_api.py b/backend/tests/test_jitsi_api.py new file mode 100644 index 0000000..80e0ab7 --- /dev/null +++ b/backend/tests/test_jitsi_api.py @@ -0,0 +1,188 @@ +""" +Tests fuer die Jitsi API. + +Testet: +- Meeting-Raum generieren +- Einzel-Einladung senden +- Bulk-Einladungen senden +""" + +import pytest +from unittest.mock import patch, MagicMock + +from jitsi_api import ( + generate_room_name, + build_jitsi_url, + JitsiInvitation, + JitsiBulkInvitation +) + + +class TestHelperFunctions: + """Tests fuer Helper-Funktionen.""" + + def test_generate_room_name_format(self): + """Test: Raumname hat korrektes Format.""" + room_name = generate_room_name() + + assert room_name.startswith("BreakPilot-") + assert len(room_name) == len("BreakPilot-") + 12 # 12 hex chars + + def test_generate_room_name_unique(self): + """Test: Raumnamen sind eindeutig.""" + names = [generate_room_name() for _ in range(100)] + unique_names = set(names) + + assert len(unique_names) == 100 + + def test_build_jitsi_url_default_server(self): + """Test: URL mit Standard-Server.""" + url = build_jitsi_url("TestRoom123") + + assert url == "https://meet.jit.si/TestRoom123" + + def test_build_jitsi_url_with_special_chars(self): + """Test: URL mit Sonderzeichen im Raumnamen.""" + url = build_jitsi_url("BreakPilot-abc123def456") + + assert "BreakPilot-abc123def456" in url + + +class TestJitsiInvitationModel: + """Tests fuer das JitsiInvitation Model.""" + + def test_valid_invitation(self): + """Test: Gueltiges Einladungsmodell.""" + invitation = JitsiInvitation( + to_email="parent@example.com", + to_name="Max Mustermann", + meeting_title="Elterngespraech", + meeting_date="20. Dezember 2024", + meeting_time="14:00 Uhr" + ) + + assert invitation.to_email == "parent@example.com" + assert invitation.to_name == "Max Mustermann" + assert invitation.organizer_name == "BreakPilot Lehrer" # Default + assert invitation.room_name is None # Optional + + def test_invitation_with_room_name(self): + """Test: Einladung mit vordefiniertem Raumname.""" + invitation = JitsiInvitation( + to_email="parent@example.com", + to_name="Max Mustermann", + meeting_title="Elterngespraech", + meeting_date="20. Dezember 2024", + meeting_time="14:00 Uhr", + room_name="CustomRoom123" + ) + + assert invitation.room_name == "CustomRoom123" + + def test_invitation_with_additional_info(self): + """Test: Einladung mit zusaetzlichen Informationen.""" + invitation = JitsiInvitation( + to_email="parent@example.com", + to_name="Max Mustermann", + meeting_title="Elterngespraech", + meeting_date="20. Dezember 2024", + meeting_time="14:00 Uhr", + additional_info="Bitte Zeugnisse mitbringen." + ) + + assert invitation.additional_info == "Bitte Zeugnisse mitbringen." + + +class TestJitsiBulkInvitationModel: + """Tests fuer das JitsiBulkInvitation Model.""" + + def test_valid_bulk_invitation(self): + """Test: Gueltiges Bulk-Einladungsmodell.""" + bulk = JitsiBulkInvitation( + recipients=[ + {"email": "parent1@example.com", "name": "Eltern A"}, + {"email": "parent2@example.com", "name": "Eltern B"} + ], + meeting_title="Elternabend", + meeting_date="20. Dezember 2024", + meeting_time="19:00 Uhr" + ) + + assert len(bulk.recipients) == 2 + assert bulk.meeting_title == "Elternabend" + assert bulk.organizer_name == "BreakPilot Lehrer" + + +class TestJitsiAPIIntegration: + """Integration Tests fuer die Jitsi API.""" + + BASE_URL = "http://localhost:8000" + + def test_generate_room_endpoint(self): + """Test: Room-Generator Endpoint.""" + import requests + + try: + response = requests.get(f"{self.BASE_URL}/api/jitsi/room", timeout=5) + + if response.status_code == 200: + data = response.json() + assert "room_name" in data + assert "jitsi_url" in data + assert data["room_name"].startswith("BreakPilot-") + except requests.exceptions.ConnectionError: + pytest.skip("Backend nicht erreichbar") + + def test_send_invitation_endpoint(self): + """Test: Einladungs-Endpoint (Integrationstest mit echtem Email-Service).""" + import requests + + try: + response = requests.post( + f"{self.BASE_URL}/api/jitsi/invite", + json={ + "to_email": "parent@example.com", + "to_name": "Max Mustermann", + "meeting_title": "Test Meeting", + "meeting_date": "20. Dezember 2024", + "meeting_time": "14:00 Uhr" + }, + timeout=5 + ) + + if response.status_code == 200: + data = response.json() + assert "jitsi_url" in data + assert "room_name" in data + assert "email_sent" in data + assert data["room_name"].startswith("BreakPilot-") + except requests.exceptions.ConnectionError: + pytest.skip("Backend nicht erreichbar") + + def test_bulk_invitation_endpoint(self): + """Test: Bulk-Einladungs-Endpoint (Integrationstest).""" + import requests + + try: + response = requests.post( + f"{self.BASE_URL}/api/jitsi/invite/bulk", + json={ + "recipients": [ + {"email": "parent1@example.com", "name": "Eltern A"}, + {"email": "parent2@example.com", "name": "Eltern B"} + ], + "meeting_title": "Elternabend", + "meeting_date": "20. Dezember 2024", + "meeting_time": "19:00 Uhr" + }, + timeout=5 + ) + + if response.status_code == 200: + data = response.json() + assert "jitsi_url" in data + assert "sent" in data + assert "failed" in data + assert data["sent"] == 2 # Beide Emails sollten gesendet werden + except requests.exceptions.ConnectionError: + pytest.skip("Backend nicht erreichbar") diff --git a/backend/tests/test_keycloak_auth.py b/backend/tests/test_keycloak_auth.py new file mode 100644 index 0000000..576eebc --- /dev/null +++ b/backend/tests/test_keycloak_auth.py @@ -0,0 +1,516 @@ +""" +Tests for Keycloak Authentication Module + +Tests cover: +- Local JWT validation +- Keycloak token detection +- HybridAuthenticator token routing +- FastAPI dependency integration +""" + +import pytest +import jwt +import os +from datetime import datetime, timezone, timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +# Import the auth module +import sys +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from auth.keycloak_auth import ( + KeycloakConfig, + KeycloakUser, + KeycloakAuthenticator, + HybridAuthenticator, + TokenExpiredError, + TokenInvalidError, + KeycloakConfigError, + get_keycloak_config_from_env, +) + + +# ============================================= +# Test Data +# ============================================= + +TEST_JWT_SECRET = "test-secret-key-32-chars-min-here" +TEST_USER_ID = "10000000-0000-0000-0000-000000000001" +TEST_EMAIL = "lehrer@test.de" + + +def create_local_token( + user_id: str = TEST_USER_ID, + email: str = TEST_EMAIL, + role: str = "teacher", + exp_hours: int = 1 +) -> str: + """Create a local JWT token for testing.""" + payload = { + "user_id": user_id, + "email": email, + "role": role, + "iss": "breakpilot", + "iat": datetime.now(timezone.utc), + "exp": datetime.now(timezone.utc) + timedelta(hours=exp_hours), + } + return jwt.encode(payload, TEST_JWT_SECRET, algorithm="HS256") + + +def create_expired_token() -> str: + """Create an expired JWT token.""" + payload = { + "user_id": TEST_USER_ID, + "email": TEST_EMAIL, + "role": "teacher", + "iss": "breakpilot", + "iat": datetime.now(timezone.utc) - timedelta(hours=2), + "exp": datetime.now(timezone.utc) - timedelta(hours=1), + } + return jwt.encode(payload, TEST_JWT_SECRET, algorithm="HS256") + + +# ============================================= +# KeycloakConfig Tests +# ============================================= + +class TestKeycloakConfig: + """Tests for KeycloakConfig dataclass.""" + + def test_config_urls(self): + """Test URL generation from config.""" + config = KeycloakConfig( + server_url="https://keycloak.example.com", + realm="test-realm", + client_id="test-client" + ) + + assert config.issuer_url == "https://keycloak.example.com/realms/test-realm" + assert config.jwks_url == "https://keycloak.example.com/realms/test-realm/protocol/openid-connect/certs" + assert config.token_url == "https://keycloak.example.com/realms/test-realm/protocol/openid-connect/token" + + def test_config_defaults(self): + """Test default values.""" + config = KeycloakConfig( + server_url="https://kc.example.com", + realm="breakpilot", + client_id="backend" + ) + + assert config.client_secret is None + assert config.verify_ssl is True + + +# ============================================= +# KeycloakUser Tests +# ============================================= + +class TestKeycloakUser: + """Tests for KeycloakUser dataclass.""" + + def test_has_realm_role(self): + """Test realm role checking.""" + user = KeycloakUser( + user_id="user-123", + email="test@example.com", + email_verified=True, + name="Test User", + given_name="Test", + family_name="User", + realm_roles=["teacher", "admin"], + client_roles={}, + groups=[], + tenant_id=None, + raw_claims={} + ) + + assert user.has_realm_role("teacher") is True + assert user.has_realm_role("admin") is True + assert user.has_realm_role("superadmin") is False + + def test_has_client_role(self): + """Test client role checking.""" + user = KeycloakUser( + user_id="user-123", + email="test@example.com", + email_verified=True, + name="Test User", + given_name=None, + family_name=None, + realm_roles=[], + client_roles={"backend": ["editor", "viewer"]}, + groups=[], + tenant_id=None, + raw_claims={} + ) + + assert user.has_client_role("backend", "editor") is True + assert user.has_client_role("backend", "admin") is False + assert user.has_client_role("frontend", "viewer") is False + + def test_is_admin(self): + """Test admin detection.""" + admin_user = KeycloakUser( + user_id="admin-123", + email="admin@example.com", + email_verified=True, + name=None, + given_name=None, + family_name=None, + realm_roles=["admin"], + client_roles={}, + groups=[], + tenant_id=None, + raw_claims={} + ) + + regular_user = KeycloakUser( + user_id="user-123", + email="user@example.com", + email_verified=True, + name=None, + given_name=None, + family_name=None, + realm_roles=["teacher"], + client_roles={}, + groups=[], + tenant_id=None, + raw_claims={} + ) + + assert admin_user.is_admin() is True + assert regular_user.is_admin() is False + + def test_is_teacher(self): + """Test teacher detection with German role name.""" + teacher_de = KeycloakUser( + user_id="t1", + email="t@test.de", + email_verified=True, + name=None, + given_name=None, + family_name=None, + realm_roles=["lehrer"], # German role name + client_roles={}, + groups=[], + tenant_id=None, + raw_claims={} + ) + + teacher_en = KeycloakUser( + user_id="t2", + email="t2@test.de", + email_verified=True, + name=None, + given_name=None, + family_name=None, + realm_roles=["teacher"], # English role name + client_roles={}, + groups=[], + tenant_id=None, + raw_claims={} + ) + + assert teacher_de.is_teacher() is True + assert teacher_en.is_teacher() is True + + +# ============================================= +# HybridAuthenticator Tests (Local JWT) +# ============================================= + +class TestHybridAuthenticatorLocalJWT: + """Tests for HybridAuthenticator with local JWT.""" + + @pytest.fixture + def authenticator(self): + """Create authenticator without Keycloak.""" + return HybridAuthenticator( + keycloak_config=None, + local_jwt_secret=TEST_JWT_SECRET, + environment="development" + ) + + @pytest.mark.asyncio + async def test_valid_local_token(self, authenticator): + """Test validation of valid local token.""" + token = create_local_token() + + user = await authenticator.validate_token(token) + + assert user["user_id"] == TEST_USER_ID + assert user["email"] == TEST_EMAIL + assert user["role"] == "teacher" + assert user["auth_method"] == "local_jwt" + + @pytest.mark.asyncio + async def test_expired_token(self, authenticator): + """Test that expired tokens are rejected.""" + token = create_expired_token() + + with pytest.raises(TokenExpiredError): + await authenticator.validate_token(token) + + @pytest.mark.asyncio + async def test_invalid_token(self, authenticator): + """Test that invalid tokens are rejected.""" + with pytest.raises(TokenInvalidError): + await authenticator.validate_token("invalid.token.here") + + @pytest.mark.asyncio + async def test_empty_token(self, authenticator): + """Test that empty tokens are rejected.""" + with pytest.raises(TokenInvalidError): + await authenticator.validate_token("") + + @pytest.mark.asyncio + async def test_wrong_secret(self): + """Test that tokens signed with wrong secret are rejected.""" + auth = HybridAuthenticator( + keycloak_config=None, + local_jwt_secret="different-secret-key-here-32chars", + environment="development" + ) + + token = create_local_token() # Signed with TEST_JWT_SECRET + + with pytest.raises(TokenInvalidError): + await auth.validate_token(token) + + +# ============================================= +# HybridAuthenticator Tests (Role Mapping) +# ============================================= + +class TestRoleMapping: + """Tests for role mapping in HybridAuthenticator.""" + + @pytest.fixture + def authenticator(self): + return HybridAuthenticator( + keycloak_config=None, + local_jwt_secret=TEST_JWT_SECRET, + environment="development" + ) + + @pytest.mark.asyncio + async def test_admin_role_mapping(self, authenticator): + """Test that admin role is preserved.""" + token = create_local_token(role="admin") + + user = await authenticator.validate_token(token) + + assert user["role"] == "admin" + assert "admin" in user["realm_roles"] + + @pytest.mark.asyncio + async def test_teacher_role_mapping(self, authenticator): + """Test that teacher role is preserved.""" + token = create_local_token(role="teacher") + + user = await authenticator.validate_token(token) + + assert user["role"] == "teacher" + + @pytest.mark.asyncio + async def test_user_role_default(self, authenticator): + """Test that unknown roles default to user.""" + token = create_local_token(role="custom_role") + + user = await authenticator.validate_token(token) + + # Custom role should be preserved + assert user["role"] == "custom_role" + + +# ============================================= +# Environment Configuration Tests +# ============================================= + +class TestEnvironmentConfiguration: + """Tests for environment-based configuration.""" + + def test_keycloak_config_from_env_missing(self): + """Test that missing env vars return None.""" + # Clear any existing env vars + for key in ["KEYCLOAK_SERVER_URL", "KEYCLOAK_REALM", "KEYCLOAK_CLIENT_ID"]: + os.environ.pop(key, None) + + config = get_keycloak_config_from_env() + assert config is None + + def test_keycloak_config_from_env_complete(self): + """Test that complete env vars create config.""" + os.environ["KEYCLOAK_SERVER_URL"] = "https://kc.test.com" + os.environ["KEYCLOAK_REALM"] = "test" + os.environ["KEYCLOAK_CLIENT_ID"] = "test-client" + + try: + config = get_keycloak_config_from_env() + assert config is not None + assert config.server_url == "https://kc.test.com" + assert config.realm == "test" + assert config.client_id == "test-client" + finally: + # Cleanup + os.environ.pop("KEYCLOAK_SERVER_URL", None) + os.environ.pop("KEYCLOAK_REALM", None) + os.environ.pop("KEYCLOAK_CLIENT_ID", None) + + +# ============================================= +# Token Detection Tests +# ============================================= + +class TestTokenDetection: + """Tests for automatic token type detection.""" + + @pytest.mark.asyncio + async def test_local_token_detection(self): + """Test that local tokens are correctly detected.""" + auth = HybridAuthenticator( + keycloak_config=None, + local_jwt_secret=TEST_JWT_SECRET, + environment="development" + ) + + token = create_local_token() + user = await auth.validate_token(token) + + assert user["auth_method"] == "local_jwt" + + @pytest.mark.asyncio + async def test_keycloak_token_detection_without_keycloak(self): + """Test that Keycloak tokens fail when Keycloak is not configured.""" + auth = HybridAuthenticator( + keycloak_config=None, + local_jwt_secret=TEST_JWT_SECRET, + environment="development" + ) + + # Create a fake Keycloak-style token + payload = { + "sub": "user-123", + "email": "test@test.com", + "iss": "https://keycloak.example.com/realms/test", + "iat": datetime.now(timezone.utc), + "exp": datetime.now(timezone.utc) + timedelta(hours=1), + } + fake_kc_token = jwt.encode(payload, "different-key", algorithm="HS256") + + # Should fail because local JWT validation will fail + with pytest.raises(TokenInvalidError): + await auth.validate_token(fake_kc_token) + + +# ============================================= +# Integration Tests (with Mock FastAPI Request) +# ============================================= + +class TestFastAPIIntegration: + """Tests for FastAPI dependency integration.""" + + @pytest.mark.asyncio + async def test_get_current_user_valid_token(self): + """Test get_current_user with valid token.""" + from auth.keycloak_auth import get_current_user + from unittest.mock import AsyncMock + + # Create a mock request + mock_request = MagicMock() + token = create_local_token() + mock_request.headers.get.return_value = f"Bearer {token}" + + # Patch environment + with patch.dict(os.environ, { + "JWT_SECRET": TEST_JWT_SECRET, + "ENVIRONMENT": "development" + }): + # Reset the global authenticator + import auth.keycloak_auth as auth_module + auth_module._authenticator = None + + user = await get_current_user(mock_request) + + assert user["user_id"] == TEST_USER_ID + assert user["email"] == TEST_EMAIL + + @pytest.mark.asyncio + async def test_get_current_user_development_bypass(self): + """Test that development mode allows requests without token.""" + from auth.keycloak_auth import get_current_user + from fastapi import HTTPException + + mock_request = MagicMock() + mock_request.headers.get.return_value = "" # No auth header + + with patch.dict(os.environ, { + "JWT_SECRET": TEST_JWT_SECRET, + "ENVIRONMENT": "development" + }): + import auth.keycloak_auth as auth_module + auth_module._authenticator = None + + # In development, should return demo user + user = await get_current_user(mock_request) + assert user["auth_method"] == "development_bypass" + + +# ============================================= +# Security Tests +# ============================================= + +class TestSecurityEdgeCases: + """Tests for security edge cases.""" + + @pytest.mark.asyncio + async def test_no_jwt_secret_in_production(self): + """Test that missing JWT_SECRET raises error in production.""" + with patch.dict(os.environ, {"ENVIRONMENT": "production"}, clear=True): + with pytest.raises(KeycloakConfigError): + from auth.keycloak_auth import get_authenticator + # This should fail because JWT_SECRET is required in production + import auth.keycloak_auth as auth_module + auth_module._authenticator = None + get_authenticator() + + @pytest.mark.asyncio + async def test_tampered_token(self): + """Test that tampered tokens are rejected.""" + auth = HybridAuthenticator( + keycloak_config=None, + local_jwt_secret=TEST_JWT_SECRET, + environment="development" + ) + + token = create_local_token() + # Tamper with the token + parts = token.split(".") + parts[1] = parts[1][:-4] + "XXXX" # Modify payload + tampered = ".".join(parts) + + with pytest.raises(TokenInvalidError): + await auth.validate_token(tampered) + + @pytest.mark.asyncio + async def test_none_algorithm_attack(self): + """Test protection against 'none' algorithm attack.""" + auth = HybridAuthenticator( + keycloak_config=None, + local_jwt_secret=TEST_JWT_SECRET, + environment="development" + ) + + # Create a token with 'none' algorithm (attack vector) + header = {"alg": "none", "typ": "JWT"} + payload = {"user_id": "attacker", "role": "admin"} + + import base64 + import json + + h = base64.urlsafe_b64encode(json.dumps(header).encode()).rstrip(b"=").decode() + p = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b"=").decode() + malicious_token = f"{h}.{p}." + + with pytest.raises(TokenInvalidError): + await auth.validate_token(malicious_token) diff --git a/backend/tests/test_klausur_korrektur_api.py b/backend/tests/test_klausur_korrektur_api.py new file mode 100644 index 0000000..bf77b4c --- /dev/null +++ b/backend/tests/test_klausur_korrektur_api.py @@ -0,0 +1,346 @@ +""" +Tests fuer die Klausur-Korrektur API + +Tests fuer: +- Klausuren erstellen, abrufen, aktualisieren, loeschen +- Text-Quellen hinzufuegen und verwalten +- Schuelerarbeiten hochladen +- Bewertung und Gutachten +- 15-Punkte-Notensystem +""" + +import pytest +from unittest.mock import MagicMock, patch +from datetime import datetime + +# Import des zu testenden Moduls +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from klausur_korrektur_api import ( + router, + AbiturKlausur, + KlausurModus, + KlausurStatus, + TextSource, + TextSourceType, + TextSourceStatus, + StudentKlausur, + StudentKlausurStatus, + Erwartungshorizont, + Aufgabe, + CriterionScore, + Gutachten, + ExaminerResult, + DEFAULT_CRITERIA, + GRADE_THRESHOLDS, + calculate_15_point_grade, + klausuren_db, +) + + +class TestGradeCalculation: + """Tests fuer die Notenberechnung im 15-Punkte-System.""" + + def test_calculate_15_point_grade_perfect(self): + """100% sollte 15 Punkte ergeben.""" + assert calculate_15_point_grade(100.0) == 15 + + def test_calculate_15_point_grade_95(self): + """95% sollte 15 Punkte ergeben (1+).""" + assert calculate_15_point_grade(95.0) == 15 + + def test_calculate_15_point_grade_90(self): + """90% sollte 14 Punkte ergeben (1).""" + assert calculate_15_point_grade(90.0) == 14 + + def test_calculate_15_point_grade_85(self): + """85% sollte 13 Punkte ergeben (1-).""" + assert calculate_15_point_grade(85.0) == 13 + + def test_calculate_15_point_grade_50(self): + """50% sollte 6 Punkte ergeben (4+).""" + assert calculate_15_point_grade(50.0) == 6 + + def test_calculate_15_point_grade_45(self): + """45% sollte 5 Punkte ergeben (4).""" + assert calculate_15_point_grade(45.0) == 5 + + def test_calculate_15_point_grade_below_threshold(self): + """19% sollte 0 Punkte ergeben (6).""" + assert calculate_15_point_grade(19.0) == 0 + + def test_calculate_15_point_grade_zero(self): + """0% sollte 0 Punkte ergeben.""" + assert calculate_15_point_grade(0.0) == 0 + + def test_calculate_15_point_grade_boundary_values(self): + """Test aller Grenzwerte.""" + expected_results = [ + (95, 15), + (94.9, 14), + (90, 14), + (89.9, 13), + (85, 13), + (84.9, 12), + (80, 12), + (79.9, 11), + (75, 11), + (74.9, 10), + (70, 10), + (69.9, 9), + (65, 9), + (64.9, 8), + (60, 8), + (59.9, 7), + (55, 7), + (54.9, 6), + (50, 6), + (49.9, 5), + (45, 5), + (44.9, 4), + (40, 4), + (39.9, 3), + (33, 3), + (32.9, 2), + (27, 2), + (26.9, 1), + (20, 1), + (19.9, 0), + ] + for percentage, expected_points in expected_results: + result = calculate_15_point_grade(percentage) + assert result == expected_points, f"Expected {expected_points} for {percentage}%, got {result}" + + +class TestGradeThresholds: + """Tests fuer die Notenschwellen.""" + + def test_all_thresholds_present(self): + """Alle 16 Notenpunkte (0-15) sollten definiert sein.""" + assert len(GRADE_THRESHOLDS) == 16 + for i in range(16): + assert i in GRADE_THRESHOLDS + + def test_thresholds_descending(self): + """Schwellen sollten von 15 nach 0 absteigend sein.""" + prev_threshold = 100 + for points in range(15, -1, -1): + threshold = GRADE_THRESHOLDS[points] + assert threshold < prev_threshold or (points == 15 and threshold <= prev_threshold) + prev_threshold = threshold + + +class TestDefaultCriteria: + """Tests fuer die Standard-Bewertungskriterien.""" + + def test_criteria_weights_sum_to_one(self): + """Gewichte aller Kriterien sollten 1.0 ergeben.""" + total_weight = sum(c["weight"] for c in DEFAULT_CRITERIA.values()) + assert abs(total_weight - 1.0) < 0.001 + + def test_required_criteria_present(self): + """Alle erforderlichen Kriterien sollten vorhanden sein.""" + required = ["rechtschreibung", "grammatik", "inhalt", "struktur", "stil"] + for criterion in required: + assert criterion in DEFAULT_CRITERIA + + def test_inhalt_has_highest_weight(self): + """Inhalt sollte das hoechste Gewicht haben.""" + inhalt_weight = DEFAULT_CRITERIA["inhalt"]["weight"] + for name, criterion in DEFAULT_CRITERIA.items(): + if name != "inhalt": + assert criterion["weight"] <= inhalt_weight + + +class TestKlausurModels: + """Tests fuer die Datenmodelle.""" + + def test_create_abitur_klausur(self): + """Eine neue Klausur sollte erstellt werden koennen.""" + now = datetime.now() + klausur = AbiturKlausur( + id="test-123", + title="Deutsch LK Q4", + subject="deutsch", + modus=KlausurModus.LANDES_ABITUR, + year=2025, + semester="Q4", + kurs="LK", + class_id=None, + status=KlausurStatus.DRAFT, + text_sources=[], + erwartungshorizont=None, + students=[], + created_at=now, + updated_at=now + ) + assert klausur.id == "test-123" + assert klausur.modus == KlausurModus.LANDES_ABITUR + + def test_create_text_source(self): + """Eine Textquelle sollte erstellt werden koennen.""" + source = TextSource( + id="src-1", + source_type=TextSourceType.NIBIS, + title="Kafka - Die Verwandlung", + author="Franz Kafka", + content="Als Gregor Samsa eines Morgens...", + nibis_id=None, + license_status=TextSourceStatus.VERIFIED, + license_info={"license": "PD"}, + created_at=datetime.now() + ) + assert source.license_status == TextSourceStatus.VERIFIED + + def test_student_klausur_status_workflow(self): + """Der Status-Workflow einer Schuelerarbeit sollte korrekt sein.""" + statuses = list(StudentKlausurStatus) + expected_order = [ + StudentKlausurStatus.UPLOADED, + StudentKlausurStatus.OCR_PROCESSING, + StudentKlausurStatus.OCR_COMPLETE, + StudentKlausurStatus.ANALYZING, + StudentKlausurStatus.FIRST_EXAMINER, + StudentKlausurStatus.SECOND_EXAMINER, + StudentKlausurStatus.COMPLETED, + StudentKlausurStatus.ERROR, # Error state can occur at any point + ] + assert statuses == expected_order + + +class TestCriterionScore: + """Tests fuer die Bewertungskriterien-Punkte.""" + + def test_create_criterion_score(self): + """Ein Kriterium-Score sollte erstellt werden koennen.""" + score = CriterionScore( + score=85, + weight=0.4, + annotations=["Gute Argumentation"], + comment="Insgesamt gut", + ai_suggestions=["Mehr Beispiele hinzufuegen"] + ) + assert score.score == 85 + assert score.weight == 0.4 + + def test_weighted_score_calculation(self): + """Der gewichtete Score sollte korrekt berechnet werden.""" + score = CriterionScore( + score=80, + weight=0.4, + annotations=[], + comment="", + ai_suggestions=[] + ) + weighted = score.score * score.weight + assert weighted == 32.0 + + +class TestExpectationHorizon: + """Tests fuer den Erwartungshorizont.""" + + def test_create_aufgabe(self): + """Eine Aufgabe sollte erstellt werden koennen.""" + aufgabe = Aufgabe( + id="aufg-1", + nummer="1a", + text="Analysieren Sie das Gedicht.", + operator="analysieren", + anforderungsbereich=2, + erwartete_leistungen=["Epoche erkennen", "Stilmittel benennen"], + punkte=20 + ) + assert aufgabe.anforderungsbereich == 2 + assert aufgabe.punkte == 20 + + def test_create_erwartungshorizont(self): + """Ein Erwartungshorizont sollte erstellt werden koennen.""" + aufgaben = [ + Aufgabe(id="a1", nummer="1", text="Aufgabe 1", operator="analysieren", anforderungsbereich=2, + erwartete_leistungen=["Test"], punkte=30), + Aufgabe(id="a2", nummer="2", text="Aufgabe 2", operator="erlaeutern", anforderungsbereich=2, + erwartete_leistungen=["Test2"], punkte=30), + Aufgabe(id="a3", nummer="3", text="Aufgabe 3", operator="beurteilen", anforderungsbereich=3, + erwartete_leistungen=["Test3"], punkte=40), + ] + ewh = Erwartungshorizont( + id="ewh-1", + aufgaben=aufgaben, + max_points=100, + hinweise="Allgemeine Hinweise", + generated=False, + created_at=datetime.now() + ) + assert ewh.max_points == 100 + assert len(ewh.aufgaben) == 3 + total_points = sum(a.punkte for a in ewh.aufgaben) + assert total_points == 100 + + +class TestKlausurDB: + """Tests fuer die In-Memory Datenbank.""" + + def setup_method(self): + """Setup vor jedem Test - leere die DB.""" + klausuren_db.clear() + + def test_empty_db(self): + """Eine leere DB sollte leer sein.""" + assert len(klausuren_db) == 0 + + def test_add_klausur_to_db(self): + """Eine Klausur sollte zur DB hinzugefuegt werden koennen.""" + now = datetime.now() + klausur = AbiturKlausur( + id="test-1", + title="Test Klausur", + subject="deutsch", + modus=KlausurModus.VORABITUR, + year=2025, + semester="Q3", + kurs="GK", + class_id=None, + status=KlausurStatus.DRAFT, + text_sources=[], + erwartungshorizont=None, + students=[], + created_at=now, + updated_at=now + ) + klausuren_db["test-1"] = klausur + assert "test-1" in klausuren_db + assert klausuren_db["test-1"].title == "Test Klausur" + + +class TestKlausurModus: + """Tests fuer die Klausur-Modi.""" + + def test_landes_abitur_mode(self): + """Landes-Abitur Modus sollte existieren.""" + assert KlausurModus.LANDES_ABITUR.value == "landes_abitur" + + def test_vorabitur_mode(self): + """Vorabitur Modus sollte existieren.""" + assert KlausurModus.VORABITUR.value == "vorabitur" + + +class TestTextSourceStatus: + """Tests fuer den TextSource-Status.""" + + def test_pending_status(self): + """Pending Status sollte existieren.""" + assert TextSourceStatus.PENDING.value == "pending" + + def test_verified_status(self): + """Verified Status sollte existieren.""" + assert TextSourceStatus.VERIFIED.value == "verified" + + def test_rejected_status(self): + """Rejected Status sollte existieren.""" + assert TextSourceStatus.REJECTED.value == "rejected" + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/backend/tests/test_letters_api.py b/backend/tests/test_letters_api.py new file mode 100644 index 0000000..80ef7c8 --- /dev/null +++ b/backend/tests/test_letters_api.py @@ -0,0 +1,360 @@ +""" +Tests für die Letters API. + +Testet: +- CRUD-Operationen für Elternbriefe +- PDF-Export +- GFK-Verbesserungsvorschläge + +Note: Some tests require WeasyPrint which needs system libraries. +""" + +import pytest +from fastapi.testclient import TestClient +from unittest.mock import patch, AsyncMock, MagicMock +import sys +import os + +# Add parent directory to path +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Check if WeasyPrint is available (required for PDF endpoints) +try: + import weasyprint + WEASYPRINT_AVAILABLE = True +except (ImportError, OSError): + WEASYPRINT_AVAILABLE = False + + +class TestLettersAPIImport: + """Tests für Letters API Import.""" + + def test_import_letters_api(self): + """Test that letters_api can be imported.""" + from letters_api import router + assert router is not None + + def test_import_enums(self): + """Test that enums can be imported.""" + from letters_api import LetterType, LetterTone, LetterStatus + assert LetterType is not None + assert LetterTone is not None + assert LetterStatus is not None + + def test_import_models(self): + """Test that Pydantic models can be imported.""" + from letters_api import ( + LetterCreateRequest, + LetterUpdateRequest, + LetterResponse, + LetterListResponse, + ExportPDFRequest, + ImproveRequest, + ImproveResponse + ) + assert LetterCreateRequest is not None + assert LetterResponse is not None + + +class TestLetterTypes: + """Tests für Brieftypen.""" + + def test_letter_types_values(self): + """Test that all letter types have correct values.""" + from letters_api import LetterType + + expected_types = ["general", "halbjahr", "fehlzeiten", "elternabend", "lob", "custom"] + actual_types = [t.value for t in LetterType] + + for expected in expected_types: + assert expected in actual_types + + def test_letter_tones_values(self): + """Test that all tones have correct values.""" + from letters_api import LetterTone + + expected_tones = ["formal", "professional", "warm", "concerned", "appreciative"] + actual_tones = [t.value for t in LetterTone] + + for expected in expected_tones: + assert expected in actual_tones + + +class TestLetterCreateRequest: + """Tests für LetterCreateRequest Model.""" + + def test_create_minimal_request(self): + """Test creating a request with minimal required fields.""" + from letters_api import LetterCreateRequest + + request = LetterCreateRequest( + recipient_name="Familie Müller", + recipient_address="Musterstraße 1, 12345 Musterstadt", + student_name="Max Müller", + student_class="5a", + subject="Einladung Elternabend", + content="Sehr geehrte Familie Müller...", + teacher_name="Frau Schmidt" + ) + + assert request.recipient_name == "Familie Müller" + assert request.student_name == "Max Müller" + assert request.teacher_name == "Frau Schmidt" + + def test_create_full_request(self): + """Test creating a request with all fields.""" + from letters_api import LetterCreateRequest, LetterType, LetterTone, SchoolInfoModel + + school_info = SchoolInfoModel( + name="Musterschule", + address="Schulweg 1, 12345 Musterstadt", + phone="0123-456789", + email="info@musterschule.de" + ) + + request = LetterCreateRequest( + recipient_name="Familie Müller", + recipient_address="Musterstraße 1, 12345 Musterstadt", + student_name="Max Müller", + student_class="5a", + subject="Einladung Elternabend", + content="Sehr geehrte Familie Müller...", + teacher_name="Frau Schmidt", + teacher_title="Klassenlehrerin", + letter_type=LetterType.ELTERNABEND, + tone=LetterTone.PROFESSIONAL, + school_info=school_info, + gfk_principles_applied=["Beobachtung", "Bitte"] + ) + + assert request.letter_type == LetterType.ELTERNABEND + assert request.tone == LetterTone.PROFESSIONAL + assert request.school_info.name == "Musterschule" + + +class TestHelperFunctions: + """Tests für Helper-Funktionen.""" + + def test_get_type_label(self): + """Test type label function.""" + from letters_api import _get_type_label, LetterType + + assert "Einladung" in _get_type_label(LetterType.ELTERNABEND) + assert "Fehlzeiten" in _get_type_label(LetterType.FEHLZEITEN) + assert "Positives" in _get_type_label(LetterType.LOB) + + def test_get_tone_label(self): + """Test tone label function.""" + from letters_api import _get_tone_label, LetterTone + + assert "förmlich" in _get_tone_label(LetterTone.FORMAL) + assert "Professionell" in _get_tone_label(LetterTone.PROFESSIONAL) + assert "Warmherzig" in _get_tone_label(LetterTone.WARM) + + +@pytest.mark.skipif( + not WEASYPRINT_AVAILABLE, + reason="WeasyPrint not available (requires system libraries)" +) +class TestLettersAPIEndpoints: + """Integration tests für Letters API Endpoints.""" + + @pytest.fixture + def client(self): + """Create test client.""" + try: + from main import app + return TestClient(app) + except ImportError: + pytest.skip("main.py not available for testing") + + @pytest.fixture + def sample_letter_data(self): + """Sample letter data for tests.""" + return { + "recipient_name": "Familie Test", + "recipient_address": "Teststraße 1\n12345 Teststadt", + "student_name": "Test Kind", + "student_class": "5a", + "subject": "Testbrief", + "content": "Dies ist ein Testbrief.", + "teacher_name": "Herr Test", + "letter_type": "general", + "tone": "professional" + } + + def test_create_letter(self, client, sample_letter_data): + """Test creating a new letter.""" + if not client: + pytest.skip("Client not available") + + response = client.post("/api/letters/", json=sample_letter_data) + + assert response.status_code == 200 + data = response.json() + assert data["recipient_name"] == sample_letter_data["recipient_name"] + assert data["student_name"] == sample_letter_data["student_name"] + assert data["status"] == "draft" + assert "id" in data + + def test_get_letter(self, client, sample_letter_data): + """Test getting a letter by ID.""" + if not client: + pytest.skip("Client not available") + + # First create a letter + create_response = client.post("/api/letters/", json=sample_letter_data) + letter_id = create_response.json()["id"] + + # Then get it + response = client.get(f"/api/letters/{letter_id}") + + assert response.status_code == 200 + data = response.json() + assert data["id"] == letter_id + + def test_update_letter(self, client, sample_letter_data): + """Test updating a letter.""" + if not client: + pytest.skip("Client not available") + + # Create letter + create_response = client.post("/api/letters/", json=sample_letter_data) + letter_id = create_response.json()["id"] + + # Update it + update_data = {"subject": "Aktualisierter Betreff"} + response = client.put(f"/api/letters/{letter_id}", json=update_data) + + assert response.status_code == 200 + data = response.json() + assert data["subject"] == "Aktualisierter Betreff" + + def test_delete_letter(self, client, sample_letter_data): + """Test deleting a letter.""" + if not client: + pytest.skip("Client not available") + + # Create letter + create_response = client.post("/api/letters/", json=sample_letter_data) + letter_id = create_response.json()["id"] + + # Delete it + response = client.delete(f"/api/letters/{letter_id}") + assert response.status_code == 200 + + # Verify it's deleted + get_response = client.get(f"/api/letters/{letter_id}") + assert get_response.status_code == 404 + + def test_list_letters(self, client, sample_letter_data): + """Test listing letters.""" + if not client: + pytest.skip("Client not available") + + # Create a letter + client.post("/api/letters/", json=sample_letter_data) + + # List all + response = client.get("/api/letters/") + + assert response.status_code == 200 + data = response.json() + assert "letters" in data + assert "total" in data + assert isinstance(data["letters"], list) + + def test_get_letter_types(self, client): + """Test getting available letter types.""" + if not client: + pytest.skip("Client not available") + + response = client.get("/api/letters/types") + + assert response.status_code == 200 + data = response.json() + assert "types" in data + assert len(data["types"]) > 0 + + def test_get_letter_tones(self, client): + """Test getting available tones.""" + if not client: + pytest.skip("Client not available") + + response = client.get("/api/letters/tones") + + assert response.status_code == 200 + data = response.json() + assert "tones" in data + assert len(data["tones"]) > 0 + + def test_export_pdf(self, client, sample_letter_data): + """Test PDF export.""" + if not client: + pytest.skip("Client not available") + + # Create letter + create_response = client.post("/api/letters/", json=sample_letter_data) + letter_id = create_response.json()["id"] + + # Export as PDF + response = client.post(f"/api/letters/{letter_id}/export-pdf") + + assert response.status_code == 200 + assert response.headers["content-type"] == "application/pdf" + assert b"%PDF" in response.content[:10] + + def test_export_pdf_direct(self, client, sample_letter_data): + """Test direct PDF export without saving.""" + if not client: + pytest.skip("Client not available") + + export_data = {"letter_data": sample_letter_data} + response = client.post("/api/letters/export-pdf", json=export_data) + + assert response.status_code == 200 + assert response.headers["content-type"] == "application/pdf" + + def test_get_nonexistent_letter(self, client): + """Test getting a letter that doesn't exist.""" + if not client: + pytest.skip("Client not available") + + response = client.get("/api/letters/nonexistent-id") + assert response.status_code == 404 + + +class TestLetterImprove: + """Tests für GFK-Verbesserungsvorschläge.""" + + def test_improve_request_model(self): + """Test ImproveRequest model.""" + from letters_api import ImproveRequest + + request = ImproveRequest( + content="Der Schüler macht nie seine Hausaufgaben.", + communication_type="behavior", + tone="concerned" + ) + + assert request.content == "Der Schüler macht nie seine Hausaufgaben." + assert request.communication_type == "behavior" + + def test_improve_response_model(self): + """Test ImproveResponse model.""" + from letters_api import ImproveResponse + + response = ImproveResponse( + improved_content="Ich habe beobachtet, dass die Hausaufgaben...", + changes=["'nie' durch konkretes Datum ersetzt", "Ich-Botschaft verwendet"], + gfk_score=0.85, + gfk_principles_applied=["Beobachtung", "Gefühl", "Bedürfnis"] + ) + + assert response.gfk_score == 0.85 + assert "Beobachtung" in response.gfk_principles_applied + + +# Run tests if executed directly +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/backend/tests/test_llm_gateway/__init__.py b/backend/tests/test_llm_gateway/__init__.py new file mode 100644 index 0000000..91687e8 --- /dev/null +++ b/backend/tests/test_llm_gateway/__init__.py @@ -0,0 +1,3 @@ +""" +Tests für LLM Gateway. +""" diff --git a/backend/tests/test_llm_gateway/test_communication_service.py b/backend/tests/test_llm_gateway/test_communication_service.py new file mode 100644 index 0000000..a6e8c20 --- /dev/null +++ b/backend/tests/test_llm_gateway/test_communication_service.py @@ -0,0 +1,501 @@ +""" +Tests für den Communication Service. + +Testet die KI-gestützte Lehrer-Eltern-Kommunikation mit GFK-Prinzipien. +""" + +import pytest +from unittest.mock import AsyncMock, patch, MagicMock + +from llm_gateway.services.communication_service import ( + CommunicationService, + CommunicationType, + CommunicationTone, + LegalReference, + GFKPrinciple, + get_communication_service, + fetch_legal_references_from_db, + parse_db_references_to_legal_refs, + FALLBACK_LEGAL_REFERENCES, + GFK_PRINCIPLES, +) + + +class TestCommunicationType: + """Tests für CommunicationType Enum.""" + + def test_all_communication_types_exist(self): + """Test alle erwarteten Kommunikationstypen existieren.""" + expected_types = [ + "general_info", + "behavior", + "academic", + "attendance", + "meeting_invite", + "positive_feedback", + "concern", + "conflict", + "special_needs", + ] + actual_types = [ct.value for ct in CommunicationType] + assert set(expected_types) == set(actual_types) + + def test_communication_type_is_string_enum(self): + """Test CommunicationType ist String Enum.""" + assert CommunicationType.BEHAVIOR == "behavior" + assert CommunicationType.ACADEMIC.value == "academic" + + +class TestCommunicationTone: + """Tests für CommunicationTone Enum.""" + + def test_all_tones_exist(self): + """Test alle Tonalitäten existieren.""" + expected_tones = ["formal", "professional", "warm", "concerned", "appreciative"] + actual_tones = [t.value for t in CommunicationTone] + assert set(expected_tones) == set(actual_tones) + + +class TestLegalReference: + """Tests für LegalReference Dataclass.""" + + def test_legal_reference_creation(self): + """Test LegalReference erstellen.""" + ref = LegalReference( + law="SchulG NRW", + paragraph="§ 42", + title="Pflichten der Eltern", + summary="Eltern unterstützen die Schule.", + relevance="Kooperationsaufforderungen", + ) + assert ref.law == "SchulG NRW" + assert ref.paragraph == "§ 42" + assert "Eltern" in ref.title + + +class TestGFKPrinciple: + """Tests für GFKPrinciple Dataclass.""" + + def test_gfk_principle_creation(self): + """Test GFKPrinciple erstellen.""" + principle = GFKPrinciple( + principle="Beobachtung", + description="Konkrete Handlungen beschreiben", + example="Ich habe bemerkt...", + ) + assert principle.principle == "Beobachtung" + assert "beschreiben" in principle.description + + +class TestFallbackLegalReferences: + """Tests für die Fallback-Referenzen.""" + + def test_default_references_exist(self): + """Test DEFAULT Referenzen existieren.""" + assert "DEFAULT" in FALLBACK_LEGAL_REFERENCES + assert "elternpflichten" in FALLBACK_LEGAL_REFERENCES["DEFAULT"] + assert "schulpflicht" in FALLBACK_LEGAL_REFERENCES["DEFAULT"] + + def test_fallback_references_are_legal_reference(self): + """Test Fallback Referenzen sind LegalReference Objekte.""" + ref = FALLBACK_LEGAL_REFERENCES["DEFAULT"]["elternpflichten"] + assert isinstance(ref, LegalReference) + assert ref.law == "Landesschulgesetz" + + +class TestGFKPrinciples: + """Tests für GFK-Prinzipien.""" + + def test_four_gfk_principles_exist(self): + """Test alle 4 GFK-Prinzipien existieren.""" + assert len(GFK_PRINCIPLES) == 4 + principles = [p.principle for p in GFK_PRINCIPLES] + assert "Beobachtung" in principles + assert "Gefühle" in principles + assert "Bedürfnisse" in principles + assert "Bitten" in principles + + +class TestCommunicationService: + """Tests für CommunicationService Klasse.""" + + def test_service_initialization(self): + """Test Service wird korrekt initialisiert.""" + service = CommunicationService() + assert service.fallback_references is not None + assert service.gfk_principles is not None + assert service.templates is not None + assert service._cached_references == {} + + def test_get_legal_references_sync(self): + """Test synchrone get_legal_references Methode (Fallback).""" + service = CommunicationService() + refs = service.get_legal_references("NRW", "elternpflichten") + + assert len(refs) > 0 + assert isinstance(refs[0], LegalReference) + + def test_get_fallback_references(self): + """Test _get_fallback_references Methode.""" + service = CommunicationService() + refs = service._get_fallback_references("DEFAULT", "elternpflichten") + + assert len(refs) == 1 + assert refs[0].law == "Landesschulgesetz" + + def test_get_gfk_guidance(self): + """Test get_gfk_guidance gibt GFK-Prinzipien zurück.""" + service = CommunicationService() + guidance = service.get_gfk_guidance(CommunicationType.BEHAVIOR) + + assert len(guidance) == 4 + assert all(isinstance(g, GFKPrinciple) for g in guidance) + + def test_get_template(self): + """Test get_template gibt Vorlage zurück.""" + service = CommunicationService() + template = service.get_template(CommunicationType.MEETING_INVITE) + + assert "subject" in template + assert "opening" in template + assert "closing" in template + assert "Einladung" in template["subject"] + + def test_get_template_fallback(self): + """Test get_template Fallback zu GENERAL_INFO.""" + service = CommunicationService() + # Unbekannter Typ sollte auf GENERAL_INFO fallen + template = service.get_template(CommunicationType.GENERAL_INFO) + + assert "Information" in template["subject"] + + +class TestCommunicationServiceAsync: + """Async Tests für CommunicationService.""" + + @pytest.mark.asyncio + async def test_get_legal_references_async_with_db(self): + """Test async get_legal_references_async mit DB-Daten.""" + service = CommunicationService() + + # Mock die fetch_legal_references_from_db Funktion + mock_docs = [ + { + "law_name": "SchulG NRW", + "title": "Schulgesetz NRW", + "paragraphs": [ + {"nr": "§ 42", "title": "Pflichten der Eltern"}, + {"nr": "§ 41", "title": "Schulpflicht"}, + ], + } + ] + + with patch( + "llm_gateway.services.communication_service.fetch_legal_references_from_db", + new_callable=AsyncMock, + ) as mock_fetch: + mock_fetch.return_value = mock_docs + + refs = await service.get_legal_references_async("NRW", "elternpflichten") + + assert len(refs) > 0 + mock_fetch.assert_called_once_with("NRW") + + @pytest.mark.asyncio + async def test_get_legal_references_async_fallback(self): + """Test async get_legal_references_async Fallback wenn DB leer.""" + service = CommunicationService() + + with patch( + "llm_gateway.services.communication_service.fetch_legal_references_from_db", + new_callable=AsyncMock, + ) as mock_fetch: + mock_fetch.return_value = [] # Leere DB + + refs = await service.get_legal_references_async("NRW", "elternpflichten") + + # Sollte Fallback nutzen + assert len(refs) > 0 + assert refs[0].law == "Landesschulgesetz" + + @pytest.mark.asyncio + async def test_get_legal_references_async_caching(self): + """Test dass Ergebnisse gecached werden.""" + service = CommunicationService() + + mock_docs = [ + { + "law_name": "SchulG NRW", + "paragraphs": [{"nr": "§ 42", "title": "Pflichten der Eltern"}], + } + ] + + with patch( + "llm_gateway.services.communication_service.fetch_legal_references_from_db", + new_callable=AsyncMock, + ) as mock_fetch: + mock_fetch.return_value = mock_docs + + # Erster Aufruf + await service.get_legal_references_async("NRW", "elternpflichten") + + # Zweiter Aufruf sollte Cache nutzen + await service.get_legal_references_async("NRW", "elternpflichten") + + # fetch sollte nur einmal aufgerufen werden + assert mock_fetch.call_count == 1 + + +class TestFetchLegalReferencesFromDB: + """Tests für fetch_legal_references_from_db Funktion.""" + + @pytest.mark.asyncio + async def test_fetch_success(self): + """Test erfolgreicher API-Aufruf.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "documents": [ + {"law_name": "SchulG NRW", "paragraphs": []}, + ] + } + + with patch("httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.get.return_value = mock_response + mock_instance.__aenter__.return_value = mock_instance + mock_instance.__aexit__.return_value = None + mock_client.return_value = mock_instance + + docs = await fetch_legal_references_from_db("NRW") + + assert len(docs) == 1 + assert docs[0]["law_name"] == "SchulG NRW" + + @pytest.mark.asyncio + async def test_fetch_api_error(self): + """Test API-Fehler gibt leere Liste zurück.""" + mock_response = MagicMock() + mock_response.status_code = 500 + + with patch("httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.get.return_value = mock_response + mock_instance.__aenter__.return_value = mock_instance + mock_instance.__aexit__.return_value = None + mock_client.return_value = mock_instance + + docs = await fetch_legal_references_from_db("NRW") + + assert docs == [] + + @pytest.mark.asyncio + async def test_fetch_network_error(self): + """Test Netzwerkfehler gibt leere Liste zurück.""" + import httpx + + with patch("httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.get.side_effect = httpx.ConnectError("Connection failed") + mock_instance.__aenter__.return_value = mock_instance + mock_instance.__aexit__.return_value = None + mock_client.return_value = mock_instance + + docs = await fetch_legal_references_from_db("NRW") + + assert docs == [] + + +class TestParseDbReferencesToLegalRefs: + """Tests für parse_db_references_to_legal_refs Funktion.""" + + def test_parse_with_matching_paragraphs(self): + """Test Parsing mit passenden Paragraphen.""" + db_docs = [ + { + "law_name": "SchulG NRW", + "title": "Schulgesetz", + "paragraphs": [ + {"nr": "§ 42", "title": "Pflichten der Eltern"}, + {"nr": "§ 1", "title": "Bildungsauftrag"}, + ], + } + ] + + refs = parse_db_references_to_legal_refs(db_docs, "elternpflichten") + + assert len(refs) > 0 + # § 42 sollte für elternpflichten relevant sein + assert any("42" in r.paragraph for r in refs) + + def test_parse_without_paragraphs(self): + """Test Parsing ohne Paragraphen.""" + db_docs = [ + { + "law_name": "SchulG NRW", + "title": "Schulgesetz", + "paragraphs": [], + } + ] + + refs = parse_db_references_to_legal_refs(db_docs, "elternpflichten") + + # Sollte trotzdem Referenz erstellen + assert len(refs) == 1 + assert refs[0].law == "SchulG NRW" + + def test_parse_empty_docs(self): + """Test Parsing mit leerer Dokumentenliste.""" + refs = parse_db_references_to_legal_refs([], "elternpflichten") + + assert refs == [] + + +class TestBuildSystemPrompt: + """Tests für build_system_prompt Methode.""" + + def test_build_system_prompt_contains_gfk(self): + """Test System-Prompt enthält GFK-Prinzipien.""" + service = CommunicationService() + prompt = service.build_system_prompt( + CommunicationType.BEHAVIOR, + "NRW", + CommunicationTone.PROFESSIONAL, + ) + + # Prüfe Großbuchstaben-Varianten (wie im Prompt verwendet) + assert "BEOBACHTUNG" in prompt + assert "GEFÜHLE" in prompt + assert "BEDÜRFNISSE" in prompt + assert "BITTEN" in prompt + + def test_build_system_prompt_contains_tone(self): + """Test System-Prompt enthält Tonalität.""" + service = CommunicationService() + prompt = service.build_system_prompt( + CommunicationType.BEHAVIOR, + "NRW", + CommunicationTone.WARM, + ) + + assert "warmherzig" in prompt.lower() + + +class TestBuildUserPrompt: + """Tests für build_user_prompt Methode.""" + + def test_build_user_prompt_with_context(self): + """Test User-Prompt mit Kontext.""" + service = CommunicationService() + prompt = service.build_user_prompt( + CommunicationType.BEHAVIOR, + { + "student_name": "Max", + "parent_name": "Frau Müller", + "situation": "Max stört häufig den Unterricht.", + "additional_info": "Bereits 3x ermahnt.", + }, + ) + + assert "Max" in prompt + assert "Frau Müller" in prompt + assert "stört" in prompt + assert "ermahnt" in prompt + + +class TestValidateCommunication: + """Tests für validate_communication Methode.""" + + def test_validate_good_communication(self): + """Test Validierung einer guten Kommunikation.""" + service = CommunicationService() + text = """ + Sehr geehrte Frau Müller, + + ich habe bemerkt, dass Max in letzter Zeit häufiger abwesend war. + Ich möchte Sie gerne zu einem Gespräch einladen, da mir eine gute + Zusammenarbeit sehr wichtig ist. + + Wären Sie bereit, nächste Woche zu einem Termin zu kommen? + + Mit freundlichen Grüßen + """ + + result = service.validate_communication(text) + + assert result["is_valid"] is True + assert len(result["positive_elements"]) > 0 + assert result["gfk_score"] > 0.5 + + def test_validate_bad_communication(self): + """Test Validierung einer problematischen Kommunikation.""" + service = CommunicationService() + text = """ + Sehr geehrte Frau Müller, + + Sie müssen endlich etwas tun! Das Kind ist faul und respektlos. + Sie sollten mehr kontrollieren. + """ + + result = service.validate_communication(text) + + assert result["is_valid"] is False + assert len(result["issues"]) > 0 + assert len(result["suggestions"]) > 0 + + +class TestGetAllCommunicationTypes: + """Tests für get_all_communication_types Methode.""" + + def test_returns_all_types(self): + """Test gibt alle Typen zurück.""" + service = CommunicationService() + types = service.get_all_communication_types() + + assert len(types) == 9 + assert all("value" in t and "label" in t for t in types) + + +class TestGetAllTones: + """Tests für get_all_tones Methode.""" + + def test_returns_all_tones(self): + """Test gibt alle Tonalitäten zurück.""" + service = CommunicationService() + tones = service.get_all_tones() + + assert len(tones) == 5 + assert all("value" in t and "label" in t for t in tones) + + +class TestGetStates: + """Tests für get_states Methode.""" + + def test_returns_all_16_bundeslaender(self): + """Test gibt alle 16 Bundesländer zurück.""" + service = CommunicationService() + states = service.get_states() + + assert len(states) == 16 + # Prüfe einige + state_values = [s["value"] for s in states] + assert "NRW" in state_values + assert "BY" in state_values + assert "BE" in state_values + + +class TestGetCommunicationService: + """Tests für Singleton-Pattern.""" + + def test_singleton_pattern(self): + """Test dass get_communication_service immer dieselbe Instanz zurückgibt.""" + service1 = get_communication_service() + service2 = get_communication_service() + + assert service1 is service2 + + def test_returns_communication_service(self): + """Test dass CommunicationService zurückgegeben wird.""" + service = get_communication_service() + + assert isinstance(service, CommunicationService) diff --git a/backend/tests/test_llm_gateway/test_config.py b/backend/tests/test_llm_gateway/test_config.py new file mode 100644 index 0000000..feb7740 --- /dev/null +++ b/backend/tests/test_llm_gateway/test_config.py @@ -0,0 +1,175 @@ +""" +Tests für LLM Gateway Config. +""" + +import pytest +import os +from unittest.mock import patch +from llm_gateway.config import ( + GatewayConfig, + LLMBackendConfig, + load_config, + get_config, +) + + +class TestGatewayConfig: + """Tests für GatewayConfig Dataclass.""" + + def test_default_values(self): + """Test Standardwerte.""" + config = GatewayConfig() + assert config.host == "0.0.0.0" + assert config.port == 8002 + assert config.debug is False + assert config.rate_limit_requests_per_minute == 60 + assert config.log_level == "INFO" + + def test_custom_values(self): + """Test benutzerdefinierte Werte.""" + config = GatewayConfig( + host="127.0.0.1", + port=9000, + debug=True, + rate_limit_requests_per_minute=100, + ) + assert config.host == "127.0.0.1" + assert config.port == 9000 + assert config.debug is True + assert config.rate_limit_requests_per_minute == 100 + + +class TestLLMBackendConfig: + """Tests für LLMBackendConfig.""" + + def test_minimal_config(self): + """Test minimale Backend-Konfiguration.""" + config = LLMBackendConfig( + name="test", + base_url="http://localhost:8000", + ) + assert config.name == "test" + assert config.base_url == "http://localhost:8000" + assert config.api_key is None + assert config.enabled is True + + def test_full_config(self): + """Test vollständige Backend-Konfiguration.""" + config = LLMBackendConfig( + name="vllm", + base_url="http://gpu-server:8000", + api_key="secret-key", + default_model="llama-3.1-8b", + timeout=180, + enabled=True, + ) + assert config.api_key == "secret-key" + assert config.default_model == "llama-3.1-8b" + assert config.timeout == 180 + + +class TestLoadConfig: + """Tests für load_config Funktion.""" + + def test_load_config_defaults(self): + """Test Laden mit Standardwerten.""" + with patch.dict(os.environ, {}, clear=True): + config = load_config() + assert config.host == "0.0.0.0" + assert config.port == 8002 + assert config.debug is False + + def test_load_config_with_env_vars(self): + """Test Laden mit Umgebungsvariablen.""" + env = { + "LLM_GATEWAY_HOST": "127.0.0.1", + "LLM_GATEWAY_PORT": "9000", + "LLM_GATEWAY_DEBUG": "true", + "LLM_RATE_LIMIT_RPM": "120", + "LLM_LOG_LEVEL": "DEBUG", + } + with patch.dict(os.environ, env, clear=True): + config = load_config() + assert config.host == "127.0.0.1" + assert config.port == 9000 + assert config.debug is True + assert config.rate_limit_requests_per_minute == 120 + assert config.log_level == "DEBUG" + + def test_load_config_ollama_backend(self): + """Test Ollama Backend Konfiguration.""" + env = { + "OLLAMA_BASE_URL": "http://localhost:11434", + "OLLAMA_DEFAULT_MODEL": "mistral:7b", + "OLLAMA_TIMEOUT": "60", + "OLLAMA_ENABLED": "true", + } + with patch.dict(os.environ, env, clear=True): + config = load_config() + assert config.ollama is not None + assert config.ollama.base_url == "http://localhost:11434" + assert config.ollama.default_model == "mistral:7b" + assert config.ollama.timeout == 60 + assert config.ollama.enabled is True + + def test_load_config_vllm_backend(self): + """Test vLLM Backend Konfiguration.""" + env = { + "VLLM_BASE_URL": "http://gpu-server:8000", + "VLLM_API_KEY": "secret-key", + "VLLM_DEFAULT_MODEL": "meta-llama/Llama-3.1-8B-Instruct", + "VLLM_ENABLED": "true", + } + with patch.dict(os.environ, env, clear=True): + config = load_config() + assert config.vllm is not None + assert config.vllm.base_url == "http://gpu-server:8000" + assert config.vllm.api_key == "secret-key" + assert config.vllm.enabled is True + + def test_load_config_anthropic_backend(self): + """Test Anthropic Backend Konfiguration.""" + env = { + "ANTHROPIC_API_KEY": "sk-ant-xxx", + "ANTHROPIC_DEFAULT_MODEL": "claude-3-5-sonnet-20241022", + "ANTHROPIC_ENABLED": "true", + } + with patch.dict(os.environ, env, clear=True): + config = load_config() + assert config.anthropic is not None + assert config.anthropic.api_key == "sk-ant-xxx" + assert config.anthropic.default_model == "claude-3-5-sonnet-20241022" + assert config.anthropic.enabled is True + + def test_load_config_no_anthropic_without_key(self): + """Test dass Anthropic ohne Key nicht konfiguriert wird.""" + with patch.dict(os.environ, {}, clear=True): + config = load_config() + assert config.anthropic is None + + def test_load_config_backend_priority(self): + """Test Backend Priorität.""" + env = { + "LLM_BACKEND_PRIORITY": "vllm,anthropic,ollama", + } + with patch.dict(os.environ, env, clear=True): + config = load_config() + assert config.backend_priority == ["vllm", "anthropic", "ollama"] + + def test_load_config_api_keys(self): + """Test API Keys Liste.""" + env = { + "LLM_API_KEYS": "key1,key2,key3", + } + with patch.dict(os.environ, env, clear=True): + config = load_config() + assert config.api_keys == ["key1", "key2", "key3"] + + def test_load_config_jwt_secret(self): + """Test JWT Secret.""" + env = { + "JWT_SECRET": "my-secret-key", + } + with patch.dict(os.environ, env, clear=True): + config = load_config() + assert config.jwt_secret == "my-secret-key" diff --git a/backend/tests/test_llm_gateway/test_inference_service.py b/backend/tests/test_llm_gateway/test_inference_service.py new file mode 100644 index 0000000..25d106f --- /dev/null +++ b/backend/tests/test_llm_gateway/test_inference_service.py @@ -0,0 +1,195 @@ +""" +Tests für Inference Service. +""" + +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from llm_gateway.services.inference import ( + InferenceService, + InferenceResult, + get_inference_service, +) +from llm_gateway.models.chat import ( + ChatCompletionRequest, + ChatMessage, + Usage, +) + + +class TestInferenceServiceModelMapping: + """Tests für Model Mapping.""" + + def setup_method(self): + """Setup für jeden Test.""" + self.service = InferenceService() + + def test_map_breakpilot_model_to_ollama(self): + """Test Mapping von BreakPilot Modell zu Ollama.""" + # Mock Ollama als verfügbares Backend + with patch.object(self.service, 'config') as mock_config: + mock_config.ollama = MagicMock() + mock_config.ollama.name = "ollama" + mock_config.ollama.enabled = True + mock_config.vllm = None + mock_config.anthropic = None + mock_config.backend_priority = ["ollama", "vllm", "anthropic"] + + actual_model, backend = self.service._map_model_to_backend("breakpilot-teacher-8b") + assert actual_model == "llama3.1:8b" + assert backend.name == "ollama" + + def test_map_breakpilot_70b_model(self): + """Test Mapping von 70B Modell.""" + with patch.object(self.service, 'config') as mock_config: + mock_config.ollama = MagicMock() + mock_config.ollama.name = "ollama" + mock_config.ollama.enabled = True + mock_config.vllm = None + mock_config.anthropic = None + mock_config.backend_priority = ["ollama"] + + actual_model, backend = self.service._map_model_to_backend("breakpilot-teacher-70b") + assert "70b" in actual_model.lower() + + def test_map_claude_model_to_anthropic(self): + """Test Mapping von Claude Modell zu Anthropic.""" + with patch.object(self.service, 'config') as mock_config: + mock_config.ollama = None + mock_config.vllm = None + mock_config.anthropic = MagicMock() + mock_config.anthropic.name = "anthropic" + mock_config.anthropic.enabled = True + mock_config.anthropic.default_model = "claude-3-5-sonnet-20241022" + mock_config.backend_priority = ["anthropic"] + + actual_model, backend = self.service._map_model_to_backend("claude-3-5-sonnet") + assert backend.name == "anthropic" + assert "claude" in actual_model.lower() + + def test_map_model_no_backend_available(self): + """Test Fehler wenn kein Backend verfügbar.""" + with patch.object(self.service, 'config') as mock_config: + mock_config.ollama = None + mock_config.vllm = None + mock_config.anthropic = None + mock_config.backend_priority = [] + + with pytest.raises(ValueError, match="No LLM backend available"): + self.service._map_model_to_backend("breakpilot-teacher-8b") + + +class TestInferenceServiceBackendSelection: + """Tests für Backend-Auswahl.""" + + def setup_method(self): + """Setup für jeden Test.""" + self.service = InferenceService() + + def test_get_available_backend_priority(self): + """Test Backend-Auswahl nach Priorität.""" + with patch.object(self.service, 'config') as mock_config: + # Beide Backends verfügbar + mock_config.ollama = MagicMock() + mock_config.ollama.enabled = True + mock_config.vllm = MagicMock() + mock_config.vllm.enabled = True + mock_config.anthropic = None + mock_config.backend_priority = ["vllm", "ollama"] + + backend = self.service._get_available_backend() + # vLLM hat höhere Priorität + assert backend == mock_config.vllm + + def test_get_available_backend_fallback(self): + """Test Fallback wenn primäres Backend nicht verfügbar.""" + with patch.object(self.service, 'config') as mock_config: + mock_config.ollama = MagicMock() + mock_config.ollama.enabled = True + mock_config.vllm = MagicMock() + mock_config.vllm.enabled = False # Deaktiviert + mock_config.anthropic = None + mock_config.backend_priority = ["vllm", "ollama"] + + backend = self.service._get_available_backend() + # Ollama als Fallback + assert backend == mock_config.ollama + + def test_get_available_backend_none_available(self): + """Test wenn kein Backend verfügbar.""" + with patch.object(self.service, 'config') as mock_config: + mock_config.ollama = None + mock_config.vllm = None + mock_config.anthropic = None + mock_config.backend_priority = ["ollama", "vllm", "anthropic"] + + backend = self.service._get_available_backend() + assert backend is None + + +class TestInferenceResult: + """Tests für InferenceResult Dataclass.""" + + def test_inference_result_creation(self): + """Test InferenceResult erstellen.""" + result = InferenceResult( + content="Hello, world!", + model="llama3.1:8b", + backend="ollama", + usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15), + finish_reason="stop", + ) + assert result.content == "Hello, world!" + assert result.model == "llama3.1:8b" + assert result.backend == "ollama" + assert result.usage.total_tokens == 15 + + def test_inference_result_defaults(self): + """Test Standardwerte.""" + result = InferenceResult( + content="Test", + model="test", + backend="test", + ) + assert result.usage is None + assert result.finish_reason == "stop" + + +class TestInferenceServiceComplete: + """Tests für complete() Methode.""" + + @pytest.mark.asyncio + async def test_complete_calls_correct_backend(self): + """Test dass correct Backend aufgerufen wird.""" + service = InferenceService() + + request = ChatCompletionRequest( + model="breakpilot-teacher-8b", + messages=[ChatMessage(role="user", content="Hello")], + ) + + # Mock das Backend + with patch.object(service, '_map_model_to_backend') as mock_map: + with patch.object(service, '_call_ollama') as mock_call: + mock_backend = MagicMock() + mock_backend.name = "ollama" + mock_map.return_value = ("llama3.1:8b", mock_backend) + mock_call.return_value = InferenceResult( + content="Hello!", + model="llama3.1:8b", + backend="ollama", + ) + + response = await service.complete(request) + + mock_call.assert_called_once() + assert response.choices[0].message.content == "Hello!" + + +class TestGetInferenceServiceSingleton: + """Tests für Singleton Pattern.""" + + def test_singleton_returns_same_instance(self): + """Test dass get_inference_service Singleton zurückgibt.""" + service1 = get_inference_service() + service2 = get_inference_service() + assert service1 is service2 diff --git a/backend/tests/test_llm_gateway/test_legal_crawler.py b/backend/tests/test_llm_gateway/test_legal_crawler.py new file mode 100644 index 0000000..0c78d4f --- /dev/null +++ b/backend/tests/test_llm_gateway/test_legal_crawler.py @@ -0,0 +1,237 @@ +""" +Tests für den Legal Crawler Service. + +Testet das Crawlen und Parsen von rechtlichen Bildungsinhalten. +""" + +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +import httpx + +from llm_gateway.services.legal_crawler import ( + LegalCrawler, + CrawledDocument, + get_legal_crawler, +) + + +class TestLegalCrawler: + """Tests für LegalCrawler Klasse.""" + + def test_crawler_initialization(self): + """Test Crawler wird korrekt initialisiert.""" + crawler = LegalCrawler() + assert crawler.user_agent == "BreakPilot-Crawler/1.0 (Educational Purpose)" + assert crawler.timeout == 30.0 + assert crawler.rate_limit_delay == 1.0 + assert crawler.db_pool is None + + def test_crawler_with_db_pool(self): + """Test Crawler mit DB Pool.""" + mock_pool = MagicMock() + crawler = LegalCrawler(db_pool=mock_pool) + assert crawler.db_pool == mock_pool + + +class TestCrawledDocument: + """Tests für CrawledDocument Dataclass.""" + + def test_document_creation(self): + """Test CrawledDocument erstellen.""" + doc = CrawledDocument( + url="https://example.com/schulgesetz", + canonical_url="https://example.com/schulgesetz", + title="Schulgesetz NRW", + content="§ 1 Bildungsauftrag...", + content_hash="abc123", + category="legal", + doc_type="schulgesetz", + state="NW", + law_name="SchulG NRW", + paragraphs=[{"nr": "§ 1", "title": "Bildungsauftrag"}], + trust_score=0.9, + ) + assert doc.url == "https://example.com/schulgesetz" + assert doc.state == "NW" + assert doc.law_name == "SchulG NRW" + assert len(doc.paragraphs) == 1 + + def test_document_without_optional_fields(self): + """Test CrawledDocument ohne optionale Felder.""" + doc = CrawledDocument( + url="https://example.com/info", + canonical_url=None, + title="Info Page", + content="Some content", + content_hash="def456", + category="legal", + doc_type="info", + state=None, + law_name=None, + paragraphs=None, + trust_score=0.5, + ) + assert doc.state is None + assert doc.paragraphs is None + + +class TestParagraphExtraction: + """Tests für die Paragraphen-Extraktion.""" + + def test_extract_paragraphs_from_html(self): + """Test Paragraphen werden aus HTML extrahiert.""" + crawler = LegalCrawler() + html_content = """ + § 1 Bildungsauftrag + Die Schule hat den Auftrag... + + § 2 Erziehungsauftrag + Die Schule erzieht... + + § 42 Pflichten der Eltern + Die Eltern sind verpflichtet... + """ + from bs4 import BeautifulSoup + soup = BeautifulSoup("", "html.parser") + + paragraphs = crawler._extract_paragraphs(soup, html_content) + + assert paragraphs is not None + assert len(paragraphs) >= 3 + # Prüfe dass § 42 gefunden wurde + para_numbers = [p["nr"] for p in paragraphs] + assert any("42" in nr for nr in para_numbers) + + def test_extract_paragraphs_empty_content(self): + """Test keine Paragraphen bei leerem Content.""" + crawler = LegalCrawler() + from bs4 import BeautifulSoup + soup = BeautifulSoup("", "html.parser") + + paragraphs = crawler._extract_paragraphs(soup, "") + + assert paragraphs is None or len(paragraphs) == 0 + + def test_extract_paragraphs_no_pattern_match(self): + """Test keine Paragraphen wenn kein Pattern matched.""" + crawler = LegalCrawler() + from bs4 import BeautifulSoup + soup = BeautifulSoup("", "html.parser") + + paragraphs = crawler._extract_paragraphs(soup, "Just some text without paragraphs") + + assert paragraphs is None or len(paragraphs) == 0 + + +class TestCrawlUrl: + """Tests für das URL-Crawling.""" + + @pytest.mark.asyncio + async def test_crawl_url_html_success(self): + """Test erfolgreiches Crawlen einer HTML-URL.""" + crawler = LegalCrawler() + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {"content-type": "text/html; charset=utf-8"} + mock_response.text = """ + + Schulgesetz NRW + +
            + § 1 Bildungsauftrag + Die Schule hat den Auftrag... +
            + + + """ + mock_response.url = "https://example.com/schulgesetz" + + with patch("httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.get.return_value = mock_response + mock_instance.__aenter__.return_value = mock_instance + mock_instance.__aexit__.return_value = None + mock_client.return_value = mock_instance + + seed_info = {"name": "SchulG NRW", "state": "NW", "trust_boost": 0.95} + doc = await crawler.crawl_url("https://example.com/schulgesetz", seed_info) + + assert doc is not None + assert doc.title == "Schulgesetz NRW" + assert doc.state == "NW" + assert doc.trust_score == 0.95 + + @pytest.mark.asyncio + async def test_crawl_url_404_returns_none(self): + """Test 404 Error gibt None zurück.""" + crawler = LegalCrawler() + + mock_response = MagicMock() + mock_response.status_code = 404 + + with patch("httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.get.return_value = mock_response + mock_instance.__aenter__.return_value = mock_instance + mock_instance.__aexit__.return_value = None + mock_client.return_value = mock_instance + + doc = await crawler.crawl_url("https://example.com/notfound", {}) + + assert doc is None + + @pytest.mark.asyncio + async def test_crawl_url_network_error_returns_none(self): + """Test Netzwerkfehler gibt None zurück.""" + crawler = LegalCrawler() + + with patch("httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.get.side_effect = httpx.ConnectError("Network error") + mock_instance.__aenter__.return_value = mock_instance + mock_instance.__aexit__.return_value = None + mock_client.return_value = mock_instance + + doc = await crawler.crawl_url("https://example.com/error", {}) + + assert doc is None + + @pytest.mark.asyncio + async def test_crawl_url_pdf_returns_none(self): + """Test PDF URLs werden aktuell übersprungen (not implemented).""" + crawler = LegalCrawler() + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.headers = {"content-type": "application/pdf"} + mock_response.content = b"%PDF-1.4..." + + with patch("httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.get.return_value = mock_response + mock_instance.__aenter__.return_value = mock_instance + mock_instance.__aexit__.return_value = None + mock_client.return_value = mock_instance + + doc = await crawler.crawl_url("https://example.com/doc.pdf", {}) + + # PDF extraction ist noch nicht implementiert + assert doc is None + + +class TestGetLegalCrawler: + """Tests für Singleton-Pattern.""" + + def test_get_legal_crawler_singleton(self): + """Test dass get_legal_crawler immer dieselbe Instanz zurückgibt.""" + crawler1 = get_legal_crawler() + crawler2 = get_legal_crawler() + + assert crawler1 is crawler2 + + def test_get_legal_crawler_returns_crawler(self): + """Test dass get_legal_crawler einen LegalCrawler zurückgibt.""" + crawler = get_legal_crawler() + + assert isinstance(crawler, LegalCrawler) diff --git a/backend/tests/test_llm_gateway/test_models.py b/backend/tests/test_llm_gateway/test_models.py new file mode 100644 index 0000000..adbf7ac --- /dev/null +++ b/backend/tests/test_llm_gateway/test_models.py @@ -0,0 +1,204 @@ +""" +Tests für LLM Gateway Pydantic Models. +""" + +import pytest +from llm_gateway.models.chat import ( + ChatMessage, + ChatCompletionRequest, + ChatCompletionResponse, + ChatCompletionChunk, + ChatChoice, + StreamChoice, + ChatChoiceDelta, + Usage, + ModelInfo, + ModelListResponse, + RequestMetadata, +) + + +class TestChatMessage: + """Tests für ChatMessage Model.""" + + def test_user_message(self): + """Test User Message erstellen.""" + msg = ChatMessage(role="user", content="Hello") + assert msg.role == "user" + assert msg.content == "Hello" + assert msg.name is None + + def test_assistant_message(self): + """Test Assistant Message erstellen.""" + msg = ChatMessage(role="assistant", content="Hi there!") + assert msg.role == "assistant" + assert msg.content == "Hi there!" + + def test_system_message(self): + """Test System Message erstellen.""" + msg = ChatMessage(role="system", content="You are a helpful assistant.") + assert msg.role == "system" + + def test_tool_message(self): + """Test Tool Message erstellen.""" + msg = ChatMessage(role="tool", content='{"result": "success"}', tool_call_id="call_123") + assert msg.role == "tool" + assert msg.tool_call_id == "call_123" + + +class TestChatCompletionRequest: + """Tests für ChatCompletionRequest Model.""" + + def test_minimal_request(self): + """Test minimale Request.""" + req = ChatCompletionRequest( + model="breakpilot-teacher-8b", + messages=[ChatMessage(role="user", content="Hello")], + ) + assert req.model == "breakpilot-teacher-8b" + assert len(req.messages) == 1 + assert req.stream is False + assert req.temperature == 0.7 + + def test_full_request(self): + """Test vollständige Request.""" + req = ChatCompletionRequest( + model="breakpilot-teacher-70b", + messages=[ + ChatMessage(role="system", content="Du bist ein Assistent."), + ChatMessage(role="user", content="Schreibe einen Brief."), + ], + stream=True, + temperature=0.5, + max_tokens=1000, + metadata=RequestMetadata(playbook_id="pb_elternbrief"), + ) + assert req.stream is True + assert req.temperature == 0.5 + assert req.max_tokens == 1000 + assert req.metadata.playbook_id == "pb_elternbrief" + + def test_temperature_bounds(self): + """Test Temperature Grenzen.""" + # Gültige Werte + req = ChatCompletionRequest( + model="test", + messages=[ChatMessage(role="user", content="test")], + temperature=0.0, + ) + assert req.temperature == 0.0 + + req = ChatCompletionRequest( + model="test", + messages=[ChatMessage(role="user", content="test")], + temperature=2.0, + ) + assert req.temperature == 2.0 + + # Ungültige Werte + with pytest.raises(ValueError): + ChatCompletionRequest( + model="test", + messages=[ChatMessage(role="user", content="test")], + temperature=2.5, + ) + + +class TestChatCompletionResponse: + """Tests für ChatCompletionResponse Model.""" + + def test_response_creation(self): + """Test Response erstellen.""" + response = ChatCompletionResponse( + model="breakpilot-teacher-8b", + choices=[ + ChatChoice( + index=0, + message=ChatMessage(role="assistant", content="Hello!"), + finish_reason="stop", + ) + ], + usage=Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15), + ) + assert response.object == "chat.completion" + assert response.model == "breakpilot-teacher-8b" + assert len(response.choices) == 1 + assert response.choices[0].message.content == "Hello!" + assert response.usage.total_tokens == 15 + + def test_response_has_id(self): + """Test dass Response eine ID hat.""" + response = ChatCompletionResponse( + model="test", + choices=[ + ChatChoice( + message=ChatMessage(role="assistant", content="test"), + ) + ], + ) + assert response.id.startswith("chatcmpl-") + assert len(response.id) > 10 + + +class TestChatCompletionChunk: + """Tests für Streaming Chunks.""" + + def test_chunk_creation(self): + """Test Chunk erstellen.""" + chunk = ChatCompletionChunk( + model="breakpilot-teacher-8b", + choices=[ + StreamChoice( + index=0, + delta=ChatChoiceDelta(content="Hello"), + finish_reason=None, + ) + ], + ) + assert chunk.object == "chat.completion.chunk" + assert chunk.choices[0].delta.content == "Hello" + + def test_final_chunk(self): + """Test Final Chunk mit finish_reason.""" + chunk = ChatCompletionChunk( + model="test", + choices=[ + StreamChoice( + index=0, + delta=ChatChoiceDelta(), + finish_reason="stop", + ) + ], + ) + assert chunk.choices[0].finish_reason == "stop" + + +class TestModelInfo: + """Tests für ModelInfo.""" + + def test_model_info(self): + """Test ModelInfo erstellen.""" + model = ModelInfo( + id="breakpilot-teacher-8b", + owned_by="breakpilot", + description="Test model", + context_length=8192, + ) + assert model.id == "breakpilot-teacher-8b" + assert model.object == "model" + assert model.context_length == 8192 + + +class TestModelListResponse: + """Tests für ModelListResponse.""" + + def test_model_list(self): + """Test Model List erstellen.""" + response = ModelListResponse( + data=[ + ModelInfo(id="model-1", owned_by="test"), + ModelInfo(id="model-2", owned_by="test"), + ] + ) + assert response.object == "list" + assert len(response.data) == 2 diff --git a/backend/tests/test_llm_gateway/test_pii_detector.py b/backend/tests/test_llm_gateway/test_pii_detector.py new file mode 100644 index 0000000..a2c34ce --- /dev/null +++ b/backend/tests/test_llm_gateway/test_pii_detector.py @@ -0,0 +1,296 @@ +""" +Tests für PII Detector Service. +""" + +import pytest +from llm_gateway.services.pii_detector import ( + PIIDetector, + PIIType, + PIIMatch, + RedactionResult, + get_pii_detector, +) + + +class TestPIIDetectorPatterns: + """Tests für PII-Erkennung.""" + + def setup_method(self): + """Setup für jeden Test.""" + self.detector = PIIDetector() + + def test_detect_email(self): + """Test E-Mail-Erkennung.""" + text = "Kontakt: max.mustermann@example.com für Rückfragen" + matches = self.detector.detect(text) + + assert len(matches) == 1 + assert matches[0].type == PIIType.EMAIL + assert matches[0].value == "max.mustermann@example.com" + + def test_detect_multiple_emails(self): + """Test mehrerer E-Mail-Adressen.""" + text = "Von: a@b.de An: c@d.com CC: e@f.org" + matches = self.detector.detect(text) + + assert len(matches) == 3 + assert all(m.type == PIIType.EMAIL for m in matches) + + def test_detect_german_phone(self): + """Test deutsche Telefonnummer.""" + text = "Erreichbar unter 089 12345678" + matches = self.detector.detect(text) + + assert len(matches) == 1 + assert matches[0].type == PIIType.PHONE + + def test_detect_phone_with_country_code(self): + """Test Telefonnummer mit Landesvorwahl.""" + text = "Tel: +49 30 1234567" + matches = self.detector.detect(text) + + assert len(matches) == 1 + assert matches[0].type == PIIType.PHONE + + def test_detect_iban(self): + """Test IBAN-Erkennung.""" + text = "IBAN: DE89370400440532013000" + matches = self.detector.detect(text) + + # IBAN sollte erkannt werden (evtl. auch als Telefon, aber IBAN hat Priorität) + iban_matches = [m for m in matches if m.type == PIIType.IBAN] + assert len(iban_matches) >= 1 + + def test_detect_iban_with_spaces(self): + """Test IBAN mit Leerzeichen.""" + text = "Konto: DE89 3704 0044 0532 0130 00" + matches = self.detector.detect(text) + + # Bei überlappenden Matches gewinnt IBAN wegen höherer Priorität + iban_matches = [m for m in matches if m.type == PIIType.IBAN] + assert len(iban_matches) >= 1 + + def test_detect_credit_card_visa(self): + """Test Visa Kreditkarte.""" + text = "Karte: 4111 1111 1111 1111" + matches = self.detector.detect(text) + + assert len(matches) == 1 + assert matches[0].type == PIIType.CREDIT_CARD + + def test_detect_credit_card_mastercard(self): + """Test Mastercard.""" + text = "MC: 5500 0000 0000 0004" + matches = self.detector.detect(text) + + # Kreditkarte hat höhere Priorität als Telefon + cc_matches = [m for m in matches if m.type == PIIType.CREDIT_CARD] + assert len(cc_matches) >= 1 + + def test_detect_ip_address(self): + """Test IP-Adresse.""" + text = "Server IP: 192.168.1.100" + matches = self.detector.detect(text) + + assert len(matches) == 1 + assert matches[0].type == PIIType.IP_ADDRESS + + def test_detect_date_of_birth(self): + """Test Geburtsdatum.""" + text = "Geboren am 15.03.1985" + matches = self.detector.detect(text) + + assert len(matches) == 1 + assert matches[0].type == PIIType.DATE_OF_BIRTH + + def test_detect_date_single_digit(self): + """Test Datum mit einstelligem Tag/Monat.""" + text = "DOB: 1.5.1990" + matches = self.detector.detect(text) + + assert len(matches) == 1 + assert matches[0].type == PIIType.DATE_OF_BIRTH + + def test_no_false_positive_year(self): + """Test dass Jahre allein nicht erkannt werden.""" + text = "Im Jahr 2024 wurde das System eingeführt" + matches = self.detector.detect(text) + + # Sollte kein DOB sein + assert all(m.type != PIIType.DATE_OF_BIRTH for m in matches) + + +class TestPIIDetectorRedaction: + """Tests für PII-Redaktion.""" + + def setup_method(self): + """Setup für jeden Test.""" + self.detector = PIIDetector() + + def test_redact_email(self): + """Test E-Mail Redaktion.""" + text = "Mail an test@example.com senden" + result = self.detector.redact(text) + + assert result.pii_found is True + assert "test@example.com" not in result.redacted_text + assert "[EMAIL_REDACTED]" in result.redacted_text + assert result.original_text == text + + def test_redact_multiple_pii(self): + """Test Redaktion mehrerer PII-Typen.""" + text = "Kontakt: max@test.de, Tel: 089 123456" + result = self.detector.redact(text) + + assert result.pii_found is True + assert len(result.matches) == 2 + assert "[EMAIL_REDACTED]" in result.redacted_text + assert "[PHONE_REDACTED]" in result.redacted_text + + def test_redact_preserves_structure(self): + """Test dass Textstruktur erhalten bleibt.""" + text = "Von: a@b.de\nAn: c@d.de" + result = self.detector.redact(text) + + assert "\n" in result.redacted_text + assert "Von:" in result.redacted_text + assert "An:" in result.redacted_text + + def test_no_pii_returns_original(self): + """Test ohne PII gibt Original zurück.""" + text = "Keine personenbezogenen Daten hier" + result = self.detector.redact(text) + + assert result.pii_found is False + assert result.redacted_text == text + assert len(result.matches) == 0 + + +class TestPIIDetectorContainsPII: + """Tests für schnelle PII-Prüfung.""" + + def setup_method(self): + """Setup für jeden Test.""" + self.detector = PIIDetector() + + def test_contains_pii_with_email(self): + """Test contains_pii mit E-Mail.""" + assert self.detector.contains_pii("test@example.com") is True + + def test_contains_pii_with_phone(self): + """Test contains_pii mit Telefon.""" + assert self.detector.contains_pii("+49 89 123456") is True + + def test_contains_pii_without_pii(self): + """Test contains_pii ohne PII.""" + assert self.detector.contains_pii("Schulrecht Bayern") is False + + +class TestPIIDetectorConfiguration: + """Tests für Detector-Konfiguration.""" + + def test_custom_enabled_types(self): + """Test mit eingeschränkten PII-Typen.""" + detector = PIIDetector(enabled_types=[PIIType.EMAIL]) + + # E-Mail wird erkannt + assert len(detector.detect("test@example.com")) == 1 + + # Telefon wird nicht erkannt + assert len(detector.detect("+49 89 123456")) == 0 + + def test_empty_enabled_types(self): + """Test mit leerer Typen-Liste.""" + detector = PIIDetector(enabled_types=[]) + + # Nichts wird erkannt + assert len(detector.detect("test@example.com +49 89 123456")) == 0 + + +class TestPIIMatch: + """Tests für PIIMatch Dataclass.""" + + def test_pii_match_creation(self): + """Test PIIMatch erstellen.""" + match = PIIMatch( + type=PIIType.EMAIL, + value="test@example.com", + start=0, + end=16, + replacement="[EMAIL_REDACTED]", + ) + + assert match.type == PIIType.EMAIL + assert match.value == "test@example.com" + assert match.start == 0 + assert match.end == 16 + + +class TestRedactionResult: + """Tests für RedactionResult Dataclass.""" + + def test_redaction_result_creation(self): + """Test RedactionResult erstellen.""" + result = RedactionResult( + original_text="test@example.com", + redacted_text="[EMAIL_REDACTED]", + matches=[], + pii_found=True, + ) + + assert result.pii_found is True + assert result.original_text == "test@example.com" + assert result.redacted_text == "[EMAIL_REDACTED]" + + +class TestGetPIIDetectorSingleton: + """Tests für Singleton Pattern.""" + + def test_singleton_returns_same_instance(self): + """Test dass get_pii_detector Singleton zurückgibt.""" + detector1 = get_pii_detector() + detector2 = get_pii_detector() + assert detector1 is detector2 + + +class TestPIIRealWorldExamples: + """Tests mit realistischen Beispielen.""" + + def setup_method(self): + """Setup für jeden Test.""" + self.detector = PIIDetector() + + def test_school_query_without_pii(self): + """Test Schulanfrage ohne PII.""" + query = "Welche Regeln gelten für Datenschutz an Schulen in Bayern?" + result = self.detector.redact(query) + + assert result.pii_found is False + assert result.redacted_text == query + + def test_school_query_with_email(self): + """Test Schulanfrage mit E-Mail.""" + query = "Wie kontaktiere ich lehrer.mueller@schule.de wegen Datenschutz?" + result = self.detector.redact(query) + + assert result.pii_found is True + assert "lehrer.mueller@schule.de" not in result.redacted_text + assert "[EMAIL_REDACTED]" in result.redacted_text + + def test_parent_letter_with_multiple_pii(self): + """Test Elternbrief mit mehreren PII.""" + text = """ + Sehr geehrte Familie Müller, + bitte rufen Sie unter 089 12345678 an oder + schreiben Sie an eltern@schule.de. + IBAN für Klassenfahrt: DE89370400440532013000 + """ + result = self.detector.redact(text) + + assert result.pii_found is True + # Mindestens 3 Matches (Telefon, E-Mail, IBAN) + # Überlappende werden gefiltert (IBAN hat Priorität über Telefon) + assert len(result.matches) >= 3 + assert "[PHONE_REDACTED]" in result.redacted_text + assert "[EMAIL_REDACTED]" in result.redacted_text + assert "[IBAN_REDACTED]" in result.redacted_text diff --git a/backend/tests/test_llm_gateway/test_playbook_service.py b/backend/tests/test_llm_gateway/test_playbook_service.py new file mode 100644 index 0000000..99760bf --- /dev/null +++ b/backend/tests/test_llm_gateway/test_playbook_service.py @@ -0,0 +1,199 @@ +""" +Tests für Playbook Service. +""" + +import pytest +from datetime import datetime +from llm_gateway.services.playbook_service import ( + PlaybookService, + Playbook, + get_playbook_service, +) + + +class TestPlaybookService: + """Tests für PlaybookService.""" + + def setup_method(self): + """Setup für jeden Test.""" + self.service = PlaybookService() + + def test_list_playbooks_returns_default_playbooks(self): + """Test dass Default-Playbooks geladen werden.""" + playbooks = self.service.list_playbooks() + assert len(playbooks) > 0 + # Prüfe dass bekannte Playbooks existieren + ids = [p.id for p in playbooks] + assert "pb_default" in ids + assert "pb_elternbrief" in ids + assert "pb_arbeitsblatt" in ids + + def test_list_playbooks_filter_by_status(self): + """Test Status-Filter.""" + # Alle Default-Playbooks sind published + published = self.service.list_playbooks(status="published") + assert len(published) > 0 + + # Keine Draft-Playbooks + drafts = self.service.list_playbooks(status="draft") + assert len(drafts) == 0 + + def test_get_playbook_existing(self): + """Test Playbook abrufen.""" + playbook = self.service.get_playbook("pb_default") + assert playbook is not None + assert playbook.id == "pb_default" + assert playbook.name == "Standard-Assistent" + assert len(playbook.system_prompt) > 0 + + def test_get_playbook_not_found(self): + """Test nicht existierendes Playbook.""" + playbook = self.service.get_playbook("non_existent") + assert playbook is None + + def test_get_system_prompt(self): + """Test System Prompt abrufen.""" + prompt = self.service.get_system_prompt("pb_elternbrief") + assert prompt is not None + assert "Elternbrief" in prompt or "Elternkommunikation" in prompt + + def test_get_system_prompt_not_found(self): + """Test System Prompt für nicht existierendes Playbook.""" + prompt = self.service.get_system_prompt("non_existent") + assert prompt is None + + def test_create_playbook(self): + """Test neues Playbook erstellen.""" + new_playbook = Playbook( + id="pb_test_new", + name="Test Playbook", + description="Ein Test Playbook", + system_prompt="Du bist ein Test-Assistent.", + prompt_version="1.0.0", + ) + created = self.service.create_playbook(new_playbook) + assert created.id == "pb_test_new" + + # Prüfe dass es abrufbar ist + retrieved = self.service.get_playbook("pb_test_new") + assert retrieved is not None + assert retrieved.name == "Test Playbook" + + def test_create_playbook_duplicate_id(self): + """Test Playbook mit existierender ID erstellen.""" + duplicate = Playbook( + id="pb_default", # Existiert bereits + name="Duplicate", + description="Test", + system_prompt="Test", + prompt_version="1.0.0", + ) + with pytest.raises(ValueError): + self.service.create_playbook(duplicate) + + def test_update_playbook(self): + """Test Playbook aktualisieren.""" + original = self.service.get_playbook("pb_default") + original_name = original.name + + updated = self.service.update_playbook( + "pb_default", + name="Aktualisierter Name", + ) + assert updated is not None + assert updated.name == "Aktualisierter Name" + + # Reset + self.service.update_playbook("pb_default", name=original_name) + + def test_update_playbook_not_found(self): + """Test nicht existierendes Playbook aktualisieren.""" + result = self.service.update_playbook("non_existent", name="Test") + assert result is None + + def test_delete_playbook(self): + """Test Playbook löschen.""" + # Erst erstellen + new_playbook = Playbook( + id="pb_to_delete", + name="To Delete", + description="Test", + system_prompt="Test", + prompt_version="1.0.0", + ) + self.service.create_playbook(new_playbook) + + # Dann löschen + result = self.service.delete_playbook("pb_to_delete") + assert result is True + + # Prüfen dass gelöscht + assert self.service.get_playbook("pb_to_delete") is None + + def test_delete_playbook_not_found(self): + """Test nicht existierendes Playbook löschen.""" + result = self.service.delete_playbook("non_existent") + assert result is False + + +class TestPlaybookContent: + """Tests für Playbook-Inhalte.""" + + def setup_method(self): + """Setup für jeden Test.""" + self.service = PlaybookService() + + def test_elternbrief_playbook_has_guidelines(self): + """Test dass Elternbrief-Playbook Richtlinien enthält.""" + playbook = self.service.get_playbook("pb_elternbrief") + assert playbook is not None + prompt = playbook.system_prompt.lower() + # Sollte wichtige Richtlinien enthalten + assert "ton" in prompt or "sprache" in prompt + assert "brief" in prompt or "eltern" in prompt + + def test_rechtlich_playbook_has_disclaimer(self): + """Test dass Rechtlich-Playbook Disclaimer enthält.""" + playbook = self.service.get_playbook("pb_rechtlich") + assert playbook is not None + prompt = playbook.system_prompt.lower() + # Sollte Hinweis auf keine Rechtsberatung enthalten + assert "rechtsberatung" in prompt or "fachanwalt" in prompt + + def test_foerderplan_playbook_mentions_privacy(self): + """Test dass Förderplan-Playbook Datenschutz erwähnt.""" + playbook = self.service.get_playbook("pb_foerderplan") + assert playbook is not None + prompt = playbook.system_prompt.lower() + # Sollte sensible Daten erwähnen + assert "sensib" in prompt or "vertraulich" in prompt or "daten" in prompt + + def test_all_playbooks_have_required_fields(self): + """Test dass alle Playbooks Pflichtfelder haben.""" + playbooks = self.service.list_playbooks(status=None) + for playbook in playbooks: + assert playbook.id is not None + assert len(playbook.id) > 0 + assert playbook.name is not None + assert len(playbook.name) > 0 + assert playbook.system_prompt is not None + assert len(playbook.system_prompt) > 0 + assert playbook.prompt_version is not None + + def test_playbooks_have_tool_policy(self): + """Test dass Playbooks Tool Policy haben.""" + playbooks = self.service.list_playbooks() + for playbook in playbooks: + assert hasattr(playbook, "tool_policy") + # no_pii_in_output sollte standardmäßig true sein + assert playbook.tool_policy.get("no_pii_in_output") is True + + +class TestGetPlaybookServiceSingleton: + """Tests für Singleton Pattern.""" + + def test_singleton_returns_same_instance(self): + """Test dass get_playbook_service Singleton zurückgibt.""" + service1 = get_playbook_service() + service2 = get_playbook_service() + assert service1 is service2 diff --git a/backend/tests/test_llm_gateway/test_tool_gateway.py b/backend/tests/test_llm_gateway/test_tool_gateway.py new file mode 100644 index 0000000..f1ca807 --- /dev/null +++ b/backend/tests/test_llm_gateway/test_tool_gateway.py @@ -0,0 +1,369 @@ +""" +Tests für Tool Gateway Service. +""" + +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +import httpx + +from llm_gateway.services.tool_gateway import ( + ToolGateway, + ToolGatewayConfig, + SearchDepth, + SearchResult, + SearchResponse, + TavilyError, + ToolGatewayError, + get_tool_gateway, +) +from llm_gateway.services.pii_detector import PIIDetector, RedactionResult + + +class TestToolGatewayConfig: + """Tests für ToolGatewayConfig.""" + + def test_default_config(self): + """Test Standardkonfiguration.""" + config = ToolGatewayConfig() + + assert config.tavily_api_key is None + assert config.tavily_base_url == "https://api.tavily.com" + assert config.timeout == 30 + assert config.max_results == 5 + assert config.search_depth == SearchDepth.BASIC + assert config.include_answer is True + assert config.pii_redaction_enabled is True + + def test_config_from_env(self): + """Test Konfiguration aus Umgebungsvariablen.""" + with patch.dict("os.environ", { + "TAVILY_API_KEY": "test-key", + "TAVILY_BASE_URL": "https://custom.tavily.com", + "TAVILY_TIMEOUT": "60", + "TAVILY_MAX_RESULTS": "10", + "TAVILY_SEARCH_DEPTH": "advanced", + "PII_REDACTION_ENABLED": "false", + }): + config = ToolGatewayConfig.from_env() + + assert config.tavily_api_key == "test-key" + assert config.tavily_base_url == "https://custom.tavily.com" + assert config.timeout == 60 + assert config.max_results == 10 + assert config.search_depth == SearchDepth.ADVANCED + assert config.pii_redaction_enabled is False + + +class TestToolGatewayAvailability: + """Tests für Gateway-Verfügbarkeit.""" + + def test_tavily_not_available_without_key(self): + """Test Tavily nicht verfügbar ohne API Key.""" + config = ToolGatewayConfig(tavily_api_key=None) + gateway = ToolGateway(config=config) + + assert gateway.tavily_available is False + + def test_tavily_available_with_key(self): + """Test Tavily verfügbar mit API Key.""" + config = ToolGatewayConfig(tavily_api_key="test-key") + gateway = ToolGateway(config=config) + + assert gateway.tavily_available is True + + +class TestToolGatewayPIIRedaction: + """Tests für PII-Redaktion im Gateway.""" + + def test_redact_query_with_email(self): + """Test Redaktion von E-Mail in Query.""" + config = ToolGatewayConfig(pii_redaction_enabled=True) + gateway = ToolGateway(config=config) + + result = gateway._redact_query("Kontakt test@example.com Datenschutz") + + assert result.pii_found is True + assert "test@example.com" not in result.redacted_text + assert "[EMAIL_REDACTED]" in result.redacted_text + + def test_no_redaction_when_disabled(self): + """Test keine Redaktion wenn deaktiviert.""" + config = ToolGatewayConfig(pii_redaction_enabled=False) + gateway = ToolGateway(config=config) + + result = gateway._redact_query("test@example.com") + + assert result.pii_found is False + assert result.redacted_text == "test@example.com" + + +class TestToolGatewaySearch: + """Tests für Suche mit Gateway.""" + + @pytest.mark.asyncio + async def test_search_raises_error_without_key(self): + """Test Fehler bei Suche ohne API Key.""" + config = ToolGatewayConfig(tavily_api_key=None) + gateway = ToolGateway(config=config) + + with pytest.raises(ToolGatewayError, match="not configured"): + await gateway.search("test query") + + @pytest.mark.asyncio + async def test_search_success(self): + """Test erfolgreiche Suche.""" + config = ToolGatewayConfig(tavily_api_key="test-key") + gateway = ToolGateway(config=config) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "results": [ + { + "title": "Test Result", + "url": "https://example.com", + "content": "Test content", + "score": 0.95, + } + ], + "answer": "Test answer", + } + + with patch.object(gateway, "_get_client") as mock_client: + mock_http = AsyncMock() + mock_http.post.return_value = mock_response + mock_client.return_value = mock_http + + result = await gateway.search("Schulrecht Bayern") + + assert result.query == "Schulrecht Bayern" + assert len(result.results) == 1 + assert result.results[0].title == "Test Result" + assert result.answer == "Test answer" + + @pytest.mark.asyncio + async def test_search_with_pii_redaction(self): + """Test Suche mit PII-Redaktion.""" + config = ToolGatewayConfig( + tavily_api_key="test-key", + pii_redaction_enabled=True, + ) + gateway = ToolGateway(config=config) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "results": [], + "answer": None, + } + + with patch.object(gateway, "_get_client") as mock_client: + mock_http = AsyncMock() + mock_http.post.return_value = mock_response + mock_client.return_value = mock_http + + result = await gateway.search("Kontakt test@example.com Datenschutz") + + assert result.pii_detected is True + assert "email" in result.pii_types + assert result.redacted_query is not None + assert "test@example.com" not in result.redacted_query + + # Prüfen dass redaktierte Query an Tavily gesendet wurde + call_args = mock_http.post.call_args + sent_query = call_args.kwargs["json"]["query"] + assert "test@example.com" not in sent_query + + @pytest.mark.asyncio + async def test_search_http_error(self): + """Test HTTP-Fehler bei Suche.""" + config = ToolGatewayConfig(tavily_api_key="test-key") + gateway = ToolGateway(config=config) + + mock_response = MagicMock() + mock_response.status_code = 429 + mock_response.text = "Rate limit exceeded" + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "Rate limit", + request=MagicMock(), + response=mock_response, + ) + + with patch.object(gateway, "_get_client") as mock_client: + mock_http = AsyncMock() + mock_http.post.return_value = mock_response + mock_client.return_value = mock_http + + with pytest.raises(TavilyError, match="429"): + await gateway.search("test") + + @pytest.mark.asyncio + async def test_search_with_domain_filters(self): + """Test Suche mit Domain-Filtern.""" + config = ToolGatewayConfig(tavily_api_key="test-key") + gateway = ToolGateway(config=config) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"results": [], "answer": None} + + with patch.object(gateway, "_get_client") as mock_client: + mock_http = AsyncMock() + mock_http.post.return_value = mock_response + mock_client.return_value = mock_http + + await gateway.search( + "test", + include_domains=["gov.de", "schule.de"], + exclude_domains=["wikipedia.org"], + ) + + call_args = mock_http.post.call_args + payload = call_args.kwargs["json"] + assert payload["include_domains"] == ["gov.de", "schule.de"] + assert payload["exclude_domains"] == ["wikipedia.org"] + + +class TestSearchResult: + """Tests für SearchResult Dataclass.""" + + def test_search_result_creation(self): + """Test SearchResult erstellen.""" + result = SearchResult( + title="Test", + url="https://example.com", + content="Content", + score=0.9, + published_date="2024-01-15", + ) + + assert result.title == "Test" + assert result.url == "https://example.com" + assert result.score == 0.9 + + def test_search_result_defaults(self): + """Test SearchResult Standardwerte.""" + result = SearchResult( + title="Test", + url="https://example.com", + content="Content", + ) + + assert result.score == 0.0 + assert result.published_date is None + + +class TestSearchResponse: + """Tests für SearchResponse Dataclass.""" + + def test_search_response_creation(self): + """Test SearchResponse erstellen.""" + response = SearchResponse( + query="test query", + results=[], + pii_detected=False, + ) + + assert response.query == "test query" + assert len(response.results) == 0 + assert response.pii_detected is False + + def test_search_response_with_pii(self): + """Test SearchResponse mit PII.""" + response = SearchResponse( + query="original query", + redacted_query="redacted query", + results=[], + pii_detected=True, + pii_types=["email", "phone"], + ) + + assert response.pii_detected is True + assert "email" in response.pii_types + + +class TestSearchDepthEnum: + """Tests für SearchDepth Enum.""" + + def test_search_depth_values(self): + """Test SearchDepth Werte.""" + assert SearchDepth.BASIC.value == "basic" + assert SearchDepth.ADVANCED.value == "advanced" + + +class TestGetToolGatewaySingleton: + """Tests für Singleton Pattern.""" + + def test_singleton_returns_same_instance(self): + """Test dass get_tool_gateway Singleton zurückgibt.""" + gateway1 = get_tool_gateway() + gateway2 = get_tool_gateway() + assert gateway1 is gateway2 + + +class TestToolGatewayHealthCheck: + """Tests für Health Check.""" + + @pytest.mark.asyncio + async def test_health_check_without_tavily(self): + """Test Health Check ohne Tavily.""" + config = ToolGatewayConfig(tavily_api_key=None) + gateway = ToolGateway(config=config) + + status = await gateway.health_check() + + assert status["tavily"]["configured"] is False + assert status["tavily"]["healthy"] is False + + @pytest.mark.asyncio + async def test_health_check_with_tavily_success(self): + """Test Health Check mit erfolgreichem Tavily.""" + config = ToolGatewayConfig(tavily_api_key="test-key") + gateway = ToolGateway(config=config) + + with patch.object(gateway, "search") as mock_search: + mock_search.return_value = SearchResponse( + query="test", + results=[], + response_time_ms=100, + ) + + status = await gateway.health_check() + + assert status["tavily"]["configured"] is True + assert status["tavily"]["healthy"] is True + assert status["tavily"]["response_time_ms"] == 100 + + @pytest.mark.asyncio + async def test_health_check_with_tavily_failure(self): + """Test Health Check mit Tavily-Fehler.""" + config = ToolGatewayConfig(tavily_api_key="test-key") + gateway = ToolGateway(config=config) + + with patch.object(gateway, "search") as mock_search: + mock_search.side_effect = TavilyError("Connection failed") + + status = await gateway.health_check() + + assert status["tavily"]["configured"] is True + assert status["tavily"]["healthy"] is False + assert "error" in status["tavily"] + + +class TestToolGatewayClose: + """Tests für Gateway-Cleanup.""" + + @pytest.mark.asyncio + async def test_close_client(self): + """Test Client-Cleanup.""" + config = ToolGatewayConfig(tavily_api_key="test-key") + gateway = ToolGateway(config=config) + + # Simuliere dass Client erstellt wurde + mock_client = AsyncMock() + gateway._client = mock_client + + await gateway.close() + + mock_client.aclose.assert_called_once() + assert gateway._client is None diff --git a/backend/tests/test_llm_gateway/test_tools_routes.py b/backend/tests/test_llm_gateway/test_tools_routes.py new file mode 100644 index 0000000..5baba40 --- /dev/null +++ b/backend/tests/test_llm_gateway/test_tools_routes.py @@ -0,0 +1,366 @@ +""" +Integration Tests für Tools Routes. + +Testet die API-Endpoints /llm/tools/search und /llm/tools/health. +""" + +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from fastapi.testclient import TestClient +from fastapi import FastAPI + +from llm_gateway.routes.tools import router +from llm_gateway.middleware.auth import verify_api_key +from llm_gateway.services.tool_gateway import ( + ToolGateway, + SearchResponse, + SearchResult, + TavilyError, + ToolGatewayError, + get_tool_gateway, +) + + +# Test App erstellen mit Auth-Override +app = FastAPI() +app.include_router(router, prefix="/tools") + + +# Mock für Auth-Dependency +def mock_verify_api_key(): + return "test-user" + + +class TestSearchEndpoint: + """Tests für POST /tools/search.""" + + def setup_method(self): + """Setup für jeden Test.""" + # Auth-Dependency überschreiben für Tests + app.dependency_overrides[verify_api_key] = mock_verify_api_key + self.client = TestClient(app) + + def teardown_method(self): + """Cleanup nach jedem Test.""" + app.dependency_overrides.clear() + + def test_search_requires_auth(self): + """Test dass Auth erforderlich ist.""" + # Auth-Override entfernen für diesen Test + app.dependency_overrides.clear() + client = TestClient(app) + response = client.post( + "/tools/search", + json={"query": "test"}, + ) + # Ohne API-Key sollte 401/403 kommen + assert response.status_code in [401, 403] + + def test_search_invalid_query_too_short(self): + """Test Validierung: Query zu kurz.""" + response = self.client.post( + "/tools/search", + json={"query": ""}, + ) + assert response.status_code == 422 # Validation Error + + def test_search_invalid_max_results(self): + """Test Validierung: max_results außerhalb Grenzen.""" + response = self.client.post( + "/tools/search", + json={"query": "test", "max_results": 100}, # > 20 + ) + assert response.status_code == 422 + + def test_search_success(self): + """Test erfolgreiche Suche.""" + mock_gateway = MagicMock(spec=ToolGateway) + mock_gateway.search = AsyncMock(return_value=SearchResponse( + query="Datenschutz Schule", + results=[ + SearchResult( + title="Datenschutz an Schulen", + url="https://example.com", + content="Informationen...", + score=0.9, + ) + ], + answer="Zusammenfassung", + pii_detected=False, + response_time_ms=1500, + )) + + app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway + + response = self.client.post( + "/tools/search", + json={"query": "Datenschutz Schule"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["query"] == "Datenschutz Schule" + assert len(data["results"]) == 1 + assert data["results"][0]["title"] == "Datenschutz an Schulen" + assert data["pii_detected"] is False + + def test_search_with_pii_redaction(self): + """Test Suche mit PII-Erkennung.""" + mock_gateway = MagicMock(spec=ToolGateway) + mock_gateway.search = AsyncMock(return_value=SearchResponse( + query="Kontakt test@example.com", + redacted_query="Kontakt [EMAIL_REDACTED]", + results=[], + pii_detected=True, + pii_types=["email"], + response_time_ms=1000, + )) + + app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway + + response = self.client.post( + "/tools/search", + json={"query": "Kontakt test@example.com"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["pii_detected"] is True + assert "email" in data["pii_types"] + assert data["redacted_query"] == "Kontakt [EMAIL_REDACTED]" + + def test_search_with_domain_filters(self): + """Test Suche mit Domain-Filtern.""" + mock_gateway = MagicMock(spec=ToolGateway) + mock_gateway.search = AsyncMock(return_value=SearchResponse( + query="test", + results=[], + pii_detected=False, + response_time_ms=500, + )) + + app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway + + response = self.client.post( + "/tools/search", + json={ + "query": "test", + "include_domains": ["bayern.de"], + "exclude_domains": ["wikipedia.org"], + }, + ) + + assert response.status_code == 200 + + # Prüfen dass Filter an Gateway übergeben wurden + mock_gateway.search.assert_called_once() + call_kwargs = mock_gateway.search.call_args.kwargs + assert call_kwargs["include_domains"] == ["bayern.de"] + assert call_kwargs["exclude_domains"] == ["wikipedia.org"] + + def test_search_gateway_error(self): + """Test Fehlerbehandlung bei Gateway-Fehler.""" + mock_gateway = MagicMock(spec=ToolGateway) + mock_gateway.search = AsyncMock(side_effect=ToolGatewayError("Not configured")) + + app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway + + response = self.client.post( + "/tools/search", + json={"query": "test"}, + ) + + assert response.status_code == 503 + assert "unavailable" in response.json()["detail"].lower() + + def test_search_tavily_error(self): + """Test Fehlerbehandlung bei Tavily-Fehler.""" + mock_gateway = MagicMock(spec=ToolGateway) + mock_gateway.search = AsyncMock(side_effect=TavilyError("Rate limit")) + + app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway + + response = self.client.post( + "/tools/search", + json={"query": "test"}, + ) + + assert response.status_code == 502 + assert "search service error" in response.json()["detail"].lower() + + +class TestHealthEndpoint: + """Tests für GET /tools/health.""" + + def setup_method(self): + """Setup für jeden Test.""" + app.dependency_overrides[verify_api_key] = mock_verify_api_key + self.client = TestClient(app) + + def teardown_method(self): + """Cleanup nach jedem Test.""" + app.dependency_overrides.clear() + + def test_health_requires_auth(self): + """Test dass Auth erforderlich ist.""" + app.dependency_overrides.clear() + client = TestClient(app) + response = client.get("/tools/health") + assert response.status_code in [401, 403] + + def test_health_success(self): + """Test erfolgreicher Health Check.""" + mock_gateway = MagicMock(spec=ToolGateway) + mock_gateway.health_check = AsyncMock(return_value={ + "tavily": { + "configured": True, + "healthy": True, + "response_time_ms": 1500, + }, + "pii_redaction": { + "enabled": True, + }, + }) + + app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway + + response = self.client.get("/tools/health") + + assert response.status_code == 200 + data = response.json() + assert data["tavily"]["configured"] is True + assert data["tavily"]["healthy"] is True + assert data["pii_redaction"]["enabled"] is True + + def test_health_tavily_not_configured(self): + """Test Health Check ohne Tavily-Konfiguration.""" + mock_gateway = MagicMock(spec=ToolGateway) + mock_gateway.health_check = AsyncMock(return_value={ + "tavily": { + "configured": False, + "healthy": False, + }, + "pii_redaction": { + "enabled": True, + }, + }) + + app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway + + response = self.client.get("/tools/health") + + assert response.status_code == 200 + data = response.json() + assert data["tavily"]["configured"] is False + + +class TestSearchRequestValidation: + """Tests für Request-Validierung.""" + + def setup_method(self): + """Setup für jeden Test.""" + app.dependency_overrides[verify_api_key] = mock_verify_api_key + self.client = TestClient(app) + + def teardown_method(self): + """Cleanup nach jedem Test.""" + app.dependency_overrides.clear() + + def test_query_max_length(self): + """Test Query max length Validierung.""" + # Query mit > 1000 Zeichen + response = self.client.post( + "/tools/search", + json={"query": "x" * 1001}, + ) + assert response.status_code == 422 + + def test_search_depth_enum(self): + """Test search_depth Enum Validierung.""" + mock_gateway = MagicMock(spec=ToolGateway) + mock_gateway.search = AsyncMock(return_value=SearchResponse( + query="test", + results=[], + pii_detected=False, + response_time_ms=100, + )) + + app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway + + # Gültiger Wert + response = self.client.post( + "/tools/search", + json={"query": "test", "search_depth": "advanced"}, + ) + assert response.status_code == 200 + + def test_search_depth_invalid(self): + """Test ungültiger search_depth Wert.""" + response = self.client.post( + "/tools/search", + json={"query": "test", "search_depth": "invalid"}, + ) + assert response.status_code == 422 + + +class TestSearchResponseFormat: + """Tests für Response-Format.""" + + def setup_method(self): + """Setup für jeden Test.""" + app.dependency_overrides[verify_api_key] = mock_verify_api_key + self.client = TestClient(app) + + def teardown_method(self): + """Cleanup nach jedem Test.""" + app.dependency_overrides.clear() + + def test_response_has_all_fields(self): + """Test dass Response alle erforderlichen Felder hat.""" + mock_gateway = MagicMock(spec=ToolGateway) + mock_gateway.search = AsyncMock(return_value=SearchResponse( + query="test query", + redacted_query=None, + results=[ + SearchResult( + title="Result 1", + url="https://example.com/1", + content="Content 1", + score=0.95, + published_date="2024-01-15", + ), + ], + answer="AI Summary", + pii_detected=False, + pii_types=[], + response_time_ms=2000, + )) + + app.dependency_overrides[get_tool_gateway] = lambda: mock_gateway + + response = self.client.post( + "/tools/search", + json={"query": "test query"}, + ) + + assert response.status_code == 200 + data = response.json() + + # Pflichtfelder + assert "query" in data + assert "results" in data + assert "pii_detected" in data + assert "pii_types" in data + assert "response_time_ms" in data + + # Optionale Felder + assert "redacted_query" in data + assert "answer" in data + + # Result-Felder + result = data["results"][0] + assert "title" in result + assert "url" in result + assert "content" in result + assert "score" in result + assert "published_date" in result diff --git a/backend/tests/test_meeting_consent_api.py b/backend/tests/test_meeting_consent_api.py new file mode 100644 index 0000000..5bc40a9 --- /dev/null +++ b/backend/tests/test_meeting_consent_api.py @@ -0,0 +1,357 @@ +""" +Tests for Meeting Consent API + +Tests for DSGVO-compliant consent management for meeting recordings. +""" + +import pytest +from fastapi.testclient import TestClient +from fastapi import FastAPI + +# Import the router +from meeting_consent_api import router as consent_router + +app = FastAPI() +app.include_router(consent_router) +client = TestClient(app) + + +class TestConsentRequest: + """Tests for requesting recording consent.""" + + @pytest.fixture(autouse=True) + def setup(self): + """Clear consent store before each test.""" + from meeting_consent_api import _consent_store, _participant_consents + _consent_store.clear() + _participant_consents.clear() + + def test_request_consent_success(self): + """Test requesting consent for a meeting.""" + response = client.post( + "/api/meeting-consent/request", + json={ + "meeting_id": "test-meeting-123", + "consent_type": "opt_in", + "participant_count": 3 + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["meeting_id"] == "test-meeting-123" + assert data["consent_type"] == "opt_in" + assert data["participant_count"] == 3 + assert data["all_consented"] is False + assert data["can_record"] is False + assert data["status"] == "pending" + + def test_request_consent_duplicate_rejected(self): + """Test that duplicate consent requests are rejected.""" + # First request + client.post( + "/api/meeting-consent/request", + json={"meeting_id": "dup-meeting", "consent_type": "opt_in"} + ) + + # Second request should fail + response = client.post( + "/api/meeting-consent/request", + json={"meeting_id": "dup-meeting", "consent_type": "opt_in"} + ) + + assert response.status_code == 409 + assert "already exists" in response.json()["detail"] + + def test_request_consent_default_values(self): + """Test consent request uses default values.""" + response = client.post( + "/api/meeting-consent/request", + json={"meeting_id": "default-meeting"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["consent_type"] == "opt_in" + + +class TestConsentStatus: + """Tests for checking consent status.""" + + @pytest.fixture(autouse=True) + def setup(self): + """Create test consent before each test.""" + from meeting_consent_api import _consent_store, _participant_consents + _consent_store.clear() + _participant_consents.clear() + + client.post( + "/api/meeting-consent/request", + json={ + "meeting_id": "status-test-meeting", + "consent_type": "opt_in", + "participant_count": 2 + } + ) + + def test_get_consent_status_existing(self): + """Test getting status for existing consent.""" + response = client.get("/api/meeting-consent/status-test-meeting") + + assert response.status_code == 200 + data = response.json() + assert data["meeting_id"] == "status-test-meeting" + assert data["status"] == "pending" + + def test_get_consent_status_not_requested(self): + """Test getting status for meeting without consent request.""" + response = client.get("/api/meeting-consent/nonexistent-meeting") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "not_requested" + assert data["can_record"] is False + + +class TestParticipantConsent: + """Tests for recording individual participant consent.""" + + @pytest.fixture(autouse=True) + def setup(self): + """Create test consent with 2 participants.""" + from meeting_consent_api import _consent_store, _participant_consents + _consent_store.clear() + _participant_consents.clear() + + client.post( + "/api/meeting-consent/request", + json={ + "meeting_id": "participant-test", + "consent_type": "opt_in", + "participant_count": 2 + } + ) + + def test_record_participant_consent_positive(self): + """Test recording positive consent from participant.""" + response = client.post( + "/api/meeting-consent/participant-test/participant", + json={ + "participant_id": "user-1", + "participant_name": "Alice", + "consented": True + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["consented_count"] == 1 + assert data["all_consented"] is False + + def test_record_participant_consent_negative(self): + """Test recording negative consent from participant.""" + response = client.post( + "/api/meeting-consent/participant-test/participant", + json={ + "participant_id": "user-1", + "consented": False + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["consented"] is False + + def test_all_participants_consented_auto_approves(self): + """Test that recording is approved when all participants consent.""" + # First participant + client.post( + "/api/meeting-consent/participant-test/participant", + json={"participant_id": "user-1", "consented": True} + ) + + # Second participant (should trigger approval) + response = client.post( + "/api/meeting-consent/participant-test/participant", + json={"participant_id": "user-2", "consented": True} + ) + + assert response.status_code == 200 + data = response.json() + assert data["all_consented"] is True + assert data["can_record"] is True + + def test_record_consent_meeting_not_found(self): + """Test recording consent for non-existent meeting.""" + response = client.post( + "/api/meeting-consent/nonexistent/participant", + json={"participant_id": "user-1", "consented": True} + ) + + assert response.status_code == 404 + + def test_update_existing_participant_consent(self): + """Test updating consent for same participant.""" + # Initial consent + client.post( + "/api/meeting-consent/participant-test/participant", + json={"participant_id": "user-1", "consented": True} + ) + + # Update to negative + response = client.post( + "/api/meeting-consent/participant-test/participant", + json={"participant_id": "user-1", "consented": False} + ) + + assert response.status_code == 200 + data = response.json() + assert data["consented"] is False + + +class TestConsentWithdrawal: + """Tests for withdrawing consent.""" + + @pytest.fixture(autouse=True) + def setup(self): + """Create approved consent.""" + from meeting_consent_api import _consent_store, _participant_consents + _consent_store.clear() + _participant_consents.clear() + + # Create and approve consent + client.post( + "/api/meeting-consent/request", + json={ + "meeting_id": "withdraw-test", + "consent_type": "opt_in", + "participant_count": 1 + } + ) + client.post( + "/api/meeting-consent/withdraw-test/participant", + json={"participant_id": "user-1", "consented": True} + ) + + def test_withdraw_consent(self): + """Test withdrawing consent for a meeting.""" + response = client.post( + "/api/meeting-consent/withdraw-test/withdraw", + json={"reason": "Changed my mind"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "withdrawn" + + def test_withdraw_consent_stops_recording_capability(self): + """Test that withdrawal stops recording capability.""" + # Withdraw + client.post( + "/api/meeting-consent/withdraw-test/withdraw", + json={} + ) + + # Check status + response = client.get("/api/meeting-consent/withdraw-test") + data = response.json() + assert data["status"] == "withdrawn" or data["status"] == "not_requested" + + def test_withdraw_consent_not_found(self): + """Test withdrawing consent for non-existent meeting.""" + response = client.post( + "/api/meeting-consent/nonexistent/withdraw", + json={} + ) + + assert response.status_code == 404 + + +class TestAnnouncedRecording: + """Tests for announced recording mode.""" + + @pytest.fixture(autouse=True) + def setup(self): + """Clear store before each test.""" + from meeting_consent_api import _consent_store, _participant_consents + _consent_store.clear() + _participant_consents.clear() + + def test_announce_recording(self): + """Test announcing a recording.""" + response = client.post( + "/api/meeting-consent/announce?meeting_id=announced-meeting&announced_by=Teacher" + ) + + assert response.status_code == 200 + data = response.json() + assert data["consent_type"] == "announced" + assert data["can_record"] is True + assert data["announced_by"] == "Teacher" + + def test_announce_recording_duplicate_rejected(self): + """Test that duplicate announcements are rejected.""" + # First announcement + client.post( + "/api/meeting-consent/announce?meeting_id=dup-announce&announced_by=Teacher" + ) + + # Second announcement + response = client.post( + "/api/meeting-consent/announce?meeting_id=dup-announce&announced_by=Teacher" + ) + + assert response.status_code == 409 + + +class TestParticipantsList: + """Tests for listing participant consents.""" + + @pytest.fixture(autouse=True) + def setup(self): + """Create test consent with participants.""" + from meeting_consent_api import _consent_store, _participant_consents + _consent_store.clear() + _participant_consents.clear() + + client.post( + "/api/meeting-consent/request", + json={"meeting_id": "list-test", "participant_count": 2} + ) + client.post( + "/api/meeting-consent/list-test/participant", + json={"participant_id": "user-1-uuid-12345678", "consented": True} + ) + client.post( + "/api/meeting-consent/list-test/participant", + json={"participant_id": "user-2-uuid-87654321", "consented": False} + ) + + def test_get_participants_list(self): + """Test getting list of participant consents.""" + response = client.get("/api/meeting-consent/list-test/participants") + + assert response.status_code == 200 + data = response.json() + assert len(data["participants"]) == 2 + + def test_participants_list_anonymized(self): + """Test that participant IDs are anonymized.""" + response = client.get("/api/meeting-consent/list-test/participants") + data = response.json() + + # IDs should be truncated to last 8 chars + for p in data["participants"]: + assert len(p["participant_id"]) == 8 + + +class TestHealthCheck: + """Tests for health check endpoint.""" + + def test_health_check(self): + """Test health check returns healthy status.""" + response = client.get("/api/meeting-consent/health") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" diff --git a/backend/tests/test_meetings_api.py b/backend/tests/test_meetings_api.py new file mode 100644 index 0000000..2737aca --- /dev/null +++ b/backend/tests/test_meetings_api.py @@ -0,0 +1,547 @@ +""" +Unit Tests for Meetings API +Tests for Jitsi Meet integration endpoints +""" +import pytest +from unittest.mock import patch, AsyncMock, MagicMock +from fastapi.testclient import TestClient +from datetime import datetime, timedelta + +# Import the app and router +import sys +sys.path.insert(0, '..') + +from meetings_api import ( + router, + generate_room_name, + generate_password, + build_jitsi_url, + MeetingConfig, + CreateMeetingRequest, + ScheduleMeetingRequest, + TrainingRequest, + ParentTeacherRequest, + scheduled_meetings, + active_meetings, + trainings +) +from fastapi import FastAPI + +# Create test app +app = FastAPI() +app.include_router(router) +client = TestClient(app) + + +class TestHelperFunctions: + """Test helper functions""" + + def test_generate_room_name_default_prefix(self): + """Test room name generation with default prefix""" + room_name = generate_room_name() + assert room_name.startswith("meeting-") + assert len(room_name) == len("meeting-") + 8 + + def test_generate_room_name_custom_prefix(self): + """Test room name generation with custom prefix""" + room_name = generate_room_name("schulung") + assert room_name.startswith("schulung-") + + def test_generate_room_name_unique(self): + """Test that room names are unique""" + names = [generate_room_name() for _ in range(100)] + assert len(set(names)) == 100 + + def test_generate_password(self): + """Test password generation""" + password = generate_password() + assert len(password) == 8 + assert password.isalnum() + + def test_generate_password_unique(self): + """Test that passwords are unique""" + passwords = [generate_password() for _ in range(100)] + assert len(set(passwords)) == 100 + + def test_build_jitsi_url_basic(self): + """Test basic Jitsi URL building""" + url = build_jitsi_url("test-room") + assert "localhost:8443/test-room" in url + assert "config.prejoinPageEnabled=false" in url + assert "config.defaultLanguage=de" in url + + def test_build_jitsi_url_with_config(self): + """Test Jitsi URL with config options""" + config = MeetingConfig( + start_with_audio_muted=True, + start_with_video_muted=True, + require_display_name=True + ) + url = build_jitsi_url("test-room", config) + assert "config.startWithAudioMuted=true" in url + assert "config.startWithVideoMuted=true" in url + assert "config.requireDisplayName=true" in url + + def test_build_jitsi_url_without_config(self): + """Test Jitsi URL without config""" + url = build_jitsi_url("test-room", None) + assert "localhost:8443/test-room" in url + + +class TestMeetingStatsEndpoint: + """Test /stats endpoint""" + + def test_get_stats_empty(self): + """Test stats with no meetings""" + # Clear any existing data + scheduled_meetings.clear() + active_meetings.clear() + + response = client.get("/api/meetings/stats") + assert response.status_code == 200 + + data = response.json() + assert "active" in data + assert "scheduled" in data + assert "recordings" in data + assert "participants" in data + + def test_get_stats_with_data(self): + """Test stats with meetings""" + scheduled_meetings.clear() + active_meetings.clear() + + # Add test data + scheduled_meetings.append({"room_name": "test", "title": "Test"}) + active_meetings.append({"room_name": "active", "title": "Active", "participants": 5}) + + response = client.get("/api/meetings/stats") + assert response.status_code == 200 + + data = response.json() + assert data["scheduled"] == 1 + assert data["active"] == 1 + assert data["participants"] == 5 + + +class TestActiveMeetingsEndpoint: + """Test /active endpoint""" + + def test_get_active_empty(self): + """Test active meetings when empty""" + active_meetings.clear() + + response = client.get("/api/meetings/active") + assert response.status_code == 200 + assert response.json() == [] + + def test_get_active_with_meetings(self): + """Test active meetings with data""" + active_meetings.clear() + active_meetings.append({ + "room_name": "test-room", + "title": "Test Meeting", + "participants": 3, + "started_at": "2025-12-15T10:00:00" + }) + + response = client.get("/api/meetings/active") + assert response.status_code == 200 + + data = response.json() + assert len(data) == 1 + assert data[0]["room_name"] == "test-room" + assert data[0]["title"] == "Test Meeting" + + +class TestCreateMeetingEndpoint: + """Test /create endpoint""" + + def test_create_quick_meeting(self): + """Test creating a quick meeting""" + scheduled_meetings.clear() + + response = client.post("/api/meetings/create", json={ + "type": "quick", + "title": "Quick Meeting", + "duration": 30 + }) + + assert response.status_code == 200 + data = response.json() + assert "room_name" in data + assert data["room_name"].startswith("quick-") + assert "join_url" in data + + def test_create_scheduled_meeting(self): + """Test creating a scheduled meeting""" + scheduled_meetings.clear() + + response = client.post("/api/meetings/create", json={ + "type": "scheduled", + "title": "Scheduled Meeting", + "duration": 60, + "scheduled_at": "2025-12-20T14:00:00" + }) + + assert response.status_code == 200 + data = response.json() + assert "room_name" in data + assert "join_url" in data + + def test_create_training_meeting(self): + """Test creating a training meeting""" + response = client.post("/api/meetings/create", json={ + "type": "training", + "title": "Training Session", + "duration": 120 + }) + + assert response.status_code == 200 + data = response.json() + assert data["room_name"].startswith("schulung-") + + def test_create_parent_meeting(self): + """Test creating a parent meeting""" + response = client.post("/api/meetings/create", json={ + "type": "parent", + "title": "Elterngespraech", + "duration": 30 + }) + + assert response.status_code == 200 + data = response.json() + assert data["room_name"].startswith("elterngespraech-") + + def test_create_class_meeting(self): + """Test creating a class meeting""" + response = client.post("/api/meetings/create", json={ + "type": "class", + "title": "Klasse 5a", + "duration": 45 + }) + + assert response.status_code == 200 + data = response.json() + assert data["room_name"].startswith("klasse-") + + def test_create_meeting_with_config(self): + """Test creating meeting with custom config""" + response = client.post("/api/meetings/create", json={ + "type": "quick", + "title": "Configured Meeting", + "duration": 60, + "config": { + "enable_lobby": True, + "enable_recording": True, + "start_with_audio_muted": True + } + }) + + assert response.status_code == 200 + + +class TestScheduleMeetingEndpoint: + """Test /schedule endpoint""" + + def test_schedule_meeting(self): + """Test scheduling a meeting""" + scheduled_meetings.clear() + + response = client.post("/api/meetings/schedule", json={ + "title": "Team Meeting", + "scheduled_at": "2025-12-20T14:00:00", + "duration": 60, + "description": "Weekly team sync" + }) + + assert response.status_code == 200 + data = response.json() + assert "room_name" in data + assert "join_url" in data + assert len(scheduled_meetings) == 1 + + def test_schedule_meeting_with_invites(self): + """Test scheduling with invites""" + scheduled_meetings.clear() + + response = client.post("/api/meetings/schedule", json={ + "title": "Team Meeting", + "scheduled_at": "2025-12-20T14:00:00", + "duration": 60, + "invites": ["user1@example.com", "user2@example.com"] + }) + + assert response.status_code == 200 + + +class TestTrainingEndpoint: + """Test /training endpoint""" + + def test_create_training(self): + """Test creating a training session""" + trainings.clear() + scheduled_meetings.clear() + + response = client.post("/api/meetings/training", json={ + "title": "Go Grundlagen", + "description": "Introduction to Go programming", + "scheduled_at": "2025-12-20T10:00:00", + "duration": 120, + "max_participants": 20, + "trainer": "Max Mustermann" + }) + + assert response.status_code == 200 + data = response.json() + assert "schulung-" in data["room_name"] + assert "go-grundlagen" in data["room_name"].lower() + assert len(trainings) == 1 + + def test_create_training_with_config(self): + """Test creating training with custom config""" + trainings.clear() + + response = client.post("/api/meetings/training", json={ + "title": "Docker Workshop", + "scheduled_at": "2025-12-21T14:00:00", + "duration": 180, + "max_participants": 15, + "trainer": "Lisa Schmidt", + "config": { + "enable_recording": True, + "enable_breakout": True + } + }) + + assert response.status_code == 200 + + +class TestParentTeacherEndpoint: + """Test /parent-teacher endpoint""" + + def test_create_parent_teacher_meeting(self): + """Test creating parent-teacher meeting""" + scheduled_meetings.clear() + + response = client.post("/api/meetings/parent-teacher", json={ + "student_name": "Max Müller", + "parent_name": "Herr Müller", + "parent_email": "mueller@example.com", + "scheduled_at": "2025-12-18T15:00:00", + "reason": "Halbjahresgespräch", + "send_invite": True + }) + + assert response.status_code == 200 + data = response.json() + assert "elterngespraech-" in data["room_name"] + assert "max-m" in data["room_name"].lower() + assert "password" in data + assert len(data["password"]) == 8 + + def test_create_parent_teacher_without_email(self): + """Test creating without email""" + response = client.post("/api/meetings/parent-teacher", json={ + "student_name": "Anna Schmidt", + "parent_name": "Frau Schmidt", + "scheduled_at": "2025-12-19T14:30:00" + }) + + assert response.status_code == 200 + + +class TestScheduledMeetingsEndpoint: + """Test /scheduled endpoint""" + + def test_get_scheduled_empty(self): + """Test getting scheduled meetings when empty""" + scheduled_meetings.clear() + + response = client.get("/api/meetings/scheduled") + assert response.status_code == 200 + assert response.json() == [] + + def test_get_scheduled_with_data(self): + """Test getting scheduled meetings with data""" + scheduled_meetings.clear() + scheduled_meetings.append({ + "room_name": "test-123", + "title": "Test Meeting", + "scheduled_at": "2025-12-20T10:00:00" + }) + + response = client.get("/api/meetings/scheduled") + assert response.status_code == 200 + assert len(response.json()) == 1 + + +class TestTrainingsEndpoint: + """Test /trainings endpoint""" + + def test_get_trainings(self): + """Test getting training sessions""" + trainings.clear() + trainings.append({ + "room_name": "schulung-test", + "title": "Test Training", + "trainer": "Test Trainer" + }) + + response = client.get("/api/meetings/trainings") + assert response.status_code == 200 + assert len(response.json()) == 1 + + +class TestDeleteMeetingEndpoint: + """Test DELETE endpoint""" + + def test_delete_meeting(self): + """Test deleting a meeting""" + # Clear and add a single meeting + scheduled_meetings.clear() + scheduled_meetings.append({ + "room_name": "to-delete", + "title": "Delete Me" + }) + initial_count = len(scheduled_meetings) + + response = client.delete("/api/meetings/to-delete") + assert response.status_code == 200 + assert response.json()["status"] == "deleted" + # Check that meeting was removed + assert len([m for m in scheduled_meetings if m["room_name"] == "to-delete"]) == 0 + + def test_delete_nonexistent_meeting(self): + """Test deleting a non-existent meeting""" + initial_count = len(scheduled_meetings) + + response = client.delete("/api/meetings/nonexistent") + assert response.status_code == 200 + # Count should remain the same (nothing was deleted) + assert len(scheduled_meetings) == initial_count + + +class TestRecordingsEndpoints: + """Test recordings endpoints""" + + def test_get_recordings(self): + """Test getting recordings list""" + response = client.get("/api/meetings/recordings") + assert response.status_code == 200 + + data = response.json() + assert isinstance(data, list) + assert len(data) > 0 + + def test_get_recording_details(self): + """Test getting recording details""" + response = client.get("/api/meetings/recordings/docker-basics") + assert response.status_code == 200 + + data = response.json() + assert data["id"] == "docker-basics" + assert "title" in data + assert "download_url" in data + + def test_download_recording_demo_mode(self): + """Test download in demo mode returns 404""" + response = client.get("/api/meetings/recordings/test/download") + assert response.status_code == 404 + + def test_delete_recording(self): + """Test deleting a recording""" + response = client.delete("/api/meetings/recordings/test-recording") + assert response.status_code == 200 + assert response.json()["status"] == "deleted" + + +class TestHealthEndpoint: + """Test health check endpoint""" + + @patch('meetings_api.httpx.AsyncClient') + def test_health_check_jitsi_available(self, mock_client): + """Test health check when Jitsi is available""" + # Skip this test as it requires async mocking + pass + + def test_health_check_returns_status(self): + """Test health check returns expected fields""" + response = client.get("/api/meetings/health") + assert response.status_code == 200 + + data = response.json() + assert "status" in data + assert "jitsi_url" in data + assert "jitsi_available" in data + assert "scheduled_meetings" in data + assert "active_meetings" in data + + +class TestMeetingConfigModel: + """Test MeetingConfig model""" + + def test_default_config(self): + """Test default config values""" + config = MeetingConfig() + assert config.enable_lobby is True + assert config.enable_recording is False + assert config.start_with_audio_muted is True + assert config.start_with_video_muted is False + assert config.require_display_name is True + assert config.enable_breakout is False + + def test_custom_config(self): + """Test custom config values""" + config = MeetingConfig( + enable_lobby=False, + enable_recording=True, + enable_breakout=True + ) + assert config.enable_lobby is False + assert config.enable_recording is True + assert config.enable_breakout is True + + +class TestRequestModels: + """Test request models""" + + def test_create_meeting_request_defaults(self): + """Test CreateMeetingRequest defaults""" + request = CreateMeetingRequest() + assert request.type == "quick" + assert request.title == "Neues Meeting" + assert request.duration == 60 + assert request.scheduled_at is None + assert request.config is None + + def test_schedule_meeting_request(self): + """Test ScheduleMeetingRequest""" + request = ScheduleMeetingRequest( + title="Test", + scheduled_at="2025-12-20T10:00:00" + ) + assert request.title == "Test" + assert request.duration == 60 + + def test_training_request(self): + """Test TrainingRequest""" + request = TrainingRequest( + title="Test Training", + scheduled_at="2025-12-20T10:00:00", + trainer="Trainer" + ) + assert request.title == "Test Training" + assert request.duration == 120 + assert request.max_participants == 20 + + def test_parent_teacher_request(self): + """Test ParentTeacherRequest""" + request = ParentTeacherRequest( + student_name="Max", + parent_name="Herr Müller", + scheduled_at="2025-12-20T10:00:00" + ) + assert request.student_name == "Max" + assert request.duration == 30 + assert request.send_invite is True diff --git a/backend/tests/test_meetings_frontend.py b/backend/tests/test_meetings_frontend.py new file mode 100644 index 0000000..6baad02 --- /dev/null +++ b/backend/tests/test_meetings_frontend.py @@ -0,0 +1,235 @@ +""" +Unit Tests for Meetings Frontend Module + +Tests for the refactored meetings frontend components: +- meetings_styles.py (CSS and Icons) +- meetings_templates.py (Sidebar and Base-Page Templates) +- meetings.py (Route handlers) +""" +import pytest +from unittest.mock import patch, MagicMock +from fastapi.testclient import TestClient +from fastapi import FastAPI + +import sys +sys.path.insert(0, '..') + +from frontend.meetings_styles import BREAKPILOT_STYLES, ICONS +from frontend.meetings_templates import render_sidebar, render_base_page +from frontend.meetings import router + + +# Create test app +app = FastAPI() +app.include_router(router) +client = TestClient(app) + + +class TestMeetingsStyles: + """Test CSS styles and icons""" + + def test_breakpilot_styles_exists(self): + """Test that BREAKPILOT_STYLES is defined""" + assert BREAKPILOT_STYLES is not None + assert isinstance(BREAKPILOT_STYLES, str) + assert len(BREAKPILOT_STYLES) > 0 + + def test_breakpilot_styles_contains_css_variables(self): + """Test that CSS contains required variables""" + assert "--bp-primary:" in BREAKPILOT_STYLES + assert "--bp-bg:" in BREAKPILOT_STYLES + assert "--bp-surface:" in BREAKPILOT_STYLES + assert "--bp-text:" in BREAKPILOT_STYLES + + def test_breakpilot_styles_contains_layout_classes(self): + """Test that CSS contains layout classes""" + assert ".app-container" in BREAKPILOT_STYLES + assert ".sidebar" in BREAKPILOT_STYLES + assert ".main-content" in BREAKPILOT_STYLES + + def test_icons_exists(self): + """Test that ICONS dictionary is defined""" + assert ICONS is not None + assert isinstance(ICONS, dict) + + def test_icons_contains_required_icons(self): + """Test that required icons are present""" + required_icons = ['home', 'video', 'calendar', 'graduation', 'record', 'grid', 'external', 'users', 'plus'] + for icon in required_icons: + assert icon in ICONS, f"Missing icon: {icon}" + + def test_icons_are_svg(self): + """Test that icons are SVG strings""" + for name, svg in ICONS.items(): + assert isinstance(svg, str), f"Icon {name} is not a string" + assert "Test Content

            ") + assert isinstance(result, str) + assert "" in result + assert "" in result + + def test_render_base_page_contains_title(self): + """Test that title is included in HTML""" + result = render_base_page("My Test Page", "

            Content

            ") + assert "My Test Page" in result + assert "BreakPilot Meet – My Test Page" in result + + def test_render_base_page_contains_content(self): + """Test that content is included in HTML""" + test_content = "

            This is test content

            " + result = render_base_page("Title", test_content) + assert test_content in result + + def test_render_base_page_includes_styles(self): + """Test that styles are included""" + result = render_base_page("Title", "Content") + assert " + + +
            +
            +
            + +
            +
            BreakPilot Studio
            +
            Arbeitsblätter · Eltern · KI
            +
            +
            + +
            +
            MVP · Lokal auf deinem Mac
            +
            +
            + +
            + + +
            + +
            +
            +
            +
            Arbeitsblätter & Vergleich
            +
            Links Scan · Rechts neu aufgebautes Arbeitsblatt
            +
            + 0 Dateien +
            + +
            +
            + + +
            + +
              + +
              + +
              + +
              +
              + Lade Arbeitsblätter hoch und klicke auf „Arbeitsblätter neu aufbauen“.
              + Dann kannst du mit den Pfeilen durch die Scans und die bereinigten Versionen blättern. +
              +
              +
              +
              + + +
              +
              +
              +
              Aufbereitungs-Tools
              +
              Kacheln für den Lernflow aktivieren/deaktivieren
              +
              +
              + +
              +
              + + + + +
              + +
              + +
              +
              +
              Original-Arbeitsblatt
              +
              Neuaufbau
              +
              +
              +
              Erzeugt bereinigte Versionen deiner Arbeitsblätter (ohne Handschrift) und baut saubere HTML-Arbeitsblätter, die im Vergleich rechts angezeigt werden.
              +
              + +
              +
              +
              + + +
              +
              +
              Frage–Antwort-Blatt
              +
              Kommen bald
              +
              +
              +
              Aus dem Original-Arbeitsblatt entsteht ein Frage–Antwort-Blatt. Elternmodus mit Übersetzung & Aussprache-Button wird hier andocken.
              +
              + +
              +
              +
              + + +
              +
              +
              Multiple Choice Test
              +
              Kommen bald
              +
              +
              +
              Erzeugt passende MC-Aufgaben zur ursprünglichen Schwierigkeit (z. B. Klasse 7), ohne das Niveau zu verändern.
              +
              + +
              +
              +
              + + +
              +
              +
              Lückentext
              +
              Kommen bald
              +
              +
              +
              Erzeugt oder rekonstruiert Lückentexte mit sinnvoll aufgeteilten Lücken (z. B. „habe“ + „gemacht“ getrennt).
              +
              + +
              +
              +
              + +
              +
              +
              +
              + +
              + + 1 von 2 + +
              + +
              +
              +
              +
              +
              +
              +
              +
              + + + + + + + """ diff --git a/backend/tools/migrate_frontend_ui.py b/backend/tools/migrate_frontend_ui.py new file mode 100644 index 0000000..a453c5e --- /dev/null +++ b/backend/tools/migrate_frontend_ui.py @@ -0,0 +1,73 @@ +import re +from pathlib import Path + + +def extract_app_ui_body(source_text: str) -> str: + """ + Extrahiert den HTML-Block aus der alten app_ui()-Funktion. + """ + match = re.search(r"def app_ui[^(]*\([^)]*\):\s*\n(\s*)return \"\"\"", source_text) + if not match: + raise RuntimeError("Konnte def app_ui() mit return \"\"\" nicht finden.") + + indent = match.group(1) + start = match.end() # direkt hinter return \"\"\" beginnt der HTML-Code + close_pattern = "\n" + indent + '"""' + end = source_text.find(close_pattern, start) + + if end == -1: + raise RuntimeError("Konnte schließende \"\"\" nicht finden.") + + body = source_text[start:end] + return body + + +def build_frontend_studio_file(html_body: str) -> str: + """ + Baut die vollständige Datei studio.py mit APIRouter + HTML-Block. + """ + header = ( + "from fastapi import APIRouter\n" + "from fastapi.responses import HTMLResponse\n\n" + "router = APIRouter()\n\n\n" + "@router.get(\"/app\", response_class=HTMLResponse)\n" + "def app_ui():\n" + " return \"\"\"" + ) + + footer = "\n \"\"\"\n" + return header + html_body + footer + + +def main(): + backend_dir = Path(__file__).parent + + # 1) Sicherungsdatei bevorzugt + backup_path = backend_dir / "frontend_app_backup.py" + if backup_path.exists(): + source_path = backup_path + print(f"Verwende Sicherungsdatei: {source_path}") + else: + # 2) Fallback auf frontend/app.py + candidate = backend_dir / "frontend" / "app.py" + if not candidate.exists(): + raise RuntimeError( + "Keine Quelle gefunden: frontend_app_backup.py oder frontend/app.py fehlen." + ) + source_path = candidate + print(f"Verwende Datei: {source_path}") + + source_text = source_path.read_text(encoding="utf-8") + html_body = extract_app_ui_body(source_text) + + studio_text = build_frontend_studio_file(html_body) + + studio_path = backend_dir / "frontend" / "studio.py" + studio_path.write_text(studio_text, encoding="utf-8") + + print(f"frontend/studio.py erfolgreich erzeugt: {studio_path}") + + +if __name__ == "__main__": + main() + diff --git a/backend/transcription_worker/__init__.py b/backend/transcription_worker/__init__.py new file mode 100644 index 0000000..afaec42 --- /dev/null +++ b/backend/transcription_worker/__init__.py @@ -0,0 +1,24 @@ +""" +BreakPilot Transcription Worker + +Asynchronous processing of meeting recordings using: +- faster-whisper for transcription (MIT License) +- pyannote.audio for speaker diarization (MIT License) + +All components are open source and commercially usable. +""" + +__version__ = "1.0.0" +__author__ = "BreakPilot Team" + +from .transcriber import WhisperTranscriber +from .diarizer import SpeakerDiarizer +from .aligner import TranscriptAligner +from .storage import MinIOStorage + +__all__ = [ + "WhisperTranscriber", + "SpeakerDiarizer", + "TranscriptAligner", + "MinIOStorage" +] diff --git a/backend/transcription_worker/aligner.py b/backend/transcription_worker/aligner.py new file mode 100644 index 0000000..ed0ccec --- /dev/null +++ b/backend/transcription_worker/aligner.py @@ -0,0 +1,202 @@ +""" +BreakPilot Transcript Aligner + +Aligns Whisper transcription segments with pyannote speaker diarization. +Assigns speaker IDs to each transcribed segment. +""" + +import structlog +from typing import List, Dict, Optional +from collections import defaultdict + +log = structlog.get_logger(__name__) + + +class TranscriptAligner: + """ + Aligns transcription segments with speaker diarization results. + + Uses overlap-based matching to assign speaker IDs to each + transcribed segment. Handles cases where speakers change + mid-sentence. + """ + + def __init__(self): + """Initialize the aligner.""" + self._speaker_count = 0 + self._speaker_map = {} # Maps pyannote IDs to friendly names + + def align( + self, + transcription_segments: List[Dict], + diarization_segments: List[Dict], + min_overlap_ratio: float = 0.3 + ) -> List[Dict]: + """ + Align transcription with speaker diarization. + + Args: + transcription_segments: List of segments from Whisper + diarization_segments: List of segments from pyannote + min_overlap_ratio: Minimum overlap ratio to assign speaker + + Returns: + Transcription segments with speaker_id added + """ + if not diarization_segments: + log.warning("no_diarization_segments", message="Returning transcription without speakers") + return transcription_segments + + log.info( + "aligning_transcription", + transcription_count=len(transcription_segments), + diarization_count=len(diarization_segments) + ) + + # Build speaker mapping + unique_speakers = set(s["speaker_id"] for s in diarization_segments) + self._speaker_count = len(unique_speakers) + + for i, speaker in enumerate(sorted(unique_speakers)): + self._speaker_map[speaker] = f"SPEAKER_{i:02d}" + + # Align each transcription segment + aligned_segments = [] + for trans_seg in transcription_segments: + speaker_id = self._find_speaker_for_segment( + trans_seg, + diarization_segments, + min_overlap_ratio + ) + + aligned_seg = trans_seg.copy() + aligned_seg["speaker_id"] = speaker_id + + aligned_segments.append(aligned_seg) + + # Log statistics + speaker_counts = defaultdict(int) + for seg in aligned_segments: + speaker_counts[seg.get("speaker_id", "UNKNOWN")] += 1 + + log.info( + "alignment_complete", + speakers=dict(speaker_counts), + total_speakers=self._speaker_count + ) + + return aligned_segments + + def _find_speaker_for_segment( + self, + trans_seg: Dict, + diarization_segments: List[Dict], + min_overlap_ratio: float + ) -> Optional[str]: + """ + Find the best matching speaker for a transcription segment. + + Uses overlap-based matching with the speaker who has the + highest overlap with the segment. + """ + trans_start = trans_seg["start_time_ms"] + trans_end = trans_seg["end_time_ms"] + trans_duration = trans_end - trans_start + + if trans_duration <= 0: + return None + + # Find overlapping diarization segments + overlaps = [] + for diar_seg in diarization_segments: + diar_start = diar_seg["start_time_ms"] + diar_end = diar_seg["end_time_ms"] + + # Calculate overlap + overlap_start = max(trans_start, diar_start) + overlap_end = min(trans_end, diar_end) + overlap_duration = max(0, overlap_end - overlap_start) + + if overlap_duration > 0: + overlap_ratio = overlap_duration / trans_duration + overlaps.append({ + "speaker_id": diar_seg["speaker_id"], + "overlap_duration": overlap_duration, + "overlap_ratio": overlap_ratio + }) + + if not overlaps: + return None + + # Find speaker with highest overlap + best_match = max(overlaps, key=lambda x: x["overlap_duration"]) + + if best_match["overlap_ratio"] >= min_overlap_ratio: + original_id = best_match["speaker_id"] + return self._speaker_map.get(original_id, original_id) + + return None + + def get_speaker_count(self) -> int: + """Get the number of unique speakers detected.""" + return self._speaker_count + + def get_speaker_mapping(self) -> Dict[str, str]: + """Get the mapping from pyannote IDs to friendly names.""" + return self._speaker_map.copy() + + def merge_consecutive_segments( + self, + segments: List[Dict], + max_gap_ms: int = 1000, + same_speaker_only: bool = True + ) -> List[Dict]: + """ + Merge consecutive segments that are close together. + + Useful for creating cleaner subtitle output. + + Args: + segments: List of aligned segments + max_gap_ms: Maximum gap between segments to merge + same_speaker_only: Only merge if same speaker + + Returns: + List of merged segments + """ + if not segments: + return [] + + merged = [] + current = segments[0].copy() + + for next_seg in segments[1:]: + gap = next_seg["start_time_ms"] - current["end_time_ms"] + same_speaker = ( + not same_speaker_only or + current.get("speaker_id") == next_seg.get("speaker_id") + ) + + if gap <= max_gap_ms and same_speaker: + # Merge segments + current["end_time_ms"] = next_seg["end_time_ms"] + current["text"] = current["text"] + " " + next_seg["text"] + + # Merge word timestamps if present + if "words" in current and "words" in next_seg: + current["words"].extend(next_seg["words"]) + else: + # Save current and start new + merged.append(current) + current = next_seg.copy() + + # Don't forget the last segment + merged.append(current) + + log.info( + "segments_merged", + original_count=len(segments), + merged_count=len(merged) + ) + + return merged diff --git a/backend/transcription_worker/diarizer.py b/backend/transcription_worker/diarizer.py new file mode 100644 index 0000000..28634ad --- /dev/null +++ b/backend/transcription_worker/diarizer.py @@ -0,0 +1,197 @@ +""" +BreakPilot Speaker Diarizer + +Uses pyannote.audio (MIT License) for speaker diarization. +Identifies who spoke when in an audio recording. +""" + +import os +import structlog +from typing import List, Dict, Optional + +log = structlog.get_logger(__name__) + + +class SpeakerDiarizer: + """ + Speaker diarization using pyannote.audio. + + Identifies distinct speakers in an audio recording and provides + timestamp information for when each speaker is talking. + + License: MIT + Source: https://github.com/pyannote/pyannote-audio + + Note: Requires a HuggingFace token with access to pyannote models. + Accept the conditions at: https://huggingface.co/pyannote/speaker-diarization + """ + + def __init__( + self, + auth_token: Optional[str] = None, + device: str = "auto" + ): + """ + Initialize the diarizer. + + Args: + auth_token: HuggingFace token with pyannote access + device: Device to run on ("cpu", "cuda", "auto") + """ + self.auth_token = auth_token or os.getenv("PYANNOTE_AUTH_TOKEN") + self.device = device + self._pipeline = None + + if not self.auth_token: + log.warning( + "pyannote_token_missing", + message="Speaker diarization requires a HuggingFace token" + ) + + def _load_pipeline(self): + """Lazy load the diarization pipeline.""" + if self._pipeline is not None: + return + + if not self.auth_token: + raise ValueError( + "HuggingFace token required for pyannote.audio. " + "Set PYANNOTE_AUTH_TOKEN environment variable." + ) + + try: + from pyannote.audio import Pipeline + import torch + + log.info("loading_pyannote_pipeline", device=self.device) + + # Load pre-trained speaker diarization pipeline + self._pipeline = Pipeline.from_pretrained( + "pyannote/speaker-diarization-3.1", + use_auth_token=self.auth_token + ) + + # Move to appropriate device + if self.device == "auto": + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + else: + device = torch.device(self.device) + + self._pipeline.to(device) + + log.info("pyannote_pipeline_loaded", device=str(device)) + + except ImportError: + log.error("pyannote_not_installed") + raise ImportError( + "pyannote.audio is not installed. " + "Install with: pip install pyannote.audio" + ) + + def diarize( + self, + audio_path: str, + num_speakers: Optional[int] = None, + min_speakers: Optional[int] = None, + max_speakers: Optional[int] = None + ) -> List[Dict]: + """ + Perform speaker diarization on an audio file. + + Args: + audio_path: Path to audio file (WAV recommended) + num_speakers: Exact number of speakers (if known) + min_speakers: Minimum number of speakers + max_speakers: Maximum number of speakers + + Returns: + List of speaker segments with speaker ID and timestamps + """ + self._load_pipeline() + + if not os.path.exists(audio_path): + raise FileNotFoundError(f"Audio file not found: {audio_path}") + + log.info( + "starting_diarization", + audio_path=audio_path, + num_speakers=num_speakers + ) + + # Run diarization + diarization = self._pipeline( + audio_path, + num_speakers=num_speakers, + min_speakers=min_speakers, + max_speakers=max_speakers + ) + + # Convert to list of segments + segments = [] + for turn, _, speaker in diarization.itertracks(yield_label=True): + segments.append({ + "speaker_id": speaker, + "start_time_ms": int(turn.start * 1000), + "end_time_ms": int(turn.end * 1000), + "duration_ms": int((turn.end - turn.start) * 1000) + }) + + # Get unique speakers + unique_speakers = set(s["speaker_id"] for s in segments) + + log.info( + "diarization_complete", + segments_count=len(segments), + speakers_count=len(unique_speakers), + speakers=list(unique_speakers) + ) + + return segments + + def get_speaker_stats(self, segments: List[Dict]) -> Dict: + """ + Calculate speaking statistics per speaker. + + Args: + segments: List of speaker segments from diarize() + + Returns: + dict with speaking time and percentage per speaker + """ + speaker_times = {} + + for seg in segments: + speaker = seg["speaker_id"] + duration = seg["duration_ms"] + + if speaker not in speaker_times: + speaker_times[speaker] = 0 + speaker_times[speaker] += duration + + total_time = sum(speaker_times.values()) + + stats = {} + for speaker, time_ms in speaker_times.items(): + stats[speaker] = { + "total_time_ms": time_ms, + "total_time_seconds": round(time_ms / 1000, 1), + "percentage": round((time_ms / total_time) * 100, 1) if total_time > 0 else 0 + } + + return { + "speakers": stats, + "total_speakers": len(stats), + "total_duration_ms": total_time + } + + def is_available(self) -> bool: + """Check if diarization is available (token configured).""" + return bool(self.auth_token) + + def get_pipeline_info(self) -> dict: + """Get information about the pipeline.""" + return { + "available": self.is_available(), + "device": self.device, + "loaded": self._pipeline is not None + } diff --git a/backend/transcription_worker/export.py b/backend/transcription_worker/export.py new file mode 100644 index 0000000..a82cfff --- /dev/null +++ b/backend/transcription_worker/export.py @@ -0,0 +1,291 @@ +""" +BreakPilot Transcript Export + +Functions to export transcription segments to various formats: +- WebVTT (for HTML5 video captions) +- SRT (universal subtitle format) +- JSON (full data with speakers and timestamps) +""" + +import json +from typing import List, Dict, Any +from datetime import datetime + + +def ms_to_vtt_timestamp(ms: int) -> str: + """ + Convert milliseconds to WebVTT timestamp format. + + Args: + ms: Milliseconds + + Returns: + Timestamp string (HH:MM:SS.mmm) + """ + hours = ms // 3600000 + minutes = (ms % 3600000) // 60000 + seconds = (ms % 60000) // 1000 + millis = ms % 1000 + + return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{millis:03d}" + + +def ms_to_srt_timestamp(ms: int) -> str: + """ + Convert milliseconds to SRT timestamp format. + + Args: + ms: Milliseconds + + Returns: + Timestamp string (HH:MM:SS,mmm) + """ + hours = ms // 3600000 + minutes = (ms % 3600000) // 60000 + seconds = (ms % 60000) // 1000 + millis = ms % 1000 + + # SRT uses comma as decimal separator + return f"{hours:02d}:{minutes:02d}:{seconds:02d},{millis:03d}" + + +def export_to_vtt( + segments: List[Dict], + include_speakers: bool = True, + header: str = "WEBVTT\nKind: captions\nLanguage: de\n" +) -> str: + """ + Export segments to WebVTT format. + + Args: + segments: List of transcription segments + include_speakers: Include speaker labels + header: VTT header text + + Returns: + WebVTT formatted string + """ + lines = [header] + + for i, seg in enumerate(segments): + # Cue identifier + lines.append(f"\n{i + 1}") + + # Timestamps + start = ms_to_vtt_timestamp(seg["start_time_ms"]) + end = ms_to_vtt_timestamp(seg["end_time_ms"]) + lines.append(f"{start} --> {end}") + + # Text with optional speaker + text = seg["text"] + if include_speakers and seg.get("speaker_id"): + text = f"{text}" + + lines.append(text) + + return "\n".join(lines) + "\n" + + +def export_to_srt( + segments: List[Dict], + include_speakers: bool = True +) -> str: + """ + Export segments to SRT format. + + Args: + segments: List of transcription segments + include_speakers: Include speaker labels in text + + Returns: + SRT formatted string + """ + lines = [] + + for i, seg in enumerate(segments): + # Sequence number + lines.append(str(i + 1)) + + # Timestamps + start = ms_to_srt_timestamp(seg["start_time_ms"]) + end = ms_to_srt_timestamp(seg["end_time_ms"]) + lines.append(f"{start} --> {end}") + + # Text with optional speaker + text = seg["text"] + if include_speakers and seg.get("speaker_id"): + text = f"[{seg['speaker_id']}] {text}" + + lines.append(text) + lines.append("") # Empty line between entries + + return "\n".join(lines) + + +def export_to_json( + segments: List[Dict], + metadata: Dict[str, Any] = None +) -> str: + """ + Export segments to JSON format with full metadata. + + Args: + segments: List of transcription segments + metadata: Additional metadata to include + + Returns: + JSON formatted string + """ + # Prepare export data + export_data = { + "version": "1.0", + "format": "breakpilot-transcript", + "generated_at": datetime.utcnow().isoformat() + "Z", + "metadata": metadata or {}, + "segments": [] + } + + # Add segments + for seg in segments: + export_seg = { + "index": seg.get("index", 0), + "start_ms": seg["start_time_ms"], + "end_ms": seg["end_time_ms"], + "duration_ms": seg["end_time_ms"] - seg["start_time_ms"], + "text": seg["text"], + "speaker_id": seg.get("speaker_id"), + "confidence": seg.get("confidence") + } + + # Include word-level timestamps if available + if "words" in seg: + export_seg["words"] = seg["words"] + + export_data["segments"].append(export_seg) + + # Calculate statistics + total_duration_ms = sum(s["duration_ms"] for s in export_data["segments"]) + total_words = sum(len(s["text"].split()) for s in export_data["segments"]) + unique_speakers = set(s["speaker_id"] for s in export_data["segments"] if s["speaker_id"]) + + export_data["statistics"] = { + "total_segments": len(export_data["segments"]), + "total_duration_ms": total_duration_ms, + "total_duration_seconds": round(total_duration_ms / 1000, 1), + "total_words": total_words, + "unique_speakers": len(unique_speakers), + "speakers": list(unique_speakers) + } + + return json.dumps(export_data, indent=2, ensure_ascii=False) + + +def export_to_txt( + segments: List[Dict], + include_timestamps: bool = False, + include_speakers: bool = True, + paragraph_gap_ms: int = 3000 +) -> str: + """ + Export segments to plain text format. + + Args: + segments: List of transcription segments + include_timestamps: Add timestamps + include_speakers: Add speaker labels + paragraph_gap_ms: Gap threshold for new paragraph + + Returns: + Plain text formatted string + """ + lines = [] + last_end = 0 + current_speaker = None + + for seg in segments: + # Add paragraph break for large gaps + gap = seg["start_time_ms"] - last_end + if gap > paragraph_gap_ms and lines: + lines.append("") + + # Build text line + parts = [] + + if include_timestamps: + ts = ms_to_vtt_timestamp(seg["start_time_ms"]) + parts.append(f"[{ts}]") + + speaker = seg.get("speaker_id") + if include_speakers and speaker and speaker != current_speaker: + parts.append(f"\n{speaker}:") + current_speaker = speaker + + parts.append(seg["text"]) + + lines.append(" ".join(parts)) + last_end = seg["end_time_ms"] + + return "\n".join(lines) + + +def create_chapters( + segments: List[Dict], + min_chapter_duration_ms: int = 60000, + speaker_change_as_chapter: bool = True +) -> List[Dict]: + """ + Create chapter markers from segments. + + Useful for video navigation and table of contents. + + Args: + segments: List of transcription segments + min_chapter_duration_ms: Minimum chapter duration + speaker_change_as_chapter: Create chapter on speaker change + + Returns: + List of chapter markers + """ + if not segments: + return [] + + chapters = [] + chapter_start = segments[0]["start_time_ms"] + chapter_text_parts = [] + current_speaker = segments[0].get("speaker_id") + + for seg in segments: + elapsed = seg["start_time_ms"] - chapter_start + + # Check for new chapter + speaker_changed = ( + speaker_change_as_chapter and + seg.get("speaker_id") and + seg.get("speaker_id") != current_speaker + ) + + if elapsed >= min_chapter_duration_ms or speaker_changed: + # Save current chapter + if chapter_text_parts: + chapters.append({ + "start_ms": chapter_start, + "title": " ".join(chapter_text_parts[:5]) + "...", # First 5 words + "speaker": current_speaker + }) + + # Start new chapter + chapter_start = seg["start_time_ms"] + chapter_text_parts = [] + current_speaker = seg.get("speaker_id") + + chapter_text_parts.extend(seg["text"].split()) + + # Don't forget the last chapter + if chapter_text_parts: + chapters.append({ + "start_ms": chapter_start, + "title": " ".join(chapter_text_parts[:5]) + "...", + "speaker": current_speaker + }) + + return chapters diff --git a/backend/transcription_worker/storage.py b/backend/transcription_worker/storage.py new file mode 100644 index 0000000..11ff21e --- /dev/null +++ b/backend/transcription_worker/storage.py @@ -0,0 +1,359 @@ +""" +BreakPilot MinIO Storage Helper + +Provides file upload/download operations for MinIO object storage. +""" + +import os +import io +import structlog +from typing import Optional, BinaryIO + +log = structlog.get_logger(__name__) + + +class MinIOStorage: + """ + MinIO storage client for recordings and transcriptions. + + Provides methods to upload, download, and manage files + in MinIO object storage (S3-compatible). + """ + + def __init__( + self, + endpoint: str = "minio:9000", + access_key: str = "breakpilot", + secret_key: str = "breakpilot123", + bucket: str = "breakpilot-recordings", + secure: bool = False + ): + """ + Initialize MinIO client. + + Args: + endpoint: MinIO server endpoint (host:port) + access_key: Access key (username) + secret_key: Secret key (password) + bucket: Default bucket name + secure: Use HTTPS + """ + self.endpoint = endpoint + self.access_key = access_key + self.secret_key = secret_key + self.bucket = bucket + self.secure = secure + self._client = None + + def _get_client(self): + """Lazy initialize MinIO client.""" + if self._client is not None: + return self._client + + try: + from minio import Minio + + self._client = Minio( + self.endpoint, + access_key=self.access_key, + secret_key=self.secret_key, + secure=self.secure + ) + + log.info( + "minio_client_initialized", + endpoint=self.endpoint, + bucket=self.bucket + ) + + return self._client + + except ImportError: + log.error("minio_not_installed") + raise ImportError( + "minio is not installed. " + "Install with: pip install minio" + ) + + def ensure_bucket(self) -> bool: + """ + Ensure the bucket exists, create if needed. + + Returns: + True if bucket exists or was created + """ + client = self._get_client() + + if not client.bucket_exists(self.bucket): + client.make_bucket(self.bucket) + log.info("bucket_created", bucket=self.bucket) + return True + + return True + + def download_file( + self, + object_name: str, + local_path: str, + bucket: Optional[str] = None + ) -> str: + """ + Download a file from MinIO. + + Args: + object_name: Path in MinIO bucket + local_path: Local destination path + bucket: Optional bucket override + + Returns: + Local file path + """ + client = self._get_client() + bucket = bucket or self.bucket + + log.info( + "downloading_file", + bucket=bucket, + object_name=object_name, + local_path=local_path + ) + + # Ensure directory exists + os.makedirs(os.path.dirname(local_path), exist_ok=True) + + # Download + client.fget_object(bucket, object_name, local_path) + + log.info( + "file_downloaded", + object_name=object_name, + local_path=local_path, + size=os.path.getsize(local_path) + ) + + return local_path + + def upload_file( + self, + local_path: str, + object_name: str, + content_type: Optional[str] = None, + bucket: Optional[str] = None + ) -> str: + """ + Upload a file to MinIO. + + Args: + local_path: Local file path + object_name: Destination path in MinIO + content_type: MIME type + bucket: Optional bucket override + + Returns: + Object name in MinIO + """ + client = self._get_client() + bucket = bucket or self.bucket + + # Ensure bucket exists + self.ensure_bucket() + + log.info( + "uploading_file", + local_path=local_path, + bucket=bucket, + object_name=object_name + ) + + # Upload + result = client.fput_object( + bucket, + object_name, + local_path, + content_type=content_type + ) + + log.info( + "file_uploaded", + object_name=object_name, + etag=result.etag + ) + + return object_name + + def upload_content( + self, + content: str, + object_name: str, + content_type: str = "text/plain", + bucket: Optional[str] = None + ) -> str: + """ + Upload string content directly to MinIO. + + Args: + content: String content to upload + object_name: Destination path in MinIO + content_type: MIME type + bucket: Optional bucket override + + Returns: + Object name in MinIO + """ + client = self._get_client() + bucket = bucket or self.bucket + + # Ensure bucket exists + self.ensure_bucket() + + # Convert to bytes + data = content.encode("utf-8") + data_stream = io.BytesIO(data) + + log.info( + "uploading_content", + bucket=bucket, + object_name=object_name, + size=len(data) + ) + + # Upload + result = client.put_object( + bucket, + object_name, + data_stream, + length=len(data), + content_type=content_type + ) + + log.info( + "content_uploaded", + object_name=object_name, + etag=result.etag + ) + + return object_name + + def get_content( + self, + object_name: str, + bucket: Optional[str] = None + ) -> str: + """ + Get string content from MinIO. + + Args: + object_name: Path in MinIO bucket + bucket: Optional bucket override + + Returns: + File content as string + """ + client = self._get_client() + bucket = bucket or self.bucket + + response = client.get_object(bucket, object_name) + content = response.read().decode("utf-8") + response.close() + response.release_conn() + + return content + + def delete_file( + self, + object_name: str, + bucket: Optional[str] = None + ) -> bool: + """ + Delete a file from MinIO. + + Args: + object_name: Path in MinIO bucket + bucket: Optional bucket override + + Returns: + True if deleted + """ + client = self._get_client() + bucket = bucket or self.bucket + + client.remove_object(bucket, object_name) + + log.info("file_deleted", object_name=object_name) + return True + + def file_exists( + self, + object_name: str, + bucket: Optional[str] = None + ) -> bool: + """ + Check if a file exists in MinIO. + + Args: + object_name: Path in MinIO bucket + bucket: Optional bucket override + + Returns: + True if file exists + """ + client = self._get_client() + bucket = bucket or self.bucket + + try: + client.stat_object(bucket, object_name) + return True + except Exception: + return False + + def get_presigned_url( + self, + object_name: str, + expires_hours: int = 24, + bucket: Optional[str] = None + ) -> str: + """ + Get a presigned URL for temporary file access. + + Args: + object_name: Path in MinIO bucket + expires_hours: URL validity in hours + bucket: Optional bucket override + + Returns: + Presigned URL + """ + from datetime import timedelta + + client = self._get_client() + bucket = bucket or self.bucket + + url = client.presigned_get_object( + bucket, + object_name, + expires=timedelta(hours=expires_hours) + ) + + return url + + def list_files( + self, + prefix: str = "", + bucket: Optional[str] = None + ) -> list: + """ + List files with a given prefix. + + Args: + prefix: Path prefix to filter + bucket: Optional bucket override + + Returns: + List of object names + """ + client = self._get_client() + bucket = bucket or self.bucket + + objects = client.list_objects(bucket, prefix=prefix, recursive=True) + + return [obj.object_name for obj in objects] diff --git a/backend/transcription_worker/tasks.py b/backend/transcription_worker/tasks.py new file mode 100644 index 0000000..fe01735 --- /dev/null +++ b/backend/transcription_worker/tasks.py @@ -0,0 +1,230 @@ +""" +BreakPilot Transcription Tasks + +RQ task definitions for transcription processing. +""" + +import os +import time +import tempfile +import structlog +from typing import Optional +from datetime import datetime + +from .transcriber import WhisperTranscriber +from .diarizer import SpeakerDiarizer +from .aligner import TranscriptAligner +from .storage import MinIOStorage +from .export import export_to_vtt, export_to_srt, export_to_json + +log = structlog.get_logger(__name__) + +# Configuration +WHISPER_MODEL = os.getenv("WHISPER_MODEL", "large-v3") +WHISPER_DEVICE = os.getenv("WHISPER_DEVICE", "cpu") +WHISPER_COMPUTE_TYPE = os.getenv("WHISPER_COMPUTE_TYPE", "int8") +PYANNOTE_AUTH_TOKEN = os.getenv("PYANNOTE_AUTH_TOKEN") +TEMP_DIR = os.getenv("TEMP_DIR", "/tmp/transcriptions") + +# MinIO Configuration +MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minio:9000") +MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "breakpilot") +MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "breakpilot123") +MINIO_BUCKET = os.getenv("MINIO_BUCKET", "breakpilot-recordings") +MINIO_SECURE = os.getenv("MINIO_SECURE", "false").lower() == "true" + +# Database URL for status updates +DATABASE_URL = os.getenv("DATABASE_URL") + + +def update_transcription_status( + transcription_id: str, + status: str, + error_message: Optional[str] = None, + **kwargs +): + """Update transcription status in database.""" + # TODO: Implement database update + log.info( + "status_update", + transcription_id=transcription_id, + status=status, + error=error_message, + **kwargs + ) + + +def transcribe_recording( + transcription_id: str, + recording_id: str, + audio_path: str, + language: str = "de", + enable_diarization: bool = True +) -> dict: + """ + Main transcription task. + + Downloads audio from MinIO, transcribes with Whisper, + optionally performs speaker diarization, and uploads results. + + Args: + transcription_id: UUID of the transcription record + recording_id: UUID of the source recording + audio_path: Path to audio file in MinIO bucket + language: Language code (de, en, etc.) + enable_diarization: Whether to perform speaker diarization + + Returns: + dict with transcription results and paths + """ + start_time = time.time() + + log.info( + "transcription_started", + transcription_id=transcription_id, + recording_id=recording_id, + audio_path=audio_path, + language=language + ) + + # Update status to processing + update_transcription_status( + transcription_id, + status="processing", + processing_started_at=datetime.utcnow().isoformat() + ) + + try: + # Initialize storage + storage = MinIOStorage( + endpoint=MINIO_ENDPOINT, + access_key=MINIO_ACCESS_KEY, + secret_key=MINIO_SECRET_KEY, + bucket=MINIO_BUCKET, + secure=MINIO_SECURE + ) + + # Create temp directory + os.makedirs(TEMP_DIR, exist_ok=True) + + # Download audio file + local_audio_path = os.path.join(TEMP_DIR, f"{transcription_id}_audio.wav") + storage.download_file(audio_path, local_audio_path) + log.info("audio_downloaded", path=local_audio_path) + + # Initialize transcriber + transcriber = WhisperTranscriber( + model_name=WHISPER_MODEL, + device=WHISPER_DEVICE, + compute_type=WHISPER_COMPUTE_TYPE + ) + + # Transcribe audio + log.info("transcription_starting", model=WHISPER_MODEL) + segments = transcriber.transcribe( + audio_path=local_audio_path, + language=language + ) + log.info("transcription_complete", segments_count=len(segments)) + + # Speaker diarization (if enabled and token available) + if enable_diarization and PYANNOTE_AUTH_TOKEN: + log.info("diarization_starting") + diarizer = SpeakerDiarizer(auth_token=PYANNOTE_AUTH_TOKEN) + speaker_segments = diarizer.diarize(local_audio_path) + + # Align transcription with speakers + aligner = TranscriptAligner() + segments = aligner.align(segments, speaker_segments) + log.info("diarization_complete", speakers=aligner.get_speaker_count()) + else: + log.info("diarization_skipped", reason="disabled or no token") + + # Calculate statistics + full_text = " ".join(s["text"] for s in segments) + word_count = len(full_text.split()) + avg_confidence = sum(s.get("confidence", 0) for s in segments) / len(segments) if segments else 0 + + # Export to different formats + base_path = audio_path.rsplit("/", 1)[0] # recordings/{recording_name} + + # WebVTT + vtt_content = export_to_vtt(segments) + vtt_path = f"{base_path}/transcript.vtt" + storage.upload_content(vtt_content, vtt_path, content_type="text/vtt") + + # SRT + srt_content = export_to_srt(segments) + srt_path = f"{base_path}/transcript.srt" + storage.upload_content(srt_content, srt_path, content_type="text/plain") + + # JSON (full data with speakers) + json_content = export_to_json(segments, { + "transcription_id": transcription_id, + "recording_id": recording_id, + "language": language, + "model": WHISPER_MODEL, + "word_count": word_count, + "confidence": avg_confidence + }) + json_path = f"{base_path}/transcript.json" + storage.upload_content(json_content, json_path, content_type="application/json") + + # Cleanup temp file + if os.path.exists(local_audio_path): + os.remove(local_audio_path) + + # Calculate processing time + processing_duration = int(time.time() - start_time) + + # Update status to completed + result = { + "transcription_id": transcription_id, + "recording_id": recording_id, + "status": "completed", + "full_text": full_text, + "word_count": word_count, + "confidence_score": round(avg_confidence, 3), + "segments_count": len(segments), + "vtt_path": vtt_path, + "srt_path": srt_path, + "json_path": json_path, + "processing_duration_seconds": processing_duration + } + + update_transcription_status( + transcription_id, + status="completed", + full_text=full_text, + word_count=word_count, + confidence_score=avg_confidence, + vtt_path=vtt_path, + srt_path=srt_path, + json_path=json_path, + processing_duration_seconds=processing_duration, + processing_completed_at=datetime.utcnow().isoformat() + ) + + log.info( + "transcription_completed", + transcription_id=transcription_id, + word_count=word_count, + duration_seconds=processing_duration + ) + + return result + + except Exception as e: + log.error( + "transcription_failed", + transcription_id=transcription_id, + error=str(e) + ) + + update_transcription_status( + transcription_id, + status="failed", + error_message=str(e) + ) + + raise diff --git a/backend/transcription_worker/transcriber.py b/backend/transcription_worker/transcriber.py new file mode 100644 index 0000000..b316105 --- /dev/null +++ b/backend/transcription_worker/transcriber.py @@ -0,0 +1,211 @@ +""" +BreakPilot Whisper Transcriber + +Uses faster-whisper (MIT License) for GPU-optimized transcription. +Based on CTranslate2 for fast inference. +""" + +import os +import structlog +from typing import List, Dict, Optional + +log = structlog.get_logger(__name__) + + +class WhisperTranscriber: + """ + Whisper-based audio transcription using faster-whisper. + + faster-whisper is a reimplementation of OpenAI Whisper using CTranslate2, + which is significantly faster than the original implementation. + + License: MIT + Source: https://github.com/SYSTRAN/faster-whisper + """ + + def __init__( + self, + model_name: str = "large-v3", + device: str = "cpu", + compute_type: str = "int8" + ): + """ + Initialize the transcriber. + + Args: + model_name: Whisper model to use (tiny, base, small, medium, large-v3) + device: Device to run on ("cpu", "cuda", "auto") + compute_type: Quantization type ("int8", "float16", "float32") + """ + self.model_name = model_name + self.device = device + self.compute_type = compute_type + self._model = None + + def _load_model(self): + """Lazy load the model on first use.""" + if self._model is not None: + return + + try: + from faster_whisper import WhisperModel + + log.info( + "loading_whisper_model", + model=self.model_name, + device=self.device, + compute_type=self.compute_type + ) + + self._model = WhisperModel( + self.model_name, + device=self.device, + compute_type=self.compute_type + ) + + log.info("whisper_model_loaded") + + except ImportError: + log.error("faster_whisper_not_installed") + raise ImportError( + "faster-whisper is not installed. " + "Install with: pip install faster-whisper" + ) + + def transcribe( + self, + audio_path: str, + language: str = "de", + beam_size: int = 5, + word_timestamps: bool = True, + vad_filter: bool = True, + vad_parameters: Optional[dict] = None + ) -> List[Dict]: + """ + Transcribe an audio file. + + Args: + audio_path: Path to audio file (WAV, MP3, etc.) + language: Language code (de, en, fr, etc.) or None for auto-detection + beam_size: Beam size for decoding (higher = better but slower) + word_timestamps: Include word-level timestamps + vad_filter: Enable Voice Activity Detection to filter silence + vad_parameters: Custom VAD parameters + + Returns: + List of segments with text, timestamps, and confidence scores + """ + self._load_model() + + if not os.path.exists(audio_path): + raise FileNotFoundError(f"Audio file not found: {audio_path}") + + log.info( + "transcribing_audio", + audio_path=audio_path, + language=language, + beam_size=beam_size + ) + + # Default VAD parameters for better speech detection + if vad_parameters is None: + vad_parameters = { + "min_silence_duration_ms": 500, + "speech_pad_ms": 400 + } + + # Run transcription + segments_gen, info = self._model.transcribe( + audio_path, + language=language, + beam_size=beam_size, + word_timestamps=word_timestamps, + vad_filter=vad_filter, + vad_parameters=vad_parameters + ) + + log.info( + "transcription_info", + detected_language=info.language, + language_probability=info.language_probability, + duration=info.duration + ) + + # Convert generator to list of segments + segments = [] + for i, segment in enumerate(segments_gen): + seg_dict = { + "index": i, + "start_time_ms": int(segment.start * 1000), + "end_time_ms": int(segment.end * 1000), + "text": segment.text.strip(), + "confidence": round(segment.avg_logprob, 3) if segment.avg_logprob else None, + "no_speech_prob": segment.no_speech_prob + } + + # Add word-level timestamps if available + if word_timestamps and segment.words: + seg_dict["words"] = [ + { + "word": word.word, + "start": int(word.start * 1000), + "end": int(word.end * 1000), + "probability": round(word.probability, 3) + } + for word in segment.words + ] + + segments.append(seg_dict) + + log.info( + "transcription_complete", + segments_count=len(segments), + duration_seconds=info.duration + ) + + return segments + + def detect_language(self, audio_path: str) -> dict: + """ + Detect the language of an audio file. + + Args: + audio_path: Path to audio file + + Returns: + dict with language code and probability + """ + self._load_model() + + if not os.path.exists(audio_path): + raise FileNotFoundError(f"Audio file not found: {audio_path}") + + # Transcribe first 30 seconds to detect language + _, info = self._model.transcribe( + audio_path, + language=None, # Auto-detect + beam_size=1, + without_timestamps=True + ) + + return { + "language": info.language, + "probability": info.language_probability + } + + @property + def available_languages(self) -> List[str]: + """List of supported languages.""" + return [ + "de", "en", "fr", "es", "it", "pt", "nl", "pl", "ru", + "zh", "ja", "ko", "ar", "tr", "hi", "vi", "th", "id" + ] + + def get_model_info(self) -> dict: + """Get information about the loaded model.""" + return { + "model_name": self.model_name, + "device": self.device, + "compute_type": self.compute_type, + "loaded": self._model is not None + } diff --git a/backend/transcription_worker/worker.py b/backend/transcription_worker/worker.py new file mode 100644 index 0000000..c51d876 --- /dev/null +++ b/backend/transcription_worker/worker.py @@ -0,0 +1,129 @@ +""" +BreakPilot Transcription Worker - Main Entry Point + +Runs as an RQ worker, processing transcription jobs from the queue. +""" + +import os +import sys +import signal +import structlog +from redis import Redis +from rq import Worker, Queue, Connection + +# Configure logging +structlog.configure( + processors=[ + structlog.stdlib.filter_by_level, + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.stdlib.PositionalArgumentsFormatter(), + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.UnicodeDecoder(), + structlog.processors.JSONRenderer() + ], + wrapper_class=structlog.stdlib.BoundLogger, + context_class=dict, + logger_factory=structlog.stdlib.LoggerFactory(), + cache_logger_on_first_use=True, +) + +log = structlog.get_logger(__name__) + +# Configuration +REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379/1") +QUEUE_NAME = os.getenv("QUEUE_NAME", "transcription") +WORKER_NAME = os.getenv("WORKER_NAME", f"transcription-worker-{os.getpid()}") + + +def setup_signal_handlers(worker: Worker): + """Setup graceful shutdown handlers.""" + + def shutdown_handler(signum, frame): + log.info("shutdown_signal_received", signal=signum) + worker.request_stop(signum, frame) + + signal.signal(signal.SIGINT, shutdown_handler) + signal.signal(signal.SIGTERM, shutdown_handler) + + +def preload_models(): + """Preload ML models to reduce first-job latency.""" + log.info("preloading_models") + + try: + from .transcriber import WhisperTranscriber + from .diarizer import SpeakerDiarizer + + # Initialize transcriber (downloads model if needed) + whisper_model = os.getenv("WHISPER_MODEL", "large-v3") + device = os.getenv("WHISPER_DEVICE", "cpu") + compute_type = os.getenv("WHISPER_COMPUTE_TYPE", "int8") + + transcriber = WhisperTranscriber( + model_name=whisper_model, + device=device, + compute_type=compute_type + ) + log.info("whisper_model_loaded", model=whisper_model, device=device) + + # Initialize diarizer (downloads model if needed) + pyannote_token = os.getenv("PYANNOTE_AUTH_TOKEN") + if pyannote_token: + diarizer = SpeakerDiarizer(auth_token=pyannote_token) + log.info("pyannote_model_loaded") + else: + log.warning("pyannote_token_missing", message="Speaker diarization disabled") + + except Exception as e: + log.error("model_preload_failed", error=str(e)) + # Don't fail startup, models will be loaded on first job + + +def main(): + """Main entry point for the worker.""" + log.info( + "worker_starting", + redis_url=REDIS_URL, + queue=QUEUE_NAME, + worker_name=WORKER_NAME + ) + + # Connect to Redis + redis_conn = Redis.from_url(REDIS_URL) + + # Test connection + try: + redis_conn.ping() + log.info("redis_connected") + except Exception as e: + log.error("redis_connection_failed", error=str(e)) + sys.exit(1) + + # Preload models + preload_models() + + # Create queue + queue = Queue(QUEUE_NAME, connection=redis_conn) + + # Create worker + worker = Worker( + queues=[queue], + connection=redis_conn, + name=WORKER_NAME + ) + + # Setup signal handlers + setup_signal_handlers(worker) + + log.info("worker_ready", queues=[QUEUE_NAME]) + + # Start processing + with Connection(redis_conn): + worker.work(with_scheduler=True) + + +if __name__ == "__main__": + main() diff --git a/backend/ui_test_api.py b/backend/ui_test_api.py new file mode 100644 index 0000000..8903733 --- /dev/null +++ b/backend/ui_test_api.py @@ -0,0 +1,738 @@ +""" +UI Test API + +Provides endpoints for the interactive UI Test Wizard. +Allows testing of middleware components with educational feedback. +""" + +from __future__ import annotations + +import asyncio +import time +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict, List, Optional + +import httpx +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel + + +# ============================================== +# Data Models +# ============================================== + + +class TestResult(BaseModel): + """Result of a single test.""" + + name: str + description: str + expected: str + actual: str + status: str # "passed" | "failed" | "pending" | "skipped" + duration_ms: float = 0.0 + error_message: Optional[str] = None + + +class TestCategoryResult(BaseModel): + """Result of a test category.""" + + category: str + display_name: str + description: str + why_important: str + tests: List[TestResult] + passed: int + failed: int + total: int + duration_ms: float + + +class FullTestResults(BaseModel): + """Full test results for all categories.""" + + timestamp: str + categories: List[TestCategoryResult] + total_passed: int + total_failed: int + total_tests: int + duration_ms: float + + +# ============================================== +# Educational Content +# ============================================== + +EDUCATION_CONTENT = { + "request-id": { + "display_name": "Request-ID & Tracing", + "description": "Request-ID Middleware fuer Distributed Tracing", + "why_important": """ +Stellen Sie sich vor, ein Benutzer meldet einen Fehler. Ohne Request-ID muessen +Sie Tausende von Log-Eintraegen durchsuchen. Mit Request-ID finden Sie den genauen +Pfad der Anfrage durch alle Microservices in Sekunden. + +Request-IDs sind essentiell fuer: +- Fehlersuche in verteilten Systemen +- Performance-Analyse +- Audit-Trails fuer Compliance +""", + }, + "security-headers": { + "display_name": "Security Headers", + "description": "HTTP Security Headers zum Schutz vor Web-Angriffen", + "why_important": """ +Security Headers sind Anweisungen an den Browser, wie er Ihre Seite schuetzen soll: + +- X-Content-Type-Options: nosniff - Verhindert MIME-Sniffing Angriffe +- X-Frame-Options: DENY - Blockiert Clickjacking-Angriffe +- Content-Security-Policy - Stoppt XSS durch Whitelist erlaubter Quellen +- Strict-Transport-Security - Erzwingt HTTPS + +OWASP empfiehlt diese Headers als Mindeststandard. Sie sind Pflicht fuer +DSGVO-Konformitaet und schuetzen Ihre Benutzer vor gaengigen Angriffen. +""", + }, + "rate-limiter": { + "display_name": "Rate Limiting", + "description": "Schutz vor Brute-Force und DDoS Angriffen", + "why_important": """ +Ohne Rate Limiting kann ein Angreifer: +- Passwort-Brute-Force durchfuehren (Millionen Versuche/Minute) +- Ihre Server mit Anfragen ueberfluten (DDoS) +- Teure API-Aufrufe missbrauchen + +BreakPilot limitiert: +- 100 Anfragen/Minute pro IP (allgemein) +- 20 Anfragen/Minute fuer Auth-Endpoints +- 500 Anfragen/Minute pro authentifiziertem Benutzer +""", + }, + "pii-redactor": { + "display_name": "PII Redaktion", + "description": "Automatische Entfernung personenbezogener Daten aus Logs", + "why_important": """ +Personenbezogene Daten in Logs sind ein DSGVO-Verstoss: + +- Email-Adressen: Bussgelder bis 20 Mio. EUR +- IP-Adressen: Gelten als personenbezogen (EuGH-Urteil) +- Telefonnummern: Direkter Personenbezug + +Der PII Redactor erkennt automatisch: +- Email-Adressen → [EMAIL_REDACTED] +- IP-Adressen → [IP_REDACTED] +- Deutsche Telefonnummern → [PHONE_REDACTED] +- IBAN-Nummern → [IBAN_REDACTED] +""", + }, + "input-gate": { + "display_name": "Input Validierung", + "description": "Validierung eingehender Anfragen und Uploads", + "why_important": """ +Das Input Gate prueft jede Anfrage bevor sie Ihren Code erreicht: + +- Groessenlimit: Blockiert ueberdimensionierte Payloads (DoS-Schutz) +- Content-Type: Erlaubt nur erwartete Formate +- Dateiendungen: Blockiert .exe, .bat, .sh Uploads + +Ein Angreifer, der an Ihrem Code vorbeikommt, wird hier gestoppt. +Dies ist Ihre erste Verteidigungslinie gegen Injection-Angriffe. +""", + }, + "cors": { + "display_name": "CORS", + "description": "Cross-Origin Resource Sharing Konfiguration", + "why_important": """ +CORS bestimmt, welche Websites Ihre API aufrufen duerfen: + +- Zu offen (*): Jede Website kann Ihre API missbrauchen +- Zu streng: Ihre eigene Frontend-App wird blockiert + +BreakPilot erlaubt nur: +- https://breakpilot.app (Produktion) +- http://localhost:3000 (Development) + +Falsch konfiguriertes CORS ist eine der haeufigsten Sicherheitsluecken +in Web-Anwendungen. +""", + }, +} + + +# ============================================== +# Test Runner +# ============================================== + + +class MiddlewareTestRunner: + """Runs middleware tests against the backend.""" + + def __init__(self, base_url: str = "http://localhost:8000"): + self.base_url = base_url + + async def test_request_id(self) -> TestCategoryResult: + """Test Request-ID middleware.""" + tests: List[TestResult] = [] + start_time = time.time() + + async with httpx.AsyncClient() as client: + # Test 1: New request should get a Request-ID + test_start = time.time() + try: + response = await client.get(f"{self.base_url}/api/health") + request_id = response.headers.get("X-Request-ID", "") + + tests.append( + TestResult( + name="Generiert neue Request-ID", + description="Eine neue UUID wird generiert wenn keine vorhanden ist", + expected="UUID im X-Request-ID Header", + actual=request_id[:36] if len(request_id) >= 36 else request_id, + status="passed" if len(request_id) >= 36 else "failed", + duration_ms=(time.time() - test_start) * 1000, + ) + ) + except Exception as e: + tests.append( + TestResult( + name="Generiert neue Request-ID", + description="Eine neue UUID wird generiert wenn keine vorhanden ist", + expected="UUID im X-Request-ID Header", + actual="Fehler", + status="failed", + duration_ms=(time.time() - test_start) * 1000, + error_message=str(e), + ) + ) + + # Test 2: Existing Request-ID should be propagated + test_start = time.time() + custom_id = "test-request-id-12345" + try: + response = await client.get( + f"{self.base_url}/api/health", + headers={"X-Request-ID": custom_id}, + ) + returned_id = response.headers.get("X-Request-ID", "") + + tests.append( + TestResult( + name="Propagiert vorhandene Request-ID", + description="Existierende X-Request-ID wird weitergeleitet", + expected=custom_id, + actual=returned_id, + status="passed" if returned_id == custom_id else "failed", + duration_ms=(time.time() - test_start) * 1000, + ) + ) + except Exception as e: + tests.append( + TestResult( + name="Propagiert vorhandene Request-ID", + description="Existierende X-Request-ID wird weitergeleitet", + expected=custom_id, + actual="Fehler", + status="failed", + duration_ms=(time.time() - test_start) * 1000, + error_message=str(e), + ) + ) + + # Test 3: Correlation-ID should work + test_start = time.time() + correlation_id = "correlation-12345" + try: + response = await client.get( + f"{self.base_url}/api/health", + headers={"X-Correlation-ID": correlation_id}, + ) + returned_id = response.headers.get("X-Request-ID", "") + + tests.append( + TestResult( + name="Unterstuetzt X-Correlation-ID", + description="X-Correlation-ID wird als Request-ID verwendet", + expected=correlation_id, + actual=returned_id, + status="passed" if returned_id == correlation_id else "failed", + duration_ms=(time.time() - test_start) * 1000, + ) + ) + except Exception as e: + tests.append( + TestResult( + name="Unterstuetzt X-Correlation-ID", + description="X-Correlation-ID wird als Request-ID verwendet", + expected=correlation_id, + actual="Fehler", + status="failed", + duration_ms=(time.time() - test_start) * 1000, + error_message=str(e), + ) + ) + + passed = sum(1 for t in tests if t.status == "passed") + return TestCategoryResult( + category="request-id", + display_name=EDUCATION_CONTENT["request-id"]["display_name"], + description=EDUCATION_CONTENT["request-id"]["description"], + why_important=EDUCATION_CONTENT["request-id"]["why_important"], + tests=tests, + passed=passed, + failed=len(tests) - passed, + total=len(tests), + duration_ms=(time.time() - start_time) * 1000, + ) + + async def test_security_headers(self) -> TestCategoryResult: + """Test Security Headers middleware.""" + tests: List[TestResult] = [] + start_time = time.time() + + async with httpx.AsyncClient() as client: + try: + response = await client.get(f"{self.base_url}/api/health") + headers = response.headers + + # Required headers + header_tests = [ + ("X-Content-Type-Options", "nosniff", "Verhindert MIME-Sniffing"), + ("X-Frame-Options", "DENY", "Verhindert Clickjacking"), + ("X-XSS-Protection", "1; mode=block", "XSS Filter aktiviert"), + ("Referrer-Policy", None, "Kontrolliert Referrer-Informationen"), + ] + + for header_name, expected_value, description in header_tests: + test_start = time.time() + actual = headers.get(header_name, "") + + if expected_value: + status = "passed" if actual == expected_value else "failed" + else: + status = "passed" if actual else "failed" + + tests.append( + TestResult( + name=f"{header_name} Header", + description=description, + expected=expected_value or "Beliebiger Wert", + actual=actual or "(nicht gesetzt)", + status=status, + duration_ms=(time.time() - test_start) * 1000, + ) + ) + + except Exception as e: + tests.append( + TestResult( + name="Security Headers", + description="Ueberpruefen der Security Headers", + expected="Headers vorhanden", + actual="Fehler", + status="failed", + error_message=str(e), + ) + ) + + passed = sum(1 for t in tests if t.status == "passed") + return TestCategoryResult( + category="security-headers", + display_name=EDUCATION_CONTENT["security-headers"]["display_name"], + description=EDUCATION_CONTENT["security-headers"]["description"], + why_important=EDUCATION_CONTENT["security-headers"]["why_important"], + tests=tests, + passed=passed, + failed=len(tests) - passed, + total=len(tests), + duration_ms=(time.time() - start_time) * 1000, + ) + + async def test_pii_redactor(self) -> TestCategoryResult: + """Test PII Redactor.""" + tests: List[TestResult] = [] + start_time = time.time() + + try: + from middleware.pii_redactor import PIIRedactor, redact_pii + + redactor = PIIRedactor() + + # Test cases + test_cases = [ + ( + "Email Redaktion", + "User test@example.com hat sich angemeldet", + "test@example.com", + "[EMAIL_REDACTED]", + ), + ( + "IPv4 Redaktion", + "Request von 192.168.1.100", + "192.168.1.100", + "[IP_REDACTED]", + ), + ( + "Telefon Redaktion", + "Anruf von +49 30 12345678", + "+49 30 12345678", + "[PHONE_REDACTED]", + ), + ] + + for name, text, pii, expected_replacement in test_cases: + test_start = time.time() + result = redact_pii(text) + status = "passed" if pii not in result and expected_replacement in result else "failed" + + tests.append( + TestResult( + name=name, + description=f"Prueft ob {pii} korrekt entfernt wird", + expected=expected_replacement, + actual=result.replace(text.replace(pii, ""), "").strip(), + status=status, + duration_ms=(time.time() - test_start) * 1000, + ) + ) + + # Test non-PII preservation + test_start = time.time() + clean_text = "Normale Nachricht ohne PII" + result = redact_pii(clean_text) + tests.append( + TestResult( + name="Nicht-PII bleibt erhalten", + description="Text ohne PII wird nicht veraendert", + expected=clean_text, + actual=result, + status="passed" if result == clean_text else "failed", + duration_ms=(time.time() - test_start) * 1000, + ) + ) + + except Exception as e: + tests.append( + TestResult( + name="PII Redactor", + description="PII Redaction testen", + expected="Redaktion funktioniert", + actual="Fehler", + status="failed", + error_message=str(e), + ) + ) + + passed = sum(1 for t in tests if t.status == "passed") + return TestCategoryResult( + category="pii-redactor", + display_name=EDUCATION_CONTENT["pii-redactor"]["display_name"], + description=EDUCATION_CONTENT["pii-redactor"]["description"], + why_important=EDUCATION_CONTENT["pii-redactor"]["why_important"], + tests=tests, + passed=passed, + failed=len(tests) - passed, + total=len(tests), + duration_ms=(time.time() - start_time) * 1000, + ) + + async def test_input_gate(self) -> TestCategoryResult: + """Test Input Gate middleware.""" + tests: List[TestResult] = [] + start_time = time.time() + + try: + from middleware.input_gate import validate_file_upload, InputGateConfig + + config = InputGateConfig() + + # Test blocked extensions + blocked_files = [ + ("malware.exe", True), + ("script.bat", True), + ("document.pdf", False), + ("image.jpg", False), + ] + + for filename, should_block in blocked_files: + test_start = time.time() + content_type = "application/pdf" if filename.endswith(".pdf") else "image/jpeg" if filename.endswith(".jpg") else "application/octet-stream" + valid, _ = validate_file_upload(filename, content_type, 1000, config) + + if should_block: + status = "passed" if not valid else "failed" + expected = "Blockiert" + actual = "Blockiert" if not valid else "Erlaubt" + else: + status = "passed" if valid else "failed" + expected = "Erlaubt" + actual = "Erlaubt" if valid else "Blockiert" + + tests.append( + TestResult( + name=f"Datei: {filename}", + description=f"Prueft ob {filename} korrekt behandelt wird", + expected=expected, + actual=actual, + status=status, + duration_ms=(time.time() - test_start) * 1000, + ) + ) + + except Exception as e: + tests.append( + TestResult( + name="Input Gate", + description="Input Validierung testen", + expected="Validierung funktioniert", + actual="Fehler", + status="failed", + error_message=str(e), + ) + ) + + passed = sum(1 for t in tests if t.status == "passed") + return TestCategoryResult( + category="input-gate", + display_name=EDUCATION_CONTENT["input-gate"]["display_name"], + description=EDUCATION_CONTENT["input-gate"]["description"], + why_important=EDUCATION_CONTENT["input-gate"]["why_important"], + tests=tests, + passed=passed, + failed=len(tests) - passed, + total=len(tests), + duration_ms=(time.time() - start_time) * 1000, + ) + + async def test_rate_limiter(self) -> TestCategoryResult: + """Test Rate Limiter middleware.""" + tests: List[TestResult] = [] + start_time = time.time() + + async with httpx.AsyncClient() as client: + # Test 1: Rate limit headers present + test_start = time.time() + try: + response = await client.get(f"{self.base_url}/api/health") + has_headers = ( + "X-RateLimit-Limit" in response.headers + or "X-RateLimit-Remaining" in response.headers + ) + + tests.append( + TestResult( + name="Rate Limit Headers", + description="Prueft ob Rate Limit Headers vorhanden sind", + expected="X-RateLimit-* Headers", + actual="Vorhanden" if has_headers else "Fehlen", + status="passed" if has_headers else "skipped", + duration_ms=(time.time() - test_start) * 1000, + ) + ) + except Exception as e: + tests.append( + TestResult( + name="Rate Limit Headers", + description="Prueft ob Rate Limit Headers vorhanden sind", + expected="X-RateLimit-* Headers", + actual="Fehler", + status="failed", + duration_ms=(time.time() - test_start) * 1000, + error_message=str(e), + ) + ) + + # Test 2: Normal requests pass + test_start = time.time() + try: + response = await client.get(f"{self.base_url}/api/health") + tests.append( + TestResult( + name="Normale Anfragen erlaubt", + description="Einzelne Anfragen werden durchgelassen", + expected="Status 200", + actual=f"Status {response.status_code}", + status="passed" if response.status_code == 200 else "failed", + duration_ms=(time.time() - test_start) * 1000, + ) + ) + except Exception as e: + tests.append( + TestResult( + name="Normale Anfragen erlaubt", + description="Einzelne Anfragen werden durchgelassen", + expected="Status 200", + actual="Fehler", + status="failed", + duration_ms=(time.time() - test_start) * 1000, + error_message=str(e), + ) + ) + + passed = sum(1 for t in tests if t.status == "passed") + return TestCategoryResult( + category="rate-limiter", + display_name=EDUCATION_CONTENT["rate-limiter"]["display_name"], + description=EDUCATION_CONTENT["rate-limiter"]["description"], + why_important=EDUCATION_CONTENT["rate-limiter"]["why_important"], + tests=tests, + passed=passed, + failed=len(tests) - passed, + total=len(tests), + duration_ms=(time.time() - start_time) * 1000, + ) + + async def test_cors(self) -> TestCategoryResult: + """Test CORS middleware.""" + tests: List[TestResult] = [] + start_time = time.time() + + async with httpx.AsyncClient() as client: + # Test preflight request + test_start = time.time() + try: + response = await client.options( + f"{self.base_url}/api/health", + headers={ + "Origin": "http://localhost:3000", + "Access-Control-Request-Method": "GET", + }, + ) + cors_header = response.headers.get("Access-Control-Allow-Origin", "") + + tests.append( + TestResult( + name="CORS Preflight", + description="OPTIONS Anfrage fuer CORS", + expected="Access-Control-Allow-Origin Header", + actual=cors_header or "(nicht gesetzt)", + status="passed" if cors_header else "skipped", + duration_ms=(time.time() - test_start) * 1000, + ) + ) + except Exception as e: + tests.append( + TestResult( + name="CORS Preflight", + description="OPTIONS Anfrage fuer CORS", + expected="Access-Control-Allow-Origin Header", + actual="Fehler", + status="failed", + duration_ms=(time.time() - test_start) * 1000, + error_message=str(e), + ) + ) + + passed = sum(1 for t in tests if t.status == "passed") + return TestCategoryResult( + category="cors", + display_name=EDUCATION_CONTENT["cors"]["display_name"], + description=EDUCATION_CONTENT["cors"]["description"], + why_important=EDUCATION_CONTENT["cors"]["why_important"], + tests=tests, + passed=passed, + failed=len(tests) - passed, + total=len(tests), + duration_ms=(time.time() - start_time) * 1000, + ) + + async def run_all(self) -> FullTestResults: + """Run all middleware tests.""" + start_time = time.time() + + # Run all test categories + categories = await asyncio.gather( + self.test_request_id(), + self.test_security_headers(), + self.test_rate_limiter(), + self.test_pii_redactor(), + self.test_input_gate(), + self.test_cors(), + ) + + total_passed = sum(c.passed for c in categories) + total_failed = sum(c.failed for c in categories) + total_tests = sum(c.total for c in categories) + + return FullTestResults( + timestamp=datetime.now().isoformat(), + categories=list(categories), + total_passed=total_passed, + total_failed=total_failed, + total_tests=total_tests, + duration_ms=(time.time() - start_time) * 1000, + ) + + +# ============================================== +# API Router +# ============================================== + +router = APIRouter(prefix="/api/admin/ui-tests", tags=["ui-tests"]) + +# Global test runner instance +test_runner = MiddlewareTestRunner() + + +@router.post("/request-id", response_model=TestCategoryResult) +async def test_request_id(): + """Run Request-ID middleware tests.""" + return await test_runner.test_request_id() + + +@router.post("/security-headers", response_model=TestCategoryResult) +async def test_security_headers(): + """Run Security Headers middleware tests.""" + return await test_runner.test_security_headers() + + +@router.post("/rate-limiter", response_model=TestCategoryResult) +async def test_rate_limiter(): + """Run Rate Limiter middleware tests.""" + return await test_runner.test_rate_limiter() + + +@router.post("/pii-redactor", response_model=TestCategoryResult) +async def test_pii_redactor(): + """Run PII Redactor tests.""" + return await test_runner.test_pii_redactor() + + +@router.post("/input-gate", response_model=TestCategoryResult) +async def test_input_gate(): + """Run Input Gate middleware tests.""" + return await test_runner.test_input_gate() + + +@router.post("/cors", response_model=TestCategoryResult) +async def test_cors(): + """Run CORS middleware tests.""" + return await test_runner.test_cors() + + +@router.post("/run-all", response_model=FullTestResults) +async def run_all_tests(): + """Run all middleware tests.""" + return await test_runner.run_all() + + +@router.get("/education/{category}") +async def get_education_content(category: str): + """Get educational content for a test category.""" + if category not in EDUCATION_CONTENT: + raise HTTPException(status_code=404, detail=f"Category '{category}' not found") + + return EDUCATION_CONTENT[category] + + +@router.get("/categories") +async def list_categories(): + """List all available test categories.""" + return [ + { + "id": key, + "display_name": value["display_name"], + "description": value["description"], + } + for key, value in EDUCATION_CONTENT.items() + ] diff --git a/backend/unit_analytics_api.py b/backend/unit_analytics_api.py new file mode 100644 index 0000000..d919910 --- /dev/null +++ b/backend/unit_analytics_api.py @@ -0,0 +1,751 @@ +# ============================================== +# Breakpilot Drive - Unit Analytics API +# ============================================== +# Erweiterte Analytics fuer Lernfortschritt: +# - Pre/Post Gain Visualisierung +# - Misconception-Tracking +# - Stop-Level Analytics +# - Aggregierte Klassen-Statistiken +# - Export-Funktionen + +from fastapi import APIRouter, HTTPException, Query, Depends, Request +from pydantic import BaseModel, Field +from typing import List, Optional, Dict, Any +from datetime import datetime, timedelta +from enum import Enum +import os +import logging +import statistics + +logger = logging.getLogger(__name__) + +# Feature flags +USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true" + +router = APIRouter(prefix="/api/analytics", tags=["Unit Analytics"]) + + +# ============================================== +# Pydantic Models +# ============================================== + +class TimeRange(str, Enum): + """Time range for analytics queries""" + WEEK = "week" + MONTH = "month" + QUARTER = "quarter" + ALL = "all" + + +class LearningGainData(BaseModel): + """Pre/Post learning gain data point""" + student_id: str + student_name: str + unit_id: str + precheck_score: float + postcheck_score: float + learning_gain: float + percentile: Optional[float] = None + + +class LearningGainSummary(BaseModel): + """Aggregated learning gain statistics""" + unit_id: str + unit_title: str + total_students: int + avg_precheck: float + avg_postcheck: float + avg_gain: float + median_gain: float + std_deviation: float + positive_gain_count: int + negative_gain_count: int + no_change_count: int + gain_distribution: Dict[str, int] # "-20+", "-10-0", "0-10", "10-20", "20+" + individual_gains: List[LearningGainData] + + +class StopPerformance(BaseModel): + """Performance data for a single stop""" + stop_id: str + stop_label: str + attempts_total: int + success_rate: float + avg_time_seconds: float + avg_attempts_before_success: float + common_errors: List[str] + difficulty_rating: float # 1-5 based on performance + + +class UnitPerformanceDetail(BaseModel): + """Detailed unit performance breakdown""" + unit_id: str + unit_title: str + template: str + total_sessions: int + completed_sessions: int + completion_rate: float + avg_duration_minutes: float + stops: List[StopPerformance] + bottleneck_stops: List[str] # Stops where students struggle most + + +class MisconceptionEntry(BaseModel): + """Individual misconception tracking""" + concept_id: str + concept_label: str + misconception_text: str + frequency: int + affected_student_ids: List[str] + unit_id: str + stop_id: str + detected_via: str # "precheck", "postcheck", "interaction" + first_detected: datetime + last_detected: datetime + + +class MisconceptionReport(BaseModel): + """Comprehensive misconception report""" + class_id: Optional[str] + time_range: str + total_misconceptions: int + unique_concepts: int + most_common: List[MisconceptionEntry] + by_unit: Dict[str, List[MisconceptionEntry]] + trending_up: List[MisconceptionEntry] # Getting more frequent + resolved: List[MisconceptionEntry] # No longer appearing + + +class StudentProgressTimeline(BaseModel): + """Timeline of student progress""" + student_id: str + student_name: str + units_completed: int + total_time_minutes: int + avg_score: float + trend: str # "improving", "stable", "declining" + timeline: List[Dict[str, Any]] # List of session events + + +class ClassComparisonData(BaseModel): + """Data for comparing class performance""" + class_id: str + class_name: str + student_count: int + units_assigned: int + avg_completion_rate: float + avg_learning_gain: float + avg_time_per_unit: float + + +class ExportFormat(str, Enum): + """Export format options""" + JSON = "json" + CSV = "csv" + + +# ============================================== +# Database Integration +# ============================================== + +_analytics_db = None + +async def get_analytics_database(): + """Get analytics database instance.""" + global _analytics_db + if not USE_DATABASE: + return None + if _analytics_db is None: + try: + from unit.database import get_analytics_db + _analytics_db = await get_analytics_db() + logger.info("Analytics database initialized") + except ImportError: + logger.warning("Analytics database module not available") + except Exception as e: + logger.warning(f"Analytics database not available: {e}") + return _analytics_db + + +# ============================================== +# Helper Functions +# ============================================== + +def calculate_gain_distribution(gains: List[float]) -> Dict[str, int]: + """Calculate distribution of learning gains into buckets.""" + distribution = { + "< -20%": 0, + "-20% to -10%": 0, + "-10% to 0%": 0, + "0% to 10%": 0, + "10% to 20%": 0, + "> 20%": 0, + } + + for gain in gains: + gain_percent = gain * 100 + if gain_percent < -20: + distribution["< -20%"] += 1 + elif gain_percent < -10: + distribution["-20% to -10%"] += 1 + elif gain_percent < 0: + distribution["-10% to 0%"] += 1 + elif gain_percent < 10: + distribution["0% to 10%"] += 1 + elif gain_percent < 20: + distribution["10% to 20%"] += 1 + else: + distribution["> 20%"] += 1 + + return distribution + + +def calculate_trend(scores: List[float]) -> str: + """Calculate trend from a series of scores.""" + if len(scores) < 3: + return "insufficient_data" + + # Simple linear regression + n = len(scores) + x_mean = (n - 1) / 2 + y_mean = sum(scores) / n + + numerator = sum((i - x_mean) * (scores[i] - y_mean) for i in range(n)) + denominator = sum((i - x_mean) ** 2 for i in range(n)) + + if denominator == 0: + return "stable" + + slope = numerator / denominator + + if slope > 0.05: + return "improving" + elif slope < -0.05: + return "declining" + else: + return "stable" + + +def calculate_difficulty_rating(success_rate: float, avg_attempts: float) -> float: + """Calculate difficulty rating 1-5 based on success metrics.""" + # Lower success rate and higher attempts = higher difficulty + base_difficulty = (1 - success_rate) * 3 + 1 # 1-4 range + attempt_modifier = min(avg_attempts - 1, 1) # 0-1 range + return min(5.0, base_difficulty + attempt_modifier) + + +# ============================================== +# API Endpoints - Learning Gain +# ============================================== + +# NOTE: Static routes must come BEFORE dynamic routes like /{unit_id} +@router.get("/learning-gain/compare") +async def compare_learning_gains( + unit_ids: str = Query(..., description="Comma-separated unit IDs"), + class_id: Optional[str] = Query(None), + time_range: TimeRange = Query(TimeRange.MONTH), +) -> Dict[str, Any]: + """ + Compare learning gains across multiple units. + """ + unit_list = [u.strip() for u in unit_ids.split(",")] + comparisons = [] + + for unit_id in unit_list: + try: + summary = await get_learning_gain_analysis(unit_id, class_id, time_range) + comparisons.append({ + "unit_id": unit_id, + "avg_gain": summary.avg_gain, + "median_gain": summary.median_gain, + "total_students": summary.total_students, + "positive_rate": summary.positive_gain_count / max(summary.total_students, 1), + }) + except Exception as e: + logger.error(f"Failed to get comparison for {unit_id}: {e}") + + return { + "time_range": time_range.value, + "class_id": class_id, + "comparisons": sorted(comparisons, key=lambda x: x["avg_gain"], reverse=True), + } + + +@router.get("/learning-gain/{unit_id}", response_model=LearningGainSummary) +async def get_learning_gain_analysis( + unit_id: str, + class_id: Optional[str] = Query(None, description="Filter by class"), + time_range: TimeRange = Query(TimeRange.MONTH, description="Time range for analysis"), +) -> LearningGainSummary: + """ + Get detailed pre/post learning gain analysis for a unit. + + Shows individual gains, aggregated statistics, and distribution. + """ + db = await get_analytics_database() + individual_gains = [] + + if db: + try: + # Get all sessions with pre/post scores for this unit + sessions = await db.get_unit_sessions_with_scores( + unit_id=unit_id, + class_id=class_id, + time_range=time_range.value + ) + + for session in sessions: + if session.get("precheck_score") is not None and session.get("postcheck_score") is not None: + gain = session["postcheck_score"] - session["precheck_score"] + individual_gains.append(LearningGainData( + student_id=session["student_id"], + student_name=session.get("student_name", session["student_id"][:8]), + unit_id=unit_id, + precheck_score=session["precheck_score"], + postcheck_score=session["postcheck_score"], + learning_gain=gain, + )) + except Exception as e: + logger.error(f"Failed to get learning gain data: {e}") + + # Calculate statistics + if not individual_gains: + # Return empty summary + return LearningGainSummary( + unit_id=unit_id, + unit_title=f"Unit {unit_id}", + total_students=0, + avg_precheck=0.0, + avg_postcheck=0.0, + avg_gain=0.0, + median_gain=0.0, + std_deviation=0.0, + positive_gain_count=0, + negative_gain_count=0, + no_change_count=0, + gain_distribution={}, + individual_gains=[], + ) + + gains = [g.learning_gain for g in individual_gains] + prechecks = [g.precheck_score for g in individual_gains] + postchecks = [g.postcheck_score for g in individual_gains] + + avg_gain = statistics.mean(gains) + median_gain = statistics.median(gains) + std_dev = statistics.stdev(gains) if len(gains) > 1 else 0.0 + + # Calculate percentiles + sorted_gains = sorted(gains) + for data in individual_gains: + rank = sorted_gains.index(data.learning_gain) + 1 + data.percentile = rank / len(sorted_gains) * 100 + + return LearningGainSummary( + unit_id=unit_id, + unit_title=f"Unit {unit_id}", + total_students=len(individual_gains), + avg_precheck=statistics.mean(prechecks), + avg_postcheck=statistics.mean(postchecks), + avg_gain=avg_gain, + median_gain=median_gain, + std_deviation=std_dev, + positive_gain_count=sum(1 for g in gains if g > 0.01), + negative_gain_count=sum(1 for g in gains if g < -0.01), + no_change_count=sum(1 for g in gains if -0.01 <= g <= 0.01), + gain_distribution=calculate_gain_distribution(gains), + individual_gains=sorted(individual_gains, key=lambda x: x.learning_gain, reverse=True), + ) + + +# ============================================== +# API Endpoints - Stop-Level Analytics +# ============================================== + +@router.get("/unit/{unit_id}/stops", response_model=UnitPerformanceDetail) +async def get_unit_stop_analytics( + unit_id: str, + class_id: Optional[str] = Query(None), + time_range: TimeRange = Query(TimeRange.MONTH), +) -> UnitPerformanceDetail: + """ + Get detailed stop-level performance analytics. + + Identifies bottleneck stops where students struggle most. + """ + db = await get_analytics_database() + stops_data = [] + + if db: + try: + # Get stop-level telemetry + stop_stats = await db.get_stop_performance( + unit_id=unit_id, + class_id=class_id, + time_range=time_range.value + ) + + for stop in stop_stats: + difficulty = calculate_difficulty_rating( + stop.get("success_rate", 0.5), + stop.get("avg_attempts", 1.0) + ) + stops_data.append(StopPerformance( + stop_id=stop["stop_id"], + stop_label=stop.get("stop_label", stop["stop_id"]), + attempts_total=stop.get("total_attempts", 0), + success_rate=stop.get("success_rate", 0.0), + avg_time_seconds=stop.get("avg_time_seconds", 0.0), + avg_attempts_before_success=stop.get("avg_attempts", 1.0), + common_errors=stop.get("common_errors", []), + difficulty_rating=difficulty, + )) + + # Get overall unit stats + unit_stats = await db.get_unit_overall_stats(unit_id, class_id, time_range.value) + except Exception as e: + logger.error(f"Failed to get stop analytics: {e}") + unit_stats = {} + else: + unit_stats = {} + + # Identify bottleneck stops (difficulty > 3.5 or success rate < 0.6) + bottlenecks = [ + s.stop_id for s in stops_data + if s.difficulty_rating > 3.5 or s.success_rate < 0.6 + ] + + return UnitPerformanceDetail( + unit_id=unit_id, + unit_title=f"Unit {unit_id}", + template=unit_stats.get("template", "unknown"), + total_sessions=unit_stats.get("total_sessions", 0), + completed_sessions=unit_stats.get("completed_sessions", 0), + completion_rate=unit_stats.get("completion_rate", 0.0), + avg_duration_minutes=unit_stats.get("avg_duration_minutes", 0.0), + stops=stops_data, + bottleneck_stops=bottlenecks, + ) + + +# ============================================== +# API Endpoints - Misconception Tracking +# ============================================== + +@router.get("/misconceptions", response_model=MisconceptionReport) +async def get_misconception_report( + class_id: Optional[str] = Query(None), + unit_id: Optional[str] = Query(None), + time_range: TimeRange = Query(TimeRange.MONTH), + limit: int = Query(20, ge=1, le=100), +) -> MisconceptionReport: + """ + Get comprehensive misconception report. + + Shows most common misconceptions and their frequency. + """ + db = await get_analytics_database() + misconceptions = [] + + if db: + try: + raw_misconceptions = await db.get_misconceptions( + class_id=class_id, + unit_id=unit_id, + time_range=time_range.value, + limit=limit + ) + + for m in raw_misconceptions: + misconceptions.append(MisconceptionEntry( + concept_id=m["concept_id"], + concept_label=m["concept_label"], + misconception_text=m["misconception_text"], + frequency=m["frequency"], + affected_student_ids=m.get("student_ids", []), + unit_id=m["unit_id"], + stop_id=m["stop_id"], + detected_via=m.get("detected_via", "unknown"), + first_detected=m.get("first_detected", datetime.utcnow()), + last_detected=m.get("last_detected", datetime.utcnow()), + )) + except Exception as e: + logger.error(f"Failed to get misconceptions: {e}") + + # Group by unit + by_unit = {} + for m in misconceptions: + if m.unit_id not in by_unit: + by_unit[m.unit_id] = [] + by_unit[m.unit_id].append(m) + + # Identify trending misconceptions (would need historical comparison in production) + trending_up = misconceptions[:3] if misconceptions else [] + resolved = [] # Would identify from historical data + + return MisconceptionReport( + class_id=class_id, + time_range=time_range.value, + total_misconceptions=sum(m.frequency for m in misconceptions), + unique_concepts=len(set(m.concept_id for m in misconceptions)), + most_common=sorted(misconceptions, key=lambda x: x.frequency, reverse=True)[:10], + by_unit=by_unit, + trending_up=trending_up, + resolved=resolved, + ) + + +@router.get("/misconceptions/student/{student_id}") +async def get_student_misconceptions( + student_id: str, + time_range: TimeRange = Query(TimeRange.ALL), +) -> Dict[str, Any]: + """ + Get misconceptions for a specific student. + + Useful for personalized remediation. + """ + db = await get_analytics_database() + + if db: + try: + misconceptions = await db.get_student_misconceptions( + student_id=student_id, + time_range=time_range.value + ) + return { + "student_id": student_id, + "misconceptions": misconceptions, + "recommended_remediation": [ + {"concept": m["concept_label"], "activity": f"Review {m['unit_id']}/{m['stop_id']}"} + for m in misconceptions[:5] + ] + } + except Exception as e: + logger.error(f"Failed to get student misconceptions: {e}") + + return { + "student_id": student_id, + "misconceptions": [], + "recommended_remediation": [], + } + + +# ============================================== +# API Endpoints - Student Progress Timeline +# ============================================== + +@router.get("/student/{student_id}/timeline", response_model=StudentProgressTimeline) +async def get_student_timeline( + student_id: str, + time_range: TimeRange = Query(TimeRange.ALL), +) -> StudentProgressTimeline: + """ + Get detailed progress timeline for a student. + + Shows all unit sessions and performance trend. + """ + db = await get_analytics_database() + timeline = [] + scores = [] + + if db: + try: + sessions = await db.get_student_sessions( + student_id=student_id, + time_range=time_range.value + ) + + for session in sessions: + timeline.append({ + "date": session.get("started_at"), + "unit_id": session.get("unit_id"), + "completed": session.get("completed_at") is not None, + "precheck": session.get("precheck_score"), + "postcheck": session.get("postcheck_score"), + "duration_minutes": session.get("duration_seconds", 0) // 60, + }) + if session.get("postcheck_score") is not None: + scores.append(session["postcheck_score"]) + except Exception as e: + logger.error(f"Failed to get student timeline: {e}") + + trend = calculate_trend(scores) if scores else "insufficient_data" + + return StudentProgressTimeline( + student_id=student_id, + student_name=f"Student {student_id[:8]}", # Would load actual name + units_completed=sum(1 for t in timeline if t["completed"]), + total_time_minutes=sum(t["duration_minutes"] for t in timeline), + avg_score=statistics.mean(scores) if scores else 0.0, + trend=trend, + timeline=timeline, + ) + + +# ============================================== +# API Endpoints - Class Comparison +# ============================================== + +@router.get("/compare/classes", response_model=List[ClassComparisonData]) +async def compare_classes( + class_ids: str = Query(..., description="Comma-separated class IDs"), + time_range: TimeRange = Query(TimeRange.MONTH), +) -> List[ClassComparisonData]: + """ + Compare performance across multiple classes. + """ + class_list = [c.strip() for c in class_ids.split(",")] + comparisons = [] + + db = await get_analytics_database() + if db: + for class_id in class_list: + try: + stats = await db.get_class_aggregate_stats(class_id, time_range.value) + comparisons.append(ClassComparisonData( + class_id=class_id, + class_name=stats.get("class_name", f"Klasse {class_id[:8]}"), + student_count=stats.get("student_count", 0), + units_assigned=stats.get("units_assigned", 0), + avg_completion_rate=stats.get("avg_completion_rate", 0.0), + avg_learning_gain=stats.get("avg_learning_gain", 0.0), + avg_time_per_unit=stats.get("avg_time_per_unit", 0.0), + )) + except Exception as e: + logger.error(f"Failed to get stats for class {class_id}: {e}") + + return sorted(comparisons, key=lambda x: x.avg_learning_gain, reverse=True) + + +# ============================================== +# API Endpoints - Export +# ============================================== + +@router.get("/export/learning-gains") +async def export_learning_gains( + unit_id: Optional[str] = Query(None), + class_id: Optional[str] = Query(None), + time_range: TimeRange = Query(TimeRange.ALL), + format: ExportFormat = Query(ExportFormat.JSON), +) -> Any: + """ + Export learning gain data. + """ + from fastapi.responses import Response + + db = await get_analytics_database() + data = [] + + if db: + try: + data = await db.export_learning_gains( + unit_id=unit_id, + class_id=class_id, + time_range=time_range.value + ) + except Exception as e: + logger.error(f"Failed to export data: {e}") + + if format == ExportFormat.CSV: + # Convert to CSV + if not data: + csv_content = "student_id,unit_id,precheck,postcheck,gain\n" + else: + csv_content = "student_id,unit_id,precheck,postcheck,gain\n" + for row in data: + csv_content += f"{row['student_id']},{row['unit_id']},{row.get('precheck', '')},{row.get('postcheck', '')},{row.get('gain', '')}\n" + + return Response( + content=csv_content, + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=learning_gains.csv"} + ) + + return { + "export_date": datetime.utcnow().isoformat(), + "filters": { + "unit_id": unit_id, + "class_id": class_id, + "time_range": time_range.value, + }, + "data": data, + } + + +@router.get("/export/misconceptions") +async def export_misconceptions( + class_id: Optional[str] = Query(None), + format: ExportFormat = Query(ExportFormat.JSON), +) -> Any: + """ + Export misconception data for further analysis. + """ + report = await get_misconception_report( + class_id=class_id, + unit_id=None, + time_range=TimeRange.MONTH, + limit=100 + ) + + if format == ExportFormat.CSV: + from fastapi.responses import Response + csv_content = "concept_id,concept_label,misconception,frequency,unit_id,stop_id\n" + for m in report.most_common: + csv_content += f'"{m.concept_id}","{m.concept_label}","{m.misconception_text}",{m.frequency},"{m.unit_id}","{m.stop_id}"\n' + + return Response( + content=csv_content, + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=misconceptions.csv"} + ) + + return { + "export_date": datetime.utcnow().isoformat(), + "class_id": class_id, + "total_entries": len(report.most_common), + "data": [m.model_dump() for m in report.most_common], + } + + +# ============================================== +# API Endpoints - Dashboard Aggregates +# ============================================== + +@router.get("/dashboard/overview") +async def get_analytics_overview( + time_range: TimeRange = Query(TimeRange.MONTH), +) -> Dict[str, Any]: + """ + Get high-level analytics overview for dashboard. + """ + db = await get_analytics_database() + + if db: + try: + overview = await db.get_analytics_overview(time_range.value) + return overview + except Exception as e: + logger.error(f"Failed to get analytics overview: {e}") + + return { + "time_range": time_range.value, + "total_sessions": 0, + "unique_students": 0, + "avg_completion_rate": 0.0, + "avg_learning_gain": 0.0, + "most_played_units": [], + "struggling_concepts": [], + "active_classes": 0, + } + + +@router.get("/health") +async def health_check() -> Dict[str, Any]: + """Health check for analytics API.""" + db = await get_analytics_database() + return { + "status": "healthy", + "service": "unit-analytics", + "database": "connected" if db else "disconnected", + } diff --git a/backend/unit_api.py b/backend/unit_api.py new file mode 100644 index 0000000..cab4c66 --- /dev/null +++ b/backend/unit_api.py @@ -0,0 +1,1226 @@ +# ============================================== +# Breakpilot Drive - Unit API +# ============================================== +# API-Endpunkte fuer kontextuelle Lerneinheiten: +# - Unit-Sessions erstellen und verwalten +# - Telemetrie-Events empfangen +# - Unit-Definitionen abrufen +# - Pre/Post-Check verarbeiten +# +# Mit PostgreSQL-Integration fuer persistente Speicherung. +# Auth: Optional via GAME_REQUIRE_AUTH=true + +from fastapi import APIRouter, HTTPException, Query, Depends, Request +from pydantic import BaseModel, Field +from typing import List, Optional, Dict, Any +from datetime import datetime, timedelta +import uuid +import os +import logging +import jwt + +logger = logging.getLogger(__name__) + +# Feature flags +USE_DATABASE = os.getenv("GAME_USE_DATABASE", "true").lower() == "true" +REQUIRE_AUTH = os.getenv("GAME_REQUIRE_AUTH", "false").lower() == "true" +SECRET_KEY = os.getenv("JWT_SECRET_KEY", "dev-secret-key-change-in-production") + +router = APIRouter(prefix="/api/units", tags=["Breakpilot Units"]) + + +# ============================================== +# Auth Dependency (reuse from game_api) +# ============================================== + +async def get_optional_current_user(request: Request) -> Optional[Dict[str, Any]]: + """Optional auth dependency for Unit API.""" + if not REQUIRE_AUTH: + return None + + try: + from auth import get_current_user + return await get_current_user(request) + except ImportError: + logger.warning("Auth module not available") + return None + except HTTPException: + raise + except Exception as e: + logger.error(f"Auth error: {e}") + raise HTTPException(status_code=401, detail="Authentication failed") + + +# ============================================== +# Pydantic Models +# ============================================== + +class UnitDefinitionResponse(BaseModel): + """Unit definition response""" + unit_id: str + template: str + version: str + locale: List[str] + grade_band: List[str] + duration_minutes: int + difficulty: str + definition: Dict[str, Any] + + +class CreateSessionRequest(BaseModel): + """Request to create a unit session""" + unit_id: str + student_id: str + locale: str = "de-DE" + difficulty: str = "base" + + +class SessionResponse(BaseModel): + """Response after creating a session""" + session_id: str + unit_definition_url: str + session_token: str + telemetry_endpoint: str + expires_at: datetime + + +class TelemetryEvent(BaseModel): + """Single telemetry event""" + ts: Optional[str] = None + type: str = Field(..., alias="type") + stop_id: Optional[str] = None + metrics: Optional[Dict[str, Any]] = None + + class Config: + populate_by_name = True + + +class TelemetryPayload(BaseModel): + """Batch telemetry payload""" + session_id: str + events: List[TelemetryEvent] + + +class TelemetryResponse(BaseModel): + """Response after receiving telemetry""" + accepted: int + + +class PostcheckAnswer(BaseModel): + """Single postcheck answer""" + question_id: str + answer: str + + +class CompleteSessionRequest(BaseModel): + """Request to complete a session""" + postcheck_answers: Optional[List[PostcheckAnswer]] = None + + +class SessionSummaryResponse(BaseModel): + """Response with session summary""" + summary: Dict[str, Any] + next_recommendations: Dict[str, Any] + + +class UnitListItem(BaseModel): + """Unit list item""" + unit_id: str + template: str + difficulty: str + duration_minutes: int + locale: List[str] + grade_band: List[str] + + +class RecommendedUnit(BaseModel): + """Recommended unit with reason""" + unit_id: str + template: str + difficulty: str + reason: str + + +class CreateUnitRequest(BaseModel): + """Request to create a new unit definition""" + unit_id: str = Field(..., description="Unique unit identifier") + template: str = Field(..., description="Template type: flight_path or station_loop") + version: str = Field(default="1.0.0", description="Version string") + locale: List[str] = Field(default=["de-DE"], description="Supported locales") + grade_band: List[str] = Field(default=["5", "6", "7"], description="Target grade levels") + duration_minutes: int = Field(default=8, ge=3, le=20, description="Expected duration") + difficulty: str = Field(default="base", description="Difficulty level: base or advanced") + subject: Optional[str] = Field(default=None, description="Subject area") + topic: Optional[str] = Field(default=None, description="Topic within subject") + learning_objectives: List[str] = Field(default=[], description="Learning objectives") + stops: List[Dict[str, Any]] = Field(default=[], description="Unit stops/stations") + precheck: Optional[Dict[str, Any]] = Field(default=None, description="Pre-check configuration") + postcheck: Optional[Dict[str, Any]] = Field(default=None, description="Post-check configuration") + teacher_controls: Optional[Dict[str, Any]] = Field(default=None, description="Teacher control settings") + assets: Optional[Dict[str, Any]] = Field(default=None, description="Asset configuration") + metadata: Optional[Dict[str, Any]] = Field(default=None, description="Additional metadata") + status: str = Field(default="draft", description="Publication status: draft or published") + + +class UpdateUnitRequest(BaseModel): + """Request to update an existing unit definition""" + version: Optional[str] = None + locale: Optional[List[str]] = None + grade_band: Optional[List[str]] = None + duration_minutes: Optional[int] = Field(default=None, ge=3, le=20) + difficulty: Optional[str] = None + subject: Optional[str] = None + topic: Optional[str] = None + learning_objectives: Optional[List[str]] = None + stops: Optional[List[Dict[str, Any]]] = None + precheck: Optional[Dict[str, Any]] = None + postcheck: Optional[Dict[str, Any]] = None + teacher_controls: Optional[Dict[str, Any]] = None + assets: Optional[Dict[str, Any]] = None + metadata: Optional[Dict[str, Any]] = None + status: Optional[str] = None + + +class ValidationError(BaseModel): + """Single validation error""" + field: str + message: str + severity: str = "error" # error or warning + + +class ValidationResult(BaseModel): + """Result of unit validation""" + valid: bool + errors: List[ValidationError] = [] + warnings: List[ValidationError] = [] + + +# ============================================== +# Database Integration +# ============================================== + +_unit_db = None + +async def get_unit_database(): + """Get unit database instance with lazy initialization.""" + global _unit_db + if not USE_DATABASE: + return None + if _unit_db is None: + try: + from unit.database import get_unit_db + _unit_db = await get_unit_db() + logger.info("Unit database initialized") + except ImportError: + logger.warning("Unit database module not available") + except Exception as e: + logger.warning(f"Unit database not available: {e}") + return _unit_db + + +# ============================================== +# Helper Functions +# ============================================== + +def create_session_token(session_id: str, student_id: str, expires_hours: int = 4) -> str: + """Create a JWT session token for telemetry authentication.""" + payload = { + "session_id": session_id, + "student_id": student_id, + "exp": datetime.utcnow() + timedelta(hours=expires_hours), + "iat": datetime.utcnow(), + } + return jwt.encode(payload, SECRET_KEY, algorithm="HS256") + + +def verify_session_token(token: str) -> Optional[Dict[str, Any]]: + """Verify a session token and return payload.""" + try: + return jwt.decode(token, SECRET_KEY, algorithms=["HS256"]) + except jwt.ExpiredSignatureError: + return None + except jwt.InvalidTokenError: + return None + + +async def get_session_from_token(request: Request) -> Optional[Dict[str, Any]]: + """Extract and verify session from Authorization header.""" + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + return None + token = auth_header[7:] + return verify_session_token(token) + + +def validate_unit_definition(unit_data: Dict[str, Any]) -> ValidationResult: + """ + Validate a unit definition structure. + + Returns validation result with errors and warnings. + """ + errors = [] + warnings = [] + + # Required fields + if not unit_data.get("unit_id"): + errors.append(ValidationError(field="unit_id", message="unit_id ist erforderlich")) + + if not unit_data.get("template"): + errors.append(ValidationError(field="template", message="template ist erforderlich")) + elif unit_data["template"] not in ["flight_path", "station_loop"]: + errors.append(ValidationError( + field="template", + message="template muss 'flight_path' oder 'station_loop' sein" + )) + + # Validate stops + stops = unit_data.get("stops", []) + if not stops: + errors.append(ValidationError(field="stops", message="Mindestens 1 Stop erforderlich")) + else: + # Check minimum stops for flight_path + if unit_data.get("template") == "flight_path" and len(stops) < 3: + warnings.append(ValidationError( + field="stops", + message="FlightPath sollte mindestens 3 Stops haben", + severity="warning" + )) + + # Validate each stop + stop_ids = set() + for i, stop in enumerate(stops): + if not stop.get("stop_id"): + errors.append(ValidationError( + field=f"stops[{i}].stop_id", + message=f"Stop {i}: stop_id fehlt" + )) + else: + if stop["stop_id"] in stop_ids: + errors.append(ValidationError( + field=f"stops[{i}].stop_id", + message=f"Stop {i}: Doppelte stop_id '{stop['stop_id']}'" + )) + stop_ids.add(stop["stop_id"]) + + # Check interaction type + interaction = stop.get("interaction", {}) + if not interaction.get("type"): + errors.append(ValidationError( + field=f"stops[{i}].interaction.type", + message=f"Stop {stop.get('stop_id', i)}: Interaktionstyp fehlt" + )) + elif interaction["type"] not in [ + "aim_and_pass", "slider_adjust", "slider_equivalence", + "sequence_arrange", "toggle_switch", "drag_match", + "error_find", "transfer_apply" + ]: + warnings.append(ValidationError( + field=f"stops[{i}].interaction.type", + message=f"Stop {stop.get('stop_id', i)}: Unbekannter Interaktionstyp '{interaction['type']}'", + severity="warning" + )) + + # Check for label + if not stop.get("label"): + warnings.append(ValidationError( + field=f"stops[{i}].label", + message=f"Stop {stop.get('stop_id', i)}: Label fehlt", + severity="warning" + )) + + # Validate duration + duration = unit_data.get("duration_minutes", 0) + if duration < 3 or duration > 20: + warnings.append(ValidationError( + field="duration_minutes", + message="Dauer sollte zwischen 3 und 20 Minuten liegen", + severity="warning" + )) + + # Validate difficulty + if unit_data.get("difficulty") and unit_data["difficulty"] not in ["base", "advanced"]: + warnings.append(ValidationError( + field="difficulty", + message="difficulty sollte 'base' oder 'advanced' sein", + severity="warning" + )) + + return ValidationResult( + valid=len(errors) == 0, + errors=errors, + warnings=warnings + ) + + +# ============================================== +# API Endpoints +# ============================================== + +@router.get("/definitions", response_model=List[UnitListItem]) +async def list_unit_definitions( + template: Optional[str] = Query(None, description="Filter by template: flight_path, station_loop"), + grade: Optional[str] = Query(None, description="Filter by grade level"), + locale: str = Query("de-DE", description="Filter by locale"), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> List[UnitListItem]: + """ + List available unit definitions. + + Returns published units matching the filter criteria. + """ + db = await get_unit_database() + if db: + try: + units = await db.list_units( + template=template, + grade=grade, + locale=locale, + published_only=True + ) + return [ + UnitListItem( + unit_id=u["unit_id"], + template=u["template"], + difficulty=u["difficulty"], + duration_minutes=u["duration_minutes"], + locale=u["locale"], + grade_band=u["grade_band"], + ) + for u in units + ] + except Exception as e: + logger.error(f"Failed to list units: {e}") + + # Fallback: return demo unit + return [ + UnitListItem( + unit_id="demo_unit_v1", + template="flight_path", + difficulty="base", + duration_minutes=5, + locale=["de-DE"], + grade_band=["5", "6", "7"], + ) + ] + + +@router.get("/definitions/{unit_id}", response_model=UnitDefinitionResponse) +async def get_unit_definition( + unit_id: str, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> UnitDefinitionResponse: + """ + Get a specific unit definition. + + Returns the full unit configuration including stops, interactions, etc. + """ + db = await get_unit_database() + if db: + try: + unit = await db.get_unit_definition(unit_id) + if unit: + return UnitDefinitionResponse( + unit_id=unit["unit_id"], + template=unit["template"], + version=unit["version"], + locale=unit["locale"], + grade_band=unit["grade_band"], + duration_minutes=unit["duration_minutes"], + difficulty=unit["difficulty"], + definition=unit["definition"], + ) + except Exception as e: + logger.error(f"Failed to get unit definition: {e}") + + # Demo unit fallback + if unit_id == "demo_unit_v1": + return UnitDefinitionResponse( + unit_id="demo_unit_v1", + template="flight_path", + version="1.0.0", + locale=["de-DE"], + grade_band=["5", "6", "7"], + duration_minutes=5, + difficulty="base", + definition={ + "unit_id": "demo_unit_v1", + "template": "flight_path", + "version": "1.0.0", + "learning_objectives": ["Demo: Grundfunktion testen"], + "stops": [ + {"stop_id": "stop_1", "label": {"de-DE": "Start"}, "interaction": {"type": "aim_and_pass"}}, + {"stop_id": "stop_2", "label": {"de-DE": "Mitte"}, "interaction": {"type": "aim_and_pass"}}, + {"stop_id": "stop_3", "label": {"de-DE": "Ende"}, "interaction": {"type": "aim_and_pass"}}, + ], + "teacher_controls": {"allow_skip": True, "allow_replay": True}, + }, + ) + + raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}") + + +@router.post("/definitions", response_model=UnitDefinitionResponse) +async def create_unit_definition( + request_data: CreateUnitRequest, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> UnitDefinitionResponse: + """ + Create a new unit definition. + + - Validates unit structure + - Saves to database or JSON file + - Returns created unit + """ + import json + from pathlib import Path + + # Build full definition + definition = { + "unit_id": request_data.unit_id, + "template": request_data.template, + "version": request_data.version, + "locale": request_data.locale, + "grade_band": request_data.grade_band, + "duration_minutes": request_data.duration_minutes, + "difficulty": request_data.difficulty, + "subject": request_data.subject, + "topic": request_data.topic, + "learning_objectives": request_data.learning_objectives, + "stops": request_data.stops, + "precheck": request_data.precheck or { + "question_set_id": f"{request_data.unit_id}_precheck", + "required": True, + "time_limit_seconds": 120 + }, + "postcheck": request_data.postcheck or { + "question_set_id": f"{request_data.unit_id}_postcheck", + "required": True, + "time_limit_seconds": 180 + }, + "teacher_controls": request_data.teacher_controls or { + "allow_skip": True, + "allow_replay": True, + "max_time_per_stop_sec": 90, + "show_hints": True, + "require_precheck": True, + "require_postcheck": True + }, + "assets": request_data.assets or {}, + "metadata": request_data.metadata or { + "author": user.get("email", "Unknown") if user else "Unknown", + "created": datetime.utcnow().isoformat(), + "curriculum_reference": "" + } + } + + # Validate + validation = validate_unit_definition(definition) + if not validation.valid: + error_msgs = [f"{e.field}: {e.message}" for e in validation.errors] + raise HTTPException(status_code=400, detail=f"Validierung fehlgeschlagen: {'; '.join(error_msgs)}") + + # Check if unit_id already exists + db = await get_unit_database() + if db: + try: + existing = await db.get_unit_definition(request_data.unit_id) + if existing: + raise HTTPException(status_code=409, detail=f"Unit existiert bereits: {request_data.unit_id}") + + # Save to database + await db.create_unit_definition( + unit_id=request_data.unit_id, + template=request_data.template, + version=request_data.version, + locale=request_data.locale, + grade_band=request_data.grade_band, + duration_minutes=request_data.duration_minutes, + difficulty=request_data.difficulty, + definition=definition, + status=request_data.status + ) + logger.info(f"Unit created in database: {request_data.unit_id}") + except HTTPException: + raise + except Exception as e: + logger.warning(f"Database save failed, using JSON fallback: {e}") + # Fallback to JSON + units_dir = Path(__file__).parent / "data" / "units" + units_dir.mkdir(parents=True, exist_ok=True) + json_path = units_dir / f"{request_data.unit_id}.json" + if json_path.exists(): + raise HTTPException(status_code=409, detail=f"Unit existiert bereits: {request_data.unit_id}") + with open(json_path, "w", encoding="utf-8") as f: + json.dump(definition, f, ensure_ascii=False, indent=2) + logger.info(f"Unit created as JSON: {json_path}") + else: + # JSON only mode + units_dir = Path(__file__).parent / "data" / "units" + units_dir.mkdir(parents=True, exist_ok=True) + json_path = units_dir / f"{request_data.unit_id}.json" + if json_path.exists(): + raise HTTPException(status_code=409, detail=f"Unit existiert bereits: {request_data.unit_id}") + with open(json_path, "w", encoding="utf-8") as f: + json.dump(definition, f, ensure_ascii=False, indent=2) + logger.info(f"Unit created as JSON: {json_path}") + + return UnitDefinitionResponse( + unit_id=request_data.unit_id, + template=request_data.template, + version=request_data.version, + locale=request_data.locale, + grade_band=request_data.grade_band, + duration_minutes=request_data.duration_minutes, + difficulty=request_data.difficulty, + definition=definition + ) + + +@router.put("/definitions/{unit_id}", response_model=UnitDefinitionResponse) +async def update_unit_definition( + unit_id: str, + request_data: UpdateUnitRequest, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> UnitDefinitionResponse: + """ + Update an existing unit definition. + + - Merges updates with existing definition + - Re-validates + - Saves updated version + """ + import json + from pathlib import Path + + # Get existing unit + db = await get_unit_database() + existing = None + + if db: + try: + existing = await db.get_unit_definition(unit_id) + except Exception as e: + logger.warning(f"Database read failed: {e}") + + if not existing: + # Try JSON file + json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json" + if json_path.exists(): + with open(json_path, "r", encoding="utf-8") as f: + file_data = json.load(f) + existing = { + "unit_id": file_data.get("unit_id"), + "template": file_data.get("template"), + "version": file_data.get("version", "1.0.0"), + "locale": file_data.get("locale", ["de-DE"]), + "grade_band": file_data.get("grade_band", []), + "duration_minutes": file_data.get("duration_minutes", 8), + "difficulty": file_data.get("difficulty", "base"), + "definition": file_data + } + + if not existing: + raise HTTPException(status_code=404, detail=f"Unit nicht gefunden: {unit_id}") + + # Merge updates into existing definition + definition = existing.get("definition", {}) + update_dict = request_data.model_dump(exclude_unset=True) + + for key, value in update_dict.items(): + if value is not None: + definition[key] = value + + # Validate updated definition + validation = validate_unit_definition(definition) + if not validation.valid: + error_msgs = [f"{e.field}: {e.message}" for e in validation.errors] + raise HTTPException(status_code=400, detail=f"Validierung fehlgeschlagen: {'; '.join(error_msgs)}") + + # Save + if db: + try: + await db.update_unit_definition( + unit_id=unit_id, + version=definition.get("version"), + locale=definition.get("locale"), + grade_band=definition.get("grade_band"), + duration_minutes=definition.get("duration_minutes"), + difficulty=definition.get("difficulty"), + definition=definition, + status=update_dict.get("status") + ) + logger.info(f"Unit updated in database: {unit_id}") + except Exception as e: + logger.warning(f"Database update failed, using JSON: {e}") + json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json" + with open(json_path, "w", encoding="utf-8") as f: + json.dump(definition, f, ensure_ascii=False, indent=2) + else: + json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json" + with open(json_path, "w", encoding="utf-8") as f: + json.dump(definition, f, ensure_ascii=False, indent=2) + logger.info(f"Unit updated as JSON: {json_path}") + + return UnitDefinitionResponse( + unit_id=unit_id, + template=definition.get("template", existing.get("template")), + version=definition.get("version", existing.get("version", "1.0.0")), + locale=definition.get("locale", existing.get("locale", ["de-DE"])), + grade_band=definition.get("grade_band", existing.get("grade_band", [])), + duration_minutes=definition.get("duration_minutes", existing.get("duration_minutes", 8)), + difficulty=definition.get("difficulty", existing.get("difficulty", "base")), + definition=definition + ) + + +@router.delete("/definitions/{unit_id}") +async def delete_unit_definition( + unit_id: str, + force: bool = Query(False, description="Force delete even if published"), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> Dict[str, Any]: + """ + Delete a unit definition. + + - By default, only drafts can be deleted + - Use force=true to delete published units + """ + import json + from pathlib import Path + + db = await get_unit_database() + deleted = False + + if db: + try: + existing = await db.get_unit_definition(unit_id) + if existing: + status = existing.get("status", "draft") + if status == "published" and not force: + raise HTTPException( + status_code=400, + detail="Veroeffentlichte Units koennen nicht geloescht werden. Verwende force=true." + ) + await db.delete_unit_definition(unit_id) + deleted = True + logger.info(f"Unit deleted from database: {unit_id}") + except HTTPException: + raise + except Exception as e: + logger.warning(f"Database delete failed: {e}") + + # Also check JSON file + json_path = Path(__file__).parent / "data" / "units" / f"{unit_id}.json" + if json_path.exists(): + json_path.unlink() + deleted = True + logger.info(f"Unit JSON deleted: {json_path}") + + if not deleted: + raise HTTPException(status_code=404, detail=f"Unit nicht gefunden: {unit_id}") + + return {"success": True, "unit_id": unit_id, "message": "Unit geloescht"} + + +@router.post("/definitions/validate", response_model=ValidationResult) +async def validate_unit( + unit_data: Dict[str, Any], + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> ValidationResult: + """ + Validate a unit definition without saving. + + Returns validation result with errors and warnings. + """ + return validate_unit_definition(unit_data) + + +@router.post("/sessions", response_model=SessionResponse) +async def create_unit_session( + request_data: CreateSessionRequest, + request: Request, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> SessionResponse: + """ + Create a new unit session. + + - Validates unit exists + - Creates session record + - Returns session token for telemetry + """ + session_id = str(uuid.uuid4()) + expires_at = datetime.utcnow() + timedelta(hours=4) + + # Validate unit exists + db = await get_unit_database() + if db: + try: + unit = await db.get_unit_definition(request_data.unit_id) + if not unit: + raise HTTPException(status_code=404, detail=f"Unit not found: {request_data.unit_id}") + + # Create session in database + total_stops = len(unit.get("definition", {}).get("stops", [])) + await db.create_session( + session_id=session_id, + unit_id=request_data.unit_id, + student_id=request_data.student_id, + locale=request_data.locale, + difficulty=request_data.difficulty, + total_stops=total_stops, + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to create session: {e}") + # Continue with in-memory fallback + + # Create session token + session_token = create_session_token(session_id, request_data.student_id) + + # Build definition URL + base_url = str(request.base_url).rstrip("/") + definition_url = f"{base_url}/api/units/definitions/{request_data.unit_id}" + + return SessionResponse( + session_id=session_id, + unit_definition_url=definition_url, + session_token=session_token, + telemetry_endpoint="/api/units/telemetry", + expires_at=expires_at, + ) + + +@router.post("/telemetry", response_model=TelemetryResponse) +async def receive_telemetry( + payload: TelemetryPayload, + request: Request, +) -> TelemetryResponse: + """ + Receive batched telemetry events from Unity client. + + - Validates session token + - Stores events in database + - Returns count of accepted events + """ + # Verify session token + session_data = await get_session_from_token(request) + if session_data is None: + # Allow without auth in dev mode + if REQUIRE_AUTH: + raise HTTPException(status_code=401, detail="Invalid or expired session token") + logger.warning("Telemetry received without valid token (dev mode)") + + # Verify session_id matches + if session_data and session_data.get("session_id") != payload.session_id: + raise HTTPException(status_code=403, detail="Session ID mismatch") + + accepted = 0 + db = await get_unit_database() + + for event in payload.events: + try: + # Set timestamp if not provided + timestamp = event.ts or datetime.utcnow().isoformat() + + if db: + await db.store_telemetry_event( + session_id=payload.session_id, + event_type=event.type, + stop_id=event.stop_id, + timestamp=timestamp, + metrics=event.metrics, + ) + + accepted += 1 + logger.debug(f"Telemetry: {event.type} for session {payload.session_id}") + + except Exception as e: + logger.error(f"Failed to store telemetry event: {e}") + + return TelemetryResponse(accepted=accepted) + + +@router.post("/sessions/{session_id}/complete", response_model=SessionSummaryResponse) +async def complete_session( + session_id: str, + request_data: CompleteSessionRequest, + request: Request, +) -> SessionSummaryResponse: + """ + Complete a unit session. + + - Processes postcheck answers if provided + - Calculates learning gain + - Returns summary and recommendations + """ + # Verify session token + session_data = await get_session_from_token(request) + if REQUIRE_AUTH and session_data is None: + raise HTTPException(status_code=401, detail="Invalid or expired session token") + + db = await get_unit_database() + summary = {} + recommendations = {} + + if db: + try: + # Get session data + session = await db.get_session(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + # Calculate postcheck score if answers provided + postcheck_score = None + if request_data.postcheck_answers: + # Simple scoring: count correct answers + # In production, would validate against question bank + postcheck_score = len(request_data.postcheck_answers) * 0.2 # Placeholder + postcheck_score = min(postcheck_score, 1.0) + + # Complete session in database + await db.complete_session( + session_id=session_id, + postcheck_score=postcheck_score, + ) + + # Get updated session summary + session = await db.get_session(session_id) + + # Calculate learning gain + pre_score = session.get("precheck_score") + post_score = session.get("postcheck_score") + learning_gain = None + if pre_score is not None and post_score is not None: + learning_gain = post_score - pre_score + + summary = { + "session_id": session_id, + "unit_id": session.get("unit_id"), + "duration_seconds": session.get("duration_seconds"), + "completion_rate": session.get("completion_rate"), + "precheck_score": pre_score, + "postcheck_score": post_score, + "pre_to_post_gain": learning_gain, + "stops_completed": session.get("stops_completed"), + "total_stops": session.get("total_stops"), + } + + # Get recommendations + recommendations = await db.get_recommendations( + student_id=session.get("student_id"), + completed_unit_id=session.get("unit_id"), + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to complete session: {e}") + summary = {"session_id": session_id, "error": str(e)} + + else: + # Fallback summary + summary = { + "session_id": session_id, + "duration_seconds": 0, + "completion_rate": 1.0, + "message": "Database not available", + } + + return SessionSummaryResponse( + summary=summary, + next_recommendations=recommendations or { + "h5p_activity_ids": [], + "worksheet_pdf_url": None, + }, + ) + + +@router.get("/sessions/{session_id}") +async def get_session( + session_id: str, + request: Request, +) -> Dict[str, Any]: + """ + Get session details. + + Returns current state of a session including progress. + """ + # Verify session token + session_data = await get_session_from_token(request) + if REQUIRE_AUTH and session_data is None: + raise HTTPException(status_code=401, detail="Invalid or expired session token") + + db = await get_unit_database() + if db: + try: + session = await db.get_session(session_id) + if session: + return session + except Exception as e: + logger.error(f"Failed to get session: {e}") + + raise HTTPException(status_code=404, detail="Session not found") + + +@router.get("/recommendations/{student_id}", response_model=List[RecommendedUnit]) +async def get_recommendations( + student_id: str, + grade: Optional[str] = Query(None, description="Grade level filter"), + locale: str = Query("de-DE", description="Locale filter"), + limit: int = Query(5, ge=1, le=20), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> List[RecommendedUnit]: + """ + Get recommended units for a student. + + Based on completion status and performance. + """ + db = await get_unit_database() + if db: + try: + recommendations = await db.get_student_recommendations( + student_id=student_id, + grade=grade, + locale=locale, + limit=limit, + ) + return [ + RecommendedUnit( + unit_id=r["unit_id"], + template=r["template"], + difficulty=r["difficulty"], + reason=r["reason"], + ) + for r in recommendations + ] + except Exception as e: + logger.error(f"Failed to get recommendations: {e}") + + # Fallback: recommend demo unit + return [ + RecommendedUnit( + unit_id="demo_unit_v1", + template="flight_path", + difficulty="base", + reason="Neu: Noch nicht gespielt", + ) + ] + + +@router.get("/analytics/student/{student_id}") +async def get_student_analytics( + student_id: str, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> Dict[str, Any]: + """ + Get unit analytics for a student. + + Includes completion rates, learning gains, time spent. + """ + db = await get_unit_database() + if db: + try: + analytics = await db.get_student_unit_analytics(student_id) + return analytics + except Exception as e: + logger.error(f"Failed to get analytics: {e}") + + return { + "student_id": student_id, + "units_attempted": 0, + "units_completed": 0, + "avg_completion_rate": 0.0, + "avg_learning_gain": None, + "total_minutes": 0, + } + + +@router.get("/analytics/unit/{unit_id}") +async def get_unit_analytics( + unit_id: str, + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> Dict[str, Any]: + """ + Get analytics for a specific unit. + + Shows aggregate performance across all students. + """ + db = await get_unit_database() + if db: + try: + analytics = await db.get_unit_performance(unit_id) + return analytics + except Exception as e: + logger.error(f"Failed to get unit analytics: {e}") + + return { + "unit_id": unit_id, + "total_sessions": 0, + "completed_sessions": 0, + "completion_percent": 0.0, + "avg_duration_minutes": 0, + "avg_learning_gain": None, + } + + +# ============================================== +# Content Generation Endpoints +# ============================================== + +@router.get("/content/{unit_id}/h5p") +async def generate_h5p_content( + unit_id: str, + locale: str = Query("de-DE", description="Target locale"), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> Dict[str, Any]: + """ + Generate H5P content items for a unit. + + Returns H5P-compatible content structures for: + - Drag and Drop (vocabulary matching) + - Fill in the Blanks (concept texts) + - Multiple Choice (misconception targeting) + """ + from content_generators import generate_h5p_for_unit, H5PGenerator, generate_h5p_manifest + + # Get unit definition + db = await get_unit_database() + unit_def = None + + if db: + try: + unit = await db.get_unit_definition(unit_id) + if unit: + unit_def = unit.get("definition", {}) + except Exception as e: + logger.error(f"Failed to get unit for H5P generation: {e}") + + if not unit_def: + raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}") + + try: + generator = H5PGenerator(locale=locale) + contents = generator.generate_from_unit(unit_def) + manifest = generate_h5p_manifest(contents, unit_id) + + return { + "unit_id": unit_id, + "locale": locale, + "generated_count": len(contents), + "manifest": manifest, + "contents": [c.to_h5p_structure() for c in contents] + } + except Exception as e: + logger.error(f"H5P generation failed: {e}") + raise HTTPException(status_code=500, detail=f"H5P generation failed: {str(e)}") + + +@router.get("/content/{unit_id}/worksheet") +async def generate_worksheet_html( + unit_id: str, + locale: str = Query("de-DE", description="Target locale"), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +) -> Dict[str, Any]: + """ + Generate worksheet HTML for a unit. + + Returns HTML that can be: + - Displayed in browser + - Converted to PDF using weasyprint + - Printed directly + """ + from content_generators import PDFGenerator + + # Get unit definition + db = await get_unit_database() + unit_def = None + + if db: + try: + unit = await db.get_unit_definition(unit_id) + if unit: + unit_def = unit.get("definition", {}) + except Exception as e: + logger.error(f"Failed to get unit for worksheet generation: {e}") + + if not unit_def: + raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}") + + try: + generator = PDFGenerator(locale=locale) + worksheet = generator.generate_from_unit(unit_def) + + return { + "unit_id": unit_id, + "locale": locale, + "title": worksheet.title, + "sections": len(worksheet.sections), + "html": worksheet.to_html() + } + except Exception as e: + logger.error(f"Worksheet generation failed: {e}") + raise HTTPException(status_code=500, detail=f"Worksheet generation failed: {str(e)}") + + +@router.get("/content/{unit_id}/worksheet.pdf") +async def download_worksheet_pdf( + unit_id: str, + locale: str = Query("de-DE", description="Target locale"), + user: Optional[Dict[str, Any]] = Depends(get_optional_current_user) +): + """ + Generate and download worksheet as PDF. + + Requires weasyprint to be installed on the server. + """ + from fastapi.responses import Response + + # Get unit definition + db = await get_unit_database() + unit_def = None + + if db: + try: + unit = await db.get_unit_definition(unit_id) + if unit: + unit_def = unit.get("definition", {}) + except Exception as e: + logger.error(f"Failed to get unit for PDF generation: {e}") + + if not unit_def: + raise HTTPException(status_code=404, detail=f"Unit not found: {unit_id}") + + try: + from content_generators import generate_worksheet_pdf + pdf_bytes = generate_worksheet_pdf(unit_def, locale) + + return Response( + content=pdf_bytes, + media_type="application/pdf", + headers={ + "Content-Disposition": f'attachment; filename="{unit_id}_worksheet.pdf"' + } + ) + except ImportError: + raise HTTPException( + status_code=501, + detail="PDF generation not available. Install weasyprint: pip install weasyprint" + ) + except Exception as e: + logger.error(f"PDF generation failed: {e}") + raise HTTPException(status_code=500, detail=f"PDF generation failed: {str(e)}") + + +@router.get("/health") +async def health_check() -> Dict[str, Any]: + """Health check for unit API.""" + db = await get_unit_database() + db_status = "connected" if db else "disconnected" + + return { + "status": "healthy", + "service": "breakpilot-units", + "database": db_status, + "auth_required": REQUIRE_AUTH, + } diff --git a/backend/worksheets_api.py b/backend/worksheets_api.py new file mode 100644 index 0000000..527bf95 --- /dev/null +++ b/backend/worksheets_api.py @@ -0,0 +1,592 @@ +""" +Worksheets API - REST API für Arbeitsblatt-Generierung. + +Integriert alle Content-Generatoren: +- Multiple Choice Questions +- Lückentexte (Cloze) +- Mindmaps +- Quizze (True/False, Matching, Sorting, Open) + +Unterstützt: +- H5P-Export für interaktive Inhalte +- PDF-Export für Druckversionen +- JSON-Export für Frontend-Integration +""" + +import logging +import uuid +from datetime import datetime +from typing import List, Dict, Any, Optional +from enum import Enum + +from fastapi import APIRouter, HTTPException, UploadFile, File, Form +from pydantic import BaseModel, Field + +from generators import ( + MultipleChoiceGenerator, + ClozeGenerator, + MindmapGenerator, + QuizGenerator +) +from generators.mc_generator import Difficulty +from generators.cloze_generator import ClozeType +from generators.quiz_generator import QuizType + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/worksheets", + tags=["worksheets"], +) + + +# ============================================================================ +# Pydantic Models +# ============================================================================ + +class ContentType(str, Enum): + """Verfügbare Content-Typen.""" + MULTIPLE_CHOICE = "multiple_choice" + CLOZE = "cloze" + MINDMAP = "mindmap" + QUIZ = "quiz" + + +class GenerateRequest(BaseModel): + """Basis-Request für Generierung.""" + source_text: str = Field(..., min_length=50, description="Quelltext für Generierung") + topic: Optional[str] = Field(None, description="Thema/Titel") + subject: Optional[str] = Field(None, description="Fach") + grade_level: Optional[str] = Field(None, description="Klassenstufe") + + +class MCGenerateRequest(GenerateRequest): + """Request für Multiple-Choice-Generierung.""" + num_questions: int = Field(5, ge=1, le=20, description="Anzahl Fragen") + difficulty: str = Field("medium", description="easy, medium, hard") + + +class ClozeGenerateRequest(GenerateRequest): + """Request für Lückentext-Generierung.""" + num_gaps: int = Field(5, ge=1, le=15, description="Anzahl Lücken") + difficulty: str = Field("medium", description="easy, medium, hard") + cloze_type: str = Field("fill_in", description="fill_in, drag_drop, dropdown") + + +class MindmapGenerateRequest(GenerateRequest): + """Request für Mindmap-Generierung.""" + max_depth: int = Field(3, ge=2, le=5, description="Maximale Tiefe") + + +class QuizGenerateRequest(GenerateRequest): + """Request für Quiz-Generierung.""" + quiz_types: List[str] = Field( + ["true_false", "matching"], + description="Typen: true_false, matching, sorting, open_ended" + ) + num_items: int = Field(5, ge=1, le=10, description="Items pro Typ") + difficulty: str = Field("medium", description="easy, medium, hard") + + +class BatchGenerateRequest(BaseModel): + """Request für Batch-Generierung mehrerer Content-Typen.""" + source_text: str = Field(..., min_length=50) + content_types: List[str] = Field(..., description="Liste von Content-Typen") + topic: Optional[str] = None + subject: Optional[str] = None + grade_level: Optional[str] = None + difficulty: str = "medium" + + +class WorksheetContent(BaseModel): + """Generierter Content.""" + id: str + content_type: str + data: Dict[str, Any] + h5p_format: Optional[Dict[str, Any]] = None + created_at: datetime + topic: Optional[str] = None + difficulty: Optional[str] = None + + +class GenerateResponse(BaseModel): + """Response mit generiertem Content.""" + success: bool + content: Optional[WorksheetContent] = None + error: Optional[str] = None + + +class BatchGenerateResponse(BaseModel): + """Response für Batch-Generierung.""" + success: bool + contents: List[WorksheetContent] = [] + errors: List[str] = [] + + +# ============================================================================ +# In-Memory Storage (später durch DB ersetzen) +# ============================================================================ + +_generated_content: Dict[str, WorksheetContent] = {} + + +# ============================================================================ +# Generator Instances +# ============================================================================ + +# Generatoren ohne LLM-Client (automatische Generierung) +# In Produktion würde hier der LLM-Client injiziert +mc_generator = MultipleChoiceGenerator() +cloze_generator = ClozeGenerator() +mindmap_generator = MindmapGenerator() +quiz_generator = QuizGenerator() + + +# ============================================================================ +# Helper Functions +# ============================================================================ + +def _parse_difficulty(difficulty_str: str) -> Difficulty: + """Konvertiert String zu Difficulty Enum.""" + mapping = { + "easy": Difficulty.EASY, + "medium": Difficulty.MEDIUM, + "hard": Difficulty.HARD + } + return mapping.get(difficulty_str.lower(), Difficulty.MEDIUM) + + +def _parse_cloze_type(type_str: str) -> ClozeType: + """Konvertiert String zu ClozeType Enum.""" + mapping = { + "fill_in": ClozeType.FILL_IN, + "drag_drop": ClozeType.DRAG_DROP, + "dropdown": ClozeType.DROPDOWN + } + return mapping.get(type_str.lower(), ClozeType.FILL_IN) + + +def _parse_quiz_types(type_strs: List[str]) -> List[QuizType]: + """Konvertiert String-Liste zu QuizType Enums.""" + mapping = { + "true_false": QuizType.TRUE_FALSE, + "matching": QuizType.MATCHING, + "sorting": QuizType.SORTING, + "open_ended": QuizType.OPEN_ENDED + } + return [mapping.get(t.lower(), QuizType.TRUE_FALSE) for t in type_strs] + + +def _store_content(content: WorksheetContent) -> None: + """Speichert generierten Content.""" + _generated_content[content.id] = content + + +# ============================================================================ +# API Endpoints +# ============================================================================ + +@router.post("/generate/multiple-choice", response_model=GenerateResponse) +async def generate_multiple_choice(request: MCGenerateRequest): + """ + Generiert Multiple-Choice-Fragen aus Quelltext. + + - **source_text**: Text mit mind. 50 Zeichen + - **num_questions**: Anzahl Fragen (1-20) + - **difficulty**: easy, medium, hard + """ + try: + difficulty = _parse_difficulty(request.difficulty) + + questions = mc_generator.generate( + source_text=request.source_text, + num_questions=request.num_questions, + difficulty=difficulty, + subject=request.subject, + grade_level=request.grade_level + ) + + if not questions: + return GenerateResponse( + success=False, + error="Keine Fragen generiert. Text möglicherweise zu kurz." + ) + + # Konvertiere zu Dict + questions_dict = mc_generator.to_dict(questions) + h5p_format = mc_generator.to_h5p_format(questions) + + content = WorksheetContent( + id=str(uuid.uuid4()), + content_type=ContentType.MULTIPLE_CHOICE.value, + data={"questions": questions_dict}, + h5p_format=h5p_format, + created_at=datetime.utcnow(), + topic=request.topic, + difficulty=request.difficulty + ) + + _store_content(content) + + return GenerateResponse(success=True, content=content) + + except Exception as e: + logger.error(f"Error generating MC questions: {e}") + return GenerateResponse(success=False, error=str(e)) + + +@router.post("/generate/cloze", response_model=GenerateResponse) +async def generate_cloze(request: ClozeGenerateRequest): + """ + Generiert Lückentext aus Quelltext. + + - **source_text**: Text mit mind. 50 Zeichen + - **num_gaps**: Anzahl Lücken (1-15) + - **cloze_type**: fill_in, drag_drop, dropdown + """ + try: + cloze_type = _parse_cloze_type(request.cloze_type) + + cloze = cloze_generator.generate( + source_text=request.source_text, + num_gaps=request.num_gaps, + difficulty=request.difficulty, + cloze_type=cloze_type, + topic=request.topic + ) + + if not cloze.gaps: + return GenerateResponse( + success=False, + error="Keine Lücken generiert. Text möglicherweise zu kurz." + ) + + cloze_dict = cloze_generator.to_dict(cloze) + h5p_format = cloze_generator.to_h5p_format(cloze) + + content = WorksheetContent( + id=str(uuid.uuid4()), + content_type=ContentType.CLOZE.value, + data=cloze_dict, + h5p_format=h5p_format, + created_at=datetime.utcnow(), + topic=request.topic, + difficulty=request.difficulty + ) + + _store_content(content) + + return GenerateResponse(success=True, content=content) + + except Exception as e: + logger.error(f"Error generating cloze: {e}") + return GenerateResponse(success=False, error=str(e)) + + +@router.post("/generate/mindmap", response_model=GenerateResponse) +async def generate_mindmap(request: MindmapGenerateRequest): + """ + Generiert Mindmap aus Quelltext. + + - **source_text**: Text mit mind. 50 Zeichen + - **max_depth**: Maximale Tiefe (2-5) + """ + try: + mindmap = mindmap_generator.generate( + source_text=request.source_text, + title=request.topic, + max_depth=request.max_depth, + topic=request.topic + ) + + if mindmap.total_nodes <= 1: + return GenerateResponse( + success=False, + error="Mindmap konnte nicht generiert werden. Text möglicherweise zu kurz." + ) + + mindmap_dict = mindmap_generator.to_dict(mindmap) + mermaid = mindmap_generator.to_mermaid(mindmap) + json_tree = mindmap_generator.to_json_tree(mindmap) + + content = WorksheetContent( + id=str(uuid.uuid4()), + content_type=ContentType.MINDMAP.value, + data={ + "mindmap": mindmap_dict, + "mermaid": mermaid, + "json_tree": json_tree + }, + h5p_format=None, # Mindmaps haben kein H5P-Format + created_at=datetime.utcnow(), + topic=request.topic, + difficulty=None + ) + + _store_content(content) + + return GenerateResponse(success=True, content=content) + + except Exception as e: + logger.error(f"Error generating mindmap: {e}") + return GenerateResponse(success=False, error=str(e)) + + +@router.post("/generate/quiz", response_model=GenerateResponse) +async def generate_quiz(request: QuizGenerateRequest): + """ + Generiert Quiz mit verschiedenen Fragetypen. + + - **source_text**: Text mit mind. 50 Zeichen + - **quiz_types**: Liste von true_false, matching, sorting, open_ended + - **num_items**: Items pro Typ (1-10) + """ + try: + quiz_types = _parse_quiz_types(request.quiz_types) + + # Generate quiz for each type and combine results + all_questions = [] + quizzes = [] + + for quiz_type in quiz_types: + quiz = quiz_generator.generate( + source_text=request.source_text, + quiz_type=quiz_type, + num_questions=request.num_items, + difficulty=request.difficulty, + topic=request.topic + ) + quizzes.append(quiz) + all_questions.extend(quiz.questions) + + if len(all_questions) == 0: + return GenerateResponse( + success=False, + error="Quiz konnte nicht generiert werden. Text möglicherweise zu kurz." + ) + + # Combine all quizzes into a single dict + combined_quiz_dict = { + "quiz_types": [qt.value for qt in quiz_types], + "title": f"Combined Quiz - {request.topic or 'Various Topics'}", + "topic": request.topic, + "difficulty": request.difficulty, + "questions": [] + } + + # Add questions from each quiz + for quiz in quizzes: + quiz_dict = quiz_generator.to_dict(quiz) + combined_quiz_dict["questions"].extend(quiz_dict.get("questions", [])) + + # Use first quiz's H5P format as base (or empty if none) + h5p_format = quiz_generator.to_h5p_format(quizzes[0]) if quizzes else {} + + content = WorksheetContent( + id=str(uuid.uuid4()), + content_type=ContentType.QUIZ.value, + data=combined_quiz_dict, + h5p_format=h5p_format, + created_at=datetime.utcnow(), + topic=request.topic, + difficulty=request.difficulty + ) + + _store_content(content) + + return GenerateResponse(success=True, content=content) + + except Exception as e: + logger.error(f"Error generating quiz: {e}") + return GenerateResponse(success=False, error=str(e)) + + +@router.post("/generate/batch", response_model=BatchGenerateResponse) +async def generate_batch(request: BatchGenerateRequest): + """ + Generiert mehrere Content-Typen aus einem Quelltext. + + Ideal für die Erstellung kompletter Arbeitsblätter mit + verschiedenen Übungstypen. + """ + contents = [] + errors = [] + + type_mapping = { + "multiple_choice": MCGenerateRequest, + "cloze": ClozeGenerateRequest, + "mindmap": MindmapGenerateRequest, + "quiz": QuizGenerateRequest + } + + for content_type in request.content_types: + try: + if content_type == "multiple_choice": + mc_req = MCGenerateRequest( + source_text=request.source_text, + topic=request.topic, + subject=request.subject, + grade_level=request.grade_level, + difficulty=request.difficulty + ) + result = await generate_multiple_choice(mc_req) + + elif content_type == "cloze": + cloze_req = ClozeGenerateRequest( + source_text=request.source_text, + topic=request.topic, + subject=request.subject, + grade_level=request.grade_level, + difficulty=request.difficulty + ) + result = await generate_cloze(cloze_req) + + elif content_type == "mindmap": + mindmap_req = MindmapGenerateRequest( + source_text=request.source_text, + topic=request.topic, + subject=request.subject, + grade_level=request.grade_level + ) + result = await generate_mindmap(mindmap_req) + + elif content_type == "quiz": + quiz_req = QuizGenerateRequest( + source_text=request.source_text, + topic=request.topic, + subject=request.subject, + grade_level=request.grade_level, + difficulty=request.difficulty + ) + result = await generate_quiz(quiz_req) + + else: + errors.append(f"Unbekannter Content-Typ: {content_type}") + continue + + if result.success and result.content: + contents.append(result.content) + elif result.error: + errors.append(f"{content_type}: {result.error}") + + except Exception as e: + errors.append(f"{content_type}: {str(e)}") + + return BatchGenerateResponse( + success=len(contents) > 0, + contents=contents, + errors=errors + ) + + +@router.get("/content/{content_id}", response_model=GenerateResponse) +async def get_content(content_id: str): + """Ruft gespeicherten Content ab.""" + content = _generated_content.get(content_id) + + if not content: + raise HTTPException(status_code=404, detail="Content nicht gefunden") + + return GenerateResponse(success=True, content=content) + + +@router.get("/content/{content_id}/h5p") +async def get_content_h5p(content_id: str): + """Gibt H5P-Format für Content zurück.""" + content = _generated_content.get(content_id) + + if not content: + raise HTTPException(status_code=404, detail="Content nicht gefunden") + + if not content.h5p_format: + raise HTTPException( + status_code=400, + detail="H5P-Format für diesen Content-Typ nicht verfügbar" + ) + + return content.h5p_format + + +@router.delete("/content/{content_id}") +async def delete_content(content_id: str): + """Löscht gespeicherten Content.""" + if content_id not in _generated_content: + raise HTTPException(status_code=404, detail="Content nicht gefunden") + + del _generated_content[content_id] + return {"status": "deleted", "id": content_id} + + +@router.get("/types") +async def list_content_types(): + """Listet verfügbare Content-Typen und deren Optionen.""" + return { + "content_types": [ + { + "type": "multiple_choice", + "name": "Multiple Choice", + "description": "Fragen mit 4 Antwortmöglichkeiten", + "options": { + "num_questions": {"min": 1, "max": 20, "default": 5}, + "difficulty": ["easy", "medium", "hard"] + }, + "h5p_supported": True + }, + { + "type": "cloze", + "name": "Lückentext", + "description": "Text mit ausgeblendeten Schlüsselwörtern", + "options": { + "num_gaps": {"min": 1, "max": 15, "default": 5}, + "difficulty": ["easy", "medium", "hard"], + "cloze_type": ["fill_in", "drag_drop", "dropdown"] + }, + "h5p_supported": True + }, + { + "type": "mindmap", + "name": "Mindmap", + "description": "Hierarchische Struktur aus Hauptthema und Unterthemen", + "options": { + "max_depth": {"min": 2, "max": 5, "default": 3} + }, + "h5p_supported": False, + "export_formats": ["mermaid", "json_tree"] + }, + { + "type": "quiz", + "name": "Quiz", + "description": "Verschiedene Fragetypen kombiniert", + "options": { + "quiz_types": ["true_false", "matching", "sorting", "open_ended"], + "num_items": {"min": 1, "max": 10, "default": 5}, + "difficulty": ["easy", "medium", "hard"] + }, + "h5p_supported": True + } + ] + } + + +@router.get("/history") +async def get_generation_history(limit: int = 10): + """Gibt die letzten generierten Contents zurück.""" + sorted_contents = sorted( + _generated_content.values(), + key=lambda x: x.created_at, + reverse=True + ) + + return { + "total": len(_generated_content), + "contents": [ + { + "id": c.id, + "content_type": c.content_type, + "topic": c.topic, + "difficulty": c.difficulty, + "created_at": c.created_at.isoformat() + } + for c in sorted_contents[:limit] + ] + } diff --git a/bpmn-processes/README.md b/bpmn-processes/README.md new file mode 100644 index 0000000..33f375c --- /dev/null +++ b/bpmn-processes/README.md @@ -0,0 +1,171 @@ +# BreakPilot BPMN Prozesse + +Dieses Verzeichnis enthaelt die BPMN 2.0 Prozessdefinitionen fuer BreakPilot. + +## Prozess-Uebersicht + +| Datei | Prozess | Beschreibung | Status | +|-------|---------|--------------|--------| +| `classroom-lesson.bpmn` | Unterrichtsstunde | Phasenbasierte Unterrichtssteuerung | Entwurf | +| `consent-document.bpmn` | Consent-Dokument | DSB-Approval, Publishing, Monitoring | Entwurf | +| `klausur-korrektur.bpmn` | Klausurkorrektur | OCR, AI-Grading, Export | Entwurf | +| `dsr-request.bpmn` | DSR/GDPR | Betroffenenanfragen (Art. 15-20) | Entwurf | + +## Verwendung + +### Im BPMN Editor laden + +1. Navigiere zu http://localhost:3000/admin/workflow oder http://localhost:8000/app (Workflow) +2. Klicke "Oeffnen" und waehle eine .bpmn Datei +3. Bearbeite den Prozess im Editor +4. Speichere und deploye zu Camunda + +### In Camunda deployen + +```bash +# Camunda starten (falls noch nicht aktiv) +docker compose --profile bpmn up -d camunda + +# Prozess deployen via API +curl -X POST http://localhost:8000/api/bpmn/deployment/create \ + -F "deployment-name=breakpilot-processes" \ + -F "data=@classroom-lesson.bpmn" +``` + +### Prozess starten + +```bash +# Unterrichtsstunde starten +curl -X POST http://localhost:8000/api/bpmn/process-definition/ClassroomLessonProcess/start \ + -H "Content-Type: application/json" \ + -d '{ + "variables": { + "teacherId": {"value": "teacher-123"}, + "classId": {"value": "class-7a"}, + "subject": {"value": "Mathematik"} + } + }' +``` + +## Prozess-Details + +### 1. Classroom Lesson (classroom-lesson.bpmn) + +**Phasen:** +- Einstieg (Motivation, Problemstellung) +- Erarbeitung I (Einzelarbeit, Partnerarbeit, Gruppenarbeit) +- Erarbeitung II (optional) +- Sicherung (Tafel, Digital, Schueler-Praesentation) +- Transfer (Anwendungsaufgaben) +- Reflexion & Abschluss (Hausaufgaben, Notizen) + +**Service Tasks:** +- `contentSuggestionDelegate` - Content-Vorschlaege basierend auf Phase +- `lessonProtocolDelegate` - Automatisches Stundenprotokoll + +**Timer Events:** +- Phasen-Timer mit Warnungen + +--- + +### 2. Consent Document (consent-document.bpmn) + +**Workflow:** +1. Dokument bearbeiten (Autor) +2. DSB-Pruefung (Vier-Augen-Prinzip) +3. Bei Ablehnung: Zurueck an Autor +4. Bei Genehmigung: Veroeffentlichen +5. Benutzer benachrichtigen +6. Consent sammeln mit Deadline-Timer +7. Monitoring-Subprocess fuer jaehrliche Erneuerung +8. Archivierung bei neuer Version + +**Service Tasks:** +- `publishConsentDocumentDelegate` +- `notifyUsersDelegate` +- `sendConsentReminderDelegate` +- `checkConsentStatusDelegate` +- `triggerRenewalDelegate` +- `archiveDocumentDelegate` + +--- + +### 3. Klausur Korrektur (klausur-korrektur.bpmn) + +**Workflow:** +1. OCR-Verarbeitung der hochgeladenen Klausuren +2. Qualitaets-Check (Confidence >= 85%) +3. Bei schlechter Qualitaet: Manuelle Nachbearbeitung +4. Erwartungshorizont definieren +5. AI-Bewertung mit Claude +6. Lehrer-Review mit Anpassungsmoeglichkeit +7. Noten berechnen (15-Punkte-Skala) +8. Notenbuch aktualisieren +9. Export (PDF, Excel) +10. Optional: Eltern benachrichtigen +11. Archivierung + +**Service Tasks:** +- `ocrProcessingDelegate` +- `ocrQualityCheckDelegate` +- `aiGradingDelegate` +- `calculateGradesDelegate` +- `updateGradebookDelegate` +- `generateExportDelegate` +- `notifyParentsDelegate` +- `archiveExamDelegate` +- `deadlineWarningDelegate` + +--- + +### 4. DSR Request (dsr-request.bpmn) + +**GDPR Artikel:** +- Art. 15: Recht auf Auskunft (Access) +- Art. 16: Recht auf Berichtigung (Rectification) +- Art. 17: Recht auf Loeschung (Deletion) +- Art. 20: Recht auf Datenuebertragbarkeit (Portability) + +**Workflow:** +1. Anfrage validieren +2. Bei ungueltig: Ablehnen +3. Je nach Typ: + - Access: Daten sammeln → Anonymisieren → Review → Export + - Deletion: Identifizieren → Genehmigen → Loeschen → Verifizieren + - Portability: Sammeln → JSON formatieren + - Rectification: Pruefen → Anwenden +4. Betroffenen benachrichtigen +5. Audit Log erstellen + +**30-Tage Frist:** +- Timer-Event nach 25 Tagen fuer Eskalation an DSB + +**Service Tasks:** +- `validateDSRDelegate` +- `rejectDSRDelegate` +- `collectUserDataDelegate` +- `anonymizeDataDelegate` +- `prepareExportDelegate` +- `identifyUserDataDelegate` +- `executeDataDeletionDelegate` +- `verifyDeletionDelegate` +- `collectPortableDataDelegate` +- `formatPortableDataDelegate` +- `applyRectificationDelegate` +- `notifyDataSubjectDelegate` +- `createAuditLogDelegate` +- `escalateToDSBDelegate` + +## Naechste Schritte + +1. **Delegates implementieren**: Java/Python Service Tasks +2. **Camunda Connect**: REST-Aufrufe zu Backend-APIs +3. **User Task Forms**: Camunda Forms oder Custom UI +4. **Timer konfigurieren**: Realistische Dauern setzen +5. **Testing**: Prozesse mit Testdaten durchlaufen + +## Referenzen + +- [Camunda 7 Docs](https://docs.camunda.org/manual/7.21/) +- [BPMN 2.0 Spec](https://www.omg.org/spec/BPMN/2.0/) +- [bpmn-js](https://bpmn.io/toolkit/bpmn-js/) diff --git a/bpmn-processes/classroom-lesson.bpmn b/bpmn-processes/classroom-lesson.bpmn new file mode 100644 index 0000000..9175d83 --- /dev/null +++ b/bpmn-processes/classroom-lesson.bpmn @@ -0,0 +1,181 @@ + + + + + + + + flow_to_einstieg + + + + + + + + + + + flow_to_einstieg + flow_to_erarbeitung1 + + + + + flow_suggest_einstieg + flow_from_suggest_einstieg + + + + + + + + + + + + + + + + flow_to_erarbeitung1 + flow_to_erarbeitung_gateway + + + + + flow_to_erarbeitung_gateway + flow_to_erarbeitung2 + flow_to_sicherung + + + + + flow_to_erarbeitung2 + flow_from_erarbeitung2 + + + + + + + + + + + + + + + flow_to_sicherung + flow_from_erarbeitung2 + flow_to_transfer + + + + + + + + + + + flow_to_transfer + flow_to_reflexion + + + + + + + + + + + + flow_to_reflexion + flow_to_protokoll + + + + + flow_to_protokoll + flow_to_end + + + + + flow_to_end + + + + + + PT${einstiegDuration}M + + flow_timer_warning + + + + + + + + ${needsMoreWork == true} + + + ${needsMoreWork == false} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bpmn-processes/consent-document.bpmn b/bpmn-processes/consent-document.bpmn new file mode 100644 index 0000000..e1ab4bf --- /dev/null +++ b/bpmn-processes/consent-document.bpmn @@ -0,0 +1,206 @@ + + + + + + + + flow_to_edit + + + + + + + + + + + + + + + + + + flow_to_edit + flow_rejected_to_edit + flow_to_review + + + + + + + + + + + + flow_to_review + flow_to_approval_gateway + + + + + flow_to_approval_gateway + flow_approved + flow_rejected + + + + + flow_approved + flow_to_notify + + + + + flow_to_notify + flow_to_collect_consent + + + + + flow_to_collect_consent + flow_to_check_deadline + + + + + + P${consentDeadlineDays}D + + flow_to_reminder + + + + + flow_to_reminder + flow_back_to_collect + + + + + flow_to_check_deadline + flow_to_active + + + + + flow_to_active + flow_to_monitor + + + + + flow_to_monitor + flow_to_archive + + + + + + flow_from_monitoring_start + flow_to_renewal_timer + flow_to_supersede_event + + + + + flow_to_renewal_timer + flow_to_renewal_task + + P1Y + + + + + + flow_to_supersede_event + flow_to_monitoring_end + + + + + flow_to_renewal_task + flow_back_to_gateway + + + + + + + + flow_to_archive + flow_to_end + + + + + flow_to_end + + + + + + + + ${approved == true} + + + ${approved == false} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bpmn-processes/dsr-request.bpmn b/bpmn-processes/dsr-request.bpmn new file mode 100644 index 0000000..f1afb6d --- /dev/null +++ b/bpmn-processes/dsr-request.bpmn @@ -0,0 +1,222 @@ + + + + + + + + flow_to_validate + + + + + flow_to_validate + flow_to_validation_gateway + + + + + flow_to_validation_gateway + flow_valid + flow_invalid + + + + + flow_invalid + flow_to_reject_end + + + + + flow_to_reject_end + + + + + flow_valid + flow_access + flow_deletion + flow_portability + flow_rectification + + + + + flow_access + flow_access_done + + + + + + + + + + + + + + + + + + + + + + + + flow_deletion + flow_deletion_done + + + + + + + + + + + + + + + + + + + + + + + + flow_portability + flow_portability_done + + + + + + + + + + + + + flow_rectification + flow_rectification_done + + + + + + + + + + + + + flow_access_done + flow_deletion_done + flow_portability_done + flow_rectification_done + flow_to_notify + + + + + flow_to_notify + flow_to_audit + + + + + flow_to_audit + flow_to_end + + + + + flow_to_end + + + + + + P25D + + flow_deadline_escalation + + + + + flow_deadline_escalation + + + + + + + ${valid == true} + + + ${valid == false} + + + + ${requestType == 'access'} + + + ${requestType == 'deletion'} + + + ${requestType == 'portability'} + + + ${requestType == 'rectification'} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bpmn-processes/klausur-korrektur.bpmn b/bpmn-processes/klausur-korrektur.bpmn new file mode 100644 index 0000000..69c3741 --- /dev/null +++ b/bpmn-processes/klausur-korrektur.bpmn @@ -0,0 +1,215 @@ + + + + + + + + flow_to_ocr + + + + + flow_to_ocr + flow_to_quality_check + + + + + flow_to_quality_check + flow_to_quality_gateway + + + + + flow_to_quality_gateway + flow_quality_ok + flow_quality_bad + + + + + + + + + + flow_quality_bad + flow_from_manual_fix + + + + + + + + + + + + flow_quality_ok + flow_from_manual_fix + flow_to_ai_grading + + + + + flow_to_ai_grading + flow_to_teacher_review + + + + + + + + + + + + flow_to_teacher_review + flow_to_review_gateway + + + + + flow_to_review_gateway + flow_review_ok + flow_review_adjust + + + + + flow_review_ok + flow_to_gradebook + + + + + flow_to_gradebook + flow_to_export + + + + + flow_to_export + flow_to_notify_gateway + + + + + flow_to_notify_gateway + flow_notify_yes + flow_notify_no + + + + + flow_notify_yes + flow_from_notify + + + + + flow_notify_no + flow_from_notify + flow_to_end + + + + + flow_to_end + + + + + + P${correctionDeadlineDays}D + + flow_deadline_warning + + + + + flow_deadline_warning + + + + + + + + ${ocrConfidence >= 0.85} + + + ${ocrConfidence < 0.85} + + + + + + + ${approved == true} + + + ${approved == false} + + + + + + ${notifyParents == true} + + + ${notifyParents == false} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/breakpilot-compliance-sdk/.changeset/config.json b/breakpilot-compliance-sdk/.changeset/config.json new file mode 100644 index 0000000..d134892 --- /dev/null +++ b/breakpilot-compliance-sdk/.changeset/config.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [], + "linked": [ + ["@breakpilot/compliance-sdk-core", "@breakpilot/compliance-sdk-react", "@breakpilot/compliance-sdk-vue", "@breakpilot/compliance-sdk-vanilla", "@breakpilot/compliance-sdk-types"] + ], + "access": "restricted", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": [] +} diff --git a/breakpilot-compliance-sdk/apps/admin-dashboard/next.config.js b/breakpilot-compliance-sdk/apps/admin-dashboard/next.config.js new file mode 100644 index 0000000..0f41faa --- /dev/null +++ b/breakpilot-compliance-sdk/apps/admin-dashboard/next.config.js @@ -0,0 +1,14 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + reactStrictMode: true, + transpilePackages: [ + '@breakpilot/compliance-sdk-react', + '@breakpilot/compliance-sdk-core', + '@breakpilot/compliance-sdk-types', + ], + experimental: { + serverComponentsExternalPackages: [], + }, +}; + +module.exports = nextConfig; diff --git a/breakpilot-compliance-sdk/apps/admin-dashboard/package.json b/breakpilot-compliance-sdk/apps/admin-dashboard/package.json new file mode 100644 index 0000000..9c0ddd7 --- /dev/null +++ b/breakpilot-compliance-sdk/apps/admin-dashboard/package.json @@ -0,0 +1,51 @@ +{ + "name": "@breakpilot/admin-dashboard", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev -p 3100", + "build": "next build", + "start": "next start -p 3100", + "lint": "next lint", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@breakpilot/compliance-sdk-react": "workspace:*", + "@breakpilot/compliance-sdk-types": "workspace:*", + "@radix-ui/react-accordion": "^1.2.0", + "@radix-ui/react-alert-dialog": "^1.1.1", + "@radix-ui/react-avatar": "^1.1.0", + "@radix-ui/react-checkbox": "^1.1.0", + "@radix-ui/react-dialog": "^1.1.1", + "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-popover": "^1.1.1", + "@radix-ui/react-progress": "^1.1.0", + "@radix-ui/react-select": "^2.1.1", + "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-switch": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.0", + "@radix-ui/react-toast": "^1.2.1", + "@radix-ui/react-tooltip": "^1.1.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "date-fns": "^3.6.0", + "lucide-react": "^0.400.0", + "next": "^14.2.5", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "recharts": "^2.12.7", + "tailwind-merge": "^2.4.0", + "tailwindcss-animate": "^1.0.7" + }, + "devDependencies": { + "@types/node": "^20.14.10", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.39", + "tailwindcss": "^3.4.6", + "typescript": "^5.5.3" + } +} diff --git a/breakpilot-compliance-sdk/apps/admin-dashboard/postcss.config.js b/breakpilot-compliance-sdk/apps/admin-dashboard/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/breakpilot-compliance-sdk/apps/admin-dashboard/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/breakpilot-compliance-sdk/apps/admin-dashboard/src/app/compliance/page.tsx b/breakpilot-compliance-sdk/apps/admin-dashboard/src/app/compliance/page.tsx new file mode 100644 index 0000000..1e9db7f --- /dev/null +++ b/breakpilot-compliance-sdk/apps/admin-dashboard/src/app/compliance/page.tsx @@ -0,0 +1,240 @@ +'use client'; + +import { useControls } from '@breakpilot/compliance-sdk-react'; +import Link from 'next/link'; +import { + ArrowLeft, + CheckCircle, + Shield, + FileCheck, + AlertTriangle, + Target, + BarChart3, + FileText, + Download, +} from 'lucide-react'; + +export default function CompliancePage() { + const { controls, evidence, risks, isLoading } = useControls(); + + const domains = [ + { id: 'access', name: 'Access Control', count: 5 }, + { id: 'data', name: 'Data Protection', count: 6 }, + { id: 'network', name: 'Network Security', count: 4 }, + { id: 'incident', name: 'Incident Response', count: 5 }, + { id: 'business', name: 'Business Continuity', count: 4 }, + { id: 'vendor', name: 'Vendor Management', count: 4 }, + { id: 'training', name: 'Security Training', count: 4 }, + { id: 'physical', name: 'Physical Security', count: 6 }, + { id: 'governance', name: 'Governance', count: 6 }, + ]; + + const controlStats = { + total: controls?.length ?? 44, + implemented: + controls?.filter((c) => c.implementationStatus === 'IMPLEMENTED').length ?? + 32, + inProgress: + controls?.filter((c) => c.implementationStatus === 'IN_PROGRESS').length ?? + 8, + notImplemented: + controls?.filter((c) => c.implementationStatus === 'NOT_IMPLEMENTED') + .length ?? 4, + }; + + const implementationRate = Math.round( + (controlStats.implemented / controlStats.total) * 100 + ); + + return ( +
              + {/* Header */} +
              +
              +
              +
              + + + +
              +
              + +
              +
              +

              Compliance Hub

              +

              + Controls, Evidence & Obligations +

              +
              +
              +
              +
              + + + Export Report + +
              +
              +
              +
              + +
              + {/* Stats Overview */} +
              +
              +
              + + {implementationRate}% +
              +
              Implementation Rate
              +
              +
              +
              +
              + +
              +
              + {controlStats.implemented} +
              +
              + Controls Implemented +
              +
              + of {controlStats.total} total +
              +
              + +
              +
              + {evidence?.length ?? 0} +
              +
              + Evidence Items +
              +
              + Uploaded documents +
              +
              + +
              +
              + {risks?.filter((r) => r.status !== 'MITIGATED').length ?? 0} +
              +
              Open Risks
              +
              + Require attention +
              +
              +
              + + {/* Quick Links */} +
              + +
              +
              + +
              +
              +

              Controls

              +

              + 44+ controls in 9 domains +

              +
              +
              + + + +
              +
              + +
              +
              +

              Evidence

              +

              + {evidence?.length ?? 0} documents uploaded +

              +
              +
              + + + +
              +
              + +
              +
              +

              Risk Register

              +

              + {risks?.length ?? 0} risks tracked +

              +
              +
              + +
              + + {/* Control Domains */} +
              +

              Control Domains

              +
              + {domains.map((domain) => { + const domainControls = controls?.filter( + (c) => c.domain === domain.id + ); + const implemented = + domainControls?.filter( + (c) => c.implementationStatus === 'IMPLEMENTED' + ).length ?? 0; + const total = domain.count; + const percent = Math.round((implemented / total) * 100); + + return ( + +
              + {domain.name} + + {implemented}/{total} + +
              +
              +
              = 50 + ? 'bg-blue-500' + : 'bg-yellow-500' + }`} + style={{ width: `${percent}%` }} + /> +
              + + ); + })} +
              +
              +
              +
              + ); +} diff --git a/breakpilot-compliance-sdk/apps/admin-dashboard/src/app/dsgvo/consent/page.tsx b/breakpilot-compliance-sdk/apps/admin-dashboard/src/app/dsgvo/consent/page.tsx new file mode 100644 index 0000000..232a79e --- /dev/null +++ b/breakpilot-compliance-sdk/apps/admin-dashboard/src/app/dsgvo/consent/page.tsx @@ -0,0 +1,209 @@ +'use client'; + +import { useState } from 'react'; +import { useDSGVO } from '@breakpilot/compliance-sdk-react'; +import Link from 'next/link'; +import { + ArrowLeft, + ClipboardCheck, + Plus, + Search, + Filter, + MoreVertical, + CheckCircle, + XCircle, + Clock, +} from 'lucide-react'; + +export default function ConsentManagementPage() { + const { consents, grantConsent, revokeConsent, isLoading } = useDSGVO(); + const [searchQuery, setSearchQuery] = useState(''); + const [filterPurpose, setFilterPurpose] = useState('all'); + + const purposes = [ + { id: 'analytics', name: 'Analytics', description: 'Website usage tracking' }, + { id: 'marketing', name: 'Marketing', description: 'Marketing communications' }, + { id: 'functional', name: 'Functional', description: 'Enhanced features' }, + { id: 'necessary', name: 'Necessary', description: 'Essential cookies' }, + ]; + + const filteredConsents = consents?.filter((consent) => { + const matchesSearch = + consent.userId.toLowerCase().includes(searchQuery.toLowerCase()) || + consent.purpose.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesPurpose = + filterPurpose === 'all' || consent.purpose === filterPurpose; + return matchesSearch && matchesPurpose; + }); + + const stats = { + total: consents?.length ?? 0, + granted: consents?.filter((c) => c.granted).length ?? 0, + revoked: consents?.filter((c) => !c.granted).length ?? 0, + pending: consents?.filter((c) => c.granted === null).length ?? 0, + }; + + return ( +
              + {/* Header */} +
              +
              +
              + + + +
              +
              + +
              +
              +

              Einwilligungen

              +

              + Art. 6, 7 DSGVO - Consent Management +

              +
              +
              +
              +
              +
              + +
              + {/* Stats */} +
              +
              +
              {stats.total}
              +
              Total
              +
              +
              +
              + {stats.granted} +
              +
              Granted
              +
              +
              +
              + {stats.revoked} +
              +
              Revoked
              +
              +
              +
              + {stats.pending} +
              +
              Pending
              +
              +
              + + {/* Filters */} +
              +
              + + setSearchQuery(e.target.value)} + className="w-full pl-10 pr-4 py-2 border rounded-lg bg-background focus:outline-none focus:ring-2 focus:ring-primary" + /> +
              + +
              + + {/* Consent List */} +
              + + + + + + + + + + + + + {filteredConsents?.map((consent) => ( + + + + + + + + + ))} + {(!filteredConsents || filteredConsents.length === 0) && ( + + + + )} + +
              + User ID + + Purpose + + Status + + Source + + Created + + Actions +
              + {consent.userId} + + {consent.purpose} + + {consent.granted ? ( + + + Granted + + ) : consent.granted === false ? ( + + + Revoked + + ) : ( + + + Pending + + )} + + {consent.source || 'Website'} + + {consent.createdAt + ? new Date(consent.createdAt).toLocaleDateString('de-DE') + : '-'} + + +
              + No consents found +
              +
              +
              +
              + ); +} diff --git a/breakpilot-compliance-sdk/apps/admin-dashboard/src/app/dsgvo/page.tsx b/breakpilot-compliance-sdk/apps/admin-dashboard/src/app/dsgvo/page.tsx new file mode 100644 index 0000000..9911b8a --- /dev/null +++ b/breakpilot-compliance-sdk/apps/admin-dashboard/src/app/dsgvo/page.tsx @@ -0,0 +1,188 @@ +'use client'; + +import { useDSGVO } from '@breakpilot/compliance-sdk-react'; +import Link from 'next/link'; +import { + Shield, + Users, + FileCheck, + FileText, + Lock, + Trash2, + ClipboardCheck, + AlertCircle, + ArrowRight, + ArrowLeft, +} from 'lucide-react'; + +export default function DSGVOPage() { + const { consents, dsrRequests, vvtActivities, toms, isLoading } = useDSGVO(); + + const modules = [ + { + id: 'consent', + title: 'Einwilligungen', + description: 'Consent-Tracking Multi-Channel', + icon: ClipboardCheck, + href: '/dsgvo/consent', + articles: 'Art. 6, 7', + count: consents?.length ?? 0, + color: 'bg-blue-500', + }, + { + id: 'dsr', + title: 'Betroffenenrechte (DSR)', + description: 'Auskunft, Loeschung, Berichtigung', + icon: Users, + href: '/dsgvo/dsr', + articles: 'Art. 15-21', + count: dsrRequests?.length ?? 0, + color: 'bg-green-500', + }, + { + id: 'vvt', + title: 'Verarbeitungsverzeichnis', + description: 'Dokumentation aller Verarbeitungstaetigkeiten', + icon: FileText, + href: '/dsgvo/vvt', + articles: 'Art. 30', + count: vvtActivities?.length ?? 0, + color: 'bg-purple-500', + }, + { + id: 'dsfa', + title: 'Datenschutz-Folgenabschaetzung', + description: 'Risk Assessment fuer Verarbeitungen', + icon: AlertCircle, + href: '/dsgvo/dsfa', + articles: 'Art. 35, 36', + count: 0, + color: 'bg-yellow-500', + }, + { + id: 'tom', + title: 'TOM', + description: 'Technische & Organisatorische Massnahmen', + icon: Lock, + href: '/dsgvo/tom', + articles: 'Art. 32', + count: toms?.length ?? 0, + color: 'bg-red-500', + }, + { + id: 'retention', + title: 'Loeschfristen', + description: 'Retention Policies & Automations', + icon: Trash2, + href: '/dsgvo/retention', + articles: 'Art. 5, 17', + count: 0, + color: 'bg-orange-500', + }, + ]; + + return ( +
              + {/* Header */} +
              +
              +
              + + + +
              +
              + +
              +
              +

              DSGVO Modul

              +

              + Datenschutz-Grundverordnung Compliance +

              +
              +
              +
              +
              +
              + +
              + {/* Progress Overview */} +
              +

              DSGVO Compliance Status

              +
              +
              +
              + {consents?.length ?? 0} +
              +
              + Active Consents +
              +
              +
              +
              + {dsrRequests?.filter((r) => r.status === 'PENDING').length ?? 0} +
              +
              Pending DSRs
              +
              +
              +
              + {vvtActivities?.length ?? 0} +
              +
              + Processing Activities +
              +
              +
              +
              + {toms?.filter((t) => t.implementationStatus === 'IMPLEMENTED').length ?? + 0} +
              +
              + Implemented TOMs +
              +
              +
              +
              + + {/* Module Grid */} +
              + {modules.map((module) => { + const Icon = module.icon; + return ( + +
              +
              + +
              + + {module.articles} + +
              +

              {module.title}

              +

              + {module.description} +

              +
              + + + {module.count} + {' '} + Eintraege + + +
              + + ); + })} +
              +
              +
              + ); +} diff --git a/breakpilot-compliance-sdk/apps/admin-dashboard/src/app/globals.css b/breakpilot-compliance-sdk/apps/admin-dashboard/src/app/globals.css new file mode 100644 index 0000000..00b08e3 --- /dev/null +++ b/breakpilot-compliance-sdk/apps/admin-dashboard/src/app/globals.css @@ -0,0 +1,59 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 221.2 83.2% 53.3%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 221.2 83.2% 53.3%; + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 217.2 91.2% 59.8%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 224.3 76.3% 48%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/breakpilot-compliance-sdk/apps/admin-dashboard/src/app/layout.tsx b/breakpilot-compliance-sdk/apps/admin-dashboard/src/app/layout.tsx new file mode 100644 index 0000000..55d3a6b --- /dev/null +++ b/breakpilot-compliance-sdk/apps/admin-dashboard/src/app/layout.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from 'next'; +import { Inter } from 'next/font/google'; +import './globals.css'; +import { Providers } from './providers'; + +const inter = Inter({ subsets: ['latin'] }); + +export const metadata: Metadata = { + title: 'BreakPilot Compliance Admin', + description: 'Compliance Management Dashboard', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} diff --git a/breakpilot-compliance-sdk/apps/admin-dashboard/src/app/page.tsx b/breakpilot-compliance-sdk/apps/admin-dashboard/src/app/page.tsx new file mode 100644 index 0000000..6f38c0e --- /dev/null +++ b/breakpilot-compliance-sdk/apps/admin-dashboard/src/app/page.tsx @@ -0,0 +1,265 @@ +'use client'; + +import { useCompliance } from '@breakpilot/compliance-sdk-react'; +import Link from 'next/link'; +import { + Shield, + FileText, + Lock, + Search, + AlertTriangle, + CheckCircle, + ArrowRight, + BarChart3, + Settings, +} from 'lucide-react'; + +export default function DashboardPage() { + const { state, isLoading, error, progress } = useCompliance(); + + if (isLoading) { + return ( +
              +
              +
              +

              Loading compliance data...

              +
              +
              + ); + } + + if (error) { + return ( +
              +
              + +

              Error loading compliance data

              +

              {error}

              +
              +
              + ); + } + + const modules = [ + { + id: 'dsgvo', + title: 'DSGVO', + description: 'Datenschutz-Grundverordnung Compliance', + icon: Shield, + href: '/dsgvo', + stats: { completed: 8, total: 12 }, + color: 'text-blue-500', + }, + { + id: 'compliance', + title: 'Compliance Hub', + description: 'Controls, Evidence & Obligations', + icon: CheckCircle, + href: '/compliance', + stats: { completed: 32, total: 44 }, + color: 'text-green-500', + }, + { + id: 'rag', + title: 'Legal Assistant', + description: 'AI-powered regulatory search', + icon: Search, + href: '/rag', + stats: { documents: 21 }, + color: 'text-purple-500', + }, + { + id: 'sbom', + title: 'SBOM', + description: 'Software Bill of Materials', + icon: FileText, + href: '/sbom', + stats: { components: 140 }, + color: 'text-orange-500', + }, + { + id: 'security', + title: 'Security', + description: 'Vulnerability scanning & findings', + icon: Lock, + href: '/security', + stats: { findings: 5, critical: 0 }, + color: 'text-red-500', + }, + ]; + + return ( +
              + {/* Header */} +
              +
              +
              +
              + +
              +

              BreakPilot Compliance

              +

              Admin Dashboard

              +
              +
              +
              + + + +
              +
              +
              +
              + +
              + {/* Overall Progress */} +
              +
              +
              +
              +

              Overall Compliance Score

              +

              + Based on all active modules +

              +
              +
              + {state?.complianceScore ?? progress?.overall ?? 0}% +
              +
              +
              +
              +
              +
              +
              +
              DSGVO
              +
              {progress?.dsgvo ?? 0}%
              +
              +
              +
              Compliance
              +
              {progress?.compliance ?? 0}%
              +
              +
              +
              Security
              +
              {progress?.security ?? 0}%
              +
              +
              +
              SBOM
              +
              {progress?.sbom ?? 0}%
              +
              +
              +
              RAG
              +
              {progress?.rag ?? 0}%
              +
              +
              +
              +
              + + {/* Module Grid */} +
              + {modules.map((module) => { + const Icon = module.icon; + return ( + +
              +
              + +
              + +
              +

              {module.title}

              +

              + {module.description} +

              +
              + {'completed' in module.stats && ( + + + {module.stats.completed} + + /{module.stats.total} complete + + )} + {'documents' in module.stats && ( + + + {module.stats.documents} + {' '} + documents + + )} + {'components' in module.stats && ( + + + {module.stats.components} + {' '} + components + + )} + {'findings' in module.stats && ( + + + {module.stats.findings} + {' '} + findings + {module.stats.critical > 0 && ( + + ({module.stats.critical} critical) + + )} + + )} +
              + + ); + })} +
              + + {/* Quick Actions */} +
              +

              Quick Actions

              +
              + + + Ask Legal Question + + + + Run Security Scan + + + + Export Report + + + + Manage Consents + +
              +
              +
              +
              + ); +} diff --git a/breakpilot-compliance-sdk/apps/admin-dashboard/src/app/providers.tsx b/breakpilot-compliance-sdk/apps/admin-dashboard/src/app/providers.tsx new file mode 100644 index 0000000..f8b4de7 --- /dev/null +++ b/breakpilot-compliance-sdk/apps/admin-dashboard/src/app/providers.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { ComplianceProvider } from '@breakpilot/compliance-sdk-react'; + +export function Providers({ children }: { children: React.ReactNode }) { + const apiEndpoint = process.env.NEXT_PUBLIC_API_ENDPOINT || 'http://localhost:8080/api/v1'; + const apiKey = process.env.NEXT_PUBLIC_API_KEY || ''; + const tenantId = process.env.NEXT_PUBLIC_TENANT_ID || 'default'; + + return ( + + {children} + + ); +} diff --git a/breakpilot-compliance-sdk/apps/admin-dashboard/src/app/rag/page.tsx b/breakpilot-compliance-sdk/apps/admin-dashboard/src/app/rag/page.tsx new file mode 100644 index 0000000..8e9433b --- /dev/null +++ b/breakpilot-compliance-sdk/apps/admin-dashboard/src/app/rag/page.tsx @@ -0,0 +1,245 @@ +'use client'; + +import { useState } from 'react'; +import { useRAG } from '@breakpilot/compliance-sdk-react'; +import Link from 'next/link'; +import { + ArrowLeft, + Search, + MessageSquare, + FileText, + Upload, + Send, + Loader2, + BookOpen, + Scale, +} from 'lucide-react'; + +export default function RAGPage() { + const { search, ask, isSearching, isAsking, searchResults, chatHistory } = + useRAG(); + const [query, setQuery] = useState(''); + const [mode, setMode] = useState<'search' | 'chat'>('chat'); + const [results, setResults] = useState([]); + const [answer, setAnswer] = useState(''); + + const regulations = [ + { id: 'dsgvo', name: 'DSGVO', chunks: 99 }, + { id: 'ai-act', name: 'AI Act', chunks: 85 }, + { id: 'nis2', name: 'NIS2', chunks: 46 }, + { id: 'eprivacy', name: 'ePrivacy', chunks: 32 }, + { id: 'tdddg', name: 'TDDDG', chunks: 28 }, + { id: 'cra', name: 'CRA', chunks: 41 }, + ]; + + const handleSearch = async () => { + if (!query.trim()) return; + + if (mode === 'search') { + const res = await search(query); + setResults(res || []); + } else { + const res = await ask(query); + setAnswer(res?.answer || ''); + setResults(res?.sources || []); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSearch(); + } + }; + + return ( +
              + {/* Header */} +
              +
              +
              +
              + + + +
              +
              + +
              +
              +

              Legal Assistant

              +

              + AI-powered regulatory knowledge base +

              +
              +
              +
              +
              + + +
              +
              +
              +
              + + {/* Main Content */} +
              + {/* Sidebar - Regulations */} + + + {/* Chat/Search Area */} +
              + {/* Results Area */} +
              + {mode === 'chat' && answer && ( +
              +
              +
              + {answer} +
              +
              + {results.length > 0 && ( +
              +

              Sources

              +
              + {results.map((source, i) => ( +
              +
              {source.regulation}
              +
              + {source.text?.substring(0, 200)}... +
              +
              + ))} +
              +
              + )} +
              + )} + + {mode === 'search' && results.length > 0 && ( +
              + {results.map((result, i) => ( +
              +
              + + {result.regulation} + + Score: {(result.score * 100).toFixed(0)}% + +
              +

              + {result.text} +

              +
              + ))} +
              + )} + + {!answer && results.length === 0 && ( +
              +
              + +

              + Ask a Legal Question +

              +

              + Search through 21 indexed regulations including DSGVO, AI + Act, NIS2, and more. Get AI-powered answers with source + references. +

              +
              +
              + )} +
              + + {/* Input Area */} +
              +
              +
              + +
              + + ${this.error ? `
              ${this.error}
              ` : ''} + + + + +

              ${t.disclaimer}

              +
              + ` + + // Bind events + const form = this.shadow.getElementById('dsr-form') as HTMLFormElement + form.onsubmit = this.handleSubmit + + const nameInput = this.shadow.getElementById('name-input') as HTMLInputElement + nameInput.oninput = e => { + this.name = (e.target as HTMLInputElement).value + } + + const emailInput = this.shadow.getElementById('email-input') as HTMLInputElement + emailInput.oninput = e => { + this.email = (e.target as HTMLInputElement).value + } + + const infoInput = this.shadow.getElementById('info-input') as HTMLTextAreaElement + infoInput.oninput = e => { + this.additionalInfo = (e.target as HTMLTextAreaElement).value + } + + // Bind radio buttons + this.shadow.querySelectorAll('input[name="dsrType"]').forEach(radio => { + radio.onchange = () => { + this.handleTypeSelect(radio.value as DSRRequestType) + } + }) + } + + private renderSuccess(styles: string): void { + const t = this.t + + this.shadow.innerHTML = ` + +
              +
              +
              +

              ${t.successTitle}

              +

              + ${t.successMessage} ${this.email}. +

              +
              +
              + ` + } +} + +// Register the custom element +if (typeof customElements !== 'undefined') { + customElements.define('breakpilot-dsr-portal', DSRPortalElement) +} diff --git a/breakpilot-compliance-sdk/packages/vanilla/src/web-components/index.ts b/breakpilot-compliance-sdk/packages/vanilla/src/web-components/index.ts new file mode 100644 index 0000000..d2ca088 --- /dev/null +++ b/breakpilot-compliance-sdk/packages/vanilla/src/web-components/index.ts @@ -0,0 +1,13 @@ +/** + * BreakPilot Compliance SDK - Web Components + * + * Available components: + * - + * - + * - + */ + +export { BreakPilotElement, COMMON_STYLES } from './base' +export { ConsentBannerElement } from './consent-banner' +export { DSRPortalElement } from './dsr-portal' +export { ComplianceScoreElement } from './compliance-score' diff --git a/breakpilot-compliance-sdk/packages/vanilla/tsconfig.json b/breakpilot-compliance-sdk/packages/vanilla/tsconfig.json new file mode 100644 index 0000000..f6bbb79 --- /dev/null +++ b/breakpilot-compliance-sdk/packages/vanilla/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/breakpilot-compliance-sdk/packages/vanilla/tsup.config.ts b/breakpilot-compliance-sdk/packages/vanilla/tsup.config.ts new file mode 100644 index 0000000..f6764e6 --- /dev/null +++ b/breakpilot-compliance-sdk/packages/vanilla/tsup.config.ts @@ -0,0 +1,32 @@ +import { defineConfig } from 'tsup' + +export default defineConfig([ + // Main bundle with web components + { + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + dts: true, + splitting: false, + sourcemap: true, + clean: true, + treeshake: true, + }, + // Embed script (IIFE for + + diff --git a/breakpilot-drive/index.html b/breakpilot-drive/index.html new file mode 100644 index 0000000..08039aa --- /dev/null +++ b/breakpilot-drive/index.html @@ -0,0 +1,239 @@ + + + + + + Breakpilot Drive - Lernspiel + + + +
              +

              Breakpilot Drive

              +

              Lernspiel fuer Klasse 2-6

              + +
              +
              🚧
              +

              Unity Build in Entwicklung

              +

              + Diese Seite wird durch den Unity WebGL Build ersetzt,
              + sobald das Spiel fertig ist. +

              + +
              + + +
              +
              + +
              +
              +

              🚗 Endless Runner

              +

              Steuere dein Auto durch 3 Spuren, weiche Hindernissen aus und sammle Punkte!

              +
              +
              +

              📚 Quiz-Integration

              +

              Beantworte Fragen waehrend der Fahrt oder nimm dir Zeit bei Denkaufgaben.

              +
              +
              +

              📈 Adaptiv

              +

              Die Schwierigkeit passt sich automatisch an dein Breakpilot-Lernniveau an.

              +
              +
              +

              🔊 Barrierefrei

              +

              Audio-Version fuer Hoerspiel-Modus mit raeumlichen Sound-Cues.

              +
              +
              + +
              + Pruefe Backend-Verbindung... +
              + +
              +

              Breakpilot Drive © 2026 - Lernen mit Spass

              +
              +
              + + + + diff --git a/breakpilot-drive/nginx.conf b/breakpilot-drive/nginx.conf new file mode 100644 index 0000000..cfc857c --- /dev/null +++ b/breakpilot-drive/nginx.conf @@ -0,0 +1,76 @@ +# ============================================== +# Nginx Konfiguration fuer Unity WebGL +# ============================================== +# Optimiert fuer grosse WASM-Dateien und +# korrektes CORS fuer API-Aufrufe + +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Standard MIME-Types einbinden + WebGL-spezifische hinzufuegen + include /etc/nginx/mime.types; + + # WebGL-spezifische MIME-Types (zusaetzlich) + types { + application/wasm wasm; + } + + # Gzip Kompression fuer grosse Dateien + gzip on; + gzip_vary on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types + application/javascript + application/wasm + application/json + text/plain + text/css + text/html; + gzip_min_length 1000; + + # Brotli-komprimierte Unity-Dateien (falls vorhanden) + location ~ \.br$ { + gzip off; + add_header Content-Encoding br; + default_type application/octet-stream; + } + + # Gzip-komprimierte Unity-Dateien + location ~ \.gz$ { + gzip off; + add_header Content-Encoding gzip; + default_type application/octet-stream; + } + + # Caching fuer statische Assets (1 Jahr) + location ~* \.(data|wasm|js|css|png|jpg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + add_header Access-Control-Allow-Origin "*"; + } + + # Health-Check Endpunkt + location = /health { + default_type application/json; + alias /usr/share/nginx/html/health.json; + } + + location = /health.json { + access_log off; + default_type application/json; + } + + # SPA Routing - alle Requests auf index.html + location / { + # CORS Headers + add_header Access-Control-Allow-Origin "*" always; + add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always; + add_header Access-Control-Allow-Headers "Content-Type, Authorization" always; + + try_files $uri $uri/ /index.html; + } +} diff --git a/consent-sdk/.gitignore b/consent-sdk/.gitignore new file mode 100644 index 0000000..a02dd45 --- /dev/null +++ b/consent-sdk/.gitignore @@ -0,0 +1,26 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* + +# Test coverage +coverage/ + +# Temporary files +*.tmp +*.temp diff --git a/consent-sdk/LICENSE b/consent-sdk/LICENSE new file mode 100644 index 0000000..3f6a9e4 --- /dev/null +++ b/consent-sdk/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 BreakPilot GmbH + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/consent-sdk/README.md b/consent-sdk/README.md new file mode 100644 index 0000000..7954c94 --- /dev/null +++ b/consent-sdk/README.md @@ -0,0 +1,243 @@ +# @breakpilot/consent-sdk + +DSGVO/TTDSG-konformes Consent Management SDK fuer Web, PWA und Mobile Apps. + +## Features + +- DSGVO, TTDSG und ePrivacy-Richtlinie konform +- IAB TCF 2.2 Unterstuetzung +- Google Consent Mode v2 Integration +- Plattformuebergreifend (Web, PWA, iOS, Android, Flutter, React Native) +- Typsicher (TypeScript) +- Barrierefreundlich (WCAG 2.1 AA) +- Open Source (Apache 2.0) + +## Installation + +```bash +npm install @breakpilot/consent-sdk +``` + +## Quick Start + +### Vanilla JavaScript + +```javascript +import { ConsentManager } from '@breakpilot/consent-sdk'; + +const consent = new ConsentManager({ + apiEndpoint: 'https://consent.example.com/api/v1', + siteId: 'site_abc123', + language: 'de', +}); + +await consent.init(); + +// Consent pruefen +if (consent.hasConsent('analytics')) { + // Analytics laden +} + +// Events +consent.on('change', (newConsent) => { + console.log('Consent changed:', newConsent); +}); +``` + +### React + +```tsx +import { ConsentProvider, useConsent, ConsentBanner, ConsentGate } from '@breakpilot/consent-sdk/react'; + +const config = { + apiEndpoint: 'https://consent.example.com/api/v1', + siteId: 'site_abc123', +}; + +function App() { + return ( + + + + + ); +} + +function AnalyticsSection() { + return ( + Analytics erfordert Ihre Zustimmung.

              } + > + +
              + ); +} + +function SettingsButton() { + const { showSettings } = useConsent(); + return ; +} +``` + +## Script Blocking + +Verwenden Sie `data-consent` um Skripte zu blockieren: + +```html + + + + + + + + +``` + +## Consent-Kategorien + +| Kategorie | Beschreibung | Einwilligung | +|-----------|--------------|--------------| +| `essential` | Technisch notwendig | Nicht erforderlich | +| `functional` | Personalisierung | Erforderlich | +| `analytics` | Nutzungsanalyse | Erforderlich | +| `marketing` | Werbung | Erforderlich | +| `social` | Social Media | Erforderlich | + +## Konfiguration + +```typescript +const config: ConsentConfig = { + // Pflicht + apiEndpoint: 'https://consent.example.com/api/v1', + siteId: 'site_abc123', + + // Sprache + language: 'de', + fallbackLanguage: 'en', + + // UI + ui: { + position: 'bottom', // 'bottom' | 'top' | 'center' + layout: 'modal', // 'bar' | 'modal' | 'floating' + theme: 'auto', // 'light' | 'dark' | 'auto' + zIndex: 999999, + }, + + // Verhalten + consent: { + required: true, + rejectAllVisible: true, // "Alle ablehnen" Button + acceptAllVisible: true, // "Alle akzeptieren" Button + granularControl: true, // Kategorien einzeln waehlbar + rememberDays: 365, // Speicherdauer + recheckAfterDays: 180, // Erneut fragen nach X Tagen + }, + + // Callbacks + onConsentChange: (consent) => { + console.log('Consent:', consent); + }, + + // Debug + debug: process.env.NODE_ENV === 'development', +}; +``` + +## API + +### ConsentManager + +```typescript +// Initialisieren +await consent.init(); + +// Consent pruefen +consent.hasConsent('analytics'); // boolean +consent.hasVendorConsent('google'); // boolean + +// Consent abrufen +consent.getConsent(); // ConsentState | null + +// Consent setzen +await consent.setConsent({ + essential: true, + analytics: true, + marketing: false, +}); + +// Aktionen +await consent.acceptAll(); +await consent.rejectAll(); +await consent.revokeAll(); + +// Banner +consent.showBanner(); +consent.hideBanner(); +consent.showSettings(); +consent.needsConsent(); // boolean + +// Events +consent.on('change', callback); +consent.on('banner_show', callback); +consent.on('banner_hide', callback); +consent.off('change', callback); + +// Export (DSGVO Art. 20) +const data = await consent.exportConsent(); +``` + +### React Hooks + +```typescript +// Basis-Hook +const { + consent, + isLoading, + isBannerVisible, + needsConsent, + hasConsent, + acceptAll, + rejectAll, + showBanner, + showSettings, +} = useConsent(); + +// Mit Kategorie +const { allowed } = useConsent('analytics'); + +// Manager-Zugriff +const manager = useConsentManager(); +``` + +## Rechtliche Compliance + +Dieses SDK erfuellt: + +- **DSGVO** (EU 2016/679) - Art. 4, 6, 7, 12, 13, 17, 20 +- **TTDSG** (Deutschland) - § 25 +- **ePrivacy-Richtlinie** (2002/58/EG) - Art. 5 +- **Planet49-Urteil** (EuGH C-673/17) +- **BGH Cookie-Einwilligung II** (2023) +- **DSK Orientierungshilfe Telemedien** + +## Lizenz + +Apache 2.0 - Open Source, kommerziell nutzbar. + +``` +Copyright 2026 BreakPilot GmbH + +Licensed under the Apache License, Version 2.0 +``` diff --git a/consent-sdk/package-lock.json b/consent-sdk/package-lock.json new file mode 100644 index 0000000..de8a4d3 --- /dev/null +++ b/consent-sdk/package-lock.json @@ -0,0 +1,5403 @@ +{ + "name": "@breakpilot/consent-sdk", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@breakpilot/consent-sdk", + "version": "1.0.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "^20.11.0", + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "@typescript-eslint/eslint-plugin": "^6.19.0", + "@typescript-eslint/parser": "^6.19.0", + "@vitest/coverage-v8": "^1.2.1", + "eslint": "^8.56.0", + "jsdom": "^28.0.0", + "tsup": "^8.0.1", + "typescript": "^5.3.3", + "vitest": "^1.2.1", + "vue": "^3.5.27" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0", + "vue": ">=3.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", + "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.4" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.7.tgz", + "integrity": "sha512-8CO/UQ4tzDd7ula+/CVimJIVWez99UJlbMyIgk8xOnhAVPKLnBZmUFYVgugS441v2ZqUq5EnSh6B0Ua0liSFAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.26", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.26.tgz", + "integrity": "sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.11.0.tgz", + "integrity": "sha512-wO3vd8nsEHdumsXrjGO/v4p6irbg7hy9kvIeR6i2AwylZSk4HJdWgL0FNaVquW1+AweJcdvU1IEpuIWk/WaPnA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.10", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", + "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.31", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.31.tgz", + "integrity": "sha512-5jsi0wpncvTD33Sh1UCgacK37FFwDn+EG7wCmEvs62fCvBL+n8/76cAYDok21NF6+jaVWIqKwCZyX7Vbu8eB3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz", + "integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.5.1", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/type-utils": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.4", + "natural-compare": "^1.4.0", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha", + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", + "integrity": "sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz", + "integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "6.21.0", + "@typescript-eslint/utils": "6.21.0", + "debug": "^4.3.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitest/coverage-v8": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-1.6.1.tgz", + "integrity": "sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.4", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.4", + "istanbul-reports": "^3.1.6", + "magic-string": "^0.30.5", + "magicast": "^0.3.3", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "test-exclude": "^6.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "1.6.1" + } + }, + "node_modules/@vitest/expect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", + "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", + "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "1.6.1", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/runner/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/runner/node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", + "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", + "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", + "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.27.tgz", + "integrity": "sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/shared": "3.5.27", + "entities": "^7.0.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.27.tgz", + "integrity": "sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.27.tgz", + "integrity": "sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "@vue/compiler-core": "3.5.27", + "@vue/compiler-dom": "3.5.27", + "@vue/compiler-ssr": "3.5.27", + "@vue/shared": "3.5.27", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.27.tgz", + "integrity": "sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.27.tgz", + "integrity": "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.27.tgz", + "integrity": "sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.27", + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.27.tgz", + "integrity": "sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.27", + "@vue/runtime-core": "3.5.27", + "@vue/shared": "3.5.27", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.27.tgz", + "integrity": "sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.27", + "@vue/shared": "3.5.27" + }, + "peerDependencies": { + "vue": "3.5.27" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.27.tgz", + "integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.0.0.tgz", + "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^5.3.7", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.20.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, + "node_modules/lru-cache": { + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.22", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.22.tgz", + "integrity": "sha512-nqpKFC53CgopKPjT6Wfb6tpIcZXHcI6G37hesvikhx0EmUGPkZrujRyAjgnmp1SHNgpQfKVanZ+KfpANFt2Hxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.22" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.22", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.22.tgz", + "integrity": "sha512-KgbTDC5wzlL6j/x6np6wCnDSMUq4kucHNm00KXPbfNzmllCmtmvtykJHfmgdHntwIeupW04y8s1N/43S1PkQDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-api-utils": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", + "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/tsup/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici": { + "version": "7.20.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.20.0.tgz", + "integrity": "sha512-MJZrkjyd7DeC+uPZh+5/YaMDxFiiEEaDgbUSVMXayofAkDWF1088CDo+2RPg7B1BuS1qf1vgNE7xqwPxE0DuSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", + "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/vitest": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", + "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "1.6.1", + "@vitest/runner": "1.6.1", + "@vitest/snapshot": "1.6.1", + "@vitest/spy": "1.6.1", + "@vitest/utils": "1.6.1", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.1", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.1", + "@vitest/ui": "1.6.1", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", + "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.27", + "@vue/compiler-sfc": "3.5.27", + "@vue/runtime-dom": "3.5.27", + "@vue/server-renderer": "3.5.27", + "@vue/shared": "3.5.27" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz", + "integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/consent-sdk/package.json b/consent-sdk/package.json new file mode 100644 index 0000000..6d4c317 --- /dev/null +++ b/consent-sdk/package.json @@ -0,0 +1,94 @@ +{ + "name": "@breakpilot/consent-sdk", + "version": "1.0.0", + "description": "DSGVO/TTDSG-konformes Consent Management SDK für Web, PWA und Mobile Apps", + "main": "dist/index.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.esm.js", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./react": { + "import": "./dist/react/index.esm.js", + "require": "./dist/react/index.js", + "types": "./dist/react/index.d.ts" + }, + "./vue": { + "import": "./dist/vue/index.esm.js", + "require": "./dist/vue/index.js", + "types": "./dist/vue/index.d.ts" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest", + "test:coverage": "vitest --coverage", + "lint": "eslint src --ext .ts,.tsx", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "consent", + "cookie", + "gdpr", + "dsgvo", + "ttdsg", + "privacy", + "cookie-banner", + "consent-management", + "tcf" + ], + "author": "BreakPilot GmbH", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/breakpilot/consent-sdk.git" + }, + "homepage": "https://github.com/breakpilot/consent-sdk#readme", + "bugs": { + "url": "https://github.com/breakpilot/consent-sdk/issues" + }, + "devDependencies": { + "@types/node": "^20.11.0", + "@types/react": "^18.2.48", + "@types/react-dom": "^18.2.18", + "@typescript-eslint/eslint-plugin": "^6.19.0", + "@typescript-eslint/parser": "^6.19.0", + "@vitest/coverage-v8": "^1.2.1", + "eslint": "^8.56.0", + "jsdom": "^28.0.0", + "tsup": "^8.0.1", + "typescript": "^5.3.3", + "vitest": "^1.2.1", + "vue": "^3.5.27" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0", + "vue": ">=3.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "vue": { + "optional": true + } + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/consent-sdk/src/angular/index.ts b/consent-sdk/src/angular/index.ts new file mode 100644 index 0000000..6c4ece4 --- /dev/null +++ b/consent-sdk/src/angular/index.ts @@ -0,0 +1,509 @@ +/** + * Angular Integration fuer @breakpilot/consent-sdk + * + * @example + * ```typescript + * // app.module.ts + * import { ConsentModule } from '@breakpilot/consent-sdk/angular'; + * + * @NgModule({ + * imports: [ + * ConsentModule.forRoot({ + * apiEndpoint: 'https://consent.example.com/api/v1', + * siteId: 'site_abc123', + * }), + * ], + * }) + * export class AppModule {} + * ``` + */ + +// ============================================================================= +// NOTE: Angular SDK Structure +// ============================================================================= +// +// Angular hat ein komplexeres Build-System (ngc, ng-packagr). +// Diese Datei definiert die Schnittstelle - fuer Production muss ein +// separates Angular Library Package erstellt werden: +// +// ng generate library @breakpilot/consent-sdk-angular +// +// Die folgende Implementation ist fuer direkten Import vorgesehen. +// ============================================================================= + +import { ConsentManager } from '../core/ConsentManager'; +import type { + ConsentConfig, + ConsentState, + ConsentCategory, + ConsentCategories, +} from '../types'; + +// ============================================================================= +// Angular Service Interface +// ============================================================================= + +/** + * ConsentService Interface fuer Angular DI + * + * @example + * ```typescript + * @Component({...}) + * export class MyComponent { + * constructor(private consent: ConsentService) { + * if (this.consent.hasConsent('analytics')) { + * // Analytics laden + * } + * } + * } + * ``` + */ +export interface IConsentService { + /** Initialisiert? */ + readonly isInitialized: boolean; + + /** Laedt noch? */ + readonly isLoading: boolean; + + /** Banner sichtbar? */ + readonly isBannerVisible: boolean; + + /** Aktueller Consent-Zustand */ + readonly consent: ConsentState | null; + + /** Muss Consent eingeholt werden? */ + readonly needsConsent: boolean; + + /** Prueft Consent fuer Kategorie */ + hasConsent(category: ConsentCategory): boolean; + + /** Alle akzeptieren */ + acceptAll(): Promise; + + /** Alle ablehnen */ + rejectAll(): Promise; + + /** Auswahl speichern */ + saveSelection(categories: Partial): Promise; + + /** Banner anzeigen */ + showBanner(): void; + + /** Banner ausblenden */ + hideBanner(): void; + + /** Einstellungen oeffnen */ + showSettings(): void; +} + +// ============================================================================= +// ConsentService Implementation +// ============================================================================= + +/** + * ConsentService - Angular Service Wrapper + * + * Diese Klasse kann als Angular Service registriert werden: + * + * @example + * ```typescript + * // consent.service.ts + * import { Injectable } from '@angular/core'; + * import { ConsentServiceBase } from '@breakpilot/consent-sdk/angular'; + * + * @Injectable({ providedIn: 'root' }) + * export class ConsentService extends ConsentServiceBase { + * constructor() { + * super({ + * apiEndpoint: environment.consentApiEndpoint, + * siteId: environment.siteId, + * }); + * } + * } + * ``` + */ +export class ConsentServiceBase implements IConsentService { + private manager: ConsentManager; + private _consent: ConsentState | null = null; + private _isInitialized = false; + private _isLoading = true; + private _isBannerVisible = false; + + // Callbacks fuer Angular Change Detection + private changeCallbacks: Array<(consent: ConsentState) => void> = []; + private bannerShowCallbacks: Array<() => void> = []; + private bannerHideCallbacks: Array<() => void> = []; + + constructor(config: ConsentConfig) { + this.manager = new ConsentManager(config); + this.setupEventListeners(); + this.initialize(); + } + + // --------------------------------------------------------------------------- + // Getters + // --------------------------------------------------------------------------- + + get isInitialized(): boolean { + return this._isInitialized; + } + + get isLoading(): boolean { + return this._isLoading; + } + + get isBannerVisible(): boolean { + return this._isBannerVisible; + } + + get consent(): ConsentState | null { + return this._consent; + } + + get needsConsent(): boolean { + return this.manager.needsConsent(); + } + + // --------------------------------------------------------------------------- + // Methods + // --------------------------------------------------------------------------- + + hasConsent(category: ConsentCategory): boolean { + return this.manager.hasConsent(category); + } + + async acceptAll(): Promise { + await this.manager.acceptAll(); + } + + async rejectAll(): Promise { + await this.manager.rejectAll(); + } + + async saveSelection(categories: Partial): Promise { + await this.manager.setConsent(categories); + this.manager.hideBanner(); + } + + showBanner(): void { + this.manager.showBanner(); + } + + hideBanner(): void { + this.manager.hideBanner(); + } + + showSettings(): void { + this.manager.showSettings(); + } + + // --------------------------------------------------------------------------- + // Change Detection Support + // --------------------------------------------------------------------------- + + /** + * Registriert Callback fuer Consent-Aenderungen + * (fuer Angular Change Detection) + */ + onConsentChange(callback: (consent: ConsentState) => void): () => void { + this.changeCallbacks.push(callback); + return () => { + const index = this.changeCallbacks.indexOf(callback); + if (index > -1) { + this.changeCallbacks.splice(index, 1); + } + }; + } + + /** + * Registriert Callback wenn Banner angezeigt wird + */ + onBannerShow(callback: () => void): () => void { + this.bannerShowCallbacks.push(callback); + return () => { + const index = this.bannerShowCallbacks.indexOf(callback); + if (index > -1) { + this.bannerShowCallbacks.splice(index, 1); + } + }; + } + + /** + * Registriert Callback wenn Banner ausgeblendet wird + */ + onBannerHide(callback: () => void): () => void { + this.bannerHideCallbacks.push(callback); + return () => { + const index = this.bannerHideCallbacks.indexOf(callback); + if (index > -1) { + this.bannerHideCallbacks.splice(index, 1); + } + }; + } + + // --------------------------------------------------------------------------- + // Internal + // --------------------------------------------------------------------------- + + private setupEventListeners(): void { + this.manager.on('change', (consent) => { + this._consent = consent; + this.changeCallbacks.forEach((cb) => cb(consent)); + }); + + this.manager.on('banner_show', () => { + this._isBannerVisible = true; + this.bannerShowCallbacks.forEach((cb) => cb()); + }); + + this.manager.on('banner_hide', () => { + this._isBannerVisible = false; + this.bannerHideCallbacks.forEach((cb) => cb()); + }); + } + + private async initialize(): Promise { + try { + await this.manager.init(); + this._consent = this.manager.getConsent(); + this._isInitialized = true; + this._isBannerVisible = this.manager.isBannerVisible(); + } catch (error) { + console.error('Failed to initialize ConsentManager:', error); + } finally { + this._isLoading = false; + } + } +} + +// ============================================================================= +// Angular Module Configuration +// ============================================================================= + +/** + * Konfiguration fuer ConsentModule.forRoot() + */ +export interface ConsentModuleConfig extends ConsentConfig {} + +/** + * Token fuer Dependency Injection + * Verwendung mit Angular @Inject(): + * + * @example + * ```typescript + * constructor(@Inject(CONSENT_CONFIG) private config: ConsentConfig) {} + * ``` + */ +export const CONSENT_CONFIG = 'CONSENT_CONFIG'; +export const CONSENT_SERVICE = 'CONSENT_SERVICE'; + +// ============================================================================= +// Factory Functions fuer Angular DI +// ============================================================================= + +/** + * Factory fuer ConsentService + * + * @example + * ```typescript + * // app.module.ts + * providers: [ + * { provide: CONSENT_CONFIG, useValue: { apiEndpoint: '...', siteId: '...' } }, + * { provide: CONSENT_SERVICE, useFactory: consentServiceFactory, deps: [CONSENT_CONFIG] }, + * ] + * ``` + */ +export function consentServiceFactory(config: ConsentConfig): ConsentServiceBase { + return new ConsentServiceBase(config); +} + +// ============================================================================= +// Angular Module Definition (Template) +// ============================================================================= + +/** + * ConsentModule - Angular Module + * + * Dies ist eine Template-Definition. Fuer echte Angular-Nutzung + * muss ein separates Angular Library Package erstellt werden. + * + * @example + * ```typescript + * // In einem Angular Library Package: + * @NgModule({ + * declarations: [ConsentBannerComponent, ConsentGateDirective], + * exports: [ConsentBannerComponent, ConsentGateDirective], + * }) + * export class ConsentModule { + * static forRoot(config: ConsentModuleConfig): ModuleWithProviders { + * return { + * ngModule: ConsentModule, + * providers: [ + * { provide: CONSENT_CONFIG, useValue: config }, + * { provide: CONSENT_SERVICE, useFactory: consentServiceFactory, deps: [CONSENT_CONFIG] }, + * ], + * }; + * } + * } + * ``` + */ +export const ConsentModuleDefinition = { + /** + * Providers fuer Root-Module + */ + forRoot: (config: ConsentModuleConfig) => ({ + provide: CONSENT_CONFIG, + useValue: config, + }), +}; + +// ============================================================================= +// Component Templates (fuer Angular Library) +// ============================================================================= + +/** + * ConsentBannerComponent Template + * + * Fuer Angular Library Implementation: + * + * @example + * ```typescript + * @Component({ + * selector: 'bp-consent-banner', + * template: CONSENT_BANNER_TEMPLATE, + * styles: [CONSENT_BANNER_STYLES], + * }) + * export class ConsentBannerComponent { + * constructor(public consent: ConsentService) {} + * } + * ``` + */ +export const CONSENT_BANNER_TEMPLATE = ` + +`; + +/** + * ConsentGateDirective Template + * + * @example + * ```typescript + * @Directive({ + * selector: '[bpConsentGate]', + * }) + * export class ConsentGateDirective implements OnInit, OnDestroy { + * @Input('bpConsentGate') category!: ConsentCategory; + * + * private unsubscribe?: () => void; + * + * constructor( + * private templateRef: TemplateRef, + * private viewContainer: ViewContainerRef, + * private consent: ConsentService + * ) {} + * + * ngOnInit() { + * this.updateView(); + * this.unsubscribe = this.consent.onConsentChange(() => this.updateView()); + * } + * + * ngOnDestroy() { + * this.unsubscribe?.(); + * } + * + * private updateView() { + * if (this.consent.hasConsent(this.category)) { + * this.viewContainer.createEmbeddedView(this.templateRef); + * } else { + * this.viewContainer.clear(); + * } + * } + * } + * ``` + */ +export const CONSENT_GATE_USAGE = ` + +
              + +
              + + + + + + +

              Bitte akzeptieren Sie Marketing-Cookies.

              +
              +`; + +// ============================================================================= +// RxJS Observable Wrapper (Optional) +// ============================================================================= + +/** + * RxJS Observable Wrapper fuer ConsentService + * + * Fuer Projekte die RxJS bevorzugen: + * + * @example + * ```typescript + * import { BehaviorSubject, Observable } from 'rxjs'; + * + * export class ConsentServiceRx extends ConsentServiceBase { + * private consentSubject = new BehaviorSubject(null); + * private bannerVisibleSubject = new BehaviorSubject(false); + * + * consent$ = this.consentSubject.asObservable(); + * isBannerVisible$ = this.bannerVisibleSubject.asObservable(); + * + * constructor(config: ConsentConfig) { + * super(config); + * this.onConsentChange((c) => this.consentSubject.next(c)); + * this.onBannerShow(() => this.bannerVisibleSubject.next(true)); + * this.onBannerHide(() => this.bannerVisibleSubject.next(false)); + * } + * } + * ``` + */ + +// ============================================================================= +// Exports +// ============================================================================= + +export type { ConsentConfig, ConsentState, ConsentCategory, ConsentCategories }; diff --git a/consent-sdk/src/core/ConsentAPI.test.ts b/consent-sdk/src/core/ConsentAPI.test.ts new file mode 100644 index 0000000..9fa39eb --- /dev/null +++ b/consent-sdk/src/core/ConsentAPI.test.ts @@ -0,0 +1,312 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ConsentAPI } from './ConsentAPI'; +import type { ConsentConfig, ConsentState } from '../types'; + +describe('ConsentAPI', () => { + let api: ConsentAPI; + const mockConfig: ConsentConfig = { + apiEndpoint: 'https://api.example.com/', + siteId: 'test-site', + debug: false, + }; + + const mockConsent: ConsentState = { + categories: { + essential: true, + functional: true, + analytics: false, + marketing: false, + social: false, + }, + vendors: {}, + timestamp: '2024-01-15T10:00:00.000Z', + version: '1.0.0', + }; + + beforeEach(() => { + api = new ConsentAPI(mockConfig); + vi.clearAllMocks(); + }); + + describe('constructor', () => { + it('should strip trailing slash from apiEndpoint', () => { + expect(api).toBeDefined(); + }); + }); + + describe('saveConsent', () => { + it('should POST consent to the API', async () => { + const mockResponse = { + consentId: 'consent-123', + timestamp: '2024-01-15T10:00:00.000Z', + expiresAt: '2025-01-15T10:00:00.000Z', + }; + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve(mockResponse), + } as Response); + + const result = await api.saveConsent({ + siteId: 'test-site', + deviceFingerprint: 'fp_123', + consent: mockConsent, + }); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch).toHaveBeenCalledWith( + 'https://api.example.com/consent', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + credentials: 'include', + }) + ); + + expect(result.consentId).toBe('consent-123'); + }); + + it('should include metadata in the request', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ consentId: '123' }), + } as Response); + + await api.saveConsent({ + siteId: 'test-site', + deviceFingerprint: 'fp_123', + consent: mockConsent, + }); + + const call = vi.mocked(fetch).mock.calls[0]; + const body = JSON.parse(call[1]?.body as string); + + expect(body.metadata).toBeDefined(); + expect(body.metadata.platform).toBe('web'); + }); + + it('should throw on non-ok response', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 500, + } as Response); + + await expect( + api.saveConsent({ + siteId: 'test-site', + deviceFingerprint: 'fp_123', + consent: mockConsent, + }) + ).rejects.toThrow('Failed to save consent: 500'); + }); + + it('should include signature headers', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ consentId: '123' }), + } as Response); + + await api.saveConsent({ + siteId: 'test-site', + deviceFingerprint: 'fp_123', + consent: mockConsent, + }); + + const call = vi.mocked(fetch).mock.calls[0]; + const headers = call[1]?.headers as Record; + + expect(headers['X-Consent-Timestamp']).toBeDefined(); + expect(headers['X-Consent-Signature']).toMatch(/^sha256=/); + }); + }); + + describe('getConsent', () => { + it('should GET consent from the API', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ consent: mockConsent }), + } as Response); + + const result = await api.getConsent('test-site', 'fp_123'); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/consent?siteId=test-site&deviceFingerprint=fp_123'), + expect.objectContaining({ + headers: expect.any(Object), + credentials: 'include', + }) + ); + + expect(result?.categories.essential).toBe(true); + }); + + it('should return null on 404', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 404, + } as Response); + + const result = await api.getConsent('test-site', 'fp_123'); + + expect(result).toBeNull(); + }); + + it('should throw on other errors', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 500, + } as Response); + + await expect(api.getConsent('test-site', 'fp_123')).rejects.toThrow( + 'Failed to get consent: 500' + ); + }); + }); + + describe('revokeConsent', () => { + it('should DELETE consent from the API', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 204, + } as Response); + + await api.revokeConsent('consent-123'); + + expect(fetch).toHaveBeenCalledWith( + 'https://api.example.com/consent/consent-123', + expect.objectContaining({ + method: 'DELETE', + }) + ); + }); + + it('should throw on non-ok response', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 404, + } as Response); + + await expect(api.revokeConsent('consent-123')).rejects.toThrow( + 'Failed to revoke consent: 404' + ); + }); + }); + + describe('getSiteConfig', () => { + it('should GET site configuration', async () => { + const mockSiteConfig = { + siteId: 'test-site', + name: 'Test Site', + categories: ['essential', 'analytics'], + }; + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve(mockSiteConfig), + } as Response); + + const result = await api.getSiteConfig('test-site'); + + expect(fetch).toHaveBeenCalledWith( + 'https://api.example.com/config/test-site', + expect.any(Object) + ); + + expect(result.siteId).toBe('test-site'); + }); + + it('should throw on error', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 404, + } as Response); + + await expect(api.getSiteConfig('unknown-site')).rejects.toThrow( + 'Failed to get site config: 404' + ); + }); + }); + + describe('exportConsent', () => { + it('should GET consent export for user', async () => { + const mockExport = { + userId: 'user-123', + consents: [mockConsent], + }; + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve(mockExport), + } as Response); + + const result = await api.exportConsent('user-123'); + + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/consent/export?userId=user-123'), + expect.any(Object) + ); + + expect(result).toEqual(mockExport); + }); + + it('should throw on error', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: false, + status: 403, + } as Response); + + await expect(api.exportConsent('user-123')).rejects.toThrow( + 'Failed to export consent: 403' + ); + }); + }); + + describe('network errors', () => { + it('should propagate fetch errors', async () => { + vi.mocked(fetch).mockRejectedValueOnce(new Error('Network error')); + + await expect( + api.saveConsent({ + siteId: 'test-site', + deviceFingerprint: 'fp_123', + consent: mockConsent, + }) + ).rejects.toThrow('Network error'); + }); + }); + + describe('debug mode', () => { + it('should log when debug is enabled', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const debugApi = new ConsentAPI({ + ...mockConfig, + debug: true, + }); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ consentId: '123' }), + } as Response); + + await debugApi.saveConsent({ + siteId: 'test-site', + deviceFingerprint: 'fp_123', + consent: mockConsent, + }); + + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/consent-sdk/src/core/ConsentAPI.ts b/consent-sdk/src/core/ConsentAPI.ts new file mode 100644 index 0000000..afc2d9a --- /dev/null +++ b/consent-sdk/src/core/ConsentAPI.ts @@ -0,0 +1,212 @@ +/** + * ConsentAPI - Kommunikation mit dem Consent-Backend + * + * Sendet Consent-Entscheidungen an das Backend zur + * revisionssicheren Speicherung. + */ + +import type { + ConsentConfig, + ConsentState, + ConsentAPIResponse, + SiteConfigResponse, +} from '../types'; + +/** + * Request-Payload fuer Consent-Speicherung + */ +interface SaveConsentRequest { + siteId: string; + userId?: string; + deviceFingerprint: string; + consent: ConsentState; + metadata?: { + userAgent?: string; + language?: string; + screenResolution?: string; + platform?: string; + appVersion?: string; + }; +} + +/** + * ConsentAPI - Backend-Kommunikation + */ +export class ConsentAPI { + private config: ConsentConfig; + private baseUrl: string; + + constructor(config: ConsentConfig) { + this.config = config; + this.baseUrl = config.apiEndpoint.replace(/\/$/, ''); + } + + /** + * Consent speichern + */ + async saveConsent(request: SaveConsentRequest): Promise { + const payload = { + ...request, + metadata: { + userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '', + language: typeof navigator !== 'undefined' ? navigator.language : '', + screenResolution: + typeof window !== 'undefined' + ? `${window.screen.width}x${window.screen.height}` + : '', + platform: 'web', + ...request.metadata, + }, + }; + + const response = await this.fetch('/consent', { + method: 'POST', + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Failed to save consent: ${response.status}`); + } + + return response.json(); + } + + /** + * Consent abrufen + */ + async getConsent( + siteId: string, + deviceFingerprint: string + ): Promise { + const params = new URLSearchParams({ + siteId, + deviceFingerprint, + }); + + const response = await this.fetch(`/consent?${params}`); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + throw new Error(`Failed to get consent: ${response.status}`); + } + + const data = await response.json(); + return data.consent; + } + + /** + * Consent widerrufen + */ + async revokeConsent(consentId: string): Promise { + const response = await this.fetch(`/consent/${consentId}`, { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error(`Failed to revoke consent: ${response.status}`); + } + } + + /** + * Site-Konfiguration abrufen + */ + async getSiteConfig(siteId: string): Promise { + const response = await this.fetch(`/config/${siteId}`); + + if (!response.ok) { + throw new Error(`Failed to get site config: ${response.status}`); + } + + return response.json(); + } + + /** + * Consent-Historie exportieren (DSGVO Art. 20) + */ + async exportConsent(userId: string): Promise { + const params = new URLSearchParams({ userId }); + const response = await this.fetch(`/consent/export?${params}`); + + if (!response.ok) { + throw new Error(`Failed to export consent: ${response.status}`); + } + + return response.json(); + } + + // =========================================================================== + // Internal Methods + // =========================================================================== + + /** + * Fetch mit Standard-Headers + */ + private async fetch( + path: string, + options: RequestInit = {} + ): Promise { + const url = `${this.baseUrl}${path}`; + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...this.getSignatureHeaders(), + ...(options.headers || {}), + }; + + try { + const response = await fetch(url, { + ...options, + headers, + credentials: 'include', + }); + + this.log(`${options.method || 'GET'} ${path}:`, response.status); + return response; + } catch (error) { + this.log('Fetch error:', error); + throw error; + } + } + + /** + * Signatur-Headers generieren (HMAC) + */ + private getSignatureHeaders(): Record { + const timestamp = Math.floor(Date.now() / 1000).toString(); + + // Einfache Signatur fuer Client-Side + // In Produktion: Server-seitige Validierung mit echtem HMAC + const signature = this.simpleHash(`${this.config.siteId}:${timestamp}`); + + return { + 'X-Consent-Timestamp': timestamp, + 'X-Consent-Signature': `sha256=${signature}`, + }; + } + + /** + * Einfache Hash-Funktion (djb2) + */ + private simpleHash(str: string): string { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = (hash * 33) ^ str.charCodeAt(i); + } + return (hash >>> 0).toString(16); + } + + /** + * Debug-Logging + */ + private log(...args: unknown[]): void { + if (this.config.debug) { + console.log('[ConsentAPI]', ...args); + } + } +} + +export default ConsentAPI; diff --git a/consent-sdk/src/core/ConsentManager.test.ts b/consent-sdk/src/core/ConsentManager.test.ts new file mode 100644 index 0000000..8e7be71 --- /dev/null +++ b/consent-sdk/src/core/ConsentManager.test.ts @@ -0,0 +1,605 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ConsentManager } from './ConsentManager'; +import type { ConsentConfig, ConsentState } from '../types'; + +describe('ConsentManager', () => { + let manager: ConsentManager; + const mockConfig: ConsentConfig = { + apiEndpoint: 'https://api.example.com', + siteId: 'test-site', + debug: false, + }; + + beforeEach(() => { + localStorage.clear(); + vi.clearAllMocks(); + + // Mock successful API response + vi.mocked(fetch).mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + consentId: 'consent-123', + timestamp: '2024-01-15T10:00:00.000Z', + expiresAt: '2025-01-15T10:00:00.000Z', + }), + } as Response); + + manager = new ConsentManager(mockConfig); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('constructor', () => { + it('should create manager with merged config', () => { + expect(manager).toBeDefined(); + }); + + it('should apply default config values', () => { + // Default consent config should be applied + expect(manager).toBeDefined(); + }); + }); + + describe('init', () => { + it('should initialize the manager', async () => { + await manager.init(); + + // Should have generated fingerprint and be initialized + expect(manager.needsConsent()).toBe(true); // No consent stored + }); + + it('should only initialize once', async () => { + await manager.init(); + await manager.init(); // Second call should be skipped + + expect(manager.needsConsent()).toBe(true); + }); + + it('should emit init event', async () => { + const callback = vi.fn(); + manager.on('init', callback); + + await manager.init(); + + expect(callback).toHaveBeenCalled(); + }); + + it('should load existing consent from storage', async () => { + // Pre-set consent in storage + const storageKey = `bp_consent_${mockConfig.siteId}`; + const mockConsent = { + categories: { + essential: true, + functional: true, + analytics: true, + marketing: false, + social: false, + }, + vendors: {}, + timestamp: new Date().toISOString(), + version: '1.0.0', + }; + + // Create a simple hash for signature + const data = JSON.stringify(mockConsent) + mockConfig.siteId; + let hash = 5381; + for (let i = 0; i < data.length; i++) { + hash = (hash * 33) ^ data.charCodeAt(i); + } + const signature = (hash >>> 0).toString(16); + + localStorage.setItem( + storageKey, + JSON.stringify({ + version: '1', + consent: mockConsent, + signature, + }) + ); + + manager = new ConsentManager(mockConfig); + await manager.init(); + + expect(manager.hasConsent('analytics')).toBe(true); + }); + + it('should show banner when no consent exists', async () => { + const callback = vi.fn(); + manager.on('banner_show', callback); + + await manager.init(); + + expect(callback).toHaveBeenCalled(); + expect(manager.isBannerVisible()).toBe(true); + }); + }); + + describe('hasConsent', () => { + it('should return true for essential without initialization', () => { + expect(manager.hasConsent('essential')).toBe(true); + }); + + it('should return false for other categories without consent', () => { + expect(manager.hasConsent('analytics')).toBe(false); + expect(manager.hasConsent('marketing')).toBe(false); + }); + }); + + describe('hasVendorConsent', () => { + it('should return false when no consent exists', () => { + expect(manager.hasVendorConsent('google-analytics')).toBe(false); + }); + }); + + describe('getConsent', () => { + it('should return null when no consent exists', () => { + expect(manager.getConsent()).toBeNull(); + }); + + it('should return a copy of consent state', async () => { + await manager.init(); + await manager.acceptAll(); + + const consent1 = manager.getConsent(); + const consent2 = manager.getConsent(); + + expect(consent1).not.toBe(consent2); // Different objects + expect(consent1).toEqual(consent2); // Same content + }); + }); + + describe('setConsent', () => { + it('should set consent categories', async () => { + await manager.init(); + await manager.setConsent({ + essential: true, + functional: true, + analytics: true, + marketing: false, + social: false, + }); + + expect(manager.hasConsent('analytics')).toBe(true); + expect(manager.hasConsent('marketing')).toBe(false); + }); + + it('should always keep essential enabled', async () => { + await manager.init(); + await manager.setConsent({ + essential: false, // Attempting to disable + functional: false, + analytics: false, + marketing: false, + social: false, + }); + + expect(manager.hasConsent('essential')).toBe(true); + }); + + it('should emit change event', async () => { + await manager.init(); + const callback = vi.fn(); + manager.on('change', callback); + + await manager.setConsent({ + essential: true, + functional: true, + analytics: true, + marketing: false, + social: false, + }); + + expect(callback).toHaveBeenCalled(); + }); + + it('should save consent locally even on API error', async () => { + vi.mocked(fetch).mockRejectedValueOnce(new Error('Network error')); + + await manager.init(); + await manager.setConsent({ + essential: true, + functional: false, + analytics: true, + marketing: false, + social: false, + }); + + expect(manager.hasConsent('analytics')).toBe(true); + }); + }); + + describe('acceptAll', () => { + it('should enable all categories', async () => { + await manager.init(); + await manager.acceptAll(); + + expect(manager.hasConsent('essential')).toBe(true); + expect(manager.hasConsent('functional')).toBe(true); + expect(manager.hasConsent('analytics')).toBe(true); + expect(manager.hasConsent('marketing')).toBe(true); + expect(manager.hasConsent('social')).toBe(true); + }); + + it('should emit accept_all event', async () => { + await manager.init(); + const callback = vi.fn(); + manager.on('accept_all', callback); + + await manager.acceptAll(); + + expect(callback).toHaveBeenCalled(); + }); + + it('should hide banner', async () => { + await manager.init(); + expect(manager.isBannerVisible()).toBe(true); + + await manager.acceptAll(); + expect(manager.isBannerVisible()).toBe(false); + }); + }); + + describe('rejectAll', () => { + it('should only keep essential enabled', async () => { + await manager.init(); + await manager.rejectAll(); + + expect(manager.hasConsent('essential')).toBe(true); + expect(manager.hasConsent('functional')).toBe(false); + expect(manager.hasConsent('analytics')).toBe(false); + expect(manager.hasConsent('marketing')).toBe(false); + expect(manager.hasConsent('social')).toBe(false); + }); + + it('should emit reject_all event', async () => { + await manager.init(); + const callback = vi.fn(); + manager.on('reject_all', callback); + + await manager.rejectAll(); + + expect(callback).toHaveBeenCalled(); + }); + + it('should hide banner', async () => { + await manager.init(); + await manager.rejectAll(); + + expect(manager.isBannerVisible()).toBe(false); + }); + }); + + describe('revokeAll', () => { + it('should clear all consent', async () => { + await manager.init(); + await manager.acceptAll(); + await manager.revokeAll(); + + expect(manager.getConsent()).toBeNull(); + }); + + it('should try to revoke on server', async () => { + await manager.init(); + await manager.acceptAll(); + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + status: 204, + } as Response); + + await manager.revokeAll(); + + // DELETE request should have been made + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining('/consent/'), + expect.objectContaining({ method: 'DELETE' }) + ); + }); + }); + + describe('exportConsent', () => { + it('should export consent data as JSON', async () => { + await manager.init(); + await manager.acceptAll(); + + const exported = await manager.exportConsent(); + const parsed = JSON.parse(exported); + + expect(parsed.currentConsent).toBeDefined(); + expect(parsed.exportedAt).toBeDefined(); + expect(parsed.siteId).toBe('test-site'); + }); + }); + + describe('needsConsent', () => { + it('should return true when no consent exists', () => { + expect(manager.needsConsent()).toBe(true); + }); + + it('should return false when valid consent exists', async () => { + await manager.init(); + await manager.acceptAll(); + + // After acceptAll, consent should exist + expect(manager.getConsent()).not.toBeNull(); + // needsConsent checks for currentConsent and expiration + // Since we just accepted all, consent should be valid + const consent = manager.getConsent(); + expect(consent?.categories?.essential).toBe(true); + }); + }); + + describe('banner control', () => { + it('should show banner', async () => { + await manager.init(); + manager.hideBanner(); + manager.showBanner(); + + expect(manager.isBannerVisible()).toBe(true); + }); + + it('should hide banner', async () => { + await manager.init(); + manager.hideBanner(); + + expect(manager.isBannerVisible()).toBe(false); + }); + + it('should emit banner_show event', async () => { + const callback = vi.fn(); + manager.on('banner_show', callback); + + await manager.init(); // This shows banner + + expect(callback).toHaveBeenCalled(); + }); + + it('should emit banner_hide event', async () => { + await manager.init(); + const callback = vi.fn(); + manager.on('banner_hide', callback); + + manager.hideBanner(); + + expect(callback).toHaveBeenCalled(); + }); + + it('should not show banner if already visible', async () => { + await manager.init(); + const callback = vi.fn(); + manager.on('banner_show', callback); + + callback.mockClear(); + manager.showBanner(); + manager.showBanner(); + + expect(callback).toHaveBeenCalledTimes(0); // Already visible from init + }); + }); + + describe('showSettings', () => { + it('should emit settings_open event', async () => { + await manager.init(); + const callback = vi.fn(); + manager.on('settings_open', callback); + + manager.showSettings(); + + expect(callback).toHaveBeenCalled(); + }); + }); + + describe('event handling', () => { + it('should register event listeners', async () => { + await manager.init(); + const callback = vi.fn(); + manager.on('change', callback); + + await manager.acceptAll(); + + expect(callback).toHaveBeenCalled(); + }); + + it('should unregister event listeners', async () => { + await manager.init(); + const callback = vi.fn(); + manager.on('change', callback); + manager.off('change', callback); + + await manager.acceptAll(); + + expect(callback).not.toHaveBeenCalled(); + }); + + it('should return unsubscribe function', async () => { + await manager.init(); + const callback = vi.fn(); + const unsubscribe = manager.on('change', callback); + + unsubscribe(); + await manager.acceptAll(); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe('callbacks', () => { + it('should call onConsentChange callback', async () => { + const onConsentChange = vi.fn(); + manager = new ConsentManager({ + ...mockConfig, + onConsentChange, + }); + + await manager.init(); + await manager.acceptAll(); + + expect(onConsentChange).toHaveBeenCalled(); + }); + + it('should call onBannerShow callback', async () => { + const onBannerShow = vi.fn(); + manager = new ConsentManager({ + ...mockConfig, + onBannerShow, + }); + + await manager.init(); + + expect(onBannerShow).toHaveBeenCalled(); + }); + + it('should call onBannerHide callback', async () => { + const onBannerHide = vi.fn(); + manager = new ConsentManager({ + ...mockConfig, + onBannerHide, + }); + + await manager.init(); + manager.hideBanner(); + + expect(onBannerHide).toHaveBeenCalled(); + }); + }); + + describe('static methods', () => { + it('should return SDK version', () => { + const version = ConsentManager.getVersion(); + + expect(version).toBeDefined(); + expect(typeof version).toBe('string'); + }); + }); + + describe('Google Consent Mode', () => { + it('should update Google Consent Mode when gtag is available', async () => { + const gtag = vi.fn(); + (window as unknown as { gtag: typeof gtag }).gtag = gtag; + + await manager.init(); + await manager.acceptAll(); + + expect(gtag).toHaveBeenCalledWith( + 'consent', + 'update', + expect.objectContaining({ + analytics_storage: 'granted', + ad_storage: 'granted', + }) + ); + + delete (window as unknown as { gtag?: typeof gtag }).gtag; + }); + }); + + describe('consent expiration', () => { + it('should clear expired consent on init', async () => { + const storageKey = `bp_consent_${mockConfig.siteId}`; + const expiredConsent = { + categories: { + essential: true, + functional: true, + analytics: true, + marketing: false, + social: false, + }, + vendors: {}, + timestamp: '2020-01-01T00:00:00.000Z', // Very old + version: '1.0.0', + expiresAt: '2020-06-01T00:00:00.000Z', // Expired + }; + + const data = JSON.stringify(expiredConsent) + mockConfig.siteId; + let hash = 5381; + for (let i = 0; i < data.length; i++) { + hash = (hash * 33) ^ data.charCodeAt(i); + } + const signature = (hash >>> 0).toString(16); + + localStorage.setItem( + storageKey, + JSON.stringify({ + version: '1', + consent: expiredConsent, + signature, + }) + ); + + manager = new ConsentManager(mockConfig); + await manager.init(); + + expect(manager.needsConsent()).toBe(true); + }); + }); + + describe('debug mode', () => { + it('should log when debug is enabled', async () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const debugManager = new ConsentManager({ + ...mockConfig, + debug: true, + }); + + await debugManager.init(); + + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); + + describe('consent input normalization', () => { + it('should accept categories object directly', async () => { + await manager.init(); + await manager.setConsent({ + essential: true, + functional: true, + analytics: false, + marketing: false, + social: false, + }); + + expect(manager.hasConsent('functional')).toBe(true); + }); + + it('should accept nested categories object', async () => { + await manager.init(); + await manager.setConsent({ + categories: { + essential: true, + functional: false, + analytics: true, + marketing: false, + social: false, + }, + }); + + expect(manager.hasConsent('analytics')).toBe(true); + expect(manager.hasConsent('functional')).toBe(false); + }); + + it('should accept vendors in consent input', async () => { + await manager.init(); + await manager.setConsent({ + categories: { + essential: true, + functional: true, + analytics: true, + marketing: false, + social: false, + }, + vendors: { + 'google-analytics': true, + }, + }); + + const consent = manager.getConsent(); + expect(consent?.vendors['google-analytics']).toBe(true); + }); + }); +}); diff --git a/consent-sdk/src/core/ConsentManager.ts b/consent-sdk/src/core/ConsentManager.ts new file mode 100644 index 0000000..be203d9 --- /dev/null +++ b/consent-sdk/src/core/ConsentManager.ts @@ -0,0 +1,525 @@ +/** + * ConsentManager - Hauptklasse fuer das Consent Management + * + * DSGVO/TTDSG-konformes Consent Management fuer Web, PWA und Mobile. + */ + +import type { + ConsentConfig, + ConsentState, + ConsentCategory, + ConsentCategories, + ConsentInput, + ConsentEventType, + ConsentEventCallback, + ConsentEventData, +} from '../types'; +import { ConsentStorage } from './ConsentStorage'; +import { ScriptBlocker } from './ScriptBlocker'; +import { ConsentAPI } from './ConsentAPI'; +import { EventEmitter } from '../utils/EventEmitter'; +import { generateFingerprint } from '../utils/fingerprint'; +import { SDK_VERSION } from '../version'; + +/** + * Default-Konfiguration + */ +const DEFAULT_CONFIG: Partial = { + language: 'de', + fallbackLanguage: 'en', + ui: { + position: 'bottom', + layout: 'modal', + theme: 'auto', + zIndex: 999999, + blockScrollOnModal: true, + }, + consent: { + required: true, + rejectAllVisible: true, + acceptAllVisible: true, + granularControl: true, + vendorControl: false, + rememberChoice: true, + rememberDays: 365, + geoTargeting: false, + recheckAfterDays: 180, + }, + categories: ['essential', 'functional', 'analytics', 'marketing', 'social'], + debug: false, +}; + +/** + * Default Consent-State (nur Essential aktiv) + */ +const DEFAULT_CONSENT: ConsentCategories = { + essential: true, + functional: false, + analytics: false, + marketing: false, + social: false, +}; + +/** + * ConsentManager - Zentrale Klasse fuer Consent-Verwaltung + */ +export class ConsentManager { + private config: ConsentConfig; + private storage: ConsentStorage; + private scriptBlocker: ScriptBlocker; + private api: ConsentAPI; + private events: EventEmitter; + private currentConsent: ConsentState | null = null; + private initialized = false; + private bannerVisible = false; + private deviceFingerprint: string = ''; + + constructor(config: ConsentConfig) { + this.config = this.mergeConfig(config); + this.storage = new ConsentStorage(this.config); + this.scriptBlocker = new ScriptBlocker(this.config); + this.api = new ConsentAPI(this.config); + this.events = new EventEmitter(); + + this.log('ConsentManager created with config:', this.config); + } + + /** + * SDK initialisieren + */ + async init(): Promise { + if (this.initialized) { + this.log('Already initialized, skipping'); + return; + } + + try { + this.log('Initializing ConsentManager...'); + + // Device Fingerprint generieren + this.deviceFingerprint = await generateFingerprint(); + + // Consent aus Storage laden + this.currentConsent = this.storage.get(); + + if (this.currentConsent) { + this.log('Loaded consent from storage:', this.currentConsent); + + // Pruefen ob Consent abgelaufen + if (this.isConsentExpired()) { + this.log('Consent expired, clearing'); + this.storage.clear(); + this.currentConsent = null; + } else { + // Consent anwenden + this.applyConsent(); + } + } + + // Script-Blocker initialisieren + this.scriptBlocker.init(); + + this.initialized = true; + this.emit('init', this.currentConsent); + + // Banner anzeigen falls noetig + if (this.needsConsent()) { + this.showBanner(); + } + + this.log('ConsentManager initialized successfully'); + } catch (error) { + this.handleError(error as Error); + throw error; + } + } + + // =========================================================================== + // Public API + // =========================================================================== + + /** + * Pruefen ob Consent fuer Kategorie vorhanden + */ + hasConsent(category: ConsentCategory): boolean { + if (!this.currentConsent) { + return category === 'essential'; + } + return this.currentConsent.categories[category] ?? false; + } + + /** + * Pruefen ob Consent fuer Vendor vorhanden + */ + hasVendorConsent(vendorId: string): boolean { + if (!this.currentConsent) { + return false; + } + return this.currentConsent.vendors[vendorId] ?? false; + } + + /** + * Aktuellen Consent-State abrufen + */ + getConsent(): ConsentState | null { + return this.currentConsent ? { ...this.currentConsent } : null; + } + + /** + * Consent setzen + */ + async setConsent(input: ConsentInput): Promise { + const categories = this.normalizeConsentInput(input); + + // Essential ist immer aktiv + categories.essential = true; + + const newConsent: ConsentState = { + categories, + vendors: 'vendors' in input && input.vendors ? input.vendors : {}, + timestamp: new Date().toISOString(), + version: SDK_VERSION, + }; + + try { + // An Backend senden + const response = await this.api.saveConsent({ + siteId: this.config.siteId, + deviceFingerprint: this.deviceFingerprint, + consent: newConsent, + }); + + newConsent.consentId = response.consentId; + newConsent.expiresAt = response.expiresAt; + + // Lokal speichern + this.storage.set(newConsent); + this.currentConsent = newConsent; + + // Consent anwenden + this.applyConsent(); + + // Event emittieren + this.emit('change', newConsent); + this.config.onConsentChange?.(newConsent); + + this.log('Consent saved:', newConsent); + } catch (error) { + // Bei Netzwerkfehler trotzdem lokal speichern + this.log('API error, saving locally:', error); + this.storage.set(newConsent); + this.currentConsent = newConsent; + this.applyConsent(); + this.emit('change', newConsent); + } + } + + /** + * Alle Kategorien akzeptieren + */ + async acceptAll(): Promise { + const allCategories: ConsentCategories = { + essential: true, + functional: true, + analytics: true, + marketing: true, + social: true, + }; + + await this.setConsent(allCategories); + this.emit('accept_all', this.currentConsent!); + this.hideBanner(); + } + + /** + * Alle nicht-essentiellen Kategorien ablehnen + */ + async rejectAll(): Promise { + const minimalCategories: ConsentCategories = { + essential: true, + functional: false, + analytics: false, + marketing: false, + social: false, + }; + + await this.setConsent(minimalCategories); + this.emit('reject_all', this.currentConsent!); + this.hideBanner(); + } + + /** + * Alle Einwilligungen widerrufen + */ + async revokeAll(): Promise { + if (this.currentConsent?.consentId) { + try { + await this.api.revokeConsent(this.currentConsent.consentId); + } catch (error) { + this.log('Failed to revoke on server:', error); + } + } + + this.storage.clear(); + this.currentConsent = null; + this.scriptBlocker.blockAll(); + + this.log('All consents revoked'); + } + + /** + * Consent-Daten exportieren (DSGVO Art. 20) + */ + async exportConsent(): Promise { + const exportData = { + currentConsent: this.currentConsent, + exportedAt: new Date().toISOString(), + siteId: this.config.siteId, + deviceFingerprint: this.deviceFingerprint, + }; + + return JSON.stringify(exportData, null, 2); + } + + // =========================================================================== + // Banner Control + // =========================================================================== + + /** + * Pruefen ob Consent-Abfrage noetig + */ + needsConsent(): boolean { + if (!this.currentConsent) { + return true; + } + + if (this.isConsentExpired()) { + return true; + } + + // Recheck nach X Tagen + if (this.config.consent?.recheckAfterDays) { + const consentDate = new Date(this.currentConsent.timestamp); + const recheckDate = new Date(consentDate); + recheckDate.setDate( + recheckDate.getDate() + this.config.consent.recheckAfterDays + ); + + if (new Date() > recheckDate) { + return true; + } + } + + return false; + } + + /** + * Banner anzeigen + */ + showBanner(): void { + if (this.bannerVisible) { + return; + } + + this.bannerVisible = true; + this.emit('banner_show', undefined); + this.config.onBannerShow?.(); + + // Banner wird von UI-Komponente gerendert + // Hier nur Status setzen + this.log('Banner shown'); + } + + /** + * Banner verstecken + */ + hideBanner(): void { + if (!this.bannerVisible) { + return; + } + + this.bannerVisible = false; + this.emit('banner_hide', undefined); + this.config.onBannerHide?.(); + + this.log('Banner hidden'); + } + + /** + * Einstellungs-Modal oeffnen + */ + showSettings(): void { + this.emit('settings_open', undefined); + this.log('Settings opened'); + } + + /** + * Pruefen ob Banner sichtbar + */ + isBannerVisible(): boolean { + return this.bannerVisible; + } + + // =========================================================================== + // Event Handling + // =========================================================================== + + /** + * Event-Listener registrieren + */ + on( + event: T, + callback: ConsentEventCallback + ): () => void { + return this.events.on(event, callback); + } + + /** + * Event-Listener entfernen + */ + off( + event: T, + callback: ConsentEventCallback + ): void { + this.events.off(event, callback); + } + + // =========================================================================== + // Internal Methods + // =========================================================================== + + /** + * Konfiguration zusammenfuehren + */ + private mergeConfig(config: ConsentConfig): ConsentConfig { + return { + ...DEFAULT_CONFIG, + ...config, + ui: { ...DEFAULT_CONFIG.ui, ...config.ui }, + consent: { ...DEFAULT_CONFIG.consent, ...config.consent }, + } as ConsentConfig; + } + + /** + * Consent-Input normalisieren + */ + private normalizeConsentInput(input: ConsentInput): ConsentCategories { + if ('categories' in input && input.categories) { + return { ...DEFAULT_CONSENT, ...input.categories }; + } + + return { ...DEFAULT_CONSENT, ...(input as Partial) }; + } + + /** + * Consent anwenden (Skripte aktivieren/blockieren) + */ + private applyConsent(): void { + if (!this.currentConsent) { + return; + } + + for (const [category, allowed] of Object.entries( + this.currentConsent.categories + )) { + if (allowed) { + this.scriptBlocker.enableCategory(category as ConsentCategory); + } else { + this.scriptBlocker.disableCategory(category as ConsentCategory); + } + } + + // Google Consent Mode aktualisieren + this.updateGoogleConsentMode(); + } + + /** + * Google Consent Mode v2 aktualisieren + */ + private updateGoogleConsentMode(): void { + if (typeof window === 'undefined' || !this.currentConsent) { + return; + } + + const gtag = (window as unknown as { gtag?: (...args: unknown[]) => void }).gtag; + if (typeof gtag !== 'function') { + return; + } + + const { categories } = this.currentConsent; + + gtag('consent', 'update', { + ad_storage: categories.marketing ? 'granted' : 'denied', + ad_user_data: categories.marketing ? 'granted' : 'denied', + ad_personalization: categories.marketing ? 'granted' : 'denied', + analytics_storage: categories.analytics ? 'granted' : 'denied', + functionality_storage: categories.functional ? 'granted' : 'denied', + personalization_storage: categories.functional ? 'granted' : 'denied', + security_storage: 'granted', + }); + + this.log('Google Consent Mode updated'); + } + + /** + * Pruefen ob Consent abgelaufen + */ + private isConsentExpired(): boolean { + if (!this.currentConsent?.expiresAt) { + // Fallback: Nach rememberDays ablaufen + if (this.currentConsent?.timestamp && this.config.consent?.rememberDays) { + const consentDate = new Date(this.currentConsent.timestamp); + const expiryDate = new Date(consentDate); + expiryDate.setDate( + expiryDate.getDate() + this.config.consent.rememberDays + ); + return new Date() > expiryDate; + } + return false; + } + + return new Date() > new Date(this.currentConsent.expiresAt); + } + + /** + * Event emittieren + */ + private emit( + event: T, + data: ConsentEventData[T] + ): void { + this.events.emit(event, data); + } + + /** + * Fehler behandeln + */ + private handleError(error: Error): void { + this.log('Error:', error); + this.emit('error', error); + this.config.onError?.(error); + } + + /** + * Debug-Logging + */ + private log(...args: unknown[]): void { + if (this.config.debug) { + console.log('[ConsentSDK]', ...args); + } + } + + // =========================================================================== + // Static Methods + // =========================================================================== + + /** + * SDK-Version abrufen + */ + static getVersion(): string { + return SDK_VERSION; + } +} + +// Default-Export +export default ConsentManager; diff --git a/consent-sdk/src/core/ConsentStorage.test.ts b/consent-sdk/src/core/ConsentStorage.test.ts new file mode 100644 index 0000000..a00871a --- /dev/null +++ b/consent-sdk/src/core/ConsentStorage.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ConsentStorage } from './ConsentStorage'; +import type { ConsentConfig, ConsentState } from '../types'; + +describe('ConsentStorage', () => { + let storage: ConsentStorage; + const mockConfig: ConsentConfig = { + apiEndpoint: 'https://api.example.com', + siteId: 'test-site', + debug: false, + consent: { + rememberDays: 365, + }, + }; + + const mockConsent: ConsentState = { + categories: { + essential: true, + functional: true, + analytics: false, + marketing: false, + social: false, + }, + vendors: {}, + timestamp: '2024-01-15T10:00:00.000Z', + version: '1.0.0', + }; + + beforeEach(() => { + localStorage.clear(); + storage = new ConsentStorage(mockConfig); + }); + + describe('constructor', () => { + it('should create storage with site-specific key', () => { + expect(storage).toBeDefined(); + }); + }); + + describe('get', () => { + it('should return null when no consent stored', () => { + const result = storage.get(); + expect(result).toBeNull(); + }); + + it('should return consent when valid data exists', () => { + storage.set(mockConsent); + const result = storage.get(); + + expect(result).toBeDefined(); + expect(result?.categories.essential).toBe(true); + expect(result?.categories.analytics).toBe(false); + }); + + it('should return null and clear when version mismatch', () => { + // Manually set invalid version in storage + const storageKey = `bp_consent_${mockConfig.siteId}`; + localStorage.setItem( + storageKey, + JSON.stringify({ + version: 'invalid', + consent: mockConsent, + signature: 'test', + }) + ); + + const result = storage.get(); + expect(result).toBeNull(); + }); + + it('should return null and clear when signature invalid', () => { + const storageKey = `bp_consent_${mockConfig.siteId}`; + localStorage.setItem( + storageKey, + JSON.stringify({ + version: '1', + consent: mockConsent, + signature: 'invalid-signature', + }) + ); + + const result = storage.get(); + expect(result).toBeNull(); + }); + + it('should return null when JSON is invalid', () => { + const storageKey = `bp_consent_${mockConfig.siteId}`; + localStorage.setItem(storageKey, 'invalid-json'); + + const result = storage.get(); + expect(result).toBeNull(); + }); + }); + + describe('set', () => { + it('should store consent in localStorage', () => { + storage.set(mockConsent); + + const storageKey = `bp_consent_${mockConfig.siteId}`; + const stored = localStorage.getItem(storageKey); + + expect(stored).toBeDefined(); + expect(stored).toContain('"version":"1"'); + }); + + it('should set a cookie for SSR support', () => { + storage.set(mockConsent); + + expect(document.cookie).toContain(`bp_consent_${mockConfig.siteId}`); + }); + + it('should include signature in stored data', () => { + storage.set(mockConsent); + + const storageKey = `bp_consent_${mockConfig.siteId}`; + const stored = JSON.parse(localStorage.getItem(storageKey) || '{}'); + + expect(stored.signature).toBeDefined(); + expect(typeof stored.signature).toBe('string'); + }); + }); + + describe('clear', () => { + it('should remove consent from localStorage', () => { + storage.set(mockConsent); + storage.clear(); + + const result = storage.get(); + expect(result).toBeNull(); + }); + + it('should clear the cookie', () => { + storage.set(mockConsent); + storage.clear(); + + // Cookie should be cleared (expired) + expect(document.cookie).toContain('expires='); + }); + }); + + describe('exists', () => { + it('should return false when no consent exists', () => { + expect(storage.exists()).toBe(false); + }); + + it('should return true when consent exists', () => { + storage.set(mockConsent); + expect(storage.exists()).toBe(true); + }); + }); + + describe('signature verification', () => { + it('should detect tampered consent data', () => { + storage.set(mockConsent); + + const storageKey = `bp_consent_${mockConfig.siteId}`; + const stored = JSON.parse(localStorage.getItem(storageKey) || '{}'); + + // Tamper with the data + stored.consent.categories.marketing = true; + localStorage.setItem(storageKey, JSON.stringify(stored)); + + const result = storage.get(); + expect(result).toBeNull(); // Signature mismatch should clear + }); + }); + + describe('debug mode', () => { + it('should log when debug is enabled', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const debugStorage = new ConsentStorage({ + ...mockConfig, + debug: true, + }); + + debugStorage.set(mockConsent); + + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); + + describe('cookie settings', () => { + it('should set Secure flag on HTTPS', () => { + storage.set(mockConsent); + expect(document.cookie).toContain('Secure'); + }); + + it('should set SameSite=Lax', () => { + storage.set(mockConsent); + expect(document.cookie).toContain('SameSite=Lax'); + }); + + it('should set expiration based on rememberDays', () => { + storage.set(mockConsent); + expect(document.cookie).toContain('expires='); + }); + }); + + describe('different sites', () => { + it('should isolate storage by siteId', () => { + const storage1 = new ConsentStorage({ ...mockConfig, siteId: 'site-1' }); + const storage2 = new ConsentStorage({ ...mockConfig, siteId: 'site-2' }); + + storage1.set(mockConsent); + + expect(storage1.exists()).toBe(true); + expect(storage2.exists()).toBe(false); + }); + }); +}); diff --git a/consent-sdk/src/core/ConsentStorage.ts b/consent-sdk/src/core/ConsentStorage.ts new file mode 100644 index 0000000..f91b0df --- /dev/null +++ b/consent-sdk/src/core/ConsentStorage.ts @@ -0,0 +1,203 @@ +/** + * ConsentStorage - Lokale Speicherung des Consent-Status + * + * Speichert Consent-Daten im localStorage mit HMAC-Signatur + * zur Manipulationserkennung. + */ + +import type { ConsentConfig, ConsentState } from '../types'; + +const STORAGE_KEY = 'bp_consent'; +const STORAGE_VERSION = '1'; + +/** + * Gespeichertes Format + */ +interface StoredConsent { + version: string; + consent: ConsentState; + signature: string; +} + +/** + * ConsentStorage - Persistente Speicherung + */ +export class ConsentStorage { + private config: ConsentConfig; + private storageKey: string; + + constructor(config: ConsentConfig) { + this.config = config; + // Pro Site ein separater Key + this.storageKey = `${STORAGE_KEY}_${config.siteId}`; + } + + /** + * Consent laden + */ + get(): ConsentState | null { + if (typeof window === 'undefined') { + return null; + } + + try { + const raw = localStorage.getItem(this.storageKey); + if (!raw) { + return null; + } + + const stored: StoredConsent = JSON.parse(raw); + + // Version pruefen + if (stored.version !== STORAGE_VERSION) { + this.log('Storage version mismatch, clearing'); + this.clear(); + return null; + } + + // Signatur pruefen + if (!this.verifySignature(stored.consent, stored.signature)) { + this.log('Invalid signature, clearing'); + this.clear(); + return null; + } + + return stored.consent; + } catch (error) { + this.log('Failed to load consent:', error); + return null; + } + } + + /** + * Consent speichern + */ + set(consent: ConsentState): void { + if (typeof window === 'undefined') { + return; + } + + try { + const signature = this.generateSignature(consent); + + const stored: StoredConsent = { + version: STORAGE_VERSION, + consent, + signature, + }; + + localStorage.setItem(this.storageKey, JSON.stringify(stored)); + + // Auch als Cookie setzen (fuer Server-Side Rendering) + this.setCookie(consent); + + this.log('Consent saved to storage'); + } catch (error) { + this.log('Failed to save consent:', error); + } + } + + /** + * Consent loeschen + */ + clear(): void { + if (typeof window === 'undefined') { + return; + } + + try { + localStorage.removeItem(this.storageKey); + this.clearCookie(); + this.log('Consent cleared from storage'); + } catch (error) { + this.log('Failed to clear consent:', error); + } + } + + /** + * Pruefen ob Consent existiert + */ + exists(): boolean { + return this.get() !== null; + } + + // =========================================================================== + // Cookie Management + // =========================================================================== + + /** + * Consent als Cookie setzen + */ + private setCookie(consent: ConsentState): void { + const days = this.config.consent?.rememberDays ?? 365; + const expires = new Date(); + expires.setDate(expires.getDate() + days); + + // Nur Kategorien als Cookie (fuer SSR) + const cookieValue = JSON.stringify(consent.categories); + const encoded = encodeURIComponent(cookieValue); + + document.cookie = [ + `${this.storageKey}=${encoded}`, + `expires=${expires.toUTCString()}`, + 'path=/', + 'SameSite=Lax', + location.protocol === 'https:' ? 'Secure' : '', + ] + .filter(Boolean) + .join('; '); + } + + /** + * Cookie loeschen + */ + private clearCookie(): void { + document.cookie = `${this.storageKey}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`; + } + + // =========================================================================== + // Signature (Simple HMAC-like) + // =========================================================================== + + /** + * Signatur generieren + */ + private generateSignature(consent: ConsentState): string { + const data = JSON.stringify(consent); + const key = this.config.siteId; + + // Einfache Hash-Funktion (fuer Client-Side) + // In Produktion wuerde man SubtleCrypto verwenden + return this.simpleHash(data + key); + } + + /** + * Signatur verifizieren + */ + private verifySignature(consent: ConsentState, signature: string): boolean { + const expected = this.generateSignature(consent); + return expected === signature; + } + + /** + * Einfache Hash-Funktion (djb2) + */ + private simpleHash(str: string): string { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = (hash * 33) ^ str.charCodeAt(i); + } + return (hash >>> 0).toString(16); + } + + /** + * Debug-Logging + */ + private log(...args: unknown[]): void { + if (this.config.debug) { + console.log('[ConsentStorage]', ...args); + } + } +} + +export default ConsentStorage; diff --git a/consent-sdk/src/core/ScriptBlocker.test.ts b/consent-sdk/src/core/ScriptBlocker.test.ts new file mode 100644 index 0000000..d7f2dfc --- /dev/null +++ b/consent-sdk/src/core/ScriptBlocker.test.ts @@ -0,0 +1,305 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ScriptBlocker } from './ScriptBlocker'; +import type { ConsentConfig } from '../types'; + +describe('ScriptBlocker', () => { + let blocker: ScriptBlocker; + const mockConfig: ConsentConfig = { + apiEndpoint: 'https://api.example.com', + siteId: 'test-site', + debug: false, + }; + + beforeEach(() => { + // Clear document body + document.body.innerHTML = ''; + blocker = new ScriptBlocker(mockConfig); + }); + + afterEach(() => { + blocker.destroy(); + }); + + describe('constructor', () => { + it('should create blocker with essential category enabled by default', () => { + expect(blocker.isCategoryEnabled('essential')).toBe(true); + }); + + it('should have other categories disabled by default', () => { + expect(blocker.isCategoryEnabled('analytics')).toBe(false); + expect(blocker.isCategoryEnabled('marketing')).toBe(false); + expect(blocker.isCategoryEnabled('functional')).toBe(false); + expect(blocker.isCategoryEnabled('social')).toBe(false); + }); + }); + + describe('init', () => { + it('should process existing scripts with data-consent', () => { + // Add a blocked script before init + const script = document.createElement('script'); + script.setAttribute('data-consent', 'analytics'); + script.setAttribute('data-src', 'https://analytics.example.com/script.js'); + script.type = 'text/plain'; + document.body.appendChild(script); + + blocker.init(); + + // Script should remain blocked (analytics not enabled) + expect(script.type).toBe('text/plain'); + }); + + it('should start MutationObserver for new elements', () => { + blocker.init(); + + // Add a script after init + const script = document.createElement('script'); + script.setAttribute('data-consent', 'analytics'); + script.setAttribute('data-src', 'https://analytics.example.com/script.js'); + script.type = 'text/plain'; + document.body.appendChild(script); + + // Script should be tracked (processed) + expect(script.type).toBe('text/plain'); + }); + }); + + describe('enableCategory', () => { + it('should enable a category', () => { + blocker.enableCategory('analytics'); + + expect(blocker.isCategoryEnabled('analytics')).toBe(true); + }); + + it('should not duplicate enabling', () => { + blocker.enableCategory('analytics'); + blocker.enableCategory('analytics'); + + expect(blocker.isCategoryEnabled('analytics')).toBe(true); + }); + + it('should activate blocked scripts for the category', () => { + const script = document.createElement('script'); + script.setAttribute('data-consent', 'analytics'); + script.setAttribute('data-src', 'https://analytics.example.com/script.js'); + script.type = 'text/plain'; + document.body.appendChild(script); + + blocker.init(); + blocker.enableCategory('analytics'); + + // The original script should be replaced + const scripts = document.querySelectorAll('script[src="https://analytics.example.com/script.js"]'); + expect(scripts.length).toBe(1); + }); + }); + + describe('disableCategory', () => { + it('should disable a category', () => { + blocker.enableCategory('analytics'); + blocker.disableCategory('analytics'); + + expect(blocker.isCategoryEnabled('analytics')).toBe(false); + }); + + it('should not disable essential category', () => { + blocker.disableCategory('essential'); + + expect(blocker.isCategoryEnabled('essential')).toBe(true); + }); + }); + + describe('blockAll', () => { + it('should block all categories except essential', () => { + blocker.enableCategory('analytics'); + blocker.enableCategory('marketing'); + blocker.enableCategory('social'); + + blocker.blockAll(); + + expect(blocker.isCategoryEnabled('essential')).toBe(true); + expect(blocker.isCategoryEnabled('analytics')).toBe(false); + expect(blocker.isCategoryEnabled('marketing')).toBe(false); + expect(blocker.isCategoryEnabled('social')).toBe(false); + }); + }); + + describe('isCategoryEnabled', () => { + it('should return true for enabled categories', () => { + blocker.enableCategory('functional'); + expect(blocker.isCategoryEnabled('functional')).toBe(true); + }); + + it('should return false for disabled categories', () => { + expect(blocker.isCategoryEnabled('marketing')).toBe(false); + }); + }); + + describe('destroy', () => { + it('should disconnect the MutationObserver', () => { + blocker.init(); + blocker.destroy(); + + // After destroy, adding new elements should not trigger processing + // This is hard to test directly, but we can verify it doesn't throw + expect(() => { + const script = document.createElement('script'); + script.setAttribute('data-consent', 'analytics'); + document.body.appendChild(script); + }).not.toThrow(); + }); + }); + + describe('script processing', () => { + it('should handle external scripts with data-src', () => { + const script = document.createElement('script'); + script.setAttribute('data-consent', 'essential'); + script.setAttribute('data-src', 'https://essential.example.com/script.js'); + script.type = 'text/plain'; + document.body.appendChild(script); + + blocker.init(); + + // Essential is enabled, so script should be activated + const activatedScript = document.querySelector('script[src="https://essential.example.com/script.js"]'); + expect(activatedScript).toBeDefined(); + }); + + it('should handle inline scripts', () => { + const script = document.createElement('script'); + script.setAttribute('data-consent', 'essential'); + script.type = 'text/plain'; + script.textContent = 'console.log("test");'; + document.body.appendChild(script); + + blocker.init(); + + // Check that script was processed + const scripts = document.querySelectorAll('script'); + expect(scripts.length).toBeGreaterThan(0); + }); + }); + + describe('iframe processing', () => { + it('should block iframes with data-consent', () => { + const iframe = document.createElement('iframe'); + iframe.setAttribute('data-consent', 'social'); + iframe.setAttribute('data-src', 'https://social.example.com/embed'); + document.body.appendChild(iframe); + + blocker.init(); + + // Should be hidden and have placeholder + expect(iframe.style.display).toBe('none'); + }); + + it('should show placeholder for blocked iframes', () => { + const iframe = document.createElement('iframe'); + iframe.setAttribute('data-consent', 'social'); + iframe.setAttribute('data-src', 'https://social.example.com/embed'); + document.body.appendChild(iframe); + + blocker.init(); + + const placeholder = document.querySelector('.bp-consent-placeholder'); + expect(placeholder).toBeDefined(); + }); + + it('should activate iframe when category enabled', () => { + const iframe = document.createElement('iframe'); + iframe.setAttribute('data-consent', 'social'); + iframe.setAttribute('data-src', 'https://social.example.com/embed'); + document.body.appendChild(iframe); + + blocker.init(); + blocker.enableCategory('social'); + + expect(iframe.src).toBe('https://social.example.com/embed'); + expect(iframe.style.display).toBe(''); + }); + + it('should remove placeholder when iframe activated', () => { + const iframe = document.createElement('iframe'); + iframe.setAttribute('data-consent', 'social'); + iframe.setAttribute('data-src', 'https://social.example.com/embed'); + document.body.appendChild(iframe); + + blocker.init(); + blocker.enableCategory('social'); + + const placeholder = document.querySelector('.bp-consent-placeholder'); + expect(placeholder).toBeNull(); + }); + }); + + describe('placeholder button', () => { + it('should dispatch event when placeholder button clicked', () => { + const iframe = document.createElement('iframe'); + iframe.setAttribute('data-consent', 'marketing'); + iframe.setAttribute('data-src', 'https://marketing.example.com/embed'); + document.body.appendChild(iframe); + + blocker.init(); + + const eventHandler = vi.fn(); + window.addEventListener('bp-consent-request', eventHandler); + + const button = document.querySelector('.bp-consent-placeholder-btn') as HTMLButtonElement; + button?.click(); + + expect(eventHandler).toHaveBeenCalled(); + expect(eventHandler.mock.calls[0][0].detail.category).toBe('marketing'); + + window.removeEventListener('bp-consent-request', eventHandler); + }); + }); + + describe('category names', () => { + it('should show correct category name in placeholder', () => { + const iframe = document.createElement('iframe'); + iframe.setAttribute('data-consent', 'analytics'); + iframe.setAttribute('data-src', 'https://analytics.example.com/embed'); + document.body.appendChild(iframe); + + blocker.init(); + + const placeholder = document.querySelector('.bp-consent-placeholder'); + expect(placeholder?.innerHTML).toContain('Statistik-Cookies aktivieren'); + }); + }); + + describe('debug mode', () => { + it('should log when debug is enabled', () => { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const debugBlocker = new ScriptBlocker({ + ...mockConfig, + debug: true, + }); + + debugBlocker.init(); + debugBlocker.enableCategory('analytics'); + + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + debugBlocker.destroy(); + }); + }); + + describe('nested elements', () => { + it('should process scripts in nested containers', () => { + const container = document.createElement('div'); + const script = document.createElement('script'); + script.setAttribute('data-consent', 'essential'); + script.setAttribute('data-src', 'https://essential.example.com/nested.js'); + script.type = 'text/plain'; + container.appendChild(script); + + blocker.init(); + document.body.appendChild(container); + + // Give MutationObserver time to process + const activatedScript = document.querySelector('script[src="https://essential.example.com/nested.js"]'); + expect(activatedScript).toBeDefined(); + }); + }); +}); diff --git a/consent-sdk/src/core/ScriptBlocker.ts b/consent-sdk/src/core/ScriptBlocker.ts new file mode 100644 index 0000000..0cc587b --- /dev/null +++ b/consent-sdk/src/core/ScriptBlocker.ts @@ -0,0 +1,367 @@ +/** + * ScriptBlocker - Blockiert Skripte bis Consent erteilt wird + * + * Verwendet das data-consent Attribut zur Identifikation von + * Skripten, die erst nach Consent geladen werden duerfen. + * + * Beispiel: + * + */ + +import type { ConsentConfig, ConsentCategory } from '../types'; + +/** + * Script-Element mit Consent-Attributen + */ +interface ConsentScript extends HTMLScriptElement { + dataset: DOMStringMap & { + consent?: string; + src?: string; + }; +} + +/** + * iFrame-Element mit Consent-Attributen + */ +interface ConsentIframe extends HTMLIFrameElement { + dataset: DOMStringMap & { + consent?: string; + src?: string; + }; +} + +/** + * ScriptBlocker - Verwaltet Script-Blocking + */ +export class ScriptBlocker { + private config: ConsentConfig; + private observer: MutationObserver | null = null; + private enabledCategories: Set = new Set(['essential']); + private processedElements: WeakSet = new WeakSet(); + + constructor(config: ConsentConfig) { + this.config = config; + } + + /** + * Initialisieren und Observer starten + */ + init(): void { + if (typeof window === 'undefined') { + return; + } + + // Bestehende Elemente verarbeiten + this.processExistingElements(); + + // MutationObserver fuer neue Elemente + this.observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node.nodeType === Node.ELEMENT_NODE) { + this.processElement(node as Element); + } + } + } + }); + + this.observer.observe(document.documentElement, { + childList: true, + subtree: true, + }); + + this.log('ScriptBlocker initialized'); + } + + /** + * Kategorie aktivieren + */ + enableCategory(category: ConsentCategory): void { + if (this.enabledCategories.has(category)) { + return; + } + + this.enabledCategories.add(category); + this.log('Category enabled:', category); + + // Blockierte Elemente dieser Kategorie aktivieren + this.activateCategory(category); + } + + /** + * Kategorie deaktivieren + */ + disableCategory(category: ConsentCategory): void { + if (category === 'essential') { + // Essential kann nicht deaktiviert werden + return; + } + + this.enabledCategories.delete(category); + this.log('Category disabled:', category); + + // Hinweis: Bereits geladene Skripte koennen nicht entladen werden + // Page-Reload noetig fuer vollstaendige Deaktivierung + } + + /** + * Alle Kategorien blockieren (ausser Essential) + */ + blockAll(): void { + this.enabledCategories.clear(); + this.enabledCategories.add('essential'); + this.log('All categories blocked'); + } + + /** + * Pruefen ob Kategorie aktiviert + */ + isCategoryEnabled(category: ConsentCategory): boolean { + return this.enabledCategories.has(category); + } + + /** + * Observer stoppen + */ + destroy(): void { + this.observer?.disconnect(); + this.observer = null; + this.log('ScriptBlocker destroyed'); + } + + // =========================================================================== + // Internal Methods + // =========================================================================== + + /** + * Bestehende Elemente verarbeiten + */ + private processExistingElements(): void { + // Scripts mit data-consent + const scripts = document.querySelectorAll( + 'script[data-consent]' + ); + scripts.forEach((script) => this.processScript(script)); + + // iFrames mit data-consent + const iframes = document.querySelectorAll( + 'iframe[data-consent]' + ); + iframes.forEach((iframe) => this.processIframe(iframe)); + + this.log(`Processed ${scripts.length} scripts, ${iframes.length} iframes`); + } + + /** + * Element verarbeiten + */ + private processElement(element: Element): void { + if (element.tagName === 'SCRIPT') { + this.processScript(element as ConsentScript); + } else if (element.tagName === 'IFRAME') { + this.processIframe(element as ConsentIframe); + } + + // Auch Kinder verarbeiten + element + .querySelectorAll('script[data-consent]') + .forEach((script) => this.processScript(script)); + element + .querySelectorAll('iframe[data-consent]') + .forEach((iframe) => this.processIframe(iframe)); + } + + /** + * Script-Element verarbeiten + */ + private processScript(script: ConsentScript): void { + if (this.processedElements.has(script)) { + return; + } + + const category = script.dataset.consent as ConsentCategory | undefined; + if (!category) { + return; + } + + this.processedElements.add(script); + + if (this.enabledCategories.has(category)) { + this.activateScript(script); + } else { + this.log(`Script blocked (${category}):`, script.dataset.src || 'inline'); + } + } + + /** + * iFrame-Element verarbeiten + */ + private processIframe(iframe: ConsentIframe): void { + if (this.processedElements.has(iframe)) { + return; + } + + const category = iframe.dataset.consent as ConsentCategory | undefined; + if (!category) { + return; + } + + this.processedElements.add(iframe); + + if (this.enabledCategories.has(category)) { + this.activateIframe(iframe); + } else { + this.log(`iFrame blocked (${category}):`, iframe.dataset.src); + // Placeholder anzeigen + this.showPlaceholder(iframe, category); + } + } + + /** + * Script aktivieren + */ + private activateScript(script: ConsentScript): void { + const src = script.dataset.src; + + if (src) { + // Externes Script: neues Element erstellen + const newScript = document.createElement('script'); + + // Attribute kopieren + for (const attr of script.attributes) { + if (attr.name !== 'type' && attr.name !== 'data-src') { + newScript.setAttribute(attr.name, attr.value); + } + } + + newScript.src = src; + newScript.removeAttribute('data-consent'); + + // Altes Element ersetzen + script.parentNode?.replaceChild(newScript, script); + + this.log('External script activated:', src); + } else { + // Inline-Script: type aendern + const newScript = document.createElement('script'); + + for (const attr of script.attributes) { + if (attr.name !== 'type') { + newScript.setAttribute(attr.name, attr.value); + } + } + + newScript.textContent = script.textContent; + newScript.removeAttribute('data-consent'); + + script.parentNode?.replaceChild(newScript, script); + + this.log('Inline script activated'); + } + } + + /** + * iFrame aktivieren + */ + private activateIframe(iframe: ConsentIframe): void { + const src = iframe.dataset.src; + if (!src) { + return; + } + + // Placeholder entfernen falls vorhanden + const placeholder = iframe.parentElement?.querySelector( + '.bp-consent-placeholder' + ); + placeholder?.remove(); + + // src setzen + iframe.src = src; + iframe.removeAttribute('data-src'); + iframe.removeAttribute('data-consent'); + iframe.style.display = ''; + + this.log('iFrame activated:', src); + } + + /** + * Placeholder fuer blockierten iFrame anzeigen + */ + private showPlaceholder(iframe: ConsentIframe, category: ConsentCategory): void { + // iFrame verstecken + iframe.style.display = 'none'; + + // Placeholder erstellen + const placeholder = document.createElement('div'); + placeholder.className = 'bp-consent-placeholder'; + placeholder.setAttribute('data-category', category); + placeholder.innerHTML = ` + + `; + + // Click-Handler + const btn = placeholder.querySelector('button'); + btn?.addEventListener('click', () => { + // Event dispatchen damit ConsentManager reagieren kann + window.dispatchEvent( + new CustomEvent('bp-consent-request', { + detail: { category }, + }) + ); + }); + + // Nach iFrame einfuegen + iframe.parentNode?.insertBefore(placeholder, iframe.nextSibling); + } + + /** + * Alle Elemente einer Kategorie aktivieren + */ + private activateCategory(category: ConsentCategory): void { + // Scripts + const scripts = document.querySelectorAll( + `script[data-consent="${category}"]` + ); + scripts.forEach((script) => this.activateScript(script)); + + // iFrames + const iframes = document.querySelectorAll( + `iframe[data-consent="${category}"]` + ); + iframes.forEach((iframe) => this.activateIframe(iframe)); + + this.log( + `Activated ${scripts.length} scripts, ${iframes.length} iframes for ${category}` + ); + } + + /** + * Kategorie-Name fuer UI + */ + private getCategoryName(category: ConsentCategory): string { + const names: Record = { + essential: 'Essentielle Cookies', + functional: 'Funktionale Cookies', + analytics: 'Statistik-Cookies', + marketing: 'Marketing-Cookies', + social: 'Social Media-Cookies', + }; + return names[category] ?? category; + } + + /** + * Debug-Logging + */ + private log(...args: unknown[]): void { + if (this.config.debug) { + console.log('[ScriptBlocker]', ...args); + } + } +} + +export default ScriptBlocker; diff --git a/consent-sdk/src/core/index.ts b/consent-sdk/src/core/index.ts new file mode 100644 index 0000000..6d1fbb6 --- /dev/null +++ b/consent-sdk/src/core/index.ts @@ -0,0 +1,7 @@ +/** + * Core module exports + */ +export { ConsentManager } from './ConsentManager'; +export { ConsentStorage } from './ConsentStorage'; +export { ScriptBlocker } from './ScriptBlocker'; +export { ConsentAPI } from './ConsentAPI'; diff --git a/consent-sdk/src/index.ts b/consent-sdk/src/index.ts new file mode 100644 index 0000000..1a34b4e --- /dev/null +++ b/consent-sdk/src/index.ts @@ -0,0 +1,81 @@ +/** + * @breakpilot/consent-sdk + * + * DSGVO/TTDSG-konformes Consent Management SDK + * + * @example + * ```typescript + * import { ConsentManager } from '@breakpilot/consent-sdk'; + * + * const consent = new ConsentManager({ + * apiEndpoint: 'https://consent.example.com/api/v1', + * siteId: 'site_abc123', + * }); + * + * await consent.init(); + * + * if (consent.hasConsent('analytics')) { + * // Analytics laden + * } + * ``` + */ + +// Core +export { ConsentManager } from './core/ConsentManager'; +export { ConsentStorage } from './core/ConsentStorage'; +export { ScriptBlocker } from './core/ScriptBlocker'; +export { ConsentAPI } from './core/ConsentAPI'; + +// Utils +export { EventEmitter } from './utils/EventEmitter'; +export { generateFingerprint, generateFingerprintSync } from './utils/fingerprint'; + +// Types +export type { + // Categories + ConsentCategory, + ConsentCategories, + ConsentVendors, + + // State + ConsentState, + ConsentInput, + + // Config + ConsentConfig, + ConsentUIConfig, + ConsentBehaviorConfig, + TCFConfig, + PWAConfig, + BannerPosition, + BannerLayout, + BannerTheme, + + // Vendors + ConsentVendor, + CookieInfo, + + // API + ConsentAPIResponse, + SiteConfigResponse, + CategoryConfig, + LegalConfig, + + // Events + ConsentEventType, + ConsentEventCallback, + ConsentEventData, + + // Storage + ConsentStorageAdapter, + + // Translations + ConsentTranslations, + SupportedLanguage, +} from './types'; + +// Version +export { SDK_VERSION } from './version'; + +// Default export +export { ConsentManager as default } from './core/ConsentManager'; diff --git a/consent-sdk/src/mobile/README.md b/consent-sdk/src/mobile/README.md new file mode 100644 index 0000000..cd0078e --- /dev/null +++ b/consent-sdk/src/mobile/README.md @@ -0,0 +1,182 @@ +# Mobile SDKs - @breakpilot/consent-sdk + +## Übersicht + +Die Mobile SDKs bieten native Integration für iOS, Android und Flutter. + +## SDK Übersicht + +| Platform | Sprache | Min Version | Status | +|----------|---------|-------------|--------| +| iOS | Swift 5.9+ | iOS 15.0+ | 📋 Spec | +| Android | Kotlin | API 26+ | 📋 Spec | +| Flutter | Dart 3.0+ | Flutter 3.16+ | 📋 Spec | + +## Architektur + +``` +Mobile SDK +├── Core (shared) +│ ├── ConsentManager +│ ├── ConsentStorage (Keychain/SharedPrefs) +│ ├── API Client +│ └── Device Fingerprint +├── UI Components +│ ├── ConsentBanner +│ ├── ConsentSettings +│ └── ConsentGate +└── Platform-specific + ├── iOS: SwiftUI + UIKit + ├── Android: Jetpack Compose + XML + └── Flutter: Widgets +``` + +## Feature-Parität mit Web SDK + +| Feature | iOS | Android | Flutter | +|---------|-----|---------|---------| +| Consent Storage | Keychain | SharedPrefs | SecureStorage | +| Banner UI | SwiftUI | Compose | Widget | +| Settings Modal | ✓ | ✓ | ✓ | +| Category Control | ✓ | ✓ | ✓ | +| Vendor Control | ✓ | ✓ | ✓ | +| Offline Support | ✓ | ✓ | ✓ | +| Google Consent Mode | ✓ | ✓ | ✓ | +| ATT Integration | ✓ | - | ✓ (iOS) | +| TCF 2.2 | ✓ | ✓ | ✓ | + +## Installation + +### iOS (Swift Package Manager) + +```swift +// Package.swift +dependencies: [ + .package(url: "https://github.com/breakpilot/consent-sdk-ios.git", from: "1.0.0") +] +``` + +### Android (Gradle) + +```kotlin +// build.gradle.kts +dependencies { + implementation("com.breakpilot:consent-sdk:1.0.0") +} +``` + +### Flutter + +```yaml +# pubspec.yaml +dependencies: + breakpilot_consent_sdk: ^1.0.0 +``` + +## Quick Start + +### iOS + +```swift +import BreakpilotConsentSDK + +// AppDelegate.swift +ConsentManager.shared.configure( + apiEndpoint: "https://consent.example.com/api/v1", + siteId: "site_abc123" +) + +// ContentView.swift (SwiftUI) +struct ContentView: View { + @EnvironmentObject var consent: ConsentManager + + var body: some View { + VStack { + if consent.hasConsent(.analytics) { + AnalyticsView() + } + } + .consentBanner() + } +} +``` + +### Android + +```kotlin +import com.breakpilot.consent.ConsentManager +import com.breakpilot.consent.ui.ConsentBanner + +// Application.kt +class MyApp : Application() { + override fun onCreate() { + super.onCreate() + ConsentManager.configure( + context = this, + apiEndpoint = "https://consent.example.com/api/v1", + siteId = "site_abc123" + ) + } +} + +// MainActivity.kt (Jetpack Compose) +@Composable +fun MainScreen() { + val consent = ConsentManager.current + + if (consent.hasConsent(ConsentCategory.ANALYTICS)) { + AnalyticsComponent() + } + + ConsentBanner() +} +``` + +### Flutter + +```dart +import 'package:breakpilot_consent_sdk/consent_sdk.dart'; + +// main.dart +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + await ConsentManager.configure( + apiEndpoint: 'https://consent.example.com/api/v1', + siteId: 'site_abc123', + ); + + runApp(MyApp()); +} + +// Widget +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return ConsentProvider( + child: MaterialApp( + home: Scaffold( + body: Column( + children: [ + ConsentGate( + category: ConsentCategory.analytics, + child: AnalyticsWidget(), + placeholder: Text('Analytics nicht aktiviert'), + ), + ], + ), + bottomSheet: ConsentBanner(), + ), + ), + ); + } +} +``` + +## Dateien + +Siehe die einzelnen Platform-SDKs: + +- [iOS SDK Spec](./ios/README.md) +- [Android SDK Spec](./android/README.md) +- [Flutter SDK Spec](./flutter/README.md) diff --git a/consent-sdk/src/mobile/android/ConsentManager.kt b/consent-sdk/src/mobile/android/ConsentManager.kt new file mode 100644 index 0000000..f46cf12 --- /dev/null +++ b/consent-sdk/src/mobile/android/ConsentManager.kt @@ -0,0 +1,499 @@ +/** + * Android Consent SDK - ConsentManager + * + * DSGVO/TTDSG-konformes Consent Management fuer Android Apps. + * + * Nutzung: + * 1. In Application.onCreate() konfigurieren + * 2. In Activities/Fragments mit ConsentManager.current nutzen + * 3. In Jetpack Compose mit rememberConsentState() + * + * Copyright (c) 2025 BreakPilot + * Apache License 2.0 + */ + +package com.breakpilot.consent + +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import android.provider.Settings +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalContext +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKey +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import java.security.MessageDigest +import java.util.* + +// ============================================================================= +// Consent Categories +// ============================================================================= + +/** + * Standard-Consent-Kategorien nach IAB TCF 2.2 + */ +enum class ConsentCategory { + ESSENTIAL, // Technisch notwendig + FUNCTIONAL, // Personalisierung + ANALYTICS, // Nutzungsanalyse + MARKETING, // Werbung + SOCIAL // Social Media +} + +// ============================================================================= +// Consent State +// ============================================================================= + +/** + * Aktueller Consent-Zustand + */ +@Serializable +data class ConsentState( + val categories: Map = defaultCategories(), + val vendors: Map = emptyMap(), + val timestamp: Long = System.currentTimeMillis(), + val version: String = "1.0.0", + val consentId: String? = null, + val expiresAt: Long? = null, + val tcfString: String? = null +) { + companion object { + fun defaultCategories() = mapOf( + ConsentCategory.ESSENTIAL to true, + ConsentCategory.FUNCTIONAL to false, + ConsentCategory.ANALYTICS to false, + ConsentCategory.MARKETING to false, + ConsentCategory.SOCIAL to false + ) + + val DEFAULT = ConsentState() + } +} + +// ============================================================================= +// Configuration +// ============================================================================= + +/** + * SDK-Konfiguration + */ +data class ConsentConfig( + val apiEndpoint: String, + val siteId: String, + val language: String = Locale.getDefault().language, + val showRejectAll: Boolean = true, + val showAcceptAll: Boolean = true, + val granularControl: Boolean = true, + val rememberDays: Int = 365, + val debug: Boolean = false +) + +// ============================================================================= +// Consent Manager +// ============================================================================= + +/** + * Haupt-Manager fuer Consent-Verwaltung + */ +class ConsentManager private constructor() { + + // State + private val _consent = MutableStateFlow(ConsentState.DEFAULT) + val consent: StateFlow = _consent.asStateFlow() + + private val _isInitialized = MutableStateFlow(false) + val isInitialized: StateFlow = _isInitialized.asStateFlow() + + private val _isLoading = MutableStateFlow(true) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _isBannerVisible = MutableStateFlow(false) + val isBannerVisible: StateFlow = _isBannerVisible.asStateFlow() + + private val _isSettingsVisible = MutableStateFlow(false) + val isSettingsVisible: StateFlow = _isSettingsVisible.asStateFlow() + + // Private + private var config: ConsentConfig? = null + private var storage: ConsentStorage? = null + private var apiClient: ConsentApiClient? = null + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + + // Singleton + companion object { + @Volatile + private var INSTANCE: ConsentManager? = null + + val current: ConsentManager + get() = INSTANCE ?: synchronized(this) { + INSTANCE ?: ConsentManager().also { INSTANCE = it } + } + + /** + * Konfiguriert den ConsentManager + * Sollte in Application.onCreate() aufgerufen werden + */ + fun configure(context: Context, config: ConsentConfig) { + current.apply { + this.config = config + this.storage = ConsentStorage(context) + this.apiClient = ConsentApiClient(config.apiEndpoint, config.siteId) + + if (config.debug) { + println("[ConsentSDK] Configured with siteId: ${config.siteId}") + } + + scope.launch { + initialize(context) + } + } + } + } + + // ========================================================================== + // Initialization + // ========================================================================== + + private suspend fun initialize(context: Context) { + try { + // Lokalen Consent laden + storage?.load()?.let { stored -> + // Pruefen ob abgelaufen + val expiresAt = stored.expiresAt + if (expiresAt != null && System.currentTimeMillis() > expiresAt) { + _consent.value = ConsentState.DEFAULT + storage?.clear() + } else { + _consent.value = stored + } + } + + // Vom Server synchronisieren + try { + apiClient?.getConsent(DeviceFingerprint.generate(context))?.let { serverConsent -> + _consent.value = serverConsent + storage?.save(serverConsent) + } + } catch (e: Exception) { + if (config?.debug == true) { + println("[ConsentSDK] Failed to sync consent: $e") + } + } + + _isInitialized.value = true + + // Banner anzeigen falls noetig + if (needsConsent) { + showBanner() + } + } finally { + _isLoading.value = false + } + } + + // ========================================================================== + // Public API + // ========================================================================== + + /** + * Prueft ob Consent fuer Kategorie erteilt wurde + */ + fun hasConsent(category: ConsentCategory): Boolean { + // Essential ist immer erlaubt + if (category == ConsentCategory.ESSENTIAL) return true + return consent.value.categories[category] ?: false + } + + /** + * Prueft ob Consent eingeholt werden muss + */ + val needsConsent: Boolean + get() = consent.value.consentId == null + + /** + * Alle Kategorien akzeptieren + */ + suspend fun acceptAll() { + val newCategories = ConsentCategory.values().associateWith { true } + val newConsent = consent.value.copy( + categories = newCategories, + timestamp = System.currentTimeMillis() + ) + saveConsent(newConsent) + hideBanner() + } + + /** + * Alle nicht-essentiellen Kategorien ablehnen + */ + suspend fun rejectAll() { + val newCategories = ConsentCategory.values().associateWith { + it == ConsentCategory.ESSENTIAL + } + val newConsent = consent.value.copy( + categories = newCategories, + timestamp = System.currentTimeMillis() + ) + saveConsent(newConsent) + hideBanner() + } + + /** + * Auswahl speichern + */ + suspend fun saveSelection(categories: Map) { + val updated = categories.toMutableMap() + updated[ConsentCategory.ESSENTIAL] = true // Essential immer true + val newConsent = consent.value.copy( + categories = updated, + timestamp = System.currentTimeMillis() + ) + saveConsent(newConsent) + hideBanner() + } + + // ========================================================================== + // UI Control + // ========================================================================== + + fun showBanner() { + _isBannerVisible.value = true + } + + fun hideBanner() { + _isBannerVisible.value = false + } + + fun showSettings() { + _isSettingsVisible.value = true + } + + fun hideSettings() { + _isSettingsVisible.value = false + } + + // ========================================================================== + // Private Methods + // ========================================================================== + + private suspend fun saveConsent(newConsent: ConsentState) { + // Lokal speichern + storage?.save(newConsent) + + // An Server senden + try { + val response = apiClient?.saveConsent( + newConsent, + DeviceFingerprint.generate(storage?.context!!) + ) + val updated = newConsent.copy( + consentId = response?.consentId, + expiresAt = response?.expiresAt + ) + _consent.value = updated + storage?.save(updated) + } catch (e: Exception) { + // Lokal speichern auch bei Fehler + _consent.value = newConsent + if (config?.debug == true) { + println("[ConsentSDK] Failed to sync consent: $e") + } + } + } +} + +// ============================================================================= +// Storage +// ============================================================================= + +/** + * Sichere Speicherung mit EncryptedSharedPreferences + */ +internal class ConsentStorage(val context: Context) { + private val prefs: SharedPreferences by lazy { + val masterKey = MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build() + + EncryptedSharedPreferences.create( + context, + "breakpilot_consent", + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ) + } + + private val json = Json { ignoreUnknownKeys = true } + + fun load(): ConsentState? { + val data = prefs.getString("consent_state", null) ?: return null + return try { + json.decodeFromString(data) + } catch (e: Exception) { + null + } + } + + fun save(consent: ConsentState) { + val data = json.encodeToString(consent) + prefs.edit().putString("consent_state", data).apply() + } + + fun clear() { + prefs.edit().remove("consent_state").apply() + } +} + +// ============================================================================= +// API Client +// ============================================================================= + +/** + * API Client fuer Backend-Kommunikation + */ +internal class ConsentApiClient( + private val baseUrl: String, + private val siteId: String +) { + private val client = OkHttpClient() + private val json = Json { ignoreUnknownKeys = true } + + @Serializable + data class ConsentResponse( + val consentId: String, + val expiresAt: Long + ) + + suspend fun getConsent(fingerprint: String): ConsentState? = withContext(Dispatchers.IO) { + val request = Request.Builder() + .url("$baseUrl/banner/consent?site_id=$siteId&fingerprint=$fingerprint") + .get() + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@withContext null + val body = response.body?.string() ?: return@withContext null + json.decodeFromString(body) + } + } + + suspend fun saveConsent( + consent: ConsentState, + fingerprint: String + ): ConsentResponse = withContext(Dispatchers.IO) { + val body = """ + { + "site_id": "$siteId", + "device_fingerprint": "$fingerprint", + "categories": ${json.encodeToString(consent.categories.mapKeys { it.key.name.lowercase() })}, + "vendors": ${json.encodeToString(consent.vendors)}, + "platform": "android", + "app_version": "${BuildConfig.VERSION_NAME}" + } + """.trimIndent() + + val request = Request.Builder() + .url("$baseUrl/banner/consent") + .post(body.toRequestBody("application/json".toMediaType())) + .build() + + client.newCall(request).execute().use { response -> + val responseBody = response.body?.string() ?: throw Exception("Empty response") + json.decodeFromString(responseBody) + } + } +} + +// ============================================================================= +// Device Fingerprint +// ============================================================================= + +/** + * Privacy-konformer Device Fingerprint + */ +internal object DeviceFingerprint { + fun generate(context: Context): String { + // Android ID (reset bei Factory Reset) + val androidId = Settings.Secure.getString( + context.contentResolver, + Settings.Secure.ANDROID_ID + ) ?: UUID.randomUUID().toString() + + // Device Info + val model = Build.MODEL + val version = Build.VERSION.SDK_INT.toString() + val locale = Locale.getDefault().toString() + + // Hash erstellen + val raw = "$androidId-$model-$version-$locale" + val md = MessageDigest.getInstance("SHA-256") + val digest = md.digest(raw.toByteArray()) + return digest.joinToString("") { "%02x".format(it) } + } +} + +// ============================================================================= +// Jetpack Compose Integration +// ============================================================================= + +/** + * State Holder fuer Compose + */ +@Composable +fun rememberConsentState(): State { + return ConsentManager.current.consent.collectAsState() +} + +/** + * Banner Visibility State + */ +@Composable +fun rememberBannerVisibility(): State { + return ConsentManager.current.isBannerVisible.collectAsState() +} + +/** + * Consent Gate - Zeigt Inhalt nur bei Consent + */ +@Composable +fun ConsentGate( + category: ConsentCategory, + placeholder: @Composable () -> Unit = {}, + content: @Composable () -> Unit +) { + val consent by rememberConsentState() + + if (ConsentManager.current.hasConsent(category)) { + content() + } else { + placeholder() + } +} + +/** + * Local Composition fuer ConsentManager + */ +val LocalConsentManager = staticCompositionLocalOf { ConsentManager.current } + +/** + * Consent Provider + */ +@Composable +fun ConsentProvider( + content: @Composable () -> Unit +) { + CompositionLocalProvider( + LocalConsentManager provides ConsentManager.current + ) { + content() + } +} diff --git a/consent-sdk/src/mobile/flutter/consent_sdk.dart b/consent-sdk/src/mobile/flutter/consent_sdk.dart new file mode 100644 index 0000000..b032d0e --- /dev/null +++ b/consent-sdk/src/mobile/flutter/consent_sdk.dart @@ -0,0 +1,658 @@ +/// Flutter Consent SDK +/// +/// DSGVO/TTDSG-konformes Consent Management fuer Flutter Apps. +/// +/// Nutzung: +/// 1. In main() mit ConsentManager.configure() initialisieren +/// 2. App mit ConsentProvider wrappen +/// 3. Mit ConsentGate Inhalte schuetzen +/// +/// Copyright (c) 2025 BreakPilot +/// Apache License 2.0 + +library consent_sdk; + +import 'dart:convert'; +import 'dart:io'; +import 'package:crypto/crypto.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; +import 'package:provider/provider.dart'; + +// ============================================================================= +// Consent Categories +// ============================================================================= + +/// Standard-Consent-Kategorien nach IAB TCF 2.2 +enum ConsentCategory { + essential, // Technisch notwendig + functional, // Personalisierung + analytics, // Nutzungsanalyse + marketing, // Werbung + social, // Social Media +} + +// ============================================================================= +// Consent State +// ============================================================================= + +/// Aktueller Consent-Zustand +class ConsentState { + final Map categories; + final Map vendors; + final DateTime timestamp; + final String version; + final String? consentId; + final DateTime? expiresAt; + final String? tcfString; + + const ConsentState({ + required this.categories, + this.vendors = const {}, + required this.timestamp, + this.version = '1.0.0', + this.consentId, + this.expiresAt, + this.tcfString, + }); + + /// Default State mit nur essential = true + factory ConsentState.defaultState() { + return ConsentState( + categories: { + ConsentCategory.essential: true, + ConsentCategory.functional: false, + ConsentCategory.analytics: false, + ConsentCategory.marketing: false, + ConsentCategory.social: false, + }, + timestamp: DateTime.now(), + ); + } + + ConsentState copyWith({ + Map? categories, + Map? vendors, + DateTime? timestamp, + String? version, + String? consentId, + DateTime? expiresAt, + String? tcfString, + }) { + return ConsentState( + categories: categories ?? this.categories, + vendors: vendors ?? this.vendors, + timestamp: timestamp ?? this.timestamp, + version: version ?? this.version, + consentId: consentId ?? this.consentId, + expiresAt: expiresAt ?? this.expiresAt, + tcfString: tcfString ?? this.tcfString, + ); + } + + Map toJson() => { + 'categories': categories.map((k, v) => MapEntry(k.name, v)), + 'vendors': vendors, + 'timestamp': timestamp.toIso8601String(), + 'version': version, + 'consentId': consentId, + 'expiresAt': expiresAt?.toIso8601String(), + 'tcfString': tcfString, + }; + + factory ConsentState.fromJson(Map json) { + return ConsentState( + categories: (json['categories'] as Map).map( + (k, v) => MapEntry( + ConsentCategory.values.firstWhere((e) => e.name == k), + v as bool, + ), + ), + vendors: Map.from(json['vendors'] ?? {}), + timestamp: DateTime.parse(json['timestamp']), + version: json['version'] ?? '1.0.0', + consentId: json['consentId'], + expiresAt: json['expiresAt'] != null + ? DateTime.parse(json['expiresAt']) + : null, + tcfString: json['tcfString'], + ); + } +} + +// ============================================================================= +// Configuration +// ============================================================================= + +/// SDK-Konfiguration +class ConsentConfig { + final String apiEndpoint; + final String siteId; + final String language; + final bool showRejectAll; + final bool showAcceptAll; + final bool granularControl; + final int rememberDays; + final bool debug; + + const ConsentConfig({ + required this.apiEndpoint, + required this.siteId, + this.language = 'en', + this.showRejectAll = true, + this.showAcceptAll = true, + this.granularControl = true, + this.rememberDays = 365, + this.debug = false, + }); +} + +// ============================================================================= +// Consent Manager +// ============================================================================= + +/// Haupt-Manager fuer Consent-Verwaltung +class ConsentManager extends ChangeNotifier { + // Singleton + static ConsentManager? _instance; + static ConsentManager get instance => _instance!; + + // State + ConsentState _consent = ConsentState.defaultState(); + bool _isInitialized = false; + bool _isLoading = true; + bool _isBannerVisible = false; + bool _isSettingsVisible = false; + + // Private + ConsentConfig? _config; + late ConsentStorage _storage; + late ConsentApiClient _apiClient; + + // Getters + ConsentState get consent => _consent; + bool get isInitialized => _isInitialized; + bool get isLoading => _isLoading; + bool get isBannerVisible => _isBannerVisible; + bool get isSettingsVisible => _isSettingsVisible; + bool get needsConsent => _consent.consentId == null; + + // Private constructor + ConsentManager._(); + + /// Konfiguriert den ConsentManager + /// Sollte in main() vor runApp() aufgerufen werden + static Future configure(ConsentConfig config) async { + _instance = ConsentManager._(); + _instance!._config = config; + _instance!._storage = ConsentStorage(); + _instance!._apiClient = ConsentApiClient( + baseUrl: config.apiEndpoint, + siteId: config.siteId, + ); + + if (config.debug) { + debugPrint('[ConsentSDK] Configured with siteId: ${config.siteId}'); + } + + await _instance!._initialize(); + } + + // ========================================================================== + // Initialization + // ========================================================================== + + Future _initialize() async { + try { + // Lokalen Consent laden + final stored = await _storage.load(); + if (stored != null) { + // Pruefen ob abgelaufen + if (stored.expiresAt != null && + DateTime.now().isAfter(stored.expiresAt!)) { + _consent = ConsentState.defaultState(); + await _storage.clear(); + } else { + _consent = stored; + } + } + + // Vom Server synchronisieren + try { + final fingerprint = await DeviceFingerprint.generate(); + final serverConsent = await _apiClient.getConsent(fingerprint); + if (serverConsent != null) { + _consent = serverConsent; + await _storage.save(_consent); + } + } catch (e) { + if (_config?.debug == true) { + debugPrint('[ConsentSDK] Failed to sync consent: $e'); + } + } + + _isInitialized = true; + + // Banner anzeigen falls noetig + if (needsConsent) { + showBanner(); + } + } finally { + _isLoading = false; + notifyListeners(); + } + } + + // ========================================================================== + // Public API + // ========================================================================== + + /// Prueft ob Consent fuer Kategorie erteilt wurde + bool hasConsent(ConsentCategory category) { + // Essential ist immer erlaubt + if (category == ConsentCategory.essential) return true; + return _consent.categories[category] ?? false; + } + + /// Alle Kategorien akzeptieren + Future acceptAll() async { + final newCategories = { + for (var cat in ConsentCategory.values) cat: true + }; + final newConsent = _consent.copyWith( + categories: newCategories, + timestamp: DateTime.now(), + ); + await _saveConsent(newConsent); + hideBanner(); + } + + /// Alle nicht-essentiellen Kategorien ablehnen + Future rejectAll() async { + final newCategories = { + for (var cat in ConsentCategory.values) + cat: cat == ConsentCategory.essential + }; + final newConsent = _consent.copyWith( + categories: newCategories, + timestamp: DateTime.now(), + ); + await _saveConsent(newConsent); + hideBanner(); + } + + /// Auswahl speichern + Future saveSelection(Map categories) async { + final updated = Map.from(categories); + updated[ConsentCategory.essential] = true; // Essential immer true + final newConsent = _consent.copyWith( + categories: updated, + timestamp: DateTime.now(), + ); + await _saveConsent(newConsent); + hideBanner(); + } + + // ========================================================================== + // UI Control + // ========================================================================== + + void showBanner() { + _isBannerVisible = true; + notifyListeners(); + } + + void hideBanner() { + _isBannerVisible = false; + notifyListeners(); + } + + void showSettings() { + _isSettingsVisible = true; + notifyListeners(); + } + + void hideSettings() { + _isSettingsVisible = false; + notifyListeners(); + } + + // ========================================================================== + // Private Methods + // ========================================================================== + + Future _saveConsent(ConsentState newConsent) async { + // Lokal speichern + await _storage.save(newConsent); + + // An Server senden + try { + final fingerprint = await DeviceFingerprint.generate(); + final response = await _apiClient.saveConsent(newConsent, fingerprint); + final updated = newConsent.copyWith( + consentId: response['consentId'], + expiresAt: DateTime.parse(response['expiresAt']), + ); + _consent = updated; + await _storage.save(updated); + } catch (e) { + // Lokal speichern auch bei Fehler + _consent = newConsent; + if (_config?.debug == true) { + debugPrint('[ConsentSDK] Failed to sync consent: $e'); + } + } + + notifyListeners(); + } +} + +// ============================================================================= +// Storage +// ============================================================================= + +/// Sichere Speicherung mit flutter_secure_storage +class ConsentStorage { + final _storage = const FlutterSecureStorage(); + static const _key = 'breakpilot_consent_state'; + + Future load() async { + final data = await _storage.read(key: _key); + if (data == null) return null; + try { + return ConsentState.fromJson(jsonDecode(data)); + } catch (e) { + return null; + } + } + + Future save(ConsentState consent) async { + final data = jsonEncode(consent.toJson()); + await _storage.write(key: _key, value: data); + } + + Future clear() async { + await _storage.delete(key: _key); + } +} + +// ============================================================================= +// API Client +// ============================================================================= + +/// API Client fuer Backend-Kommunikation +class ConsentApiClient { + final String baseUrl; + final String siteId; + + ConsentApiClient({ + required this.baseUrl, + required this.siteId, + }); + + Future getConsent(String fingerprint) async { + final response = await http.get( + Uri.parse('$baseUrl/banner/consent?site_id=$siteId&fingerprint=$fingerprint'), + ); + + if (response.statusCode != 200) return null; + return ConsentState.fromJson(jsonDecode(response.body)); + } + + Future> saveConsent( + ConsentState consent, + String fingerprint, + ) async { + final response = await http.post( + Uri.parse('$baseUrl/banner/consent'), + headers: {'Content-Type': 'application/json'}, + body: jsonEncode({ + 'site_id': siteId, + 'device_fingerprint': fingerprint, + 'categories': consent.categories.map((k, v) => MapEntry(k.name, v)), + 'vendors': consent.vendors, + 'platform': Platform.isIOS ? 'ios' : 'android', + 'app_version': '1.0.0', // TODO: Get from package_info_plus + }), + ); + + return jsonDecode(response.body); + } +} + +// ============================================================================= +// Device Fingerprint +// ============================================================================= + +/// Privacy-konformer Device Fingerprint +class DeviceFingerprint { + static Future generate() async { + final deviceInfo = DeviceInfoPlugin(); + String rawId; + + if (Platform.isIOS) { + final iosInfo = await deviceInfo.iosInfo; + rawId = '${iosInfo.identifierForVendor}-${iosInfo.model}-${iosInfo.systemVersion}'; + } else if (Platform.isAndroid) { + final androidInfo = await deviceInfo.androidInfo; + rawId = '${androidInfo.id}-${androidInfo.model}-${androidInfo.version.sdkInt}'; + } else { + rawId = DateTime.now().millisecondsSinceEpoch.toString(); + } + + // SHA-256 Hash + final bytes = utf8.encode(rawId); + final digest = sha256.convert(bytes); + return digest.toString(); + } +} + +// ============================================================================= +// Flutter Widgets +// ============================================================================= + +/// Consent Provider - Wraps the app +class ConsentProvider extends StatelessWidget { + final Widget child; + + const ConsentProvider({ + super.key, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return ChangeNotifierProvider.value( + value: ConsentManager.instance, + child: child, + ); + } +} + +/// Consent Gate - Zeigt Inhalt nur bei Consent +class ConsentGate extends StatelessWidget { + final ConsentCategory category; + final Widget child; + final Widget? placeholder; + final Widget? loading; + + const ConsentGate({ + super.key, + required this.category, + required this.child, + this.placeholder, + this.loading, + }); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, consent, _) { + if (consent.isLoading) { + return loading ?? const SizedBox.shrink(); + } + + if (!consent.hasConsent(category)) { + return placeholder ?? const SizedBox.shrink(); + } + + return child; + }, + ); + } +} + +/// Consent Banner - Default Banner UI +class ConsentBanner extends StatelessWidget { + final String? title; + final String? description; + final String? acceptAllText; + final String? rejectAllText; + final String? settingsText; + + const ConsentBanner({ + super.key, + this.title, + this.description, + this.acceptAllText, + this.rejectAllText, + this.settingsText, + }); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, consent, _) { + if (!consent.isBannerVisible) { + return const SizedBox.shrink(); + } + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 20, + offset: const Offset(0, -5), + ), + ], + ), + child: SafeArea( + top: false, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title ?? 'Datenschutzeinstellungen', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 12), + Text( + description ?? + 'Wir nutzen Cookies und ähnliche Technologien, um Ihnen ein optimales Nutzererlebnis zu bieten.', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: consent.rejectAll, + child: Text(rejectAllText ?? 'Alle ablehnen'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: OutlinedButton( + onPressed: consent.showSettings, + child: Text(settingsText ?? 'Einstellungen'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FilledButton( + onPressed: consent.acceptAll, + child: Text(acceptAllText ?? 'Alle akzeptieren'), + ), + ), + ], + ), + ], + ), + ), + ); + }, + ); + } +} + +/// Consent Placeholder - Placeholder fuer blockierten Inhalt +class ConsentPlaceholder extends StatelessWidget { + final ConsentCategory category; + final String? message; + final String? buttonText; + + const ConsentPlaceholder({ + super.key, + required this.category, + this.message, + this.buttonText, + }); + + String get _categoryName { + switch (category) { + case ConsentCategory.essential: + return 'Essentielle Cookies'; + case ConsentCategory.functional: + return 'Funktionale Cookies'; + case ConsentCategory.analytics: + return 'Statistik-Cookies'; + case ConsentCategory.marketing: + return 'Marketing-Cookies'; + case ConsentCategory.social: + return 'Social Media-Cookies'; + } + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceVariant, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + message ?? 'Dieser Inhalt erfordert $_categoryName.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + OutlinedButton( + onPressed: ConsentManager.instance.showSettings, + child: Text(buttonText ?? 'Cookie-Einstellungen öffnen'), + ), + ], + ), + ); + } +} + +// ============================================================================= +// Extension for easy context access +// ============================================================================= + +extension ConsentExtension on BuildContext { + ConsentManager get consent => Provider.of(this, listen: false); + + bool hasConsent(ConsentCategory category) => consent.hasConsent(category); +} diff --git a/consent-sdk/src/mobile/ios/ConsentManager.swift b/consent-sdk/src/mobile/ios/ConsentManager.swift new file mode 100644 index 0000000..bf4133f --- /dev/null +++ b/consent-sdk/src/mobile/ios/ConsentManager.swift @@ -0,0 +1,517 @@ +/** + * iOS Consent SDK - ConsentManager + * + * DSGVO/TTDSG-konformes Consent Management fuer iOS Apps. + * + * Nutzung: + * 1. Im AppDelegate/App.init() konfigurieren + * 2. In SwiftUI Views mit @EnvironmentObject nutzen + * 3. Banner mit .consentBanner() Modifier anzeigen + * + * Copyright (c) 2025 BreakPilot + * Apache License 2.0 + */ + +import Foundation +import SwiftUI +import Combine +import CryptoKit + +// MARK: - Consent Categories + +/// Standard-Consent-Kategorien nach IAB TCF 2.2 +public enum ConsentCategory: String, CaseIterable, Codable { + case essential // Technisch notwendig + case functional // Personalisierung + case analytics // Nutzungsanalyse + case marketing // Werbung + case social // Social Media +} + +// MARK: - Consent State + +/// Aktueller Consent-Zustand +public struct ConsentState: Codable, Equatable { + public var categories: [ConsentCategory: Bool] + public var vendors: [String: Bool] + public var timestamp: Date + public var version: String + public var consentId: String? + public var expiresAt: Date? + public var tcfString: String? + + public init( + categories: [ConsentCategory: Bool] = [:], + vendors: [String: Bool] = [:], + timestamp: Date = Date(), + version: String = "1.0.0" + ) { + self.categories = categories + self.vendors = vendors + self.timestamp = timestamp + self.version = version + } + + /// Default State mit nur essential = true + public static var `default`: ConsentState { + ConsentState( + categories: [ + .essential: true, + .functional: false, + .analytics: false, + .marketing: false, + .social: false + ] + ) + } +} + +// MARK: - Configuration + +/// SDK-Konfiguration +public struct ConsentConfig { + public let apiEndpoint: String + public let siteId: String + public var language: String = Locale.current.language.languageCode?.identifier ?? "en" + public var showRejectAll: Bool = true + public var showAcceptAll: Bool = true + public var granularControl: Bool = true + public var rememberDays: Int = 365 + public var debug: Bool = false + + public init(apiEndpoint: String, siteId: String) { + self.apiEndpoint = apiEndpoint + self.siteId = siteId + } +} + +// MARK: - Consent Manager + +/// Haupt-Manager fuer Consent-Verwaltung +@MainActor +public final class ConsentManager: ObservableObject { + + // MARK: Singleton + + public static let shared = ConsentManager() + + // MARK: Published Properties + + @Published public private(set) var consent: ConsentState = .default + @Published public private(set) var isInitialized: Bool = false + @Published public private(set) var isLoading: Bool = true + @Published public private(set) var isBannerVisible: Bool = false + @Published public private(set) var isSettingsVisible: Bool = false + + // MARK: Private Properties + + private var config: ConsentConfig? + private var storage: ConsentStorage? + private var apiClient: ConsentAPIClient? + private var cancellables = Set() + + // MARK: - Initialization + + private init() {} + + /// Konfiguriert den ConsentManager + public func configure(_ config: ConsentConfig) { + self.config = config + self.storage = ConsentStorage() + self.apiClient = ConsentAPIClient( + baseURL: config.apiEndpoint, + siteId: config.siteId + ) + + if config.debug { + print("[ConsentSDK] Configured with siteId: \(config.siteId)") + } + + Task { + await initialize() + } + } + + /// Initialisiert und laedt gespeicherten Consent + private func initialize() async { + defer { isLoading = false } + + // Lokalen Consent laden + if let stored = storage?.load() { + consent = stored + + // Pruefen ob abgelaufen + if let expiresAt = stored.expiresAt, Date() > expiresAt { + consent = .default + storage?.clear() + } + } + + // Vom Server synchronisieren (optional) + do { + if let serverConsent = try await apiClient?.getConsent( + fingerprint: DeviceFingerprint.generate() + ) { + consent = serverConsent + storage?.save(consent) + } + } catch { + if config?.debug == true { + print("[ConsentSDK] Failed to sync consent: \(error)") + } + } + + isInitialized = true + + // Banner anzeigen falls noetig + if needsConsent { + showBanner() + } + } + + // MARK: - Public API + + /// Prueft ob Consent fuer Kategorie erteilt wurde + public func hasConsent(_ category: ConsentCategory) -> Bool { + // Essential ist immer erlaubt + if category == .essential { return true } + return consent.categories[category] ?? false + } + + /// Prueft ob Consent eingeholt werden muss + public var needsConsent: Bool { + consent.consentId == nil + } + + /// Alle Kategorien akzeptieren + public func acceptAll() async { + var newConsent = consent + for category in ConsentCategory.allCases { + newConsent.categories[category] = true + } + newConsent.timestamp = Date() + + await saveConsent(newConsent) + hideBanner() + } + + /// Alle nicht-essentiellen Kategorien ablehnen + public func rejectAll() async { + var newConsent = consent + for category in ConsentCategory.allCases { + newConsent.categories[category] = category == .essential + } + newConsent.timestamp = Date() + + await saveConsent(newConsent) + hideBanner() + } + + /// Auswahl speichern + public func saveSelection(_ categories: [ConsentCategory: Bool]) async { + var newConsent = consent + newConsent.categories = categories + newConsent.categories[.essential] = true // Essential immer true + newConsent.timestamp = Date() + + await saveConsent(newConsent) + hideBanner() + } + + // MARK: - UI Control + + /// Banner anzeigen + public func showBanner() { + isBannerVisible = true + } + + /// Banner ausblenden + public func hideBanner() { + isBannerVisible = false + } + + /// Einstellungen anzeigen + public func showSettings() { + isSettingsVisible = true + } + + /// Einstellungen ausblenden + public func hideSettings() { + isSettingsVisible = false + } + + // MARK: - Private Methods + + private func saveConsent(_ newConsent: ConsentState) async { + // Lokal speichern + storage?.save(newConsent) + + // An Server senden + do { + let response = try await apiClient?.saveConsent( + consent: newConsent, + fingerprint: DeviceFingerprint.generate() + ) + var updated = newConsent + updated.consentId = response?.consentId + updated.expiresAt = response?.expiresAt + consent = updated + storage?.save(updated) + } catch { + // Lokal speichern auch bei Fehler + consent = newConsent + if config?.debug == true { + print("[ConsentSDK] Failed to sync consent: \(error)") + } + } + } +} + +// MARK: - Storage + +/// Sichere Speicherung im Keychain +final class ConsentStorage { + private let key = "com.breakpilot.consent.state" + + func load() -> ConsentState? { + guard let data = KeychainHelper.read(key: key) else { return nil } + return try? JSONDecoder().decode(ConsentState.self, from: data) + } + + func save(_ consent: ConsentState) { + guard let data = try? JSONEncoder().encode(consent) else { return } + KeychainHelper.write(data: data, key: key) + } + + func clear() { + KeychainHelper.delete(key: key) + } +} + +/// Keychain Helper +enum KeychainHelper { + static func write(data: Data, key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + SecItemDelete(query as CFDictionary) + SecItemAdd(query as CFDictionary, nil) + } + + static func read(key: String) -> Data? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key, + kSecReturnData as String: true + ] + var result: AnyObject? + SecItemCopyMatching(query as CFDictionary, &result) + return result as? Data + } + + static func delete(key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: key + ] + SecItemDelete(query as CFDictionary) + } +} + +// MARK: - API Client + +/// API Client fuer Backend-Kommunikation +final class ConsentAPIClient { + private let baseURL: String + private let siteId: String + + init(baseURL: String, siteId: String) { + self.baseURL = baseURL + self.siteId = siteId + } + + struct ConsentResponse: Codable { + let consentId: String + let expiresAt: Date + } + + func getConsent(fingerprint: String) async throws -> ConsentState? { + let url = URL(string: "\(baseURL)/banner/consent?site_id=\(siteId)&fingerprint=\(fingerprint)")! + let (data, response) = try await URLSession.shared.data(from: url) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + return nil + } + + return try JSONDecoder().decode(ConsentState.self, from: data) + } + + func saveConsent(consent: ConsentState, fingerprint: String) async throws -> ConsentResponse { + var request = URLRequest(url: URL(string: "\(baseURL)/banner/consent")!) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body: [String: Any] = [ + "site_id": siteId, + "device_fingerprint": fingerprint, + "categories": Dictionary( + uniqueKeysWithValues: consent.categories.map { ($0.key.rawValue, $0.value) } + ), + "vendors": consent.vendors, + "platform": "ios", + "app_version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0.0" + ] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + + let (data, _) = try await URLSession.shared.data(for: request) + return try JSONDecoder().decode(ConsentResponse.self, from: data) + } +} + +// MARK: - Device Fingerprint + +/// Privacy-konformer Device Fingerprint +enum DeviceFingerprint { + static func generate() -> String { + // Vendor ID (reset-safe) + let vendorId = UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString + + // System Info + let model = UIDevice.current.model + let systemVersion = UIDevice.current.systemVersion + let locale = Locale.current.identifier + + // Hash erstellen + let raw = "\(vendorId)-\(model)-\(systemVersion)-\(locale)" + let data = Data(raw.utf8) + let hash = SHA256.hash(data: data) + return hash.compactMap { String(format: "%02x", $0) }.joined() + } +} + +// MARK: - SwiftUI Extensions + +/// Environment Key fuer ConsentManager +private struct ConsentManagerKey: EnvironmentKey { + static let defaultValue = ConsentManager.shared +} + +extension EnvironmentValues { + public var consentManager: ConsentManager { + get { self[ConsentManagerKey.self] } + set { self[ConsentManagerKey.self] = newValue } + } +} + +/// Banner ViewModifier +public struct ConsentBannerModifier: ViewModifier { + @ObservedObject var consent = ConsentManager.shared + + public func body(content: Content) -> some View { + ZStack { + content + + if consent.isBannerVisible { + ConsentBannerView() + } + } + } +} + +extension View { + /// Fuegt einen Consent-Banner hinzu + public func consentBanner() -> some View { + modifier(ConsentBannerModifier()) + } +} + +// MARK: - Banner View + +/// Default Consent Banner UI +public struct ConsentBannerView: View { + @ObservedObject var consent = ConsentManager.shared + + public init() {} + + public var body: some View { + VStack(spacing: 0) { + Spacer() + + VStack(spacing: 16) { + Text("Datenschutzeinstellungen") + .font(.headline) + + Text("Wir nutzen Cookies und ähnliche Technologien, um Ihnen ein optimales Nutzererlebnis zu bieten.") + .font(.subheadline) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + HStack(spacing: 12) { + Button("Alle ablehnen") { + Task { await consent.rejectAll() } + } + .buttonStyle(.bordered) + + Button("Einstellungen") { + consent.showSettings() + } + .buttonStyle(.bordered) + + Button("Alle akzeptieren") { + Task { await consent.acceptAll() } + } + .buttonStyle(.borderedProminent) + } + } + .padding(24) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .padding() + .shadow(radius: 20) + } + .transition(.move(edge: .bottom).combined(with: .opacity)) + .animation(.spring(), value: consent.isBannerVisible) + } +} + +// MARK: - Consent Gate + +/// Zeigt Inhalt nur bei Consent an +public struct ConsentGate: View { + let category: ConsentCategory + let content: () -> Content + let placeholder: () -> Placeholder + + @ObservedObject var consent = ConsentManager.shared + + public init( + category: ConsentCategory, + @ViewBuilder content: @escaping () -> Content, + @ViewBuilder placeholder: @escaping () -> Placeholder + ) { + self.category = category + self.content = content + self.placeholder = placeholder + } + + public var body: some View { + if consent.hasConsent(category) { + content() + } else { + placeholder() + } + } +} + +extension ConsentGate where Placeholder == EmptyView { + public init( + category: ConsentCategory, + @ViewBuilder content: @escaping () -> Content + ) { + self.init(category: category, content: content, placeholder: { EmptyView() }) + } +} diff --git a/consent-sdk/src/react/index.tsx b/consent-sdk/src/react/index.tsx new file mode 100644 index 0000000..abaf0bb --- /dev/null +++ b/consent-sdk/src/react/index.tsx @@ -0,0 +1,511 @@ +/** + * React Integration fuer @breakpilot/consent-sdk + * + * @example + * ```tsx + * import { ConsentProvider, useConsent, ConsentBanner } from '@breakpilot/consent-sdk/react'; + * + * function App() { + * return ( + * + * + * + * + * ); + * } + * ``` + */ + +import { + createContext, + useContext, + useEffect, + useState, + useCallback, + useMemo, + type ReactNode, + type FC, +} from 'react'; +import { ConsentManager } from '../core/ConsentManager'; +import type { + ConsentConfig, + ConsentState, + ConsentCategory, + ConsentCategories, +} from '../types'; + +// ============================================================================= +// Context +// ============================================================================= + +interface ConsentContextValue { + /** ConsentManager Instanz */ + manager: ConsentManager | null; + + /** Aktueller Consent-State */ + consent: ConsentState | null; + + /** Ist SDK initialisiert? */ + isInitialized: boolean; + + /** Wird geladen? */ + isLoading: boolean; + + /** Ist Banner sichtbar? */ + isBannerVisible: boolean; + + /** Wird Consent benoetigt? */ + needsConsent: boolean; + + /** Consent fuer Kategorie pruefen */ + hasConsent: (category: ConsentCategory) => boolean; + + /** Alle akzeptieren */ + acceptAll: () => Promise; + + /** Alle ablehnen */ + rejectAll: () => Promise; + + /** Auswahl speichern */ + saveSelection: (categories: Partial) => Promise; + + /** Banner anzeigen */ + showBanner: () => void; + + /** Banner verstecken */ + hideBanner: () => void; + + /** Einstellungen oeffnen */ + showSettings: () => void; +} + +const ConsentContext = createContext(null); + +// ============================================================================= +// Provider +// ============================================================================= + +interface ConsentProviderProps { + /** SDK-Konfiguration */ + config: ConsentConfig; + + /** Kinder-Komponenten */ + children: ReactNode; +} + +/** + * ConsentProvider - Stellt Consent-Kontext bereit + */ +export const ConsentProvider: FC = ({ + config, + children, +}) => { + const [manager, setManager] = useState(null); + const [consent, setConsent] = useState(null); + const [isInitialized, setIsInitialized] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isBannerVisible, setIsBannerVisible] = useState(false); + + // Manager erstellen und initialisieren + useEffect(() => { + const consentManager = new ConsentManager(config); + setManager(consentManager); + + // Events abonnieren + const unsubChange = consentManager.on('change', (newConsent) => { + setConsent(newConsent); + }); + + const unsubBannerShow = consentManager.on('banner_show', () => { + setIsBannerVisible(true); + }); + + const unsubBannerHide = consentManager.on('banner_hide', () => { + setIsBannerVisible(false); + }); + + // Initialisieren + consentManager + .init() + .then(() => { + setConsent(consentManager.getConsent()); + setIsInitialized(true); + setIsLoading(false); + setIsBannerVisible(consentManager.isBannerVisible()); + }) + .catch((error) => { + console.error('Failed to initialize ConsentManager:', error); + setIsLoading(false); + }); + + // Cleanup + return () => { + unsubChange(); + unsubBannerShow(); + unsubBannerHide(); + }; + }, [config]); + + // Callback-Funktionen + const hasConsent = useCallback( + (category: ConsentCategory): boolean => { + return manager?.hasConsent(category) ?? category === 'essential'; + }, + [manager] + ); + + const acceptAll = useCallback(async () => { + await manager?.acceptAll(); + }, [manager]); + + const rejectAll = useCallback(async () => { + await manager?.rejectAll(); + }, [manager]); + + const saveSelection = useCallback( + async (categories: Partial) => { + await manager?.setConsent(categories); + manager?.hideBanner(); + }, + [manager] + ); + + const showBanner = useCallback(() => { + manager?.showBanner(); + }, [manager]); + + const hideBanner = useCallback(() => { + manager?.hideBanner(); + }, [manager]); + + const showSettings = useCallback(() => { + manager?.showSettings(); + }, [manager]); + + const needsConsent = useMemo(() => { + return manager?.needsConsent() ?? true; + }, [manager, consent]); + + // Context-Wert + const contextValue = useMemo( + () => ({ + manager, + consent, + isInitialized, + isLoading, + isBannerVisible, + needsConsent, + hasConsent, + acceptAll, + rejectAll, + saveSelection, + showBanner, + hideBanner, + showSettings, + }), + [ + manager, + consent, + isInitialized, + isLoading, + isBannerVisible, + needsConsent, + hasConsent, + acceptAll, + rejectAll, + saveSelection, + showBanner, + hideBanner, + showSettings, + ] + ); + + return ( + + {children} + + ); +}; + +// ============================================================================= +// Hooks +// ============================================================================= + +/** + * useConsent - Hook fuer Consent-Zugriff + * + * @example + * ```tsx + * const { hasConsent, acceptAll, rejectAll } = useConsent(); + * + * if (hasConsent('analytics')) { + * // Analytics laden + * } + * ``` + */ +export function useConsent(): ConsentContextValue; +export function useConsent( + category: ConsentCategory +): ConsentContextValue & { allowed: boolean }; +export function useConsent(category?: ConsentCategory) { + const context = useContext(ConsentContext); + + if (!context) { + throw new Error('useConsent must be used within a ConsentProvider'); + } + + if (category) { + return { + ...context, + allowed: context.hasConsent(category), + }; + } + + return context; +} + +/** + * useConsentManager - Direkter Zugriff auf ConsentManager + */ +export function useConsentManager(): ConsentManager | null { + const context = useContext(ConsentContext); + return context?.manager ?? null; +} + +// ============================================================================= +// Components +// ============================================================================= + +interface ConsentGateProps { + /** Erforderliche Kategorie */ + category: ConsentCategory; + + /** Inhalt bei Consent */ + children: ReactNode; + + /** Inhalt ohne Consent */ + placeholder?: ReactNode; + + /** Fallback waehrend Laden */ + fallback?: ReactNode; +} + +/** + * ConsentGate - Zeigt Inhalt nur bei Consent + * + * @example + * ```tsx + * } + * > + * + * + * ``` + */ +export const ConsentGate: FC = ({ + category, + children, + placeholder = null, + fallback = null, +}) => { + const { hasConsent, isLoading } = useConsent(); + + if (isLoading) { + return <>{fallback}; + } + + if (!hasConsent(category)) { + return <>{placeholder}; + } + + return <>{children}; +}; + +interface ConsentPlaceholderProps { + /** Kategorie */ + category: ConsentCategory; + + /** Custom Nachricht */ + message?: string; + + /** Custom Button-Text */ + buttonText?: string; + + /** Custom Styling */ + className?: string; +} + +/** + * ConsentPlaceholder - Placeholder fuer blockierten Inhalt + */ +export const ConsentPlaceholder: FC = ({ + category, + message, + buttonText, + className = '', +}) => { + const { showSettings } = useConsent(); + + const categoryNames: Record = { + essential: 'Essentielle Cookies', + functional: 'Funktionale Cookies', + analytics: 'Statistik-Cookies', + marketing: 'Marketing-Cookies', + social: 'Social Media-Cookies', + }; + + const defaultMessage = `Dieser Inhalt erfordert ${categoryNames[category]}.`; + + return ( +
              +

              {message || defaultMessage}

              + +
              + ); +}; + +// ============================================================================= +// Banner Component (Headless) +// ============================================================================= + +interface ConsentBannerRenderProps { + /** Ist Banner sichtbar? */ + isVisible: boolean; + + /** Aktueller Consent */ + consent: ConsentState | null; + + /** Wird Consent benoetigt? */ + needsConsent: boolean; + + /** Alle akzeptieren */ + onAcceptAll: () => void; + + /** Alle ablehnen */ + onRejectAll: () => void; + + /** Auswahl speichern */ + onSaveSelection: (categories: Partial) => void; + + /** Einstellungen oeffnen */ + onShowSettings: () => void; + + /** Banner schliessen */ + onClose: () => void; +} + +interface ConsentBannerProps { + /** Render-Funktion fuer Custom UI */ + render?: (props: ConsentBannerRenderProps) => ReactNode; + + /** Custom Styling */ + className?: string; +} + +/** + * ConsentBanner - Headless Banner-Komponente + * + * Kann mit eigener UI gerendert werden oder nutzt Default-UI. + * + * @example + * ```tsx + * // Mit eigener UI + * ( + * isVisible && ( + *
              + * + * + *
              + * ) + * )} + * /> + * + * // Mit Default-UI + * + * ``` + */ +export const ConsentBanner: FC = ({ render, className }) => { + const { + consent, + isBannerVisible, + needsConsent, + acceptAll, + rejectAll, + saveSelection, + showSettings, + hideBanner, + } = useConsent(); + + const renderProps: ConsentBannerRenderProps = { + isVisible: isBannerVisible, + consent, + needsConsent, + onAcceptAll: acceptAll, + onRejectAll: rejectAll, + onSaveSelection: saveSelection, + onShowSettings: showSettings, + onClose: hideBanner, + }; + + // Custom Render + if (render) { + return <>{render(renderProps)}; + } + + // Default UI + if (!isBannerVisible) { + return null; + } + + return ( +
              +
              +

              Datenschutzeinstellungen

              +

              + Wir nutzen Cookies und aehnliche Technologien, um Ihnen ein optimales + Nutzererlebnis zu bieten. +

              + +
              + + + +
              +
              +
              + ); +}; + +// ============================================================================= +// Exports +// ============================================================================= + +export { ConsentContext }; +export type { ConsentContextValue, ConsentBannerRenderProps }; diff --git a/consent-sdk/src/types/index.ts b/consent-sdk/src/types/index.ts new file mode 100644 index 0000000..f017c07 --- /dev/null +++ b/consent-sdk/src/types/index.ts @@ -0,0 +1,438 @@ +/** + * Consent SDK Types + * + * DSGVO/TTDSG-konforme Typdefinitionen für das Consent Management System. + */ + +// ============================================================================= +// Consent Categories +// ============================================================================= + +/** + * Standard-Consent-Kategorien nach IAB TCF 2.2 + */ +export type ConsentCategory = + | 'essential' // Technisch notwendig (TTDSG § 25 Abs. 2) + | 'functional' // Personalisierung, Komfortfunktionen + | 'analytics' // Anonyme Nutzungsanalyse + | 'marketing' // Werbung, Retargeting + | 'social'; // Social Media Plugins + +/** + * Consent-Status pro Kategorie + */ +export type ConsentCategories = Record; + +/** + * Consent-Status pro Vendor + */ +export type ConsentVendors = Record; + +// ============================================================================= +// Consent State +// ============================================================================= + +/** + * Aktueller Consent-Zustand + */ +export interface ConsentState { + /** Consent pro Kategorie */ + categories: ConsentCategories; + + /** Consent pro Vendor (optional, für granulare Kontrolle) */ + vendors: ConsentVendors; + + /** Zeitstempel der letzten Aenderung */ + timestamp: string; + + /** SDK-Version bei Erstellung */ + version: string; + + /** Eindeutige Consent-ID vom Backend */ + consentId?: string; + + /** Ablaufdatum */ + expiresAt?: string; + + /** IAB TCF String (falls aktiviert) */ + tcfString?: string; +} + +/** + * Minimaler Consent-Input fuer setConsent() + */ +export type ConsentInput = Partial | { + categories?: Partial; + vendors?: ConsentVendors; +}; + +// ============================================================================= +// Configuration +// ============================================================================= + +/** + * UI-Position des Banners + */ +export type BannerPosition = 'bottom' | 'top' | 'center'; + +/** + * Banner-Layout + */ +export type BannerLayout = 'bar' | 'modal' | 'floating'; + +/** + * Farbschema + */ +export type BannerTheme = 'light' | 'dark' | 'auto'; + +/** + * UI-Konfiguration + */ +export interface ConsentUIConfig { + /** Position des Banners */ + position?: BannerPosition; + + /** Layout-Typ */ + layout?: BannerLayout; + + /** Farbschema */ + theme?: BannerTheme; + + /** Pfad zu Custom CSS */ + customCss?: string; + + /** z-index fuer Banner */ + zIndex?: number; + + /** Scroll blockieren bei Modal */ + blockScrollOnModal?: boolean; + + /** Custom Container-ID */ + containerId?: string; +} + +/** + * Consent-Verhaltens-Konfiguration + */ +export interface ConsentBehaviorConfig { + /** Muss Nutzer interagieren? */ + required?: boolean; + + /** "Alle ablehnen" Button sichtbar */ + rejectAllVisible?: boolean; + + /** "Alle akzeptieren" Button sichtbar */ + acceptAllVisible?: boolean; + + /** Einzelne Kategorien waehlbar */ + granularControl?: boolean; + + /** Einzelne Vendors waehlbar */ + vendorControl?: boolean; + + /** Auswahl speichern */ + rememberChoice?: boolean; + + /** Speicherdauer in Tagen */ + rememberDays?: number; + + /** Nur in EU anzeigen (Geo-Targeting) */ + geoTargeting?: boolean; + + /** Erneut nachfragen nach X Tagen */ + recheckAfterDays?: number; +} + +/** + * TCF 2.2 Konfiguration + */ +export interface TCFConfig { + /** TCF aktivieren */ + enabled?: boolean; + + /** CMP ID */ + cmpId?: number; + + /** CMP Version */ + cmpVersion?: number; +} + +/** + * PWA-spezifische Konfiguration + */ +export interface PWAConfig { + /** Offline-Unterstuetzung aktivieren */ + offlineSupport?: boolean; + + /** Bei Reconnect synchronisieren */ + syncOnReconnect?: boolean; + + /** Cache-Strategie */ + cacheStrategy?: 'stale-while-revalidate' | 'network-first' | 'cache-first'; +} + +/** + * Haupt-Konfiguration fuer ConsentManager + */ +export interface ConsentConfig { + // Pflichtfelder + /** API-Endpunkt fuer Consent-Backend */ + apiEndpoint: string; + + /** Site-ID */ + siteId: string; + + // Sprache + /** Sprache (ISO 639-1) */ + language?: string; + + /** Fallback-Sprache */ + fallbackLanguage?: string; + + // UI + /** UI-Konfiguration */ + ui?: ConsentUIConfig; + + // Verhalten + /** Consent-Verhaltens-Konfiguration */ + consent?: ConsentBehaviorConfig; + + // Kategorien + /** Aktive Kategorien */ + categories?: ConsentCategory[]; + + // TCF + /** TCF 2.2 Konfiguration */ + tcf?: TCFConfig; + + // PWA + /** PWA-Konfiguration */ + pwa?: PWAConfig; + + // Callbacks + /** Callback bei Consent-Aenderung */ + onConsentChange?: (consent: ConsentState) => void; + + /** Callback wenn Banner angezeigt wird */ + onBannerShow?: () => void; + + /** Callback wenn Banner geschlossen wird */ + onBannerHide?: () => void; + + /** Callback bei Fehler */ + onError?: (error: Error) => void; + + // Debug + /** Debug-Modus aktivieren */ + debug?: boolean; +} + +// ============================================================================= +// Vendor Configuration +// ============================================================================= + +/** + * Cookie-Information + */ +export interface CookieInfo { + /** Cookie-Name */ + name: string; + + /** Cookie-Domain */ + domain: string; + + /** Ablaufzeit (z.B. "2 Jahre", "Session") */ + expiration: string; + + /** Speichertyp */ + type: 'http' | 'localStorage' | 'sessionStorage' | 'indexedDB'; + + /** Beschreibung */ + description: string; +} + +/** + * Vendor-Definition + */ +export interface ConsentVendor { + /** Eindeutige Vendor-ID */ + id: string; + + /** Anzeigename */ + name: string; + + /** Kategorie */ + category: ConsentCategory; + + /** IAB TCF Purposes (falls relevant) */ + purposes?: number[]; + + /** Legitimate Interests */ + legitimateInterests?: number[]; + + /** Cookie-Liste */ + cookies: CookieInfo[]; + + /** Link zur Datenschutzerklaerung */ + privacyPolicyUrl: string; + + /** Datenaufbewahrung */ + dataRetention?: string; + + /** Datentransfer (z.B. "USA (EU-US DPF)", "EU") */ + dataTransfer?: string; +} + +// ============================================================================= +// API Types +// ============================================================================= + +/** + * API-Antwort fuer Consent-Erstellung + */ +export interface ConsentAPIResponse { + consentId: string; + timestamp: string; + expiresAt: string; + version: string; +} + +/** + * API-Antwort fuer Site-Konfiguration + */ +export interface SiteConfigResponse { + siteId: string; + siteName: string; + categories: CategoryConfig[]; + ui: ConsentUIConfig; + legal: LegalConfig; + tcf?: TCFConfig; +} + +/** + * Kategorie-Konfiguration vom Server + */ +export interface CategoryConfig { + id: ConsentCategory; + name: Record; + description: Record; + required: boolean; + vendors: ConsentVendor[]; +} + +/** + * Rechtliche Konfiguration + */ +export interface LegalConfig { + privacyPolicyUrl: string; + imprintUrl: string; + dpo?: { + name: string; + email: string; + }; +} + +// ============================================================================= +// Events +// ============================================================================= + +/** + * Event-Typen + */ +export type ConsentEventType = + | 'init' + | 'change' + | 'accept_all' + | 'reject_all' + | 'save_selection' + | 'banner_show' + | 'banner_hide' + | 'settings_open' + | 'settings_close' + | 'vendor_enable' + | 'vendor_disable' + | 'error'; + +/** + * Event-Listener Callback + */ +export type ConsentEventCallback = (data: T) => void; + +/** + * Event-Daten fuer verschiedene Events + */ +export type ConsentEventData = { + init: ConsentState | null; + change: ConsentState; + accept_all: ConsentState; + reject_all: ConsentState; + save_selection: ConsentState; + banner_show: undefined; + banner_hide: undefined; + settings_open: undefined; + settings_close: undefined; + vendor_enable: string; + vendor_disable: string; + error: Error; +}; + +// ============================================================================= +// Storage +// ============================================================================= + +/** + * Storage-Adapter Interface + */ +export interface ConsentStorageAdapter { + /** Consent laden */ + get(): ConsentState | null; + + /** Consent speichern */ + set(consent: ConsentState): void; + + /** Consent loeschen */ + clear(): void; + + /** Pruefen ob Consent existiert */ + exists(): boolean; +} + +// ============================================================================= +// Translations +// ============================================================================= + +/** + * Uebersetzungsstruktur + */ +export interface ConsentTranslations { + title: string; + description: string; + acceptAll: string; + rejectAll: string; + settings: string; + saveSelection: string; + close: string; + categories: { + [K in ConsentCategory]: { + name: string; + description: string; + }; + }; + footer: { + privacyPolicy: string; + imprint: string; + cookieDetails: string; + }; + accessibility: { + closeButton: string; + categoryToggle: string; + requiredCategory: string; + }; +} + +/** + * Alle unterstuetzten Sprachen + */ +export type SupportedLanguage = + | 'de' | 'en' | 'fr' | 'es' | 'it' | 'nl' | 'pl' | 'pt' + | 'cs' | 'da' | 'el' | 'fi' | 'hu' | 'ro' | 'sk' | 'sl' | 'sv'; diff --git a/consent-sdk/src/utils/EventEmitter.test.ts b/consent-sdk/src/utils/EventEmitter.test.ts new file mode 100644 index 0000000..b8c29eb --- /dev/null +++ b/consent-sdk/src/utils/EventEmitter.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from './EventEmitter'; + +interface TestEvents { + test: string; + count: number; + data: { value: string }; +} + +describe('EventEmitter', () => { + let emitter: EventEmitter; + + beforeEach(() => { + emitter = new EventEmitter(); + }); + + describe('on', () => { + it('should register an event listener', () => { + const callback = vi.fn(); + emitter.on('test', callback); + + emitter.emit('test', 'hello'); + + expect(callback).toHaveBeenCalledWith('hello'); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should return an unsubscribe function', () => { + const callback = vi.fn(); + const unsubscribe = emitter.on('test', callback); + + emitter.emit('test', 'first'); + unsubscribe(); + emitter.emit('test', 'second'); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('first'); + }); + + it('should allow multiple listeners for the same event', () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + emitter.on('test', callback1); + emitter.on('test', callback2); + emitter.emit('test', 'value'); + + expect(callback1).toHaveBeenCalledWith('value'); + expect(callback2).toHaveBeenCalledWith('value'); + }); + }); + + describe('off', () => { + it('should remove an event listener', () => { + const callback = vi.fn(); + emitter.on('test', callback); + + emitter.emit('test', 'first'); + emitter.off('test', callback); + emitter.emit('test', 'second'); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should not throw when removing non-existent listener', () => { + const callback = vi.fn(); + expect(() => emitter.off('test', callback)).not.toThrow(); + }); + }); + + describe('emit', () => { + it('should call all listeners with the data', () => { + const callback = vi.fn(); + emitter.on('data', callback); + + emitter.emit('data', { value: 'test' }); + + expect(callback).toHaveBeenCalledWith({ value: 'test' }); + }); + + it('should not throw when emitting to no listeners', () => { + expect(() => emitter.emit('test', 'value')).not.toThrow(); + }); + + it('should catch errors in listeners and continue', () => { + const errorCallback = vi.fn(() => { + throw new Error('Test error'); + }); + const successCallback = vi.fn(); + + emitter.on('test', errorCallback); + emitter.on('test', successCallback); + + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + emitter.emit('test', 'value'); + + expect(errorCallback).toHaveBeenCalled(); + expect(successCallback).toHaveBeenCalled(); + expect(consoleSpy).toHaveBeenCalled(); + + consoleSpy.mockRestore(); + }); + }); + + describe('once', () => { + it('should call listener only once', () => { + const callback = vi.fn(); + emitter.once('test', callback); + + emitter.emit('test', 'first'); + emitter.emit('test', 'second'); + + expect(callback).toHaveBeenCalledTimes(1); + expect(callback).toHaveBeenCalledWith('first'); + }); + + it('should return an unsubscribe function', () => { + const callback = vi.fn(); + const unsubscribe = emitter.once('test', callback); + + unsubscribe(); + emitter.emit('test', 'value'); + + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe('clear', () => { + it('should remove all listeners', () => { + const callback1 = vi.fn(); + const callback2 = vi.fn(); + + emitter.on('test', callback1); + emitter.on('count', callback2); + emitter.clear(); + + emitter.emit('test', 'value'); + emitter.emit('count', 42); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + }); + }); + + describe('clearEvent', () => { + it('should remove all listeners for a specific event', () => { + const testCallback = vi.fn(); + const countCallback = vi.fn(); + + emitter.on('test', testCallback); + emitter.on('count', countCallback); + emitter.clearEvent('test'); + + emitter.emit('test', 'value'); + emitter.emit('count', 42); + + expect(testCallback).not.toHaveBeenCalled(); + expect(countCallback).toHaveBeenCalledWith(42); + }); + }); + + describe('listenerCount', () => { + it('should return the number of listeners for an event', () => { + expect(emitter.listenerCount('test')).toBe(0); + + emitter.on('test', () => {}); + expect(emitter.listenerCount('test')).toBe(1); + + emitter.on('test', () => {}); + expect(emitter.listenerCount('test')).toBe(2); + }); + + it('should return 0 for events with no listeners', () => { + expect(emitter.listenerCount('count')).toBe(0); + }); + }); +}); diff --git a/consent-sdk/src/utils/EventEmitter.ts b/consent-sdk/src/utils/EventEmitter.ts new file mode 100644 index 0000000..f3d4216 --- /dev/null +++ b/consent-sdk/src/utils/EventEmitter.ts @@ -0,0 +1,89 @@ +/** + * EventEmitter - Typsicherer Event-Handler + */ + +type EventCallback = (data: T) => void; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export class EventEmitter = Record> { + private listeners: Map>> = new Map(); + + /** + * Event-Listener registrieren + * @returns Unsubscribe-Funktion + */ + on( + event: K, + callback: EventCallback + ): () => void { + if (!this.listeners.has(event)) { + this.listeners.set(event, new Set()); + } + + this.listeners.get(event)!.add(callback as EventCallback); + + // Unsubscribe-Funktion zurueckgeben + return () => this.off(event, callback); + } + + /** + * Event-Listener entfernen + */ + off( + event: K, + callback: EventCallback + ): void { + this.listeners.get(event)?.delete(callback as EventCallback); + } + + /** + * Event emittieren + */ + emit(event: K, data: Events[K]): void { + this.listeners.get(event)?.forEach((callback) => { + try { + callback(data); + } catch (error) { + console.error(`Error in event handler for ${String(event)}:`, error); + } + }); + } + + /** + * Einmaligen Listener registrieren + */ + once( + event: K, + callback: EventCallback + ): () => void { + const wrapper = (data: Events[K]) => { + this.off(event, wrapper); + callback(data); + }; + + return this.on(event, wrapper); + } + + /** + * Alle Listener entfernen + */ + clear(): void { + this.listeners.clear(); + } + + /** + * Alle Listener fuer ein Event entfernen + */ + clearEvent(event: K): void { + this.listeners.delete(event); + } + + /** + * Anzahl Listener fuer ein Event + */ + listenerCount(event: K): number { + return this.listeners.get(event)?.size ?? 0; + } +} + +export default EventEmitter; diff --git a/consent-sdk/src/utils/fingerprint.test.ts b/consent-sdk/src/utils/fingerprint.test.ts new file mode 100644 index 0000000..47e6681 --- /dev/null +++ b/consent-sdk/src/utils/fingerprint.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { generateFingerprint, generateFingerprintSync } from './fingerprint'; + +describe('fingerprint', () => { + describe('generateFingerprint', () => { + it('should generate a fingerprint with fp_ prefix', async () => { + const fingerprint = await generateFingerprint(); + + expect(fingerprint).toMatch(/^fp_[a-f0-9]{32}$/); + }); + + it('should generate consistent fingerprints for same environment', async () => { + const fp1 = await generateFingerprint(); + const fp2 = await generateFingerprint(); + + expect(fp1).toBe(fp2); + }); + + it('should include browser detection in fingerprint components', async () => { + // Chrome is in the mocked userAgent + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + }); + }); + + describe('generateFingerprintSync', () => { + it('should generate a fingerprint with fp_ prefix', () => { + const fingerprint = generateFingerprintSync(); + + expect(fingerprint).toMatch(/^fp_[a-f0-9]+$/); + }); + + it('should be consistent for same environment', () => { + const fp1 = generateFingerprintSync(); + const fp2 = generateFingerprintSync(); + + expect(fp1).toBe(fp2); + }); + }); + + describe('environment variations', () => { + it('should detect screen categories correctly', async () => { + // Default is 1920px (FHD) + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + }); + + it('should handle touch detection', async () => { + Object.defineProperty(navigator, 'maxTouchPoints', { + value: 5, + configurable: true, + }); + + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + + // Reset + Object.defineProperty(navigator, 'maxTouchPoints', { + value: 0, + configurable: true, + }); + }); + + it('should handle Do Not Track', async () => { + Object.defineProperty(navigator, 'doNotTrack', { + value: '1', + configurable: true, + }); + + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + + // Reset + Object.defineProperty(navigator, 'doNotTrack', { + value: null, + configurable: true, + }); + }); + }); + + describe('browser detection', () => { + it('should detect Firefox', async () => { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/115.0', + configurable: true, + }); + + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + + // Reset + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + configurable: true, + }); + }); + + it('should detect Safari', async () => { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15', + configurable: true, + }); + + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + + // Reset + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + configurable: true, + }); + }); + + it('should detect Edge', async () => { + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0', + configurable: true, + }); + + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + + // Reset + Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + configurable: true, + }); + }); + }); + + describe('platform detection', () => { + it('should detect Windows', async () => { + Object.defineProperty(navigator, 'platform', { + value: 'Win32', + configurable: true, + }); + + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + + // Reset + Object.defineProperty(navigator, 'platform', { + value: 'MacIntel', + configurable: true, + }); + }); + + it('should detect Linux', async () => { + Object.defineProperty(navigator, 'platform', { + value: 'Linux x86_64', + configurable: true, + }); + + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + + // Reset + Object.defineProperty(navigator, 'platform', { + value: 'MacIntel', + configurable: true, + }); + }); + + it('should detect iOS', async () => { + Object.defineProperty(navigator, 'platform', { + value: 'iPhone', + configurable: true, + }); + + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + + // Reset + Object.defineProperty(navigator, 'platform', { + value: 'MacIntel', + configurable: true, + }); + }); + }); + + describe('screen categories', () => { + it('should detect 4K screens', async () => { + Object.defineProperty(window, 'screen', { + value: { width: 3840, height: 2160, colorDepth: 24 }, + configurable: true, + }); + + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + + // Reset + Object.defineProperty(window, 'screen', { + value: { width: 1920, height: 1080, colorDepth: 24 }, + configurable: true, + }); + }); + + it('should detect tablet screens', async () => { + Object.defineProperty(window, 'screen', { + value: { width: 1024, height: 768, colorDepth: 24 }, + configurable: true, + }); + + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + + // Reset + Object.defineProperty(window, 'screen', { + value: { width: 1920, height: 1080, colorDepth: 24 }, + configurable: true, + }); + }); + + it('should detect mobile screens', async () => { + Object.defineProperty(window, 'screen', { + value: { width: 375, height: 812, colorDepth: 24 }, + configurable: true, + }); + + const fingerprint = await generateFingerprint(); + expect(fingerprint).toBeDefined(); + + // Reset + Object.defineProperty(window, 'screen', { + value: { width: 1920, height: 1080, colorDepth: 24 }, + configurable: true, + }); + }); + }); +}); diff --git a/consent-sdk/src/utils/fingerprint.ts b/consent-sdk/src/utils/fingerprint.ts new file mode 100644 index 0000000..4815c64 --- /dev/null +++ b/consent-sdk/src/utils/fingerprint.ts @@ -0,0 +1,176 @@ +/** + * Device Fingerprinting - Datenschutzkonform + * + * Generiert einen anonymen Fingerprint OHNE: + * - Canvas Fingerprinting + * - WebGL Fingerprinting + * - Audio Fingerprinting + * - Hardware-spezifische IDs + * + * Verwendet nur: + * - User Agent + * - Sprache + * - Bildschirmaufloesung + * - Zeitzone + * - Platform + */ + +/** + * Fingerprint-Komponenten sammeln + */ +function getComponents(): string[] { + if (typeof window === 'undefined') { + return ['server']; + } + + const components: string[] = []; + + // User Agent (anonymisiert) + try { + // Nur Browser-Familie, nicht vollstaendiger UA + const ua = navigator.userAgent; + if (ua.includes('Chrome')) components.push('chrome'); + else if (ua.includes('Firefox')) components.push('firefox'); + else if (ua.includes('Safari')) components.push('safari'); + else if (ua.includes('Edge')) components.push('edge'); + else components.push('other'); + } catch { + components.push('unknown-browser'); + } + + // Sprache + try { + components.push(navigator.language || 'unknown-lang'); + } catch { + components.push('unknown-lang'); + } + + // Bildschirm-Kategorie (nicht exakte Aufloesung) + try { + const width = window.screen.width; + if (width >= 2560) components.push('4k'); + else if (width >= 1920) components.push('fhd'); + else if (width >= 1366) components.push('hd'); + else if (width >= 768) components.push('tablet'); + else components.push('mobile'); + } catch { + components.push('unknown-screen'); + } + + // Farbtiefe (grob) + try { + const depth = window.screen.colorDepth; + if (depth >= 24) components.push('deep-color'); + else components.push('standard-color'); + } catch { + components.push('unknown-color'); + } + + // Zeitzone (nur Offset, nicht Name) + try { + const offset = new Date().getTimezoneOffset(); + const hours = Math.floor(Math.abs(offset) / 60); + const sign = offset <= 0 ? '+' : '-'; + components.push(`tz${sign}${hours}`); + } catch { + components.push('unknown-tz'); + } + + // Platform-Kategorie + try { + const platform = navigator.platform?.toLowerCase() || ''; + if (platform.includes('mac')) components.push('mac'); + else if (platform.includes('win')) components.push('win'); + else if (platform.includes('linux')) components.push('linux'); + else if (platform.includes('iphone') || platform.includes('ipad')) + components.push('ios'); + else if (platform.includes('android')) components.push('android'); + else components.push('other-platform'); + } catch { + components.push('unknown-platform'); + } + + // Touch-Faehigkeit + try { + if ('ontouchstart' in window || navigator.maxTouchPoints > 0) { + components.push('touch'); + } else { + components.push('no-touch'); + } + } catch { + components.push('unknown-touch'); + } + + // Do Not Track (als Datenschutz-Signal) + try { + if (navigator.doNotTrack === '1') { + components.push('dnt'); + } + } catch { + // Ignorieren + } + + return components; +} + +/** + * SHA-256 Hash (async, nutzt SubtleCrypto) + */ +async function sha256(message: string): Promise { + if (typeof window === 'undefined' || !window.crypto?.subtle) { + // Fallback fuer Server/alte Browser + return simpleHash(message); + } + + try { + const encoder = new TextEncoder(); + const data = encoder.encode(message); + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + } catch { + return simpleHash(message); + } +} + +/** + * Fallback Hash-Funktion (djb2) + */ +function simpleHash(str: string): string { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = (hash * 33) ^ str.charCodeAt(i); + } + return (hash >>> 0).toString(16).padStart(8, '0'); +} + +/** + * Datenschutzkonformen Fingerprint generieren + * + * Der Fingerprint ist: + * - Nicht eindeutig (viele Nutzer teilen sich denselben) + * - Nicht persistent (aendert sich bei Browser-Updates) + * - Nicht invasiv (keine Canvas/WebGL/Audio) + * - Anonymisiert (SHA-256 Hash) + */ +export async function generateFingerprint(): Promise { + const components = getComponents(); + const combined = components.join('|'); + const hash = await sha256(combined); + + // Prefix fuer Identifikation + return `fp_${hash.substring(0, 32)}`; +} + +/** + * Synchrone Version (mit einfachem Hash) + */ +export function generateFingerprintSync(): string { + const components = getComponents(); + const combined = components.join('|'); + const hash = simpleHash(combined); + + return `fp_${hash}`; +} + +export default generateFingerprint; diff --git a/consent-sdk/src/version.ts b/consent-sdk/src/version.ts new file mode 100644 index 0000000..11fe8da --- /dev/null +++ b/consent-sdk/src/version.ts @@ -0,0 +1,6 @@ +/** + * SDK Version + */ +export const SDK_VERSION = '1.0.0'; + +export default SDK_VERSION; diff --git a/consent-sdk/src/vue/index.ts b/consent-sdk/src/vue/index.ts new file mode 100644 index 0000000..9f157f7 --- /dev/null +++ b/consent-sdk/src/vue/index.ts @@ -0,0 +1,511 @@ +/** + * Vue 3 Integration fuer @breakpilot/consent-sdk + * + * @example + * ```vue + * + * + * + * ``` + */ + +import { + ref, + computed, + readonly, + inject, + provide, + onMounted, + onUnmounted, + defineComponent, + h, + type Ref, + type InjectionKey, + type PropType, +} from 'vue'; +import { ConsentManager } from '../core/ConsentManager'; +import type { + ConsentConfig, + ConsentState, + ConsentCategory, + ConsentCategories, +} from '../types'; + +// ============================================================================= +// Injection Key +// ============================================================================= + +const CONSENT_KEY: InjectionKey = Symbol('consent'); + +// ============================================================================= +// Types +// ============================================================================= + +interface ConsentContext { + manager: Ref; + consent: Ref; + isInitialized: Ref; + isLoading: Ref; + isBannerVisible: Ref; + needsConsent: Ref; + hasConsent: (category: ConsentCategory) => boolean; + acceptAll: () => Promise; + rejectAll: () => Promise; + saveSelection: (categories: Partial) => Promise; + showBanner: () => void; + hideBanner: () => void; + showSettings: () => void; +} + +// ============================================================================= +// Composable: useConsent +// ============================================================================= + +/** + * Haupt-Composable fuer Consent-Zugriff + * + * @example + * ```vue + * + * ``` + */ +export function useConsent(): ConsentContext { + const context = inject(CONSENT_KEY); + + if (!context) { + throw new Error( + 'useConsent() must be used within a component that has called provideConsent() or is wrapped in ConsentProvider' + ); + } + + return context; +} + +/** + * Consent-Provider einrichten (in App.vue aufrufen) + * + * @example + * ```vue + * + * ``` + */ +export function provideConsent(config: ConsentConfig): ConsentContext { + const manager = ref(null); + const consent = ref(null); + const isInitialized = ref(false); + const isLoading = ref(true); + const isBannerVisible = ref(false); + + const needsConsent = computed(() => { + return manager.value?.needsConsent() ?? true; + }); + + // Initialisierung + onMounted(async () => { + const consentManager = new ConsentManager(config); + manager.value = consentManager; + + // Events abonnieren + const unsubChange = consentManager.on('change', (newConsent) => { + consent.value = newConsent; + }); + + const unsubBannerShow = consentManager.on('banner_show', () => { + isBannerVisible.value = true; + }); + + const unsubBannerHide = consentManager.on('banner_hide', () => { + isBannerVisible.value = false; + }); + + try { + await consentManager.init(); + consent.value = consentManager.getConsent(); + isInitialized.value = true; + isBannerVisible.value = consentManager.isBannerVisible(); + } catch (error) { + console.error('Failed to initialize ConsentManager:', error); + } finally { + isLoading.value = false; + } + + // Cleanup bei Unmount + onUnmounted(() => { + unsubChange(); + unsubBannerShow(); + unsubBannerHide(); + }); + }); + + // Methoden + const hasConsent = (category: ConsentCategory): boolean => { + return manager.value?.hasConsent(category) ?? category === 'essential'; + }; + + const acceptAll = async (): Promise => { + await manager.value?.acceptAll(); + }; + + const rejectAll = async (): Promise => { + await manager.value?.rejectAll(); + }; + + const saveSelection = async (categories: Partial): Promise => { + await manager.value?.setConsent(categories); + manager.value?.hideBanner(); + }; + + const showBanner = (): void => { + manager.value?.showBanner(); + }; + + const hideBanner = (): void => { + manager.value?.hideBanner(); + }; + + const showSettings = (): void => { + manager.value?.showSettings(); + }; + + const context: ConsentContext = { + manager: readonly(manager) as Ref, + consent: readonly(consent) as Ref, + isInitialized: readonly(isInitialized), + isLoading: readonly(isLoading), + isBannerVisible: readonly(isBannerVisible), + needsConsent, + hasConsent, + acceptAll, + rejectAll, + saveSelection, + showBanner, + hideBanner, + showSettings, + }; + + provide(CONSENT_KEY, context); + + return context; +} + +// ============================================================================= +// Components +// ============================================================================= + +/** + * ConsentProvider - Wrapper-Komponente + * + * @example + * ```vue + * + * + * + * ``` + */ +export const ConsentProvider = defineComponent({ + name: 'ConsentProvider', + props: { + config: { + type: Object as PropType, + required: true, + }, + }, + setup(props, { slots }) { + provideConsent(props.config); + return () => slots.default?.(); + }, +}); + +/** + * ConsentGate - Zeigt Inhalt nur bei Consent + * + * @example + * ```vue + * + * + * + * + * ``` + */ +export const ConsentGate = defineComponent({ + name: 'ConsentGate', + props: { + category: { + type: String as PropType, + required: true, + }, + }, + setup(props, { slots }) { + const { hasConsent, isLoading } = useConsent(); + + return () => { + if (isLoading.value) { + return slots.fallback?.() ?? null; + } + + if (!hasConsent(props.category)) { + return slots.placeholder?.() ?? null; + } + + return slots.default?.(); + }; + }, +}); + +/** + * ConsentPlaceholder - Placeholder fuer blockierten Inhalt + * + * @example + * ```vue + * + * ``` + */ +export const ConsentPlaceholder = defineComponent({ + name: 'ConsentPlaceholder', + props: { + category: { + type: String as PropType, + required: true, + }, + message: { + type: String, + default: '', + }, + buttonText: { + type: String, + default: 'Cookie-Einstellungen öffnen', + }, + }, + setup(props) { + const { showSettings } = useConsent(); + + const categoryNames: Record = { + essential: 'Essentielle Cookies', + functional: 'Funktionale Cookies', + analytics: 'Statistik-Cookies', + marketing: 'Marketing-Cookies', + social: 'Social Media-Cookies', + }; + + const displayMessage = computed(() => { + return props.message || `Dieser Inhalt erfordert ${categoryNames[props.category]}.`; + }); + + return () => + h('div', { class: 'bp-consent-placeholder' }, [ + h('p', displayMessage.value), + h( + 'button', + { + type: 'button', + onClick: showSettings, + }, + props.buttonText + ), + ]); + }, +}); + +/** + * ConsentBanner - Cookie-Banner Komponente + * + * @example + * ```vue + * + * + * + * ``` + */ +export const ConsentBanner = defineComponent({ + name: 'ConsentBanner', + setup(_, { slots }) { + const { + consent, + isBannerVisible, + needsConsent, + acceptAll, + rejectAll, + saveSelection, + showSettings, + hideBanner, + } = useConsent(); + + const slotProps = computed(() => ({ + isVisible: isBannerVisible.value, + consent: consent.value, + needsConsent: needsConsent.value, + onAcceptAll: acceptAll, + onRejectAll: rejectAll, + onSaveSelection: saveSelection, + onShowSettings: showSettings, + onClose: hideBanner, + })); + + return () => { + // Custom Slot + if (slots.default) { + return slots.default(slotProps.value); + } + + // Default UI + if (!isBannerVisible.value) { + return null; + } + + return h( + 'div', + { + class: 'bp-consent-banner', + role: 'dialog', + 'aria-modal': 'true', + 'aria-label': 'Cookie-Einstellungen', + }, + [ + h('div', { class: 'bp-consent-banner-content' }, [ + h('h2', 'Datenschutzeinstellungen'), + h( + 'p', + 'Wir nutzen Cookies und ähnliche Technologien, um Ihnen ein optimales Nutzererlebnis zu bieten.' + ), + h('div', { class: 'bp-consent-banner-actions' }, [ + h( + 'button', + { + type: 'button', + class: 'bp-consent-btn bp-consent-btn-reject', + onClick: rejectAll, + }, + 'Alle ablehnen' + ), + h( + 'button', + { + type: 'button', + class: 'bp-consent-btn bp-consent-btn-settings', + onClick: showSettings, + }, + 'Einstellungen' + ), + h( + 'button', + { + type: 'button', + class: 'bp-consent-btn bp-consent-btn-accept', + onClick: acceptAll, + }, + 'Alle akzeptieren' + ), + ]), + ]), + ] + ); + }; + }, +}); + +// ============================================================================= +// Plugin +// ============================================================================= + +/** + * Vue Plugin fuer globale Installation + * + * @example + * ```ts + * import { createApp } from 'vue'; + * import { ConsentPlugin } from '@breakpilot/consent-sdk/vue'; + * + * const app = createApp(App); + * app.use(ConsentPlugin, { + * apiEndpoint: 'https://consent.example.com/api/v1', + * siteId: 'site_abc123', + * }); + * ``` + */ +export const ConsentPlugin = { + install(app: { provide: (key: symbol | string, value: unknown) => void }, config: ConsentConfig) { + const manager = new ConsentManager(config); + const consent = ref(null); + const isInitialized = ref(false); + const isLoading = ref(true); + const isBannerVisible = ref(false); + + // Initialisieren + manager.init().then(() => { + consent.value = manager.getConsent(); + isInitialized.value = true; + isLoading.value = false; + isBannerVisible.value = manager.isBannerVisible(); + }); + + // Events + manager.on('change', (newConsent) => { + consent.value = newConsent; + }); + manager.on('banner_show', () => { + isBannerVisible.value = true; + }); + manager.on('banner_hide', () => { + isBannerVisible.value = false; + }); + + const context: ConsentContext = { + manager: ref(manager) as Ref, + consent: consent as Ref, + isInitialized, + isLoading, + isBannerVisible, + needsConsent: computed(() => manager.needsConsent()), + hasConsent: (category: ConsentCategory) => manager.hasConsent(category), + acceptAll: () => manager.acceptAll(), + rejectAll: () => manager.rejectAll(), + saveSelection: async (categories: Partial) => { + await manager.setConsent(categories); + manager.hideBanner(); + }, + showBanner: () => manager.showBanner(), + hideBanner: () => manager.hideBanner(), + showSettings: () => manager.showSettings(), + }; + + app.provide(CONSENT_KEY, context); + }, +}; + +// ============================================================================= +// Exports +// ============================================================================= + +export { CONSENT_KEY }; +export type { ConsentContext }; diff --git a/consent-sdk/test-setup.ts b/consent-sdk/test-setup.ts new file mode 100644 index 0000000..cbd3683 --- /dev/null +++ b/consent-sdk/test-setup.ts @@ -0,0 +1,137 @@ +import { vi } from 'vitest'; + +// Mock localStorage +const localStorageMock = { + store: {} as Record, + getItem: vi.fn((key: string) => localStorageMock.store[key] || null), + setItem: vi.fn((key: string, value: string) => { + localStorageMock.store[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete localStorageMock.store[key]; + }), + clear: vi.fn(() => { + localStorageMock.store = {}; + }), + get length() { + return Object.keys(localStorageMock.store).length; + }, + key: vi.fn((index: number) => Object.keys(localStorageMock.store)[index] || null), +}; + +vi.stubGlobal('localStorage', localStorageMock); + +// Mock fetch +vi.stubGlobal( + 'fetch', + vi.fn(() => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + text: () => Promise.resolve(''), + }) + ) +); + +// Mock crypto for fingerprinting +const cryptoMock = { + subtle: { + digest: vi.fn(async (_algorithm: string, data: ArrayBuffer) => { + // Simple mock hash - returns predictable data + const view = new Uint8Array(data); + const hash = new Uint8Array(32); + for (let i = 0; i < hash.length; i++) { + hash[i] = (view[i % view.length] || 0) ^ (i * 7); + } + return hash.buffer; + }), + }, + getRandomValues: vi.fn((arr: T): T => { + if (arr instanceof Uint8Array) { + for (let i = 0; i < arr.length; i++) { + arr[i] = Math.floor(Math.random() * 256); + } + } + return arr; + }), +}; + +vi.stubGlobal('crypto', cryptoMock); + +// Mock document.cookie +let documentCookie = ''; +Object.defineProperty(document, 'cookie', { + get: () => documentCookie, + set: (value: string) => { + documentCookie = value; + }, + configurable: true, +}); + +// Mock navigator properties +Object.defineProperty(navigator, 'userAgent', { + value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + configurable: true, +}); + +Object.defineProperty(navigator, 'language', { + value: 'de-DE', + configurable: true, +}); + +Object.defineProperty(navigator, 'platform', { + value: 'MacIntel', + configurable: true, +}); + +Object.defineProperty(navigator, 'doNotTrack', { + value: null, + configurable: true, +}); + +Object.defineProperty(navigator, 'maxTouchPoints', { + value: 0, + configurable: true, +}); + +// Mock screen +Object.defineProperty(window, 'screen', { + value: { + width: 1920, + height: 1080, + colorDepth: 24, + pixelDepth: 24, + availWidth: 1920, + availHeight: 1040, + orientation: { type: 'landscape-primary', angle: 0 }, + }, + configurable: true, +}); + +// Mock location +Object.defineProperty(window, 'location', { + value: { + protocol: 'https:', + hostname: 'localhost', + port: '3000', + pathname: '/', + href: 'https://localhost:3000/', + }, + writable: true, + configurable: true, +}); + +// Reset mocks before each test +beforeEach(() => { + localStorageMock.store = {}; + localStorageMock.getItem.mockClear(); + localStorageMock.setItem.mockClear(); + localStorageMock.removeItem.mockClear(); + localStorageMock.clear.mockClear(); + documentCookie = ''; + vi.clearAllMocks(); +}); + +// Export for use in tests +export { localStorageMock }; diff --git a/consent-sdk/tsconfig.json b/consent-sdk/tsconfig.json new file mode 100644 index 0000000..eb59e2d --- /dev/null +++ b/consent-sdk/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "jsx": "react-jsx", + "resolveJsonModule": true, + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/consent-sdk/tsup.config.ts b/consent-sdk/tsup.config.ts new file mode 100644 index 0000000..e9be8fa --- /dev/null +++ b/consent-sdk/tsup.config.ts @@ -0,0 +1,44 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig([ + // Main entry + { + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + dts: true, + sourcemap: true, + clean: true, + outDir: 'dist', + external: ['react', 'react-dom', 'vue'], + }, + // React entry + { + entry: ['src/react/index.tsx'], + format: ['cjs', 'esm'], + dts: true, + sourcemap: true, + outDir: 'dist/react', + external: ['react', 'react-dom'], + esbuildOptions(options) { + options.jsx = 'automatic'; + }, + }, + // Vue entry + { + entry: ['src/vue/index.ts'], + format: ['cjs', 'esm'], + dts: true, + sourcemap: true, + outDir: 'dist/vue', + external: ['vue'], + }, + // Angular entry + { + entry: ['src/angular/index.ts'], + format: ['cjs', 'esm'], + dts: true, + sourcemap: true, + outDir: 'dist/angular', + external: ['@angular/core', '@angular/common', 'rxjs'], + }, +]); diff --git a/consent-sdk/vitest.config.ts b/consent-sdk/vitest.config.ts new file mode 100644 index 0000000..f4829fe --- /dev/null +++ b/consent-sdk/vitest.config.ts @@ -0,0 +1,34 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./test-setup.ts'], + include: ['src/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/', + 'dist/', + '**/*.d.ts', + 'test-setup.ts', + 'vitest.config.ts', + // Framework integrations require separate component testing + 'src/react/**', + 'src/vue/**', + 'src/angular/**', + // Re-export index files + 'src/index.ts', + 'src/core/index.ts', + ], + thresholds: { + statements: 80, + branches: 70, + functions: 80, + lines: 80, + }, + }, + }, +}); diff --git a/consent-service/.dockerignore b/consent-service/.dockerignore new file mode 100644 index 0000000..d1d1cf5 --- /dev/null +++ b/consent-service/.dockerignore @@ -0,0 +1,48 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib +server + +# Test binary +*.test + +# Output of go coverage tool +*.out + +# Go workspace file +go.work + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Local config +.env +.env.local +*.local + +# Logs +*.log +logs/ + +# Temp files +*.tmp +*.temp +.DS_Store + +# Git +.git/ +.gitignore + +# Docker +Dockerfile +docker-compose*.yml + +# Vendor (if using) +vendor/ diff --git a/consent-service/.env.example b/consent-service/.env.example new file mode 100644 index 0000000..14fb97a --- /dev/null +++ b/consent-service/.env.example @@ -0,0 +1,21 @@ +# Server Configuration +PORT=8081 +ENVIRONMENT=development + +# Database Configuration +# PostgreSQL connection string +DATABASE_URL=postgres://user:password@localhost:5432/consent_db?sslmode=disable + +# JWT Configuration (should match BreakPilot's JWT secret for token validation) +JWT_SECRET=your-jwt-secret-here +JWT_REFRESH_SECRET=your-refresh-secret-here + +# CORS Configuration +ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8000,https://breakpilot.app + +# Rate Limiting +RATE_LIMIT_REQUESTS=100 +RATE_LIMIT_WINDOW=60 + +# BreakPilot Integration +BREAKPILOT_API_URL=http://localhost:8000 diff --git a/consent-service/Dockerfile b/consent-service/Dockerfile new file mode 100644 index 0000000..27e2cec --- /dev/null +++ b/consent-service/Dockerfile @@ -0,0 +1,42 @@ +# Build stage +FROM golang:1.23-alpine AS builder + +WORKDIR /app + +# Install build dependencies +RUN apk add --no-cache git ca-certificates + +# Copy go mod files +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build the binary +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o consent-service ./cmd/server + +# Runtime stage +FROM alpine:3.19 + +WORKDIR /app + +# Install runtime dependencies +RUN apk --no-cache add ca-certificates tzdata + +# Copy binary from builder +COPY --from=builder /app/consent-service . + +# Create non-root user +RUN adduser -D -g '' appuser +USER appuser + +# Expose port +EXPOSE 8081 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1 + +# Run the binary +CMD ["./consent-service"] diff --git a/consent-service/cmd/server/main.go b/consent-service/cmd/server/main.go new file mode 100644 index 0000000..9004bc9 --- /dev/null +++ b/consent-service/cmd/server/main.go @@ -0,0 +1,471 @@ +package main + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/breakpilot/consent-service/internal/config" + "github.com/breakpilot/consent-service/internal/database" + "github.com/breakpilot/consent-service/internal/handlers" + "github.com/breakpilot/consent-service/internal/middleware" + "github.com/breakpilot/consent-service/internal/services" + "github.com/breakpilot/consent-service/internal/services/jitsi" + "github.com/breakpilot/consent-service/internal/services/matrix" + "github.com/gin-gonic/gin" +) + +func main() { + // Load configuration + cfg, err := config.Load() + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + // Initialize database + db, err := database.Connect(cfg.DatabaseURL) + if err != nil { + log.Fatalf("Failed to connect to database: %v", err) + } + defer db.Close() + + // Run migrations + if err := database.Migrate(db); err != nil { + log.Fatalf("Failed to run migrations: %v", err) + } + + // Setup Gin router + if cfg.Environment == "production" { + gin.SetMode(gin.ReleaseMode) + } + + router := gin.Default() + + // Global middleware + router.Use(middleware.CORS()) + router.Use(middleware.RequestLogger()) + router.Use(middleware.RateLimiter()) + + // Health check + router.GET("/health", func(c *gin.Context) { + c.JSON(200, gin.H{ + "status": "healthy", + "service": "consent-service", + "version": "1.0.0", + }) + }) + + // Initialize services + authService := services.NewAuthService(db.Pool, cfg.JWTSecret, cfg.JWTRefreshSecret) + oauthService := services.NewOAuthService(db.Pool, cfg.JWTSecret) + totpService := services.NewTOTPService(db.Pool, "BreakPilot") + emailService := services.NewEmailService(services.EmailConfig{ + Host: cfg.SMTPHost, + Port: cfg.SMTPPort, + Username: cfg.SMTPUsername, + Password: cfg.SMTPPassword, + FromName: cfg.SMTPFromName, + FromAddr: cfg.SMTPFromAddr, + BaseURL: cfg.FrontendURL, + }) + notificationService := services.NewNotificationService(db.Pool, emailService) + deadlineService := services.NewDeadlineService(db.Pool, notificationService) + emailTemplateService := services.NewEmailTemplateService(db.Pool) + dsrService := services.NewDSRService(db.Pool, notificationService, emailService) + + // Initialize handlers + h := handlers.New(db) + authHandler := handlers.NewAuthHandler(authService, emailService) + oauthHandler := handlers.NewOAuthHandler(oauthService, totpService, authService) + notificationHandler := handlers.NewNotificationHandler(notificationService) + deadlineHandler := handlers.NewDeadlineHandler(deadlineService) + emailTemplateHandler := handlers.NewEmailTemplateHandler(emailTemplateService) + dsrHandler := handlers.NewDSRHandler(dsrService) + + // Initialize Matrix service (if enabled) + var matrixService *matrix.MatrixService + if cfg.MatrixEnabled && cfg.MatrixAccessToken != "" { + matrixService = matrix.NewMatrixService(matrix.Config{ + HomeserverURL: cfg.MatrixHomeserverURL, + AccessToken: cfg.MatrixAccessToken, + ServerName: cfg.MatrixServerName, + }) + log.Println("Matrix service initialized") + } else { + log.Println("Matrix service disabled or not configured") + } + + // Initialize Jitsi service (if enabled) + var jitsiService *jitsi.JitsiService + if cfg.JitsiEnabled { + jitsiService = jitsi.NewJitsiService(jitsi.Config{ + BaseURL: cfg.JitsiBaseURL, + AppID: cfg.JitsiAppID, + AppSecret: cfg.JitsiAppSecret, + }) + log.Println("Jitsi service initialized") + } else { + log.Println("Jitsi service disabled") + } + + // Initialize communication handlers + communicationHandler := handlers.NewCommunicationHandlers(matrixService, jitsiService) + + // Initialize default email templates (runs only once) + if err := emailTemplateService.InitDefaultTemplates(context.Background()); err != nil { + log.Printf("Warning: Failed to initialize default email templates: %v", err) + } + + // API v1 routes + v1 := router.Group("/api/v1") + { + // ============================================= + // OAuth 2.0 Endpoints (RFC 6749) + // ============================================= + oauth := v1.Group("/oauth") + { + // Authorization endpoint (requires user auth for consent) + oauth.GET("/authorize", middleware.AuthMiddleware(cfg.JWTSecret), oauthHandler.Authorize) + // Token endpoint (public) + oauth.POST("/token", oauthHandler.Token) + // Revocation endpoint (RFC 7009) + oauth.POST("/revoke", oauthHandler.Revoke) + // Introspection endpoint (RFC 7662) + oauth.POST("/introspect", oauthHandler.Introspect) + } + + // ============================================= + // Authentication Routes (with 2FA support) + // ============================================= + auth := v1.Group("/auth") + { + // Registration with mandatory 2FA setup + auth.POST("/register", oauthHandler.RegisterWith2FA) + // Login with 2FA support + auth.POST("/login", oauthHandler.LoginWith2FA) + // 2FA challenge verification (during login) + auth.POST("/2fa/verify", oauthHandler.Verify2FAChallenge) + // Legacy endpoints (kept for compatibility) + auth.POST("/logout", authHandler.Logout) + auth.POST("/refresh", authHandler.RefreshToken) + auth.POST("/verify-email", authHandler.VerifyEmail) + auth.POST("/resend-verification", authHandler.ResendVerification) + auth.POST("/forgot-password", authHandler.ForgotPassword) + auth.POST("/reset-password", authHandler.ResetPassword) + } + + // ============================================= + // 2FA Management Routes (require auth) + // ============================================= + twoFA := v1.Group("/auth/2fa") + twoFA.Use(middleware.AuthMiddleware(cfg.JWTSecret)) + { + twoFA.GET("/status", oauthHandler.Get2FAStatus) + twoFA.POST("/setup", oauthHandler.Setup2FA) + twoFA.POST("/verify-setup", oauthHandler.Verify2FASetup) + twoFA.POST("/disable", oauthHandler.Disable2FA) + twoFA.POST("/recovery-codes", oauthHandler.RegenerateRecoveryCodes) + } + + // ============================================= + // Profile Routes (require auth) + // ============================================= + profile := v1.Group("/profile") + profile.Use(middleware.AuthMiddleware(cfg.JWTSecret)) + { + profile.GET("", authHandler.GetProfile) + profile.PUT("", authHandler.UpdateProfile) + profile.PUT("/password", authHandler.ChangePassword) + profile.GET("/sessions", authHandler.GetActiveSessions) + profile.DELETE("/sessions/:id", authHandler.RevokeSession) + } + + // ============================================= + // Public consent routes (require user auth) + // ============================================= + public := v1.Group("") + public.Use(middleware.AuthMiddleware(cfg.JWTSecret)) + { + // Documents + public.GET("/documents", h.GetDocuments) + public.GET("/documents/:type", h.GetDocumentByType) + public.GET("/documents/:type/latest", h.GetLatestDocumentVersion) + + // User Consent + public.POST("/consent", h.CreateConsent) + public.GET("/consent/my", h.GetMyConsents) + public.GET("/consent/check/:documentType", h.CheckConsent) + public.DELETE("/consent/:id", h.WithdrawConsent) + + // Cookie Consent + public.GET("/cookies/categories", h.GetCookieCategories) + public.POST("/cookies/consent", h.SetCookieConsent) + public.GET("/cookies/consent/my", h.GetMyCookieConsent) + + // GDPR / Data Subject Rights + public.GET("/privacy/my-data", h.GetMyData) + public.POST("/privacy/export", h.RequestDataExport) + public.POST("/privacy/delete", h.RequestDataDeletion) + + // Data Subject Requests (User-facing) + public.POST("/dsr", dsrHandler.CreateDSR) + public.GET("/dsr", dsrHandler.GetMyDSRs) + public.GET("/dsr/:id", dsrHandler.GetMyDSR) + public.POST("/dsr/:id/cancel", dsrHandler.CancelMyDSR) + + // Notifications + public.GET("/notifications", notificationHandler.GetNotifications) + public.GET("/notifications/unread-count", notificationHandler.GetUnreadCount) + public.PUT("/notifications/:id/read", notificationHandler.MarkAsRead) + public.PUT("/notifications/read-all", notificationHandler.MarkAllAsRead) + public.DELETE("/notifications/:id", notificationHandler.DeleteNotification) + public.GET("/notifications/preferences", notificationHandler.GetPreferences) + public.PUT("/notifications/preferences", notificationHandler.UpdatePreferences) + + // Consent Deadlines & Suspension Status + public.GET("/consent/deadlines", deadlineHandler.GetPendingDeadlines) + public.GET("/account/suspension-status", deadlineHandler.GetSuspensionStatus) + } + + // Admin routes (require admin auth) + admin := v1.Group("/admin") + admin.Use(middleware.AuthMiddleware(cfg.JWTSecret)) + admin.Use(middleware.AdminOnly()) + { + // Document Management + admin.GET("/documents", h.AdminGetDocuments) + admin.POST("/documents", h.AdminCreateDocument) + admin.PUT("/documents/:id", h.AdminUpdateDocument) + admin.DELETE("/documents/:id", h.AdminDeleteDocument) + admin.GET("/documents/:docId/versions", h.AdminGetVersions) + + // Version Management + admin.POST("/versions", h.AdminCreateVersion) + admin.PUT("/versions/:id", h.AdminUpdateVersion) + admin.DELETE("/versions/:id", h.AdminDeleteVersion) + admin.POST("/versions/:id/archive", h.AdminArchiveVersion) + admin.POST("/versions/:id/submit-review", h.AdminSubmitForReview) + admin.POST("/versions/:id/approve", h.AdminApproveVersion) + admin.POST("/versions/:id/reject", h.AdminRejectVersion) + admin.GET("/versions/:id/compare", h.AdminCompareVersions) + admin.GET("/versions/:id/approval-history", h.AdminGetApprovalHistory) + + // Publishing (DSB role recommended but Admin can also do it in dev) + admin.POST("/versions/:id/publish", h.AdminPublishVersion) + + // Cookie Categories + admin.GET("/cookies/categories", h.AdminGetCookieCategories) + admin.POST("/cookies/categories", h.AdminCreateCookieCategory) + admin.PUT("/cookies/categories/:id", h.AdminUpdateCookieCategory) + admin.DELETE("/cookies/categories/:id", h.AdminDeleteCookieCategory) + + // Statistics & Audit + admin.GET("/stats/consents", h.GetConsentStats) + admin.GET("/stats/cookies", h.GetCookieStats) + admin.GET("/audit-log", h.GetAuditLog) + + // Deadline Management (for testing/manual trigger) + admin.POST("/deadlines/process", deadlineHandler.TriggerDeadlineProcessing) + + // Scheduled Publishing + admin.GET("/scheduled-versions", h.GetScheduledVersions) + admin.POST("/scheduled-publishing/process", h.ProcessScheduledPublishing) + + // OAuth Client Management + admin.GET("/oauth/clients", oauthHandler.AdminGetClients) + admin.POST("/oauth/clients", oauthHandler.AdminCreateClient) + + // ============================================= + // E-Mail Template Management + // ============================================= + admin.GET("/email-templates/types", emailTemplateHandler.GetAllTemplateTypes) + admin.GET("/email-templates", emailTemplateHandler.GetAllTemplates) + admin.GET("/email-templates/settings", emailTemplateHandler.GetSettings) + admin.PUT("/email-templates/settings", emailTemplateHandler.UpdateSettings) + admin.GET("/email-templates/stats", emailTemplateHandler.GetEmailStats) + admin.GET("/email-templates/logs", emailTemplateHandler.GetSendLogs) + admin.GET("/email-templates/default/:type", emailTemplateHandler.GetDefaultContent) + admin.POST("/email-templates/initialize", emailTemplateHandler.InitializeTemplates) + admin.GET("/email-templates/:id", emailTemplateHandler.GetTemplate) + admin.POST("/email-templates", emailTemplateHandler.CreateTemplate) + admin.GET("/email-templates/:id/versions", emailTemplateHandler.GetTemplateVersions) + + // E-Mail Template Versions + admin.GET("/email-template-versions/:id", emailTemplateHandler.GetVersion) + admin.POST("/email-template-versions", emailTemplateHandler.CreateVersion) + admin.PUT("/email-template-versions/:id", emailTemplateHandler.UpdateVersion) + admin.POST("/email-template-versions/:id/submit", emailTemplateHandler.SubmitForReview) + admin.POST("/email-template-versions/:id/approve", emailTemplateHandler.ApproveVersion) + admin.POST("/email-template-versions/:id/reject", emailTemplateHandler.RejectVersion) + admin.POST("/email-template-versions/:id/publish", emailTemplateHandler.PublishVersion) + admin.GET("/email-template-versions/:id/approvals", emailTemplateHandler.GetApprovals) + admin.POST("/email-template-versions/:id/preview", emailTemplateHandler.PreviewVersion) + admin.POST("/email-template-versions/:id/send-test", emailTemplateHandler.SendTestEmail) + + // ============================================= + // Data Subject Requests (DSR) Management + // ============================================= + admin.GET("/dsr", dsrHandler.AdminListDSR) + admin.GET("/dsr/stats", dsrHandler.AdminGetDSRStats) + admin.POST("/dsr", dsrHandler.AdminCreateDSR) + admin.GET("/dsr/:id", dsrHandler.AdminGetDSR) + admin.PUT("/dsr/:id", dsrHandler.AdminUpdateDSR) + admin.POST("/dsr/:id/status", dsrHandler.AdminUpdateDSRStatus) + admin.POST("/dsr/:id/verify-identity", dsrHandler.AdminVerifyIdentity) + admin.POST("/dsr/:id/assign", dsrHandler.AdminAssignDSR) + admin.POST("/dsr/:id/extend", dsrHandler.AdminExtendDSRDeadline) + admin.POST("/dsr/:id/complete", dsrHandler.AdminCompleteDSR) + admin.POST("/dsr/:id/reject", dsrHandler.AdminRejectDSR) + admin.GET("/dsr/:id/history", dsrHandler.AdminGetDSRHistory) + admin.GET("/dsr/:id/communications", dsrHandler.AdminGetDSRCommunications) + admin.POST("/dsr/:id/communicate", dsrHandler.AdminSendDSRCommunication) + admin.GET("/dsr/:id/exception-checks", dsrHandler.AdminGetExceptionChecks) + admin.POST("/dsr/:id/exception-checks/init", dsrHandler.AdminInitExceptionChecks) + admin.PUT("/dsr/:id/exception-checks/:checkId", dsrHandler.AdminUpdateExceptionCheck) + admin.POST("/dsr/deadlines/process", dsrHandler.ProcessDeadlines) + + // DSR Templates + admin.GET("/dsr-templates", dsrHandler.AdminGetDSRTemplates) + admin.GET("/dsr-templates/published", dsrHandler.AdminGetPublishedDSRTemplates) + admin.GET("/dsr-templates/:id/versions", dsrHandler.AdminGetDSRTemplateVersions) + admin.POST("/dsr-templates/:id/versions", dsrHandler.AdminCreateDSRTemplateVersion) + admin.POST("/dsr-template-versions/:versionId/publish", dsrHandler.AdminPublishDSRTemplateVersion) + } + + // ============================================= + // Communication Routes (Matrix + Jitsi) + // ============================================= + communicationHandler.RegisterRoutes(v1, cfg.JWTSecret, middleware.AuthMiddleware(cfg.JWTSecret)) + + // ============================================= + // Cookie Banner SDK Routes (Public - Anonymous) + // ============================================= + // Diese Endpoints werden vom @breakpilot/consent-sdk verwendet + // für anonyme (device-basierte) Cookie-Einwilligungen. + banner := v1.Group("/banner") + { + // Public Endpoints (keine Auth erforderlich) + banner.POST("/consent", h.CreateBannerConsent) + banner.GET("/consent", h.GetBannerConsent) + banner.DELETE("/consent/:consentId", h.RevokeBannerConsent) + banner.GET("/config/:siteId", h.GetSiteConfig) + banner.GET("/consent/export", h.ExportBannerConsent) + } + + // Banner Admin Routes (require admin auth) + bannerAdmin := v1.Group("/banner/admin") + bannerAdmin.Use(middleware.AuthMiddleware(cfg.JWTSecret)) + bannerAdmin.Use(middleware.AdminOnly()) + { + bannerAdmin.GET("/stats/:siteId", h.GetBannerStats) + } + } + + // Start background scheduler for scheduled publishing + go startScheduledPublishingWorker(db) + + // Start DSR deadline monitoring worker + go startDSRDeadlineWorker(dsrService) + + // Start server + port := cfg.Port + if port == "" { + port = "8080" + } + + log.Printf("Starting Consent Service on port %s", port) + if err := router.Run(":" + port); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} + +// startScheduledPublishingWorker runs every minute to check for scheduled versions +func startScheduledPublishingWorker(db *database.DB) { + ticker := time.NewTicker(1 * time.Minute) + defer ticker.Stop() + + log.Println("Scheduled publishing worker started (checking every minute)") + + for range ticker.C { + processScheduledVersions(db) + } +} + +func processScheduledVersions(db *database.DB) { + ctx := context.Background() + + // Find all scheduled versions that are due + rows, err := db.Pool.Query(ctx, ` + SELECT id, document_id, version + FROM document_versions + WHERE status = 'scheduled' + AND scheduled_publish_at IS NOT NULL + AND scheduled_publish_at <= NOW() + `) + if err != nil { + log.Printf("Scheduler: Error fetching scheduled versions: %v", err) + return + } + defer rows.Close() + + var publishedCount int + for rows.Next() { + var versionID, docID string + var version string + if err := rows.Scan(&versionID, &docID, &version); err != nil { + continue + } + + // Publish this version + _, err := db.Pool.Exec(ctx, ` + UPDATE document_versions + SET status = 'published', published_at = NOW(), updated_at = NOW() + WHERE id = $1 + `, versionID) + + if err == nil { + // Archive previous published versions for this document + db.Pool.Exec(ctx, ` + UPDATE document_versions + SET status = 'archived', updated_at = NOW() + WHERE document_id = $1 AND id != $2 AND status = 'published' + `, docID, versionID) + + // Log the publishing + details := fmt.Sprintf("Version %s automatically published by scheduler", version) + db.Pool.Exec(ctx, ` + INSERT INTO consent_audit_log (action, entity_type, entity_id, details, user_agent) + VALUES ('version_scheduled_published', 'document_version', $1, $2, 'scheduler') + `, versionID, details) + + publishedCount++ + log.Printf("Scheduler: Published version %s (ID: %s)", version, versionID) + } + } + + if publishedCount > 0 { + log.Printf("Scheduler: Published %d version(s)", publishedCount) + } +} + +// startDSRDeadlineWorker monitors DSR deadlines and sends notifications +func startDSRDeadlineWorker(dsrService *services.DSRService) { + ticker := time.NewTicker(1 * time.Hour) + defer ticker.Stop() + + log.Println("DSR deadline monitoring worker started (checking every hour)") + + // Run immediately on startup + ctx := context.Background() + if err := dsrService.ProcessDeadlines(ctx); err != nil { + log.Printf("DSR Worker: Error processing deadlines: %v", err) + } + + for range ticker.C { + ctx := context.Background() + if err := dsrService.ProcessDeadlines(ctx); err != nil { + log.Printf("DSR Worker: Error processing deadlines: %v", err) + } + } +} diff --git a/consent-service/docker-compose.yml b/consent-service/docker-compose.yml new file mode 100644 index 0000000..725562c --- /dev/null +++ b/consent-service/docker-compose.yml @@ -0,0 +1,41 @@ +version: '3.8' + +services: + consent-service: + build: . + ports: + - "8081:8081" + env_file: + - .env + environment: + - DATABASE_URL=postgres://consent:consent123@postgres:5432/consent_db?sslmode=disable + depends_on: + postgres: + condition: service_healthy + networks: + - consent-network + + postgres: + image: postgres:16-alpine + ports: + - "5433:5432" + environment: + - POSTGRES_USER=consent + - POSTGRES_PASSWORD=consent123 + - POSTGRES_DB=consent_db + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U consent -d consent_db"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - consent-network + +volumes: + postgres_data: + +networks: + consent-network: + driver: bridge diff --git a/consent-service/go.mod b/consent-service/go.mod new file mode 100644 index 0000000..3556f67 --- /dev/null +++ b/consent-service/go.mod @@ -0,0 +1,49 @@ +module github.com/breakpilot/consent-service + +go 1.23.0 + +require ( + github.com/gin-gonic/gin v1.11.0 + github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/uuid v1.6.0 + github.com/jackc/pgx/v5 v5.7.6 + github.com/joho/godotenv v1.5.1 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e + golang.org/x/crypto v0.40.0 +) + +require ( + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.27.0 // indirect + golang.org/x/tools v0.34.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect +) diff --git a/consent-service/go.sum b/consent-service/go.sum new file mode 100644 index 0000000..b54d921 --- /dev/null +++ b/consent-service/go.sum @@ -0,0 +1,105 @@ +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/consent-service/internal/config/config.go b/consent-service/internal/config/config.go new file mode 100644 index 0000000..2c7c371 --- /dev/null +++ b/consent-service/internal/config/config.go @@ -0,0 +1,170 @@ +package config + +import ( + "fmt" + "os" + + "github.com/joho/godotenv" +) + +// Config holds all configuration for the service +type Config struct { + // Server + Port string + Environment string + + // Database + DatabaseURL string + + // JWT + JWTSecret string + JWTRefreshSecret string + + // CORS + AllowedOrigins []string + + // Rate Limiting + RateLimitRequests int + RateLimitWindow int // in seconds + + // BreakPilot Integration + BreakPilotAPIURL string + FrontendURL string + + // SMTP Email Configuration + SMTPHost string + SMTPPort int + SMTPUsername string + SMTPPassword string + SMTPFromName string + SMTPFromAddr string + + // Consent Settings + ConsentDeadlineDays int + ConsentReminderEnabled bool + + // VAPID Keys for Web Push + VAPIDPublicKey string + VAPIDPrivateKey string + + // Matrix (Synapse) Configuration + MatrixHomeserverURL string + MatrixAccessToken string + MatrixServerName string + MatrixEnabled bool + + // Jitsi Configuration + JitsiBaseURL string + JitsiAppID string + JitsiAppSecret string + JitsiEnabled bool +} + +// Load loads configuration from environment variables +func Load() (*Config, error) { + // Load .env file if exists (for development) + _ = godotenv.Load() + + cfg := &Config{ + Port: getEnv("PORT", "8080"), + Environment: getEnv("ENVIRONMENT", "development"), + DatabaseURL: getEnv("DATABASE_URL", ""), + JWTSecret: getEnv("JWT_SECRET", ""), + JWTRefreshSecret: getEnv("JWT_REFRESH_SECRET", ""), + RateLimitRequests: getEnvInt("RATE_LIMIT_REQUESTS", 100), + RateLimitWindow: getEnvInt("RATE_LIMIT_WINDOW", 60), + BreakPilotAPIURL: getEnv("BREAKPILOT_API_URL", "http://localhost:8000"), + FrontendURL: getEnv("FRONTEND_URL", "http://localhost:8000"), + + // SMTP Configuration + SMTPHost: getEnv("SMTP_HOST", ""), + SMTPPort: getEnvInt("SMTP_PORT", 587), + SMTPUsername: getEnv("SMTP_USERNAME", ""), + SMTPPassword: getEnv("SMTP_PASSWORD", ""), + SMTPFromName: getEnv("SMTP_FROM_NAME", "BreakPilot"), + SMTPFromAddr: getEnv("SMTP_FROM_ADDR", "noreply@breakpilot.app"), + + // Consent Settings + ConsentDeadlineDays: getEnvInt("CONSENT_DEADLINE_DAYS", 30), + ConsentReminderEnabled: getEnvBool("CONSENT_REMINDER_ENABLED", true), + + // VAPID Keys + VAPIDPublicKey: getEnv("VAPID_PUBLIC_KEY", ""), + VAPIDPrivateKey: getEnv("VAPID_PRIVATE_KEY", ""), + + // Matrix Configuration + MatrixHomeserverURL: getEnv("MATRIX_HOMESERVER_URL", "http://synapse:8008"), + MatrixAccessToken: getEnv("MATRIX_ACCESS_TOKEN", ""), + MatrixServerName: getEnv("MATRIX_SERVER_NAME", "breakpilot.local"), + MatrixEnabled: getEnvBool("MATRIX_ENABLED", true), + + // Jitsi Configuration + JitsiBaseURL: getEnv("JITSI_BASE_URL", "http://localhost:8443"), + JitsiAppID: getEnv("JITSI_APP_ID", "breakpilot"), + JitsiAppSecret: getEnv("JITSI_APP_SECRET", ""), + JitsiEnabled: getEnvBool("JITSI_ENABLED", true), + } + + // Parse allowed origins + originsStr := getEnv("ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:8000") + cfg.AllowedOrigins = parseCommaSeparated(originsStr) + + // Validate required fields + if cfg.DatabaseURL == "" { + return nil, fmt.Errorf("DATABASE_URL is required") + } + + if cfg.JWTSecret == "" { + return nil, fmt.Errorf("JWT_SECRET is required") + } + + return cfg, nil +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +func getEnvInt(key string, defaultValue int) int { + if value := os.Getenv(key); value != "" { + var result int + fmt.Sscanf(value, "%d", &result) + return result + } + return defaultValue +} + +func getEnvBool(key string, defaultValue bool) bool { + if value := os.Getenv(key); value != "" { + return value == "true" || value == "1" || value == "yes" + } + return defaultValue +} + +func parseCommaSeparated(s string) []string { + if s == "" { + return []string{} + } + var result []string + start := 0 + for i := 0; i <= len(s); i++ { + if i == len(s) || s[i] == ',' { + item := s[start:i] + // Trim whitespace + for len(item) > 0 && item[0] == ' ' { + item = item[1:] + } + for len(item) > 0 && item[len(item)-1] == ' ' { + item = item[:len(item)-1] + } + if item != "" { + result = append(result, item) + } + start = i + 1 + } + } + return result +} diff --git a/consent-service/internal/config/config_test.go b/consent-service/internal/config/config_test.go new file mode 100644 index 0000000..05dea2e --- /dev/null +++ b/consent-service/internal/config/config_test.go @@ -0,0 +1,322 @@ +package config + +import ( + "os" + "testing" +) + +// TestGetEnv tests the getEnv helper function +func TestGetEnv(t *testing.T) { + // Test with default value when env var not set + result := getEnv("TEST_NONEXISTENT_VAR_12345", "default") + if result != "default" { + t.Errorf("Expected 'default', got '%s'", result) + } + + // Test with set env var + os.Setenv("TEST_ENV_VAR", "custom_value") + defer os.Unsetenv("TEST_ENV_VAR") + + result = getEnv("TEST_ENV_VAR", "default") + if result != "custom_value" { + t.Errorf("Expected 'custom_value', got '%s'", result) + } +} + +// TestGetEnvInt tests the getEnvInt helper function +func TestGetEnvInt(t *testing.T) { + tests := []struct { + name string + envValue string + defaultValue int + expected int + }{ + {"default when not set", "", 100, 100}, + {"parse valid int", "42", 0, 42}, + {"parse zero", "0", 100, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envValue != "" { + os.Setenv("TEST_INT_VAR", tt.envValue) + defer os.Unsetenv("TEST_INT_VAR") + } else { + os.Unsetenv("TEST_INT_VAR") + } + + result := getEnvInt("TEST_INT_VAR", tt.defaultValue) + if result != tt.expected { + t.Errorf("Expected %d, got %d", tt.expected, result) + } + }) + } +} + +// TestGetEnvBool tests the getEnvBool helper function +func TestGetEnvBool(t *testing.T) { + tests := []struct { + name string + envValue string + defaultValue bool + expected bool + }{ + {"default when not set", "", true, true}, + {"default false when not set", "", false, false}, + {"parse true", "true", false, true}, + {"parse 1", "1", false, true}, + {"parse yes", "yes", false, true}, + {"parse false", "false", true, false}, + {"parse 0", "0", true, false}, + {"parse no", "no", true, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envValue != "" { + os.Setenv("TEST_BOOL_VAR", tt.envValue) + defer os.Unsetenv("TEST_BOOL_VAR") + } else { + os.Unsetenv("TEST_BOOL_VAR") + } + + result := getEnvBool("TEST_BOOL_VAR", tt.defaultValue) + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +// TestParseCommaSeparated tests the parseCommaSeparated helper function +func TestParseCommaSeparated(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + {"empty string", "", []string{}}, + {"single value", "value1", []string{"value1"}}, + {"multiple values", "value1,value2,value3", []string{"value1", "value2", "value3"}}, + {"with spaces", "value1, value2, value3", []string{"value1", "value2", "value3"}}, + {"with trailing comma", "value1,value2,", []string{"value1", "value2"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseCommaSeparated(tt.input) + if len(result) != len(tt.expected) { + t.Errorf("Expected length %d, got %d", len(tt.expected), len(result)) + return + } + for i := range result { + if result[i] != tt.expected[i] { + t.Errorf("At index %d: expected '%s', got '%s'", i, tt.expected[i], result[i]) + } + } + }) + } +} + +// TestConfigEnvironmentDefaults tests default environment values +func TestConfigEnvironmentDefaults(t *testing.T) { + // Clear any existing env vars that might interfere + varsToUnset := []string{ + "PORT", "ENVIRONMENT", "DATABASE_URL", "JWT_SECRET", "JWT_REFRESH_SECRET", + } + for _, v := range varsToUnset { + os.Unsetenv(v) + } + + // Set required vars + os.Setenv("DATABASE_URL", "postgres://test:test@localhost:5432/test") + os.Setenv("JWT_SECRET", "test-secret-32-chars-minimum-here") + defer func() { + os.Unsetenv("DATABASE_URL") + os.Unsetenv("JWT_SECRET") + }() + + cfg, err := Load() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + // Test defaults + if cfg.Port != "8080" { + t.Errorf("Expected default port '8080', got '%s'", cfg.Port) + } + + if cfg.Environment != "development" { + t.Errorf("Expected default environment 'development', got '%s'", cfg.Environment) + } +} + +// TestConfigLoadWithEnvironment tests loading config with different environments +func TestConfigLoadWithEnvironment(t *testing.T) { + // Set required vars + os.Setenv("DATABASE_URL", "postgres://test:test@localhost:5432/test") + os.Setenv("JWT_SECRET", "test-secret-32-chars-minimum-here") + defer func() { + os.Unsetenv("DATABASE_URL") + os.Unsetenv("JWT_SECRET") + }() + + tests := []struct { + name string + environment string + }{ + {"development", "development"}, + {"staging", "staging"}, + {"production", "production"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Setenv("ENVIRONMENT", tt.environment) + defer os.Unsetenv("ENVIRONMENT") + + cfg, err := Load() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + if cfg.Environment != tt.environment { + t.Errorf("Expected environment '%s', got '%s'", tt.environment, cfg.Environment) + } + }) + } +} + +// TestConfigMissingRequiredVars tests that missing required vars return errors +func TestConfigMissingRequiredVars(t *testing.T) { + // Clear all env vars + os.Unsetenv("DATABASE_URL") + os.Unsetenv("JWT_SECRET") + + _, err := Load() + if err == nil { + t.Error("Expected error when DATABASE_URL is missing") + } + + // Set DATABASE_URL but not JWT_SECRET + os.Setenv("DATABASE_URL", "postgres://test:test@localhost:5432/test") + defer os.Unsetenv("DATABASE_URL") + + _, err = Load() + if err == nil { + t.Error("Expected error when JWT_SECRET is missing") + } +} + +// TestConfigAllowedOrigins tests that allowed origins are parsed correctly +func TestConfigAllowedOrigins(t *testing.T) { + // Set required vars + os.Setenv("DATABASE_URL", "postgres://test:test@localhost:5432/test") + os.Setenv("JWT_SECRET", "test-secret-32-chars-minimum-here") + os.Setenv("ALLOWED_ORIGINS", "http://localhost:3000,http://localhost:8000,http://localhost:8001") + defer func() { + os.Unsetenv("DATABASE_URL") + os.Unsetenv("JWT_SECRET") + os.Unsetenv("ALLOWED_ORIGINS") + }() + + cfg, err := Load() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + expected := []string{"http://localhost:3000", "http://localhost:8000", "http://localhost:8001"} + if len(cfg.AllowedOrigins) != len(expected) { + t.Errorf("Expected %d origins, got %d", len(expected), len(cfg.AllowedOrigins)) + } + + for i, origin := range cfg.AllowedOrigins { + if origin != expected[i] { + t.Errorf("At index %d: expected '%s', got '%s'", i, expected[i], origin) + } + } +} + +// TestConfigDebugSettings tests debug-related settings for different environments +func TestConfigDebugSettings(t *testing.T) { + // Set required vars + os.Setenv("DATABASE_URL", "postgres://test:test@localhost:5432/test") + os.Setenv("JWT_SECRET", "test-secret-32-chars-minimum-here") + defer func() { + os.Unsetenv("DATABASE_URL") + os.Unsetenv("JWT_SECRET") + }() + + // Test development environment + t.Run("development", func(t *testing.T) { + os.Setenv("ENVIRONMENT", "development") + defer os.Unsetenv("ENVIRONMENT") + + cfg, err := Load() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + if cfg.Environment != "development" { + t.Errorf("Expected 'development', got '%s'", cfg.Environment) + } + }) + + // Test staging environment + t.Run("staging", func(t *testing.T) { + os.Setenv("ENVIRONMENT", "staging") + defer os.Unsetenv("ENVIRONMENT") + + cfg, err := Load() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + if cfg.Environment != "staging" { + t.Errorf("Expected 'staging', got '%s'", cfg.Environment) + } + }) + + // Test production environment + t.Run("production", func(t *testing.T) { + os.Setenv("ENVIRONMENT", "production") + defer os.Unsetenv("ENVIRONMENT") + + cfg, err := Load() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + if cfg.Environment != "production" { + t.Errorf("Expected 'production', got '%s'", cfg.Environment) + } + }) +} + +// TestConfigStagingPorts tests that staging uses different ports +func TestConfigStagingPorts(t *testing.T) { + // Set required vars + os.Setenv("DATABASE_URL", "postgres://test:test@localhost:5433/breakpilot_staging") + os.Setenv("JWT_SECRET", "test-secret-32-chars-minimum-here") + os.Setenv("ENVIRONMENT", "staging") + os.Setenv("PORT", "8081") + defer func() { + os.Unsetenv("DATABASE_URL") + os.Unsetenv("JWT_SECRET") + os.Unsetenv("ENVIRONMENT") + os.Unsetenv("PORT") + }() + + cfg, err := Load() + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + if cfg.Port != "8081" { + t.Errorf("Expected staging port '8081', got '%s'", cfg.Port) + } + + if cfg.Environment != "staging" { + t.Errorf("Expected 'staging', got '%s'", cfg.Environment) + } +} diff --git a/consent-service/internal/database/database.go b/consent-service/internal/database/database.go new file mode 100644 index 0000000..9f81bf6 --- /dev/null +++ b/consent-service/internal/database/database.go @@ -0,0 +1,1317 @@ +package database + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// DB wraps the pgx pool +type DB struct { + Pool *pgxpool.Pool +} + +// Connect establishes a connection to the PostgreSQL database +func Connect(databaseURL string) (*DB, error) { + config, err := pgxpool.ParseConfig(databaseURL) + if err != nil { + return nil, fmt.Errorf("failed to parse database URL: %w", err) + } + + // Configure connection pool + config.MaxConns = 25 + config.MinConns = 5 + config.MaxConnLifetime = time.Hour + config.MaxConnIdleTime = 30 * time.Minute + config.HealthCheckPeriod = time.Minute + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + return nil, fmt.Errorf("failed to create connection pool: %w", err) + } + + // Test the connection + if err := pool.Ping(ctx); err != nil { + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + return &DB{Pool: pool}, nil +} + +// Close closes the database connection pool +func (db *DB) Close() { + db.Pool.Close() +} + +// Migrate runs database migrations +func Migrate(db *DB) error { + ctx := context.Background() + + // Create tables + migrations := []string{ + // Users table (extended for full auth) + `CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + external_id VARCHAR(255) UNIQUE, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255), + name VARCHAR(255), + role VARCHAR(50) DEFAULT 'user', + email_verified BOOLEAN DEFAULT FALSE, + email_verified_at TIMESTAMPTZ, + account_status VARCHAR(20) DEFAULT 'active', + last_login_at TIMESTAMPTZ, + failed_login_attempts INT DEFAULT 0, + locked_until TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Legal documents table + `CREATE TABLE IF NOT EXISTS legal_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + type VARCHAR(50) NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + is_mandatory BOOLEAN DEFAULT true, + is_active BOOLEAN DEFAULT true, + sort_order INT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Document versions table + `CREATE TABLE IF NOT EXISTS document_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + document_id UUID REFERENCES legal_documents(id) ON DELETE CASCADE, + version VARCHAR(20) NOT NULL, + language VARCHAR(5) DEFAULT 'de', + title VARCHAR(255) NOT NULL, + content TEXT NOT NULL, + summary TEXT, + status VARCHAR(20) DEFAULT 'draft', + published_at TIMESTAMPTZ, + scheduled_publish_at TIMESTAMPTZ, + created_by UUID REFERENCES users(id), + approved_by UUID REFERENCES users(id), + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(document_id, version, language) + )`, + + // Add scheduled_publish_at column if not exists (migration) + `ALTER TABLE document_versions ADD COLUMN IF NOT EXISTS scheduled_publish_at TIMESTAMPTZ`, + `ALTER TABLE document_versions ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ`, + + // User consents table + `CREATE TABLE IF NOT EXISTS user_consents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + document_version_id UUID REFERENCES document_versions(id), + consented BOOLEAN NOT NULL, + ip_address INET, + user_agent TEXT, + consented_at TIMESTAMPTZ DEFAULT NOW(), + withdrawn_at TIMESTAMPTZ, + UNIQUE(user_id, document_version_id) + )`, + + // Cookie categories table + `CREATE TABLE IF NOT EXISTS cookie_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL UNIQUE, + display_name_de VARCHAR(255) NOT NULL, + display_name_en VARCHAR(255), + description_de TEXT, + description_en TEXT, + is_mandatory BOOLEAN DEFAULT false, + sort_order INT DEFAULT 0, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Cookie consents table + `CREATE TABLE IF NOT EXISTS cookie_consents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + category_id UUID REFERENCES cookie_categories(id) ON DELETE CASCADE, + consented BOOLEAN NOT NULL, + consented_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(user_id, category_id) + )`, + + // Audit log table + `CREATE TABLE IF NOT EXISTS consent_audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + action VARCHAR(50) NOT NULL, + entity_type VARCHAR(50), + entity_id UUID, + details JSONB, + ip_address INET, + user_agent TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Data export requests table + `CREATE TABLE IF NOT EXISTS data_export_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + status VARCHAR(20) DEFAULT 'pending', + download_url TEXT, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + completed_at TIMESTAMPTZ + )`, + + // Data deletion requests table + `CREATE TABLE IF NOT EXISTS data_deletion_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + status VARCHAR(20) DEFAULT 'pending', + reason TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + processed_at TIMESTAMPTZ, + processed_by UUID REFERENCES users(id) + )`, + + // ============================================= + // Phase 1: User Management Tables + // ============================================= + + // Email verification tokens + `CREATE TABLE IF NOT EXISTS email_verification_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + token VARCHAR(255) UNIQUE NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Password reset tokens + `CREATE TABLE IF NOT EXISTS password_reset_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + token VARCHAR(255) UNIQUE NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + ip_address INET, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // User sessions (for JWT revocation and session management) + `CREATE TABLE IF NOT EXISTS user_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) NOT NULL, + device_info TEXT, + ip_address INET, + user_agent TEXT, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + last_activity_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // ============================================= + // Phase 3: Version Approvals (DSB Workflow) + // ============================================= + + // Version approval tracking + `CREATE TABLE IF NOT EXISTS version_approvals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + version_id UUID REFERENCES document_versions(id) ON DELETE CASCADE, + approver_id UUID REFERENCES users(id), + action VARCHAR(30) NOT NULL, + comment TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // ============================================= + // Phase 4: Notification System + // ============================================= + + // Notifications + `CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + type VARCHAR(50) NOT NULL, + channel VARCHAR(20) NOT NULL, + title VARCHAR(255) NOT NULL, + body TEXT NOT NULL, + data JSONB, + read_at TIMESTAMPTZ, + sent_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Push subscriptions for Web Push + `CREATE TABLE IF NOT EXISTS push_subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + endpoint TEXT NOT NULL, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + user_agent TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(user_id, endpoint) + )`, + + // Notification preferences per user + `CREATE TABLE IF NOT EXISTS notification_preferences ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE UNIQUE, + email_enabled BOOLEAN DEFAULT TRUE, + push_enabled BOOLEAN DEFAULT TRUE, + in_app_enabled BOOLEAN DEFAULT TRUE, + reminder_frequency VARCHAR(20) DEFAULT 'weekly', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // ============================================= + // Phase 5: Consent Deadlines & Account Suspension + // ============================================= + + // Consent deadlines per user per version + `CREATE TABLE IF NOT EXISTS consent_deadlines ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + document_version_id UUID REFERENCES document_versions(id) ON DELETE CASCADE, + deadline_at TIMESTAMPTZ NOT NULL, + reminder_count INT DEFAULT 0, + last_reminder_at TIMESTAMPTZ, + consent_given_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(user_id, document_version_id) + )`, + + // Account suspensions tracking + `CREATE TABLE IF NOT EXISTS account_suspensions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + reason VARCHAR(50) NOT NULL, + details JSONB, + suspended_at TIMESTAMPTZ DEFAULT NOW(), + lifted_at TIMESTAMPTZ, + lifted_reason TEXT + )`, + + // ============================================= + // Indexes for performance + // ============================================= + `CREATE INDEX IF NOT EXISTS idx_user_consents_user ON user_consents(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_user_consents_version ON user_consents(document_version_id)`, + `CREATE INDEX IF NOT EXISTS idx_cookie_consents_user ON cookie_consents(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_audit_log_user ON consent_audit_log(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_audit_log_created ON consent_audit_log(created_at)`, + `CREATE INDEX IF NOT EXISTS idx_document_versions_document ON document_versions(document_id)`, + `CREATE INDEX IF NOT EXISTS idx_document_versions_status ON document_versions(status)`, + `CREATE INDEX IF NOT EXISTS idx_legal_documents_type ON legal_documents(type)`, + + // Phase 1: Auth indexes + `CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_token ON email_verification_tokens(token)`, + `CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_user ON email_verification_tokens(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token)`, + `CREATE INDEX IF NOT EXISTS idx_user_sessions_user ON user_sessions(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(token_hash)`, + + // Phase 3: Approval indexes + `CREATE INDEX IF NOT EXISTS idx_version_approvals_version ON version_approvals(version_id)`, + + // Phase 4: Notification indexes + `CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_notifications_unread ON notifications(user_id, read_at)`, + `CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user ON push_subscriptions(user_id)`, + + // Phase 5: Deadline indexes + `CREATE INDEX IF NOT EXISTS idx_consent_deadlines_user ON consent_deadlines(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_consent_deadlines_deadline ON consent_deadlines(deadline_at)`, + `CREATE INDEX IF NOT EXISTS idx_account_suspensions_user ON account_suspensions(user_id)`, + + // ============================================= + // Phase 6: OAuth 2.0 Authorization Code Flow + // ============================================= + + // OAuth 2.0 Clients + `CREATE TABLE IF NOT EXISTS oauth_clients ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_id VARCHAR(64) UNIQUE NOT NULL, + client_secret VARCHAR(255), + name VARCHAR(255) NOT NULL, + description TEXT, + redirect_uris JSONB NOT NULL DEFAULT '[]', + scopes JSONB NOT NULL DEFAULT '["openid", "profile", "email"]', + grant_types JSONB NOT NULL DEFAULT '["authorization_code", "refresh_token"]', + is_public BOOLEAN DEFAULT FALSE, + is_active BOOLEAN DEFAULT TRUE, + created_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // OAuth 2.0 Authorization Codes + `CREATE TABLE IF NOT EXISTS oauth_authorization_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(255) UNIQUE NOT NULL, + client_id VARCHAR(64) NOT NULL REFERENCES oauth_clients(client_id), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + redirect_uri TEXT NOT NULL, + scopes JSONB NOT NULL DEFAULT '[]', + code_challenge VARCHAR(255), + code_challenge_method VARCHAR(10), + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // OAuth 2.0 Access Tokens + `CREATE TABLE IF NOT EXISTS oauth_access_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token_hash VARCHAR(255) UNIQUE NOT NULL, + client_id VARCHAR(64) NOT NULL REFERENCES oauth_clients(client_id), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + scopes JSONB NOT NULL DEFAULT '[]', + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // OAuth 2.0 Refresh Tokens + `CREATE TABLE IF NOT EXISTS oauth_refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + token_hash VARCHAR(255) UNIQUE NOT NULL, + access_token_id UUID REFERENCES oauth_access_tokens(id) ON DELETE CASCADE, + client_id VARCHAR(64) NOT NULL REFERENCES oauth_clients(client_id), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + scopes JSONB NOT NULL DEFAULT '[]', + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // ============================================= + // Phase 7: Two-Factor Authentication (2FA/TOTP) + // ============================================= + + // User TOTP secrets and recovery codes + `CREATE TABLE IF NOT EXISTS user_totp ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID UNIQUE NOT NULL REFERENCES users(id) ON DELETE CASCADE, + secret VARCHAR(255) NOT NULL, + verified BOOLEAN DEFAULT FALSE, + recovery_codes JSONB DEFAULT '[]', + enabled_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // 2FA challenges during login + `CREATE TABLE IF NOT EXISTS two_factor_challenges ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + challenge_id VARCHAR(255) UNIQUE NOT NULL, + ip_address INET, + user_agent TEXT, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Add 2FA required flag to users + `ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_enabled BOOLEAN DEFAULT FALSE`, + `ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_verified_at TIMESTAMPTZ`, + + // Phase 6 & 7 Indexes + `CREATE INDEX IF NOT EXISTS idx_oauth_clients_client_id ON oauth_clients(client_id)`, + `CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_code ON oauth_authorization_codes(code)`, + `CREATE INDEX IF NOT EXISTS idx_oauth_auth_codes_user ON oauth_authorization_codes(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_oauth_access_tokens_hash ON oauth_access_tokens(token_hash)`, + `CREATE INDEX IF NOT EXISTS idx_oauth_access_tokens_user ON oauth_access_tokens(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_oauth_refresh_tokens_hash ON oauth_refresh_tokens(token_hash)`, + `CREATE INDEX IF NOT EXISTS idx_oauth_refresh_tokens_user ON oauth_refresh_tokens(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_user_totp_user ON user_totp(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_two_factor_challenges_id ON two_factor_challenges(challenge_id)`, + `CREATE INDEX IF NOT EXISTS idx_two_factor_challenges_user ON two_factor_challenges(user_id)`, + + // Insert default OAuth client for BreakPilot PWA (public client with PKCE) + `INSERT INTO oauth_clients (client_id, name, description, redirect_uris, scopes, grant_types, is_public) + VALUES ( + 'breakpilot-pwa', + 'BreakPilot PWA', + 'Official BreakPilot Progressive Web Application', + '["http://localhost:8000/oauth/callback", "http://localhost:8000/app/oauth/callback"]', + '["openid", "profile", "email", "consent:read", "consent:write"]', + '["authorization_code", "refresh_token"]', + true + ) ON CONFLICT (client_id) DO NOTHING`, + + // Insert default cookie categories + `INSERT INTO cookie_categories (name, display_name_de, display_name_en, description_de, description_en, is_mandatory, sort_order) + VALUES + ('necessary', 'Notwendige Cookies', 'Necessary Cookies', + 'Diese Cookies sind für die Grundfunktionen der Website unbedingt erforderlich.', + 'These cookies are essential for the basic functions of the website.', + true, 1), + ('functional', 'Funktionale Cookies', 'Functional Cookies', + 'Diese Cookies ermöglichen erweiterte Funktionen und Personalisierung.', + 'These cookies enable enhanced functionality and personalization.', + false, 2), + ('analytics', 'Analyse Cookies', 'Analytics Cookies', + 'Diese Cookies helfen uns zu verstehen, wie Besucher mit der Website interagieren.', + 'These cookies help us understand how visitors interact with the website.', + false, 3), + ('marketing', 'Marketing Cookies', 'Marketing Cookies', + 'Diese Cookies werden verwendet, um Werbung relevanter für Sie zu gestalten.', + 'These cookies are used to make advertising more relevant to you.', + false, 4) + ON CONFLICT (name) DO NOTHING`, + + // Insert default legal documents + `INSERT INTO legal_documents (type, name, description, is_mandatory, sort_order) + VALUES + ('terms', 'Allgemeine Geschäftsbedingungen', 'Die allgemeinen Geschäftsbedingungen für die Nutzung von BreakPilot.', true, 1), + ('privacy', 'Datenschutzerklärung', 'Informationen über die Verarbeitung Ihrer personenbezogenen Daten.', true, 2), + ('cookies', 'Cookie-Richtlinie', 'Informationen über die Verwendung von Cookies auf unserer Website.', false, 3), + ('community', 'Community Guidelines', 'Regeln für das Verhalten in der BreakPilot Community.', true, 4) + ON CONFLICT DO NOTHING`, + + // ============================================= + // Phase 8: E-Mail Templates (Transactional) + // ============================================= + + // Email templates (like legal_documents) + `CREATE TABLE IF NOT EXISTS email_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + type VARCHAR(50) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + is_active BOOLEAN DEFAULT TRUE, + sort_order INT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Email template versions (like document_versions) + `CREATE TABLE IF NOT EXISTS email_template_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + template_id UUID REFERENCES email_templates(id) ON DELETE CASCADE, + version VARCHAR(20) NOT NULL, + language VARCHAR(5) DEFAULT 'de', + subject VARCHAR(500) NOT NULL, + body_html TEXT NOT NULL, + body_text TEXT NOT NULL, + summary TEXT, + status VARCHAR(20) DEFAULT 'draft', + published_at TIMESTAMPTZ, + scheduled_publish_at TIMESTAMPTZ, + created_by UUID REFERENCES users(id), + approved_by UUID REFERENCES users(id), + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(template_id, version, language) + )`, + + // Email template approvals (like version_approvals) + `CREATE TABLE IF NOT EXISTS email_template_approvals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + version_id UUID REFERENCES email_template_versions(id) ON DELETE CASCADE, + approver_id UUID REFERENCES users(id), + action VARCHAR(30) NOT NULL, + comment TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Email send logs for audit + `CREATE TABLE IF NOT EXISTS email_send_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + version_id UUID REFERENCES email_template_versions(id) ON DELETE SET NULL, + recipient VARCHAR(255) NOT NULL, + subject VARCHAR(500) NOT NULL, + status VARCHAR(20) DEFAULT 'queued', + error_msg TEXT, + variables JSONB, + sent_at TIMESTAMPTZ, + delivered_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Global email settings (logo, colors, signature) + `CREATE TABLE IF NOT EXISTS email_template_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + logo_url TEXT, + logo_base64 TEXT, + company_name VARCHAR(255) DEFAULT 'BreakPilot', + sender_name VARCHAR(255) DEFAULT 'BreakPilot', + sender_email VARCHAR(255) DEFAULT 'noreply@breakpilot.app', + reply_to_email VARCHAR(255), + footer_html TEXT, + footer_text TEXT, + primary_color VARCHAR(7) DEFAULT '#2563eb', + secondary_color VARCHAR(7) DEFAULT '#64748b', + updated_at TIMESTAMPTZ DEFAULT NOW(), + updated_by UUID REFERENCES users(id) + )`, + + // Insert default email settings + `INSERT INTO email_template_settings (id, company_name, sender_name, sender_email, primary_color, secondary_color) + VALUES (gen_random_uuid(), 'BreakPilot', 'BreakPilot', 'noreply@breakpilot.app', '#2563eb', '#64748b') + ON CONFLICT DO NOTHING`, + + // Phase 8 Indexes + `CREATE INDEX IF NOT EXISTS idx_email_templates_type ON email_templates(type)`, + `CREATE INDEX IF NOT EXISTS idx_email_template_versions_template ON email_template_versions(template_id)`, + `CREATE INDEX IF NOT EXISTS idx_email_template_versions_status ON email_template_versions(status)`, + `CREATE INDEX IF NOT EXISTS idx_email_template_approvals_version ON email_template_approvals(version_id)`, + `CREATE INDEX IF NOT EXISTS idx_email_send_logs_user ON email_send_logs(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_email_send_logs_created ON email_send_logs(created_at)`, + `CREATE INDEX IF NOT EXISTS idx_email_send_logs_status ON email_send_logs(status)`, + + // ============================================= + // Phase 9: Schulverwaltung / School Management + // Matrix-basierte Kommunikation für Schulen + // ============================================= + + // Schools table + `CREATE TABLE IF NOT EXISTS schools ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + short_name VARCHAR(50), + type VARCHAR(50) NOT NULL, + address TEXT, + city VARCHAR(100), + postal_code VARCHAR(20), + state VARCHAR(50), + country VARCHAR(2) DEFAULT 'DE', + phone VARCHAR(50), + email VARCHAR(255), + website VARCHAR(255), + matrix_server_name VARCHAR(255), + logo_url TEXT, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // School years + `CREATE TABLE IF NOT EXISTS school_years ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + name VARCHAR(20) NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + is_current BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(school_id, name) + )`, + + // Subjects + `CREATE TABLE IF NOT EXISTS subjects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + short_name VARCHAR(10) NOT NULL, + color VARCHAR(7), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(school_id, short_name) + )`, + + // Classes + `CREATE TABLE IF NOT EXISTS classes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + school_year_id UUID NOT NULL REFERENCES school_years(id) ON DELETE CASCADE, + name VARCHAR(20) NOT NULL, + grade INT NOT NULL, + section VARCHAR(5), + room VARCHAR(50), + matrix_info_room VARCHAR(255), + matrix_rep_room VARCHAR(255), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(school_id, school_year_id, name) + )`, + + // Students + `CREATE TABLE IF NOT EXISTS students ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + student_number VARCHAR(50), + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + date_of_birth DATE, + gender VARCHAR(1), + matrix_user_id VARCHAR(255), + matrix_dm_room VARCHAR(255), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Teachers + `CREATE TABLE IF NOT EXISTS teachers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + teacher_code VARCHAR(10), + title VARCHAR(20), + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + matrix_user_id VARCHAR(255), + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(school_id, user_id) + )`, + + // Class teachers assignment + `CREATE TABLE IF NOT EXISTS class_teachers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE, + teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE, + is_primary BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(class_id, teacher_id) + )`, + + // Teacher subjects assignment + `CREATE TABLE IF NOT EXISTS teacher_subjects ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE, + subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(teacher_id, subject_id) + )`, + + // Parents + `CREATE TABLE IF NOT EXISTS parents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + matrix_user_id VARCHAR(255), + first_name VARCHAR(100) NOT NULL, + last_name VARCHAR(100) NOT NULL, + phone VARCHAR(50), + emergency_contact BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(user_id) + )`, + + // Student-parent relationships + `CREATE TABLE IF NOT EXISTS student_parents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE, + parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE, + relationship VARCHAR(20) NOT NULL, + is_primary BOOLEAN DEFAULT FALSE, + has_custody BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(student_id, parent_id) + )`, + + // Parent representatives + `CREATE TABLE IF NOT EXISTS parent_representatives ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE, + parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE, + role VARCHAR(20) NOT NULL, + elected_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // ============================================= + // Stundenplan / Timetable + // ============================================= + + // Timetable slots (Stundenraster) + `CREATE TABLE IF NOT EXISTS timetable_slots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + slot_number INT NOT NULL, + start_time TIME NOT NULL, + end_time TIME NOT NULL, + is_break BOOLEAN DEFAULT FALSE, + name VARCHAR(50), + UNIQUE(school_id, slot_number) + )`, + + // Timetable entries (Stundenplan) + `CREATE TABLE IF NOT EXISTS timetable_entries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + school_year_id UUID NOT NULL REFERENCES school_years(id) ON DELETE CASCADE, + class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE, + subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE, + teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE, + slot_id UUID NOT NULL REFERENCES timetable_slots(id) ON DELETE CASCADE, + day_of_week INT NOT NULL CHECK (day_of_week >= 1 AND day_of_week <= 7), + room VARCHAR(50), + valid_from DATE NOT NULL, + valid_until DATE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Timetable substitutions (Vertretungsplan) + `CREATE TABLE IF NOT EXISTS timetable_substitutions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + original_entry_id UUID NOT NULL REFERENCES timetable_entries(id) ON DELETE CASCADE, + date DATE NOT NULL, + substitute_teacher_id UUID REFERENCES teachers(id) ON DELETE SET NULL, + substitute_subject_id UUID REFERENCES subjects(id) ON DELETE SET NULL, + room VARCHAR(50), + type VARCHAR(20) NOT NULL, + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID NOT NULL REFERENCES users(id) + )`, + + // ============================================= + // Abwesenheit / Attendance + // ============================================= + + // Attendance records per lesson + `CREATE TABLE IF NOT EXISTS attendance_records ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE, + timetable_entry_id UUID REFERENCES timetable_entries(id) ON DELETE SET NULL, + date DATE NOT NULL, + slot_id UUID NOT NULL REFERENCES timetable_slots(id) ON DELETE CASCADE, + status VARCHAR(30) NOT NULL, + recorded_by UUID NOT NULL REFERENCES users(id), + note TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(student_id, date, slot_id) + )`, + + // Absence reports (Krankmeldungen) + `CREATE TABLE IF NOT EXISTS absence_reports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + reason TEXT, + reason_category VARCHAR(30) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'reported', + reported_by UUID NOT NULL REFERENCES users(id), + reported_at TIMESTAMPTZ DEFAULT NOW(), + confirmed_by UUID REFERENCES users(id), + confirmed_at TIMESTAMPTZ, + medical_certificate BOOLEAN DEFAULT FALSE, + certificate_uploaded BOOLEAN DEFAULT FALSE, + matrix_notification_sent BOOLEAN DEFAULT FALSE, + email_notification_sent BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Absence notifications to parents + `CREATE TABLE IF NOT EXISTS absence_notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + attendance_record_id UUID NOT NULL REFERENCES attendance_records(id) ON DELETE CASCADE, + parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE, + channel VARCHAR(20) NOT NULL, + message_content TEXT NOT NULL, + sent_at TIMESTAMPTZ, + read_at TIMESTAMPTZ, + response_received BOOLEAN DEFAULT FALSE, + response_content TEXT, + response_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // ============================================= + // Notenspiegel / Grades + // ============================================= + + // Grade scales + `CREATE TABLE IF NOT EXISTS grade_scales ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + name VARCHAR(50) NOT NULL, + min_value DECIMAL(5,2) NOT NULL, + max_value DECIMAL(5,2) NOT NULL, + passing_value DECIMAL(5,2) NOT NULL, + is_ascending BOOLEAN DEFAULT FALSE, + is_default BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Grades + `CREATE TABLE IF NOT EXISTS grades ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE, + subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE, + teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE, + school_year_id UUID NOT NULL REFERENCES school_years(id) ON DELETE CASCADE, + grade_scale_id UUID NOT NULL REFERENCES grade_scales(id) ON DELETE CASCADE, + type VARCHAR(30) NOT NULL, + value DECIMAL(5,2) NOT NULL, + weight DECIMAL(3,2) DEFAULT 1.0, + date DATE NOT NULL, + title VARCHAR(100), + description TEXT, + is_visible BOOLEAN DEFAULT TRUE, + semester INT NOT NULL CHECK (semester IN (1, 2)), + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Grade comments + `CREATE TABLE IF NOT EXISTS grade_comments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + grade_id UUID NOT NULL REFERENCES grades(id) ON DELETE CASCADE, + teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE, + comment TEXT NOT NULL, + is_private BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // ============================================= + // Klassenbuch / Class Diary + // ============================================= + + // Class diary entries + `CREATE TABLE IF NOT EXISTS class_diary_entries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE, + date DATE NOT NULL, + slot_id UUID NOT NULL REFERENCES timetable_slots(id) ON DELETE CASCADE, + subject_id UUID NOT NULL REFERENCES subjects(id) ON DELETE CASCADE, + teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE, + topic TEXT, + homework TEXT, + homework_due_date DATE, + materials TEXT, + notes TEXT, + is_cancelled BOOLEAN DEFAULT FALSE, + cancellation_reason TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(class_id, date, slot_id) + )`, + + // ============================================= + // Elterngespräche / Parent Meetings + // ============================================= + + // Parent meeting slots + `CREATE TABLE IF NOT EXISTS parent_meeting_slots ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + teacher_id UUID NOT NULL REFERENCES teachers(id) ON DELETE CASCADE, + date DATE NOT NULL, + start_time TIME NOT NULL, + end_time TIME NOT NULL, + location VARCHAR(100), + is_online BOOLEAN DEFAULT FALSE, + meeting_link TEXT, + is_booked BOOLEAN DEFAULT FALSE, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Parent meetings + `CREATE TABLE IF NOT EXISTS parent_meetings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slot_id UUID NOT NULL REFERENCES parent_meeting_slots(id) ON DELETE CASCADE, + parent_id UUID NOT NULL REFERENCES parents(id) ON DELETE CASCADE, + student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE, + topic TEXT, + notes TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'scheduled', + cancelled_at TIMESTAMPTZ, + cancelled_by UUID REFERENCES users(id), + cancel_reason TEXT, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // ============================================= + // Matrix / Communication Integration + // ============================================= + + // Matrix rooms + `CREATE TABLE IF NOT EXISTS matrix_rooms ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + matrix_room_id VARCHAR(255) NOT NULL UNIQUE, + type VARCHAR(30) NOT NULL, + class_id UUID REFERENCES classes(id) ON DELETE SET NULL, + student_id UUID REFERENCES students(id) ON DELETE SET NULL, + name VARCHAR(255) NOT NULL, + is_encrypted BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Matrix room members + `CREATE TABLE IF NOT EXISTS matrix_room_members ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + matrix_room_id UUID NOT NULL REFERENCES matrix_rooms(id) ON DELETE CASCADE, + matrix_user_id VARCHAR(255) NOT NULL, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + power_level INT DEFAULT 0, + can_write BOOLEAN DEFAULT TRUE, + joined_at TIMESTAMPTZ DEFAULT NOW(), + left_at TIMESTAMPTZ, + UNIQUE(matrix_room_id, matrix_user_id) + )`, + + // Parent onboarding tokens (QR codes) + `CREATE TABLE IF NOT EXISTS parent_onboarding_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + school_id UUID NOT NULL REFERENCES schools(id) ON DELETE CASCADE, + class_id UUID NOT NULL REFERENCES classes(id) ON DELETE CASCADE, + student_id UUID NOT NULL REFERENCES students(id) ON DELETE CASCADE, + token VARCHAR(255) NOT NULL UNIQUE, + role VARCHAR(30) NOT NULL DEFAULT 'parent', + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + used_by_user_id UUID REFERENCES users(id), + created_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID NOT NULL REFERENCES users(id) + )`, + + // ============================================= + // Phase 9 Indexes + // ============================================= + `CREATE INDEX IF NOT EXISTS idx_schools_active ON schools(is_active)`, + `CREATE INDEX IF NOT EXISTS idx_school_years_school ON school_years(school_id)`, + `CREATE INDEX IF NOT EXISTS idx_school_years_current ON school_years(is_current)`, + `CREATE INDEX IF NOT EXISTS idx_classes_school ON classes(school_id)`, + `CREATE INDEX IF NOT EXISTS idx_classes_school_year ON classes(school_year_id)`, + `CREATE INDEX IF NOT EXISTS idx_students_school ON students(school_id)`, + `CREATE INDEX IF NOT EXISTS idx_students_class ON students(class_id)`, + `CREATE INDEX IF NOT EXISTS idx_students_user ON students(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_teachers_school ON teachers(school_id)`, + `CREATE INDEX IF NOT EXISTS idx_teachers_user ON teachers(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_class_teachers_class ON class_teachers(class_id)`, + `CREATE INDEX IF NOT EXISTS idx_class_teachers_teacher ON class_teachers(teacher_id)`, + `CREATE INDEX IF NOT EXISTS idx_parents_user ON parents(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_student_parents_student ON student_parents(student_id)`, + `CREATE INDEX IF NOT EXISTS idx_student_parents_parent ON student_parents(parent_id)`, + `CREATE INDEX IF NOT EXISTS idx_timetable_entries_class ON timetable_entries(class_id)`, + `CREATE INDEX IF NOT EXISTS idx_timetable_entries_teacher ON timetable_entries(teacher_id)`, + `CREATE INDEX IF NOT EXISTS idx_timetable_entries_day ON timetable_entries(day_of_week)`, + `CREATE INDEX IF NOT EXISTS idx_timetable_substitutions_date ON timetable_substitutions(date)`, + `CREATE INDEX IF NOT EXISTS idx_timetable_substitutions_entry ON timetable_substitutions(original_entry_id)`, + `CREATE INDEX IF NOT EXISTS idx_attendance_records_student ON attendance_records(student_id)`, + `CREATE INDEX IF NOT EXISTS idx_attendance_records_date ON attendance_records(date)`, + `CREATE INDEX IF NOT EXISTS idx_absence_reports_student ON absence_reports(student_id)`, + `CREATE INDEX IF NOT EXISTS idx_absence_reports_dates ON absence_reports(start_date, end_date)`, + `CREATE INDEX IF NOT EXISTS idx_grades_student ON grades(student_id)`, + `CREATE INDEX IF NOT EXISTS idx_grades_subject ON grades(subject_id)`, + `CREATE INDEX IF NOT EXISTS idx_grades_teacher ON grades(teacher_id)`, + `CREATE INDEX IF NOT EXISTS idx_grades_school_year ON grades(school_year_id)`, + `CREATE INDEX IF NOT EXISTS idx_class_diary_class_date ON class_diary_entries(class_id, date)`, + `CREATE INDEX IF NOT EXISTS idx_parent_meeting_slots_teacher ON parent_meeting_slots(teacher_id)`, + `CREATE INDEX IF NOT EXISTS idx_parent_meeting_slots_date ON parent_meeting_slots(date)`, + `CREATE INDEX IF NOT EXISTS idx_parent_meetings_slot ON parent_meetings(slot_id)`, + `CREATE INDEX IF NOT EXISTS idx_parent_meetings_parent ON parent_meetings(parent_id)`, + `CREATE INDEX IF NOT EXISTS idx_matrix_rooms_school ON matrix_rooms(school_id)`, + `CREATE INDEX IF NOT EXISTS idx_matrix_rooms_class ON matrix_rooms(class_id)`, + `CREATE INDEX IF NOT EXISTS idx_matrix_room_members_room ON matrix_room_members(matrix_room_id)`, + `CREATE INDEX IF NOT EXISTS idx_parent_onboarding_tokens_token ON parent_onboarding_tokens(token)`, + `CREATE INDEX IF NOT EXISTS idx_parent_onboarding_tokens_student ON parent_onboarding_tokens(student_id)`, + + // Insert default grade scales + `INSERT INTO grade_scales (id, school_id, name, min_value, max_value, passing_value, is_ascending, is_default) + SELECT gen_random_uuid(), s.id, '1-6 (Noten)', 1, 6, 4, false, true + FROM schools s + WHERE NOT EXISTS (SELECT 1 FROM grade_scales gs WHERE gs.school_id = s.id AND gs.name = '1-6 (Noten)') + ON CONFLICT DO NOTHING`, + + // Insert default timetable slots for schools + `DO $$ + DECLARE + school_rec RECORD; + BEGIN + FOR school_rec IN SELECT id FROM schools LOOP + INSERT INTO timetable_slots (school_id, slot_number, start_time, end_time, is_break, name) + VALUES + (school_rec.id, 1, '08:00', '08:45', false, '1. Stunde'), + (school_rec.id, 2, '08:45', '09:30', false, '2. Stunde'), + (school_rec.id, 3, '09:30', '09:50', true, 'Erste Pause'), + (school_rec.id, 4, '09:50', '10:35', false, '3. Stunde'), + (school_rec.id, 5, '10:35', '11:20', false, '4. Stunde'), + (school_rec.id, 6, '11:20', '11:40', true, 'Zweite Pause'), + (school_rec.id, 7, '11:40', '12:25', false, '5. Stunde'), + (school_rec.id, 8, '12:25', '13:10', false, '6. Stunde'), + (school_rec.id, 9, '13:10', '14:00', true, 'Mittagspause'), + (school_rec.id, 10, '14:00', '14:45', false, '7. Stunde'), + (school_rec.id, 11, '14:45', '15:30', false, '8. Stunde') + ON CONFLICT (school_id, slot_number) DO NOTHING; + END LOOP; + END $$`, + + // ============================================= + // Phase 10: DSGVO Betroffenenanfragen (DSR) + // Data Subject Request Management + // ============================================= + + // Sequence for request numbers + `CREATE SEQUENCE IF NOT EXISTS dsr_request_number_seq START 1`, + + // Main table: Data Subject Requests + `CREATE TABLE IF NOT EXISTS data_subject_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + request_number VARCHAR(50) UNIQUE NOT NULL, + request_type VARCHAR(30) NOT NULL, + status VARCHAR(30) NOT NULL DEFAULT 'intake', + priority VARCHAR(20) DEFAULT 'normal', + source VARCHAR(30) NOT NULL DEFAULT 'api', + requester_email VARCHAR(255) NOT NULL, + requester_name VARCHAR(255), + requester_phone VARCHAR(50), + identity_verified BOOLEAN DEFAULT FALSE, + identity_verified_at TIMESTAMPTZ, + identity_verified_by UUID REFERENCES users(id), + identity_verification_method VARCHAR(50), + request_details JSONB DEFAULT '{}', + deadline_at TIMESTAMPTZ NOT NULL, + legal_deadline_days INT NOT NULL, + extended_deadline_at TIMESTAMPTZ, + extension_reason TEXT, + assigned_to UUID REFERENCES users(id), + processing_notes TEXT, + completed_at TIMESTAMPTZ, + completed_by UUID REFERENCES users(id), + result_summary TEXT, + result_data JSONB, + rejected_at TIMESTAMPTZ, + rejected_by UUID REFERENCES users(id), + rejection_reason TEXT, + rejection_legal_basis TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID REFERENCES users(id) + )`, + + // DSR Status History for audit trail + `CREATE TABLE IF NOT EXISTS dsr_status_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + request_id UUID NOT NULL REFERENCES data_subject_requests(id) ON DELETE CASCADE, + from_status VARCHAR(30), + to_status VARCHAR(30) NOT NULL, + changed_by UUID REFERENCES users(id), + comment TEXT, + metadata JSONB, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // DSR Communications log + `CREATE TABLE IF NOT EXISTS dsr_communications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + request_id UUID NOT NULL REFERENCES data_subject_requests(id) ON DELETE CASCADE, + direction VARCHAR(10) NOT NULL, + channel VARCHAR(20) NOT NULL, + communication_type VARCHAR(50) NOT NULL, + template_version_id UUID, + subject VARCHAR(500), + body_html TEXT, + body_text TEXT, + recipient_email VARCHAR(255), + sent_at TIMESTAMPTZ, + error_message TEXT, + attachments JSONB DEFAULT '[]', + created_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID REFERENCES users(id) + )`, + + // DSR Templates + `CREATE TABLE IF NOT EXISTS dsr_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + template_type VARCHAR(50) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + request_types JSONB DEFAULT '["access","rectification","erasure","restriction","portability"]', + is_active BOOLEAN DEFAULT TRUE, + sort_order INT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // DSR Template Versions + `CREATE TABLE IF NOT EXISTS dsr_template_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + template_id UUID REFERENCES dsr_templates(id) ON DELETE CASCADE, + version VARCHAR(20) NOT NULL, + language VARCHAR(5) DEFAULT 'de', + subject VARCHAR(500) NOT NULL, + body_html TEXT NOT NULL, + body_text TEXT NOT NULL, + status VARCHAR(20) DEFAULT 'draft', + published_at TIMESTAMPTZ, + created_by UUID REFERENCES users(id), + approved_by UUID REFERENCES users(id), + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE(template_id, version, language) + )`, + + // DSR Exception Checks (for Art. 17(3) erasure exceptions) + `CREATE TABLE IF NOT EXISTS dsr_exception_checks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + request_id UUID NOT NULL REFERENCES data_subject_requests(id) ON DELETE CASCADE, + exception_type VARCHAR(50) NOT NULL, + description TEXT NOT NULL, + applies BOOLEAN, + checked_by UUID REFERENCES users(id), + checked_at TIMESTAMPTZ, + notes TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // Phase 10 Indexes + `CREATE INDEX IF NOT EXISTS idx_dsr_user ON data_subject_requests(user_id)`, + `CREATE INDEX IF NOT EXISTS idx_dsr_status ON data_subject_requests(status)`, + `CREATE INDEX IF NOT EXISTS idx_dsr_type ON data_subject_requests(request_type)`, + `CREATE INDEX IF NOT EXISTS idx_dsr_deadline ON data_subject_requests(deadline_at)`, + `CREATE INDEX IF NOT EXISTS idx_dsr_assigned ON data_subject_requests(assigned_to)`, + `CREATE INDEX IF NOT EXISTS idx_dsr_request_number ON data_subject_requests(request_number)`, + `CREATE INDEX IF NOT EXISTS idx_dsr_created ON data_subject_requests(created_at)`, + `CREATE INDEX IF NOT EXISTS idx_dsr_status_history_request ON dsr_status_history(request_id)`, + `CREATE INDEX IF NOT EXISTS idx_dsr_communications_request ON dsr_communications(request_id)`, + `CREATE INDEX IF NOT EXISTS idx_dsr_exception_checks_request ON dsr_exception_checks(request_id)`, + `CREATE INDEX IF NOT EXISTS idx_dsr_templates_type ON dsr_templates(template_type)`, + `CREATE INDEX IF NOT EXISTS idx_dsr_template_versions_template ON dsr_template_versions(template_id)`, + `CREATE INDEX IF NOT EXISTS idx_dsr_template_versions_status ON dsr_template_versions(status)`, + + // Insert default DSR templates + `INSERT INTO dsr_templates (template_type, name, description, request_types, sort_order) + VALUES + ('dsr_receipt_access', 'Eingangsbestätigung Auskunft', 'Bestätigung des Eingangs einer Auskunftsanfrage nach Art. 15 DSGVO', '["access"]', 1), + ('dsr_receipt_rectification', 'Eingangsbestätigung Berichtigung', 'Bestätigung des Eingangs einer Berichtigungsanfrage nach Art. 16 DSGVO', '["rectification"]', 2), + ('dsr_receipt_erasure', 'Eingangsbestätigung Löschung', 'Bestätigung des Eingangs einer Löschanfrage nach Art. 17 DSGVO', '["erasure"]', 3), + ('dsr_receipt_restriction', 'Eingangsbestätigung Einschränkung', 'Bestätigung des Eingangs einer Einschränkungsanfrage nach Art. 18 DSGVO', '["restriction"]', 4), + ('dsr_receipt_portability', 'Eingangsbestätigung Datenübertragung', 'Bestätigung des Eingangs einer Datenübertragungsanfrage nach Art. 20 DSGVO', '["portability"]', 5), + ('dsr_identity_request', 'Anfrage Identitätsnachweis', 'Aufforderung zur Identitätsverifizierung', '["access","rectification","erasure","restriction","portability"]', 6), + ('dsr_processing_started', 'Bearbeitungsbestätigung', 'Bestätigung, dass die Bearbeitung begonnen hat', '["access","rectification","erasure","restriction","portability"]', 7), + ('dsr_processing_update', 'Zwischenbericht', 'Zwischenstand zur Bearbeitung', '["access","rectification","erasure","restriction","portability"]', 8), + ('dsr_clarification_request', 'Rückfragen', 'Anfrage zur Klärung des Begehrens', '["access","rectification","erasure","restriction","portability"]', 9), + ('dsr_completed_access', 'Auskunft erteilt', 'Abschließende Mitteilung mit Datenauskunft', '["access"]', 10), + ('dsr_completed_access_negative', 'Negativauskunft', 'Mitteilung dass keine Daten vorhanden sind', '["access"]', 11), + ('dsr_completed_rectification', 'Berichtigung durchgeführt', 'Bestätigung der Datenberichtigung', '["rectification"]', 12), + ('dsr_completed_erasure', 'Löschung durchgeführt', 'Bestätigung der Datenlöschung', '["erasure"]', 13), + ('dsr_completed_restriction', 'Einschränkung aktiviert', 'Bestätigung der Verarbeitungseinschränkung', '["restriction"]', 14), + ('dsr_completed_portability', 'Daten bereitgestellt', 'Mitteilung zur Datenübermittlung', '["portability"]', 15), + ('dsr_restriction_lifted', 'Einschränkung aufgehoben', 'Vorabbenachrichtigung vor Aufhebung der Einschränkung', '["restriction"]', 16), + ('dsr_rejected_identity', 'Ablehnung - Identität nicht verifizierbar', 'Ablehnung mangels Identitätsnachweis', '["access","rectification","erasure","restriction","portability"]', 17), + ('dsr_rejected_exception', 'Ablehnung - Ausnahme', 'Ablehnung aufgrund gesetzlicher Ausnahmen (z.B. Art. 17 Abs. 3)', '["erasure","restriction"]', 18), + ('dsr_rejected_unfounded', 'Ablehnung - Offensichtlich unbegründet', 'Ablehnung nach Art. 12 Abs. 5 DSGVO', '["access","rectification","erasure","restriction","portability"]', 19) + ON CONFLICT (template_type) DO NOTHING`, + + // ============================================= + // Phase 11: EduSearch Seeds Management + // Seed URLs for the education search crawler + // ============================================= + + // EduSearch Seed Categories + `CREATE TABLE IF NOT EXISTS edu_search_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(50) UNIQUE NOT NULL, + display_name VARCHAR(100) NOT NULL, + description TEXT, + icon VARCHAR(10), + sort_order INT DEFAULT 0, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() + )`, + + // EduSearch Seeds (crawler seed URLs) + `CREATE TABLE IF NOT EXISTS edu_search_seeds ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + url VARCHAR(500) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + category_id UUID REFERENCES edu_search_categories(id) ON DELETE SET NULL, + source_type VARCHAR(20) DEFAULT 'GOV', + scope VARCHAR(20) DEFAULT 'FEDERAL', + state VARCHAR(5), + trust_boost DECIMAL(3,2) DEFAULT 0.50, + enabled BOOLEAN DEFAULT TRUE, + crawl_depth INT DEFAULT 2, + crawl_frequency VARCHAR(20) DEFAULT 'weekly', + last_crawled_at TIMESTAMPTZ, + last_crawl_status VARCHAR(20), + last_crawl_docs INT DEFAULT 0, + total_documents INT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID REFERENCES users(id) + )`, + + // EduSearch Crawl Runs (history of crawl executions) + `CREATE TABLE IF NOT EXISTS edu_search_crawl_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + seed_id UUID REFERENCES edu_search_seeds(id) ON DELETE CASCADE, + status VARCHAR(20) DEFAULT 'running', + started_at TIMESTAMPTZ DEFAULT NOW(), + completed_at TIMESTAMPTZ, + pages_crawled INT DEFAULT 0, + documents_indexed INT DEFAULT 0, + errors_count INT DEFAULT 0, + error_details JSONB, + triggered_by UUID REFERENCES users(id) + )`, + + // EduSearch Denylist (URLs/domains to never crawl) + `CREATE TABLE IF NOT EXISTS edu_search_denylist ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + pattern VARCHAR(500) UNIQUE NOT NULL, + pattern_type VARCHAR(20) DEFAULT 'domain', + reason TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID REFERENCES users(id) + )`, + + // Phase 11 Indexes + `CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_category ON edu_search_seeds(category_id)`, + `CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_enabled ON edu_search_seeds(enabled)`, + `CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_state ON edu_search_seeds(state)`, + `CREATE INDEX IF NOT EXISTS idx_edu_search_seeds_scope ON edu_search_seeds(scope)`, + `CREATE INDEX IF NOT EXISTS idx_edu_search_crawl_runs_seed ON edu_search_crawl_runs(seed_id)`, + `CREATE INDEX IF NOT EXISTS idx_edu_search_crawl_runs_status ON edu_search_crawl_runs(status)`, + + // Insert default EduSearch categories + `INSERT INTO edu_search_categories (name, display_name, description, icon, sort_order) + VALUES + ('federal', 'Bundesebene', 'KMK, BMBF, Bildungsserver', '🏛️', 1), + ('states', 'Bundesländer', 'Ministerien, Landesbildungsserver', '🗺️', 2), + ('science', 'Wissenschaft', 'Bertelsmann, PISA, IGLU, TIMSS', '🔬', 3), + ('universities', 'Universitäten', 'Deutsche Hochschulen', '🎓', 4), + ('schools', 'Schulen', 'Schulwebsites', '🏫', 5), + ('portals', 'Bildungsportale', 'Lehrer-Online, 4teachers, ZUM', '📚', 6), + ('eu', 'EU/International', 'Europäische Bildungsberichte', '🇪🇺', 7), + ('authorities', 'Schulbehörden', 'Regierungspräsidien, Schulämter', '📋', 8) + ON CONFLICT (name) DO NOTHING`, + } + + for _, migration := range migrations { + if _, err := db.Pool.Exec(ctx, migration); err != nil { + return fmt.Errorf("failed to run migration: %w", err) + } + } + + return nil +} diff --git a/consent-service/internal/handlers/auth_handlers.go b/consent-service/internal/handlers/auth_handlers.go new file mode 100644 index 0000000..cd00d6c --- /dev/null +++ b/consent-service/internal/handlers/auth_handlers.go @@ -0,0 +1,442 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/breakpilot/consent-service/internal/models" + "github.com/breakpilot/consent-service/internal/services" +) + +// AuthHandler handles authentication endpoints +type AuthHandler struct { + authService *services.AuthService + emailService *services.EmailService +} + +// NewAuthHandler creates a new AuthHandler +func NewAuthHandler(authService *services.AuthService, emailService *services.EmailService) *AuthHandler { + return &AuthHandler{ + authService: authService, + emailService: emailService, + } +} + +// Register handles user registration +// @Summary Register a new user +// @Tags auth +// @Accept json +// @Produce json +// @Param request body models.RegisterRequest true "Registration data" +// @Success 201 {object} map[string]interface{} +// @Failure 400 {object} map[string]string +// @Failure 409 {object} map[string]string +// @Router /auth/register [post] +func (h *AuthHandler) Register(c *gin.Context) { + var req models.RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) + return + } + + user, verificationToken, err := h.authService.Register(c.Request.Context(), &req) + if err != nil { + if err == services.ErrUserExists { + c.JSON(http.StatusConflict, gin.H{"error": "User with this email already exists"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to register user"}) + return + } + + // Send verification email (async, don't block response) + go func() { + var name string + if user.Name != nil { + name = *user.Name + } + if err := h.emailService.SendVerificationEmail(user.Email, name, verificationToken); err != nil { + // Log error but don't fail registration + println("Failed to send verification email:", err.Error()) + } + }() + + c.JSON(http.StatusCreated, gin.H{ + "message": "Registration successful. Please check your email to verify your account.", + "user": gin.H{ + "id": user.ID, + "email": user.Email, + "name": user.Name, + }, + }) +} + +// Login handles user login +// @Summary Login user +// @Tags auth +// @Accept json +// @Produce json +// @Param request body models.LoginRequest true "Login credentials" +// @Success 200 {object} models.LoginResponse +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 403 {object} map[string]string +// @Router /auth/login [post] +func (h *AuthHandler) Login(c *gin.Context) { + var req models.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) + return + } + + ipAddress := c.ClientIP() + userAgent := c.Request.UserAgent() + + response, err := h.authService.Login(c.Request.Context(), &req, ipAddress, userAgent) + if err != nil { + switch err { + case services.ErrInvalidCredentials: + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"}) + case services.ErrAccountLocked: + c.JSON(http.StatusForbidden, gin.H{"error": "Account is temporarily locked. Please try again later."}) + case services.ErrAccountSuspended: + c.JSON(http.StatusForbidden, gin.H{ + "error": "Account is suspended", + "reason": "consent_required", + "redirect": "/consent/pending", + }) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Login failed"}) + } + return + } + + c.JSON(http.StatusOK, response) +} + +// Logout handles user logout +// @Summary Logout user +// @Tags auth +// @Accept json +// @Produce json +// @Param Authorization header string true "Bearer token" +// @Success 200 {object} map[string]string +// @Router /auth/logout [post] +func (h *AuthHandler) Logout(c *gin.Context) { + var req struct { + RefreshToken string `json:"refresh_token"` + } + if err := c.ShouldBindJSON(&req); err == nil && req.RefreshToken != "" { + _ = h.authService.Logout(c.Request.Context(), req.RefreshToken) + } + + c.JSON(http.StatusOK, gin.H{"message": "Logged out successfully"}) +} + +// RefreshToken refreshes the access token +// @Summary Refresh access token +// @Tags auth +// @Accept json +// @Produce json +// @Param request body models.RefreshTokenRequest true "Refresh token" +// @Success 200 {object} models.LoginResponse +// @Failure 401 {object} map[string]string +// @Router /auth/refresh [post] +func (h *AuthHandler) RefreshToken(c *gin.Context) { + var req models.RefreshTokenRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) + return + } + + response, err := h.authService.RefreshToken(c.Request.Context(), req.RefreshToken) + if err != nil { + if err == services.ErrAccountSuspended { + c.JSON(http.StatusForbidden, gin.H{ + "error": "Account is suspended", + "reason": "consent_required", + "redirect": "/consent/pending", + }) + return + } + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired refresh token"}) + return + } + + c.JSON(http.StatusOK, response) +} + +// VerifyEmail verifies user email +// @Summary Verify email address +// @Tags auth +// @Accept json +// @Produce json +// @Param request body models.VerifyEmailRequest true "Verification token" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Router /auth/verify-email [post] +func (h *AuthHandler) VerifyEmail(c *gin.Context) { + var req models.VerifyEmailRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) + return + } + + if err := h.authService.VerifyEmail(c.Request.Context(), req.Token); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired verification token"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Email verified successfully. You can now log in."}) +} + +// ResendVerification resends verification email +// @Summary Resend verification email +// @Tags auth +// @Accept json +// @Produce json +// @Param request body map[string]string true "Email" +// @Success 200 {object} map[string]string +// @Router /auth/resend-verification [post] +func (h *AuthHandler) ResendVerification(c *gin.Context) { + var req struct { + Email string `json:"email" binding:"required,email"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) + return + } + + // Always return success to prevent email enumeration + c.JSON(http.StatusOK, gin.H{"message": "If an account exists with this email, a verification email has been sent."}) +} + +// ForgotPassword initiates password reset +// @Summary Request password reset +// @Tags auth +// @Accept json +// @Produce json +// @Param request body models.ForgotPasswordRequest true "Email" +// @Success 200 {object} map[string]string +// @Router /auth/forgot-password [post] +func (h *AuthHandler) ForgotPassword(c *gin.Context) { + var req models.ForgotPasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) + return + } + + token, userID, err := h.authService.CreatePasswordResetToken(c.Request.Context(), req.Email, c.ClientIP()) + if err == nil && userID != nil { + // Send email asynchronously + go func() { + _ = h.emailService.SendPasswordResetEmail(req.Email, "", token) + }() + } + + // Always return success to prevent email enumeration + c.JSON(http.StatusOK, gin.H{"message": "If an account exists with this email, a password reset link has been sent."}) +} + +// ResetPassword resets password with token +// @Summary Reset password +// @Tags auth +// @Accept json +// @Produce json +// @Param request body models.ResetPasswordRequest true "Reset token and new password" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Router /auth/reset-password [post] +func (h *AuthHandler) ResetPassword(c *gin.Context) { + var req models.ResetPasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) + return + } + + if err := h.authService.ResetPassword(c.Request.Context(), req.Token, req.NewPassword); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid or expired reset token"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Password reset successfully. You can now log in with your new password."}) +} + +// GetProfile returns the current user's profile +// @Summary Get user profile +// @Tags profile +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} models.User +// @Failure 401 {object} map[string]string +// @Router /profile [get] +func (h *AuthHandler) GetProfile(c *gin.Context) { + userIDStr, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + userID, err := uuid.Parse(userIDStr.(string)) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user ID"}) + return + } + + user, err := h.authService.GetUserByID(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "User not found"}) + return + } + + c.JSON(http.StatusOK, user) +} + +// UpdateProfile updates the current user's profile +// @Summary Update user profile +// @Tags profile +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body models.UpdateProfileRequest true "Profile data" +// @Success 200 {object} models.User +// @Failure 400 {object} map[string]string +// @Router /profile [put] +func (h *AuthHandler) UpdateProfile(c *gin.Context) { + userIDStr, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + userID, err := uuid.Parse(userIDStr.(string)) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user ID"}) + return + } + + var req models.UpdateProfileRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) + return + } + + user, err := h.authService.UpdateProfile(c.Request.Context(), userID, &req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile"}) + return + } + + c.JSON(http.StatusOK, user) +} + +// ChangePassword changes the current user's password +// @Summary Change password +// @Tags profile +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param request body models.ChangePasswordRequest true "Password data" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Router /profile/password [put] +func (h *AuthHandler) ChangePassword(c *gin.Context) { + userIDStr, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + userID, err := uuid.Parse(userIDStr.(string)) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user ID"}) + return + } + + var req models.ChangePasswordRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "details": err.Error()}) + return + } + + if err := h.authService.ChangePassword(c.Request.Context(), userID, req.CurrentPassword, req.NewPassword); err != nil { + if err == services.ErrInvalidCredentials { + c.JSON(http.StatusBadRequest, gin.H{"error": "Current password is incorrect"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to change password"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Password changed successfully"}) +} + +// GetActiveSessions returns all active sessions for the current user +// @Summary Get active sessions +// @Tags profile +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {array} models.UserSession +// @Router /profile/sessions [get] +func (h *AuthHandler) GetActiveSessions(c *gin.Context) { + userIDStr, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + userID, err := uuid.Parse(userIDStr.(string)) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user ID"}) + return + } + + sessions, err := h.authService.GetActiveSessions(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get sessions"}) + return + } + + c.JSON(http.StatusOK, gin.H{"sessions": sessions}) +} + +// RevokeSession revokes a specific session +// @Summary Revoke session +// @Tags profile +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Session ID" +// @Success 200 {object} map[string]string +// @Failure 404 {object} map[string]string +// @Router /profile/sessions/{id} [delete] +func (h *AuthHandler) RevokeSession(c *gin.Context) { + userIDStr, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return + } + + userID, err := uuid.Parse(userIDStr.(string)) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user ID"}) + return + } + + sessionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid session ID"}) + return + } + + if err := h.authService.RevokeSession(c.Request.Context(), userID, sessionID); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Session not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Session revoked successfully"}) +} diff --git a/consent-service/internal/handlers/banner_handlers.go b/consent-service/internal/handlers/banner_handlers.go new file mode 100644 index 0000000..71aa7b8 --- /dev/null +++ b/consent-service/internal/handlers/banner_handlers.go @@ -0,0 +1,561 @@ +package handlers + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// ======================================== +// Cookie Banner SDK API Handlers +// ======================================== +// Diese Endpoints werden vom @breakpilot/consent-sdk verwendet +// für anonyme (device-basierte) Cookie-Einwilligungen. + +// BannerConsentRecord repräsentiert einen anonymen Consent-Eintrag +type BannerConsentRecord struct { + ID string `json:"id"` + SiteID string `json:"site_id"` + DeviceFingerprint string `json:"device_fingerprint"` + UserID *string `json:"user_id,omitempty"` + Categories map[string]bool `json:"categories"` + Vendors map[string]bool `json:"vendors,omitempty"` + TCFString *string `json:"tcf_string,omitempty"` + IPHash *string `json:"ip_hash,omitempty"` + UserAgent *string `json:"user_agent,omitempty"` + Language *string `json:"language,omitempty"` + Platform *string `json:"platform,omitempty"` + AppVersion *string `json:"app_version,omitempty"` + Version string `json:"version"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + RevokedAt *time.Time `json:"revoked_at,omitempty"` +} + +// BannerConsentRequest ist der Request-Body für POST /consent +type BannerConsentRequest struct { + SiteID string `json:"siteId" binding:"required"` + UserID *string `json:"userId,omitempty"` + DeviceFingerprint string `json:"deviceFingerprint" binding:"required"` + Consent ConsentData `json:"consent" binding:"required"` + Metadata *ConsentMetadata `json:"metadata,omitempty"` +} + +// ConsentData enthält die eigentlichen Consent-Daten +type ConsentData struct { + Categories map[string]bool `json:"categories" binding:"required"` + Vendors map[string]bool `json:"vendors,omitempty"` +} + +// ConsentMetadata enthält optionale Metadaten +type ConsentMetadata struct { + UserAgent *string `json:"userAgent,omitempty"` + Language *string `json:"language,omitempty"` + ScreenResolution *string `json:"screenResolution,omitempty"` + Platform *string `json:"platform,omitempty"` + AppVersion *string `json:"appVersion,omitempty"` +} + +// BannerConsentResponse ist die Antwort auf POST /consent +type BannerConsentResponse struct { + ConsentID string `json:"consentId"` + Timestamp string `json:"timestamp"` + ExpiresAt string `json:"expiresAt"` + Version string `json:"version"` +} + +// SiteConfig repräsentiert die Konfiguration für eine Site +type SiteConfig struct { + SiteID string `json:"siteId"` + SiteName string `json:"siteName"` + Categories []CategoryConfig `json:"categories"` + UI UIConfig `json:"ui"` + Legal LegalConfig `json:"legal"` + TCF *TCFConfig `json:"tcf,omitempty"` +} + +// CategoryConfig repräsentiert eine Consent-Kategorie +type CategoryConfig struct { + ID string `json:"id"` + Name map[string]string `json:"name"` + Description map[string]string `json:"description"` + Required bool `json:"required"` + Vendors []VendorConfig `json:"vendors"` +} + +// VendorConfig repräsentiert einen Vendor (Third-Party) +type VendorConfig struct { + ID string `json:"id"` + Name string `json:"name"` + PrivacyPolicyURL string `json:"privacyPolicyUrl"` + Cookies []CookieInfo `json:"cookies"` +} + +// CookieInfo repräsentiert ein Cookie +type CookieInfo struct { + Name string `json:"name"` + Expiration string `json:"expiration"` + Description string `json:"description"` +} + +// UIConfig repräsentiert UI-Einstellungen +type UIConfig struct { + Theme string `json:"theme"` + Position string `json:"position"` +} + +// LegalConfig repräsentiert rechtliche Informationen +type LegalConfig struct { + PrivacyPolicyURL string `json:"privacyPolicyUrl"` + ImprintURL string `json:"imprintUrl"` +} + +// TCFConfig repräsentiert TCF 2.2 Einstellungen +type TCFConfig struct { + Enabled bool `json:"enabled"` + CmpID int `json:"cmpId"` + CmpVersion int `json:"cmpVersion"` +} + +// ======================================== +// Handler Methods +// ======================================== + +// CreateBannerConsent erstellt oder aktualisiert einen Consent-Eintrag +// POST /api/v1/banner/consent +func (h *Handler) CreateBannerConsent(c *gin.Context) { + var req BannerConsentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_request", + "message": "Invalid request body: " + err.Error(), + }) + return + } + + ctx := context.Background() + + // IP-Adresse anonymisieren + ipHash := anonymizeIP(c.ClientIP()) + + // Consent-ID generieren + consentID := uuid.New().String() + + // Ablaufdatum (1 Jahr) + expiresAt := time.Now().AddDate(1, 0, 0) + + // Categories und Vendors als JSON + categoriesJSON, _ := json.Marshal(req.Consent.Categories) + vendorsJSON, _ := json.Marshal(req.Consent.Vendors) + + // Metadaten extrahieren + var userAgent, language, platform, appVersion *string + if req.Metadata != nil { + userAgent = req.Metadata.UserAgent + language = req.Metadata.Language + platform = req.Metadata.Platform + appVersion = req.Metadata.AppVersion + } + + // In Datenbank speichern + _, err := h.db.Pool.Exec(ctx, ` + INSERT INTO banner_consents ( + id, site_id, device_fingerprint, user_id, + categories, vendors, ip_hash, user_agent, + language, platform, app_version, version, + expires_at, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), NOW()) + ON CONFLICT (site_id, device_fingerprint) + DO UPDATE SET + categories = $5, + vendors = $6, + ip_hash = $7, + user_agent = $8, + language = $9, + platform = $10, + app_version = $11, + version = $12, + expires_at = $13, + updated_at = NOW() + RETURNING id + `, consentID, req.SiteID, req.DeviceFingerprint, req.UserID, + categoriesJSON, vendorsJSON, ipHash, userAgent, + language, platform, appVersion, "1.0.0", expiresAt) + + if err != nil { + // Fallback: Existierenden Consent abrufen + var existingID string + err2 := h.db.Pool.QueryRow(ctx, ` + SELECT id FROM banner_consents + WHERE site_id = $1 AND device_fingerprint = $2 + `, req.SiteID, req.DeviceFingerprint).Scan(&existingID) + + if err2 == nil { + consentID = existingID + } + } + + // Audit-Log schreiben + h.logBannerConsentAudit(ctx, consentID, "created", req, ipHash) + + // Response + c.JSON(http.StatusCreated, BannerConsentResponse{ + ConsentID: consentID, + Timestamp: time.Now().UTC().Format(time.RFC3339), + ExpiresAt: expiresAt.UTC().Format(time.RFC3339), + Version: "1.0.0", + }) +} + +// GetBannerConsent ruft einen bestehenden Consent ab +// GET /api/v1/banner/consent?siteId=xxx&deviceFingerprint=xxx +func (h *Handler) GetBannerConsent(c *gin.Context) { + siteID := c.Query("siteId") + deviceFingerprint := c.Query("deviceFingerprint") + + if siteID == "" || deviceFingerprint == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "missing_parameters", + "message": "siteId and deviceFingerprint are required", + }) + return + } + + ctx := context.Background() + + var record BannerConsentRecord + var categoriesJSON, vendorsJSON []byte + + err := h.db.Pool.QueryRow(ctx, ` + SELECT id, site_id, device_fingerprint, user_id, + categories, vendors, version, + created_at, updated_at, expires_at, revoked_at + FROM banner_consents + WHERE site_id = $1 AND device_fingerprint = $2 AND revoked_at IS NULL + `, siteID, deviceFingerprint).Scan( + &record.ID, &record.SiteID, &record.DeviceFingerprint, &record.UserID, + &categoriesJSON, &vendorsJSON, &record.Version, + &record.CreatedAt, &record.UpdatedAt, &record.ExpiresAt, &record.RevokedAt, + ) + + if err != nil { + c.JSON(http.StatusNotFound, gin.H{ + "error": "consent_not_found", + "message": "No consent record found", + }) + return + } + + // JSON parsen + json.Unmarshal(categoriesJSON, &record.Categories) + json.Unmarshal(vendorsJSON, &record.Vendors) + + c.JSON(http.StatusOK, gin.H{ + "consentId": record.ID, + "consent": gin.H{ + "categories": record.Categories, + "vendors": record.Vendors, + }, + "createdAt": record.CreatedAt.UTC().Format(time.RFC3339), + "updatedAt": record.UpdatedAt.UTC().Format(time.RFC3339), + "expiresAt": record.ExpiresAt.UTC().Format(time.RFC3339), + "version": record.Version, + }) +} + +// RevokeBannerConsent widerruft einen Consent +// DELETE /api/v1/banner/consent/:consentId +func (h *Handler) RevokeBannerConsent(c *gin.Context) { + consentID := c.Param("consentId") + + ctx := context.Background() + + result, err := h.db.Pool.Exec(ctx, ` + UPDATE banner_consents + SET revoked_at = NOW(), updated_at = NOW() + WHERE id = $1 AND revoked_at IS NULL + `, consentID) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "revoke_failed", + "message": "Failed to revoke consent", + }) + return + } + + if result.RowsAffected() == 0 { + c.JSON(http.StatusNotFound, gin.H{ + "error": "consent_not_found", + "message": "Consent not found or already revoked", + }) + return + } + + // Audit-Log + h.logBannerConsentAudit(ctx, consentID, "revoked", nil, anonymizeIP(c.ClientIP())) + + c.JSON(http.StatusOK, gin.H{ + "status": "revoked", + "revokedAt": time.Now().UTC().Format(time.RFC3339), + }) +} + +// GetSiteConfig gibt die Konfiguration für eine Site zurück +// GET /api/v1/banner/config/:siteId +func (h *Handler) GetSiteConfig(c *gin.Context) { + siteID := c.Param("siteId") + + // Standard-Kategorien (aus Datenbank oder Default) + categories := []CategoryConfig{ + { + ID: "essential", + Name: map[string]string{ + "de": "Essentiell", + "en": "Essential", + }, + Description: map[string]string{ + "de": "Notwendig für die Grundfunktionen der Website.", + "en": "Required for basic website functionality.", + }, + Required: true, + Vendors: []VendorConfig{}, + }, + { + ID: "functional", + Name: map[string]string{ + "de": "Funktional", + "en": "Functional", + }, + Description: map[string]string{ + "de": "Ermöglicht Personalisierung und Komfortfunktionen.", + "en": "Enables personalization and comfort features.", + }, + Required: false, + Vendors: []VendorConfig{}, + }, + { + ID: "analytics", + Name: map[string]string{ + "de": "Statistik", + "en": "Analytics", + }, + Description: map[string]string{ + "de": "Hilft uns, die Website zu verbessern.", + "en": "Helps us improve the website.", + }, + Required: false, + Vendors: []VendorConfig{}, + }, + { + ID: "marketing", + Name: map[string]string{ + "de": "Marketing", + "en": "Marketing", + }, + Description: map[string]string{ + "de": "Ermöglicht personalisierte Werbung.", + "en": "Enables personalized advertising.", + }, + Required: false, + Vendors: []VendorConfig{}, + }, + { + ID: "social", + Name: map[string]string{ + "de": "Soziale Medien", + "en": "Social Media", + }, + Description: map[string]string{ + "de": "Ermöglicht Inhalte von sozialen Netzwerken.", + "en": "Enables content from social networks.", + }, + Required: false, + Vendors: []VendorConfig{}, + }, + } + + config := SiteConfig{ + SiteID: siteID, + SiteName: "BreakPilot", + Categories: categories, + UI: UIConfig{ + Theme: "auto", + Position: "bottom", + }, + Legal: LegalConfig{ + PrivacyPolicyURL: "/datenschutz", + ImprintURL: "/impressum", + }, + } + + c.JSON(http.StatusOK, config) +} + +// ExportBannerConsent exportiert alle Consent-Daten eines Nutzers (DSGVO Art. 20) +// GET /api/v1/banner/consent/export?userId=xxx +func (h *Handler) ExportBannerConsent(c *gin.Context) { + userID := c.Query("userId") + + if userID == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "missing_user_id", + "message": "userId parameter is required", + }) + return + } + + ctx := context.Background() + + rows, err := h.db.Pool.Query(ctx, ` + SELECT id, site_id, device_fingerprint, categories, vendors, + version, created_at, updated_at, revoked_at + FROM banner_consents + WHERE user_id = $1 + ORDER BY created_at DESC + `, userID) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "export_failed", + "message": "Failed to export consent data", + }) + return + } + defer rows.Close() + + var consents []map[string]interface{} + for rows.Next() { + var id, siteID, deviceFingerprint, version string + var categoriesJSON, vendorsJSON []byte + var createdAt, updatedAt time.Time + var revokedAt *time.Time + + rows.Scan(&id, &siteID, &deviceFingerprint, &categoriesJSON, &vendorsJSON, + &version, &createdAt, &updatedAt, &revokedAt) + + var categories, vendors map[string]bool + json.Unmarshal(categoriesJSON, &categories) + json.Unmarshal(vendorsJSON, &vendors) + + consent := map[string]interface{}{ + "consentId": id, + "siteId": siteID, + "consent": map[string]interface{}{ + "categories": categories, + "vendors": vendors, + }, + "createdAt": createdAt.UTC().Format(time.RFC3339), + "revokedAt": nil, + } + + if revokedAt != nil { + consent["revokedAt"] = revokedAt.UTC().Format(time.RFC3339) + } + + consents = append(consents, consent) + } + + c.JSON(http.StatusOK, gin.H{ + "userId": userID, + "exportedAt": time.Now().UTC().Format(time.RFC3339), + "consents": consents, + }) +} + +// GetBannerStats gibt anonymisierte Statistiken zurück (Admin) +// GET /api/v1/banner/admin/stats/:siteId +func (h *Handler) GetBannerStats(c *gin.Context) { + siteID := c.Param("siteId") + + ctx := context.Background() + + // Gesamtanzahl Consents + var totalConsents int + h.db.Pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM banner_consents + WHERE site_id = $1 AND revoked_at IS NULL + `, siteID).Scan(&totalConsents) + + // Consent-Rate pro Kategorie + categoryStats := make(map[string]map[string]interface{}) + + rows, _ := h.db.Pool.Query(ctx, ` + SELECT + key as category, + COUNT(*) FILTER (WHERE value::text = 'true') as accepted, + COUNT(*) as total + FROM banner_consents, + jsonb_each(categories::jsonb) + WHERE site_id = $1 AND revoked_at IS NULL + GROUP BY key + `, siteID) + + if rows != nil { + defer rows.Close() + for rows.Next() { + var category string + var accepted, total int + rows.Scan(&category, &accepted, &total) + + rate := float64(0) + if total > 0 { + rate = float64(accepted) / float64(total) + } + + categoryStats[category] = map[string]interface{}{ + "accepted": accepted, + "rate": rate, + } + } + } + + c.JSON(http.StatusOK, gin.H{ + "siteId": siteID, + "period": gin.H{ + "from": time.Now().AddDate(0, -1, 0).Format("2006-01-02"), + "to": time.Now().Format("2006-01-02"), + }, + "totalConsents": totalConsents, + "consentByCategory": categoryStats, + }) +} + +// ======================================== +// Helper Functions +// ======================================== + +// anonymizeIP anonymisiert eine IP-Adresse (DSGVO-konform) +func anonymizeIP(ip string) string { + // IPv4: Letztes Oktett auf 0 + parts := strings.Split(ip, ".") + if len(parts) == 4 { + parts[3] = "0" + anonymized := strings.Join(parts, ".") + hash := sha256.Sum256([]byte(anonymized)) + return hex.EncodeToString(hash[:])[:16] + } + + // IPv6: Hash + hash := sha256.Sum256([]byte(ip)) + return hex.EncodeToString(hash[:])[:16] +} + +// logBannerConsentAudit schreibt einen Audit-Log-Eintrag +func (h *Handler) logBannerConsentAudit(ctx context.Context, consentID, action string, req interface{}, ipHash string) { + details, _ := json.Marshal(req) + + h.db.Pool.Exec(ctx, ` + INSERT INTO banner_consent_audit_log ( + id, consent_id, action, details, ip_hash, created_at + ) VALUES ($1, $2, $3, $4, $5, NOW()) + `, uuid.New().String(), consentID, action, string(details), ipHash) +} diff --git a/consent-service/internal/handlers/communication_handlers.go b/consent-service/internal/handlers/communication_handlers.go new file mode 100644 index 0000000..8c45607 --- /dev/null +++ b/consent-service/internal/handlers/communication_handlers.go @@ -0,0 +1,511 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/breakpilot/consent-service/internal/services/jitsi" + "github.com/breakpilot/consent-service/internal/services/matrix" + "github.com/gin-gonic/gin" +) + +// CommunicationHandlers handles Matrix and Jitsi API endpoints +type CommunicationHandlers struct { + matrixService *matrix.MatrixService + jitsiService *jitsi.JitsiService +} + +// NewCommunicationHandlers creates new communication handlers +func NewCommunicationHandlers(matrixSvc *matrix.MatrixService, jitsiSvc *jitsi.JitsiService) *CommunicationHandlers { + return &CommunicationHandlers{ + matrixService: matrixSvc, + jitsiService: jitsiSvc, + } +} + +// ======================================== +// Health & Status Endpoints +// ======================================== + +// GetCommunicationStatus returns status of Matrix and Jitsi services +func (h *CommunicationHandlers) GetCommunicationStatus(c *gin.Context) { + ctx := c.Request.Context() + + status := gin.H{ + "timestamp": time.Now().UTC().Format(time.RFC3339), + } + + // Check Matrix + if h.matrixService != nil { + matrixErr := h.matrixService.HealthCheck(ctx) + status["matrix"] = gin.H{ + "enabled": true, + "healthy": matrixErr == nil, + "server_name": h.matrixService.GetServerName(), + "error": errToString(matrixErr), + } + } else { + status["matrix"] = gin.H{ + "enabled": false, + "healthy": false, + } + } + + // Check Jitsi + if h.jitsiService != nil { + jitsiErr := h.jitsiService.HealthCheck(ctx) + serverInfo := h.jitsiService.GetServerInfo() + status["jitsi"] = gin.H{ + "enabled": true, + "healthy": jitsiErr == nil, + "base_url": serverInfo["base_url"], + "auth_enabled": serverInfo["auth_enabled"], + "error": errToString(jitsiErr), + } + } else { + status["jitsi"] = gin.H{ + "enabled": false, + "healthy": false, + } + } + + c.JSON(http.StatusOK, status) +} + +// ======================================== +// Matrix Room Endpoints +// ======================================== + +// CreateRoomRequest for creating Matrix rooms +type CreateRoomRequest struct { + Type string `json:"type" binding:"required"` // "class_info", "student_dm", "parent_rep" + ClassName string `json:"class_name"` + SchoolName string `json:"school_name"` + StudentName string `json:"student_name,omitempty"` + TeacherIDs []string `json:"teacher_ids"` + ParentIDs []string `json:"parent_ids,omitempty"` + ParentRepIDs []string `json:"parent_rep_ids,omitempty"` +} + +// CreateRoom creates a new Matrix room based on type +func (h *CommunicationHandlers) CreateRoom(c *gin.Context) { + if h.matrixService == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Matrix service not configured"}) + return + } + + var req CreateRoomRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx := c.Request.Context() + var resp *matrix.CreateRoomResponse + var err error + + switch req.Type { + case "class_info": + resp, err = h.matrixService.CreateClassInfoRoom(ctx, req.ClassName, req.SchoolName, req.TeacherIDs) + case "student_dm": + resp, err = h.matrixService.CreateStudentDMRoom(ctx, req.StudentName, req.ClassName, req.TeacherIDs, req.ParentIDs) + case "parent_rep": + resp, err = h.matrixService.CreateParentRepRoom(ctx, req.ClassName, req.TeacherIDs, req.ParentRepIDs) + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid room type. Use: class_info, student_dm, parent_rep"}) + return + } + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "room_id": resp.RoomID, + "type": req.Type, + }) +} + +// InviteUserRequest for inviting users to rooms +type InviteUserRequest struct { + RoomID string `json:"room_id" binding:"required"` + UserID string `json:"user_id" binding:"required"` +} + +// InviteUser invites a user to a Matrix room +func (h *CommunicationHandlers) InviteUser(c *gin.Context) { + if h.matrixService == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Matrix service not configured"}) + return + } + + var req InviteUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx := c.Request.Context() + if err := h.matrixService.InviteUser(ctx, req.RoomID, req.UserID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// SendMessageRequest for sending messages +type SendMessageRequest struct { + RoomID string `json:"room_id" binding:"required"` + Message string `json:"message" binding:"required"` + HTML string `json:"html,omitempty"` +} + +// SendMessage sends a message to a Matrix room +func (h *CommunicationHandlers) SendMessage(c *gin.Context) { + if h.matrixService == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Matrix service not configured"}) + return + } + + var req SendMessageRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx := c.Request.Context() + var err error + + if req.HTML != "" { + err = h.matrixService.SendHTMLMessage(ctx, req.RoomID, req.Message, req.HTML) + } else { + err = h.matrixService.SendMessage(ctx, req.RoomID, req.Message) + } + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// SendNotificationRequest for sending school notifications +type SendNotificationRequest struct { + RoomID string `json:"room_id" binding:"required"` + Type string `json:"type" binding:"required"` // "absence", "grade", "announcement" + StudentName string `json:"student_name,omitempty"` + Date string `json:"date,omitempty"` + Lesson int `json:"lesson,omitempty"` + Subject string `json:"subject,omitempty"` + GradeType string `json:"grade_type,omitempty"` + Grade float64 `json:"grade,omitempty"` + Title string `json:"title,omitempty"` + Content string `json:"content,omitempty"` + TeacherName string `json:"teacher_name,omitempty"` +} + +// SendNotification sends a typed notification (absence, grade, announcement) +func (h *CommunicationHandlers) SendNotification(c *gin.Context) { + if h.matrixService == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Matrix service not configured"}) + return + } + + var req SendNotificationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx := c.Request.Context() + var err error + + switch req.Type { + case "absence": + err = h.matrixService.SendAbsenceNotification(ctx, req.RoomID, req.StudentName, req.Date, req.Lesson) + case "grade": + err = h.matrixService.SendGradeNotification(ctx, req.RoomID, req.StudentName, req.Subject, req.GradeType, req.Grade) + case "announcement": + err = h.matrixService.SendClassAnnouncement(ctx, req.RoomID, req.Title, req.Content, req.TeacherName) + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification type. Use: absence, grade, announcement"}) + return + } + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// RegisterUserRequest for user registration +type RegisterUserRequest struct { + Username string `json:"username" binding:"required"` + DisplayName string `json:"display_name"` +} + +// RegisterMatrixUser registers a new Matrix user +func (h *CommunicationHandlers) RegisterMatrixUser(c *gin.Context) { + if h.matrixService == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Matrix service not configured"}) + return + } + + var req RegisterUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx := c.Request.Context() + resp, err := h.matrixService.RegisterUser(ctx, req.Username, req.DisplayName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "user_id": resp.UserID, + }) +} + +// ======================================== +// Jitsi Video Conference Endpoints +// ======================================== + +// CreateMeetingRequest for creating Jitsi meetings +type CreateMeetingRequest struct { + Type string `json:"type" binding:"required"` // "quick", "training", "parent_teacher", "class" + Title string `json:"title,omitempty"` + DisplayName string `json:"display_name"` + Email string `json:"email,omitempty"` + Duration int `json:"duration,omitempty"` // minutes + ClassName string `json:"class_name,omitempty"` + ParentName string `json:"parent_name,omitempty"` + StudentName string `json:"student_name,omitempty"` + Subject string `json:"subject,omitempty"` + StartTime time.Time `json:"start_time,omitempty"` +} + +// CreateMeeting creates a new Jitsi meeting +func (h *CommunicationHandlers) CreateMeeting(c *gin.Context) { + if h.jitsiService == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"}) + return + } + + var req CreateMeetingRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + ctx := c.Request.Context() + var link *jitsi.MeetingLink + var err error + + switch req.Type { + case "quick": + link, err = h.jitsiService.CreateQuickMeeting(ctx, req.DisplayName) + case "training": + link, err = h.jitsiService.CreateTrainingSession(ctx, req.Title, req.DisplayName, req.Email, req.Duration) + case "parent_teacher": + link, err = h.jitsiService.CreateParentTeacherMeeting(ctx, req.DisplayName, req.ParentName, req.StudentName, req.StartTime) + case "class": + link, err = h.jitsiService.CreateClassMeeting(ctx, req.ClassName, req.DisplayName, req.Subject) + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid meeting type. Use: quick, training, parent_teacher, class"}) + return + } + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "room_name": link.RoomName, + "url": link.URL, + "join_url": link.JoinURL, + "moderator_url": link.ModeratorURL, + "password": link.Password, + "expires_at": link.ExpiresAt, + }) +} + +// GetEmbedURLRequest for embedding Jitsi +type GetEmbedURLRequest struct { + RoomName string `json:"room_name" binding:"required"` + DisplayName string `json:"display_name"` + AudioMuted bool `json:"audio_muted"` + VideoMuted bool `json:"video_muted"` +} + +// GetEmbedURL returns an embeddable Jitsi URL +func (h *CommunicationHandlers) GetEmbedURL(c *gin.Context) { + if h.jitsiService == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"}) + return + } + + var req GetEmbedURLRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + config := &jitsi.MeetingConfig{ + StartWithAudioMuted: req.AudioMuted, + StartWithVideoMuted: req.VideoMuted, + DisableDeepLinking: true, + } + + embedURL := h.jitsiService.BuildEmbedURL(req.RoomName, req.DisplayName, config) + iframeCode := h.jitsiService.BuildIFrameCode(req.RoomName, 800, 600) + + c.JSON(http.StatusOK, gin.H{ + "embed_url": embedURL, + "iframe_code": iframeCode, + }) +} + +// GetJitsiInfo returns Jitsi server information +func (h *CommunicationHandlers) GetJitsiInfo(c *gin.Context) { + if h.jitsiService == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Jitsi service not configured"}) + return + } + + info := h.jitsiService.GetServerInfo() + c.JSON(http.StatusOK, info) +} + +// ======================================== +// Admin Statistics Endpoints (for Admin Panel) +// ======================================== + +// CommunicationStats holds communication service statistics +type CommunicationStats struct { + Matrix MatrixStats `json:"matrix"` + Jitsi JitsiStats `json:"jitsi"` +} + +// MatrixStats holds Matrix-specific statistics +type MatrixStats struct { + Enabled bool `json:"enabled"` + Healthy bool `json:"healthy"` + ServerName string `json:"server_name"` + // TODO: Add real stats from Matrix Synapse Admin API + TotalUsers int `json:"total_users"` + TotalRooms int `json:"total_rooms"` + ActiveToday int `json:"active_today"` + MessagesToday int `json:"messages_today"` +} + +// JitsiStats holds Jitsi-specific statistics +type JitsiStats struct { + Enabled bool `json:"enabled"` + Healthy bool `json:"healthy"` + BaseURL string `json:"base_url"` + AuthEnabled bool `json:"auth_enabled"` + // TODO: Add real stats from Jitsi SRTP API or Jicofo + ActiveMeetings int `json:"active_meetings"` + TotalParticipants int `json:"total_participants"` + MeetingsToday int `json:"meetings_today"` + AvgDurationMin int `json:"avg_duration_min"` +} + +// GetAdminStats returns admin statistics for Matrix and Jitsi +func (h *CommunicationHandlers) GetAdminStats(c *gin.Context) { + ctx := c.Request.Context() + + stats := CommunicationStats{} + + // Matrix Stats + if h.matrixService != nil { + matrixErr := h.matrixService.HealthCheck(ctx) + stats.Matrix = MatrixStats{ + Enabled: true, + Healthy: matrixErr == nil, + ServerName: h.matrixService.GetServerName(), + // Placeholder stats - in production these would come from Synapse Admin API + TotalUsers: 0, + TotalRooms: 0, + ActiveToday: 0, + MessagesToday: 0, + } + } else { + stats.Matrix = MatrixStats{Enabled: false} + } + + // Jitsi Stats + if h.jitsiService != nil { + jitsiErr := h.jitsiService.HealthCheck(ctx) + serverInfo := h.jitsiService.GetServerInfo() + stats.Jitsi = JitsiStats{ + Enabled: true, + Healthy: jitsiErr == nil, + BaseURL: serverInfo["base_url"], + AuthEnabled: serverInfo["auth_enabled"] == "true", + // Placeholder stats - in production these would come from Jicofo/JVB stats + ActiveMeetings: 0, + TotalParticipants: 0, + MeetingsToday: 0, + AvgDurationMin: 0, + } + } else { + stats.Jitsi = JitsiStats{Enabled: false} + } + + c.JSON(http.StatusOK, stats) +} + +// ======================================== +// Helper Functions +// ======================================== + +func errToString(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +// RegisterRoutes registers all communication routes +func (h *CommunicationHandlers) RegisterRoutes(router *gin.RouterGroup, jwtSecret string, authMiddleware gin.HandlerFunc) { + comm := router.Group("/communication") + { + // Public health check + comm.GET("/status", h.GetCommunicationStatus) + + // Protected routes + protected := comm.Group("") + protected.Use(authMiddleware) + { + // Matrix + protected.POST("/rooms", h.CreateRoom) + protected.POST("/rooms/invite", h.InviteUser) + protected.POST("/messages", h.SendMessage) + protected.POST("/notifications", h.SendNotification) + + // Jitsi + protected.POST("/meetings", h.CreateMeeting) + protected.POST("/meetings/embed", h.GetEmbedURL) + protected.GET("/jitsi/info", h.GetJitsiInfo) + } + + // Admin routes (for Matrix user registration and stats) + admin := comm.Group("/admin") + admin.Use(authMiddleware) + // TODO: Add AdminOnly middleware + { + admin.POST("/matrix/users", h.RegisterMatrixUser) + admin.GET("/stats", h.GetAdminStats) + } + } +} diff --git a/consent-service/internal/handlers/communication_handlers_test.go b/consent-service/internal/handlers/communication_handlers_test.go new file mode 100644 index 0000000..03322da --- /dev/null +++ b/consent-service/internal/handlers/communication_handlers_test.go @@ -0,0 +1,407 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +// TestGetCommunicationStatus_NoServices tests status with no services configured +func TestGetCommunicationStatus_NoServices_ReturnsDisabled(t *testing.T) { + gin.SetMode(gin.TestMode) + + // Create handler with no services + handler := NewCommunicationHandlers(nil, nil) + + router := gin.New() + router.GET("/api/v1/communication/status", handler.GetCommunicationStatus) + + req, _ := http.NewRequest("GET", "/api/v1/communication/status", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + // Check matrix is disabled + matrix, ok := response["matrix"].(map[string]interface{}) + if !ok { + t.Fatal("Expected matrix in response") + } + if matrix["enabled"] != false { + t.Error("Expected matrix.enabled to be false") + } + + // Check jitsi is disabled + jitsi, ok := response["jitsi"].(map[string]interface{}) + if !ok { + t.Fatal("Expected jitsi in response") + } + if jitsi["enabled"] != false { + t.Error("Expected jitsi.enabled to be false") + } + + // Check timestamp exists + if _, ok := response["timestamp"]; !ok { + t.Error("Expected timestamp in response") + } +} + +// TestCreateRoom_NoMatrixService tests room creation without Matrix +func TestCreateRoom_NoMatrixService_Returns503(t *testing.T) { + gin.SetMode(gin.TestMode) + + handler := NewCommunicationHandlers(nil, nil) + + router := gin.New() + router.POST("/api/v1/communication/rooms", handler.CreateRoom) + + body := `{"type": "class_info", "class_name": "5b"}` + req, _ := http.NewRequest("POST", "/api/v1/communication/rooms", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("Expected status 503, got %d", w.Code) + } + + var response map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if response["error"] != "Matrix service not configured" { + t.Errorf("Unexpected error message: %s", response["error"]) + } +} + +// TestCreateRoom_InvalidBody tests room creation with invalid body +func TestCreateRoom_InvalidBody_Returns400(t *testing.T) { + gin.SetMode(gin.TestMode) + + handler := NewCommunicationHandlers(nil, nil) + + router := gin.New() + router.POST("/api/v1/communication/rooms", handler.CreateRoom) + + req, _ := http.NewRequest("POST", "/api/v1/communication/rooms", bytes.NewBufferString("{invalid")) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Service unavailable check happens first, so we get 503 + // This is expected behavior - service check before body validation + if w.Code != http.StatusServiceUnavailable { + t.Errorf("Expected status 503, got %d", w.Code) + } +} + +// TestInviteUser_NoMatrixService tests invite without Matrix +func TestInviteUser_NoMatrixService_Returns503(t *testing.T) { + gin.SetMode(gin.TestMode) + + handler := NewCommunicationHandlers(nil, nil) + + router := gin.New() + router.POST("/api/v1/communication/rooms/invite", handler.InviteUser) + + body := `{"room_id": "!abc:server", "user_id": "@user:server"}` + req, _ := http.NewRequest("POST", "/api/v1/communication/rooms/invite", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("Expected status 503, got %d", w.Code) + } +} + +// TestSendMessage_NoMatrixService tests message sending without Matrix +func TestSendMessage_NoMatrixService_Returns503(t *testing.T) { + gin.SetMode(gin.TestMode) + + handler := NewCommunicationHandlers(nil, nil) + + router := gin.New() + router.POST("/api/v1/communication/messages", handler.SendMessage) + + body := `{"room_id": "!abc:server", "message": "Hello"}` + req, _ := http.NewRequest("POST", "/api/v1/communication/messages", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("Expected status 503, got %d", w.Code) + } +} + +// TestSendNotification_NoMatrixService tests notification without Matrix +func TestSendNotification_NoMatrixService_Returns503(t *testing.T) { + gin.SetMode(gin.TestMode) + + handler := NewCommunicationHandlers(nil, nil) + + router := gin.New() + router.POST("/api/v1/communication/notifications", handler.SendNotification) + + body := `{"room_id": "!abc:server", "type": "absence", "student_name": "Max"}` + req, _ := http.NewRequest("POST", "/api/v1/communication/notifications", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("Expected status 503, got %d", w.Code) + } +} + +// TestCreateMeeting_NoJitsiService tests meeting creation without Jitsi +func TestCreateMeeting_NoJitsiService_Returns503(t *testing.T) { + gin.SetMode(gin.TestMode) + + handler := NewCommunicationHandlers(nil, nil) + + router := gin.New() + router.POST("/api/v1/communication/meetings", handler.CreateMeeting) + + body := `{"type": "quick", "display_name": "Teacher"}` + req, _ := http.NewRequest("POST", "/api/v1/communication/meetings", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("Expected status 503, got %d", w.Code) + } + + var response map[string]string + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if response["error"] != "Jitsi service not configured" { + t.Errorf("Unexpected error message: %s", response["error"]) + } +} + +// TestGetEmbedURL_NoJitsiService tests embed URL without Jitsi +func TestGetEmbedURL_NoJitsiService_Returns503(t *testing.T) { + gin.SetMode(gin.TestMode) + + handler := NewCommunicationHandlers(nil, nil) + + router := gin.New() + router.POST("/api/v1/communication/meetings/embed", handler.GetEmbedURL) + + body := `{"room_name": "test-room", "display_name": "User"}` + req, _ := http.NewRequest("POST", "/api/v1/communication/meetings/embed", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("Expected status 503, got %d", w.Code) + } +} + +// TestGetJitsiInfo_NoJitsiService tests Jitsi info without service +func TestGetJitsiInfo_NoJitsiService_Returns503(t *testing.T) { + gin.SetMode(gin.TestMode) + + handler := NewCommunicationHandlers(nil, nil) + + router := gin.New() + router.GET("/api/v1/communication/jitsi/info", handler.GetJitsiInfo) + + req, _ := http.NewRequest("GET", "/api/v1/communication/jitsi/info", nil) + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("Expected status 503, got %d", w.Code) + } +} + +// TestRegisterMatrixUser_NoMatrixService tests user registration without Matrix +func TestRegisterMatrixUser_NoMatrixService_Returns503(t *testing.T) { + gin.SetMode(gin.TestMode) + + handler := NewCommunicationHandlers(nil, nil) + + router := gin.New() + router.POST("/api/v1/communication/admin/matrix/users", handler.RegisterMatrixUser) + + body := `{"username": "testuser", "display_name": "Test User"}` + req, _ := http.NewRequest("POST", "/api/v1/communication/admin/matrix/users", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("Expected status 503, got %d", w.Code) + } +} + +// TestGetAdminStats_NoServices tests admin stats without services +func TestGetAdminStats_NoServices_ReturnsDisabledStats(t *testing.T) { + gin.SetMode(gin.TestMode) + + handler := NewCommunicationHandlers(nil, nil) + + router := gin.New() + router.GET("/api/v1/communication/admin/stats", handler.GetAdminStats) + + req, _ := http.NewRequest("GET", "/api/v1/communication/admin/stats", nil) + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response CommunicationStats + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if response.Matrix.Enabled { + t.Error("Expected matrix.enabled to be false") + } + + if response.Jitsi.Enabled { + t.Error("Expected jitsi.enabled to be false") + } +} + +// TestErrToString tests the helper function +func TestErrToString_NilError_ReturnsEmpty(t *testing.T) { + result := errToString(nil) + if result != "" { + t.Errorf("Expected empty string, got %s", result) + } +} + +// TestErrToString_WithError_ReturnsMessage tests error string conversion +func TestErrToString_WithError_ReturnsMessage(t *testing.T) { + err := &testError{"test error message"} + result := errToString(err) + if result != "test error message" { + t.Errorf("Expected 'test error message', got %s", result) + } +} + +// testError is a simple error implementation for testing +type testError struct { + message string +} + +func (e *testError) Error() string { + return e.message +} + +// TestCreateRoomRequest_Types tests different room types validation +func TestCreateRoom_InvalidType_Returns400(t *testing.T) { + gin.SetMode(gin.TestMode) + + // Since we don't have Matrix service, we get 503 first + // This test documents expected behavior when Matrix IS available + handler := NewCommunicationHandlers(nil, nil) + + router := gin.New() + router.POST("/api/v1/communication/rooms", handler.CreateRoom) + + body := `{"type": "invalid_type", "class_name": "5b"}` + req, _ := http.NewRequest("POST", "/api/v1/communication/rooms", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Without Matrix service, we get 503 before type validation + if w.Code != http.StatusServiceUnavailable { + t.Errorf("Expected status 503, got %d", w.Code) + } +} + +// TestCreateMeeting_InvalidType tests invalid meeting type +func TestCreateMeeting_InvalidType_Returns503(t *testing.T) { + gin.SetMode(gin.TestMode) + + handler := NewCommunicationHandlers(nil, nil) + + router := gin.New() + router.POST("/api/v1/communication/meetings", handler.CreateMeeting) + + body := `{"type": "invalid", "display_name": "User"}` + req, _ := http.NewRequest("POST", "/api/v1/communication/meetings", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Without Jitsi service, we get 503 before type validation + if w.Code != http.StatusServiceUnavailable { + t.Errorf("Expected status 503, got %d", w.Code) + } +} + +// TestSendNotification_InvalidType tests invalid notification type +func TestSendNotification_InvalidType_Returns503(t *testing.T) { + gin.SetMode(gin.TestMode) + + handler := NewCommunicationHandlers(nil, nil) + + router := gin.New() + router.POST("/api/v1/communication/notifications", handler.SendNotification) + + body := `{"room_id": "!abc:server", "type": "invalid", "student_name": "Max"}` + req, _ := http.NewRequest("POST", "/api/v1/communication/notifications", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Without Matrix service, we get 503 before type validation + if w.Code != http.StatusServiceUnavailable { + t.Errorf("Expected status 503, got %d", w.Code) + } +} + +// TestNewCommunicationHandlers tests constructor +func TestNewCommunicationHandlers_WithNilServices_CreatesHandler(t *testing.T) { + handler := NewCommunicationHandlers(nil, nil) + + if handler == nil { + t.Fatal("Expected handler to be created") + } + + if handler.matrixService != nil { + t.Error("Expected matrixService to be nil") + } + + if handler.jitsiService != nil { + t.Error("Expected jitsiService to be nil") + } +} diff --git a/consent-service/internal/handlers/deadline_handlers.go b/consent-service/internal/handlers/deadline_handlers.go new file mode 100644 index 0000000..21bebf5 --- /dev/null +++ b/consent-service/internal/handlers/deadline_handlers.go @@ -0,0 +1,92 @@ +package handlers + +import ( + "net/http" + + "github.com/breakpilot/consent-service/internal/middleware" + "github.com/breakpilot/consent-service/internal/services" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// DeadlineHandler handles deadline-related requests +type DeadlineHandler struct { + deadlineService *services.DeadlineService +} + +// NewDeadlineHandler creates a new deadline handler +func NewDeadlineHandler(deadlineService *services.DeadlineService) *DeadlineHandler { + return &DeadlineHandler{ + deadlineService: deadlineService, + } +} + +// GetPendingDeadlines returns all pending consent deadlines for the current user +func (h *DeadlineHandler) GetPendingDeadlines(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + deadlines, err := h.deadlineService.GetPendingDeadlines(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch deadlines"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "deadlines": deadlines, + "count": len(deadlines), + }) +} + +// GetSuspensionStatus returns the current suspension status for a user +func (h *DeadlineHandler) GetSuspensionStatus(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + suspended, err := h.deadlineService.IsUserSuspended(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check suspension status"}) + return + } + + response := gin.H{ + "suspended": suspended, + } + + if suspended { + suspension, err := h.deadlineService.GetAccountSuspension(c.Request.Context(), userID) + if err == nil && suspension != nil { + response["reason"] = suspension.Reason + response["suspended_at"] = suspension.SuspendedAt + response["details"] = suspension.Details + } + + deadlines, err := h.deadlineService.GetPendingDeadlines(c.Request.Context(), userID) + if err == nil { + response["pending_deadlines"] = deadlines + } + } + + c.JSON(http.StatusOK, response) +} + +// TriggerDeadlineProcessing manually triggers deadline processing (admin only) +func (h *DeadlineHandler) TriggerDeadlineProcessing(c *gin.Context) { + if !middleware.IsAdmin(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) + return + } + + if err := h.deadlineService.ProcessDailyDeadlines(c.Request.Context()); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process deadlines"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Deadline processing completed"}) +} diff --git a/consent-service/internal/handlers/dsr_handlers.go b/consent-service/internal/handlers/dsr_handlers.go new file mode 100644 index 0000000..af8e83b --- /dev/null +++ b/consent-service/internal/handlers/dsr_handlers.go @@ -0,0 +1,948 @@ +package handlers + +import ( + "context" + "net/http" + "strconv" + "time" + + "github.com/breakpilot/consent-service/internal/middleware" + "github.com/breakpilot/consent-service/internal/models" + "github.com/breakpilot/consent-service/internal/services" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// DSRHandler handles Data Subject Request HTTP endpoints +type DSRHandler struct { + dsrService *services.DSRService +} + +// NewDSRHandler creates a new DSR handler +func NewDSRHandler(dsrService *services.DSRService) *DSRHandler { + return &DSRHandler{ + dsrService: dsrService, + } +} + +// ======================================== +// USER ENDPOINTS +// ======================================== + +// CreateDSR creates a new data subject request (user-facing) +func (h *DSRHandler) CreateDSR(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + var req models.CreateDSRRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) + return + } + + // Get user email if not provided + if req.RequesterEmail == "" { + var email string + ctx := context.Background() + h.dsrService.GetPool().QueryRow(ctx, "SELECT email FROM users WHERE id = $1", userID).Scan(&email) + req.RequesterEmail = email + } + + // Set source as API + req.Source = "api" + + dsr, err := h.dsrService.CreateRequest(c.Request.Context(), req, &userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Ihre Anfrage wurde erfolgreich eingereicht", + "request_number": dsr.RequestNumber, + "dsr": dsr, + }) +} + +// GetMyDSRs returns DSRs for the current user +func (h *DSRHandler) GetMyDSRs(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + dsrs, err := h.dsrService.ListByUser(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch requests"}) + return + } + + c.JSON(http.StatusOK, gin.H{"requests": dsrs}) +} + +// GetMyDSR returns a specific DSR for the current user +func (h *DSRHandler) GetMyDSR(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + dsr, err := h.dsrService.GetByID(c.Request.Context(), dsrID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Request not found"}) + return + } + + // Verify ownership + if dsr.UserID == nil || *dsr.UserID != userID { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } + + c.JSON(http.StatusOK, dsr) +} + +// CancelMyDSR cancels a user's own DSR +func (h *DSRHandler) CancelMyDSR(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + err = h.dsrService.CancelRequest(c.Request.Context(), dsrID, userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde storniert"}) +} + +// ======================================== +// ADMIN ENDPOINTS +// ======================================== + +// AdminListDSR returns all DSRs with filters (admin only) +func (h *DSRHandler) AdminListDSR(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + // Parse pagination + limit := 20 + offset := 0 + if l := c.Query("limit"); l != "" { + if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { + limit = parsed + } + } + if o := c.Query("offset"); o != "" { + if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { + offset = parsed + } + } + + // Parse filters + filters := models.DSRListFilters{} + if status := c.Query("status"); status != "" { + filters.Status = &status + } + if reqType := c.Query("request_type"); reqType != "" { + filters.RequestType = &reqType + } + if assignedTo := c.Query("assigned_to"); assignedTo != "" { + filters.AssignedTo = &assignedTo + } + if priority := c.Query("priority"); priority != "" { + filters.Priority = &priority + } + if c.Query("overdue_only") == "true" { + filters.OverdueOnly = true + } + if search := c.Query("search"); search != "" { + filters.Search = &search + } + if from := c.Query("from_date"); from != "" { + if t, err := time.Parse("2006-01-02", from); err == nil { + filters.FromDate = &t + } + } + if to := c.Query("to_date"); to != "" { + if t, err := time.Parse("2006-01-02", to); err == nil { + filters.ToDate = &t + } + } + + dsrs, total, err := h.dsrService.List(c.Request.Context(), filters, limit, offset) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch requests"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "requests": dsrs, + "total": total, + "limit": limit, + "offset": offset, + }) +} + +// AdminGetDSR returns a specific DSR (admin only) +func (h *DSRHandler) AdminGetDSR(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + dsr, err := h.dsrService.GetByID(c.Request.Context(), dsrID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Request not found"}) + return + } + + c.JSON(http.StatusOK, dsr) +} + +// AdminCreateDSR creates a DSR manually (admin only) +func (h *DSRHandler) AdminCreateDSR(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + userID, _ := middleware.GetUserID(c) + + var req models.CreateDSRRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) + return + } + + // Set source as admin_panel + if req.Source == "" { + req.Source = "admin_panel" + } + + dsr, err := h.dsrService.CreateRequest(c.Request.Context(), req, &userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Anfrage wurde erstellt", + "request_number": dsr.RequestNumber, + "dsr": dsr, + }) +} + +// AdminUpdateDSR updates a DSR (admin only) +func (h *DSRHandler) AdminUpdateDSR(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + + var req models.UpdateDSRRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + ctx := c.Request.Context() + + // Update status if provided + if req.Status != nil { + err = h.dsrService.UpdateStatus(ctx, dsrID, models.DSRStatus(*req.Status), "", &userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + } + + // Update processing notes + if req.ProcessingNotes != nil { + h.dsrService.GetPool().Exec(ctx, ` + UPDATE data_subject_requests SET processing_notes = $1, updated_at = NOW() WHERE id = $2 + `, *req.ProcessingNotes, dsrID) + } + + // Update priority + if req.Priority != nil { + h.dsrService.GetPool().Exec(ctx, ` + UPDATE data_subject_requests SET priority = $1, updated_at = NOW() WHERE id = $2 + `, *req.Priority, dsrID) + } + + // Get updated DSR + dsr, _ := h.dsrService.GetByID(ctx, dsrID) + + c.JSON(http.StatusOK, gin.H{ + "message": "Anfrage wurde aktualisiert", + "dsr": dsr, + }) +} + +// AdminGetDSRStats returns dashboard statistics +func (h *DSRHandler) AdminGetDSRStats(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + stats, err := h.dsrService.GetDashboardStats(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch statistics"}) + return + } + + c.JSON(http.StatusOK, stats) +} + +// AdminVerifyIdentity verifies the identity of a requester +func (h *DSRHandler) AdminVerifyIdentity(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + + var req models.VerifyDSRIdentityRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + err = h.dsrService.VerifyIdentity(c.Request.Context(), dsrID, req.Method, userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Identität wurde verifiziert"}) +} + +// AdminAssignDSR assigns a DSR to a user +func (h *DSRHandler) AdminAssignDSR(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + + var req struct { + AssigneeID string `json:"assignee_id" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + assigneeID, err := uuid.Parse(req.AssigneeID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid assignee ID"}) + return + } + + err = h.dsrService.AssignRequest(c.Request.Context(), dsrID, assigneeID, userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde zugewiesen"}) +} + +// AdminExtendDSRDeadline extends the deadline for a DSR +func (h *DSRHandler) AdminExtendDSRDeadline(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + + var req models.ExtendDSRDeadlineRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + err = h.dsrService.ExtendDeadline(c.Request.Context(), dsrID, req.Reason, req.Days, userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Frist wurde verlängert"}) +} + +// AdminCompleteDSR marks a DSR as completed +func (h *DSRHandler) AdminCompleteDSR(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + + var req models.CompleteDSRRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + err = h.dsrService.CompleteRequest(c.Request.Context(), dsrID, req.ResultSummary, req.ResultData, userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde abgeschlossen"}) +} + +// AdminRejectDSR rejects a DSR +func (h *DSRHandler) AdminRejectDSR(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + + var req models.RejectDSRRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + err = h.dsrService.RejectRequest(c.Request.Context(), dsrID, req.Reason, req.LegalBasis, userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Anfrage wurde abgelehnt"}) +} + +// AdminGetDSRHistory returns the status history for a DSR +func (h *DSRHandler) AdminGetDSRHistory(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + history, err := h.dsrService.GetStatusHistory(c.Request.Context(), dsrID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch history"}) + return + } + + c.JSON(http.StatusOK, gin.H{"history": history}) +} + +// AdminGetDSRCommunications returns communications for a DSR +func (h *DSRHandler) AdminGetDSRCommunications(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + comms, err := h.dsrService.GetCommunications(c.Request.Context(), dsrID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch communications"}) + return + } + + c.JSON(http.StatusOK, gin.H{"communications": comms}) +} + +// AdminSendDSRCommunication sends a communication for a DSR +func (h *DSRHandler) AdminSendDSRCommunication(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + + var req models.SendDSRCommunicationRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + err = h.dsrService.SendCommunication(c.Request.Context(), dsrID, req, userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Kommunikation wurde gesendet"}) +} + +// AdminUpdateDSRStatus updates the status of a DSR +func (h *DSRHandler) AdminUpdateDSRStatus(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + + var req struct { + Status string `json:"status" binding:"required"` + Comment string `json:"comment"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + err = h.dsrService.UpdateStatus(c.Request.Context(), dsrID, models.DSRStatus(req.Status), req.Comment, &userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Status wurde aktualisiert"}) +} + +// ======================================== +// EXCEPTION CHECKS (Art. 17) +// ======================================== + +// AdminGetExceptionChecks returns exception checks for an erasure DSR +func (h *DSRHandler) AdminGetExceptionChecks(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + checks, err := h.dsrService.GetExceptionChecks(c.Request.Context(), dsrID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch exception checks"}) + return + } + + c.JSON(http.StatusOK, gin.H{"exception_checks": checks}) +} + +// AdminInitExceptionChecks initializes exception checks for an erasure DSR +func (h *DSRHandler) AdminInitExceptionChecks(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + dsrID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request ID"}) + return + } + + err = h.dsrService.InitErasureExceptionChecks(c.Request.Context(), dsrID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to initialize exception checks"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Ausnahmeprüfungen wurden initialisiert"}) +} + +// AdminUpdateExceptionCheck updates a single exception check +func (h *DSRHandler) AdminUpdateExceptionCheck(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + checkID, err := uuid.Parse(c.Param("checkId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid check ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + + var req struct { + Applies bool `json:"applies"` + Notes *string `json:"notes"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + err = h.dsrService.UpdateExceptionCheck(c.Request.Context(), checkID, req.Applies, req.Notes, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update exception check"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Ausnahmeprüfung wurde aktualisiert"}) +} + +// ======================================== +// TEMPLATE ENDPOINTS +// ======================================== + +// AdminGetDSRTemplates returns all DSR templates +func (h *DSRHandler) AdminGetDSRTemplates(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + ctx := c.Request.Context() + rows, err := h.dsrService.GetPool().Query(ctx, ` + SELECT id, template_type, name, description, request_types, is_active, sort_order, created_at, updated_at + FROM dsr_templates ORDER BY sort_order, name + `) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"}) + return + } + defer rows.Close() + + var templates []map[string]interface{} + for rows.Next() { + var id uuid.UUID + var templateType, name string + var description *string + var requestTypes []byte + var isActive bool + var sortOrder int + var createdAt, updatedAt time.Time + + err := rows.Scan(&id, &templateType, &name, &description, &requestTypes, &isActive, &sortOrder, &createdAt, &updatedAt) + if err != nil { + continue + } + + templates = append(templates, map[string]interface{}{ + "id": id, + "template_type": templateType, + "name": name, + "description": description, + "request_types": string(requestTypes), + "is_active": isActive, + "sort_order": sortOrder, + "created_at": createdAt, + "updated_at": updatedAt, + }) + } + + c.JSON(http.StatusOK, gin.H{"templates": templates}) +} + +// AdminGetDSRTemplateVersions returns versions for a template +func (h *DSRHandler) AdminGetDSRTemplateVersions(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + templateID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"}) + return + } + + ctx := c.Request.Context() + rows, err := h.dsrService.GetPool().Query(ctx, ` + SELECT id, template_id, version, language, subject, body_html, body_text, + status, published_at, created_by, approved_by, approved_at, created_at, updated_at + FROM dsr_template_versions WHERE template_id = $1 ORDER BY created_at DESC + `, templateID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch versions"}) + return + } + defer rows.Close() + + var versions []map[string]interface{} + for rows.Next() { + var id, tempID uuid.UUID + var version, language, subject, bodyHTML, bodyText, status string + var publishedAt, approvedAt *time.Time + var createdBy, approvedBy *uuid.UUID + var createdAt, updatedAt time.Time + + err := rows.Scan(&id, &tempID, &version, &language, &subject, &bodyHTML, &bodyText, + &status, &publishedAt, &createdBy, &approvedBy, &approvedAt, &createdAt, &updatedAt) + if err != nil { + continue + } + + versions = append(versions, map[string]interface{}{ + "id": id, + "template_id": tempID, + "version": version, + "language": language, + "subject": subject, + "body_html": bodyHTML, + "body_text": bodyText, + "status": status, + "published_at": publishedAt, + "created_by": createdBy, + "approved_by": approvedBy, + "approved_at": approvedAt, + "created_at": createdAt, + "updated_at": updatedAt, + }) + } + + c.JSON(http.StatusOK, gin.H{"versions": versions}) +} + +// AdminCreateDSRTemplateVersion creates a new template version +func (h *DSRHandler) AdminCreateDSRTemplateVersion(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + templateID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid template ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + + var req struct { + Version string `json:"version" binding:"required"` + Language string `json:"language"` + Subject string `json:"subject" binding:"required"` + BodyHTML string `json:"body_html" binding:"required"` + BodyText string `json:"body_text"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + if req.Language == "" { + req.Language = "de" + } + + ctx := c.Request.Context() + var versionID uuid.UUID + err = h.dsrService.GetPool().QueryRow(ctx, ` + INSERT INTO dsr_template_versions (template_id, version, language, subject, body_html, body_text, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id + `, templateID, req.Version, req.Language, req.Subject, req.BodyHTML, req.BodyText, userID).Scan(&versionID) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create version"}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Version wurde erstellt", + "id": versionID, + }) +} + +// AdminPublishDSRTemplateVersion publishes a template version +func (h *DSRHandler) AdminPublishDSRTemplateVersion(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + versionID, err := uuid.Parse(c.Param("versionId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + + ctx := c.Request.Context() + _, err = h.dsrService.GetPool().Exec(ctx, ` + UPDATE dsr_template_versions + SET status = 'published', published_at = NOW(), approved_by = $1, approved_at = NOW(), updated_at = NOW() + WHERE id = $2 + `, userID, versionID) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish version"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Version wurde veröffentlicht"}) +} + +// AdminGetPublishedDSRTemplates returns all published templates for selection +func (h *DSRHandler) AdminGetPublishedDSRTemplates(c *gin.Context) { + if !middleware.IsAdmin(c) && !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin or DSB access required"}) + return + } + + requestType := c.Query("request_type") + language := c.DefaultQuery("language", "de") + + ctx := c.Request.Context() + query := ` + SELECT t.id, t.template_type, t.name, t.description, + v.id as version_id, v.version, v.subject, v.body_html, v.body_text + FROM dsr_templates t + JOIN dsr_template_versions v ON t.id = v.template_id + WHERE t.is_active = TRUE AND v.status = 'published' AND v.language = $1 + ` + args := []interface{}{language} + + if requestType != "" { + query += ` AND t.request_types @> $2::jsonb` + args = append(args, `["`+requestType+`"]`) + } + + query += " ORDER BY t.sort_order, t.name" + + rows, err := h.dsrService.GetPool().Query(ctx, query, args...) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch templates"}) + return + } + defer rows.Close() + + var templates []map[string]interface{} + for rows.Next() { + var templateID, versionID uuid.UUID + var templateType, name, version, subject, bodyHTML, bodyText string + var description *string + + err := rows.Scan(&templateID, &templateType, &name, &description, &versionID, &version, &subject, &bodyHTML, &bodyText) + if err != nil { + continue + } + + templates = append(templates, map[string]interface{}{ + "template_id": templateID, + "template_type": templateType, + "name": name, + "description": description, + "version_id": versionID, + "version": version, + "subject": subject, + "body_html": bodyHTML, + "body_text": bodyText, + }) + } + + c.JSON(http.StatusOK, gin.H{"templates": templates}) +} + +// ======================================== +// DEADLINE PROCESSING +// ======================================== + +// ProcessDeadlines triggers deadline checking (called by scheduler) +func (h *DSRHandler) ProcessDeadlines(c *gin.Context) { + err := h.dsrService.ProcessDeadlines(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process deadlines"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Deadline processing completed"}) +} diff --git a/consent-service/internal/handlers/dsr_handlers_test.go b/consent-service/internal/handlers/dsr_handlers_test.go new file mode 100644 index 0000000..0be33bf --- /dev/null +++ b/consent-service/internal/handlers/dsr_handlers_test.go @@ -0,0 +1,448 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/breakpilot/consent-service/internal/models" + "github.com/gin-gonic/gin" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +// TestCreateDSR_InvalidBody tests create DSR with invalid body +func TestCreateDSR_InvalidBody_Returns400(t *testing.T) { + router := gin.New() + + // Mock handler that mimics the actual behavior for invalid body + router.POST("/api/v1/dsr", func(c *gin.Context) { + var req models.CreateDSRRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) + return + } + }) + + // Invalid JSON + req, _ := http.NewRequest("POST", "/api/v1/dsr", bytes.NewBufferString("{invalid json")) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", w.Code) + } +} + +// TestCreateDSR_MissingType tests create DSR with missing type +func TestCreateDSR_MissingType_Returns400(t *testing.T) { + router := gin.New() + + router.POST("/api/v1/dsr", func(c *gin.Context) { + var req models.CreateDSRRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + if req.RequestType == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "request_type is required"}) + return + } + }) + + body := `{"requester_email": "test@example.com"}` + req, _ := http.NewRequest("POST", "/api/v1/dsr", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", w.Code) + } +} + +// TestCreateDSR_InvalidType tests create DSR with invalid type +func TestCreateDSR_InvalidType_Returns400(t *testing.T) { + router := gin.New() + + router.POST("/api/v1/dsr", func(c *gin.Context) { + var req models.CreateDSRRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + if !models.IsValidDSRRequestType(req.RequestType) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request_type"}) + return + } + }) + + body := `{"request_type": "invalid_type", "requester_email": "test@example.com"}` + req, _ := http.NewRequest("POST", "/api/v1/dsr", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", w.Code) + } +} + +// TestAdminListDSR_Unauthorized_Returns401 tests admin list without auth +func TestAdminListDSR_Unauthorized_Returns401(t *testing.T) { + router := gin.New() + + // Simplified auth check + router.GET("/api/v1/admin/dsr", func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization required"}) + return + } + c.JSON(http.StatusOK, gin.H{"requests": []interface{}{}}) + }) + + req, _ := http.NewRequest("GET", "/api/v1/admin/dsr", nil) + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status 401, got %d", w.Code) + } +} + +// TestAdminListDSR_ValidRequest tests admin list with valid auth +func TestAdminListDSR_ValidRequest_Returns200(t *testing.T) { + router := gin.New() + + router.GET("/api/v1/admin/dsr", func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization required"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "requests": []interface{}{}, + "total": 0, + "limit": 20, + "offset": 0, + }) + }) + + req, _ := http.NewRequest("GET", "/api/v1/admin/dsr", nil) + req.Header.Set("Authorization", "Bearer test-token") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &response) + + if _, ok := response["requests"]; !ok { + t.Error("Response should contain 'requests' field") + } + if _, ok := response["total"]; !ok { + t.Error("Response should contain 'total' field") + } +} + +// TestAdminGetDSRStats_ValidRequest tests admin stats endpoint +func TestAdminGetDSRStats_ValidRequest_Returns200(t *testing.T) { + router := gin.New() + + router.GET("/api/v1/admin/dsr/stats", func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization required"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "total_requests": 0, + "pending_requests": 0, + "overdue_requests": 0, + "completed_this_month": 0, + "average_processing_days": 0, + "by_type": map[string]int{}, + "by_status": map[string]int{}, + }) + }) + + req, _ := http.NewRequest("GET", "/api/v1/admin/dsr/stats", nil) + req.Header.Set("Authorization", "Bearer test-token") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &response) + + expectedFields := []string{"total_requests", "pending_requests", "overdue_requests", "by_type", "by_status"} + for _, field := range expectedFields { + if _, ok := response[field]; !ok { + t.Errorf("Response should contain '%s' field", field) + } + } +} + +// TestAdminUpdateDSR_InvalidStatus_Returns400 tests admin update with invalid status +func TestAdminUpdateDSR_InvalidStatus_Returns400(t *testing.T) { + router := gin.New() + + router.PUT("/api/v1/admin/dsr/:id", func(c *gin.Context) { + var req models.UpdateDSRRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + if req.Status != nil && !models.IsValidDSRStatus(*req.Status) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid status"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Updated"}) + }) + + body := `{"status": "invalid_status"}` + req, _ := http.NewRequest("PUT", "/api/v1/admin/dsr/123", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer test-token") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", w.Code) + } +} + +// TestAdminVerifyIdentity_ValidRequest_Returns200 tests identity verification +func TestAdminVerifyIdentity_ValidRequest_Returns200(t *testing.T) { + router := gin.New() + + router.POST("/api/v1/admin/dsr/:id/verify-identity", func(c *gin.Context) { + var req models.VerifyDSRIdentityRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + if req.Method == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "method is required"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Identität verifiziert"}) + }) + + body := `{"method": "id_card"}` + req, _ := http.NewRequest("POST", "/api/v1/admin/dsr/123/verify-identity", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer test-token") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } +} + +// TestAdminExtendDeadline_MissingReason_Returns400 tests extend deadline without reason +func TestAdminExtendDeadline_MissingReason_Returns400(t *testing.T) { + router := gin.New() + + router.POST("/api/v1/admin/dsr/:id/extend", func(c *gin.Context) { + var req models.ExtendDSRDeadlineRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + if req.Reason == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "reason is required"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Deadline extended"}) + }) + + body := `{"days": 30}` + req, _ := http.NewRequest("POST", "/api/v1/admin/dsr/123/extend", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer test-token") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", w.Code) + } +} + +// TestAdminCompleteDSR_ValidRequest_Returns200 tests complete DSR +func TestAdminCompleteDSR_ValidRequest_Returns200(t *testing.T) { + router := gin.New() + + router.POST("/api/v1/admin/dsr/:id/complete", func(c *gin.Context) { + var req models.CompleteDSRRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Anfrage erfolgreich abgeschlossen"}) + }) + + body := `{"result_summary": "Alle Daten wurden bereitgestellt"}` + req, _ := http.NewRequest("POST", "/api/v1/admin/dsr/123/complete", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer test-token") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } +} + +// TestAdminRejectDSR_MissingLegalBasis_Returns400 tests reject DSR without legal basis +func TestAdminRejectDSR_MissingLegalBasis_Returns400(t *testing.T) { + router := gin.New() + + router.POST("/api/v1/admin/dsr/:id/reject", func(c *gin.Context) { + var req models.RejectDSRRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + if req.LegalBasis == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "legal_basis is required"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Rejected"}) + }) + + body := `{"reason": "Some reason"}` + req, _ := http.NewRequest("POST", "/api/v1/admin/dsr/123/reject", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer test-token") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", w.Code) + } +} + +// TestAdminRejectDSR_ValidRequest_Returns200 tests reject DSR with valid data +func TestAdminRejectDSR_ValidRequest_Returns200(t *testing.T) { + router := gin.New() + + router.POST("/api/v1/admin/dsr/:id/reject", func(c *gin.Context) { + var req models.RejectDSRRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + if req.LegalBasis == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "legal_basis is required"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Anfrage abgelehnt"}) + }) + + body := `{"reason": "Daten benötigt für Rechtsstreit", "legal_basis": "Art. 17(3)e"}` + req, _ := http.NewRequest("POST", "/api/v1/admin/dsr/123/reject", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer test-token") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } +} + +// TestGetDSRTemplates_Returns200 tests templates endpoint +func TestGetDSRTemplates_Returns200(t *testing.T) { + router := gin.New() + + router.GET("/api/v1/admin/dsr-templates", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "templates": []map[string]interface{}{ + { + "id": "uuid-1", + "template_type": "dsr_receipt_access", + "name": "Eingangsbestätigung (Art. 15)", + }, + }, + }) + }) + + req, _ := http.NewRequest("GET", "/api/v1/admin/dsr-templates", nil) + req.Header.Set("Authorization", "Bearer test-token") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &response) + + if _, ok := response["templates"]; !ok { + t.Error("Response should contain 'templates' field") + } +} + +// TestRequestTypeValidation tests all valid request types +func TestRequestTypeValidation(t *testing.T) { + validTypes := []string{"access", "rectification", "erasure", "restriction", "portability"} + + for _, reqType := range validTypes { + if !models.IsValidDSRRequestType(reqType) { + t.Errorf("Expected %s to be a valid request type", reqType) + } + } + + invalidTypes := []string{"invalid", "delete", "copy", ""} + for _, reqType := range invalidTypes { + if models.IsValidDSRRequestType(reqType) { + t.Errorf("Expected %s to be an invalid request type", reqType) + } + } +} + +// TestStatusValidation tests all valid statuses +func TestStatusValidation(t *testing.T) { + validStatuses := []string{"intake", "identity_verification", "processing", "completed", "rejected", "cancelled"} + + for _, status := range validStatuses { + if !models.IsValidDSRStatus(status) { + t.Errorf("Expected %s to be a valid status", status) + } + } + + invalidStatuses := []string{"invalid", "pending", "done", ""} + for _, status := range invalidStatuses { + if models.IsValidDSRStatus(status) { + t.Errorf("Expected %s to be an invalid status", status) + } + } +} diff --git a/consent-service/internal/handlers/email_template_handlers.go b/consent-service/internal/handlers/email_template_handlers.go new file mode 100644 index 0000000..d2b6a86 --- /dev/null +++ b/consent-service/internal/handlers/email_template_handlers.go @@ -0,0 +1,528 @@ +package handlers + +import ( + "net/http" + "strconv" + "time" + + "github.com/breakpilot/consent-service/internal/models" + "github.com/breakpilot/consent-service/internal/services" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// EmailTemplateHandler handles email template operations +type EmailTemplateHandler struct { + service *services.EmailTemplateService +} + +// NewEmailTemplateHandler creates a new email template handler +func NewEmailTemplateHandler(service *services.EmailTemplateService) *EmailTemplateHandler { + return &EmailTemplateHandler{service: service} +} + +// GetAllTemplateTypes returns all available email template types with their variables +// GET /api/v1/admin/email-templates/types +func (h *EmailTemplateHandler) GetAllTemplateTypes(c *gin.Context) { + types := h.service.GetAllTemplateTypes() + c.JSON(http.StatusOK, gin.H{"types": types}) +} + +// GetAllTemplates returns all email templates with their latest published versions +// GET /api/v1/admin/email-templates +func (h *EmailTemplateHandler) GetAllTemplates(c *gin.Context) { + templates, err := h.service.GetAllTemplates(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"templates": templates}) +} + +// GetTemplate returns a single template by ID +// GET /api/v1/admin/email-templates/:id +func (h *EmailTemplateHandler) GetTemplate(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid template ID"}) + return + } + + template, err := h.service.GetTemplateByID(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "template not found"}) + return + } + c.JSON(http.StatusOK, template) +} + +// CreateTemplate creates a new email template type +// POST /api/v1/admin/email-templates +func (h *EmailTemplateHandler) CreateTemplate(c *gin.Context) { + var req models.CreateEmailTemplateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + template, err := h.service.CreateEmailTemplate(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, template) +} + +// GetTemplateVersions returns all versions for a template +// GET /api/v1/admin/email-templates/:id/versions +func (h *EmailTemplateHandler) GetTemplateVersions(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid template ID"}) + return + } + + versions, err := h.service.GetVersionsByTemplateID(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"versions": versions}) +} + +// GetVersion returns a single version by ID +// GET /api/v1/admin/email-template-versions/:id +func (h *EmailTemplateHandler) GetVersion(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"}) + return + } + + version, err := h.service.GetVersionByID(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "version not found"}) + return + } + c.JSON(http.StatusOK, version) +} + +// CreateVersion creates a new version of an email template +// POST /api/v1/admin/email-template-versions +func (h *EmailTemplateHandler) CreateVersion(c *gin.Context) { + var req models.CreateEmailTemplateVersionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Get user ID from context + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + return + } + uid, _ := uuid.Parse(userID.(string)) + + version, err := h.service.CreateTemplateVersion(c.Request.Context(), &req, uid) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, version) +} + +// UpdateVersion updates a version +// PUT /api/v1/admin/email-template-versions/:id +func (h *EmailTemplateHandler) UpdateVersion(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"}) + return + } + + var req models.UpdateEmailTemplateVersionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + if err := h.service.UpdateVersion(c.Request.Context(), id, &req); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "version updated"}) +} + +// SubmitForReview submits a version for review +// POST /api/v1/admin/email-template-versions/:id/submit +func (h *EmailTemplateHandler) SubmitForReview(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"}) + return + } + + var req struct { + Comment *string `json:"comment"` + } + c.ShouldBindJSON(&req) + + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + return + } + uid, _ := uuid.Parse(userID.(string)) + + if err := h.service.SubmitForReview(c.Request.Context(), id, uid, req.Comment); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "version submitted for review"}) +} + +// ApproveVersion approves a version (DSB only) +// POST /api/v1/admin/email-template-versions/:id/approve +func (h *EmailTemplateHandler) ApproveVersion(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"}) + return + } + + // Check role + role, exists := c.Get("user_role") + if !exists || (role != "data_protection_officer" && role != "admin" && role != "super_admin") { + c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"}) + return + } + + var req struct { + Comment *string `json:"comment"` + ScheduledPublishAt *string `json:"scheduled_publish_at"` + } + c.ShouldBindJSON(&req) + + userID, _ := c.Get("user_id") + uid, _ := uuid.Parse(userID.(string)) + + var scheduledAt *time.Time + if req.ScheduledPublishAt != nil { + t, err := time.Parse(time.RFC3339, *req.ScheduledPublishAt) + if err == nil { + scheduledAt = &t + } + } + + if err := h.service.ApproveVersion(c.Request.Context(), id, uid, req.Comment, scheduledAt); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "version approved"}) +} + +// RejectVersion rejects a version +// POST /api/v1/admin/email-template-versions/:id/reject +func (h *EmailTemplateHandler) RejectVersion(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"}) + return + } + + role, exists := c.Get("user_role") + if !exists || (role != "data_protection_officer" && role != "admin" && role != "super_admin") { + c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"}) + return + } + + var req struct { + Comment string `json:"comment" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "comment is required"}) + return + } + + userID, _ := c.Get("user_id") + uid, _ := uuid.Parse(userID.(string)) + + if err := h.service.RejectVersion(c.Request.Context(), id, uid, req.Comment); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "version rejected"}) +} + +// PublishVersion publishes an approved version +// POST /api/v1/admin/email-template-versions/:id/publish +func (h *EmailTemplateHandler) PublishVersion(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"}) + return + } + + role, exists := c.Get("user_role") + if !exists || (role != "data_protection_officer" && role != "admin" && role != "super_admin") { + c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"}) + return + } + + userID, _ := c.Get("user_id") + uid, _ := uuid.Parse(userID.(string)) + + if err := h.service.PublishVersion(c.Request.Context(), id, uid); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "version published"}) +} + +// GetApprovals returns approval history for a version +// GET /api/v1/admin/email-template-versions/:id/approvals +func (h *EmailTemplateHandler) GetApprovals(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"}) + return + } + + approvals, err := h.service.GetApprovals(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"approvals": approvals}) +} + +// PreviewVersion renders a preview of an email template version +// POST /api/v1/admin/email-template-versions/:id/preview +func (h *EmailTemplateHandler) PreviewVersion(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"}) + return + } + + var req struct { + Variables map[string]string `json:"variables"` + } + c.ShouldBindJSON(&req) + + version, err := h.service.GetVersionByID(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "version not found"}) + return + } + + // Use default test values if not provided + if req.Variables == nil { + req.Variables = map[string]string{ + "user_name": "Max Mustermann", + "user_email": "max@example.com", + "login_url": "https://breakpilot.app/login", + "support_email": "support@breakpilot.app", + "verification_url": "https://breakpilot.app/verify?token=abc123", + "verification_code": "123456", + "expires_in": "24 Stunden", + "reset_url": "https://breakpilot.app/reset?token=xyz789", + "reset_code": "RESET123", + "ip_address": "192.168.1.1", + "device_info": "Chrome auf Windows 11", + "changed_at": time.Now().Format("02.01.2006 15:04"), + "enabled_at": time.Now().Format("02.01.2006 15:04"), + "disabled_at": time.Now().Format("02.01.2006 15:04"), + "support_url": "https://breakpilot.app/support", + "security_url": "https://breakpilot.app/account/security", + "login_time": time.Now().Format("02.01.2006 15:04"), + "location": "Berlin, Deutschland", + "activity_type": "Mehrere fehlgeschlagene Login-Versuche", + "activity_time": time.Now().Format("02.01.2006 15:04"), + "locked_at": time.Now().Format("02.01.2006 15:04"), + "reason": "Zu viele fehlgeschlagene Login-Versuche", + "unlock_time": time.Now().Add(30 * time.Minute).Format("02.01.2006 15:04"), + "unlocked_at": time.Now().Format("02.01.2006 15:04"), + "requested_at": time.Now().Format("02.01.2006"), + "deletion_date": time.Now().AddDate(0, 0, 30).Format("02.01.2006"), + "cancel_url": "https://breakpilot.app/cancel-deletion?token=cancel123", + "data_info": "Benutzerdaten, Zustimmungshistorie, Audit-Logs", + "deleted_at": time.Now().Format("02.01.2006"), + "feedback_url": "https://breakpilot.app/feedback", + "download_url": "https://breakpilot.app/export/download?token=export123", + "file_size": "2.3 MB", + "old_email": "alt@example.com", + "new_email": "neu@example.com", + "document_name": "Datenschutzerklärung", + "document_type": "privacy", + "version": "2.0.0", + "consent_url": "https://breakpilot.app/consent", + "deadline": time.Now().AddDate(0, 0, 14).Format("02.01.2006"), + "days_left": "7", + "hours_left": "24 Stunden", + "consequences": "Ohne Ihre Zustimmung wird Ihr Konto suspendiert.", + "suspended_at": time.Now().Format("02.01.2006 15:04"), + "documents": "- Datenschutzerklärung v2.0.0\n- AGB v1.5.0", + } + } + + preview, err := h.service.RenderTemplate(version, req.Variables) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, preview) +} + +// SendTestEmail sends a test email +// POST /api/v1/admin/email-template-versions/:id/send-test +func (h *EmailTemplateHandler) SendTestEmail(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid version ID"}) + return + } + + var req models.SendTestEmailRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + req.VersionID = idStr + + version, err := h.service.GetVersionByID(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "version not found"}) + return + } + + // Get template to find type + template, err := h.service.GetTemplateByID(c.Request.Context(), version.TemplateID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "template not found"}) + return + } + + userID, _ := c.Get("user_id") + uid, _ := uuid.Parse(userID.(string)) + + // Send test email + if err := h.service.SendEmail(c.Request.Context(), template.Type, version.Language, req.Recipient, req.Variables, &uid); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "test email sent"}) +} + +// GetSettings returns global email settings +// GET /api/v1/admin/email-templates/settings +func (h *EmailTemplateHandler) GetSettings(c *gin.Context) { + settings, err := h.service.GetSettings(c.Request.Context()) + if err != nil { + // Return default settings if none exist + c.JSON(http.StatusOK, gin.H{ + "company_name": "BreakPilot", + "sender_name": "BreakPilot", + "sender_email": "noreply@breakpilot.app", + "primary_color": "#2563eb", + "secondary_color": "#64748b", + }) + return + } + c.JSON(http.StatusOK, settings) +} + +// UpdateSettings updates global email settings +// PUT /api/v1/admin/email-templates/settings +func (h *EmailTemplateHandler) UpdateSettings(c *gin.Context) { + var req models.UpdateEmailTemplateSettingsRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + userID, _ := c.Get("user_id") + uid, _ := uuid.Parse(userID.(string)) + + if err := h.service.UpdateSettings(c.Request.Context(), &req, uid); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "settings updated"}) +} + +// GetEmailStats returns email statistics +// GET /api/v1/admin/email-templates/stats +func (h *EmailTemplateHandler) GetEmailStats(c *gin.Context) { + stats, err := h.service.GetEmailStats(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, stats) +} + +// GetSendLogs returns email send logs +// GET /api/v1/admin/email-templates/logs +func (h *EmailTemplateHandler) GetSendLogs(c *gin.Context) { + limitStr := c.DefaultQuery("limit", "50") + offsetStr := c.DefaultQuery("offset", "0") + + limit, _ := strconv.Atoi(limitStr) + offset, _ := strconv.Atoi(offsetStr) + + if limit > 100 { + limit = 100 + } + + logs, total, err := h.service.GetSendLogs(c.Request.Context(), limit, offset) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"logs": logs, "total": total}) +} + +// GetDefaultContent returns default template content for a type +// GET /api/v1/admin/email-templates/default/:type +func (h *EmailTemplateHandler) GetDefaultContent(c *gin.Context) { + templateType := c.Param("type") + language := c.DefaultQuery("language", "de") + + subject, bodyHTML, bodyText := h.service.GetDefaultTemplateContent(templateType, language) + + c.JSON(http.StatusOK, gin.H{ + "subject": subject, + "body_html": bodyHTML, + "body_text": bodyText, + }) +} + +// InitializeTemplates initializes default email templates +// POST /api/v1/admin/email-templates/initialize +func (h *EmailTemplateHandler) InitializeTemplates(c *gin.Context) { + role, exists := c.Get("user_role") + if !exists || (role != "admin" && role != "super_admin") { + c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"}) + return + } + + if err := h.service.InitDefaultTemplates(c.Request.Context()); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "default templates initialized"}) +} diff --git a/consent-service/internal/handlers/handlers.go b/consent-service/internal/handlers/handlers.go new file mode 100644 index 0000000..c57d80a --- /dev/null +++ b/consent-service/internal/handlers/handlers.go @@ -0,0 +1,1783 @@ +package handlers + +import ( + "context" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/breakpilot/consent-service/internal/database" + "github.com/breakpilot/consent-service/internal/middleware" + "github.com/breakpilot/consent-service/internal/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// Handler holds all HTTP handlers +type Handler struct { + db *database.DB +} + +// New creates a new Handler instance +func New(db *database.DB) *Handler { + return &Handler{db: db} +} + +// ======================================== +// PUBLIC ENDPOINTS - Documents +// ======================================== + +// GetDocuments returns all active legal documents +func (h *Handler) GetDocuments(c *gin.Context) { + ctx := context.Background() + + rows, err := h.db.Pool.Query(ctx, ` + SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at + FROM legal_documents + WHERE is_active = true + ORDER BY sort_order ASC + `) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch documents"}) + return + } + defer rows.Close() + + var documents []models.LegalDocument + for rows.Next() { + var doc models.LegalDocument + if err := rows.Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description, + &doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt); err != nil { + continue + } + documents = append(documents, doc) + } + + c.JSON(http.StatusOK, gin.H{"documents": documents}) +} + +// GetDocumentByType returns a document by its type +func (h *Handler) GetDocumentByType(c *gin.Context) { + docType := c.Param("type") + ctx := context.Background() + + var doc models.LegalDocument + err := h.db.Pool.QueryRow(ctx, ` + SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at + FROM legal_documents + WHERE type = $1 AND is_active = true + `, docType).Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description, + &doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt) + + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"}) + return + } + + c.JSON(http.StatusOK, doc) +} + +// GetLatestDocumentVersion returns the latest published version of a document +func (h *Handler) GetLatestDocumentVersion(c *gin.Context) { + docType := c.Param("type") + language := c.DefaultQuery("language", "de") + ctx := context.Background() + + var version models.DocumentVersion + err := h.db.Pool.QueryRow(ctx, ` + SELECT dv.id, dv.document_id, dv.version, dv.language, dv.title, dv.content, + dv.summary, dv.status, dv.published_at, dv.created_at, dv.updated_at + FROM document_versions dv + JOIN legal_documents ld ON dv.document_id = ld.id + WHERE ld.type = $1 AND dv.language = $2 AND dv.status = 'published' + ORDER BY dv.published_at DESC + LIMIT 1 + `, docType, language).Scan(&version.ID, &version.DocumentID, &version.Version, &version.Language, + &version.Title, &version.Content, &version.Summary, &version.Status, + &version.PublishedAt, &version.CreatedAt, &version.UpdatedAt) + + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "No published version found"}) + return + } + + c.JSON(http.StatusOK, version) +} + +// ======================================== +// PUBLIC ENDPOINTS - Consent +// ======================================== + +// CreateConsent creates a new user consent +func (h *Handler) CreateConsent(c *gin.Context) { + var req models.CreateConsentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + versionID, err := uuid.Parse(req.VersionID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) + return + } + + ctx := context.Background() + ipAddress := middleware.GetClientIP(c) + userAgent := middleware.GetUserAgent(c) + + // Upsert consent + var consentID uuid.UUID + err = h.db.Pool.QueryRow(ctx, ` + INSERT INTO user_consents (user_id, document_version_id, consented, ip_address, user_agent) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_id, document_version_id) + DO UPDATE SET consented = $3, consented_at = NOW(), withdrawn_at = NULL + RETURNING id + `, userID, versionID, req.Consented, ipAddress, userAgent).Scan(&consentID) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save consent"}) + return + } + + // Log to audit trail + h.logAudit(ctx, &userID, "consent_given", "document_version", &versionID, nil, ipAddress, userAgent) + + c.JSON(http.StatusCreated, gin.H{ + "message": "Consent saved successfully", + "consent_id": consentID, + }) +} + +// GetMyConsents returns all consents for the current user +func (h *Handler) GetMyConsents(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + ctx := context.Background() + + rows, err := h.db.Pool.Query(ctx, ` + SELECT uc.id, uc.consented, uc.consented_at, uc.withdrawn_at, + ld.id, ld.type, ld.name, ld.is_mandatory, + dv.id, dv.version, dv.language, dv.title + FROM user_consents uc + JOIN document_versions dv ON uc.document_version_id = dv.id + JOIN legal_documents ld ON dv.document_id = ld.id + WHERE uc.user_id = $1 + ORDER BY uc.consented_at DESC + `, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch consents"}) + return + } + defer rows.Close() + + var consents []map[string]interface{} + for rows.Next() { + var ( + consentID uuid.UUID + consented bool + consentedAt time.Time + withdrawnAt *time.Time + docID uuid.UUID + docType string + docName string + isMandatory bool + versionID uuid.UUID + version string + language string + title string + ) + + if err := rows.Scan(&consentID, &consented, &consentedAt, &withdrawnAt, + &docID, &docType, &docName, &isMandatory, + &versionID, &version, &language, &title); err != nil { + continue + } + + consents = append(consents, map[string]interface{}{ + "consent_id": consentID, + "consented": consented, + "consented_at": consentedAt, + "withdrawn_at": withdrawnAt, + "document": map[string]interface{}{ + "id": docID, + "type": docType, + "name": docName, + "is_mandatory": isMandatory, + }, + "version": map[string]interface{}{ + "id": versionID, + "version": version, + "language": language, + "title": title, + }, + }) + } + + c.JSON(http.StatusOK, gin.H{"consents": consents}) +} + +// CheckConsent checks if the user has consented to a document +func (h *Handler) CheckConsent(c *gin.Context) { + docType := c.Param("documentType") + language := c.DefaultQuery("language", "de") + + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + ctx := context.Background() + + // Get latest published version + var latestVersionID uuid.UUID + var latestVersion string + err = h.db.Pool.QueryRow(ctx, ` + SELECT dv.id, dv.version + FROM document_versions dv + JOIN legal_documents ld ON dv.document_id = ld.id + WHERE ld.type = $1 AND dv.language = $2 AND dv.status = 'published' + ORDER BY dv.published_at DESC + LIMIT 1 + `, docType, language).Scan(&latestVersionID, &latestVersion) + + if err != nil { + c.JSON(http.StatusOK, models.ConsentCheckResponse{ + HasConsent: false, + NeedsUpdate: false, + }) + return + } + + // Check if user has consented to this version + var consentedVersionID uuid.UUID + var consentedVersion string + var consentedAt time.Time + err = h.db.Pool.QueryRow(ctx, ` + SELECT dv.id, dv.version, uc.consented_at + FROM user_consents uc + JOIN document_versions dv ON uc.document_version_id = dv.id + JOIN legal_documents ld ON dv.document_id = ld.id + WHERE uc.user_id = $1 AND ld.type = $2 AND uc.consented = true AND uc.withdrawn_at IS NULL + ORDER BY uc.consented_at DESC + LIMIT 1 + `, userID, docType).Scan(&consentedVersionID, &consentedVersion, &consentedAt) + + if err != nil { + // No consent found + latestIDStr := latestVersionID.String() + c.JSON(http.StatusOK, models.ConsentCheckResponse{ + HasConsent: false, + CurrentVersionID: &latestIDStr, + NeedsUpdate: true, + }) + return + } + + // Check if consent is for latest version + needsUpdate := consentedVersionID != latestVersionID + latestIDStr := latestVersionID.String() + consentedVerStr := consentedVersion + + c.JSON(http.StatusOK, models.ConsentCheckResponse{ + HasConsent: true, + CurrentVersionID: &latestIDStr, + ConsentedVersion: &consentedVerStr, + NeedsUpdate: needsUpdate, + ConsentedAt: &consentedAt, + }) +} + +// WithdrawConsent withdraws a consent +func (h *Handler) WithdrawConsent(c *gin.Context) { + consentID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid consent ID"}) + return + } + + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + ctx := context.Background() + ipAddress := middleware.GetClientIP(c) + userAgent := middleware.GetUserAgent(c) + + // Update consent + result, err := h.db.Pool.Exec(ctx, ` + UPDATE user_consents + SET withdrawn_at = NOW(), consented = false + WHERE id = $1 AND user_id = $2 + `, consentID, userID) + + if err != nil || result.RowsAffected() == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Consent not found"}) + return + } + + // Log to audit trail + h.logAudit(ctx, &userID, "consent_withdrawn", "consent", &consentID, nil, ipAddress, userAgent) + + c.JSON(http.StatusOK, gin.H{"message": "Consent withdrawn successfully"}) +} + +// ======================================== +// PUBLIC ENDPOINTS - Cookie Consent +// ======================================== + +// GetCookieCategories returns all active cookie categories +func (h *Handler) GetCookieCategories(c *gin.Context) { + language := c.DefaultQuery("language", "de") + ctx := context.Background() + + rows, err := h.db.Pool.Query(ctx, ` + SELECT id, name, display_name_de, display_name_en, description_de, description_en, + is_mandatory, sort_order + FROM cookie_categories + WHERE is_active = true + ORDER BY sort_order ASC + `) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch categories"}) + return + } + defer rows.Close() + + var categories []map[string]interface{} + for rows.Next() { + var cat models.CookieCategory + if err := rows.Scan(&cat.ID, &cat.Name, &cat.DisplayNameDE, &cat.DisplayNameEN, + &cat.DescriptionDE, &cat.DescriptionEN, &cat.IsMandatory, &cat.SortOrder); err != nil { + continue + } + + // Return localized data + displayName := cat.DisplayNameDE + description := cat.DescriptionDE + if language == "en" && cat.DisplayNameEN != nil { + displayName = *cat.DisplayNameEN + if cat.DescriptionEN != nil { + description = cat.DescriptionEN + } + } + + categories = append(categories, map[string]interface{}{ + "id": cat.ID, + "name": cat.Name, + "display_name": displayName, + "description": description, + "is_mandatory": cat.IsMandatory, + }) + } + + c.JSON(http.StatusOK, gin.H{"categories": categories}) +} + +// SetCookieConsent sets cookie preferences for a user +func (h *Handler) SetCookieConsent(c *gin.Context) { + var req models.CookieConsentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + ctx := context.Background() + ipAddress := middleware.GetClientIP(c) + userAgent := middleware.GetUserAgent(c) + + // Process each category + for _, cat := range req.Categories { + categoryID, err := uuid.Parse(cat.CategoryID) + if err != nil { + continue + } + + _, err = h.db.Pool.Exec(ctx, ` + INSERT INTO cookie_consents (user_id, category_id, consented) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, category_id) + DO UPDATE SET consented = $3, updated_at = NOW() + `, userID, categoryID, cat.Consented) + + if err != nil { + continue + } + } + + // Log to audit trail + h.logAudit(ctx, &userID, "cookie_consent_updated", "cookie", nil, nil, ipAddress, userAgent) + + c.JSON(http.StatusOK, gin.H{"message": "Cookie preferences saved"}) +} + +// GetMyCookieConsent returns cookie preferences for the current user +func (h *Handler) GetMyCookieConsent(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + ctx := context.Background() + + rows, err := h.db.Pool.Query(ctx, ` + SELECT cc.category_id, cc.consented, cc.updated_at, + cat.name, cat.display_name_de, cat.is_mandatory + FROM cookie_consents cc + JOIN cookie_categories cat ON cc.category_id = cat.id + WHERE cc.user_id = $1 + `, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch preferences"}) + return + } + defer rows.Close() + + var consents []map[string]interface{} + for rows.Next() { + var ( + categoryID uuid.UUID + consented bool + updatedAt time.Time + name string + displayName string + isMandatory bool + ) + + if err := rows.Scan(&categoryID, &consented, &updatedAt, &name, &displayName, &isMandatory); err != nil { + continue + } + + consents = append(consents, map[string]interface{}{ + "category_id": categoryID, + "name": name, + "display_name": displayName, + "consented": consented, + "is_mandatory": isMandatory, + "updated_at": updatedAt, + }) + } + + c.JSON(http.StatusOK, gin.H{"cookie_consents": consents}) +} + +// ======================================== +// GDPR / DATA SUBJECT RIGHTS +// ======================================== + +// GetMyData returns all data we have about the user +func (h *Handler) GetMyData(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + ctx := context.Background() + ipAddress := middleware.GetClientIP(c) + userAgent := middleware.GetUserAgent(c) + + // Get user info + var user models.User + err = h.db.Pool.QueryRow(ctx, ` + SELECT id, external_id, email, role, created_at, updated_at + FROM users WHERE id = $1 + `, userID).Scan(&user.ID, &user.ExternalID, &user.Email, &user.Role, &user.CreatedAt, &user.UpdatedAt) + + // Get consents + consentRows, _ := h.db.Pool.Query(ctx, ` + SELECT uc.consented, uc.consented_at, ld.type, ld.name, dv.version + FROM user_consents uc + JOIN document_versions dv ON uc.document_version_id = dv.id + JOIN legal_documents ld ON dv.document_id = ld.id + WHERE uc.user_id = $1 + `, userID) + defer consentRows.Close() + + var consents []map[string]interface{} + for consentRows.Next() { + var consented bool + var consentedAt time.Time + var docType, docName, version string + consentRows.Scan(&consented, &consentedAt, &docType, &docName, &version) + consents = append(consents, map[string]interface{}{ + "document_type": docType, + "document_name": docName, + "version": version, + "consented": consented, + "consented_at": consentedAt, + }) + } + + // Get cookie consents + cookieRows, _ := h.db.Pool.Query(ctx, ` + SELECT cat.name, cc.consented, cc.updated_at + FROM cookie_consents cc + JOIN cookie_categories cat ON cc.category_id = cat.id + WHERE cc.user_id = $1 + `, userID) + defer cookieRows.Close() + + var cookieConsents []map[string]interface{} + for cookieRows.Next() { + var name string + var consented bool + var updatedAt time.Time + cookieRows.Scan(&name, &consented, &updatedAt) + cookieConsents = append(cookieConsents, map[string]interface{}{ + "category": name, + "consented": consented, + "updated_at": updatedAt, + }) + } + + // Log data access + h.logAudit(ctx, &userID, "data_access", "user", &userID, nil, ipAddress, userAgent) + + c.JSON(http.StatusOK, gin.H{ + "user": map[string]interface{}{ + "id": user.ID, + "email": user.Email, + "created_at": user.CreatedAt, + }, + "consents": consents, + "cookie_consents": cookieConsents, + "exported_at": time.Now(), + }) +} + +// RequestDataExport creates a data export request +func (h *Handler) RequestDataExport(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + ctx := context.Background() + ipAddress := middleware.GetClientIP(c) + userAgent := middleware.GetUserAgent(c) + + var requestID uuid.UUID + err = h.db.Pool.QueryRow(ctx, ` + INSERT INTO data_export_requests (user_id, status) + VALUES ($1, 'pending') + RETURNING id + `, userID).Scan(&requestID) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create export request"}) + return + } + + // Log to audit trail + h.logAudit(ctx, &userID, "data_export_requested", "export_request", &requestID, nil, ipAddress, userAgent) + + c.JSON(http.StatusAccepted, gin.H{ + "message": "Export request created. You will be notified when ready.", + "request_id": requestID, + }) +} + +// RequestDataDeletion creates a data deletion request +func (h *Handler) RequestDataDeletion(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + var req struct { + Reason string `json:"reason"` + } + c.ShouldBindJSON(&req) + + ctx := context.Background() + ipAddress := middleware.GetClientIP(c) + userAgent := middleware.GetUserAgent(c) + + var requestID uuid.UUID + err = h.db.Pool.QueryRow(ctx, ` + INSERT INTO data_deletion_requests (user_id, status, reason) + VALUES ($1, 'pending', $2) + RETURNING id + `, userID, req.Reason).Scan(&requestID) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create deletion request"}) + return + } + + // Log to audit trail + h.logAudit(ctx, &userID, "data_deletion_requested", "deletion_request", &requestID, nil, ipAddress, userAgent) + + c.JSON(http.StatusAccepted, gin.H{ + "message": "Deletion request created. We will process your request within 30 days.", + "request_id": requestID, + }) +} + +// ======================================== +// ADMIN ENDPOINTS - Document Management +// ======================================== + +// AdminGetDocuments returns all documents (including inactive) for admin +func (h *Handler) AdminGetDocuments(c *gin.Context) { + ctx := context.Background() + + rows, err := h.db.Pool.Query(ctx, ` + SELECT id, type, name, description, is_mandatory, is_active, sort_order, created_at, updated_at + FROM legal_documents + ORDER BY sort_order ASC, created_at DESC + `) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch documents"}) + return + } + defer rows.Close() + + var documents []models.LegalDocument + for rows.Next() { + var doc models.LegalDocument + if err := rows.Scan(&doc.ID, &doc.Type, &doc.Name, &doc.Description, + &doc.IsMandatory, &doc.IsActive, &doc.SortOrder, &doc.CreatedAt, &doc.UpdatedAt); err != nil { + continue + } + documents = append(documents, doc) + } + + c.JSON(http.StatusOK, gin.H{"documents": documents}) +} + +// AdminCreateDocument creates a new legal document +func (h *Handler) AdminCreateDocument(c *gin.Context) { + var req models.CreateDocumentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + ctx := context.Background() + + var docID uuid.UUID + err := h.db.Pool.QueryRow(ctx, ` + INSERT INTO legal_documents (type, name, description, is_mandatory) + VALUES ($1, $2, $3, $4) + RETURNING id + `, req.Type, req.Name, req.Description, req.IsMandatory).Scan(&docID) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create document"}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Document created successfully", + "id": docID, + }) +} + +// AdminUpdateDocument updates a legal document +func (h *Handler) AdminUpdateDocument(c *gin.Context) { + docID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) + return + } + + var req struct { + Name *string `json:"name"` + Description *string `json:"description"` + IsMandatory *bool `json:"is_mandatory"` + IsActive *bool `json:"is_active"` + SortOrder *int `json:"sort_order"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + ctx := context.Background() + + result, err := h.db.Pool.Exec(ctx, ` + UPDATE legal_documents + SET name = COALESCE($2, name), + description = COALESCE($3, description), + is_mandatory = COALESCE($4, is_mandatory), + is_active = COALESCE($5, is_active), + sort_order = COALESCE($6, sort_order), + updated_at = NOW() + WHERE id = $1 + `, docID, req.Name, req.Description, req.IsMandatory, req.IsActive, req.SortOrder) + + if err != nil || result.RowsAffected() == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Document updated successfully"}) +} + +// AdminDeleteDocument soft-deletes a document (sets is_active to false) +func (h *Handler) AdminDeleteDocument(c *gin.Context) { + docID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) + return + } + + ctx := context.Background() + + result, err := h.db.Pool.Exec(ctx, ` + UPDATE legal_documents + SET is_active = false, updated_at = NOW() + WHERE id = $1 + `, docID) + + if err != nil || result.RowsAffected() == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Document not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Document deleted successfully"}) +} + +// ======================================== +// ADMIN ENDPOINTS - Version Management +// ======================================== + +// AdminGetVersions returns all versions for a document +func (h *Handler) AdminGetVersions(c *gin.Context) { + docID, err := uuid.Parse(c.Param("docId")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) + return + } + + ctx := context.Background() + + rows, err := h.db.Pool.Query(ctx, ` + SELECT id, document_id, version, language, title, content, summary, status, + published_at, scheduled_publish_at, created_by, approved_by, approved_at, created_at, updated_at + FROM document_versions + WHERE document_id = $1 + ORDER BY created_at DESC + `, docID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch versions"}) + return + } + defer rows.Close() + + var versions []models.DocumentVersion + for rows.Next() { + var v models.DocumentVersion + if err := rows.Scan(&v.ID, &v.DocumentID, &v.Version, &v.Language, &v.Title, &v.Content, + &v.Summary, &v.Status, &v.PublishedAt, &v.ScheduledPublishAt, &v.CreatedBy, &v.ApprovedBy, &v.ApprovedAt, &v.CreatedAt, &v.UpdatedAt); err != nil { + continue + } + versions = append(versions, v) + } + + c.JSON(http.StatusOK, gin.H{"versions": versions}) +} + +// AdminCreateVersion creates a new document version +func (h *Handler) AdminCreateVersion(c *gin.Context) { + var req models.CreateVersionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + docID, err := uuid.Parse(req.DocumentID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid document ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + ctx := context.Background() + + var versionID uuid.UUID + err = h.db.Pool.QueryRow(ctx, ` + INSERT INTO document_versions (document_id, version, language, title, content, summary, status, created_by) + VALUES ($1, $2, $3, $4, $5, $6, 'draft', $7) + RETURNING id + `, docID, req.Version, req.Language, req.Title, req.Content, req.Summary, userID).Scan(&versionID) + + if err != nil { + // Check for unique constraint violation + errStr := err.Error() + if strings.Contains(errStr, "duplicate key") || strings.Contains(errStr, "unique constraint") { + c.JSON(http.StatusConflict, gin.H{"error": "Eine Version mit dieser Versionsnummer und Sprache existiert bereits für dieses Dokument"}) + return + } + // Log the actual error for debugging + fmt.Printf("POST /api/v1/admin/versions ✗ %v\n", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create version: " + errStr}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Version created successfully", + "id": versionID, + }) +} + +// AdminUpdateVersion updates a document version +func (h *Handler) AdminUpdateVersion(c *gin.Context) { + versionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) + return + } + + var req models.UpdateVersionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + ctx := context.Background() + + // Check if version is in draft or review status (only these can be edited) + var status string + err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) + return + } + + if status != "draft" && status != "review" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Only draft or review versions can be edited"}) + return + } + + result, err := h.db.Pool.Exec(ctx, ` + UPDATE document_versions + SET title = COALESCE($2, title), + content = COALESCE($3, content), + summary = COALESCE($4, summary), + status = COALESCE($5, status), + updated_at = NOW() + WHERE id = $1 + `, versionID, req.Title, req.Content, req.Summary, req.Status) + + if err != nil || result.RowsAffected() == 0 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update version"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Version updated successfully"}) +} + +// AdminPublishVersion publishes a document version +func (h *Handler) AdminPublishVersion(c *gin.Context) { + versionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + ctx := context.Background() + + // Check current status + var status string + err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) + return + } + + if status != "approved" && status != "review" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Only approved or review versions can be published"}) + return + } + + result, err := h.db.Pool.Exec(ctx, ` + UPDATE document_versions + SET status = 'published', + published_at = NOW(), + approved_by = $2, + updated_at = NOW() + WHERE id = $1 + `, versionID, userID) + + if err != nil || result.RowsAffected() == 0 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to publish version"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Version published successfully"}) +} + +// AdminArchiveVersion archives a document version +func (h *Handler) AdminArchiveVersion(c *gin.Context) { + versionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) + return + } + + ctx := context.Background() + + result, err := h.db.Pool.Exec(ctx, ` + UPDATE document_versions + SET status = 'archived', updated_at = NOW() + WHERE id = $1 + `, versionID) + + if err != nil || result.RowsAffected() == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Version archived successfully"}) +} + +// AdminDeleteVersion permanently deletes a draft/rejected version +// Only draft and rejected versions can be deleted. Published versions must be archived. +func (h *Handler) AdminDeleteVersion(c *gin.Context) { + versionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) + return + } + + ctx := context.Background() + + // First check the version status - only draft/rejected can be deleted + var status string + var version string + var docID uuid.UUID + err = h.db.Pool.QueryRow(ctx, ` + SELECT status, version, document_id FROM document_versions WHERE id = $1 + `, versionID).Scan(&status, &version, &docID) + + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) + return + } + + // Only allow deletion of draft and rejected versions + if status != "draft" && status != "rejected" { + c.JSON(http.StatusForbidden, gin.H{ + "error": "Cannot delete version", + "message": "Only draft or rejected versions can be deleted. Published versions must be archived instead.", + "status": status, + }) + return + } + + // Delete the version + result, err := h.db.Pool.Exec(ctx, ` + DELETE FROM document_versions WHERE id = $1 + `, versionID) + + if err != nil || result.RowsAffected() == 0 { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete version"}) + return + } + + // Log the deletion + userID, _ := c.Get("user_id") + h.db.Pool.Exec(ctx, ` + INSERT INTO consent_audit_log (action, entity_type, entity_id, user_id, details, ip_address, user_agent) + VALUES ('version_deleted', 'document_version', $1, $2, $3, $4, $5) + `, versionID, userID, "Version "+version+" permanently deleted", c.ClientIP(), c.Request.UserAgent()) + + c.JSON(http.StatusOK, gin.H{ + "message": "Version deleted successfully", + "deleted_version": version, + "version_id": versionID, + }) +} + +// ======================================== +// ADMIN ENDPOINTS - Cookie Categories +// ======================================== + +// AdminGetCookieCategories returns all cookie categories +func (h *Handler) AdminGetCookieCategories(c *gin.Context) { + ctx := context.Background() + + rows, err := h.db.Pool.Query(ctx, ` + SELECT id, name, display_name_de, display_name_en, description_de, description_en, + is_mandatory, sort_order, is_active, created_at, updated_at + FROM cookie_categories + ORDER BY sort_order ASC + `) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch categories"}) + return + } + defer rows.Close() + + var categories []models.CookieCategory + for rows.Next() { + var cat models.CookieCategory + if err := rows.Scan(&cat.ID, &cat.Name, &cat.DisplayNameDE, &cat.DisplayNameEN, + &cat.DescriptionDE, &cat.DescriptionEN, &cat.IsMandatory, &cat.SortOrder, + &cat.IsActive, &cat.CreatedAt, &cat.UpdatedAt); err != nil { + continue + } + categories = append(categories, cat) + } + + c.JSON(http.StatusOK, gin.H{"categories": categories}) +} + +// AdminCreateCookieCategory creates a new cookie category +func (h *Handler) AdminCreateCookieCategory(c *gin.Context) { + var req models.CreateCookieCategoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + ctx := context.Background() + + var catID uuid.UUID + err := h.db.Pool.QueryRow(ctx, ` + INSERT INTO cookie_categories (name, display_name_de, display_name_en, description_de, description_en, is_mandatory, sort_order) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id + `, req.Name, req.DisplayNameDE, req.DisplayNameEN, req.DescriptionDE, req.DescriptionEN, req.IsMandatory, req.SortOrder).Scan(&catID) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create category"}) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Cookie category created successfully", + "id": catID, + }) +} + +// AdminUpdateCookieCategory updates a cookie category +func (h *Handler) AdminUpdateCookieCategory(c *gin.Context) { + catID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category ID"}) + return + } + + var req struct { + DisplayNameDE *string `json:"display_name_de"` + DisplayNameEN *string `json:"display_name_en"` + DescriptionDE *string `json:"description_de"` + DescriptionEN *string `json:"description_en"` + IsMandatory *bool `json:"is_mandatory"` + SortOrder *int `json:"sort_order"` + IsActive *bool `json:"is_active"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + ctx := context.Background() + + result, err := h.db.Pool.Exec(ctx, ` + UPDATE cookie_categories + SET display_name_de = COALESCE($2, display_name_de), + display_name_en = COALESCE($3, display_name_en), + description_de = COALESCE($4, description_de), + description_en = COALESCE($5, description_en), + is_mandatory = COALESCE($6, is_mandatory), + sort_order = COALESCE($7, sort_order), + is_active = COALESCE($8, is_active), + updated_at = NOW() + WHERE id = $1 + `, catID, req.DisplayNameDE, req.DisplayNameEN, req.DescriptionDE, req.DescriptionEN, + req.IsMandatory, req.SortOrder, req.IsActive) + + if err != nil || result.RowsAffected() == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Category not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Cookie category updated successfully"}) +} + +// AdminDeleteCookieCategory soft-deletes a cookie category +func (h *Handler) AdminDeleteCookieCategory(c *gin.Context) { + catID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid category ID"}) + return + } + + ctx := context.Background() + + result, err := h.db.Pool.Exec(ctx, ` + UPDATE cookie_categories + SET is_active = false, updated_at = NOW() + WHERE id = $1 + `, catID) + + if err != nil || result.RowsAffected() == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Category not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Cookie category deleted successfully"}) +} + +// ======================================== +// ADMIN ENDPOINTS - Statistics & Audit +// ======================================== + +// GetConsentStats returns consent statistics +func (h *Handler) GetConsentStats(c *gin.Context) { + ctx := context.Background() + docType := c.Query("document_type") + + var stats models.ConsentStats + + // Total users + h.db.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&stats.TotalUsers) + + // Consented users (with active consent) + query := ` + SELECT COUNT(DISTINCT uc.user_id) + FROM user_consents uc + JOIN document_versions dv ON uc.document_version_id = dv.id + JOIN legal_documents ld ON dv.document_id = ld.id + WHERE uc.consented = true AND uc.withdrawn_at IS NULL + ` + if docType != "" { + query += ` AND ld.type = $1` + h.db.Pool.QueryRow(ctx, query, docType).Scan(&stats.ConsentedUsers) + } else { + h.db.Pool.QueryRow(ctx, query).Scan(&stats.ConsentedUsers) + } + + // Calculate consent rate + if stats.TotalUsers > 0 { + stats.ConsentRate = float64(stats.ConsentedUsers) / float64(stats.TotalUsers) * 100 + } + + // Recent consents (last 7 days) + h.db.Pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM user_consents + WHERE consented = true AND consented_at > NOW() - INTERVAL '7 days' + `).Scan(&stats.RecentConsents) + + // Recent withdrawals + h.db.Pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM user_consents + WHERE withdrawn_at IS NOT NULL AND withdrawn_at > NOW() - INTERVAL '7 days' + `).Scan(&stats.RecentWithdrawals) + + c.JSON(http.StatusOK, stats) +} + +// GetCookieStats returns cookie consent statistics +func (h *Handler) GetCookieStats(c *gin.Context) { + ctx := context.Background() + + rows, err := h.db.Pool.Query(ctx, ` + SELECT cat.name, + COUNT(DISTINCT u.id) as total_users, + COUNT(DISTINCT CASE WHEN cc.consented = true THEN cc.user_id END) as consented_users + FROM cookie_categories cat + CROSS JOIN users u + LEFT JOIN cookie_consents cc ON cat.id = cc.category_id AND u.id = cc.user_id + WHERE cat.is_active = true + GROUP BY cat.id, cat.name + ORDER BY cat.sort_order + `) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch stats"}) + return + } + defer rows.Close() + + var stats []models.CookieStats + for rows.Next() { + var s models.CookieStats + if err := rows.Scan(&s.Category, &s.TotalUsers, &s.ConsentedUsers); err != nil { + continue + } + if s.TotalUsers > 0 { + s.ConsentRate = float64(s.ConsentedUsers) / float64(s.TotalUsers) * 100 + } + stats = append(stats, s) + } + + c.JSON(http.StatusOK, gin.H{"cookie_stats": stats}) +} + +// GetAuditLog returns audit log entries +func (h *Handler) GetAuditLog(c *gin.Context) { + ctx := context.Background() + + // Pagination + limit := 50 + offset := 0 + if l := c.Query("limit"); l != "" { + if parsed, err := parseIntFromQuery(l); err == nil && parsed > 0 { + limit = parsed + } + } + if o := c.Query("offset"); o != "" { + if parsed, err := parseIntFromQuery(o); err == nil && parsed >= 0 { + offset = parsed + } + } + + // Filters + userIDFilter := c.Query("user_id") + actionFilter := c.Query("action") + + query := ` + SELECT al.id, al.user_id, al.action, al.entity_type, al.entity_id, al.details, + al.ip_address, al.user_agent, al.created_at, u.email + FROM consent_audit_log al + LEFT JOIN users u ON al.user_id = u.id + WHERE 1=1 + ` + args := []interface{}{} + argCount := 0 + + if userIDFilter != "" { + argCount++ + query += fmt.Sprintf(" AND al.user_id = $%d", argCount) + args = append(args, userIDFilter) + } + if actionFilter != "" { + argCount++ + query += fmt.Sprintf(" AND al.action = $%d", argCount) + args = append(args, actionFilter) + } + + query += fmt.Sprintf(" ORDER BY al.created_at DESC LIMIT $%d OFFSET $%d", argCount+1, argCount+2) + args = append(args, limit, offset) + + rows, err := h.db.Pool.Query(ctx, query, args...) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch audit log"}) + return + } + defer rows.Close() + + var logs []map[string]interface{} + for rows.Next() { + var ( + id uuid.UUID + userIDPtr *uuid.UUID + action string + entityType *string + entityID *uuid.UUID + details *string + ipAddress *string + userAgent *string + createdAt time.Time + email *string + ) + + if err := rows.Scan(&id, &userIDPtr, &action, &entityType, &entityID, &details, + &ipAddress, &userAgent, &createdAt, &email); err != nil { + continue + } + + logs = append(logs, map[string]interface{}{ + "id": id, + "user_id": userIDPtr, + "user_email": email, + "action": action, + "entity_type": entityType, + "entity_id": entityID, + "details": details, + "ip_address": ipAddress, + "user_agent": userAgent, + "created_at": createdAt, + }) + } + + c.JSON(http.StatusOK, gin.H{"audit_log": logs}) +} + +// ======================================== +// ADMIN ENDPOINTS - Version Approval Workflow (DSB) +// ======================================== + +// AdminSubmitForReview submits a version for DSB review +func (h *Handler) AdminSubmitForReview(c *gin.Context) { + versionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) + return + } + + userID, _ := middleware.GetUserID(c) + ctx := context.Background() + ipAddress := middleware.GetClientIP(c) + userAgent := middleware.GetUserAgent(c) + + // Check current status + var status string + err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) + return + } + + if status != "draft" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Only draft versions can be submitted for review"}) + return + } + + // Update status to review + _, err = h.db.Pool.Exec(ctx, ` + UPDATE document_versions + SET status = 'review', updated_at = NOW() + WHERE id = $1 + `, versionID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to submit for review"}) + return + } + + // Log approval action + _, err = h.db.Pool.Exec(ctx, ` + INSERT INTO version_approvals (version_id, approver_id, action, comment) + VALUES ($1, $2, 'submitted', 'Submitted for DSB review') + `, versionID, userID) + + h.logAudit(ctx, &userID, "version_submitted_review", "document_version", &versionID, nil, ipAddress, userAgent) + + c.JSON(http.StatusOK, gin.H{"message": "Version submitted for review"}) +} + +// AdminApproveVersion approves a version with scheduled publish date (DSB only) +func (h *Handler) AdminApproveVersion(c *gin.Context) { + versionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) + return + } + + // Check if user is DSB or Admin (for dev purposes) + if !middleware.IsDSB(c) && !middleware.IsAdmin(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Only Data Protection Officers can approve versions"}) + return + } + + var req struct { + Comment string `json:"comment"` + ScheduledPublishAt *string `json:"scheduled_publish_at"` // ISO 8601: "2026-01-01T00:00:00Z" + } + c.ShouldBindJSON(&req) + + // Validate scheduled publish date + var scheduledAt *time.Time + if req.ScheduledPublishAt != nil && *req.ScheduledPublishAt != "" { + parsed, err := time.Parse(time.RFC3339, *req.ScheduledPublishAt) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid scheduled_publish_at format. Use ISO 8601 (e.g., 2026-01-01T00:00:00Z)"}) + return + } + if parsed.Before(time.Now()) { + c.JSON(http.StatusBadRequest, gin.H{"error": "Scheduled publish date must be in the future"}) + return + } + scheduledAt = &parsed + } + + userID, _ := middleware.GetUserID(c) + ctx := context.Background() + ipAddress := middleware.GetClientIP(c) + userAgent := middleware.GetUserAgent(c) + + // Check current status + var status string + var createdBy *uuid.UUID + err = h.db.Pool.QueryRow(ctx, `SELECT status, created_by FROM document_versions WHERE id = $1`, versionID).Scan(&status, &createdBy) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) + return + } + + if status != "review" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Only versions in review status can be approved"}) + return + } + + // Four-eyes principle: DSB cannot approve their own version + // Exception: Admins can approve their own versions for development/testing purposes + role, _ := c.Get("role") + roleStr, _ := role.(string) + if createdBy != nil && *createdBy == userID && roleStr != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "You cannot approve your own version (four-eyes principle)"}) + return + } + + // Determine new status: 'scheduled' if date set, otherwise 'approved' + newStatus := "approved" + if scheduledAt != nil { + newStatus = "scheduled" + } + + // Update status to approved/scheduled + _, err = h.db.Pool.Exec(ctx, ` + UPDATE document_versions + SET status = $2, approved_by = $3, approved_at = NOW(), scheduled_publish_at = $4, updated_at = NOW() + WHERE id = $1 + `, versionID, newStatus, userID, scheduledAt) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to approve version"}) + return + } + + // Log approval action + comment := req.Comment + if comment == "" { + if scheduledAt != nil { + comment = "Approved by DSB, scheduled for " + scheduledAt.Format("02.01.2006 15:04") + } else { + comment = "Approved by DSB" + } + } + _, err = h.db.Pool.Exec(ctx, ` + INSERT INTO version_approvals (version_id, approver_id, action, comment) + VALUES ($1, $2, 'approved', $3) + `, versionID, userID, comment) + + h.logAudit(ctx, &userID, "version_approved", "document_version", &versionID, &comment, ipAddress, userAgent) + + response := gin.H{"message": "Version approved", "status": newStatus} + if scheduledAt != nil { + response["scheduled_publish_at"] = scheduledAt.Format(time.RFC3339) + } + c.JSON(http.StatusOK, response) +} + +// AdminRejectVersion rejects a version (DSB only) +func (h *Handler) AdminRejectVersion(c *gin.Context) { + versionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) + return + } + + // Check if user is DSB + if !middleware.IsDSB(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "Only Data Protection Officers can reject versions"}) + return + } + + var req struct { + Comment string `json:"comment" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Comment is required when rejecting"}) + return + } + + userID, _ := middleware.GetUserID(c) + ctx := context.Background() + ipAddress := middleware.GetClientIP(c) + userAgent := middleware.GetUserAgent(c) + + // Check current status + var status string + err = h.db.Pool.QueryRow(ctx, `SELECT status FROM document_versions WHERE id = $1`, versionID).Scan(&status) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) + return + } + + if status != "review" && status != "approved" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Only versions in review or approved status can be rejected"}) + return + } + + // Update status back to draft + _, err = h.db.Pool.Exec(ctx, ` + UPDATE document_versions + SET status = 'draft', approved_by = NULL, updated_at = NOW() + WHERE id = $1 + `, versionID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reject version"}) + return + } + + // Log rejection + _, err = h.db.Pool.Exec(ctx, ` + INSERT INTO version_approvals (version_id, approver_id, action, comment) + VALUES ($1, $2, 'rejected', $3) + `, versionID, userID, req.Comment) + + h.logAudit(ctx, &userID, "version_rejected", "document_version", &versionID, &req.Comment, ipAddress, userAgent) + + c.JSON(http.StatusOK, gin.H{"message": "Version rejected and returned to draft"}) +} + +// AdminCompareVersions returns two versions for side-by-side comparison +func (h *Handler) AdminCompareVersions(c *gin.Context) { + versionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) + return + } + + ctx := context.Background() + + // Get the current version and its document + var currentVersion models.DocumentVersion + var documentID uuid.UUID + err = h.db.Pool.QueryRow(ctx, ` + SELECT id, document_id, version, language, title, content, summary, status, created_at, updated_at + FROM document_versions + WHERE id = $1 + `, versionID).Scan(¤tVersion.ID, &documentID, ¤tVersion.Version, ¤tVersion.Language, + ¤tVersion.Title, ¤tVersion.Content, ¤tVersion.Summary, ¤tVersion.Status, + ¤tVersion.CreatedAt, ¤tVersion.UpdatedAt) + + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Version not found"}) + return + } + + // Get the currently published version (if any) + var publishedVersion *models.DocumentVersion + var pv models.DocumentVersion + err = h.db.Pool.QueryRow(ctx, ` + SELECT id, document_id, version, language, title, content, summary, status, published_at, created_at, updated_at + FROM document_versions + WHERE document_id = $1 AND language = $2 AND status = 'published' + ORDER BY published_at DESC + LIMIT 1 + `, documentID, currentVersion.Language).Scan(&pv.ID, &pv.DocumentID, &pv.Version, &pv.Language, + &pv.Title, &pv.Content, &pv.Summary, &pv.Status, &pv.PublishedAt, &pv.CreatedAt, &pv.UpdatedAt) + + if err == nil && pv.ID != currentVersion.ID { + publishedVersion = &pv + } + + // Get approval history + rows, err := h.db.Pool.Query(ctx, ` + SELECT va.action, va.comment, va.created_at, u.email + FROM version_approvals va + LEFT JOIN users u ON va.approver_id = u.id + WHERE va.version_id = $1 + ORDER BY va.created_at DESC + `, versionID) + + var approvalHistory []map[string]interface{} + if err == nil { + defer rows.Close() + for rows.Next() { + var action, email string + var comment *string + var createdAt time.Time + if err := rows.Scan(&action, &comment, &createdAt, &email); err == nil { + approvalHistory = append(approvalHistory, map[string]interface{}{ + "action": action, + "comment": comment, + "created_at": createdAt, + "approver": email, + }) + } + } + } + + c.JSON(http.StatusOK, gin.H{ + "current_version": currentVersion, + "published_version": publishedVersion, + "approval_history": approvalHistory, + }) +} + +// AdminGetApprovalHistory returns the approval history for a version +func (h *Handler) AdminGetApprovalHistory(c *gin.Context) { + versionID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"}) + return + } + + ctx := context.Background() + + rows, err := h.db.Pool.Query(ctx, ` + SELECT va.id, va.action, va.comment, va.created_at, u.email, u.name + FROM version_approvals va + LEFT JOIN users u ON va.approver_id = u.id + WHERE va.version_id = $1 + ORDER BY va.created_at DESC + `, versionID) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch approval history"}) + return + } + defer rows.Close() + + var history []map[string]interface{} + for rows.Next() { + var id uuid.UUID + var action string + var comment *string + var createdAt time.Time + var email, name *string + + if err := rows.Scan(&id, &action, &comment, &createdAt, &email, &name); err != nil { + continue + } + + history = append(history, map[string]interface{}{ + "id": id, + "action": action, + "comment": comment, + "created_at": createdAt, + "approver": email, + "name": name, + }) + } + + c.JSON(http.StatusOK, gin.H{"approval_history": history}) +} + +// ======================================== +// HELPER FUNCTIONS +// ======================================== + +func (h *Handler) logAudit(ctx context.Context, userID *uuid.UUID, action, entityType string, entityID *uuid.UUID, details *string, ipAddress, userAgent string) { + h.db.Pool.Exec(ctx, ` + INSERT INTO consent_audit_log (user_id, action, entity_type, entity_id, details, ip_address, user_agent) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `, userID, action, entityType, entityID, details, ipAddress, userAgent) +} + +func parseIntFromQuery(s string) (int, error) { + return strconv.Atoi(s) +} + +// ======================================== +// SCHEDULED PUBLISHING +// ======================================== + +// ProcessScheduledPublishing publishes all versions that are due +// This should be called by a cron job or scheduler +func (h *Handler) ProcessScheduledPublishing(c *gin.Context) { + ctx := context.Background() + + // Find all scheduled versions that are due + rows, err := h.db.Pool.Query(ctx, ` + SELECT id, document_id, version + FROM document_versions + WHERE status = 'scheduled' + AND scheduled_publish_at IS NOT NULL + AND scheduled_publish_at <= NOW() + `) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch scheduled versions"}) + return + } + defer rows.Close() + + var published []string + for rows.Next() { + var versionID, docID uuid.UUID + var version string + if err := rows.Scan(&versionID, &docID, &version); err != nil { + continue + } + + // Publish this version + _, err := h.db.Pool.Exec(ctx, ` + UPDATE document_versions + SET status = 'published', published_at = NOW(), updated_at = NOW() + WHERE id = $1 + `, versionID) + + if err == nil { + // Archive previous published versions for this document + h.db.Pool.Exec(ctx, ` + UPDATE document_versions + SET status = 'archived', updated_at = NOW() + WHERE document_id = $1 AND id != $2 AND status = 'published' + `, docID, versionID) + + // Log the publishing + details := fmt.Sprintf("Version %s automatically published by scheduler", version) + h.logAudit(ctx, nil, "version_scheduled_published", "document_version", &versionID, &details, "", "scheduler") + + published = append(published, version) + } + } + + c.JSON(http.StatusOK, gin.H{ + "message": "Scheduled publishing processed", + "published_count": len(published), + "published_versions": published, + }) +} + +// GetScheduledVersions returns all versions scheduled for publishing +func (h *Handler) GetScheduledVersions(c *gin.Context) { + ctx := context.Background() + + rows, err := h.db.Pool.Query(ctx, ` + SELECT dv.id, dv.document_id, dv.version, dv.title, dv.scheduled_publish_at, ld.name as document_name + FROM document_versions dv + JOIN legal_documents ld ON ld.id = dv.document_id + WHERE dv.status = 'scheduled' + AND dv.scheduled_publish_at IS NOT NULL + ORDER BY dv.scheduled_publish_at ASC + `) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch scheduled versions"}) + return + } + defer rows.Close() + + type ScheduledVersion struct { + ID uuid.UUID `json:"id"` + DocumentID uuid.UUID `json:"document_id"` + Version string `json:"version"` + Title string `json:"title"` + ScheduledPublishAt *time.Time `json:"scheduled_publish_at"` + DocumentName string `json:"document_name"` + } + + var versions []ScheduledVersion + for rows.Next() { + var v ScheduledVersion + if err := rows.Scan(&v.ID, &v.DocumentID, &v.Version, &v.Title, &v.ScheduledPublishAt, &v.DocumentName); err != nil { + continue + } + versions = append(versions, v) + } + + c.JSON(http.StatusOK, gin.H{"scheduled_versions": versions}) +} diff --git a/consent-service/internal/handlers/handlers_test.go b/consent-service/internal/handlers/handlers_test.go new file mode 100644 index 0000000..6650f3f --- /dev/null +++ b/consent-service/internal/handlers/handlers_test.go @@ -0,0 +1,805 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +// setupTestRouter creates a test router with handlers +// Note: For full integration tests, use a test database +func setupTestRouter() *gin.Engine { + router := gin.New() + return router +} + +// TestHealthEndpoint tests the health check endpoint +func TestHealthEndpoint(t *testing.T) { + router := setupTestRouter() + + // Add health endpoint + router.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "status": "healthy", + "service": "consent-service", + "version": "1.0.0", + }) + }) + + req, _ := http.NewRequest("GET", "/health", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + var response map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &response) + + if response["status"] != "healthy" { + t.Errorf("Expected status 'healthy', got %v", response["status"]) + } +} + +// TestUnauthorizedAccess tests that protected endpoints require auth +func TestUnauthorizedAccess(t *testing.T) { + router := setupTestRouter() + + // Add a protected endpoint + router.GET("/api/v1/consent/my", func(c *gin.Context) { + auth := c.GetHeader("Authorization") + if auth == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization required"}) + return + } + c.JSON(http.StatusOK, gin.H{"consents": []interface{}{}}) + }) + + tests := []struct { + name string + authorization string + expectedStatus int + }{ + {"no auth header", "", http.StatusUnauthorized}, + {"empty bearer", "Bearer ", http.StatusOK}, // Would be invalid in real middleware + {"valid format", "Bearer test-token", http.StatusOK}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/v1/consent/my", nil) + if tt.authorization != "" { + req.Header.Set("Authorization", tt.authorization) + } + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != tt.expectedStatus { + t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code) + } + }) + } +} + +// TestCreateConsentRequest tests consent creation request validation +func TestCreateConsentRequest(t *testing.T) { + type ConsentRequest struct { + DocumentType string `json:"document_type"` + VersionID string `json:"version_id"` + Consented bool `json:"consented"` + } + + tests := []struct { + name string + request ConsentRequest + expectValid bool + }{ + { + name: "valid consent", + request: ConsentRequest{ + DocumentType: "terms", + VersionID: "123e4567-e89b-12d3-a456-426614174000", + Consented: true, + }, + expectValid: true, + }, + { + name: "missing document type", + request: ConsentRequest{ + VersionID: "123e4567-e89b-12d3-a456-426614174000", + Consented: true, + }, + expectValid: false, + }, + { + name: "missing version ID", + request: ConsentRequest{ + DocumentType: "terms", + Consented: true, + }, + expectValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isValid := tt.request.DocumentType != "" && tt.request.VersionID != "" + if isValid != tt.expectValid { + t.Errorf("Expected valid=%v, got %v", tt.expectValid, isValid) + } + }) + } +} + +// TestDocumentTypeValidation tests valid document types +func TestDocumentTypeValidation(t *testing.T) { + validTypes := map[string]bool{ + "terms": true, + "privacy": true, + "cookies": true, + "community_guidelines": true, + "imprint": true, + } + + tests := []struct { + docType string + expected bool + }{ + {"terms", true}, + {"privacy", true}, + {"cookies", true}, + {"community_guidelines", true}, + {"imprint", true}, + {"invalid", false}, + {"", false}, + {"Terms", false}, // case sensitive + } + + for _, tt := range tests { + t.Run(tt.docType, func(t *testing.T) { + _, isValid := validTypes[tt.docType] + if isValid != tt.expected { + t.Errorf("Expected %s valid=%v, got %v", tt.docType, tt.expected, isValid) + } + }) + } +} + +// TestVersionStatusTransitions tests valid status transitions +func TestVersionStatusTransitions(t *testing.T) { + validTransitions := map[string][]string{ + "draft": {"review"}, + "review": {"approved", "rejected"}, + "approved": {"scheduled", "published"}, + "scheduled": {"published"}, + "published": {"archived"}, + "rejected": {"draft"}, + "archived": {}, // terminal state + } + + tests := []struct { + fromStatus string + toStatus string + expected bool + }{ + {"draft", "review", true}, + {"draft", "published", false}, + {"review", "approved", true}, + {"review", "rejected", true}, + {"review", "published", false}, + {"approved", "published", true}, + {"approved", "scheduled", true}, + {"published", "archived", true}, + {"published", "draft", false}, + {"archived", "draft", false}, + } + + for _, tt := range tests { + t.Run(tt.fromStatus+"->"+tt.toStatus, func(t *testing.T) { + allowed := false + if transitions, ok := validTransitions[tt.fromStatus]; ok { + for _, t := range transitions { + if t == tt.toStatus { + allowed = true + break + } + } + } + + if allowed != tt.expected { + t.Errorf("Transition %s->%s: expected %v, got %v", + tt.fromStatus, tt.toStatus, tt.expected, allowed) + } + }) + } +} + +// TestRolePermissions tests role-based access control +func TestRolePermissions(t *testing.T) { + permissions := map[string]map[string]bool{ + "user": { + "view_documents": true, + "give_consent": true, + "view_own_data": true, + "request_deletion": true, + "create_document": false, + "publish_version": false, + "approve_version": false, + }, + "admin": { + "view_documents": true, + "give_consent": true, + "view_own_data": true, + "create_document": true, + "edit_version": true, + "publish_version": true, + "approve_version": false, // Only DSB + }, + "data_protection_officer": { + "view_documents": true, + "create_document": true, + "edit_version": true, + "approve_version": true, + "publish_version": true, + "view_audit_log": true, + }, + } + + tests := []struct { + role string + action string + shouldHave bool + }{ + {"user", "view_documents", true}, + {"user", "create_document", false}, + {"admin", "create_document", true}, + {"admin", "approve_version", false}, + {"data_protection_officer", "approve_version", true}, + } + + for _, tt := range tests { + t.Run(tt.role+":"+tt.action, func(t *testing.T) { + rolePerms, ok := permissions[tt.role] + if !ok { + t.Fatalf("Unknown role: %s", tt.role) + } + + hasPermission := rolePerms[tt.action] + if hasPermission != tt.shouldHave { + t.Errorf("Role %s action %s: expected %v, got %v", + tt.role, tt.action, tt.shouldHave, hasPermission) + } + }) + } +} + +// TestJSONResponseFormat tests that responses have correct format +func TestJSONResponseFormat(t *testing.T) { + router := setupTestRouter() + + router.GET("/api/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{ + "success": true, + "data": gin.H{ + "id": "123", + "name": "Test", + }, + }) + }) + + req, _ := http.NewRequest("GET", "/api/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + contentType := w.Header().Get("Content-Type") + if contentType != "application/json; charset=utf-8" { + t.Errorf("Expected Content-Type 'application/json; charset=utf-8', got %s", contentType) + } + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + if err != nil { + t.Fatalf("Response should be valid JSON: %v", err) + } +} + +// TestErrorResponseFormat tests error response format +func TestErrorResponseFormat(t *testing.T) { + router := setupTestRouter() + + router.GET("/api/error", func(c *gin.Context) { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "Bad Request", + "message": "Invalid input", + }) + }) + + req, _ := http.NewRequest("GET", "/api/error", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } + + var response map[string]interface{} + json.Unmarshal(w.Body.Bytes(), &response) + + if response["error"] == nil { + t.Error("Error response should contain 'error' field") + } +} + +// TestCookieCategoryValidation tests cookie category validation +func TestCookieCategoryValidation(t *testing.T) { + mandatoryCategories := []string{"necessary"} + optionalCategories := []string{"functional", "analytics", "marketing"} + + // Necessary should always be consented + for _, cat := range mandatoryCategories { + t.Run("mandatory_"+cat, func(t *testing.T) { + // Business rule: mandatory categories cannot be declined + isMandatory := true + if !isMandatory { + t.Errorf("Category %s should be mandatory", cat) + } + }) + } + + // Optional categories can be toggled + for _, cat := range optionalCategories { + t.Run("optional_"+cat, func(t *testing.T) { + isMandatory := false + if isMandatory { + t.Errorf("Category %s should not be mandatory", cat) + } + }) + } +} + +// TestPaginationParams tests pagination parameter handling +func TestPaginationParams(t *testing.T) { + tests := []struct { + name string + page int + perPage int + expPage int + expLimit int + }{ + {"defaults", 0, 0, 1, 50}, + {"page 1", 1, 10, 1, 10}, + {"page 5", 5, 20, 5, 20}, + {"negative page", -1, 10, 1, 10}, // should default + {"too large per_page", 1, 500, 1, 100}, // should cap + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + page := tt.page + perPage := tt.perPage + + // Apply defaults and limits + if page < 1 { + page = 1 + } + if perPage < 1 { + perPage = 50 + } + if perPage > 100 { + perPage = 100 + } + + if page != tt.expPage { + t.Errorf("Expected page %d, got %d", tt.expPage, page) + } + if perPage != tt.expLimit { + t.Errorf("Expected perPage %d, got %d", tt.expLimit, perPage) + } + }) + } +} + +// TestIPAddressExtraction tests IP address extraction from requests +func TestIPAddressExtraction(t *testing.T) { + tests := []struct { + name string + xForwarded string + remoteAddr string + expected string + }{ + {"direct connection", "", "192.168.1.1:1234", "192.168.1.1"}, + {"behind proxy", "10.0.0.1", "192.168.1.1:1234", "10.0.0.1"}, + {"multiple proxies", "10.0.0.1, 10.0.0.2", "192.168.1.1:1234", "10.0.0.1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := setupTestRouter() + var extractedIP string + + router.GET("/test", func(c *gin.Context) { + if xf := c.GetHeader("X-Forwarded-For"); xf != "" { + // Take first IP from list + for i, ch := range xf { + if ch == ',' { + extractedIP = xf[:i] + break + } + } + if extractedIP == "" { + extractedIP = xf + } + } else { + // Extract IP from RemoteAddr + addr := c.Request.RemoteAddr + for i := len(addr) - 1; i >= 0; i-- { + if addr[i] == ':' { + extractedIP = addr[:i] + break + } + } + } + c.JSON(http.StatusOK, gin.H{"ip": extractedIP}) + }) + + req, _ := http.NewRequest("GET", "/test", nil) + req.RemoteAddr = tt.remoteAddr + if tt.xForwarded != "" { + req.Header.Set("X-Forwarded-For", tt.xForwarded) + } + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if extractedIP != tt.expected { + t.Errorf("Expected IP %s, got %s", tt.expected, extractedIP) + } + }) + } +} + +// TestRequestBodySizeLimit tests that large requests are rejected +func TestRequestBodySizeLimit(t *testing.T) { + router := setupTestRouter() + + // Simulate a body size limit check + maxBodySize := int64(1024 * 1024) // 1MB + + router.POST("/api/upload", func(c *gin.Context) { + if c.Request.ContentLength > maxBodySize { + c.JSON(http.StatusRequestEntityTooLarge, gin.H{ + "error": "Request body too large", + }) + return + } + c.JSON(http.StatusOK, gin.H{"success": true}) + }) + + tests := []struct { + name string + contentLength int64 + expectedStatus int + }{ + {"small body", 1000, http.StatusOK}, + {"medium body", 500000, http.StatusOK}, + {"exactly at limit", maxBodySize, http.StatusOK}, + {"over limit", maxBodySize + 1, http.StatusRequestEntityTooLarge}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := bytes.NewReader(make([]byte, 0)) + req, _ := http.NewRequest("POST", "/api/upload", body) + req.ContentLength = tt.contentLength + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != tt.expectedStatus { + t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code) + } + }) + } +} + +// ======================================== +// EXTENDED HANDLER TESTS +// ======================================== + +// TestAuthHandlers tests authentication endpoints +func TestAuthHandlers(t *testing.T) { + router := setupTestRouter() + + // Register endpoint + router.POST("/api/v1/auth/register", func(c *gin.Context) { + var req struct { + Email string `json:"email"` + Password string `json:"password"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) + return + } + c.JSON(http.StatusCreated, gin.H{"message": "User registered"}) + }) + + // Login endpoint + router.POST("/api/v1/auth/login", func(c *gin.Context) { + var req struct { + Email string `json:"email"` + Password string `json:"password"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) + return + } + c.JSON(http.StatusOK, gin.H{"access_token": "token123"}) + }) + + tests := []struct { + name string + endpoint string + method string + body interface{} + expectedStatus int + }{ + { + name: "register - valid", + endpoint: "/api/v1/auth/register", + method: "POST", + body: map[string]string{"email": "test@example.com", "password": "password123"}, + expectedStatus: http.StatusCreated, + }, + { + name: "login - valid", + endpoint: "/api/v1/auth/login", + method: "POST", + body: map[string]string{"email": "test@example.com", "password": "password123"}, + expectedStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + jsonBody, _ := json.Marshal(tt.body) + req, _ := http.NewRequest(tt.method, tt.endpoint, bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != tt.expectedStatus { + t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code) + } + }) + } +} + +// TestDocumentHandlers tests document endpoints +func TestDocumentHandlers(t *testing.T) { + router := setupTestRouter() + + // GET documents + router.GET("/api/v1/documents", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"documents": []interface{}{}}) + }) + + // GET document by type + router.GET("/api/v1/documents/:type", func(c *gin.Context) { + docType := c.Param("type") + if docType == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid type"}) + return + } + c.JSON(http.StatusOK, gin.H{"id": "123", "type": docType}) + }) + + tests := []struct { + name string + endpoint string + expectedStatus int + }{ + {"get all documents", "/api/v1/documents", http.StatusOK}, + {"get terms", "/api/v1/documents/terms", http.StatusOK}, + {"get privacy", "/api/v1/documents/privacy", http.StatusOK}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, _ := http.NewRequest("GET", tt.endpoint, nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != tt.expectedStatus { + t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code) + } + }) + } +} + +// TestConsentHandlers tests consent endpoints +func TestConsentHandlers(t *testing.T) { + router := setupTestRouter() + + // Create consent + router.POST("/api/v1/consent", func(c *gin.Context) { + var req struct { + VersionID string `json:"version_id"` + Consented bool `json:"consented"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) + return + } + c.JSON(http.StatusCreated, gin.H{"message": "Consent saved"}) + }) + + // Check consent + router.GET("/api/v1/consent/check/:type", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"has_consent": true, "needs_update": false}) + }) + + tests := []struct { + name string + endpoint string + method string + body interface{} + expectedStatus int + }{ + { + name: "create consent", + endpoint: "/api/v1/consent", + method: "POST", + body: map[string]interface{}{"version_id": "123", "consented": true}, + expectedStatus: http.StatusCreated, + }, + { + name: "check consent", + endpoint: "/api/v1/consent/check/terms", + method: "GET", + expectedStatus: http.StatusOK, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var req *http.Request + if tt.body != nil { + jsonBody, _ := json.Marshal(tt.body) + req, _ = http.NewRequest(tt.method, tt.endpoint, bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + } else { + req, _ = http.NewRequest(tt.method, tt.endpoint, nil) + } + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != tt.expectedStatus { + t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code) + } + }) + } +} + +// TestAdminHandlers tests admin endpoints +func TestAdminHandlers(t *testing.T) { + router := setupTestRouter() + + // Create document (admin only) + router.POST("/api/v1/admin/documents", func(c *gin.Context) { + auth := c.GetHeader("Authorization") + if auth != "Bearer admin-token" { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin only"}) + return + } + c.JSON(http.StatusCreated, gin.H{"message": "Document created"}) + }) + + tests := []struct { + name string + token string + expectedStatus int + }{ + {"admin token", "Bearer admin-token", http.StatusCreated}, + {"user token", "Bearer user-token", http.StatusForbidden}, + {"no token", "", http.StatusForbidden}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := map[string]string{"type": "terms", "name": "Test"} + jsonBody, _ := json.Marshal(body) + req, _ := http.NewRequest("POST", "/api/v1/admin/documents", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + if tt.token != "" { + req.Header.Set("Authorization", tt.token) + } + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != tt.expectedStatus { + t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code) + } + }) + } +} + +// TestCORSHeaders tests CORS headers +func TestCORSHeaders(t *testing.T) { + router := setupTestRouter() + + router.Use(func(c *gin.Context) { + c.Header("Access-Control-Allow-Origin", "*") + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Next() + }) + + router.GET("/api/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": "test"}) + }) + + req, _ := http.NewRequest("GET", "/api/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Header().Get("Access-Control-Allow-Origin") != "*" { + t.Error("CORS headers not set correctly") + } +} + +// TestRateLimiting tests rate limiting logic +func TestRateLimiting(t *testing.T) { + requests := 0 + limit := 5 + + for i := 0; i < 10; i++ { + requests++ + if requests > limit { + // Would return 429 Too Many Requests + if requests <= limit { + t.Error("Rate limit not enforced") + } + } + } +} + +// TestEmailTemplateHandlers tests email template endpoints +func TestEmailTemplateHandlers(t *testing.T) { + router := setupTestRouter() + + router.GET("/api/v1/admin/email-templates", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"templates": []interface{}{}}) + }) + + router.POST("/api/v1/admin/email-templates/test", func(c *gin.Context) { + var req struct { + Recipient string `json:"recipient"` + VersionID string `json:"version_id"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Test email sent"}) + }) + + req, _ := http.NewRequest("GET", "/api/v1/admin/email-templates", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } +} diff --git a/consent-service/internal/handlers/notification_handlers.go b/consent-service/internal/handlers/notification_handlers.go new file mode 100644 index 0000000..32881ec --- /dev/null +++ b/consent-service/internal/handlers/notification_handlers.go @@ -0,0 +1,203 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/breakpilot/consent-service/internal/middleware" + "github.com/breakpilot/consent-service/internal/services" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// NotificationHandler handles notification-related requests +type NotificationHandler struct { + notificationService *services.NotificationService +} + +// NewNotificationHandler creates a new notification handler +func NewNotificationHandler(notificationService *services.NotificationService) *NotificationHandler { + return &NotificationHandler{ + notificationService: notificationService, + } +} + +// GetNotifications returns notifications for the current user +func (h *NotificationHandler) GetNotifications(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + // Parse query parameters + limit := 20 + offset := 0 + unreadOnly := false + + if l := c.Query("limit"); l != "" { + if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { + limit = parsed + } + } + if o := c.Query("offset"); o != "" { + if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { + offset = parsed + } + } + if u := c.Query("unread_only"); u == "true" { + unreadOnly = true + } + + notifications, total, err := h.notificationService.GetUserNotifications(c.Request.Context(), userID, limit, offset, unreadOnly) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch notifications"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "notifications": notifications, + "total": total, + "limit": limit, + "offset": offset, + }) +} + +// GetUnreadCount returns the count of unread notifications +func (h *NotificationHandler) GetUnreadCount(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + count, err := h.notificationService.GetUnreadCount(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get unread count"}) + return + } + + c.JSON(http.StatusOK, gin.H{"unread_count": count}) +} + +// MarkAsRead marks a notification as read +func (h *NotificationHandler) MarkAsRead(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + notificationID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification ID"}) + return + } + + if err := h.notificationService.MarkAsRead(c.Request.Context(), userID, notificationID); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Notification not found or already read"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Notification marked as read"}) +} + +// MarkAllAsRead marks all notifications as read +func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + if err := h.notificationService.MarkAllAsRead(c.Request.Context(), userID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark notifications as read"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "All notifications marked as read"}) +} + +// DeleteNotification deletes a notification +func (h *NotificationHandler) DeleteNotification(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + notificationID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification ID"}) + return + } + + if err := h.notificationService.DeleteNotification(c.Request.Context(), userID, notificationID); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Notification not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Notification deleted"}) +} + +// GetPreferences returns notification preferences for the user +func (h *NotificationHandler) GetPreferences(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + prefs, err := h.notificationService.GetPreferences(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get preferences"}) + return + } + + c.JSON(http.StatusOK, prefs) +} + +// UpdatePreferences updates notification preferences for the user +func (h *NotificationHandler) UpdatePreferences(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user"}) + return + } + + var req struct { + EmailEnabled *bool `json:"email_enabled"` + PushEnabled *bool `json:"push_enabled"` + InAppEnabled *bool `json:"in_app_enabled"` + ReminderFrequency *string `json:"reminder_frequency"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + // Get current preferences + prefs, _ := h.notificationService.GetPreferences(c.Request.Context(), userID) + + // Update only provided fields + if req.EmailEnabled != nil { + prefs.EmailEnabled = *req.EmailEnabled + } + if req.PushEnabled != nil { + prefs.PushEnabled = *req.PushEnabled + } + if req.InAppEnabled != nil { + prefs.InAppEnabled = *req.InAppEnabled + } + if req.ReminderFrequency != nil { + prefs.ReminderFrequency = *req.ReminderFrequency + } + + if err := h.notificationService.UpdatePreferences(c.Request.Context(), userID, prefs); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update preferences"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Preferences updated", "preferences": prefs}) +} diff --git a/consent-service/internal/handlers/oauth_handlers.go b/consent-service/internal/handlers/oauth_handlers.go new file mode 100644 index 0000000..c796a9e --- /dev/null +++ b/consent-service/internal/handlers/oauth_handlers.go @@ -0,0 +1,743 @@ +package handlers + +import ( + "context" + "net/http" + "strings" + + "github.com/breakpilot/consent-service/internal/middleware" + "github.com/breakpilot/consent-service/internal/models" + "github.com/breakpilot/consent-service/internal/services" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// OAuthHandler handles OAuth 2.0 endpoints +type OAuthHandler struct { + oauthService *services.OAuthService + totpService *services.TOTPService + authService *services.AuthService +} + +// NewOAuthHandler creates a new OAuthHandler +func NewOAuthHandler(oauthService *services.OAuthService, totpService *services.TOTPService, authService *services.AuthService) *OAuthHandler { + return &OAuthHandler{ + oauthService: oauthService, + totpService: totpService, + authService: authService, + } +} + +// ======================================== +// OAuth 2.0 Authorization Code Flow +// ======================================== + +// Authorize handles the OAuth 2.0 authorization request +// GET /oauth/authorize +func (h *OAuthHandler) Authorize(c *gin.Context) { + responseType := c.Query("response_type") + clientID := c.Query("client_id") + redirectURI := c.Query("redirect_uri") + scope := c.Query("scope") + state := c.Query("state") + codeChallenge := c.Query("code_challenge") + codeChallengeMethod := c.Query("code_challenge_method") + + // Validate response_type + if responseType != "code" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "unsupported_response_type", + "error_description": "Only 'code' response_type is supported", + }) + return + } + + // Validate client + ctx := context.Background() + client, err := h.oauthService.ValidateClient(ctx, clientID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_client", + "error_description": "Unknown or invalid client_id", + }) + return + } + + // Validate redirect_uri + if err := h.oauthService.ValidateRedirectURI(client, redirectURI); err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_request", + "error_description": "Invalid redirect_uri", + }) + return + } + + // Validate scopes + scopes, err := h.oauthService.ValidateScopes(client, scope) + if err != nil { + redirectWithError(c, redirectURI, "invalid_scope", "One or more requested scopes are invalid", state) + return + } + + // For public clients, PKCE is required + if client.IsPublic && codeChallenge == "" { + redirectWithError(c, redirectURI, "invalid_request", "PKCE code_challenge is required for public clients", state) + return + } + + // Get authenticated user + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + // User not authenticated - redirect to login + // Store authorization request in session and redirect to login + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "login_required", + "error_description": "User must be authenticated to authorize", + "login_url": "/auth/login", + }) + return + } + + // Generate authorization code + code, err := h.oauthService.GenerateAuthorizationCode( + ctx, client, userID, redirectURI, scopes, codeChallenge, codeChallengeMethod, + ) + if err != nil { + redirectWithError(c, redirectURI, "server_error", "Failed to generate authorization code", state) + return + } + + // Redirect with code + redirectURL := redirectURI + "?code=" + code + if state != "" { + redirectURL += "&state=" + state + } + + c.Redirect(http.StatusFound, redirectURL) +} + +// Token handles the OAuth 2.0 token request +// POST /oauth/token +func (h *OAuthHandler) Token(c *gin.Context) { + grantType := c.PostForm("grant_type") + + switch grantType { + case "authorization_code": + h.tokenAuthorizationCode(c) + case "refresh_token": + h.tokenRefreshToken(c) + default: + c.JSON(http.StatusBadRequest, gin.H{ + "error": "unsupported_grant_type", + "error_description": "Only 'authorization_code' and 'refresh_token' grant types are supported", + }) + } +} + +// tokenAuthorizationCode handles the authorization_code grant +func (h *OAuthHandler) tokenAuthorizationCode(c *gin.Context) { + code := c.PostForm("code") + clientID := c.PostForm("client_id") + redirectURI := c.PostForm("redirect_uri") + codeVerifier := c.PostForm("code_verifier") + + if code == "" || clientID == "" || redirectURI == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_request", + "error_description": "Missing required parameters: code, client_id, redirect_uri", + }) + return + } + + // Validate client + ctx := context.Background() + client, err := h.oauthService.ValidateClient(ctx, clientID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_client", + "error_description": "Unknown or invalid client_id", + }) + return + } + + // For confidential clients, validate client_secret + if !client.IsPublic { + clientSecret := c.PostForm("client_secret") + if err := h.oauthService.ValidateClientSecret(client, clientSecret); err != nil { + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "invalid_client", + "error_description": "Invalid client credentials", + }) + return + } + } + + // Exchange authorization code for tokens + tokenResponse, err := h.oauthService.ExchangeAuthorizationCode(ctx, code, clientID, redirectURI, codeVerifier) + if err != nil { + switch err { + case services.ErrCodeExpired: + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_grant", + "error_description": "Authorization code has expired", + }) + case services.ErrCodeUsed: + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_grant", + "error_description": "Authorization code has already been used", + }) + case services.ErrPKCEVerifyFailed: + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_grant", + "error_description": "PKCE verification failed", + }) + default: + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_grant", + "error_description": "Invalid authorization code", + }) + } + return + } + + c.JSON(http.StatusOK, tokenResponse) +} + +// tokenRefreshToken handles the refresh_token grant +func (h *OAuthHandler) tokenRefreshToken(c *gin.Context) { + refreshToken := c.PostForm("refresh_token") + clientID := c.PostForm("client_id") + scope := c.PostForm("scope") + + if refreshToken == "" || clientID == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_request", + "error_description": "Missing required parameters: refresh_token, client_id", + }) + return + } + + ctx := context.Background() + + // Refresh access token + tokenResponse, err := h.oauthService.RefreshAccessToken(ctx, refreshToken, clientID, scope) + if err != nil { + switch err { + case services.ErrInvalidScope: + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_scope", + "error_description": "Requested scope exceeds original grant", + }) + default: + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_grant", + "error_description": "Invalid or expired refresh token", + }) + } + return + } + + c.JSON(http.StatusOK, tokenResponse) +} + +// Revoke handles token revocation +// POST /oauth/revoke +func (h *OAuthHandler) Revoke(c *gin.Context) { + token := c.PostForm("token") + tokenTypeHint := c.PostForm("token_type_hint") + + if token == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_request", + "error_description": "Missing token parameter", + }) + return + } + + ctx := context.Background() + _ = h.oauthService.RevokeToken(ctx, token, tokenTypeHint) + + // RFC 7009: Always return 200 OK + c.Status(http.StatusOK) +} + +// Introspect handles token introspection (for resource servers) +// POST /oauth/introspect +func (h *OAuthHandler) Introspect(c *gin.Context) { + token := c.PostForm("token") + + if token == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid_request", + "error_description": "Missing token parameter", + }) + return + } + + ctx := context.Background() + claims, err := h.oauthService.ValidateAccessToken(ctx, token) + if err != nil { + c.JSON(http.StatusOK, gin.H{"active": false}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "active": true, + "sub": (*claims)["sub"], + "client_id": (*claims)["client_id"], + "scope": (*claims)["scope"], + "exp": (*claims)["exp"], + "iat": (*claims)["iat"], + "iss": (*claims)["iss"], + }) +} + +// ======================================== +// 2FA (TOTP) Endpoints +// ======================================== + +// Setup2FA initiates 2FA setup +// POST /auth/2fa/setup +func (h *OAuthHandler) Setup2FA(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + + // Get user email + ctx := context.Background() + user, err := h.authService.GetUserByID(ctx, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"}) + return + } + + // Setup 2FA + response, err := h.totpService.Setup2FA(ctx, userID, user.Email) + if err != nil { + switch err { + case services.ErrTOTPAlreadyEnabled: + c.JSON(http.StatusConflict, gin.H{"error": "2FA is already enabled for this account"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to setup 2FA"}) + } + return + } + + c.JSON(http.StatusOK, response) +} + +// Verify2FASetup verifies the 2FA setup with a code +// POST /auth/2fa/verify-setup +func (h *OAuthHandler) Verify2FASetup(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + + var req models.Verify2FARequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + ctx := context.Background() + err = h.totpService.Verify2FASetup(ctx, userID, req.Code) + if err != nil { + switch err { + case services.ErrTOTPAlreadyEnabled: + c.JSON(http.StatusConflict, gin.H{"error": "2FA is already enabled"}) + case services.ErrTOTPInvalidCode: + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify 2FA setup"}) + } + return + } + + c.JSON(http.StatusOK, gin.H{"message": "2FA enabled successfully"}) +} + +// Verify2FAChallenge verifies a 2FA challenge during login +// POST /auth/2fa/verify +func (h *OAuthHandler) Verify2FAChallenge(c *gin.Context) { + var req models.Verify2FAChallengeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + ctx := context.Background() + var userID *uuid.UUID + var err error + + if req.RecoveryCode != "" { + // Verify with recovery code + userID, err = h.totpService.VerifyChallengeWithRecoveryCode(ctx, req.ChallengeID, req.RecoveryCode) + } else { + // Verify with TOTP code + userID, err = h.totpService.VerifyChallenge(ctx, req.ChallengeID, req.Code) + } + + if err != nil { + switch err { + case services.ErrTOTPChallengeExpired: + c.JSON(http.StatusGone, gin.H{"error": "2FA challenge has expired"}) + case services.ErrTOTPInvalidCode: + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"}) + case services.ErrRecoveryCodeInvalid: + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid recovery code"}) + default: + c.JSON(http.StatusBadRequest, gin.H{"error": "2FA verification failed"}) + } + return + } + + // Get user and generate tokens + user, err := h.authService.GetUserByID(ctx, *userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get user"}) + return + } + + // Generate access token + accessToken, err := h.authService.GenerateAccessToken(user) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) + return + } + + // Generate refresh token + refreshToken, refreshTokenHash, err := h.authService.GenerateRefreshToken() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate refresh token"}) + return + } + + // Store session + ipAddress := middleware.GetClientIP(c) + userAgent := middleware.GetUserAgent(c) + + // We need direct DB access for this, or we need to add a method to AuthService + // For now, we'll return the tokens and let the caller handle session storage + c.JSON(http.StatusOK, gin.H{ + "access_token": accessToken, + "refresh_token": refreshToken, + "token_type": "Bearer", + "expires_in": 3600, + "user": map[string]interface{}{ + "id": user.ID, + "email": user.Email, + "name": user.Name, + "role": user.Role, + }, + "_session_hash": refreshTokenHash, + "_ip": ipAddress, + "_user_agent": userAgent, + }) +} + +// Disable2FA disables 2FA for the current user +// POST /auth/2fa/disable +func (h *OAuthHandler) Disable2FA(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + + var req models.Verify2FARequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + ctx := context.Background() + err = h.totpService.Disable2FA(ctx, userID, req.Code) + if err != nil { + switch err { + case services.ErrTOTPNotEnabled: + c.JSON(http.StatusNotFound, gin.H{"error": "2FA is not enabled"}) + case services.ErrTOTPInvalidCode: + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to disable 2FA"}) + } + return + } + + c.JSON(http.StatusOK, gin.H{"message": "2FA disabled successfully"}) +} + +// Get2FAStatus returns the 2FA status for the current user +// GET /auth/2fa/status +func (h *OAuthHandler) Get2FAStatus(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + + ctx := context.Background() + status, err := h.totpService.GetStatus(ctx, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get 2FA status"}) + return + } + + c.JSON(http.StatusOK, status) +} + +// RegenerateRecoveryCodes generates new recovery codes +// POST /auth/2fa/recovery-codes +func (h *OAuthHandler) RegenerateRecoveryCodes(c *gin.Context) { + userID, err := middleware.GetUserID(c) + if err != nil || userID == uuid.Nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"}) + return + } + + var req models.Verify2FARequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + ctx := context.Background() + codes, err := h.totpService.RegenerateRecoveryCodes(ctx, userID, req.Code) + if err != nil { + switch err { + case services.ErrTOTPNotEnabled: + c.JSON(http.StatusNotFound, gin.H{"error": "2FA is not enabled"}) + case services.ErrTOTPInvalidCode: + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid 2FA code"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to regenerate recovery codes"}) + } + return + } + + c.JSON(http.StatusOK, gin.H{"recovery_codes": codes}) +} + +// ======================================== +// Enhanced Login with 2FA +// ======================================== + +// LoginWith2FA handles login with optional 2FA +// POST /auth/login +func (h *OAuthHandler) LoginWith2FA(c *gin.Context) { + var req models.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + ctx := context.Background() + ipAddress := middleware.GetClientIP(c) + userAgent := middleware.GetUserAgent(c) + + // Attempt login + response, err := h.authService.Login(ctx, &req, ipAddress, userAgent) + if err != nil { + switch err { + case services.ErrInvalidCredentials: + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid email or password"}) + case services.ErrAccountLocked: + c.JSON(http.StatusForbidden, gin.H{"error": "Account is temporarily locked"}) + case services.ErrAccountSuspended: + c.JSON(http.StatusForbidden, gin.H{"error": "Account is suspended"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Login failed"}) + } + return + } + + // Check if 2FA is enabled + twoFactorEnabled, _ := h.totpService.IsTwoFactorEnabled(ctx, response.User.ID) + + if twoFactorEnabled { + // Create 2FA challenge + challengeID, err := h.totpService.CreateChallenge(ctx, response.User.ID, ipAddress, userAgent) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create 2FA challenge"}) + return + } + + // Return 2FA required response + c.JSON(http.StatusOK, gin.H{ + "requires_2fa": true, + "challenge_id": challengeID, + "message": "2FA verification required", + }) + return + } + + // No 2FA required, return tokens + c.JSON(http.StatusOK, gin.H{ + "requires_2fa": false, + "access_token": response.AccessToken, + "refresh_token": response.RefreshToken, + "token_type": "Bearer", + "expires_in": response.ExpiresIn, + "user": map[string]interface{}{ + "id": response.User.ID, + "email": response.User.Email, + "name": response.User.Name, + "role": response.User.Role, + }, + }) +} + +// ======================================== +// Registration with mandatory 2FA setup +// ======================================== + +// RegisterWith2FA handles registration with mandatory 2FA setup +// POST /auth/register +func (h *OAuthHandler) RegisterWith2FA(c *gin.Context) { + var req models.RegisterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + ctx := context.Background() + + // Validate password strength + if len(req.Password) < 8 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Password must be at least 8 characters"}) + return + } + + // Register user + user, verificationToken, err := h.authService.Register(ctx, &req) + if err != nil { + switch err { + case services.ErrUserExists: + c.JSON(http.StatusConflict, gin.H{"error": "A user with this email already exists"}) + default: + c.JSON(http.StatusInternalServerError, gin.H{"error": "Registration failed"}) + } + return + } + + // Setup 2FA immediately + twoFAResponse, err := h.totpService.Setup2FA(ctx, user.ID, user.Email) + if err != nil { + // Non-fatal - user can set up 2FA later, but log it + c.JSON(http.StatusCreated, gin.H{ + "message": "Registration successful. Please verify your email.", + "user_id": user.ID, + "verification_token": verificationToken, // In production, this would be sent via email + "two_factor_setup": nil, + "two_factor_error": "Failed to initialize 2FA. Please set it up in your account settings.", + }) + return + } + + c.JSON(http.StatusCreated, gin.H{ + "message": "Registration successful. Please verify your email and complete 2FA setup.", + "user_id": user.ID, + "verification_token": verificationToken, // In production, this would be sent via email + "two_factor_setup": map[string]interface{}{ + "secret": twoFAResponse.Secret, + "qr_code": twoFAResponse.QRCodeDataURL, + "recovery_codes": twoFAResponse.RecoveryCodes, + "setup_required": true, + "setup_endpoint": "/auth/2fa/verify-setup", + }, + }) +} + +// ======================================== +// OAuth Client Management (Admin) +// ======================================== + +// AdminCreateClient creates a new OAuth client +// POST /admin/oauth/clients +func (h *OAuthHandler) AdminCreateClient(c *gin.Context) { + var req struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + RedirectURIs []string `json:"redirect_uris" binding:"required"` + Scopes []string `json:"scopes"` + GrantTypes []string `json:"grant_types"` + IsPublic bool `json:"is_public"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"}) + return + } + + userID, _ := middleware.GetUserID(c) + + // Default scopes + if len(req.Scopes) == 0 { + req.Scopes = []string{"openid", "profile", "email"} + } + + // Default grant types + if len(req.GrantTypes) == 0 { + req.GrantTypes = []string{"authorization_code", "refresh_token"} + } + + ctx := context.Background() + client, clientSecret, err := h.oauthService.CreateClient( + ctx, req.Name, req.Description, req.RedirectURIs, req.Scopes, req.GrantTypes, req.IsPublic, &userID, + ) + + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create client"}) + return + } + + response := gin.H{ + "client_id": client.ClientID, + "name": client.Name, + "redirect_uris": client.RedirectURIs, + "scopes": client.Scopes, + "grant_types": client.GrantTypes, + "is_public": client.IsPublic, + } + + // Only show client_secret once for confidential clients + if !client.IsPublic && clientSecret != "" { + response["client_secret"] = clientSecret + response["client_secret_warning"] = "Store this secret securely. It will not be shown again." + } + + c.JSON(http.StatusCreated, response) +} + +// AdminGetClients lists all OAuth clients +// GET /admin/oauth/clients +func (h *OAuthHandler) AdminGetClients(c *gin.Context) { + // This would need a new method in OAuthService + // For now, return a placeholder + c.JSON(http.StatusOK, gin.H{ + "clients": []interface{}{}, + "message": "Client listing not yet implemented", + }) +} + +// ======================================== +// Helper Functions +// ======================================== + +func redirectWithError(c *gin.Context, redirectURI, errorCode, errorDescription, state string) { + separator := "?" + if strings.Contains(redirectURI, "?") { + separator = "&" + } + + redirectURL := redirectURI + separator + "error=" + errorCode + "&error_description=" + errorDescription + if state != "" { + redirectURL += "&state=" + state + } + + c.Redirect(http.StatusFound, redirectURL) +} diff --git a/consent-service/internal/handlers/school_handlers.go b/consent-service/internal/handlers/school_handlers.go new file mode 100644 index 0000000..aa52167 --- /dev/null +++ b/consent-service/internal/handlers/school_handlers.go @@ -0,0 +1,933 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/breakpilot/consent-service/internal/models" + "github.com/breakpilot/consent-service/internal/services" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// SchoolHandlers contains all school-related HTTP handlers +type SchoolHandlers struct { + schoolService *services.SchoolService + attendanceService *services.AttendanceService + gradeService *services.GradeService +} + +// NewSchoolHandlers creates new school handlers +func NewSchoolHandlers(schoolService *services.SchoolService, attendanceService *services.AttendanceService, gradeService *services.GradeService) *SchoolHandlers { + return &SchoolHandlers{ + schoolService: schoolService, + attendanceService: attendanceService, + gradeService: gradeService, + } +} + +// ======================================== +// School Handlers +// ======================================== + +// CreateSchool creates a new school +// POST /api/v1/schools +func (h *SchoolHandlers) CreateSchool(c *gin.Context) { + var req models.CreateSchoolRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + school, err := h.schoolService.CreateSchool(c.Request.Context(), req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, school) +} + +// GetSchool retrieves a school by ID +// GET /api/v1/schools/:id +func (h *SchoolHandlers) GetSchool(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school ID"}) + return + } + + school, err := h.schoolService.GetSchool(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, school) +} + +// ListSchools lists all schools +// GET /api/v1/schools +func (h *SchoolHandlers) ListSchools(c *gin.Context) { + schools, err := h.schoolService.ListSchools(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, schools) +} + +// ======================================== +// School Year Handlers +// ======================================== + +// CreateSchoolYear creates a new school year +// POST /api/v1/schools/:id/years +func (h *SchoolHandlers) CreateSchoolYear(c *gin.Context) { + schoolIDStr := c.Param("id") + schoolID, err := uuid.Parse(schoolIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school ID"}) + return + } + + var req struct { + Name string `json:"name" binding:"required"` + StartDate string `json:"start_date" binding:"required"` + EndDate string `json:"end_date" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + startDate, err := time.Parse("2006-01-02", req.StartDate) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid start date format"}) + return + } + + endDate, err := time.Parse("2006-01-02", req.EndDate) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid end date format"}) + return + } + + schoolYear, err := h.schoolService.CreateSchoolYear(c.Request.Context(), schoolID, req.Name, startDate, endDate) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, schoolYear) +} + +// SetCurrentSchoolYear sets a school year as current +// PUT /api/v1/schools/:id/years/:yearId/current +func (h *SchoolHandlers) SetCurrentSchoolYear(c *gin.Context) { + schoolIDStr := c.Param("id") + schoolID, err := uuid.Parse(schoolIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school ID"}) + return + } + + yearIDStr := c.Param("yearId") + yearID, err := uuid.Parse(yearIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"}) + return + } + + if err := h.schoolService.SetCurrentSchoolYear(c.Request.Context(), schoolID, yearID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "school year set as current"}) +} + +// ======================================== +// Class Handlers +// ======================================== + +// CreateClass creates a new class +// POST /api/v1/schools/:id/classes +func (h *SchoolHandlers) CreateClass(c *gin.Context) { + schoolIDStr := c.Param("id") + schoolID, err := uuid.Parse(schoolIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school ID"}) + return + } + + var req models.CreateClassRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + class, err := h.schoolService.CreateClass(c.Request.Context(), schoolID, req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, class) +} + +// GetClass retrieves a class by ID +// GET /api/v1/classes/:id +func (h *SchoolHandlers) GetClass(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"}) + return + } + + class, err := h.schoolService.GetClass(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, class) +} + +// ListClasses lists all classes for a school in a school year +// GET /api/v1/schools/:id/classes?school_year_id=... +func (h *SchoolHandlers) ListClasses(c *gin.Context) { + schoolIDStr := c.Param("id") + schoolID, err := uuid.Parse(schoolIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school ID"}) + return + } + + schoolYearIDStr := c.Query("school_year_id") + if schoolYearIDStr == "" { + // Get current school year + schoolYear, err := h.schoolService.GetCurrentSchoolYear(c.Request.Context(), schoolID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "no current school year set"}) + return + } + schoolYearIDStr = schoolYear.ID.String() + } + + schoolYearID, err := uuid.Parse(schoolYearIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"}) + return + } + + classes, err := h.schoolService.ListClasses(c.Request.Context(), schoolID, schoolYearID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, classes) +} + +// ======================================== +// Student Handlers +// ======================================== + +// CreateStudent creates a new student +// POST /api/v1/schools/:id/students +func (h *SchoolHandlers) CreateStudent(c *gin.Context) { + schoolIDStr := c.Param("id") + schoolID, err := uuid.Parse(schoolIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school ID"}) + return + } + + var req models.CreateStudentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + student, err := h.schoolService.CreateStudent(c.Request.Context(), schoolID, req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, student) +} + +// GetStudent retrieves a student by ID +// GET /api/v1/students/:id +func (h *SchoolHandlers) GetStudent(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"}) + return + } + + student, err := h.schoolService.GetStudent(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, student) +} + +// ListStudentsByClass lists all students in a class +// GET /api/v1/classes/:id/students +func (h *SchoolHandlers) ListStudentsByClass(c *gin.Context) { + classIDStr := c.Param("id") + classID, err := uuid.Parse(classIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"}) + return + } + + students, err := h.schoolService.ListStudentsByClass(c.Request.Context(), classID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, students) +} + +// ======================================== +// Subject Handlers +// ======================================== + +// CreateSubject creates a new subject +// POST /api/v1/schools/:id/subjects +func (h *SchoolHandlers) CreateSubject(c *gin.Context) { + schoolIDStr := c.Param("id") + schoolID, err := uuid.Parse(schoolIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school ID"}) + return + } + + var req struct { + Name string `json:"name" binding:"required"` + ShortName string `json:"short_name" binding:"required"` + Color *string `json:"color"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + subject, err := h.schoolService.CreateSubject(c.Request.Context(), schoolID, req.Name, req.ShortName, req.Color) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, subject) +} + +// ListSubjects lists all subjects for a school +// GET /api/v1/schools/:id/subjects +func (h *SchoolHandlers) ListSubjects(c *gin.Context) { + schoolIDStr := c.Param("id") + schoolID, err := uuid.Parse(schoolIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school ID"}) + return + } + + subjects, err := h.schoolService.ListSubjects(c.Request.Context(), schoolID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, subjects) +} + +// ======================================== +// Attendance Handlers +// ======================================== + +// RecordAttendance records attendance for a student +// POST /api/v1/attendance +func (h *SchoolHandlers) RecordAttendance(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + return + } + + var req models.RecordAttendanceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + record, err := h.attendanceService.RecordAttendance(c.Request.Context(), req, userID.(uuid.UUID)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, record) +} + +// RecordBulkAttendance records attendance for multiple students +// POST /api/v1/classes/:id/attendance +func (h *SchoolHandlers) RecordBulkAttendance(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + return + } + + classIDStr := c.Param("id") + classID, err := uuid.Parse(classIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"}) + return + } + + var req struct { + Date string `json:"date" binding:"required"` + SlotID string `json:"slot_id" binding:"required"` + Records []struct { + StudentID string `json:"student_id"` + Status string `json:"status"` + Note *string `json:"note"` + } `json:"records" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + slotID, err := uuid.Parse(req.SlotID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid slot ID"}) + return + } + + // Convert to the expected type (without JSON tags) + records := make([]struct { + StudentID string + Status string + Note *string + }, len(req.Records)) + for i, r := range req.Records { + records[i] = struct { + StudentID string + Status string + Note *string + }{ + StudentID: r.StudentID, + Status: r.Status, + Note: r.Note, + } + } + + err = h.attendanceService.RecordBulkAttendance(c.Request.Context(), classID, req.Date, slotID, records, userID.(uuid.UUID)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "attendance recorded"}) +} + +// GetClassAttendance gets attendance for a class on a specific date +// GET /api/v1/classes/:id/attendance?date=... +func (h *SchoolHandlers) GetClassAttendance(c *gin.Context) { + classIDStr := c.Param("id") + classID, err := uuid.Parse(classIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"}) + return + } + + date := c.Query("date") + if date == "" { + date = time.Now().Format("2006-01-02") + } + + overview, err := h.attendanceService.GetAttendanceByClass(c.Request.Context(), classID, date) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, overview) +} + +// GetStudentAttendance gets attendance history for a student +// GET /api/v1/students/:id/attendance?start_date=...&end_date=... +func (h *SchoolHandlers) GetStudentAttendance(c *gin.Context) { + studentIDStr := c.Param("id") + studentID, err := uuid.Parse(studentIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"}) + return + } + + startDateStr := c.Query("start_date") + endDateStr := c.Query("end_date") + + var startDate, endDate time.Time + if startDateStr == "" { + startDate = time.Now().AddDate(0, -1, 0) // Last month + } else { + startDate, _ = time.Parse("2006-01-02", startDateStr) + } + + if endDateStr == "" { + endDate = time.Now() + } else { + endDate, _ = time.Parse("2006-01-02", endDateStr) + } + + records, err := h.attendanceService.GetStudentAttendance(c.Request.Context(), studentID, startDate, endDate) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, records) +} + +// ======================================== +// Absence Report Handlers +// ======================================== + +// ReportAbsence allows parents to report absence +// POST /api/v1/absence/report +func (h *SchoolHandlers) ReportAbsence(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + return + } + + var req models.ReportAbsenceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + report, err := h.attendanceService.ReportAbsence(c.Request.Context(), req, userID.(uuid.UUID)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, report) +} + +// ConfirmAbsence allows teachers to confirm absence +// PUT /api/v1/absence/:id/confirm +func (h *SchoolHandlers) ConfirmAbsence(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + return + } + + reportIDStr := c.Param("id") + reportID, err := uuid.Parse(reportIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid report ID"}) + return + } + + var req struct { + Status string `json:"status" binding:"required"` // "excused" or "unexcused" + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + err = h.attendanceService.ConfirmAbsence(c.Request.Context(), reportID, userID.(uuid.UUID), req.Status) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "absence confirmed"}) +} + +// GetPendingAbsenceReports gets pending absence reports for a class +// GET /api/v1/classes/:id/absence/pending +func (h *SchoolHandlers) GetPendingAbsenceReports(c *gin.Context) { + classIDStr := c.Param("id") + classID, err := uuid.Parse(classIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"}) + return + } + + reports, err := h.attendanceService.GetPendingAbsenceReports(c.Request.Context(), classID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, reports) +} + +// ======================================== +// Grade Handlers +// ======================================== + +// CreateGrade creates a new grade +// POST /api/v1/grades +func (h *SchoolHandlers) CreateGrade(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + return + } + + var req models.CreateGradeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Get teacher ID from user ID + teacher, err := h.schoolService.GetTeacherByUserID(c.Request.Context(), userID.(uuid.UUID)) + if err != nil { + c.JSON(http.StatusForbidden, gin.H{"error": "user is not a teacher"}) + return + } + + grade, err := h.gradeService.CreateGrade(c.Request.Context(), req, teacher.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, grade) +} + +// GetStudentGrades gets all grades for a student +// GET /api/v1/students/:id/grades?school_year_id=... +func (h *SchoolHandlers) GetStudentGrades(c *gin.Context) { + studentIDStr := c.Param("id") + studentID, err := uuid.Parse(studentIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"}) + return + } + + schoolYearIDStr := c.Query("school_year_id") + if schoolYearIDStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"}) + return + } + + schoolYearID, err := uuid.Parse(schoolYearIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"}) + return + } + + grades, err := h.gradeService.GetStudentGrades(c.Request.Context(), studentID, schoolYearID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, grades) +} + +// GetClassGrades gets grades for all students in a class for a subject (Notenspiegel) +// GET /api/v1/classes/:id/grades/:subjectId?school_year_id=...&semester=... +func (h *SchoolHandlers) GetClassGrades(c *gin.Context) { + classIDStr := c.Param("id") + classID, err := uuid.Parse(classIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"}) + return + } + + subjectIDStr := c.Param("subjectId") + subjectID, err := uuid.Parse(subjectIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subject ID"}) + return + } + + schoolYearIDStr := c.Query("school_year_id") + if schoolYearIDStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"}) + return + } + + schoolYearID, err := uuid.Parse(schoolYearIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"}) + return + } + + semesterStr := c.DefaultQuery("semester", "1") + var semester int + if semesterStr == "1" { + semester = 1 + } else { + semester = 2 + } + + overviews, err := h.gradeService.GetClassGradesBySubject(c.Request.Context(), classID, subjectID, schoolYearID, semester) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, overviews) +} + +// GetGradeStatistics gets grade statistics for a class/subject +// GET /api/v1/classes/:id/grades/:subjectId/stats?school_year_id=...&semester=... +func (h *SchoolHandlers) GetGradeStatistics(c *gin.Context) { + classIDStr := c.Param("id") + classID, err := uuid.Parse(classIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"}) + return + } + + subjectIDStr := c.Param("subjectId") + subjectID, err := uuid.Parse(subjectIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid subject ID"}) + return + } + + schoolYearIDStr := c.Query("school_year_id") + if schoolYearIDStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "school_year_id is required"}) + return + } + + schoolYearID, err := uuid.Parse(schoolYearIDStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school year ID"}) + return + } + + semesterStr := c.DefaultQuery("semester", "1") + var semester int + if semesterStr == "1" { + semester = 1 + } else { + semester = 2 + } + + stats, err := h.gradeService.GetSubjectGradeStatistics(c.Request.Context(), classID, subjectID, schoolYearID, semester) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, stats) +} + +// ======================================== +// Parent Onboarding Handlers +// ======================================== + +// GenerateOnboardingToken generates a QR code token for parent onboarding +// POST /api/v1/onboarding/tokens +func (h *SchoolHandlers) GenerateOnboardingToken(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + return + } + + var req struct { + SchoolID string `json:"school_id" binding:"required"` + ClassID string `json:"class_id" binding:"required"` + StudentID string `json:"student_id" binding:"required"` + Role string `json:"role"` // "parent" or "parent_representative" + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + schoolID, err := uuid.Parse(req.SchoolID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid school ID"}) + return + } + + classID, err := uuid.Parse(req.ClassID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid class ID"}) + return + } + + studentID, err := uuid.Parse(req.StudentID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid student ID"}) + return + } + + role := req.Role + if role == "" { + role = "parent" + } + + token, err := h.schoolService.GenerateParentOnboardingToken(c.Request.Context(), schoolID, classID, studentID, userID.(uuid.UUID), role) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Generate QR code URL + qrURL := "/onboard-parent?token=" + token.Token + + c.JSON(http.StatusCreated, gin.H{ + "token": token.Token, + "qr_url": qrURL, + "expires_at": token.ExpiresAt, + }) +} + +// ValidateOnboardingToken validates an onboarding token +// GET /api/v1/onboarding/validate?token=... +func (h *SchoolHandlers) ValidateOnboardingToken(c *gin.Context) { + token := c.Query("token") + if token == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "token is required"}) + return + } + + onboardingToken, err := h.schoolService.ValidateOnboardingToken(c.Request.Context(), token) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "invalid or expired token"}) + return + } + + // Get student and school info + student, err := h.schoolService.GetStudent(c.Request.Context(), onboardingToken.StudentID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + class, err := h.schoolService.GetClass(c.Request.Context(), onboardingToken.ClassID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + school, err := h.schoolService.GetSchool(c.Request.Context(), onboardingToken.SchoolID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "valid": true, + "role": onboardingToken.Role, + "student_name": student.FirstName + " " + student.LastName, + "class_name": class.Name, + "school_name": school.Name, + "expires_at": onboardingToken.ExpiresAt, + }) +} + +// RedeemOnboardingToken redeems a token and creates parent account +// POST /api/v1/onboarding/redeem +func (h *SchoolHandlers) RedeemOnboardingToken(c *gin.Context) { + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "user not authenticated"}) + return + } + + var req struct { + Token string `json:"token" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + err := h.schoolService.RedeemOnboardingToken(c.Request.Context(), req.Token, userID.(uuid.UUID)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "token redeemed successfully"}) +} + +// ======================================== +// Register Routes +// ======================================== + +// RegisterRoutes registers all school-related routes +func (h *SchoolHandlers) RegisterRoutes(r *gin.RouterGroup, authMiddleware gin.HandlerFunc) { + // Public routes (for onboarding) + r.GET("/onboarding/validate", h.ValidateOnboardingToken) + + // Protected routes + protected := r.Group("") + protected.Use(authMiddleware) + + // Schools + protected.POST("/schools", h.CreateSchool) + protected.GET("/schools", h.ListSchools) + protected.GET("/schools/:id", h.GetSchool) + protected.POST("/schools/:id/years", h.CreateSchoolYear) + protected.PUT("/schools/:id/years/:yearId/current", h.SetCurrentSchoolYear) + protected.POST("/schools/:id/classes", h.CreateClass) + protected.GET("/schools/:id/classes", h.ListClasses) + protected.POST("/schools/:id/students", h.CreateStudent) + protected.POST("/schools/:id/subjects", h.CreateSubject) + protected.GET("/schools/:id/subjects", h.ListSubjects) + + // Classes + protected.GET("/classes/:id", h.GetClass) + protected.GET("/classes/:id/students", h.ListStudentsByClass) + protected.GET("/classes/:id/attendance", h.GetClassAttendance) + protected.POST("/classes/:id/attendance", h.RecordBulkAttendance) + protected.GET("/classes/:id/absence/pending", h.GetPendingAbsenceReports) + protected.GET("/classes/:id/grades/:subjectId", h.GetClassGrades) + protected.GET("/classes/:id/grades/:subjectId/stats", h.GetGradeStatistics) + + // Students + protected.GET("/students/:id", h.GetStudent) + protected.GET("/students/:id/attendance", h.GetStudentAttendance) + protected.GET("/students/:id/grades", h.GetStudentGrades) + + // Attendance & Absence + protected.POST("/attendance", h.RecordAttendance) + protected.POST("/absence/report", h.ReportAbsence) + protected.PUT("/absence/:id/confirm", h.ConfirmAbsence) + + // Grades + protected.POST("/grades", h.CreateGrade) + + // Onboarding + protected.POST("/onboarding/tokens", h.GenerateOnboardingToken) + protected.POST("/onboarding/redeem", h.RedeemOnboardingToken) +} diff --git a/consent-service/internal/middleware/input_gate.go b/consent-service/internal/middleware/input_gate.go new file mode 100644 index 0000000..0d1d348 --- /dev/null +++ b/consent-service/internal/middleware/input_gate.go @@ -0,0 +1,247 @@ +package middleware + +import ( + "net/http" + "os" + "strconv" + "strings" + + "github.com/gin-gonic/gin" +) + +// InputGateConfig holds configuration for input validation. +type InputGateConfig struct { + // Maximum request body size (default: 10MB) + MaxBodySize int64 + + // Maximum file upload size (default: 50MB) + MaxFileSize int64 + + // Allowed content types + AllowedContentTypes map[string]bool + + // Allowed file types for uploads + AllowedFileTypes map[string]bool + + // Blocked file extensions + BlockedExtensions map[string]bool + + // Paths that allow larger uploads + LargeUploadPaths []string + + // Paths excluded from validation + ExcludedPaths []string + + // Enable strict content type checking + StrictContentType bool +} + +// DefaultInputGateConfig returns sensible default configuration. +func DefaultInputGateConfig() InputGateConfig { + maxSize := int64(10 * 1024 * 1024) // 10MB + if envSize := os.Getenv("MAX_REQUEST_BODY_SIZE"); envSize != "" { + if size, err := strconv.ParseInt(envSize, 10, 64); err == nil { + maxSize = size + } + } + + return InputGateConfig{ + MaxBodySize: maxSize, + MaxFileSize: 50 * 1024 * 1024, // 50MB + AllowedContentTypes: map[string]bool{ + "application/json": true, + "application/x-www-form-urlencoded": true, + "multipart/form-data": true, + "text/plain": true, + }, + AllowedFileTypes: map[string]bool{ + "image/jpeg": true, + "image/png": true, + "image/gif": true, + "image/webp": true, + "application/pdf": true, + "text/csv": true, + "application/msword": true, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": true, + "application/vnd.ms-excel": true, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": true, + }, + BlockedExtensions: map[string]bool{ + ".exe": true, ".bat": true, ".cmd": true, ".com": true, ".msi": true, + ".dll": true, ".scr": true, ".pif": true, ".vbs": true, ".js": true, + ".jar": true, ".sh": true, ".ps1": true, ".app": true, + }, + LargeUploadPaths: []string{ + "/api/v1/files/upload", + "/api/v1/documents/upload", + "/api/v1/attachments", + }, + ExcludedPaths: []string{ + "/health", + "/metrics", + "/api/v1/health", + }, + StrictContentType: true, + } +} + +// isExcludedPath checks if path is excluded from validation. +func (c *InputGateConfig) isExcludedPath(path string) bool { + for _, excluded := range c.ExcludedPaths { + if path == excluded { + return true + } + } + return false +} + +// isLargeUploadPath checks if path allows larger uploads. +func (c *InputGateConfig) isLargeUploadPath(path string) bool { + for _, uploadPath := range c.LargeUploadPaths { + if strings.HasPrefix(path, uploadPath) { + return true + } + } + return false +} + +// getMaxSize returns the maximum allowed body size for the path. +func (c *InputGateConfig) getMaxSize(path string) int64 { + if c.isLargeUploadPath(path) { + return c.MaxFileSize + } + return c.MaxBodySize +} + +// validateContentType validates the content type. +func (c *InputGateConfig) validateContentType(contentType string) (bool, string) { + if contentType == "" { + return true, "" + } + + // Extract base content type (remove charset, boundary, etc.) + baseType := strings.Split(contentType, ";")[0] + baseType = strings.TrimSpace(strings.ToLower(baseType)) + + if !c.AllowedContentTypes[baseType] { + return false, "Content-Type '" + baseType + "' is not allowed" + } + + return true, "" +} + +// hasBlockedExtension checks if filename has a blocked extension. +func (c *InputGateConfig) hasBlockedExtension(filename string) bool { + if filename == "" { + return false + } + + lowerFilename := strings.ToLower(filename) + for ext := range c.BlockedExtensions { + if strings.HasSuffix(lowerFilename, ext) { + return true + } + } + return false +} + +// InputGate returns a middleware that validates incoming request bodies. +// +// Usage: +// +// r.Use(middleware.InputGate()) +// +// // Or with custom config: +// config := middleware.DefaultInputGateConfig() +// config.MaxBodySize = 5 * 1024 * 1024 // 5MB +// r.Use(middleware.InputGateWithConfig(config)) +func InputGate() gin.HandlerFunc { + return InputGateWithConfig(DefaultInputGateConfig()) +} + +// InputGateWithConfig returns an input gate middleware with custom configuration. +func InputGateWithConfig(config InputGateConfig) gin.HandlerFunc { + return func(c *gin.Context) { + // Skip excluded paths + if config.isExcludedPath(c.Request.URL.Path) { + c.Next() + return + } + + // Skip validation for GET, HEAD, OPTIONS requests + method := c.Request.Method + if method == "GET" || method == "HEAD" || method == "OPTIONS" { + c.Next() + return + } + + // Validate content type for requests with body + contentType := c.GetHeader("Content-Type") + if config.StrictContentType { + valid, errMsg := config.validateContentType(contentType) + if !valid { + c.AbortWithStatusJSON(http.StatusUnsupportedMediaType, gin.H{ + "error": "unsupported_media_type", + "message": errMsg, + }) + return + } + } + + // Check Content-Length header + contentLength := c.GetHeader("Content-Length") + if contentLength != "" { + length, err := strconv.ParseInt(contentLength, 10, 64) + if err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{ + "error": "invalid_content_length", + "message": "Invalid Content-Length header", + }) + return + } + + maxSize := config.getMaxSize(c.Request.URL.Path) + if length > maxSize { + c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, gin.H{ + "error": "payload_too_large", + "message": "Request body exceeds maximum size", + "max_size": maxSize, + }) + return + } + } + + // Set max multipart memory for file uploads + if strings.Contains(contentType, "multipart/form-data") { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, config.MaxFileSize) + } + + c.Next() + } +} + +// ValidateFileUpload validates a file upload. +// Use this in upload handlers for detailed validation. +func ValidateFileUpload(filename, contentType string, size int64, config *InputGateConfig) (bool, string) { + if config == nil { + defaultConfig := DefaultInputGateConfig() + config = &defaultConfig + } + + // Check size + if size > config.MaxFileSize { + return false, "File size exceeds maximum allowed" + } + + // Check extension + if config.hasBlockedExtension(filename) { + return false, "File extension is not allowed" + } + + // Check content type + if contentType != "" && !config.AllowedFileTypes[contentType] { + return false, "File type '" + contentType + "' is not allowed" + } + + return true, "" +} diff --git a/consent-service/internal/middleware/input_gate_test.go b/consent-service/internal/middleware/input_gate_test.go new file mode 100644 index 0000000..34f5cdd --- /dev/null +++ b/consent-service/internal/middleware/input_gate_test.go @@ -0,0 +1,421 @@ +package middleware + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestInputGate_AllowsGETRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(InputGate()) + + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200 for GET request, got %d", w.Code) + } +} + +func TestInputGate_AllowsHEADRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(InputGate()) + + router.HEAD("/test", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodHead, "/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200 for HEAD request, got %d", w.Code) + } +} + +func TestInputGate_AllowsOPTIONSRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(InputGate()) + + router.OPTIONS("/test", func(c *gin.Context) { + c.Status(http.StatusOK) + }) + + req := httptest.NewRequest(http.MethodOptions, "/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200 for OPTIONS request, got %d", w.Code) + } +} + +func TestInputGate_AllowsValidJSONRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(InputGate()) + + router.POST("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + body := bytes.NewBufferString(`{"key": "value"}`) + req := httptest.NewRequest(http.MethodPost, "/test", body) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Length", "16") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200 for valid JSON, got %d", w.Code) + } +} + +func TestInputGate_RejectsInvalidContentType(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + config := DefaultInputGateConfig() + config.StrictContentType = true + router.Use(InputGateWithConfig(config)) + + router.POST("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + body := bytes.NewBufferString(`data`) + req := httptest.NewRequest(http.MethodPost, "/test", body) + req.Header.Set("Content-Type", "application/xml") + req.Header.Set("Content-Length", "4") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnsupportedMediaType { + t.Errorf("Expected status 415 for invalid content type, got %d", w.Code) + } +} + +func TestInputGate_AllowsEmptyContentType(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(InputGate()) + + router.POST("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + body := bytes.NewBufferString(`data`) + req := httptest.NewRequest(http.MethodPost, "/test", body) + // No Content-Type header + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200 for empty content type, got %d", w.Code) + } +} + +func TestInputGate_RejectsOversizedRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + config := DefaultInputGateConfig() + config.MaxBodySize = 100 // 100 bytes + router.Use(InputGateWithConfig(config)) + + router.POST("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + // Create a body larger than 100 bytes + largeBody := strings.Repeat("x", 200) + body := bytes.NewBufferString(largeBody) + req := httptest.NewRequest(http.MethodPost, "/test", body) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Length", "200") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusRequestEntityTooLarge { + t.Errorf("Expected status 413 for oversized request, got %d", w.Code) + } +} + +func TestInputGate_AllowsLargeUploadPath(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + config := DefaultInputGateConfig() + config.MaxBodySize = 100 // 100 bytes + config.MaxFileSize = 1000 // 1000 bytes + config.LargeUploadPaths = []string{"/api/v1/files/upload"} + router.Use(InputGateWithConfig(config)) + + router.POST("/api/v1/files/upload", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + // Create a body larger than MaxBodySize but smaller than MaxFileSize + largeBody := strings.Repeat("x", 500) + body := bytes.NewBufferString(largeBody) + req := httptest.NewRequest(http.MethodPost, "/api/v1/files/upload", body) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Length", "500") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200 for large upload path, got %d", w.Code) + } +} + +func TestInputGate_ExcludedPaths(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + config := DefaultInputGateConfig() + config.MaxBodySize = 10 // Very small + config.ExcludedPaths = []string{"/health"} + router.Use(InputGateWithConfig(config)) + + router.POST("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "healthy"}) + }) + + // Send oversized body to excluded path + largeBody := strings.Repeat("x", 100) + body := bytes.NewBufferString(largeBody) + req := httptest.NewRequest(http.MethodPost, "/health", body) + req.Header.Set("Content-Length", "100") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Should pass because path is excluded + if w.Code != http.StatusOK { + t.Errorf("Expected status 200 for excluded path, got %d", w.Code) + } +} + +func TestInputGate_RejectsInvalidContentLength(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(InputGate()) + + router.POST("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + body := bytes.NewBufferString(`data`) + req := httptest.NewRequest(http.MethodPost, "/test", body) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Length", "invalid") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400 for invalid content length, got %d", w.Code) + } +} + +func TestValidateFileUpload_BlockedExtension(t *testing.T) { + tests := []struct { + filename string + contentType string + blocked bool + }{ + {"malware.exe", "application/octet-stream", true}, + {"script.bat", "application/octet-stream", true}, + {"hack.cmd", "application/octet-stream", true}, + {"shell.sh", "application/octet-stream", true}, + {"powershell.ps1", "application/octet-stream", true}, + {"document.pdf", "application/pdf", false}, + {"image.jpg", "image/jpeg", false}, + {"data.csv", "text/csv", false}, + } + + for _, tt := range tests { + valid, errMsg := ValidateFileUpload(tt.filename, tt.contentType, 100, nil) + if tt.blocked && valid { + t.Errorf("File %s should be blocked", tt.filename) + } + if !tt.blocked && !valid { + t.Errorf("File %s should not be blocked, error: %s", tt.filename, errMsg) + } + } +} + +func TestValidateFileUpload_OversizedFile(t *testing.T) { + config := DefaultInputGateConfig() + config.MaxFileSize = 1000 // 1KB + + valid, errMsg := ValidateFileUpload("test.pdf", "application/pdf", 2000, &config) + + if valid { + t.Error("Should reject oversized file") + } + if !strings.Contains(errMsg, "size") { + t.Errorf("Error message should mention size, got: %s", errMsg) + } +} + +func TestValidateFileUpload_ValidFile(t *testing.T) { + config := DefaultInputGateConfig() + + valid, errMsg := ValidateFileUpload("document.pdf", "application/pdf", 1000, &config) + + if !valid { + t.Errorf("Should accept valid file, got error: %s", errMsg) + } +} + +func TestValidateFileUpload_InvalidContentType(t *testing.T) { + config := DefaultInputGateConfig() + + valid, errMsg := ValidateFileUpload("file.xyz", "application/x-unknown", 100, &config) + + if valid { + t.Error("Should reject unknown file type") + } + if !strings.Contains(errMsg, "not allowed") { + t.Errorf("Error message should mention not allowed, got: %s", errMsg) + } +} + +func TestValidateFileUpload_NilConfig(t *testing.T) { + // Should use default config when nil is passed + valid, _ := ValidateFileUpload("document.pdf", "application/pdf", 1000, nil) + + if !valid { + t.Error("Should accept valid file with nil config (uses defaults)") + } +} + +func TestHasBlockedExtension(t *testing.T) { + config := DefaultInputGateConfig() + + tests := []struct { + filename string + blocked bool + }{ + {"test.exe", true}, + {"TEST.EXE", true}, // Case insensitive + {"script.BAT", true}, + {"app.APP", true}, + {"document.pdf", false}, + {"image.png", false}, + {"", false}, + } + + for _, tt := range tests { + result := config.hasBlockedExtension(tt.filename) + if result != tt.blocked { + t.Errorf("File %s: expected blocked=%v, got %v", tt.filename, tt.blocked, result) + } + } +} + +func TestValidateContentType(t *testing.T) { + config := DefaultInputGateConfig() + + tests := []struct { + contentType string + valid bool + }{ + {"application/json", true}, + {"application/json; charset=utf-8", true}, + {"APPLICATION/JSON", true}, // Case insensitive + {"multipart/form-data; boundary=----WebKitFormBoundary", true}, + {"text/plain", true}, + {"application/xml", false}, + {"text/html", false}, + {"", true}, // Empty is allowed + } + + for _, tt := range tests { + valid, _ := config.validateContentType(tt.contentType) + if valid != tt.valid { + t.Errorf("Content-Type %q: expected valid=%v, got %v", tt.contentType, tt.valid, valid) + } + } +} + +func TestIsLargeUploadPath(t *testing.T) { + config := DefaultInputGateConfig() + config.LargeUploadPaths = []string{"/api/v1/files/upload", "/api/v1/documents"} + + tests := []struct { + path string + isLarge bool + }{ + {"/api/v1/files/upload", true}, + {"/api/v1/files/upload/batch", true}, // Prefix match + {"/api/v1/documents", true}, + {"/api/v1/documents/1/attachments", true}, + {"/api/v1/users", false}, + {"/health", false}, + } + + for _, tt := range tests { + result := config.isLargeUploadPath(tt.path) + if result != tt.isLarge { + t.Errorf("Path %s: expected isLarge=%v, got %v", tt.path, tt.isLarge, result) + } + } +} + +func TestGetMaxSize(t *testing.T) { + config := DefaultInputGateConfig() + config.MaxBodySize = 100 + config.MaxFileSize = 1000 + config.LargeUploadPaths = []string{"/api/v1/files/upload"} + + tests := []struct { + path string + expected int64 + }{ + {"/api/test", 100}, + {"/api/v1/files/upload", 1000}, + {"/health", 100}, + } + + for _, tt := range tests { + result := config.getMaxSize(tt.path) + if result != tt.expected { + t.Errorf("Path %s: expected maxSize=%d, got %d", tt.path, tt.expected, result) + } + } +} + +func TestInputGate_DefaultMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(InputGate()) + + router.POST("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + body := bytes.NewBufferString(`{"key": "value"}`) + req := httptest.NewRequest(http.MethodPost, "/test", body) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } +} diff --git a/consent-service/internal/middleware/middleware.go b/consent-service/internal/middleware/middleware.go new file mode 100644 index 0000000..4a4d6b0 --- /dev/null +++ b/consent-service/internal/middleware/middleware.go @@ -0,0 +1,379 @@ +package middleware + +import ( + "net/http" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +// UserClaims represents the JWT claims for a user +type UserClaims struct { + UserID string `json:"user_id"` + Email string `json:"email"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +// CORS returns a CORS middleware +func CORS() gin.HandlerFunc { + return func(c *gin.Context) { + origin := c.Request.Header.Get("Origin") + + // Allow localhost for development + allowedOrigins := []string{ + "http://localhost:3000", + "http://localhost:8000", + "http://localhost:8080", + "https://breakpilot.app", + } + + allowed := false + for _, o := range allowedOrigins { + if origin == o { + allowed = true + break + } + } + + if allowed { + c.Header("Access-Control-Allow-Origin", origin) + } + + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization, X-Requested-With") + c.Header("Access-Control-Allow-Credentials", "true") + c.Header("Access-Control-Max-Age", "86400") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(http.StatusNoContent) + return + } + + c.Next() + } +} + +// RequestLogger logs each request +func RequestLogger() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + path := c.Request.URL.Path + method := c.Request.Method + + c.Next() + + latency := time.Since(start) + status := c.Writer.Status() + + // Log only in development or for errors + if status >= 400 { + gin.DefaultWriter.Write([]byte( + method + " " + path + " " + + string(rune(status)) + " " + + latency.String() + "\n", + )) + } + } +} + +// RateLimiter implements a simple in-memory rate limiter +// Configurable via RATE_LIMIT_PER_MINUTE env var (default: 500) +func RateLimiter() gin.HandlerFunc { + type client struct { + count int + lastSeen time.Time + } + + var ( + mu sync.Mutex + clients = make(map[string]*client) + ) + + // Clean up old entries periodically + go func() { + for { + time.Sleep(time.Minute) + mu.Lock() + for ip, c := range clients { + if time.Since(c.lastSeen) > time.Minute { + delete(clients, ip) + } + } + mu.Unlock() + } + }() + + return func(c *gin.Context) { + ip := c.ClientIP() + + // Skip rate limiting for Docker internal network (172.x.x.x) and localhost + // This prevents issues when multiple services share the same internal IP + if strings.HasPrefix(ip, "172.") || ip == "127.0.0.1" || ip == "::1" { + c.Next() + return + } + + mu.Lock() + defer mu.Unlock() + + if _, exists := clients[ip]; !exists { + clients[ip] = &client{} + } + + cli := clients[ip] + + // Reset count if more than a minute has passed + if time.Since(cli.lastSeen) > time.Minute { + cli.count = 0 + } + + cli.count++ + cli.lastSeen = time.Now() + + // Allow 500 requests per minute (increased for admin panels with many API calls) + if cli.count > 500 { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ + "error": "rate_limit_exceeded", + "message": "Too many requests. Please try again later.", + }) + return + } + + c.Next() + } +} + +// AuthMiddleware validates JWT tokens +func AuthMiddleware(jwtSecret string) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "missing_authorization", + "message": "Authorization header is required", + }) + return + } + + // Extract token from "Bearer " + parts := strings.Split(authHeader, " ") + if len(parts) != 2 || parts[0] != "Bearer" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "invalid_authorization", + "message": "Authorization header must be in format: Bearer ", + }) + return + } + + tokenString := parts[1] + + // Parse and validate token + token, err := jwt.ParseWithClaims(tokenString, &UserClaims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(jwtSecret), nil + }) + + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "invalid_token", + "message": "Invalid or expired token", + }) + return + } + + if claims, ok := token.Claims.(*UserClaims); ok && token.Valid { + // Set user info in context + c.Set("user_id", claims.UserID) + c.Set("email", claims.Email) + c.Set("role", claims.Role) + c.Next() + } else { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "invalid_claims", + "message": "Invalid token claims", + }) + return + } + } +} + +// AdminOnly ensures only admin users can access the route +func AdminOnly() gin.HandlerFunc { + return func(c *gin.Context) { + role, exists := c.Get("role") + if !exists { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "unauthorized", + "message": "User role not found", + }) + return + } + + roleStr, ok := role.(string) + if !ok || (roleStr != "admin" && roleStr != "super_admin" && roleStr != "data_protection_officer") { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "forbidden", + "message": "Admin access required", + }) + return + } + + c.Next() + } +} + +// DSBOnly ensures only Data Protection Officers can access the route +// Used for critical operations like publishing legal documents (four-eyes principle) +func DSBOnly() gin.HandlerFunc { + return func(c *gin.Context) { + role, exists := c.Get("role") + if !exists { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "unauthorized", + "message": "User role not found", + }) + return + } + + roleStr, ok := role.(string) + if !ok || (roleStr != "data_protection_officer" && roleStr != "super_admin") { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "forbidden", + "message": "Only Data Protection Officers can perform this action", + }) + return + } + + c.Next() + } +} + +// IsAdmin checks if the user has admin role +func IsAdmin(c *gin.Context) bool { + role, exists := c.Get("role") + if !exists { + return false + } + roleStr, ok := role.(string) + return ok && (roleStr == "admin" || roleStr == "super_admin" || roleStr == "data_protection_officer") +} + +// IsDSB checks if the user has DSB role +func IsDSB(c *gin.Context) bool { + role, exists := c.Get("role") + if !exists { + return false + } + roleStr, ok := role.(string) + return ok && (roleStr == "data_protection_officer" || roleStr == "super_admin") +} + +// GetUserID extracts the user ID from the context +func GetUserID(c *gin.Context) (uuid.UUID, error) { + userIDStr, exists := c.Get("user_id") + if !exists { + return uuid.Nil, nil + } + + userID, err := uuid.Parse(userIDStr.(string)) + if err != nil { + return uuid.Nil, err + } + + return userID, nil +} + +// GetClientIP returns the client's IP address +func GetClientIP(c *gin.Context) string { + // Check X-Forwarded-For header first (for proxied requests) + if xff := c.GetHeader("X-Forwarded-For"); xff != "" { + ips := strings.Split(xff, ",") + return strings.TrimSpace(ips[0]) + } + + // Check X-Real-IP header + if xri := c.GetHeader("X-Real-IP"); xri != "" { + return xri + } + + return c.ClientIP() +} + +// GetUserAgent returns the client's User-Agent +func GetUserAgent(c *gin.Context) string { + return c.GetHeader("User-Agent") +} + +// SuspensionCheckMiddleware checks if a user is suspended and restricts access +// Suspended users can only access consent-related endpoints +func SuspensionCheckMiddleware(pool interface{ QueryRow(ctx interface{}, sql string, args ...interface{}) interface{ Scan(dest ...interface{}) error } }) gin.HandlerFunc { + return func(c *gin.Context) { + userIDStr, exists := c.Get("user_id") + if !exists { + c.Next() + return + } + + userID, err := uuid.Parse(userIDStr.(string)) + if err != nil { + c.Next() + return + } + + // Check user account status + var accountStatus string + err = pool.QueryRow(c.Request.Context(), `SELECT account_status FROM users WHERE id = $1`, userID).Scan(&accountStatus) + if err != nil { + c.Next() + return + } + + if accountStatus == "suspended" { + // Check if current path is allowed for suspended users + path := c.Request.URL.Path + allowedPaths := []string{ + "/api/v1/consent", + "/api/v1/documents", + "/api/v1/notifications", + "/api/v1/profile", + "/api/v1/privacy/my-data", + "/api/v1/auth/logout", + } + + allowed := false + for _, p := range allowedPaths { + if strings.HasPrefix(path, p) { + allowed = true + break + } + } + + if !allowed { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "account_suspended", + "message": "Your account is suspended due to pending consent requirements", + "redirect": "/consent/pending", + }) + return + } + + // Set suspended flag in context for handlers to use + c.Set("account_suspended", true) + } + + c.Next() + } +} + +// IsSuspended checks if the current user's account is suspended +func IsSuspended(c *gin.Context) bool { + suspended, exists := c.Get("account_suspended") + if !exists { + return false + } + return suspended.(bool) +} diff --git a/consent-service/internal/middleware/middleware_test.go b/consent-service/internal/middleware/middleware_test.go new file mode 100644 index 0000000..f1961fe --- /dev/null +++ b/consent-service/internal/middleware/middleware_test.go @@ -0,0 +1,546 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +// Helper to create a valid JWT token for testing +func createTestToken(secret string, userID, email, role string, exp time.Time) string { + claims := UserClaims{ + UserID: userID, + Email: email, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(exp), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, _ := token.SignedString([]byte(secret)) + return tokenString +} + +// TestCORS tests the CORS middleware +func TestCORS(t *testing.T) { + router := gin.New() + router.Use(CORS()) + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"success": true}) + }) + + tests := []struct { + name string + origin string + method string + expectedStatus int + expectAllowedOrigin bool + }{ + {"localhost:3000", "http://localhost:3000", "GET", http.StatusOK, true}, + {"localhost:8000", "http://localhost:8000", "GET", http.StatusOK, true}, + {"production", "https://breakpilot.app", "GET", http.StatusOK, true}, + {"unknown origin", "https://unknown.com", "GET", http.StatusOK, false}, + {"preflight", "http://localhost:3000", "OPTIONS", http.StatusNoContent, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, _ := http.NewRequest(tt.method, "/test", nil) + req.Header.Set("Origin", tt.origin) + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != tt.expectedStatus { + t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code) + } + + allowedOrigin := w.Header().Get("Access-Control-Allow-Origin") + if tt.expectAllowedOrigin && allowedOrigin != tt.origin { + t.Errorf("Expected Access-Control-Allow-Origin to be %s, got %s", tt.origin, allowedOrigin) + } + if !tt.expectAllowedOrigin && allowedOrigin != "" { + t.Errorf("Expected no Access-Control-Allow-Origin header, got %s", allowedOrigin) + } + }) + } +} + +// TestCORSHeaders tests that CORS headers are set correctly +func TestCORSHeaders(t *testing.T) { + router := gin.New() + router.Use(CORS()) + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{}) + }) + + req, _ := http.NewRequest("GET", "/test", nil) + req.Header.Set("Origin", "http://localhost:3000") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + expectedHeaders := map[string]string{ + "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Origin, Content-Type, Authorization, X-Requested-With", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Max-Age": "86400", + } + + for header, expected := range expectedHeaders { + actual := w.Header().Get(header) + if actual != expected { + t.Errorf("Expected %s to be %s, got %s", header, expected, actual) + } + } +} + +// TestAuthMiddleware_ValidToken tests authentication with valid token +func TestAuthMiddleware_ValidToken(t *testing.T) { + secret := "test-secret-key" + userID := uuid.New().String() + email := "test@example.com" + role := "user" + + router := gin.New() + router.Use(AuthMiddleware(secret)) + router.GET("/protected", func(c *gin.Context) { + uid, _ := c.Get("user_id") + em, _ := c.Get("email") + r, _ := c.Get("role") + + c.JSON(http.StatusOK, gin.H{ + "user_id": uid, + "email": em, + "role": r, + }) + }) + + token := createTestToken(secret, userID, email, role, time.Now().Add(time.Hour)) + + req, _ := http.NewRequest("GET", "/protected", nil) + req.Header.Set("Authorization", "Bearer "+token) + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } +} + +// TestAuthMiddleware_MissingHeader tests authentication without header +func TestAuthMiddleware_MissingHeader(t *testing.T) { + router := gin.New() + router.Use(AuthMiddleware("test-secret")) + router.GET("/protected", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{}) + }) + + req, _ := http.NewRequest("GET", "/protected", nil) + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code) + } +} + +// TestAuthMiddleware_InvalidFormat tests authentication with invalid header format +func TestAuthMiddleware_InvalidFormat(t *testing.T) { + router := gin.New() + router.Use(AuthMiddleware("test-secret")) + router.GET("/protected", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{}) + }) + + tests := []struct { + name string + header string + }{ + {"no Bearer prefix", "some-token"}, + {"Basic auth", "Basic dXNlcjpwYXNz"}, + {"empty Bearer", "Bearer "}, + {"multiple spaces", "Bearer token"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req, _ := http.NewRequest("GET", "/protected", nil) + req.Header.Set("Authorization", tt.header) + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code) + } + }) + } +} + +// TestAuthMiddleware_ExpiredToken tests authentication with expired token +func TestAuthMiddleware_ExpiredToken(t *testing.T) { + secret := "test-secret" + + router := gin.New() + router.Use(AuthMiddleware(secret)) + router.GET("/protected", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{}) + }) + + // Create expired token + token := createTestToken(secret, "user-123", "test@example.com", "user", time.Now().Add(-time.Hour)) + + req, _ := http.NewRequest("GET", "/protected", nil) + req.Header.Set("Authorization", "Bearer "+token) + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code) + } +} + +// TestAuthMiddleware_WrongSecret tests authentication with wrong secret +func TestAuthMiddleware_WrongSecret(t *testing.T) { + router := gin.New() + router.Use(AuthMiddleware("correct-secret")) + router.GET("/protected", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{}) + }) + + // Create token with different secret + token := createTestToken("wrong-secret", "user-123", "test@example.com", "user", time.Now().Add(time.Hour)) + + req, _ := http.NewRequest("GET", "/protected", nil) + req.Header.Set("Authorization", "Bearer "+token) + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code) + } +} + +// TestAdminOnly tests the AdminOnly middleware +func TestAdminOnly(t *testing.T) { + tests := []struct { + name string + role string + expectedStatus int + }{ + {"admin allowed", "admin", http.StatusOK}, + {"super_admin allowed", "super_admin", http.StatusOK}, + {"dpo allowed", "data_protection_officer", http.StatusOK}, + {"user forbidden", "user", http.StatusForbidden}, + {"empty role forbidden", "", http.StatusForbidden}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", tt.role) + c.Next() + }) + router.Use(AdminOnly()) + router.GET("/admin", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{}) + }) + + req, _ := http.NewRequest("GET", "/admin", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != tt.expectedStatus { + t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code) + } + }) + } +} + +// TestAdminOnly_NoRole tests AdminOnly when role is not set +func TestAdminOnly_NoRole(t *testing.T) { + router := gin.New() + router.Use(AdminOnly()) + router.GET("/admin", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{}) + }) + + req, _ := http.NewRequest("GET", "/admin", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status %d, got %d", http.StatusUnauthorized, w.Code) + } +} + +// TestDSBOnly tests the DSBOnly middleware +func TestDSBOnly(t *testing.T) { + tests := []struct { + name string + role string + expectedStatus int + }{ + {"dpo allowed", "data_protection_officer", http.StatusOK}, + {"super_admin allowed", "super_admin", http.StatusOK}, + {"admin forbidden", "admin", http.StatusForbidden}, + {"user forbidden", "user", http.StatusForbidden}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + router := gin.New() + router.Use(func(c *gin.Context) { + c.Set("role", tt.role) + c.Next() + }) + router.Use(DSBOnly()) + router.GET("/dsb", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{}) + }) + + req, _ := http.NewRequest("GET", "/dsb", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != tt.expectedStatus { + t.Errorf("Expected status %d, got %d", tt.expectedStatus, w.Code) + } + }) + } +} + +// TestIsAdmin tests the IsAdmin helper function +func TestIsAdmin(t *testing.T) { + tests := []struct { + name string + role string + expected bool + }{ + {"admin", "admin", true}, + {"super_admin", "super_admin", true}, + {"dpo", "data_protection_officer", true}, + {"user", "user", false}, + {"empty", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + if tt.role != "" { + c.Set("role", tt.role) + } + + result := IsAdmin(c) + if result != tt.expected { + t.Errorf("Expected IsAdmin to be %v, got %v", tt.expected, result) + } + }) + } +} + +// TestIsDSB tests the IsDSB helper function +func TestIsDSB(t *testing.T) { + tests := []struct { + name string + role string + expected bool + }{ + {"dpo", "data_protection_officer", true}, + {"super_admin", "super_admin", true}, + {"admin", "admin", false}, + {"user", "user", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("role", tt.role) + + result := IsDSB(c) + if result != tt.expected { + t.Errorf("Expected IsDSB to be %v, got %v", tt.expected, result) + } + }) + } +} + +// TestGetUserID tests the GetUserID helper function +func TestGetUserID(t *testing.T) { + validUUID := uuid.New() + + tests := []struct { + name string + userID string + setUserID bool + expectError bool + expectedID uuid.UUID + }{ + {"valid UUID", validUUID.String(), true, false, validUUID}, + {"invalid UUID", "not-a-uuid", true, true, uuid.Nil}, + {"missing user_id", "", false, false, uuid.Nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + if tt.setUserID { + c.Set("user_id", tt.userID) + } + + result, err := GetUserID(c) + + if tt.expectError && err == nil { + t.Error("Expected error but got none") + } + + if !tt.expectError && result != tt.expectedID { + t.Errorf("Expected %v, got %v", tt.expectedID, result) + } + }) + } +} + +// TestGetClientIP tests the GetClientIP helper function +func TestGetClientIP(t *testing.T) { + tests := []struct { + name string + xff string + xri string + clientIP string + expectedIP string + }{ + {"X-Forwarded-For", "10.0.0.1", "", "192.168.1.1", "10.0.0.1"}, + {"X-Forwarded-For multiple", "10.0.0.1, 10.0.0.2", "", "192.168.1.1", "10.0.0.1"}, + {"X-Real-IP", "", "10.0.0.1", "192.168.1.1", "10.0.0.1"}, + {"direct", "", "", "192.168.1.1", "192.168.1.1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + c.Request, _ = http.NewRequest("GET", "/", nil) + if tt.xff != "" { + c.Request.Header.Set("X-Forwarded-For", tt.xff) + } + if tt.xri != "" { + c.Request.Header.Set("X-Real-IP", tt.xri) + } + c.Request.RemoteAddr = tt.clientIP + ":12345" + + result := GetClientIP(c) + + // Note: gin.ClientIP() might return different values + // depending on trusted proxies config + if result != tt.expectedIP && result != tt.clientIP { + t.Logf("Note: GetClientIP returned %s (expected %s or %s)", result, tt.expectedIP, tt.clientIP) + } + }) + } +} + +// TestGetUserAgent tests the GetUserAgent helper function +func TestGetUserAgent(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request, _ = http.NewRequest("GET", "/", nil) + + expectedUA := "Mozilla/5.0 (Test)" + c.Request.Header.Set("User-Agent", expectedUA) + + result := GetUserAgent(c) + if result != expectedUA { + t.Errorf("Expected %s, got %s", expectedUA, result) + } +} + +// TestIsSuspended tests the IsSuspended helper function +func TestIsSuspended(t *testing.T) { + tests := []struct { + name string + suspended interface{} + setSuspended bool + expected bool + }{ + {"suspended true", true, true, true}, + {"suspended false", false, true, false}, + {"not set", nil, false, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + if tt.setSuspended { + c.Set("account_suspended", tt.suspended) + } + + result := IsSuspended(c) + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} + +// BenchmarkCORS benchmarks the CORS middleware +func BenchmarkCORS(b *testing.B) { + router := gin.New() + router.Use(CORS()) + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{}) + }) + + req, _ := http.NewRequest("GET", "/test", nil) + req.Header.Set("Origin", "http://localhost:3000") + + for i := 0; i < b.N; i++ { + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + } +} + +// BenchmarkAuthMiddleware benchmarks the auth middleware +func BenchmarkAuthMiddleware(b *testing.B) { + secret := "test-secret-key" + token := createTestToken(secret, uuid.New().String(), "test@example.com", "user", time.Now().Add(time.Hour)) + + router := gin.New() + router.Use(AuthMiddleware(secret)) + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{}) + }) + + req, _ := http.NewRequest("GET", "/test", nil) + req.Header.Set("Authorization", "Bearer "+token) + + for i := 0; i < b.N; i++ { + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + } +} diff --git a/consent-service/internal/middleware/pii_redactor.go b/consent-service/internal/middleware/pii_redactor.go new file mode 100644 index 0000000..bf060e4 --- /dev/null +++ b/consent-service/internal/middleware/pii_redactor.go @@ -0,0 +1,197 @@ +package middleware + +import ( + "regexp" + "strings" +) + +// PIIPattern defines a pattern for identifying PII. +type PIIPattern struct { + Name string + Pattern *regexp.Regexp + Replacement string +} + +// PIIRedactor redacts personally identifiable information from strings. +type PIIRedactor struct { + patterns []*PIIPattern +} + +// Pre-compiled patterns for common PII types +var ( + emailPattern = regexp.MustCompile(`\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b`) + ipv4Pattern = regexp.MustCompile(`\b(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b`) + ipv6Pattern = regexp.MustCompile(`\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b`) + phonePattern = regexp.MustCompile(`(?:\+49|0049)[\s.-]?\d{2,4}[\s.-]?\d{3,8}|\b0\d{2,4}[\s.-]?\d{3,8}\b`) + ibanPattern = regexp.MustCompile(`(?i)\b[A-Z]{2}\d{2}[\s]?(?:\d{4}[\s]?){3,5}\d{1,4}\b`) + uuidPattern = regexp.MustCompile(`(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b`) + namePattern = regexp.MustCompile(`\b(?:Herr|Frau|Hr\.|Fr\.)\s+[A-ZÄÖÜ][a-zäöüß]+(?:\s+[A-ZÄÖÜ][a-zäöüß]+)?\b`) +) + +// DefaultPIIPatterns returns the default set of PII patterns. +func DefaultPIIPatterns() []*PIIPattern { + return []*PIIPattern{ + {Name: "email", Pattern: emailPattern, Replacement: "[EMAIL_REDACTED]"}, + {Name: "ip_v4", Pattern: ipv4Pattern, Replacement: "[IP_REDACTED]"}, + {Name: "ip_v6", Pattern: ipv6Pattern, Replacement: "[IP_REDACTED]"}, + {Name: "phone", Pattern: phonePattern, Replacement: "[PHONE_REDACTED]"}, + } +} + +// AllPIIPatterns returns all available PII patterns. +func AllPIIPatterns() []*PIIPattern { + return []*PIIPattern{ + {Name: "email", Pattern: emailPattern, Replacement: "[EMAIL_REDACTED]"}, + {Name: "ip_v4", Pattern: ipv4Pattern, Replacement: "[IP_REDACTED]"}, + {Name: "ip_v6", Pattern: ipv6Pattern, Replacement: "[IP_REDACTED]"}, + {Name: "phone", Pattern: phonePattern, Replacement: "[PHONE_REDACTED]"}, + {Name: "iban", Pattern: ibanPattern, Replacement: "[IBAN_REDACTED]"}, + {Name: "uuid", Pattern: uuidPattern, Replacement: "[UUID_REDACTED]"}, + {Name: "name", Pattern: namePattern, Replacement: "[NAME_REDACTED]"}, + } +} + +// NewPIIRedactor creates a new PII redactor with the given patterns. +func NewPIIRedactor(patterns []*PIIPattern) *PIIRedactor { + if patterns == nil { + patterns = DefaultPIIPatterns() + } + return &PIIRedactor{patterns: patterns} +} + +// NewDefaultPIIRedactor creates a PII redactor with default patterns. +func NewDefaultPIIRedactor() *PIIRedactor { + return NewPIIRedactor(DefaultPIIPatterns()) +} + +// Redact removes PII from the given text. +func (r *PIIRedactor) Redact(text string) string { + if text == "" { + return text + } + + result := text + for _, pattern := range r.patterns { + result = pattern.Pattern.ReplaceAllString(result, pattern.Replacement) + } + return result +} + +// ContainsPII checks if the text contains any PII. +func (r *PIIRedactor) ContainsPII(text string) bool { + if text == "" { + return false + } + + for _, pattern := range r.patterns { + if pattern.Pattern.MatchString(text) { + return true + } + } + return false +} + +// PIIFinding represents a found PII instance. +type PIIFinding struct { + Type string + Match string + Start int + End int +} + +// FindPII finds all PII in the text. +func (r *PIIRedactor) FindPII(text string) []PIIFinding { + if text == "" { + return nil + } + + var findings []PIIFinding + for _, pattern := range r.patterns { + matches := pattern.Pattern.FindAllStringIndex(text, -1) + for _, match := range matches { + findings = append(findings, PIIFinding{ + Type: pattern.Name, + Match: text[match[0]:match[1]], + Start: match[0], + End: match[1], + }) + } + } + return findings +} + +// Default module-level redactor +var defaultRedactor = NewDefaultPIIRedactor() + +// RedactPII is a convenience function that uses the default redactor. +func RedactPII(text string) string { + return defaultRedactor.Redact(text) +} + +// ContainsPIIDefault checks if text contains PII using default patterns. +func ContainsPIIDefault(text string) bool { + return defaultRedactor.ContainsPII(text) +} + +// RedactMap redacts PII from all string values in a map. +func RedactMap(data map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}) + for key, value := range data { + switch v := value.(type) { + case string: + result[key] = RedactPII(v) + case map[string]interface{}: + result[key] = RedactMap(v) + case []interface{}: + result[key] = redactSlice(v) + default: + result[key] = v + } + } + return result +} + +func redactSlice(data []interface{}) []interface{} { + result := make([]interface{}, len(data)) + for i, value := range data { + switch v := value.(type) { + case string: + result[i] = RedactPII(v) + case map[string]interface{}: + result[i] = RedactMap(v) + case []interface{}: + result[i] = redactSlice(v) + default: + result[i] = v + } + } + return result +} + +// SafeLogString creates a safe-to-log version of sensitive data. +// Use this for logging user-related information. +func SafeLogString(format string, args ...interface{}) string { + // Convert args to strings and redact + safeArgs := make([]interface{}, len(args)) + for i, arg := range args { + switch v := arg.(type) { + case string: + safeArgs[i] = RedactPII(v) + case error: + safeArgs[i] = RedactPII(v.Error()) + default: + safeArgs[i] = arg + } + } + + // Note: We can't use fmt.Sprintf here due to the variadic nature + // Instead, we redact the result + result := format + for _, arg := range safeArgs { + if s, ok := arg.(string); ok { + result = strings.Replace(result, "%s", s, 1) + result = strings.Replace(result, "%v", s, 1) + } + } + return RedactPII(result) +} diff --git a/consent-service/internal/middleware/pii_redactor_test.go b/consent-service/internal/middleware/pii_redactor_test.go new file mode 100644 index 0000000..0dc0e91 --- /dev/null +++ b/consent-service/internal/middleware/pii_redactor_test.go @@ -0,0 +1,228 @@ +package middleware + +import ( + "testing" +) + +func TestPIIRedactor_RedactsEmail(t *testing.T) { + redactor := NewDefaultPIIRedactor() + + text := "User test@example.com logged in" + result := redactor.Redact(text) + + if result == text { + t.Error("Email should have been redacted") + } + if result != "User [EMAIL_REDACTED] logged in" { + t.Errorf("Unexpected result: %s", result) + } +} + +func TestPIIRedactor_RedactsIPv4(t *testing.T) { + redactor := NewDefaultPIIRedactor() + + text := "Request from 192.168.1.100" + result := redactor.Redact(text) + + if result == text { + t.Error("IP should have been redacted") + } + if result != "Request from [IP_REDACTED]" { + t.Errorf("Unexpected result: %s", result) + } +} + +func TestPIIRedactor_RedactsGermanPhone(t *testing.T) { + redactor := NewDefaultPIIRedactor() + + tests := []struct { + input string + expected string + }{ + {"+49 30 12345678", "[PHONE_REDACTED]"}, + {"0049 30 12345678", "[PHONE_REDACTED]"}, + {"030 12345678", "[PHONE_REDACTED]"}, + } + + for _, tt := range tests { + result := redactor.Redact(tt.input) + if result != tt.expected { + t.Errorf("For input %q: expected %q, got %q", tt.input, tt.expected, result) + } + } +} + +func TestPIIRedactor_RedactsMultiplePII(t *testing.T) { + redactor := NewDefaultPIIRedactor() + + text := "User test@example.com from 10.0.0.1" + result := redactor.Redact(text) + + if result != "User [EMAIL_REDACTED] from [IP_REDACTED]" { + t.Errorf("Unexpected result: %s", result) + } +} + +func TestPIIRedactor_PreservesNonPIIText(t *testing.T) { + redactor := NewDefaultPIIRedactor() + + text := "User logged in successfully" + result := redactor.Redact(text) + + if result != text { + t.Errorf("Text should be unchanged: got %s", result) + } +} + +func TestPIIRedactor_EmptyString(t *testing.T) { + redactor := NewDefaultPIIRedactor() + + result := redactor.Redact("") + if result != "" { + t.Error("Empty string should remain empty") + } +} + +func TestContainsPII(t *testing.T) { + redactor := NewDefaultPIIRedactor() + + tests := []struct { + input string + expected bool + }{ + {"test@example.com", true}, + {"192.168.1.1", true}, + {"+49 30 12345678", true}, + {"Hello World", false}, + {"", false}, + } + + for _, tt := range tests { + result := redactor.ContainsPII(tt.input) + if result != tt.expected { + t.Errorf("For input %q: expected %v, got %v", tt.input, tt.expected, result) + } + } +} + +func TestFindPII(t *testing.T) { + redactor := NewDefaultPIIRedactor() + + text := "Email: test@example.com, IP: 10.0.0.1" + findings := redactor.FindPII(text) + + if len(findings) != 2 { + t.Errorf("Expected 2 findings, got %d", len(findings)) + } + + hasEmail := false + hasIP := false + for _, f := range findings { + if f.Type == "email" { + hasEmail = true + } + if f.Type == "ip_v4" { + hasIP = true + } + } + + if !hasEmail { + t.Error("Should have found email") + } + if !hasIP { + t.Error("Should have found IP") + } +} + +func TestRedactPII_GlobalFunction(t *testing.T) { + text := "User test@example.com logged in" + result := RedactPII(text) + + if result == text { + t.Error("Email should have been redacted") + } +} + +func TestContainsPIIDefault(t *testing.T) { + if !ContainsPIIDefault("test@example.com") { + t.Error("Should detect email as PII") + } + if ContainsPIIDefault("Hello World") { + t.Error("Should not detect non-PII text") + } +} + +func TestRedactMap(t *testing.T) { + data := map[string]interface{}{ + "email": "test@example.com", + "message": "Hello World", + "nested": map[string]interface{}{ + "ip": "192.168.1.1", + }, + } + + result := RedactMap(data) + + if result["email"] != "[EMAIL_REDACTED]" { + t.Errorf("Email should be redacted: %v", result["email"]) + } + if result["message"] != "Hello World" { + t.Errorf("Non-PII should be unchanged: %v", result["message"]) + } + + nested := result["nested"].(map[string]interface{}) + if nested["ip"] != "[IP_REDACTED]" { + t.Errorf("Nested IP should be redacted: %v", nested["ip"]) + } +} + +func TestAllPIIPatterns(t *testing.T) { + patterns := AllPIIPatterns() + + if len(patterns) == 0 { + t.Error("Should have PII patterns") + } + + // Check that we have the expected patterns + expectedNames := []string{"email", "ip_v4", "ip_v6", "phone", "iban", "uuid", "name"} + nameMap := make(map[string]bool) + for _, p := range patterns { + nameMap[p.Name] = true + } + + for _, name := range expectedNames { + if !nameMap[name] { + t.Errorf("Missing expected pattern: %s", name) + } + } +} + +func TestDefaultPIIPatterns(t *testing.T) { + patterns := DefaultPIIPatterns() + + if len(patterns) != 4 { + t.Errorf("Expected 4 default patterns, got %d", len(patterns)) + } +} + +func TestIBANRedaction(t *testing.T) { + redactor := NewPIIRedactor(AllPIIPatterns()) + + text := "IBAN: DE89 3704 0044 0532 0130 00" + result := redactor.Redact(text) + + if result == text { + t.Error("IBAN should have been redacted") + } +} + +func TestUUIDRedaction(t *testing.T) { + redactor := NewPIIRedactor(AllPIIPatterns()) + + text := "User ID: a0000000-0000-0000-0000-000000000001" + result := redactor.Redact(text) + + if result == text { + t.Error("UUID should have been redacted") + } +} diff --git a/consent-service/internal/middleware/request_id.go b/consent-service/internal/middleware/request_id.go new file mode 100644 index 0000000..ae25f23 --- /dev/null +++ b/consent-service/internal/middleware/request_id.go @@ -0,0 +1,75 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +const ( + // RequestIDHeader is the primary header for request IDs + RequestIDHeader = "X-Request-ID" + // CorrelationIDHeader is an alternative header for distributed tracing + CorrelationIDHeader = "X-Correlation-ID" + // RequestIDKey is the context key for storing the request ID + RequestIDKey = "request_id" +) + +// RequestID returns a middleware that generates and propagates request IDs. +// +// For each incoming request: +// 1. Check for existing X-Request-ID or X-Correlation-ID header +// 2. If not present, generate a new UUID +// 3. Store in Gin context for use by handlers and logging +// 4. Add to response headers +// +// Usage: +// +// r.Use(middleware.RequestID()) +// +// func handler(c *gin.Context) { +// requestID := middleware.GetRequestID(c) +// log.Printf("[%s] Processing request", requestID) +// } +func RequestID() gin.HandlerFunc { + return func(c *gin.Context) { + // Try to get existing request ID from headers + requestID := c.GetHeader(RequestIDHeader) + if requestID == "" { + requestID = c.GetHeader(CorrelationIDHeader) + } + + // Generate new ID if not provided + if requestID == "" { + requestID = uuid.New().String() + } + + // Store in context for handlers and logging + c.Set(RequestIDKey, requestID) + + // Add to response headers + c.Header(RequestIDHeader, requestID) + c.Header(CorrelationIDHeader, requestID) + + c.Next() + } +} + +// GetRequestID retrieves the request ID from the Gin context. +// Returns empty string if no request ID is set. +// +// Usage: +// +// requestID := middleware.GetRequestID(c) +func GetRequestID(c *gin.Context) string { + if id, exists := c.Get(RequestIDKey); exists { + if idStr, ok := id.(string); ok { + return idStr + } + } + return "" +} + +// RequestIDFromContext is an alias for GetRequestID for API compatibility. +func RequestIDFromContext(c *gin.Context) string { + return GetRequestID(c) +} diff --git a/consent-service/internal/middleware/request_id_test.go b/consent-service/internal/middleware/request_id_test.go new file mode 100644 index 0000000..cbd0aad --- /dev/null +++ b/consent-service/internal/middleware/request_id_test.go @@ -0,0 +1,152 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestRequestID_GeneratesNewID(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(RequestID()) + + router.GET("/test", func(c *gin.Context) { + requestID := GetRequestID(c) + if requestID == "" { + t.Error("Expected request ID to be set") + } + c.JSON(http.StatusOK, gin.H{"request_id": requestID}) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + // Check response header + requestID := w.Header().Get(RequestIDHeader) + if requestID == "" { + t.Error("Expected X-Request-ID header in response") + } + + // Check correlation ID header + correlationID := w.Header().Get(CorrelationIDHeader) + if correlationID == "" { + t.Error("Expected X-Correlation-ID header in response") + } + + if requestID != correlationID { + t.Error("X-Request-ID and X-Correlation-ID should match") + } +} + +func TestRequestID_PropagatesExistingID(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(RequestID()) + + customID := "custom-request-id-12345" + + router.GET("/test", func(c *gin.Context) { + requestID := GetRequestID(c) + if requestID != customID { + t.Errorf("Expected request ID %s, got %s", customID, requestID) + } + c.JSON(http.StatusOK, gin.H{"request_id": requestID}) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set(RequestIDHeader, customID) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + responseID := w.Header().Get(RequestIDHeader) + if responseID != customID { + t.Errorf("Expected response header %s, got %s", customID, responseID) + } +} + +func TestRequestID_PropagatesCorrelationID(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(RequestID()) + + correlationID := "correlation-id-67890" + + router.GET("/test", func(c *gin.Context) { + requestID := GetRequestID(c) + if requestID != correlationID { + t.Errorf("Expected request ID %s, got %s", correlationID, requestID) + } + c.JSON(http.StatusOK, gin.H{}) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set(CorrelationIDHeader, correlationID) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + // Both headers should be set with the correlation ID + if w.Header().Get(RequestIDHeader) != correlationID { + t.Error("X-Request-ID should match X-Correlation-ID") + } +} + +func TestGetRequestID_ReturnsEmptyWhenNotSet(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + // No RequestID middleware + router.GET("/test", func(c *gin.Context) { + requestID := GetRequestID(c) + if requestID != "" { + t.Errorf("Expected empty request ID, got %s", requestID) + } + c.JSON(http.StatusOK, gin.H{}) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } +} + +func TestRequestIDFromContext_IsAliasForGetRequestID(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + router.Use(RequestID()) + + router.GET("/test", func(c *gin.Context) { + id1 := GetRequestID(c) + id2 := RequestIDFromContext(c) + if id1 != id2 { + t.Errorf("GetRequestID and RequestIDFromContext should return same value") + } + c.JSON(http.StatusOK, gin.H{}) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } +} diff --git a/consent-service/internal/middleware/security_headers.go b/consent-service/internal/middleware/security_headers.go new file mode 100644 index 0000000..e3954f0 --- /dev/null +++ b/consent-service/internal/middleware/security_headers.go @@ -0,0 +1,167 @@ +package middleware + +import ( + "os" + "strconv" + "strings" + + "github.com/gin-gonic/gin" +) + +// SecurityHeadersConfig holds configuration for security headers. +type SecurityHeadersConfig struct { + // X-Content-Type-Options + ContentTypeOptions string + + // X-Frame-Options + FrameOptions string + + // X-XSS-Protection (legacy but useful for older browsers) + XSSProtection string + + // Strict-Transport-Security + HSTSEnabled bool + HSTSMaxAge int + HSTSIncludeSubdomains bool + HSTSPreload bool + + // Content-Security-Policy + CSPEnabled bool + CSPPolicy string + + // Referrer-Policy + ReferrerPolicy string + + // Permissions-Policy + PermissionsPolicy string + + // Cross-Origin headers + CrossOriginOpenerPolicy string + CrossOriginResourcePolicy string + + // Development mode (relaxes some restrictions) + DevelopmentMode bool + + // Excluded paths (e.g., health checks) + ExcludedPaths []string +} + +// DefaultSecurityHeadersConfig returns sensible default configuration. +func DefaultSecurityHeadersConfig() SecurityHeadersConfig { + env := os.Getenv("ENVIRONMENT") + isDev := env == "" || strings.ToLower(env) == "development" || strings.ToLower(env) == "dev" + + return SecurityHeadersConfig{ + ContentTypeOptions: "nosniff", + FrameOptions: "DENY", + XSSProtection: "1; mode=block", + HSTSEnabled: true, + HSTSMaxAge: 31536000, // 1 year + HSTSIncludeSubdomains: true, + HSTSPreload: false, + CSPEnabled: true, + CSPPolicy: getDefaultCSP(isDev), + ReferrerPolicy: "strict-origin-when-cross-origin", + PermissionsPolicy: "geolocation=(), microphone=(), camera=()", + CrossOriginOpenerPolicy: "same-origin", + CrossOriginResourcePolicy: "same-origin", + DevelopmentMode: isDev, + ExcludedPaths: []string{"/health", "/metrics", "/api/v1/health"}, + } +} + +// getDefaultCSP returns a sensible default CSP for the environment. +func getDefaultCSP(isDevelopment bool) string { + if isDevelopment { + return "default-src 'self' localhost:* ws://localhost:*; " + + "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data: https: blob:; " + + "font-src 'self' data:; " + + "connect-src 'self' localhost:* ws://localhost:* https:; " + + "frame-ancestors 'self'" + } + return "default-src 'self'; " + + "script-src 'self' 'unsafe-inline'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data: https:; " + + "font-src 'self' data:; " + + "connect-src 'self' https://breakpilot.app https://*.breakpilot.app; " + + "frame-ancestors 'none'" +} + +// buildHSTSHeader builds the Strict-Transport-Security header value. +func (c *SecurityHeadersConfig) buildHSTSHeader() string { + parts := []string{"max-age=" + strconv.Itoa(c.HSTSMaxAge)} + if c.HSTSIncludeSubdomains { + parts = append(parts, "includeSubDomains") + } + if c.HSTSPreload { + parts = append(parts, "preload") + } + return strings.Join(parts, "; ") +} + +// isExcludedPath checks if the path should be excluded from security headers. +func (c *SecurityHeadersConfig) isExcludedPath(path string) bool { + for _, excluded := range c.ExcludedPaths { + if path == excluded { + return true + } + } + return false +} + +// SecurityHeaders returns a middleware that adds security headers to all responses. +// +// Usage: +// +// r.Use(middleware.SecurityHeaders()) +// +// // Or with custom config: +// config := middleware.DefaultSecurityHeadersConfig() +// config.CSPPolicy = "default-src 'self'" +// r.Use(middleware.SecurityHeadersWithConfig(config)) +func SecurityHeaders() gin.HandlerFunc { + return SecurityHeadersWithConfig(DefaultSecurityHeadersConfig()) +} + +// SecurityHeadersWithConfig returns a security headers middleware with custom configuration. +func SecurityHeadersWithConfig(config SecurityHeadersConfig) gin.HandlerFunc { + return func(c *gin.Context) { + // Skip for excluded paths + if config.isExcludedPath(c.Request.URL.Path) { + c.Next() + return + } + + // Always add these headers + c.Header("X-Content-Type-Options", config.ContentTypeOptions) + c.Header("X-Frame-Options", config.FrameOptions) + c.Header("X-XSS-Protection", config.XSSProtection) + c.Header("Referrer-Policy", config.ReferrerPolicy) + + // HSTS (only in production or if explicitly enabled) + if config.HSTSEnabled && !config.DevelopmentMode { + c.Header("Strict-Transport-Security", config.buildHSTSHeader()) + } + + // Content-Security-Policy + if config.CSPEnabled && config.CSPPolicy != "" { + c.Header("Content-Security-Policy", config.CSPPolicy) + } + + // Permissions-Policy + if config.PermissionsPolicy != "" { + c.Header("Permissions-Policy", config.PermissionsPolicy) + } + + // Cross-Origin headers (only in production) + if !config.DevelopmentMode { + c.Header("Cross-Origin-Opener-Policy", config.CrossOriginOpenerPolicy) + c.Header("Cross-Origin-Resource-Policy", config.CrossOriginResourcePolicy) + } + + c.Next() + } +} diff --git a/consent-service/internal/middleware/security_headers_test.go b/consent-service/internal/middleware/security_headers_test.go new file mode 100644 index 0000000..73f299b --- /dev/null +++ b/consent-service/internal/middleware/security_headers_test.go @@ -0,0 +1,377 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +func TestSecurityHeaders_AddsBasicHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + config := DefaultSecurityHeadersConfig() + config.DevelopmentMode = true // Skip HSTS and cross-origin headers + router.Use(SecurityHeadersWithConfig(config)) + + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + // Check basic security headers + tests := []struct { + header string + expected string + }{ + {"X-Content-Type-Options", "nosniff"}, + {"X-Frame-Options", "DENY"}, + {"X-XSS-Protection", "1; mode=block"}, + {"Referrer-Policy", "strict-origin-when-cross-origin"}, + } + + for _, tt := range tests { + value := w.Header().Get(tt.header) + if value != tt.expected { + t.Errorf("Header %s: expected %q, got %q", tt.header, tt.expected, value) + } + } +} + +func TestSecurityHeaders_HSTSNotAddedInDevelopment(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + config := DefaultSecurityHeadersConfig() + config.DevelopmentMode = true + config.HSTSEnabled = true + router.Use(SecurityHeadersWithConfig(config)) + + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{}) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + hstsHeader := w.Header().Get("Strict-Transport-Security") + if hstsHeader != "" { + t.Errorf("HSTS should not be set in development mode, got: %s", hstsHeader) + } +} + +func TestSecurityHeaders_HSTSAddedInProduction(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + config := DefaultSecurityHeadersConfig() + config.DevelopmentMode = false + config.HSTSEnabled = true + config.HSTSMaxAge = 31536000 + config.HSTSIncludeSubdomains = true + router.Use(SecurityHeadersWithConfig(config)) + + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{}) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + hstsHeader := w.Header().Get("Strict-Transport-Security") + if hstsHeader == "" { + t.Error("HSTS should be set in production mode") + } + + // Check that it contains max-age + if hstsHeader != "max-age=31536000; includeSubDomains" { + t.Errorf("Unexpected HSTS value: %s", hstsHeader) + } +} + +func TestSecurityHeaders_HSTSWithPreload(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + config := DefaultSecurityHeadersConfig() + config.DevelopmentMode = false + config.HSTSEnabled = true + config.HSTSMaxAge = 31536000 + config.HSTSIncludeSubdomains = true + config.HSTSPreload = true + router.Use(SecurityHeadersWithConfig(config)) + + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{}) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + hstsHeader := w.Header().Get("Strict-Transport-Security") + expected := "max-age=31536000; includeSubDomains; preload" + if hstsHeader != expected { + t.Errorf("Expected HSTS %q, got %q", expected, hstsHeader) + } +} + +func TestSecurityHeaders_CSPHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + config := DefaultSecurityHeadersConfig() + config.CSPEnabled = true + config.CSPPolicy = "default-src 'self'" + router.Use(SecurityHeadersWithConfig(config)) + + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{}) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + cspHeader := w.Header().Get("Content-Security-Policy") + if cspHeader != "default-src 'self'" { + t.Errorf("Expected CSP %q, got %q", "default-src 'self'", cspHeader) + } +} + +func TestSecurityHeaders_NoCSPWhenDisabled(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + config := DefaultSecurityHeadersConfig() + config.CSPEnabled = false + router.Use(SecurityHeadersWithConfig(config)) + + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{}) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + cspHeader := w.Header().Get("Content-Security-Policy") + if cspHeader != "" { + t.Errorf("CSP should not be set when disabled, got: %s", cspHeader) + } +} + +func TestSecurityHeaders_ExcludedPaths(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + config := DefaultSecurityHeadersConfig() + config.ExcludedPaths = []string{"/health", "/metrics"} + router.Use(SecurityHeadersWithConfig(config)) + + router.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "healthy"}) + }) + + router.GET("/api", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) + + // Test excluded path + req := httptest.NewRequest(http.MethodGet, "/health", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Header().Get("X-Content-Type-Options") != "" { + t.Error("Security headers should not be set for excluded paths") + } + + // Test non-excluded path + req = httptest.NewRequest(http.MethodGet, "/api", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Header().Get("X-Content-Type-Options") != "nosniff" { + t.Error("Security headers should be set for non-excluded paths") + } +} + +func TestSecurityHeaders_CrossOriginInProduction(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + config := DefaultSecurityHeadersConfig() + config.DevelopmentMode = false + config.CrossOriginOpenerPolicy = "same-origin" + config.CrossOriginResourcePolicy = "same-origin" + router.Use(SecurityHeadersWithConfig(config)) + + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{}) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + coopHeader := w.Header().Get("Cross-Origin-Opener-Policy") + if coopHeader != "same-origin" { + t.Errorf("Expected COOP %q, got %q", "same-origin", coopHeader) + } + + corpHeader := w.Header().Get("Cross-Origin-Resource-Policy") + if corpHeader != "same-origin" { + t.Errorf("Expected CORP %q, got %q", "same-origin", corpHeader) + } +} + +func TestSecurityHeaders_NoCrossOriginInDevelopment(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + config := DefaultSecurityHeadersConfig() + config.DevelopmentMode = true + config.CrossOriginOpenerPolicy = "same-origin" + config.CrossOriginResourcePolicy = "same-origin" + router.Use(SecurityHeadersWithConfig(config)) + + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{}) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Header().Get("Cross-Origin-Opener-Policy") != "" { + t.Error("COOP should not be set in development mode") + } + + if w.Header().Get("Cross-Origin-Resource-Policy") != "" { + t.Error("CORP should not be set in development mode") + } +} + +func TestSecurityHeaders_PermissionsPolicy(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + config := DefaultSecurityHeadersConfig() + config.PermissionsPolicy = "geolocation=(), microphone=()" + router.Use(SecurityHeadersWithConfig(config)) + + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{}) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + ppHeader := w.Header().Get("Permissions-Policy") + if ppHeader != "geolocation=(), microphone=()" { + t.Errorf("Expected Permissions-Policy %q, got %q", "geolocation=(), microphone=()", ppHeader) + } +} + +func TestSecurityHeaders_DefaultMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + router := gin.New() + + // Use the default middleware function + router.Use(SecurityHeaders()) + + router.GET("/test", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{}) + }) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Should at least have the basic headers + if w.Header().Get("X-Content-Type-Options") != "nosniff" { + t.Error("Default middleware should set X-Content-Type-Options") + } +} + +func TestBuildHSTSHeader(t *testing.T) { + tests := []struct { + name string + config SecurityHeadersConfig + expected string + }{ + { + name: "basic HSTS", + config: SecurityHeadersConfig{ + HSTSMaxAge: 31536000, + HSTSIncludeSubdomains: false, + HSTSPreload: false, + }, + expected: "max-age=31536000", + }, + { + name: "HSTS with subdomains", + config: SecurityHeadersConfig{ + HSTSMaxAge: 31536000, + HSTSIncludeSubdomains: true, + HSTSPreload: false, + }, + expected: "max-age=31536000; includeSubDomains", + }, + { + name: "HSTS with preload", + config: SecurityHeadersConfig{ + HSTSMaxAge: 31536000, + HSTSIncludeSubdomains: true, + HSTSPreload: true, + }, + expected: "max-age=31536000; includeSubDomains; preload", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.config.buildHSTSHeader() + if result != tt.expected { + t.Errorf("Expected %q, got %q", tt.expected, result) + } + }) + } +} + +func TestIsExcludedPath(t *testing.T) { + config := SecurityHeadersConfig{ + ExcludedPaths: []string{"/health", "/metrics", "/api/v1/health"}, + } + + tests := []struct { + path string + excluded bool + }{ + {"/health", true}, + {"/metrics", true}, + {"/api/v1/health", true}, + {"/api", false}, + {"/health/check", false}, + {"/", false}, + } + + for _, tt := range tests { + result := config.isExcludedPath(tt.path) + if result != tt.excluded { + t.Errorf("Path %s: expected excluded=%v, got %v", tt.path, tt.excluded, result) + } + } +} diff --git a/consent-service/internal/models/models.go b/consent-service/internal/models/models.go new file mode 100644 index 0000000..6dfbf8b --- /dev/null +++ b/consent-service/internal/models/models.go @@ -0,0 +1,1797 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +// User represents a user with full authentication support +type User struct { + ID uuid.UUID `json:"id" db:"id"` + ExternalID *string `json:"external_id,omitempty" db:"external_id"` + Email string `json:"email" db:"email"` + PasswordHash *string `json:"-" db:"password_hash"` // Never exposed in JSON + Name *string `json:"name,omitempty" db:"name"` + Role string `json:"role" db:"role"` // 'user', 'admin', 'super_admin', 'data_protection_officer' + EmailVerified bool `json:"email_verified" db:"email_verified"` + EmailVerifiedAt *time.Time `json:"email_verified_at,omitempty" db:"email_verified_at"` + AccountStatus string `json:"account_status" db:"account_status"` // 'active', 'suspended', 'locked' + LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"` + FailedLoginAttempts int `json:"failed_login_attempts" db:"failed_login_attempts"` + LockedUntil *time.Time `json:"locked_until,omitempty" db:"locked_until"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// LegalDocument represents a type of legal document (e.g., Terms, Privacy Policy) +type LegalDocument struct { + ID uuid.UUID `json:"id" db:"id"` + Type string `json:"type" db:"type"` // 'terms', 'privacy', 'cookies', 'community' + Name string `json:"name" db:"name"` + Description *string `json:"description" db:"description"` + IsMandatory bool `json:"is_mandatory" db:"is_mandatory"` + IsActive bool `json:"is_active" db:"is_active"` + SortOrder int `json:"sort_order" db:"sort_order"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// DocumentVersion represents a specific version of a legal document +type DocumentVersion struct { + ID uuid.UUID `json:"id" db:"id"` + DocumentID uuid.UUID `json:"document_id" db:"document_id"` + Version string `json:"version" db:"version"` // Semver: 1.0.0, 1.1.0 + Language string `json:"language" db:"language"` // ISO 639-1: de, en + Title string `json:"title" db:"title"` + Content string `json:"content" db:"content"` // HTML or Markdown + Summary *string `json:"summary" db:"summary"` // Summary of changes + Status string `json:"status" db:"status"` // 'draft', 'review', 'approved', 'scheduled', 'published', 'archived' + PublishedAt *time.Time `json:"published_at" db:"published_at"` + ScheduledPublishAt *time.Time `json:"scheduled_publish_at" db:"scheduled_publish_at"` + CreatedBy *uuid.UUID `json:"created_by" db:"created_by"` + ApprovedBy *uuid.UUID `json:"approved_by" db:"approved_by"` + ApprovedAt *time.Time `json:"approved_at" db:"approved_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// UserConsent represents a user's consent to a document version +type UserConsent struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + DocumentVersionID uuid.UUID `json:"document_version_id" db:"document_version_id"` + Consented bool `json:"consented" db:"consented"` + IPAddress *string `json:"ip_address" db:"ip_address"` + UserAgent *string `json:"user_agent" db:"user_agent"` + ConsentedAt time.Time `json:"consented_at" db:"consented_at"` + WithdrawnAt *time.Time `json:"withdrawn_at" db:"withdrawn_at"` +} + +// CookieCategory represents a category of cookies +type CookieCategory struct { + ID uuid.UUID `json:"id" db:"id"` + Name string `json:"name" db:"name"` // 'necessary', 'functional', 'analytics', 'marketing' + DisplayNameDE string `json:"display_name_de" db:"display_name_de"` + DisplayNameEN *string `json:"display_name_en" db:"display_name_en"` + DescriptionDE *string `json:"description_de" db:"description_de"` + DescriptionEN *string `json:"description_en" db:"description_en"` + IsMandatory bool `json:"is_mandatory" db:"is_mandatory"` + SortOrder int `json:"sort_order" db:"sort_order"` + IsActive bool `json:"is_active" db:"is_active"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// CookieConsent represents a user's cookie preferences +type CookieConsent struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + CategoryID uuid.UUID `json:"category_id" db:"category_id"` + Consented bool `json:"consented" db:"consented"` + ConsentedAt time.Time `json:"consented_at" db:"consented_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// AuditLog represents an audit trail entry for GDPR compliance +type AuditLog struct { + ID uuid.UUID `json:"id" db:"id"` + UserID *uuid.UUID `json:"user_id" db:"user_id"` + Action string `json:"action" db:"action"` // 'consent_given', 'consent_withdrawn', 'data_export', 'data_delete' + EntityType *string `json:"entity_type" db:"entity_type"` // 'document', 'cookie_category' + EntityID *uuid.UUID `json:"entity_id" db:"entity_id"` + Details *string `json:"details" db:"details"` // JSON string + IPAddress *string `json:"ip_address" db:"ip_address"` + UserAgent *string `json:"user_agent" db:"user_agent"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// DataExportRequest represents a user's request to export their data +type DataExportRequest struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + Status string `json:"status" db:"status"` // 'pending', 'processing', 'completed', 'failed' + DownloadURL *string `json:"download_url" db:"download_url"` + ExpiresAt *time.Time `json:"expires_at" db:"expires_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + CompletedAt *time.Time `json:"completed_at" db:"completed_at"` +} + +// DataDeletionRequest represents a user's request to delete their data +type DataDeletionRequest struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + Status string `json:"status" db:"status"` // 'pending', 'processing', 'completed', 'failed' + Reason *string `json:"reason" db:"reason"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + ProcessedAt *time.Time `json:"processed_at" db:"processed_at"` + ProcessedBy *uuid.UUID `json:"processed_by" db:"processed_by"` +} + +// ======================================== +// DTOs (Data Transfer Objects) +// ======================================== + +// CreateConsentRequest is the request body for creating a consent +type CreateConsentRequest struct { + DocumentType string `json:"document_type" binding:"required"` + VersionID string `json:"version_id" binding:"required"` + Consented bool `json:"consented"` +} + +// CookieConsentRequest is the request body for setting cookie preferences +type CookieConsentRequest struct { + Categories []CookieCategoryConsent `json:"categories" binding:"required"` +} + +// CookieCategoryConsent represents consent for a single cookie category +type CookieCategoryConsent struct { + CategoryID string `json:"category_id" binding:"required"` + Consented bool `json:"consented"` +} + +// ConsentCheckResponse is the response for checking consent status +type ConsentCheckResponse struct { + HasConsent bool `json:"has_consent"` + CurrentVersionID *string `json:"current_version_id,omitempty"` + ConsentedVersion *string `json:"consented_version,omitempty"` + NeedsUpdate bool `json:"needs_update"` + ConsentedAt *time.Time `json:"consented_at,omitempty"` +} + +// DocumentWithVersion combines document info with its latest published version +type DocumentWithVersion struct { + Document LegalDocument `json:"document"` + LatestVersion *DocumentVersion `json:"latest_version,omitempty"` +} + +// ConsentHistory represents a user's consent history for a document +type ConsentHistory struct { + Document LegalDocument `json:"document"` + Version DocumentVersion `json:"version"` + Consent UserConsent `json:"consent"` +} + +// ConsentStats represents statistics about consents +type ConsentStats struct { + TotalUsers int `json:"total_users"` + ConsentedUsers int `json:"consented_users"` + ConsentRate float64 `json:"consent_rate"` + RecentConsents int `json:"recent_consents"` // Last 7 days + RecentWithdrawals int `json:"recent_withdrawals"` +} + +// CookieStats represents statistics about cookie consents +type CookieStats struct { + Category string `json:"category"` + TotalUsers int `json:"total_users"` + ConsentedUsers int `json:"consented_users"` + ConsentRate float64 `json:"consent_rate"` +} + +// MyDataResponse represents all data we have about a user +type MyDataResponse struct { + User User `json:"user"` + Consents []ConsentHistory `json:"consents"` + CookieConsents []CookieConsent `json:"cookie_consents"` + AuditLog []AuditLog `json:"audit_log"` + ExportedAt time.Time `json:"exported_at"` +} + +// CreateDocumentRequest is the request body for creating a document +type CreateDocumentRequest struct { + Type string `json:"type" binding:"required"` + Name string `json:"name" binding:"required"` + Description *string `json:"description"` + IsMandatory bool `json:"is_mandatory"` +} + +// CreateVersionRequest is the request body for creating a document version +type CreateVersionRequest struct { + DocumentID string `json:"document_id" binding:"required"` + Version string `json:"version" binding:"required"` + Language string `json:"language" binding:"required"` + Title string `json:"title" binding:"required"` + Content string `json:"content" binding:"required"` + Summary *string `json:"summary"` +} + +// UpdateVersionRequest is the request body for updating a version +type UpdateVersionRequest struct { + Title *string `json:"title"` + Content *string `json:"content"` + Summary *string `json:"summary"` + Status *string `json:"status"` +} + +// CreateCookieCategoryRequest is the request body for creating a cookie category +type CreateCookieCategoryRequest struct { + Name string `json:"name" binding:"required"` + DisplayNameDE string `json:"display_name_de" binding:"required"` + DisplayNameEN *string `json:"display_name_en"` + DescriptionDE *string `json:"description_de"` + DescriptionEN *string `json:"description_en"` + IsMandatory bool `json:"is_mandatory"` + SortOrder int `json:"sort_order"` +} + +// ======================================== +// Phase 1: Authentication Models +// ======================================== + +// EmailVerificationToken for email verification +type EmailVerificationToken struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + Token string `json:"token" db:"token"` + ExpiresAt time.Time `json:"expires_at" db:"expires_at"` + UsedAt *time.Time `json:"used_at,omitempty" db:"used_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// PasswordResetToken for password reset +type PasswordResetToken struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + Token string `json:"token" db:"token"` + ExpiresAt time.Time `json:"expires_at" db:"expires_at"` + UsedAt *time.Time `json:"used_at,omitempty" db:"used_at"` + IPAddress *string `json:"ip_address,omitempty" db:"ip_address"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// UserSession for session management +type UserSession struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + TokenHash string `json:"-" db:"token_hash"` + DeviceInfo *string `json:"device_info,omitempty" db:"device_info"` + IPAddress *string `json:"ip_address,omitempty" db:"ip_address"` + UserAgent *string `json:"user_agent,omitempty" db:"user_agent"` + ExpiresAt time.Time `json:"expires_at" db:"expires_at"` + RevokedAt *time.Time `json:"revoked_at,omitempty" db:"revoked_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + LastActivityAt time.Time `json:"last_activity_at" db:"last_activity_at"` +} + +// RegisterRequest for user registration +type RegisterRequest struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=8"` + Name *string `json:"name"` +} + +// LoginRequest for user login +type LoginRequest struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required"` +} + +// LoginResponse after successful login +type LoginResponse struct { + User User `json:"user"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` // seconds +} + +// RefreshTokenRequest for token refresh +type RefreshTokenRequest struct { + RefreshToken string `json:"refresh_token" binding:"required"` +} + +// VerifyEmailRequest for email verification +type VerifyEmailRequest struct { + Token string `json:"token" binding:"required"` +} + +// ForgotPasswordRequest for password reset request +type ForgotPasswordRequest struct { + Email string `json:"email" binding:"required,email"` +} + +// ResetPasswordRequest for password reset +type ResetPasswordRequest struct { + Token string `json:"token" binding:"required"` + NewPassword string `json:"new_password" binding:"required,min=8"` +} + +// ChangePasswordRequest for changing password +type ChangePasswordRequest struct { + CurrentPassword string `json:"current_password" binding:"required"` + NewPassword string `json:"new_password" binding:"required,min=8"` +} + +// UpdateProfileRequest for profile updates +type UpdateProfileRequest struct { + Name *string `json:"name"` +} + +// ======================================== +// Phase 3: Version Approval Models +// ======================================== + +// VersionApproval tracks the approval workflow +type VersionApproval struct { + ID uuid.UUID `json:"id" db:"id"` + VersionID uuid.UUID `json:"version_id" db:"version_id"` + ApproverID uuid.UUID `json:"approver_id" db:"approver_id"` + Action string `json:"action" db:"action"` // 'submitted_for_review', 'approved', 'rejected', 'published' + Comment *string `json:"comment,omitempty" db:"comment"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// SubmitForReviewRequest for submitting a version for review +type SubmitForReviewRequest struct { + Comment *string `json:"comment"` +} + +// ApproveVersionRequest for approving a version (DSB) +type ApproveVersionRequest struct { + Comment *string `json:"comment"` + ScheduledPublishAt *string `json:"scheduled_publish_at"` // ISO 8601 datetime for scheduled publishing +} + +// RejectVersionRequest for rejecting a version +type RejectVersionRequest struct { + Comment string `json:"comment" binding:"required"` +} + +// VersionCompareResponse for comparing versions +type VersionCompareResponse struct { + Published *DocumentVersion `json:"published,omitempty"` + Draft *DocumentVersion `json:"draft"` + Diff *string `json:"diff,omitempty"` + Approvals []VersionApproval `json:"approvals"` +} + +// ======================================== +// Phase 4: Notification Models +// ======================================== + +// Notification represents a user notification +type Notification struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + Type string `json:"type" db:"type"` // 'new_version', 'consent_reminder', 'account_warning' + Channel string `json:"channel" db:"channel"` // 'email', 'in_app', 'push' + Title string `json:"title" db:"title"` + Body string `json:"body" db:"body"` + Data *string `json:"data,omitempty" db:"data"` // JSON string + ReadAt *time.Time `json:"read_at,omitempty" db:"read_at"` + SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// PushSubscription for Web Push notifications +type PushSubscription struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + Endpoint string `json:"endpoint" db:"endpoint"` + P256dh string `json:"p256dh" db:"p256dh"` + Auth string `json:"auth" db:"auth"` + UserAgent *string `json:"user_agent,omitempty" db:"user_agent"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// NotificationPreferences for user notification settings +type NotificationPreferences struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + EmailEnabled bool `json:"email_enabled" db:"email_enabled"` + PushEnabled bool `json:"push_enabled" db:"push_enabled"` + InAppEnabled bool `json:"in_app_enabled" db:"in_app_enabled"` + ReminderFrequency string `json:"reminder_frequency" db:"reminder_frequency"` // 'daily', 'weekly', 'never' + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// SubscribePushRequest for subscribing to push notifications +type SubscribePushRequest struct { + Endpoint string `json:"endpoint" binding:"required"` + P256dh string `json:"p256dh" binding:"required"` + Auth string `json:"auth" binding:"required"` +} + +// UpdateNotificationPreferencesRequest for updating preferences +type UpdateNotificationPreferencesRequest struct { + EmailEnabled *bool `json:"email_enabled"` + PushEnabled *bool `json:"push_enabled"` + InAppEnabled *bool `json:"in_app_enabled"` + ReminderFrequency *string `json:"reminder_frequency"` +} + +// ======================================== +// Phase 6: OAuth 2.0 Authorization Code Flow +// ======================================== + +// OAuthClient represents a registered OAuth 2.0 client application +type OAuthClient struct { + ID uuid.UUID `json:"id" db:"id"` + ClientID string `json:"client_id" db:"client_id"` + ClientSecret string `json:"-" db:"client_secret"` // Never expose in JSON + Name string `json:"name" db:"name"` + Description *string `json:"description,omitempty" db:"description"` + RedirectURIs []string `json:"redirect_uris" db:"redirect_uris"` // JSON array + Scopes []string `json:"scopes" db:"scopes"` // Allowed scopes + GrantTypes []string `json:"grant_types" db:"grant_types"` // authorization_code, refresh_token + IsPublic bool `json:"is_public" db:"is_public"` // Public clients (SPAs) don't have secret + IsActive bool `json:"is_active" db:"is_active"` + CreatedBy *uuid.UUID `json:"created_by,omitempty" db:"created_by"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// OAuthAuthorizationCode represents an authorization code for the OAuth flow +type OAuthAuthorizationCode struct { + ID uuid.UUID `json:"id" db:"id"` + Code string `json:"-" db:"code"` // Hashed + ClientID string `json:"client_id" db:"client_id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + RedirectURI string `json:"redirect_uri" db:"redirect_uri"` + Scopes []string `json:"scopes" db:"scopes"` + CodeChallenge *string `json:"-" db:"code_challenge"` // For PKCE + CodeChallengeMethod *string `json:"-" db:"code_challenge_method"` // S256 or plain + ExpiresAt time.Time `json:"expires_at" db:"expires_at"` + UsedAt *time.Time `json:"used_at,omitempty" db:"used_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// OAuthAccessToken represents an OAuth access token +type OAuthAccessToken struct { + ID uuid.UUID `json:"id" db:"id"` + TokenHash string `json:"-" db:"token_hash"` + ClientID string `json:"client_id" db:"client_id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + Scopes []string `json:"scopes" db:"scopes"` + ExpiresAt time.Time `json:"expires_at" db:"expires_at"` + RevokedAt *time.Time `json:"revoked_at,omitempty" db:"revoked_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// OAuthRefreshToken represents an OAuth refresh token +type OAuthRefreshToken struct { + ID uuid.UUID `json:"id" db:"id"` + TokenHash string `json:"-" db:"token_hash"` + AccessTokenID uuid.UUID `json:"access_token_id" db:"access_token_id"` + ClientID string `json:"client_id" db:"client_id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + Scopes []string `json:"scopes" db:"scopes"` + ExpiresAt time.Time `json:"expires_at" db:"expires_at"` + RevokedAt *time.Time `json:"revoked_at,omitempty" db:"revoked_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// OAuthAuthorizeRequest for the authorization endpoint +type OAuthAuthorizeRequest struct { + ResponseType string `form:"response_type" binding:"required"` // Must be "code" + ClientID string `form:"client_id" binding:"required"` + RedirectURI string `form:"redirect_uri" binding:"required"` + Scope string `form:"scope"` // Space-separated scopes + State string `form:"state" binding:"required"` // CSRF protection + CodeChallenge string `form:"code_challenge"` // PKCE + CodeChallengeMethod string `form:"code_challenge_method"` // S256 (recommended) or plain +} + +// OAuthTokenRequest for the token endpoint +type OAuthTokenRequest struct { + GrantType string `form:"grant_type" binding:"required"` // authorization_code or refresh_token + Code string `form:"code"` // For authorization_code grant + RedirectURI string `form:"redirect_uri"` // For authorization_code grant + ClientID string `form:"client_id" binding:"required"` + ClientSecret string `form:"client_secret"` // For confidential clients + CodeVerifier string `form:"code_verifier"` // For PKCE + RefreshToken string `form:"refresh_token"` // For refresh_token grant + Scope string `form:"scope"` // For refresh_token grant (optional) +} + +// OAuthTokenResponse for successful token requests +type OAuthTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` // Always "Bearer" + ExpiresIn int `json:"expires_in"` // Seconds until expiration + RefreshToken string `json:"refresh_token,omitempty"` + Scope string `json:"scope,omitempty"` +} + +// OAuthErrorResponse for OAuth errors (RFC 6749) +type OAuthErrorResponse struct { + Error string `json:"error"` + ErrorDescription string `json:"error_description,omitempty"` + ErrorURI string `json:"error_uri,omitempty"` +} + +// ======================================== +// Phase 7: Two-Factor Authentication (2FA/TOTP) +// ======================================== + +// UserTOTP stores 2FA TOTP configuration for a user +type UserTOTP struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + Secret string `json:"-" db:"secret"` // Encrypted TOTP secret + Verified bool `json:"verified" db:"verified"` // Has 2FA been verified/activated + RecoveryCodes []string `json:"-" db:"recovery_codes"` // Encrypted backup codes + EnabledAt *time.Time `json:"enabled_at,omitempty" db:"enabled_at"` + LastUsedAt *time.Time `json:"last_used_at,omitempty" db:"last_used_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// TwoFactorChallenge represents a pending 2FA challenge during login +type TwoFactorChallenge struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + ChallengeID string `json:"challenge_id" db:"challenge_id"` // Temporary token + IPAddress *string `json:"ip_address,omitempty" db:"ip_address"` + UserAgent *string `json:"user_agent,omitempty" db:"user_agent"` + ExpiresAt time.Time `json:"expires_at" db:"expires_at"` + UsedAt *time.Time `json:"used_at,omitempty" db:"used_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// Setup2FAResponse when initiating 2FA setup +type Setup2FAResponse struct { + Secret string `json:"secret"` // Base32 encoded secret for manual entry + QRCodeDataURL string `json:"qr_code"` // Data URL for QR code image + RecoveryCodes []string `json:"recovery_codes"` // One-time backup codes +} + +// Verify2FARequest for verifying 2FA setup or login +type Verify2FARequest struct { + Code string `json:"code" binding:"required"` // 6-digit TOTP code + ChallengeID string `json:"challenge_id,omitempty"` // For login flow +} + +// TwoFactorLoginResponse when 2FA is required during login +type TwoFactorLoginResponse struct { + RequiresTwoFactor bool `json:"requires_two_factor"` + ChallengeID string `json:"challenge_id"` // Use this to complete 2FA + Message string `json:"message"` +} + +// Complete2FALoginRequest to complete login with 2FA +type Complete2FALoginRequest struct { + ChallengeID string `json:"challenge_id" binding:"required"` + Code string `json:"code" binding:"required"` // 6-digit TOTP or recovery code +} + +// Disable2FARequest for disabling 2FA +type Disable2FARequest struct { + Password string `json:"password" binding:"required"` // Require password confirmation + Code string `json:"code" binding:"required"` // Current TOTP code +} + +// RecoveryCodeUseRequest for using a recovery code +type RecoveryCodeUseRequest struct { + ChallengeID string `json:"challenge_id" binding:"required"` + RecoveryCode string `json:"recovery_code" binding:"required"` +} + +// TwoFactorStatusResponse for checking 2FA status +type TwoFactorStatusResponse struct { + Enabled bool `json:"enabled"` + Verified bool `json:"verified"` + EnabledAt *time.Time `json:"enabled_at,omitempty"` + RecoveryCodesCount int `json:"recovery_codes_count"` +} + +// Verify2FAChallengeRequest for verifying a 2FA challenge during login +type Verify2FAChallengeRequest struct { + ChallengeID string `json:"challenge_id" binding:"required"` + Code string `json:"code,omitempty"` // 6-digit TOTP code + RecoveryCode string `json:"recovery_code,omitempty"` // Alternative: recovery code +} + +// ======================================== +// Phase 5: Consent Deadline Models +// ======================================== + +// ConsentDeadline tracks consent deadlines per user +type ConsentDeadline struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + DocumentVersionID uuid.UUID `json:"document_version_id" db:"document_version_id"` + DeadlineAt time.Time `json:"deadline_at" db:"deadline_at"` + ReminderCount int `json:"reminder_count" db:"reminder_count"` + LastReminderAt *time.Time `json:"last_reminder_at,omitempty" db:"last_reminder_at"` + ConsentGivenAt *time.Time `json:"consent_given_at,omitempty" db:"consent_given_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// AccountSuspension tracks account suspensions +type AccountSuspension struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` + Reason string `json:"reason" db:"reason"` // 'consent_deadline_exceeded' + Details *string `json:"details,omitempty" db:"details"` // JSON + SuspendedAt time.Time `json:"suspended_at" db:"suspended_at"` + LiftedAt *time.Time `json:"lifted_at,omitempty" db:"lifted_at"` + LiftedReason *string `json:"lifted_reason,omitempty" db:"lifted_reason"` +} + +// PendingConsentResponse for pending consents with deadline info +type PendingConsentResponse struct { + Document LegalDocument `json:"document"` + Version DocumentVersion `json:"version"` + DeadlineAt time.Time `json:"deadline_at"` + DaysLeft int `json:"days_left"` + IsOverdue bool `json:"is_overdue"` +} + +// AccountStatusResponse for account status check +type AccountStatusResponse struct { + Status string `json:"status"` // 'active', 'suspended' + PendingConsents []PendingConsentResponse `json:"pending_consents,omitempty"` + SuspensionReason *string `json:"suspension_reason,omitempty"` + CanAccess bool `json:"can_access"` +} + +// ======================================== +// Phase 8: E-Mail Templates (Transactional) +// ======================================== + +// EmailTemplateType defines the types of transactional emails +// These are like document types but for emails +const ( + // Auth & Security + EmailTypeWelcome = "welcome" + EmailTypeEmailVerification = "email_verification" + EmailTypePasswordReset = "password_reset" + EmailTypePasswordChanged = "password_changed" + EmailType2FAEnabled = "2fa_enabled" + EmailType2FADisabled = "2fa_disabled" + EmailTypeNewDeviceLogin = "new_device_login" + EmailTypeSuspiciousActivity = "suspicious_activity" + EmailTypeAccountLocked = "account_locked" + EmailTypeAccountUnlocked = "account_unlocked" + + // Account Lifecycle + EmailTypeDeletionRequested = "deletion_requested" + EmailTypeDeletionConfirmed = "deletion_confirmed" + EmailTypeDataExportReady = "data_export_ready" + EmailTypeEmailChanged = "email_changed" + EmailTypeEmailChangeVerify = "email_change_verify" + + // Consent-related + EmailTypeNewVersionPublished = "new_version_published" + EmailTypeConsentReminder = "consent_reminder" + EmailTypeConsentDeadlineWarning = "consent_deadline_warning" + EmailTypeAccountSuspended = "account_suspended" +) + +// EmailTemplate represents a template for transactional emails (like LegalDocument) +type EmailTemplate struct { + ID uuid.UUID `json:"id" db:"id"` + Type string `json:"type" db:"type"` // One of EmailType constants + Name string `json:"name" db:"name"` // Human-readable name + Description *string `json:"description" db:"description"` + IsActive bool `json:"is_active" db:"is_active"` + SortOrder int `json:"sort_order" db:"sort_order"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// EmailTemplateVersion represents a specific version of an email template (like DocumentVersion) +type EmailTemplateVersion struct { + ID uuid.UUID `json:"id" db:"id"` + TemplateID uuid.UUID `json:"template_id" db:"template_id"` + Version string `json:"version" db:"version"` // Semver: 1.0.0 + Language string `json:"language" db:"language"` // ISO 639-1: de, en + Subject string `json:"subject" db:"subject"` // Email subject line + BodyHTML string `json:"body_html" db:"body_html"` // HTML version + BodyText string `json:"body_text" db:"body_text"` // Plain text version + Summary *string `json:"summary" db:"summary"` // Change summary + Status string `json:"status" db:"status"` // draft, review, approved, published, archived + PublishedAt *time.Time `json:"published_at" db:"published_at"` + ScheduledPublishAt *time.Time `json:"scheduled_publish_at" db:"scheduled_publish_at"` + CreatedBy *uuid.UUID `json:"created_by" db:"created_by"` + ApprovedBy *uuid.UUID `json:"approved_by" db:"approved_by"` + ApprovedAt *time.Time `json:"approved_at" db:"approved_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// EmailTemplateApproval tracks approval workflow for email templates +type EmailTemplateApproval struct { + ID uuid.UUID `json:"id" db:"id"` + VersionID uuid.UUID `json:"version_id" db:"version_id"` + ApproverID uuid.UUID `json:"approver_id" db:"approver_id"` + Action string `json:"action" db:"action"` // submitted_for_review, approved, rejected, published + Comment *string `json:"comment,omitempty" db:"comment"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// EmailSendLog tracks sent emails for audit purposes +type EmailSendLog struct { + ID uuid.UUID `json:"id" db:"id"` + UserID *uuid.UUID `json:"user_id,omitempty" db:"user_id"` + VersionID uuid.UUID `json:"version_id" db:"version_id"` + Recipient string `json:"recipient" db:"recipient"` // Email address + Subject string `json:"subject" db:"subject"` + Status string `json:"status" db:"status"` // queued, sent, delivered, bounced, failed + ErrorMsg *string `json:"error_msg,omitempty" db:"error_msg"` + Variables *string `json:"variables,omitempty" db:"variables"` // JSON of template variables used + SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"` + DeliveredAt *time.Time `json:"delivered_at,omitempty" db:"delivered_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// EmailTemplateSettings stores global email settings (logo, signature, etc.) +type EmailTemplateSettings struct { + ID uuid.UUID `json:"id" db:"id"` + LogoURL *string `json:"logo_url" db:"logo_url"` + LogoBase64 *string `json:"logo_base64" db:"logo_base64"` // For embedding in emails + CompanyName string `json:"company_name" db:"company_name"` + SenderName string `json:"sender_name" db:"sender_name"` + SenderEmail string `json:"sender_email" db:"sender_email"` + ReplyToEmail *string `json:"reply_to_email" db:"reply_to_email"` + FooterHTML *string `json:"footer_html" db:"footer_html"` + FooterText *string `json:"footer_text" db:"footer_text"` + PrimaryColor string `json:"primary_color" db:"primary_color"` // Hex color + SecondaryColor string `json:"secondary_color" db:"secondary_color"` // Hex color + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + UpdatedBy *uuid.UUID `json:"updated_by" db:"updated_by"` +} + +// ======================================== +// E-Mail Template DTOs +// ======================================== + +// CreateEmailTemplateRequest for creating a new email template type +type CreateEmailTemplateRequest struct { + Type string `json:"type" binding:"required"` + Name string `json:"name" binding:"required"` + Description *string `json:"description"` +} + +// CreateEmailTemplateVersionRequest for creating a new version of an email template +type CreateEmailTemplateVersionRequest struct { + TemplateID string `json:"template_id" binding:"required"` + Version string `json:"version" binding:"required"` + Language string `json:"language" binding:"required"` + Subject string `json:"subject" binding:"required"` + BodyHTML string `json:"body_html" binding:"required"` + BodyText string `json:"body_text" binding:"required"` + Summary *string `json:"summary"` +} + +// UpdateEmailTemplateVersionRequest for updating a version +type UpdateEmailTemplateVersionRequest struct { + Subject *string `json:"subject"` + BodyHTML *string `json:"body_html"` + BodyText *string `json:"body_text"` + Summary *string `json:"summary"` + Status *string `json:"status"` +} + +// UpdateEmailTemplateSettingsRequest for updating global settings +type UpdateEmailTemplateSettingsRequest struct { + LogoURL *string `json:"logo_url"` + LogoBase64 *string `json:"logo_base64"` + CompanyName *string `json:"company_name"` + SenderName *string `json:"sender_name"` + SenderEmail *string `json:"sender_email"` + ReplyToEmail *string `json:"reply_to_email"` + FooterHTML *string `json:"footer_html"` + FooterText *string `json:"footer_text"` + PrimaryColor *string `json:"primary_color"` + SecondaryColor *string `json:"secondary_color"` +} + +// EmailTemplateWithVersion combines template info with its latest published version +type EmailTemplateWithVersion struct { + Template EmailTemplate `json:"template"` + LatestVersion *EmailTemplateVersion `json:"latest_version,omitempty"` +} + +// SendTestEmailRequest for sending a test email +type SendTestEmailRequest struct { + VersionID string `json:"version_id" binding:"required"` + Recipient string `json:"recipient" binding:"required,email"` + Variables map[string]string `json:"variables"` // Template variable overrides +} + +// EmailPreviewResponse for previewing an email +type EmailPreviewResponse struct { + Subject string `json:"subject"` + BodyHTML string `json:"body_html"` + BodyText string `json:"body_text"` +} + +// EmailTemplateVariables defines available variables for each template type +type EmailTemplateVariables struct { + TemplateType string `json:"template_type"` + Variables []string `json:"variables"` + Descriptions map[string]string `json:"descriptions"` +} + +// EmailStats represents statistics about email sends +type EmailStats struct { + TotalSent int `json:"total_sent"` + Delivered int `json:"delivered"` + Bounced int `json:"bounced"` + Failed int `json:"failed"` + DeliveryRate float64 `json:"delivery_rate"` + RecentSent int `json:"recent_sent"` // Last 7 days +} + +// ======================================== +// Phase 9: Schulverwaltung / School Management +// Matrix-basierte Kommunikation für Schulen +// ======================================== + +// SchoolRole defines roles within the school system +const ( + SchoolRoleTeacher = "teacher" + SchoolRoleClassTeacher = "class_teacher" + SchoolRoleParent = "parent" + SchoolRoleParentRep = "parent_representative" + SchoolRoleStudent = "student" + SchoolRoleAdmin = "school_admin" + SchoolRolePrincipal = "principal" + SchoolRoleSecretary = "secretary" +) + +// AttendanceStatus defines the status of student attendance +const ( + AttendancePresent = "present" + AttendanceAbsent = "absent" + AttendanceAbsentExcused = "excused" + AttendanceAbsentUnexcused = "unexcused" + AttendanceLate = "late" + AttendanceLateExcused = "late_excused" + AttendancePending = "pending_confirmation" +) + +// School represents a school/educational institution +type School struct { + ID uuid.UUID `json:"id" db:"id"` + Name string `json:"name" db:"name"` + ShortName *string `json:"short_name,omitempty" db:"short_name"` + Type string `json:"type" db:"type"` // 'grundschule', 'hauptschule', 'realschule', 'gymnasium', 'gesamtschule', 'berufsschule' + Address *string `json:"address,omitempty" db:"address"` + City *string `json:"city,omitempty" db:"city"` + PostalCode *string `json:"postal_code,omitempty" db:"postal_code"` + State *string `json:"state,omitempty" db:"state"` // Bundesland + Country string `json:"country" db:"country"` // Default: DE + Phone *string `json:"phone,omitempty" db:"phone"` + Email *string `json:"email,omitempty" db:"email"` + Website *string `json:"website,omitempty" db:"website"` + MatrixServerName *string `json:"matrix_server_name,omitempty" db:"matrix_server_name"` // Optional: eigener Matrix-Server + LogoURL *string `json:"logo_url,omitempty" db:"logo_url"` + IsActive bool `json:"is_active" db:"is_active"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// SchoolYear represents an academic year +type SchoolYear struct { + ID uuid.UUID `json:"id" db:"id"` + SchoolID uuid.UUID `json:"school_id" db:"school_id"` + Name string `json:"name" db:"name"` // e.g., "2024/2025" + StartDate time.Time `json:"start_date" db:"start_date"` + EndDate time.Time `json:"end_date" db:"end_date"` + IsCurrent bool `json:"is_current" db:"is_current"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// Class represents a school class +type Class struct { + ID uuid.UUID `json:"id" db:"id"` + SchoolID uuid.UUID `json:"school_id" db:"school_id"` + SchoolYearID uuid.UUID `json:"school_year_id" db:"school_year_id"` + Name string `json:"name" db:"name"` // e.g., "5a", "10b" + Grade int `json:"grade" db:"grade"` // Klassenstufe: 1-13 + Section *string `json:"section,omitempty" db:"section"` // e.g., "a", "b", "c" + Room *string `json:"room,omitempty" db:"room"` // Klassenzimmer + MatrixInfoRoom *string `json:"matrix_info_room,omitempty" db:"matrix_info_room"` // Broadcast-Raum + MatrixRepRoom *string `json:"matrix_rep_room,omitempty" db:"matrix_rep_room"` // Elternvertreter-Raum + IsActive bool `json:"is_active" db:"is_active"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// Subject represents a school subject +type Subject struct { + ID uuid.UUID `json:"id" db:"id"` + SchoolID uuid.UUID `json:"school_id" db:"school_id"` + Name string `json:"name" db:"name"` // e.g., "Mathematik", "Deutsch" + ShortName string `json:"short_name" db:"short_name"` // e.g., "Ma", "De" + Color *string `json:"color,omitempty" db:"color"` // Hex color for display + IsActive bool `json:"is_active" db:"is_active"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// Student represents a student +type Student struct { + ID uuid.UUID `json:"id" db:"id"` + SchoolID uuid.UUID `json:"school_id" db:"school_id"` + ClassID uuid.UUID `json:"class_id" db:"class_id"` + UserID *uuid.UUID `json:"user_id,omitempty" db:"user_id"` // Optional: linked account + StudentNumber *string `json:"student_number,omitempty" db:"student_number"` // Internal ID + FirstName string `json:"first_name" db:"first_name"` + LastName string `json:"last_name" db:"last_name"` + DateOfBirth *time.Time `json:"date_of_birth,omitempty" db:"date_of_birth"` + Gender *string `json:"gender,omitempty" db:"gender"` // 'm', 'f', 'd' + MatrixUserID *string `json:"matrix_user_id,omitempty" db:"matrix_user_id"` + MatrixDMRoom *string `json:"matrix_dm_room,omitempty" db:"matrix_dm_room"` // Kind-Dialograum + IsActive bool `json:"is_active" db:"is_active"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// Teacher represents a teacher +type Teacher struct { + ID uuid.UUID `json:"id" db:"id"` + SchoolID uuid.UUID `json:"school_id" db:"school_id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` // Linked user account + TeacherCode *string `json:"teacher_code,omitempty" db:"teacher_code"` // e.g., "MÜL" for Müller + Title *string `json:"title,omitempty" db:"title"` // e.g., "Dr.", "StR" + FirstName string `json:"first_name" db:"first_name"` + LastName string `json:"last_name" db:"last_name"` + MatrixUserID *string `json:"matrix_user_id,omitempty" db:"matrix_user_id"` + IsActive bool `json:"is_active" db:"is_active"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// ClassTeacher assigns teachers to classes (Klassenlehrer) +type ClassTeacher struct { + ID uuid.UUID `json:"id" db:"id"` + ClassID uuid.UUID `json:"class_id" db:"class_id"` + TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` + IsPrimary bool `json:"is_primary" db:"is_primary"` // Hauptklassenlehrer vs. Stellvertreter + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// TeacherSubject assigns subjects to teachers +type TeacherSubject struct { + ID uuid.UUID `json:"id" db:"id"` + TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` + SubjectID uuid.UUID `json:"subject_id" db:"subject_id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// Parent represents a parent/guardian +type Parent struct { + ID uuid.UUID `json:"id" db:"id"` + UserID uuid.UUID `json:"user_id" db:"user_id"` // Linked user account + MatrixUserID *string `json:"matrix_user_id,omitempty" db:"matrix_user_id"` + FirstName string `json:"first_name" db:"first_name"` + LastName string `json:"last_name" db:"last_name"` + Phone *string `json:"phone,omitempty" db:"phone"` + EmergencyContact bool `json:"emergency_contact" db:"emergency_contact"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// StudentParent links students to their parents +type StudentParent struct { + ID uuid.UUID `json:"id" db:"id"` + StudentID uuid.UUID `json:"student_id" db:"student_id"` + ParentID uuid.UUID `json:"parent_id" db:"parent_id"` + Relationship string `json:"relationship" db:"relationship"` // 'mother', 'father', 'guardian', 'other' + IsPrimary bool `json:"is_primary" db:"is_primary"` // Hauptansprechpartner + HasCustody bool `json:"has_custody" db:"has_custody"` // Sorgeberechtigt + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// ParentRepresentative assigns parent representatives to classes +type ParentRepresentative struct { + ID uuid.UUID `json:"id" db:"id"` + ClassID uuid.UUID `json:"class_id" db:"class_id"` + ParentID uuid.UUID `json:"parent_id" db:"parent_id"` + Role string `json:"role" db:"role"` // 'first_rep', 'second_rep', 'substitute' + ElectedAt time.Time `json:"elected_at" db:"elected_at"` + ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"` + IsActive bool `json:"is_active" db:"is_active"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// ======================================== +// Stundenplan / Timetable +// ======================================== + +// TimetableSlot represents a time slot in the timetable +type TimetableSlot struct { + ID uuid.UUID `json:"id" db:"id"` + SchoolID uuid.UUID `json:"school_id" db:"school_id"` + SlotNumber int `json:"slot_number" db:"slot_number"` // 1, 2, 3... (Stunde) + StartTime string `json:"start_time" db:"start_time"` // "08:00" + EndTime string `json:"end_time" db:"end_time"` // "08:45" + IsBreak bool `json:"is_break" db:"is_break"` // Pause + Name *string `json:"name,omitempty" db:"name"` // e.g., "1. Stunde", "Große Pause" +} + +// TimetableEntry represents a single lesson in the timetable +type TimetableEntry struct { + ID uuid.UUID `json:"id" db:"id"` + SchoolYearID uuid.UUID `json:"school_year_id" db:"school_year_id"` + ClassID uuid.UUID `json:"class_id" db:"class_id"` + SubjectID uuid.UUID `json:"subject_id" db:"subject_id"` + TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` + SlotID uuid.UUID `json:"slot_id" db:"slot_id"` + DayOfWeek int `json:"day_of_week" db:"day_of_week"` // 1=Monday, 5=Friday + Room *string `json:"room,omitempty" db:"room"` + ValidFrom time.Time `json:"valid_from" db:"valid_from"` + ValidUntil *time.Time `json:"valid_until,omitempty" db:"valid_until"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// TimetableSubstitution represents a substitution/replacement lesson +type TimetableSubstitution struct { + ID uuid.UUID `json:"id" db:"id"` + OriginalEntryID uuid.UUID `json:"original_entry_id" db:"original_entry_id"` + Date time.Time `json:"date" db:"date"` + SubstituteTeacherID *uuid.UUID `json:"substitute_teacher_id,omitempty" db:"substitute_teacher_id"` + SubstituteSubjectID *uuid.UUID `json:"substitute_subject_id,omitempty" db:"substitute_subject_id"` + Room *string `json:"room,omitempty" db:"room"` + Type string `json:"type" db:"type"` // 'substitution', 'cancelled', 'room_change', 'supervision' + Note *string `json:"note,omitempty" db:"note"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + CreatedBy uuid.UUID `json:"created_by" db:"created_by"` +} + +// ======================================== +// Abwesenheit / Attendance +// ======================================== + +// AttendanceRecord represents a student's attendance for a specific lesson +type AttendanceRecord struct { + ID uuid.UUID `json:"id" db:"id"` + StudentID uuid.UUID `json:"student_id" db:"student_id"` + TimetableEntryID *uuid.UUID `json:"timetable_entry_id,omitempty" db:"timetable_entry_id"` + Date time.Time `json:"date" db:"date"` + SlotID uuid.UUID `json:"slot_id" db:"slot_id"` + Status string `json:"status" db:"status"` // AttendanceStatus constants + RecordedBy uuid.UUID `json:"recorded_by" db:"recorded_by"` // Teacher who recorded + Note *string `json:"note,omitempty" db:"note"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// AbsenceReport represents a full absence report (one or more days) +type AbsenceReport struct { + ID uuid.UUID `json:"id" db:"id"` + StudentID uuid.UUID `json:"student_id" db:"student_id"` + StartDate time.Time `json:"start_date" db:"start_date"` + EndDate time.Time `json:"end_date" db:"end_date"` + Reason *string `json:"reason,omitempty" db:"reason"` + ReasonCategory string `json:"reason_category" db:"reason_category"` // 'illness', 'family', 'appointment', 'other' + Status string `json:"status" db:"status"` // 'reported', 'confirmed', 'excused', 'unexcused' + ReportedBy uuid.UUID `json:"reported_by" db:"reported_by"` // Parent or student + ReportedAt time.Time `json:"reported_at" db:"reported_at"` + ConfirmedBy *uuid.UUID `json:"confirmed_by,omitempty" db:"confirmed_by"` // Teacher + ConfirmedAt *time.Time `json:"confirmed_at,omitempty" db:"confirmed_at"` + MedicalCertificate bool `json:"medical_certificate" db:"medical_certificate"` // Attestpflicht + CertificateUploaded bool `json:"certificate_uploaded" db:"certificate_uploaded"` + MatrixNotificationSent bool `json:"matrix_notification_sent" db:"matrix_notification_sent"` + EmailNotificationSent bool `json:"email_notification_sent" db:"email_notification_sent"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// AbsenceNotification tracks notifications sent to parents about absences +type AbsenceNotification struct { + ID uuid.UUID `json:"id" db:"id"` + AttendanceRecordID uuid.UUID `json:"attendance_record_id" db:"attendance_record_id"` + ParentID uuid.UUID `json:"parent_id" db:"parent_id"` + Channel string `json:"channel" db:"channel"` // 'matrix', 'email', 'push' + MessageContent string `json:"message_content" db:"message_content"` + SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"` + ReadAt *time.Time `json:"read_at,omitempty" db:"read_at"` + ResponseReceived bool `json:"response_received" db:"response_received"` + ResponseContent *string `json:"response_content,omitempty" db:"response_content"` + ResponseAt *time.Time `json:"response_at,omitempty" db:"response_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// ======================================== +// Notenspiegel / Grades +// ======================================== + +// GradeType defines the type of grade +const ( + GradeTypeExam = "exam" // Klassenarbeit/Klausur + GradeTypeTest = "test" // Test/Kurzarbeit + GradeTypeOral = "oral" // Mündlich + GradeTypeHomework = "homework" // Hausaufgabe + GradeTypeProject = "project" // Projekt + GradeTypeParticipation = "participation" // Mitarbeit + GradeTypeSemester = "semester" // Halbjahres-/Semesternote + GradeTypeFinal = "final" // Endnote/Zeugnisnote +) + +// GradeScale represents the grading scale used +type GradeScale struct { + ID uuid.UUID `json:"id" db:"id"` + SchoolID uuid.UUID `json:"school_id" db:"school_id"` + Name string `json:"name" db:"name"` // e.g., "1-6", "Punkte 0-15" + MinValue float64 `json:"min_value" db:"min_value"` // e.g., 1 or 0 + MaxValue float64 `json:"max_value" db:"max_value"` // e.g., 6 or 15 + PassingValue float64 `json:"passing_value" db:"passing_value"` // e.g., 4 or 5 + IsAscending bool `json:"is_ascending" db:"is_ascending"` // true: higher=better (Punkte), false: lower=better (Noten) + IsDefault bool `json:"is_default" db:"is_default"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// Grade represents a single grade for a student +type Grade struct { + ID uuid.UUID `json:"id" db:"id"` + StudentID uuid.UUID `json:"student_id" db:"student_id"` + SubjectID uuid.UUID `json:"subject_id" db:"subject_id"` + TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` + SchoolYearID uuid.UUID `json:"school_year_id" db:"school_year_id"` + GradeScaleID uuid.UUID `json:"grade_scale_id" db:"grade_scale_id"` + Type string `json:"type" db:"type"` // GradeType constants + Value float64 `json:"value" db:"value"` + Weight float64 `json:"weight" db:"weight"` // Gewichtung: 1.0, 2.0, 0.5 + Date time.Time `json:"date" db:"date"` + Title *string `json:"title,omitempty" db:"title"` // e.g., "1. Klassenarbeit" + Description *string `json:"description,omitempty" db:"description"` + IsVisible bool `json:"is_visible" db:"is_visible"` // Für Eltern/Schüler sichtbar + Semester int `json:"semester" db:"semester"` // 1 or 2 + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// GradeComment represents a teacher comment on a student's grade +type GradeComment struct { + ID uuid.UUID `json:"id" db:"id"` + GradeID uuid.UUID `json:"grade_id" db:"grade_id"` + TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` + Comment string `json:"comment" db:"comment"` + IsPrivate bool `json:"is_private" db:"is_private"` // Only visible to teachers + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// ======================================== +// Klassenbuch / Class Diary +// ======================================== + +// ClassDiaryEntry represents an entry in the digital class diary +type ClassDiaryEntry struct { + ID uuid.UUID `json:"id" db:"id"` + ClassID uuid.UUID `json:"class_id" db:"class_id"` + Date time.Time `json:"date" db:"date"` + SlotID uuid.UUID `json:"slot_id" db:"slot_id"` + SubjectID uuid.UUID `json:"subject_id" db:"subject_id"` + TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` + Topic *string `json:"topic,omitempty" db:"topic"` // Unterrichtsthema + Homework *string `json:"homework,omitempty" db:"homework"` // Hausaufgabe + HomeworkDueDate *time.Time `json:"homework_due_date,omitempty" db:"homework_due_date"` + Materials *string `json:"materials,omitempty" db:"materials"` // Benötigte Materialien + Notes *string `json:"notes,omitempty" db:"notes"` // Besondere Vorkommnisse + IsCancelled bool `json:"is_cancelled" db:"is_cancelled"` + CancellationReason *string `json:"cancellation_reason,omitempty" db:"cancellation_reason"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// ======================================== +// Elterngespräche / Parent Meetings +// ======================================== + +// ParentMeetingSlot represents available time slots for parent meetings +type ParentMeetingSlot struct { + ID uuid.UUID `json:"id" db:"id"` + TeacherID uuid.UUID `json:"teacher_id" db:"teacher_id"` + Date time.Time `json:"date" db:"date"` + StartTime string `json:"start_time" db:"start_time"` // "14:00" + EndTime string `json:"end_time" db:"end_time"` // "14:15" + Location *string `json:"location,omitempty" db:"location"` // Room or "Online" + IsOnline bool `json:"is_online" db:"is_online"` + MeetingLink *string `json:"meeting_link,omitempty" db:"meeting_link"` + IsBooked bool `json:"is_booked" db:"is_booked"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// ParentMeeting represents a booked parent-teacher meeting +type ParentMeeting struct { + ID uuid.UUID `json:"id" db:"id"` + SlotID uuid.UUID `json:"slot_id" db:"slot_id"` + ParentID uuid.UUID `json:"parent_id" db:"parent_id"` + StudentID uuid.UUID `json:"student_id" db:"student_id"` + Topic *string `json:"topic,omitempty" db:"topic"` + Notes *string `json:"notes,omitempty" db:"notes"` // Teacher notes (private) + Status string `json:"status" db:"status"` // 'scheduled', 'completed', 'cancelled', 'no_show' + CancelledAt *time.Time `json:"cancelled_at,omitempty" db:"cancelled_at"` + CancelledBy *uuid.UUID `json:"cancelled_by,omitempty" db:"cancelled_by"` + CancelReason *string `json:"cancel_reason,omitempty" db:"cancel_reason"` + CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// ======================================== +// Matrix / Communication Integration +// ======================================== + +// MatrixRoom tracks Matrix rooms created for school communication +type MatrixRoom struct { + ID uuid.UUID `json:"id" db:"id"` + SchoolID uuid.UUID `json:"school_id" db:"school_id"` + MatrixRoomID string `json:"matrix_room_id" db:"matrix_room_id"` // e.g., "!abc123:breakpilot.local" + Type string `json:"type" db:"type"` // 'class_info', 'class_rep', 'student_dm', 'teacher_dm', 'announcement' + ClassID *uuid.UUID `json:"class_id,omitempty" db:"class_id"` + StudentID *uuid.UUID `json:"student_id,omitempty" db:"student_id"` + Name string `json:"name" db:"name"` + IsEncrypted bool `json:"is_encrypted" db:"is_encrypted"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// MatrixRoomMember tracks membership in Matrix rooms +type MatrixRoomMember struct { + ID uuid.UUID `json:"id" db:"id"` + MatrixRoomID uuid.UUID `json:"matrix_room_id" db:"matrix_room_id"` // FK to MatrixRoom + MatrixUserID string `json:"matrix_user_id" db:"matrix_user_id"` // e.g., "@user:breakpilot.local" + UserID *uuid.UUID `json:"user_id,omitempty" db:"user_id"` // FK to User (if known) + PowerLevel int `json:"power_level" db:"power_level"` // Matrix power level (0, 50, 100) + CanWrite bool `json:"can_write" db:"can_write"` + JoinedAt time.Time `json:"joined_at" db:"joined_at"` + LeftAt *time.Time `json:"left_at,omitempty" db:"left_at"` +} + +// ParentOnboardingToken for QR-code based parent onboarding +type ParentOnboardingToken struct { + ID uuid.UUID `json:"id" db:"id"` + SchoolID uuid.UUID `json:"school_id" db:"school_id"` + ClassID uuid.UUID `json:"class_id" db:"class_id"` + StudentID uuid.UUID `json:"student_id" db:"student_id"` + Token string `json:"token" db:"token"` // Unique token for QR code + Role string `json:"role" db:"role"` // 'parent' or 'parent_representative' + ExpiresAt time.Time `json:"expires_at" db:"expires_at"` + UsedAt *time.Time `json:"used_at,omitempty" db:"used_at"` + UsedByUserID *uuid.UUID `json:"used_by_user_id,omitempty" db:"used_by_user_id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + CreatedBy uuid.UUID `json:"created_by" db:"created_by"` // Teacher who created +} + +// ======================================== +// Schulverwaltung DTOs +// ======================================== + +// CreateSchoolRequest for creating a new school +type CreateSchoolRequest struct { + Name string `json:"name" binding:"required"` + ShortName *string `json:"short_name"` + Type string `json:"type" binding:"required"` + Address *string `json:"address"` + City *string `json:"city"` + PostalCode *string `json:"postal_code"` + State *string `json:"state"` + Phone *string `json:"phone"` + Email *string `json:"email"` + Website *string `json:"website"` +} + +// CreateClassRequest for creating a new class +type CreateClassRequest struct { + SchoolYearID string `json:"school_year_id" binding:"required"` + Name string `json:"name" binding:"required"` + Grade int `json:"grade" binding:"required"` + Section *string `json:"section"` + Room *string `json:"room"` +} + +// CreateStudentRequest for creating a new student +type CreateStudentRequest struct { + ClassID string `json:"class_id" binding:"required"` + StudentNumber *string `json:"student_number"` + FirstName string `json:"first_name" binding:"required"` + LastName string `json:"last_name" binding:"required"` + DateOfBirth *string `json:"date_of_birth"` // ISO 8601 + Gender *string `json:"gender"` +} + +// RecordAttendanceRequest for recording attendance +type RecordAttendanceRequest struct { + StudentID string `json:"student_id" binding:"required"` + Date string `json:"date" binding:"required"` // ISO 8601 + SlotID string `json:"slot_id" binding:"required"` + Status string `json:"status" binding:"required"` // AttendanceStatus + Note *string `json:"note"` +} + +// ReportAbsenceRequest for parents reporting absence +type ReportAbsenceRequest struct { + StudentID string `json:"student_id" binding:"required"` + StartDate string `json:"start_date" binding:"required"` // ISO 8601 + EndDate string `json:"end_date" binding:"required"` // ISO 8601 + Reason *string `json:"reason"` + ReasonCategory string `json:"reason_category" binding:"required"` +} + +// CreateGradeRequest for creating a grade +type CreateGradeRequest struct { + StudentID string `json:"student_id" binding:"required"` + SubjectID string `json:"subject_id" binding:"required"` + SchoolYearID string `json:"school_year_id" binding:"required"` + Type string `json:"type" binding:"required"` // GradeType + Value float64 `json:"value" binding:"required"` + Weight float64 `json:"weight"` + Date string `json:"date" binding:"required"` // ISO 8601 + Title *string `json:"title"` + Description *string `json:"description"` + Semester int `json:"semester" binding:"required"` +} + +// StudentGradeOverview provides a summary of all grades for a student in a subject +type StudentGradeOverview struct { + Student Student `json:"student"` + Subject Subject `json:"subject"` + Grades []Grade `json:"grades"` + Average float64 `json:"average"` + OralAverage float64 `json:"oral_average"` + ExamAverage float64 `json:"exam_average"` + Semester int `json:"semester"` +} + +// ClassAttendanceOverview provides attendance summary for a class +type ClassAttendanceOverview struct { + Class Class `json:"class"` + Date time.Time `json:"date"` + TotalStudents int `json:"total_students"` + PresentCount int `json:"present_count"` + AbsentCount int `json:"absent_count"` + LateCount int `json:"late_count"` + Records []AttendanceRecord `json:"records"` +} + +// ParentDashboard provides a parent's view of their children's data +type ParentDashboard struct { + Children []StudentOverview `json:"children"` + UnreadMessages int `json:"unread_messages"` + UpcomingMeetings []ParentMeeting `json:"upcoming_meetings"` + RecentGrades []Grade `json:"recent_grades"` + PendingActions []string `json:"pending_actions"` // e.g., "Entschuldigung ausstehend" +} + +// StudentOverview provides summary info about a student +type StudentOverview struct { + Student Student `json:"student"` + Class Class `json:"class"` + ClassTeacher *Teacher `json:"class_teacher,omitempty"` + AttendanceRate float64 `json:"attendance_rate"` // Percentage + UnexcusedAbsences int `json:"unexcused_absences"` + GradeAverage float64 `json:"grade_average"` +} + +// TimetableView provides a formatted timetable for display +type TimetableView struct { + Class Class `json:"class"` + Week string `json:"week"` // ISO week: "2025-W01" + Days []TimetableDayView `json:"days"` +} + +// TimetableDayView represents a single day in the timetable +type TimetableDayView struct { + Date time.Time `json:"date"` + DayName string `json:"day_name"` // "Montag" + Lessons []TimetableLessonView `json:"lessons"` +} + +// TimetableLessonView represents a single lesson in the timetable view +type TimetableLessonView struct { + Slot TimetableSlot `json:"slot"` + Subject *Subject `json:"subject,omitempty"` + Teacher *Teacher `json:"teacher,omitempty"` + Room *string `json:"room,omitempty"` + IsSubstitution bool `json:"is_substitution"` + IsCancelled bool `json:"is_cancelled"` + Note *string `json:"note,omitempty"` +} + +// ======================================== +// Phase 10: DSGVO Betroffenenanfragen (DSR) +// Data Subject Request Management +// Art. 15, 16, 17, 18, 20 DSGVO +// ======================================== + +// DSRRequestType defines the GDPR article for the request +type DSRRequestType string + +const ( + DSRTypeAccess DSRRequestType = "access" // Art. 15 - Auskunftsrecht + DSRTypeRectification DSRRequestType = "rectification" // Art. 16 - Berichtigungsrecht + DSRTypeErasure DSRRequestType = "erasure" // Art. 17 - Löschungsrecht + DSRTypeRestriction DSRRequestType = "restriction" // Art. 18 - Einschränkungsrecht + DSRTypePortability DSRRequestType = "portability" // Art. 20 - Datenübertragbarkeit +) + +// DSRStatus defines the workflow state of a DSR +type DSRStatus string + +const ( + DSRStatusIntake DSRStatus = "intake" // Eingegangen + DSRStatusIdentityVerification DSRStatus = "identity_verification" // Identitätsprüfung + DSRStatusProcessing DSRStatus = "processing" // In Bearbeitung + DSRStatusCompleted DSRStatus = "completed" // Abgeschlossen + DSRStatusRejected DSRStatus = "rejected" // Abgelehnt + DSRStatusCancelled DSRStatus = "cancelled" // Storniert +) + +// DSRPriority defines the priority level of a DSR +type DSRPriority string + +const ( + DSRPriorityNormal DSRPriority = "normal" + DSRPriorityExpedited DSRPriority = "expedited" // Art. 16, 17, 18 - beschleunigt + DSRPriorityUrgent DSRPriority = "urgent" +) + +// DSRSource defines where the request came from +type DSRSource string + +const ( + DSRSourceAPI DSRSource = "api" // Über API/Self-Service + DSRSourceAdminPanel DSRSource = "admin_panel" // Manuell im Admin + DSRSourceEmail DSRSource = "email" // Per E-Mail + DSRSourcePostal DSRSource = "postal" // Per Post +) + +// DataSubjectRequest represents a GDPR data subject request +type DataSubjectRequest struct { + ID uuid.UUID `json:"id" db:"id"` + UserID *uuid.UUID `json:"user_id,omitempty" db:"user_id"` + RequestNumber string `json:"request_number" db:"request_number"` + RequestType DSRRequestType `json:"request_type" db:"request_type"` + Status DSRStatus `json:"status" db:"status"` + Priority DSRPriority `json:"priority" db:"priority"` + Source DSRSource `json:"source" db:"source"` + RequesterEmail string `json:"requester_email" db:"requester_email"` + RequesterName *string `json:"requester_name,omitempty" db:"requester_name"` + RequesterPhone *string `json:"requester_phone,omitempty" db:"requester_phone"` + IdentityVerified bool `json:"identity_verified" db:"identity_verified"` + IdentityVerifiedAt *time.Time `json:"identity_verified_at,omitempty" db:"identity_verified_at"` + IdentityVerifiedBy *uuid.UUID `json:"identity_verified_by,omitempty" db:"identity_verified_by"` + IdentityVerificationMethod *string `json:"identity_verification_method,omitempty" db:"identity_verification_method"` + RequestDetails map[string]interface{} `json:"request_details" db:"request_details"` + DeadlineAt time.Time `json:"deadline_at" db:"deadline_at"` + LegalDeadlineDays int `json:"legal_deadline_days" db:"legal_deadline_days"` + ExtendedDeadlineAt *time.Time `json:"extended_deadline_at,omitempty" db:"extended_deadline_at"` + ExtensionReason *string `json:"extension_reason,omitempty" db:"extension_reason"` + AssignedTo *uuid.UUID `json:"assigned_to,omitempty" db:"assigned_to"` + ProcessingNotes *string `json:"processing_notes,omitempty" db:"processing_notes"` + CompletedAt *time.Time `json:"completed_at,omitempty" db:"completed_at"` + CompletedBy *uuid.UUID `json:"completed_by,omitempty" db:"completed_by"` + ResultSummary *string `json:"result_summary,omitempty" db:"result_summary"` + ResultData map[string]interface{} `json:"result_data,omitempty" db:"result_data"` + RejectedAt *time.Time `json:"rejected_at,omitempty" db:"rejected_at"` + RejectedBy *uuid.UUID `json:"rejected_by,omitempty" db:"rejected_by"` + RejectionReason *string `json:"rejection_reason,omitempty" db:"rejection_reason"` + RejectionLegalBasis *string `json:"rejection_legal_basis,omitempty" db:"rejection_legal_basis"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + CreatedBy *uuid.UUID `json:"created_by,omitempty" db:"created_by"` +} + +// DSRStatusHistory tracks status changes for audit trail +type DSRStatusHistory struct { + ID uuid.UUID `json:"id" db:"id"` + RequestID uuid.UUID `json:"request_id" db:"request_id"` + FromStatus *DSRStatus `json:"from_status,omitempty" db:"from_status"` + ToStatus DSRStatus `json:"to_status" db:"to_status"` + ChangedBy *uuid.UUID `json:"changed_by,omitempty" db:"changed_by"` + Comment *string `json:"comment,omitempty" db:"comment"` + Metadata map[string]interface{} `json:"metadata,omitempty" db:"metadata"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// DSRCommunication tracks all communications related to a DSR +type DSRCommunication struct { + ID uuid.UUID `json:"id" db:"id"` + RequestID uuid.UUID `json:"request_id" db:"request_id"` + Direction string `json:"direction" db:"direction"` // 'outbound', 'inbound' + Channel string `json:"channel" db:"channel"` // 'email', 'in_app', 'postal' + CommunicationType string `json:"communication_type" db:"communication_type"` // Template type used + TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty" db:"template_version_id"` + Subject *string `json:"subject,omitempty" db:"subject"` + BodyHTML *string `json:"body_html,omitempty" db:"body_html"` + BodyText *string `json:"body_text,omitempty" db:"body_text"` + RecipientEmail *string `json:"recipient_email,omitempty" db:"recipient_email"` + SentAt *time.Time `json:"sent_at,omitempty" db:"sent_at"` + ErrorMessage *string `json:"error_message,omitempty" db:"error_message"` + Attachments []map[string]interface{} `json:"attachments,omitempty" db:"attachments"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + CreatedBy *uuid.UUID `json:"created_by,omitempty" db:"created_by"` +} + +// DSRTemplate represents a template type for DSR communications +type DSRTemplate struct { + ID uuid.UUID `json:"id" db:"id"` + TemplateType string `json:"template_type" db:"template_type"` + Name string `json:"name" db:"name"` + Description *string `json:"description,omitempty" db:"description"` + RequestTypes []string `json:"request_types" db:"request_types"` // Which DSR types use this template + IsActive bool `json:"is_active" db:"is_active"` + SortOrder int `json:"sort_order" db:"sort_order"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// DSRTemplateVersion represents a versioned template for DSR communications +type DSRTemplateVersion struct { + ID uuid.UUID `json:"id" db:"id"` + TemplateID uuid.UUID `json:"template_id" db:"template_id"` + Version string `json:"version" db:"version"` + Language string `json:"language" db:"language"` + Subject string `json:"subject" db:"subject"` + BodyHTML string `json:"body_html" db:"body_html"` + BodyText string `json:"body_text" db:"body_text"` + Status string `json:"status" db:"status"` // draft, review, approved, published, archived + PublishedAt *time.Time `json:"published_at,omitempty" db:"published_at"` + CreatedBy *uuid.UUID `json:"created_by,omitempty" db:"created_by"` + ApprovedBy *uuid.UUID `json:"approved_by,omitempty" db:"approved_by"` + ApprovedAt *time.Time `json:"approved_at,omitempty" db:"approved_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// DSRExceptionCheck tracks Art. 17(3) exception evaluations for erasure requests +type DSRExceptionCheck struct { + ID uuid.UUID `json:"id" db:"id"` + RequestID uuid.UUID `json:"request_id" db:"request_id"` + ExceptionType string `json:"exception_type" db:"exception_type"` // Type of exception (Art. 17(3) a-e) + Description string `json:"description" db:"description"` + Applies *bool `json:"applies,omitempty" db:"applies"` // nil = not checked, true/false = result + CheckedBy *uuid.UUID `json:"checked_by,omitempty" db:"checked_by"` + CheckedAt *time.Time `json:"checked_at,omitempty" db:"checked_at"` + Notes *string `json:"notes,omitempty" db:"notes"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// Art. 17(3) Exception Types +const ( + DSRExceptionFreedomExpression = "freedom_expression" // Art. 17(3)(a) + DSRExceptionLegalObligation = "legal_obligation" // Art. 17(3)(b) + DSRExceptionPublicInterest = "public_interest" // Art. 17(3)(c) + DSRExceptionPublicHealth = "public_health" // Art. 17(3)(c) + DSRExceptionArchiving = "archiving" // Art. 17(3)(d) + DSRExceptionLegalClaims = "legal_claims" // Art. 17(3)(e) +) + +// ======================================== +// DSR DTOs (Data Transfer Objects) +// ======================================== + +// CreateDSRRequest for creating a new data subject request +type CreateDSRRequest struct { + RequestType string `json:"request_type" binding:"required"` + RequesterEmail string `json:"requester_email" binding:"required,email"` + RequesterName *string `json:"requester_name"` + RequesterPhone *string `json:"requester_phone"` + Source string `json:"source"` + RequestDetails map[string]interface{} `json:"request_details"` + Priority string `json:"priority"` +} + +// UpdateDSRRequest for updating a DSR +type UpdateDSRRequest struct { + Status *string `json:"status"` + AssignedTo *string `json:"assigned_to"` // UUID string + ProcessingNotes *string `json:"processing_notes"` + ExtendDeadline *bool `json:"extend_deadline"` + ExtensionReason *string `json:"extension_reason"` + RequestDetails map[string]interface{} `json:"request_details"` + Priority *string `json:"priority"` +} + +// VerifyDSRIdentityRequest for verifying identity of requester +type VerifyDSRIdentityRequest struct { + Method string `json:"method" binding:"required"` // email_confirmation, id_document, in_person + Comment *string `json:"comment"` +} + +// CompleteDSRRequest for completing a DSR +type CompleteDSRRequest struct { + ResultSummary string `json:"result_summary" binding:"required"` + ResultData map[string]interface{} `json:"result_data"` +} + +// RejectDSRRequest for rejecting a DSR +type RejectDSRRequest struct { + Reason string `json:"reason" binding:"required"` + LegalBasis string `json:"legal_basis" binding:"required"` // Art. 12(5), Art. 17(3)(a-e), etc. +} + +// ExtendDSRDeadlineRequest for extending a DSR deadline +type ExtendDSRDeadlineRequest struct { + Reason string `json:"reason" binding:"required"` + Days int `json:"days"` // Optional: custom extension days +} + +// AssignDSRRequest for assigning a DSR to a handler +type AssignDSRRequest struct { + AssigneeID string `json:"assignee_id" binding:"required"` + Comment *string `json:"comment"` +} + +// SendDSRCommunicationRequest for sending a communication +type SendDSRCommunicationRequest struct { + CommunicationType string `json:"communication_type" binding:"required"` + TemplateVersionID *string `json:"template_version_id"` + CustomSubject *string `json:"custom_subject"` + CustomBody *string `json:"custom_body"` + Variables map[string]string `json:"variables"` +} + +// UpdateDSRExceptionCheckRequest for updating an exception check +type UpdateDSRExceptionCheckRequest struct { + Applies bool `json:"applies"` + Notes *string `json:"notes"` +} + +// DSRListFilters for filtering DSR list +type DSRListFilters struct { + Status *string `form:"status"` + RequestType *string `form:"request_type"` + AssignedTo *string `form:"assigned_to"` + Priority *string `form:"priority"` + OverdueOnly bool `form:"overdue_only"` + FromDate *time.Time `form:"from_date"` + ToDate *time.Time `form:"to_date"` + Search *string `form:"search"` // Search in request number, email, name +} + +// DSRDashboardStats for the admin dashboard +type DSRDashboardStats struct { + TotalRequests int `json:"total_requests"` + PendingRequests int `json:"pending_requests"` + OverdueRequests int `json:"overdue_requests"` + CompletedThisMonth int `json:"completed_this_month"` + AverageProcessingDays float64 `json:"average_processing_days"` + ByType map[string]int `json:"by_type"` + ByStatus map[string]int `json:"by_status"` + UpcomingDeadlines []DataSubjectRequest `json:"upcoming_deadlines"` +} + +// DSRWithDetails combines DSR with related data +type DSRWithDetails struct { + Request DataSubjectRequest `json:"request"` + StatusHistory []DSRStatusHistory `json:"status_history"` + Communications []DSRCommunication `json:"communications"` + ExceptionChecks []DSRExceptionCheck `json:"exception_checks,omitempty"` + AssigneeName *string `json:"assignee_name,omitempty"` + CreatorName *string `json:"creator_name,omitempty"` +} + +// DSRTemplateWithVersions combines template with versions +type DSRTemplateWithVersions struct { + Template DSRTemplate `json:"template"` + LatestVersion *DSRTemplateVersion `json:"latest_version,omitempty"` + Versions []DSRTemplateVersion `json:"versions,omitempty"` +} + +// CreateDSRTemplateVersionRequest for creating a template version +type CreateDSRTemplateVersionRequest struct { + TemplateID string `json:"template_id" binding:"required"` + Version string `json:"version" binding:"required"` + Language string `json:"language" binding:"required"` + Subject string `json:"subject" binding:"required"` + BodyHTML string `json:"body_html" binding:"required"` + BodyText string `json:"body_text" binding:"required"` +} + +// UpdateDSRTemplateVersionRequest for updating a template version +type UpdateDSRTemplateVersionRequest struct { + Subject *string `json:"subject"` + BodyHTML *string `json:"body_html"` + BodyText *string `json:"body_text"` + Status *string `json:"status"` +} + +// PreviewDSRTemplateRequest for previewing a template with variables +type PreviewDSRTemplateRequest struct { + Variables map[string]string `json:"variables"` +} + +// DSRTemplatePreviewResponse for template preview +type DSRTemplatePreviewResponse struct { + Subject string `json:"subject"` + BodyHTML string `json:"body_html"` + BodyText string `json:"body_text"` +} + +// GetRequestTypeLabel returns German label for request type +func (rt DSRRequestType) Label() string { + switch rt { + case DSRTypeAccess: + return "Auskunftsanfrage (Art. 15)" + case DSRTypeRectification: + return "Berichtigungsanfrage (Art. 16)" + case DSRTypeErasure: + return "Löschanfrage (Art. 17)" + case DSRTypeRestriction: + return "Einschränkungsanfrage (Art. 18)" + case DSRTypePortability: + return "Datenübertragung (Art. 20)" + default: + return string(rt) + } +} + +// GetDeadlineDays returns the legal deadline in days for request type +func (rt DSRRequestType) DeadlineDays() int { + switch rt { + case DSRTypeAccess, DSRTypePortability: + return 30 // 1 month + case DSRTypeRectification, DSRTypeErasure, DSRTypeRestriction: + return 14 // 2 weeks (expedited per BDSG) + default: + return 30 + } +} + +// IsExpedited returns whether this request type should be processed expeditiously +func (rt DSRRequestType) IsExpedited() bool { + switch rt { + case DSRTypeRectification, DSRTypeErasure, DSRTypeRestriction: + return true + default: + return false + } +} + +// GetStatusLabel returns German label for status +func (s DSRStatus) Label() string { + switch s { + case DSRStatusIntake: + return "Eingang" + case DSRStatusIdentityVerification: + return "Identitätsprüfung" + case DSRStatusProcessing: + return "In Bearbeitung" + case DSRStatusCompleted: + return "Abgeschlossen" + case DSRStatusRejected: + return "Abgelehnt" + case DSRStatusCancelled: + return "Storniert" + default: + return string(s) + } +} + +// IsValidDSRRequestType checks if a string is a valid DSR request type +func IsValidDSRRequestType(reqType string) bool { + switch DSRRequestType(reqType) { + case DSRTypeAccess, DSRTypeRectification, DSRTypeErasure, DSRTypeRestriction, DSRTypePortability: + return true + default: + return false + } +} + +// IsValidDSRStatus checks if a string is a valid DSR status +func IsValidDSRStatus(status string) bool { + switch DSRStatus(status) { + case DSRStatusIntake, DSRStatusIdentityVerification, DSRStatusProcessing, + DSRStatusCompleted, DSRStatusRejected, DSRStatusCancelled: + return true + default: + return false + } +} diff --git a/consent-service/internal/services/attendance_service.go b/consent-service/internal/services/attendance_service.go new file mode 100644 index 0000000..046e6f8 --- /dev/null +++ b/consent-service/internal/services/attendance_service.go @@ -0,0 +1,505 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/breakpilot/consent-service/internal/database" + "github.com/breakpilot/consent-service/internal/models" + "github.com/breakpilot/consent-service/internal/services/matrix" + + "github.com/google/uuid" +) + +// AttendanceService handles attendance tracking and notifications +type AttendanceService struct { + db *database.DB + matrix *matrix.MatrixService +} + +// NewAttendanceService creates a new attendance service +func NewAttendanceService(db *database.DB, matrixService *matrix.MatrixService) *AttendanceService { + return &AttendanceService{ + db: db, + matrix: matrixService, + } +} + +// ======================================== +// Attendance Recording +// ======================================== + +// RecordAttendance records a student's attendance for a specific lesson +func (s *AttendanceService) RecordAttendance(ctx context.Context, req models.RecordAttendanceRequest, recordedByUserID uuid.UUID) (*models.AttendanceRecord, error) { + studentID, err := uuid.Parse(req.StudentID) + if err != nil { + return nil, fmt.Errorf("invalid student ID: %w", err) + } + + slotID, err := uuid.Parse(req.SlotID) + if err != nil { + return nil, fmt.Errorf("invalid slot ID: %w", err) + } + + date, err := time.Parse("2006-01-02", req.Date) + if err != nil { + return nil, fmt.Errorf("invalid date format: %w", err) + } + + record := &models.AttendanceRecord{ + ID: uuid.New(), + StudentID: studentID, + Date: date, + SlotID: slotID, + Status: req.Status, + RecordedBy: recordedByUserID, + Note: req.Note, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + query := ` + INSERT INTO attendance_records (id, student_id, date, slot_id, status, recorded_by, note, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (student_id, date, slot_id) + DO UPDATE SET status = EXCLUDED.status, note = EXCLUDED.note, updated_at = EXCLUDED.updated_at + RETURNING id` + + err = s.db.Pool.QueryRow(ctx, query, + record.ID, record.StudentID, record.Date, record.SlotID, + record.Status, record.RecordedBy, record.Note, record.CreatedAt, record.UpdatedAt, + ).Scan(&record.ID) + + if err != nil { + return nil, fmt.Errorf("failed to record attendance: %w", err) + } + + // If student is absent, send notification to parents + if record.Status == models.AttendanceAbsent || record.Status == models.AttendancePending { + go s.notifyParentsOfAbsence(context.Background(), record) + } + + return record, nil +} + +// RecordBulkAttendance records attendance for multiple students at once +func (s *AttendanceService) RecordBulkAttendance(ctx context.Context, classID uuid.UUID, date string, slotID uuid.UUID, records []struct { + StudentID string + Status string + Note *string +}, recordedByUserID uuid.UUID) error { + parsedDate, err := time.Parse("2006-01-02", date) + if err != nil { + return fmt.Errorf("invalid date format: %w", err) + } + + for _, rec := range records { + studentID, err := uuid.Parse(rec.StudentID) + if err != nil { + continue + } + + query := ` + INSERT INTO attendance_records (id, student_id, date, slot_id, status, recorded_by, note, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) + ON CONFLICT (student_id, date, slot_id) + DO UPDATE SET status = EXCLUDED.status, note = EXCLUDED.note, updated_at = NOW()` + + _, err = s.db.Pool.Exec(ctx, query, + uuid.New(), studentID, parsedDate, slotID, rec.Status, recordedByUserID, rec.Note, + ) + if err != nil { + return fmt.Errorf("failed to record attendance for student %s: %w", rec.StudentID, err) + } + + // Notify parents if absent + if rec.Status == models.AttendanceAbsent || rec.Status == models.AttendancePending { + go s.notifyParentsOfAbsenceByStudentID(context.Background(), studentID, parsedDate, slotID) + } + } + + return nil +} + +// GetAttendanceByClass gets attendance records for a class on a specific date +func (s *AttendanceService) GetAttendanceByClass(ctx context.Context, classID uuid.UUID, date string) (*models.ClassAttendanceOverview, error) { + parsedDate, err := time.Parse("2006-01-02", date) + if err != nil { + return nil, fmt.Errorf("invalid date format: %w", err) + } + + // Get class info + classQuery := `SELECT id, school_id, school_year_id, name, grade, section, room, is_active FROM classes WHERE id = $1` + class := &models.Class{} + err = s.db.Pool.QueryRow(ctx, classQuery, classID).Scan( + &class.ID, &class.SchoolID, &class.SchoolYearID, &class.Name, + &class.Grade, &class.Section, &class.Room, &class.IsActive, + ) + if err != nil { + return nil, fmt.Errorf("failed to get class: %w", err) + } + + // Get total students + var totalStudents int + err = s.db.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM students WHERE class_id = $1 AND is_active = true`, classID).Scan(&totalStudents) + if err != nil { + return nil, fmt.Errorf("failed to count students: %w", err) + } + + // Get attendance records for the date + recordsQuery := ` + SELECT ar.id, ar.student_id, ar.date, ar.slot_id, ar.status, ar.recorded_by, ar.note, ar.created_at, ar.updated_at + FROM attendance_records ar + JOIN students s ON ar.student_id = s.id + WHERE s.class_id = $1 AND ar.date = $2 + ORDER BY ar.slot_id` + + rows, err := s.db.Pool.Query(ctx, recordsQuery, classID, parsedDate) + if err != nil { + return nil, fmt.Errorf("failed to get attendance records: %w", err) + } + defer rows.Close() + + var records []models.AttendanceRecord + presentCount := 0 + absentCount := 0 + lateCount := 0 + + seenStudents := make(map[uuid.UUID]bool) + + for rows.Next() { + var record models.AttendanceRecord + err := rows.Scan( + &record.ID, &record.StudentID, &record.Date, &record.SlotID, + &record.Status, &record.RecordedBy, &record.Note, &record.CreatedAt, &record.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan attendance record: %w", err) + } + records = append(records, record) + + // Count unique students for summary (use first slot's status) + if !seenStudents[record.StudentID] { + seenStudents[record.StudentID] = true + switch record.Status { + case models.AttendancePresent: + presentCount++ + case models.AttendanceAbsent, models.AttendanceAbsentExcused, models.AttendanceAbsentUnexcused, models.AttendancePending: + absentCount++ + case models.AttendanceLate, models.AttendanceLateExcused: + lateCount++ + } + } + } + + return &models.ClassAttendanceOverview{ + Class: *class, + Date: parsedDate, + TotalStudents: totalStudents, + PresentCount: presentCount, + AbsentCount: absentCount, + LateCount: lateCount, + Records: records, + }, nil +} + +// GetStudentAttendance gets attendance history for a student +func (s *AttendanceService) GetStudentAttendance(ctx context.Context, studentID uuid.UUID, startDate, endDate time.Time) ([]models.AttendanceRecord, error) { + query := ` + SELECT id, student_id, timetable_entry_id, date, slot_id, status, recorded_by, note, created_at, updated_at + FROM attendance_records + WHERE student_id = $1 AND date >= $2 AND date <= $3 + ORDER BY date DESC, slot_id` + + rows, err := s.db.Pool.Query(ctx, query, studentID, startDate, endDate) + if err != nil { + return nil, fmt.Errorf("failed to get student attendance: %w", err) + } + defer rows.Close() + + var records []models.AttendanceRecord + for rows.Next() { + var record models.AttendanceRecord + err := rows.Scan( + &record.ID, &record.StudentID, &record.TimetableEntryID, &record.Date, + &record.SlotID, &record.Status, &record.RecordedBy, &record.Note, + &record.CreatedAt, &record.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan attendance record: %w", err) + } + records = append(records, record) + } + + return records, nil +} + +// ======================================== +// Absence Reports (Parent-initiated) +// ======================================== + +// ReportAbsence allows parents to report a student's absence +func (s *AttendanceService) ReportAbsence(ctx context.Context, req models.ReportAbsenceRequest, reportedByUserID uuid.UUID) (*models.AbsenceReport, error) { + studentID, err := uuid.Parse(req.StudentID) + if err != nil { + return nil, fmt.Errorf("invalid student ID: %w", err) + } + + startDate, err := time.Parse("2006-01-02", req.StartDate) + if err != nil { + return nil, fmt.Errorf("invalid start date format: %w", err) + } + + endDate, err := time.Parse("2006-01-02", req.EndDate) + if err != nil { + return nil, fmt.Errorf("invalid end date format: %w", err) + } + + report := &models.AbsenceReport{ + ID: uuid.New(), + StudentID: studentID, + StartDate: startDate, + EndDate: endDate, + Reason: req.Reason, + ReasonCategory: req.ReasonCategory, + Status: "reported", + ReportedBy: reportedByUserID, + ReportedAt: time.Now(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + query := ` + INSERT INTO absence_reports (id, student_id, start_date, end_date, reason, reason_category, status, reported_by, reported_at, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id` + + err = s.db.Pool.QueryRow(ctx, query, + report.ID, report.StudentID, report.StartDate, report.EndDate, + report.Reason, report.ReasonCategory, report.Status, + report.ReportedBy, report.ReportedAt, report.CreatedAt, report.UpdatedAt, + ).Scan(&report.ID) + + if err != nil { + return nil, fmt.Errorf("failed to create absence report: %w", err) + } + + return report, nil +} + +// ConfirmAbsence allows teachers to confirm/excuse an absence +func (s *AttendanceService) ConfirmAbsence(ctx context.Context, reportID uuid.UUID, confirmedByUserID uuid.UUID, status string) error { + query := ` + UPDATE absence_reports + SET status = $1, confirmed_by = $2, confirmed_at = NOW(), updated_at = NOW() + WHERE id = $3` + + result, err := s.db.Pool.Exec(ctx, query, status, confirmedByUserID, reportID) + if err != nil { + return fmt.Errorf("failed to confirm absence: %w", err) + } + + if result.RowsAffected() == 0 { + return fmt.Errorf("absence report not found") + } + + return nil +} + +// GetAbsenceReports gets absence reports for a student +func (s *AttendanceService) GetAbsenceReports(ctx context.Context, studentID uuid.UUID) ([]models.AbsenceReport, error) { + query := ` + SELECT id, student_id, start_date, end_date, reason, reason_category, status, reported_by, reported_at, confirmed_by, confirmed_at, medical_certificate, certificate_uploaded, matrix_notification_sent, email_notification_sent, created_at, updated_at + FROM absence_reports + WHERE student_id = $1 + ORDER BY start_date DESC` + + rows, err := s.db.Pool.Query(ctx, query, studentID) + if err != nil { + return nil, fmt.Errorf("failed to get absence reports: %w", err) + } + defer rows.Close() + + var reports []models.AbsenceReport + for rows.Next() { + var report models.AbsenceReport + err := rows.Scan( + &report.ID, &report.StudentID, &report.StartDate, &report.EndDate, + &report.Reason, &report.ReasonCategory, &report.Status, + &report.ReportedBy, &report.ReportedAt, &report.ConfirmedBy, &report.ConfirmedAt, + &report.MedicalCertificate, &report.CertificateUploaded, + &report.MatrixNotificationSent, &report.EmailNotificationSent, + &report.CreatedAt, &report.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan absence report: %w", err) + } + reports = append(reports, report) + } + + return reports, nil +} + +// GetPendingAbsenceReports gets all unconfirmed absence reports for a class +func (s *AttendanceService) GetPendingAbsenceReports(ctx context.Context, classID uuid.UUID) ([]models.AbsenceReport, error) { + query := ` + SELECT ar.id, ar.student_id, ar.start_date, ar.end_date, ar.reason, ar.reason_category, ar.status, ar.reported_by, ar.reported_at, ar.confirmed_by, ar.confirmed_at, ar.medical_certificate, ar.certificate_uploaded, ar.matrix_notification_sent, ar.email_notification_sent, ar.created_at, ar.updated_at + FROM absence_reports ar + JOIN students s ON ar.student_id = s.id + WHERE s.class_id = $1 AND ar.status = 'reported' + ORDER BY ar.start_date DESC` + + rows, err := s.db.Pool.Query(ctx, query, classID) + if err != nil { + return nil, fmt.Errorf("failed to get pending absence reports: %w", err) + } + defer rows.Close() + + var reports []models.AbsenceReport + for rows.Next() { + var report models.AbsenceReport + err := rows.Scan( + &report.ID, &report.StudentID, &report.StartDate, &report.EndDate, + &report.Reason, &report.ReasonCategory, &report.Status, + &report.ReportedBy, &report.ReportedAt, &report.ConfirmedBy, &report.ConfirmedAt, + &report.MedicalCertificate, &report.CertificateUploaded, + &report.MatrixNotificationSent, &report.EmailNotificationSent, + &report.CreatedAt, &report.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan absence report: %w", err) + } + reports = append(reports, report) + } + + return reports, nil +} + +// ======================================== +// Attendance Statistics +// ======================================== + +// GetStudentAttendanceStats gets attendance statistics for a student +func (s *AttendanceService) GetStudentAttendanceStats(ctx context.Context, studentID uuid.UUID, schoolYearID uuid.UUID) (map[string]interface{}, error) { + query := ` + SELECT + COUNT(*) as total_records, + COUNT(CASE WHEN status = 'present' THEN 1 END) as present_count, + COUNT(CASE WHEN status IN ('absent', 'excused', 'unexcused', 'pending_confirmation') THEN 1 END) as absent_count, + COUNT(CASE WHEN status = 'unexcused' THEN 1 END) as unexcused_count, + COUNT(CASE WHEN status IN ('late', 'late_excused') THEN 1 END) as late_count + FROM attendance_records ar + JOIN timetable_slots ts ON ar.slot_id = ts.id + JOIN schools sch ON ts.school_id = sch.id + JOIN school_years sy ON sy.school_id = sch.id AND sy.id = $2 + WHERE ar.student_id = $1 AND ar.date >= sy.start_date AND ar.date <= sy.end_date` + + var totalRecords, presentCount, absentCount, unexcusedCount, lateCount int + err := s.db.Pool.QueryRow(ctx, query, studentID, schoolYearID).Scan( + &totalRecords, &presentCount, &absentCount, &unexcusedCount, &lateCount, + ) + if err != nil { + return nil, fmt.Errorf("failed to get attendance stats: %w", err) + } + + var attendanceRate float64 + if totalRecords > 0 { + attendanceRate = float64(presentCount) / float64(totalRecords) * 100 + } + + return map[string]interface{}{ + "total_records": totalRecords, + "present_count": presentCount, + "absent_count": absentCount, + "unexcused_count": unexcusedCount, + "late_count": lateCount, + "attendance_rate": attendanceRate, + }, nil +} + +// ======================================== +// Parent Notifications +// ======================================== + +func (s *AttendanceService) notifyParentsOfAbsence(ctx context.Context, record *models.AttendanceRecord) { + if s.matrix == nil { + return + } + + // Get student info + var studentFirstName, studentLastName, matrixDMRoom string + err := s.db.Pool.QueryRow(ctx, ` + SELECT first_name, last_name, matrix_dm_room + FROM students + WHERE id = $1`, record.StudentID).Scan(&studentFirstName, &studentLastName, &matrixDMRoom) + if err != nil || matrixDMRoom == "" { + return + } + + // Get slot info + var slotNumber int + err = s.db.Pool.QueryRow(ctx, `SELECT slot_number FROM timetable_slots WHERE id = $1`, record.SlotID).Scan(&slotNumber) + if err != nil { + return + } + + studentName := studentFirstName + " " + studentLastName + dateStr := record.Date.Format("02.01.2006") + + // Send Matrix notification + err = s.matrix.SendAbsenceNotification(ctx, matrixDMRoom, studentName, dateStr, slotNumber) + if err != nil { + fmt.Printf("Failed to send absence notification: %v\n", err) + return + } + + // Update notification status + s.db.Pool.Exec(ctx, ` + UPDATE attendance_records + SET updated_at = NOW() + WHERE id = $1`, record.ID) + + // Log the notification + s.createAbsenceNotificationLog(ctx, record.ID, studentName, dateStr, slotNumber) +} + +func (s *AttendanceService) notifyParentsOfAbsenceByStudentID(ctx context.Context, studentID uuid.UUID, date time.Time, slotID uuid.UUID) { + record := &models.AttendanceRecord{ + StudentID: studentID, + Date: date, + SlotID: slotID, + } + s.notifyParentsOfAbsence(ctx, record) +} + +func (s *AttendanceService) createAbsenceNotificationLog(ctx context.Context, recordID uuid.UUID, studentName, dateStr string, slotNumber int) { + // Get parent IDs for this student + query := ` + SELECT p.id + FROM parents p + JOIN student_parents sp ON p.id = sp.parent_id + JOIN attendance_records ar ON sp.student_id = ar.student_id + WHERE ar.id = $1` + + rows, err := s.db.Pool.Query(ctx, query, recordID) + if err != nil { + return + } + defer rows.Close() + + message := fmt.Sprintf("Abwesenheitsmeldung: %s war am %s in der %d. Stunde nicht anwesend.", studentName, dateStr, slotNumber) + + for rows.Next() { + var parentID uuid.UUID + if err := rows.Scan(&parentID); err != nil { + continue + } + + // Insert notification log + s.db.Pool.Exec(ctx, ` + INSERT INTO absence_notifications (id, attendance_record_id, parent_id, channel, message_content, sent_at, created_at) + VALUES ($1, $2, $3, 'matrix', $4, NOW(), NOW())`, + uuid.New(), recordID, parentID, message) + } +} diff --git a/consent-service/internal/services/attendance_service_test.go b/consent-service/internal/services/attendance_service_test.go new file mode 100644 index 0000000..d286bd5 --- /dev/null +++ b/consent-service/internal/services/attendance_service_test.go @@ -0,0 +1,388 @@ +package services + +import ( + "testing" + "time" + + "github.com/breakpilot/consent-service/internal/models" + + "github.com/google/uuid" +) + +// TestValidateAttendanceRecord tests attendance record validation +func TestValidateAttendanceRecord(t *testing.T) { + slotID := uuid.New() + + tests := []struct { + name string + record models.AttendanceRecord + expectValid bool + }{ + { + name: "valid present record", + record: models.AttendanceRecord{ + StudentID: uuid.New(), + SlotID: slotID, + Date: time.Now(), + Status: models.AttendancePresent, + RecordedBy: uuid.New(), + }, + expectValid: true, + }, + { + name: "valid absent record", + record: models.AttendanceRecord{ + StudentID: uuid.New(), + SlotID: slotID, + Date: time.Now(), + Status: models.AttendanceAbsent, + RecordedBy: uuid.New(), + }, + expectValid: true, + }, + { + name: "valid late record", + record: models.AttendanceRecord{ + StudentID: uuid.New(), + SlotID: slotID, + Date: time.Now(), + Status: models.AttendanceLate, + RecordedBy: uuid.New(), + }, + expectValid: true, + }, + { + name: "missing student ID", + record: models.AttendanceRecord{ + StudentID: uuid.Nil, + SlotID: slotID, + Date: time.Now(), + Status: models.AttendancePresent, + RecordedBy: uuid.New(), + }, + expectValid: false, + }, + { + name: "invalid status", + record: models.AttendanceRecord{ + StudentID: uuid.New(), + SlotID: slotID, + Date: time.Now(), + Status: "invalid_status", + RecordedBy: uuid.New(), + }, + expectValid: false, + }, + { + name: "future date", + record: models.AttendanceRecord{ + StudentID: uuid.New(), + SlotID: slotID, + Date: time.Now().AddDate(0, 0, 7), + Status: models.AttendancePresent, + RecordedBy: uuid.New(), + }, + expectValid: false, + }, + { + name: "missing slot ID", + record: models.AttendanceRecord{ + StudentID: uuid.New(), + SlotID: uuid.Nil, + Date: time.Now(), + Status: models.AttendancePresent, + RecordedBy: uuid.New(), + }, + expectValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isValid := validateAttendanceRecord(tt.record) + if isValid != tt.expectValid { + t.Errorf("expected valid=%v, got valid=%v", tt.expectValid, isValid) + } + }) + } +} + +// validateAttendanceRecord validates an attendance record +func validateAttendanceRecord(record models.AttendanceRecord) bool { + if record.StudentID == uuid.Nil { + return false + } + if record.SlotID == uuid.Nil { + return false + } + if record.RecordedBy == uuid.Nil { + return false + } + if record.Date.After(time.Now().AddDate(0, 0, 1)) { + return false + } + + // Validate status + validStatuses := map[string]bool{ + models.AttendancePresent: true, + models.AttendanceAbsent: true, + models.AttendanceAbsentExcused: true, + models.AttendanceAbsentUnexcused: true, + models.AttendanceLate: true, + models.AttendanceLateExcused: true, + models.AttendancePending: true, + } + + if !validStatuses[record.Status] { + return false + } + + return true +} + +// TestValidateAbsenceReport tests absence report validation +func TestValidateAbsenceReport(t *testing.T) { + now := time.Now() + today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) + reason := "Krankheit" + medicalReason := "Arzttermin" + + tests := []struct { + name string + report models.AbsenceReport + expectValid bool + }{ + { + name: "valid single day absence", + report: models.AbsenceReport{ + StudentID: uuid.New(), + ReportedBy: uuid.New(), + StartDate: today, + EndDate: today, + Reason: &reason, + ReasonCategory: "illness", + Status: "reported", + }, + expectValid: true, + }, + { + name: "valid multi-day absence", + report: models.AbsenceReport{ + StudentID: uuid.New(), + ReportedBy: uuid.New(), + StartDate: today, + EndDate: today.AddDate(0, 0, 3), + Reason: &medicalReason, + ReasonCategory: "appointment", + Status: "reported", + }, + expectValid: true, + }, + { + name: "end before start", + report: models.AbsenceReport{ + StudentID: uuid.New(), + ReportedBy: uuid.New(), + StartDate: today.AddDate(0, 0, 3), + EndDate: today, + Reason: &reason, + ReasonCategory: "illness", + Status: "reported", + }, + expectValid: false, + }, + { + name: "missing reason category", + report: models.AbsenceReport{ + StudentID: uuid.New(), + ReportedBy: uuid.New(), + StartDate: today, + EndDate: today, + Reason: &reason, + ReasonCategory: "", + Status: "reported", + }, + expectValid: false, + }, + { + name: "invalid reason category", + report: models.AbsenceReport{ + StudentID: uuid.New(), + ReportedBy: uuid.New(), + StartDate: today, + EndDate: today, + Reason: &reason, + ReasonCategory: "invalid_type", + Status: "reported", + }, + expectValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isValid := validateAbsenceReport(tt.report) + if isValid != tt.expectValid { + t.Errorf("expected valid=%v, got valid=%v", tt.expectValid, isValid) + } + }) + } +} + +// validateAbsenceReport validates an absence report +func validateAbsenceReport(report models.AbsenceReport) bool { + if report.StudentID == uuid.Nil { + return false + } + if report.ReportedBy == uuid.Nil { + return false + } + if report.EndDate.Before(report.StartDate) { + return false + } + if report.ReasonCategory == "" { + return false + } + + // Validate reason category + validCategories := map[string]bool{ + "illness": true, + "appointment": true, + "family": true, + "other": true, + } + + if !validCategories[report.ReasonCategory] { + return false + } + + return true +} + +// TestCalculateAttendanceStats tests attendance statistics calculation +func TestCalculateAttendanceStats(t *testing.T) { + tests := []struct { + name string + records []models.AttendanceRecord + expectedPresent int + expectedAbsent int + expectedLate int + }{ + { + name: "all present", + records: []models.AttendanceRecord{ + {Status: models.AttendancePresent}, + {Status: models.AttendancePresent}, + {Status: models.AttendancePresent}, + }, + expectedPresent: 3, + expectedAbsent: 0, + expectedLate: 0, + }, + { + name: "mixed attendance", + records: []models.AttendanceRecord{ + {Status: models.AttendancePresent}, + {Status: models.AttendanceAbsent}, + {Status: models.AttendanceLate}, + {Status: models.AttendancePresent}, + {Status: models.AttendanceAbsentExcused}, + }, + expectedPresent: 2, + expectedAbsent: 2, + expectedLate: 1, + }, + { + name: "empty records", + records: []models.AttendanceRecord{}, + expectedPresent: 0, + expectedAbsent: 0, + expectedLate: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + present, absent, late := calculateAttendanceStats(tt.records) + if present != tt.expectedPresent { + t.Errorf("expected present=%d, got present=%d", tt.expectedPresent, present) + } + if absent != tt.expectedAbsent { + t.Errorf("expected absent=%d, got absent=%d", tt.expectedAbsent, absent) + } + if late != tt.expectedLate { + t.Errorf("expected late=%d, got late=%d", tt.expectedLate, late) + } + }) + } +} + +// calculateAttendanceStats calculates attendance statistics +func calculateAttendanceStats(records []models.AttendanceRecord) (present, absent, late int) { + for _, r := range records { + switch r.Status { + case models.AttendancePresent: + present++ + case models.AttendanceAbsent, models.AttendanceAbsentExcused, models.AttendanceAbsentUnexcused: + absent++ + case models.AttendanceLate, models.AttendanceLateExcused: + late++ + } + } + return +} + +// TestAttendanceRateCalculation tests attendance rate percentage calculation +func TestAttendanceRateCalculation(t *testing.T) { + tests := []struct { + name string + present int + total int + expectedRate float64 + }{ + { + name: "100% attendance", + present: 26, + total: 26, + expectedRate: 100.0, + }, + { + name: "92.3% attendance", + present: 24, + total: 26, + expectedRate: 92.31, + }, + { + name: "0% attendance", + present: 0, + total: 26, + expectedRate: 0.0, + }, + { + name: "empty class", + present: 0, + total: 0, + expectedRate: 0.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rate := calculateAttendanceRate(tt.present, tt.total) + // Allow small floating point differences + if rate < tt.expectedRate-0.1 || rate > tt.expectedRate+0.1 { + t.Errorf("expected rate=%.2f, got rate=%.2f", tt.expectedRate, rate) + } + }) + } +} + +// calculateAttendanceRate calculates attendance rate as percentage +func calculateAttendanceRate(present, total int) float64 { + if total == 0 { + return 0.0 + } + rate := float64(present) / float64(total) * 100 + // Round to 2 decimal places + return float64(int(rate*100)) / 100 +} diff --git a/consent-service/internal/services/auth_service.go b/consent-service/internal/services/auth_service.go new file mode 100644 index 0000000..da02c9c --- /dev/null +++ b/consent-service/internal/services/auth_service.go @@ -0,0 +1,568 @@ +package services + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/crypto/bcrypt" + + "github.com/breakpilot/consent-service/internal/models" +) + +var ( + ErrInvalidCredentials = errors.New("invalid email or password") + ErrUserNotFound = errors.New("user not found") + ErrUserExists = errors.New("user with this email already exists") + ErrInvalidToken = errors.New("invalid or expired token") + ErrAccountLocked = errors.New("account is temporarily locked") + ErrAccountSuspended = errors.New("account is suspended") + ErrEmailNotVerified = errors.New("email not verified") +) + +// AuthService handles authentication logic +type AuthService struct { + db *pgxpool.Pool + jwtSecret string + jwtRefreshSecret string + accessTokenExp time.Duration + refreshTokenExp time.Duration +} + +// NewAuthService creates a new AuthService +func NewAuthService(db *pgxpool.Pool, jwtSecret, jwtRefreshSecret string) *AuthService { + return &AuthService{ + db: db, + jwtSecret: jwtSecret, + jwtRefreshSecret: jwtRefreshSecret, + accessTokenExp: time.Hour * 1, // 1 hour + refreshTokenExp: time.Hour * 24 * 30, // 30 days + } +} + +// HashPassword hashes a password using bcrypt +func (s *AuthService) HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", fmt.Errorf("failed to hash password: %w", err) + } + return string(bytes), nil +} + +// VerifyPassword verifies a password against a hash +func (s *AuthService) VerifyPassword(password, hash string) bool { + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + +// GenerateSecureToken generates a cryptographically secure token +func (s *AuthService) GenerateSecureToken(length int) (string, error) { + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("failed to generate token: %w", err) + } + return base64.URLEncoding.EncodeToString(bytes), nil +} + +// HashToken creates a SHA256 hash of a token for storage +func (s *AuthService) HashToken(token string) string { + hash := sha256.Sum256([]byte(token)) + return hex.EncodeToString(hash[:]) +} + +// JWTClaims for access tokens +type JWTClaims struct { + UserID string `json:"user_id"` + Email string `json:"email"` + Role string `json:"role"` + AccountStatus string `json:"account_status"` + jwt.RegisteredClaims +} + +// GenerateAccessToken creates a new JWT access token +func (s *AuthService) GenerateAccessToken(user *models.User) (string, error) { + claims := JWTClaims{ + UserID: user.ID.String(), + Email: user.Email, + Role: user.Role, + AccountStatus: user.AccountStatus, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(s.accessTokenExp)), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Subject: user.ID.String(), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(s.jwtSecret)) +} + +// GenerateRefreshToken creates a new refresh token +func (s *AuthService) GenerateRefreshToken() (string, string, error) { + token, err := s.GenerateSecureToken(32) + if err != nil { + return "", "", err + } + hash := s.HashToken(token) + return token, hash, nil +} + +// ValidateAccessToken validates a JWT access token +func (s *AuthService) ValidateAccessToken(tokenString string) (*JWTClaims, error) { + token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(s.jwtSecret), nil + }) + + if err != nil { + return nil, fmt.Errorf("failed to parse token: %w", err) + } + + claims, ok := token.Claims.(*JWTClaims) + if !ok || !token.Valid { + return nil, ErrInvalidToken + } + + return claims, nil +} + +// Register creates a new user account +func (s *AuthService) Register(ctx context.Context, req *models.RegisterRequest) (*models.User, string, error) { + // Check if user already exists + var exists bool + err := s.db.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)", req.Email).Scan(&exists) + if err != nil { + return nil, "", fmt.Errorf("failed to check existing user: %w", err) + } + if exists { + return nil, "", ErrUserExists + } + + // Hash password + passwordHash, err := s.HashPassword(req.Password) + if err != nil { + return nil, "", err + } + + // Create user + user := &models.User{ + ID: uuid.New(), + Email: req.Email, + PasswordHash: &passwordHash, + Name: req.Name, + Role: "user", + EmailVerified: false, + AccountStatus: "active", + } + + _, err = s.db.Exec(ctx, ` + INSERT INTO users (id, email, password_hash, name, role, email_verified, account_status, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW()) + `, user.ID, user.Email, user.PasswordHash, user.Name, user.Role, user.EmailVerified, user.AccountStatus) + + if err != nil { + return nil, "", fmt.Errorf("failed to create user: %w", err) + } + + // Generate email verification token + verificationToken, err := s.GenerateSecureToken(32) + if err != nil { + return nil, "", err + } + + // Store verification token + _, err = s.db.Exec(ctx, ` + INSERT INTO email_verification_tokens (user_id, token, expires_at, created_at) + VALUES ($1, $2, $3, NOW()) + `, user.ID, verificationToken, time.Now().Add(24*time.Hour)) + + if err != nil { + return nil, "", fmt.Errorf("failed to create verification token: %w", err) + } + + // Create notification preferences + _, err = s.db.Exec(ctx, ` + INSERT INTO notification_preferences (user_id, email_enabled, push_enabled, in_app_enabled, reminder_frequency, created_at, updated_at) + VALUES ($1, true, true, true, 'weekly', NOW(), NOW()) + `, user.ID) + + if err != nil { + // Non-critical error, just log + fmt.Printf("Warning: failed to create notification preferences: %v\n", err) + } + + return user, verificationToken, nil +} + +// Login authenticates a user and returns tokens +func (s *AuthService) Login(ctx context.Context, req *models.LoginRequest, ipAddress, userAgent string) (*models.LoginResponse, error) { + var user models.User + var passwordHash *string + + err := s.db.QueryRow(ctx, ` + SELECT id, email, password_hash, name, role, email_verified, account_status, + failed_login_attempts, locked_until, created_at, updated_at + FROM users WHERE email = $1 + `, req.Email).Scan( + &user.ID, &user.Email, &passwordHash, &user.Name, &user.Role, &user.EmailVerified, + &user.AccountStatus, &user.FailedLoginAttempts, &user.LockedUntil, &user.CreatedAt, &user.UpdatedAt, + ) + + if err != nil { + return nil, ErrInvalidCredentials + } + + // Check if account is locked + if user.LockedUntil != nil && user.LockedUntil.After(time.Now()) { + return nil, ErrAccountLocked + } + + // Check if account is suspended + if user.AccountStatus == "suspended" { + return nil, ErrAccountSuspended + } + + // Verify password + if passwordHash == nil || !s.VerifyPassword(req.Password, *passwordHash) { + // Increment failed login attempts + _, _ = s.db.Exec(ctx, ` + UPDATE users SET + failed_login_attempts = failed_login_attempts + 1, + locked_until = CASE WHEN failed_login_attempts >= 4 THEN NOW() + INTERVAL '30 minutes' ELSE locked_until END, + updated_at = NOW() + WHERE id = $1 + `, user.ID) + return nil, ErrInvalidCredentials + } + + // Reset failed login attempts and update last login + _, _ = s.db.Exec(ctx, ` + UPDATE users SET + failed_login_attempts = 0, + locked_until = NULL, + last_login_at = NOW(), + updated_at = NOW() + WHERE id = $1 + `, user.ID) + + // Generate tokens + accessToken, err := s.GenerateAccessToken(&user) + if err != nil { + return nil, fmt.Errorf("failed to generate access token: %w", err) + } + + refreshToken, refreshTokenHash, err := s.GenerateRefreshToken() + if err != nil { + return nil, fmt.Errorf("failed to generate refresh token: %w", err) + } + + // Store session + _, err = s.db.Exec(ctx, ` + INSERT INTO user_sessions (user_id, token_hash, ip_address, user_agent, expires_at, created_at, last_activity_at) + VALUES ($1, $2, $3, $4, $5, NOW(), NOW()) + `, user.ID, refreshTokenHash, ipAddress, userAgent, time.Now().Add(s.refreshTokenExp)) + + if err != nil { + return nil, fmt.Errorf("failed to create session: %w", err) + } + + return &models.LoginResponse{ + User: user, + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresIn: int(s.accessTokenExp.Seconds()), + }, nil +} + +// RefreshToken refreshes the access token using a refresh token +func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (*models.LoginResponse, error) { + tokenHash := s.HashToken(refreshToken) + + var session models.UserSession + var userID uuid.UUID + + err := s.db.QueryRow(ctx, ` + SELECT id, user_id, expires_at, revoked_at FROM user_sessions + WHERE token_hash = $1 + `, tokenHash).Scan(&session.ID, &userID, &session.ExpiresAt, &session.RevokedAt) + + if err != nil { + return nil, ErrInvalidToken + } + + // Check if session is expired or revoked + if session.RevokedAt != nil || session.ExpiresAt.Before(time.Now()) { + return nil, ErrInvalidToken + } + + // Get user + var user models.User + err = s.db.QueryRow(ctx, ` + SELECT id, email, name, role, email_verified, account_status, created_at, updated_at + FROM users WHERE id = $1 + `, userID).Scan( + &user.ID, &user.Email, &user.Name, &user.Role, &user.EmailVerified, + &user.AccountStatus, &user.CreatedAt, &user.UpdatedAt, + ) + + if err != nil { + return nil, ErrUserNotFound + } + + // Check account status + if user.AccountStatus == "suspended" { + return nil, ErrAccountSuspended + } + + // Generate new access token + accessToken, err := s.GenerateAccessToken(&user) + if err != nil { + return nil, fmt.Errorf("failed to generate access token: %w", err) + } + + // Update session last activity + _, _ = s.db.Exec(ctx, ` + UPDATE user_sessions SET last_activity_at = NOW() WHERE id = $1 + `, session.ID) + + return &models.LoginResponse{ + User: user, + AccessToken: accessToken, + RefreshToken: refreshToken, + ExpiresIn: int(s.accessTokenExp.Seconds()), + }, nil +} + +// VerifyEmail verifies a user's email address +func (s *AuthService) VerifyEmail(ctx context.Context, token string) error { + var tokenID uuid.UUID + var userID uuid.UUID + var expiresAt time.Time + var usedAt *time.Time + + err := s.db.QueryRow(ctx, ` + SELECT id, user_id, expires_at, used_at FROM email_verification_tokens + WHERE token = $1 + `, token).Scan(&tokenID, &userID, &expiresAt, &usedAt) + + if err != nil { + return ErrInvalidToken + } + + if usedAt != nil || expiresAt.Before(time.Now()) { + return ErrInvalidToken + } + + // Mark token as used + _, err = s.db.Exec(ctx, `UPDATE email_verification_tokens SET used_at = NOW() WHERE id = $1`, tokenID) + if err != nil { + return fmt.Errorf("failed to update token: %w", err) + } + + // Verify user email + _, err = s.db.Exec(ctx, ` + UPDATE users SET email_verified = true, email_verified_at = NOW(), updated_at = NOW() + WHERE id = $1 + `, userID) + + if err != nil { + return fmt.Errorf("failed to verify email: %w", err) + } + + return nil +} + +// CreatePasswordResetToken creates a password reset token +func (s *AuthService) CreatePasswordResetToken(ctx context.Context, email, ipAddress string) (string, *uuid.UUID, error) { + var userID uuid.UUID + err := s.db.QueryRow(ctx, "SELECT id FROM users WHERE email = $1", email).Scan(&userID) + if err != nil { + // Don't reveal if user exists + return "", nil, nil + } + + token, err := s.GenerateSecureToken(32) + if err != nil { + return "", nil, err + } + + _, err = s.db.Exec(ctx, ` + INSERT INTO password_reset_tokens (user_id, token, expires_at, ip_address, created_at) + VALUES ($1, $2, $3, $4, NOW()) + `, userID, token, time.Now().Add(time.Hour), ipAddress) + + if err != nil { + return "", nil, fmt.Errorf("failed to create reset token: %w", err) + } + + return token, &userID, nil +} + +// ResetPassword resets a user's password using a reset token +func (s *AuthService) ResetPassword(ctx context.Context, token, newPassword string) error { + var tokenID uuid.UUID + var userID uuid.UUID + var expiresAt time.Time + var usedAt *time.Time + + err := s.db.QueryRow(ctx, ` + SELECT id, user_id, expires_at, used_at FROM password_reset_tokens + WHERE token = $1 + `, token).Scan(&tokenID, &userID, &expiresAt, &usedAt) + + if err != nil { + return ErrInvalidToken + } + + if usedAt != nil || expiresAt.Before(time.Now()) { + return ErrInvalidToken + } + + // Hash new password + passwordHash, err := s.HashPassword(newPassword) + if err != nil { + return err + } + + // Mark token as used + _, err = s.db.Exec(ctx, `UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1`, tokenID) + if err != nil { + return fmt.Errorf("failed to update token: %w", err) + } + + // Update password + _, err = s.db.Exec(ctx, ` + UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2 + `, passwordHash, userID) + + if err != nil { + return fmt.Errorf("failed to update password: %w", err) + } + + // Revoke all sessions for security + _, err = s.db.Exec(ctx, `UPDATE user_sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL`, userID) + if err != nil { + fmt.Printf("Warning: failed to revoke sessions: %v\n", err) + } + + return nil +} + +// ChangePassword changes a user's password (requires current password) +func (s *AuthService) ChangePassword(ctx context.Context, userID uuid.UUID, currentPassword, newPassword string) error { + var passwordHash *string + err := s.db.QueryRow(ctx, "SELECT password_hash FROM users WHERE id = $1", userID).Scan(&passwordHash) + if err != nil { + return ErrUserNotFound + } + + if passwordHash == nil || !s.VerifyPassword(currentPassword, *passwordHash) { + return ErrInvalidCredentials + } + + newPasswordHash, err := s.HashPassword(newPassword) + if err != nil { + return err + } + + _, err = s.db.Exec(ctx, `UPDATE users SET password_hash = $1, updated_at = NOW() WHERE id = $2`, newPasswordHash, userID) + if err != nil { + return fmt.Errorf("failed to update password: %w", err) + } + + return nil +} + +// GetUserByID retrieves a user by ID +func (s *AuthService) GetUserByID(ctx context.Context, userID uuid.UUID) (*models.User, error) { + var user models.User + err := s.db.QueryRow(ctx, ` + SELECT id, email, name, role, email_verified, email_verified_at, account_status, + last_login_at, created_at, updated_at + FROM users WHERE id = $1 + `, userID).Scan( + &user.ID, &user.Email, &user.Name, &user.Role, &user.EmailVerified, &user.EmailVerifiedAt, + &user.AccountStatus, &user.LastLoginAt, &user.CreatedAt, &user.UpdatedAt, + ) + + if err != nil { + return nil, ErrUserNotFound + } + + return &user, nil +} + +// UpdateProfile updates a user's profile +func (s *AuthService) UpdateProfile(ctx context.Context, userID uuid.UUID, req *models.UpdateProfileRequest) (*models.User, error) { + _, err := s.db.Exec(ctx, `UPDATE users SET name = $1, updated_at = NOW() WHERE id = $2`, req.Name, userID) + if err != nil { + return nil, fmt.Errorf("failed to update profile: %w", err) + } + + return s.GetUserByID(ctx, userID) +} + +// GetActiveSessions retrieves all active sessions for a user +func (s *AuthService) GetActiveSessions(ctx context.Context, userID uuid.UUID) ([]models.UserSession, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, user_id, device_info, ip_address, user_agent, expires_at, created_at, last_activity_at + FROM user_sessions + WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW() + ORDER BY last_activity_at DESC + `, userID) + + if err != nil { + return nil, fmt.Errorf("failed to get sessions: %w", err) + } + defer rows.Close() + + var sessions []models.UserSession + for rows.Next() { + var session models.UserSession + err := rows.Scan( + &session.ID, &session.UserID, &session.DeviceInfo, &session.IPAddress, + &session.UserAgent, &session.ExpiresAt, &session.CreatedAt, &session.LastActivityAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan session: %w", err) + } + sessions = append(sessions, session) + } + + return sessions, nil +} + +// RevokeSession revokes a specific session +func (s *AuthService) RevokeSession(ctx context.Context, userID, sessionID uuid.UUID) error { + result, err := s.db.Exec(ctx, ` + UPDATE user_sessions SET revoked_at = NOW() WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL + `, sessionID, userID) + + if err != nil { + return fmt.Errorf("failed to revoke session: %w", err) + } + + if result.RowsAffected() == 0 { + return errors.New("session not found") + } + + return nil +} + +// Logout revokes a session by refresh token +func (s *AuthService) Logout(ctx context.Context, refreshToken string) error { + tokenHash := s.HashToken(refreshToken) + _, err := s.db.Exec(ctx, `UPDATE user_sessions SET revoked_at = NOW() WHERE token_hash = $1`, tokenHash) + return err +} diff --git a/consent-service/internal/services/auth_service_test.go b/consent-service/internal/services/auth_service_test.go new file mode 100644 index 0000000..60719b6 --- /dev/null +++ b/consent-service/internal/services/auth_service_test.go @@ -0,0 +1,367 @@ +package services + +import ( + "testing" + "time" + + "github.com/breakpilot/consent-service/internal/models" + "github.com/google/uuid" +) + +// TestHashPassword tests password hashing +func TestHashPassword(t *testing.T) { + // Create service without DB for unit tests + s := &AuthService{} + + password := "testPassword123!" + hash, err := s.HashPassword(password) + + if err != nil { + t.Fatalf("HashPassword failed: %v", err) + } + + if hash == "" { + t.Error("Hash should not be empty") + } + + if hash == password { + t.Error("Hash should not equal the original password") + } + + // Hash should be different each time (bcrypt uses random salt) + hash2, _ := s.HashPassword(password) + if hash == hash2 { + t.Error("Same password should produce different hashes due to salt") + } +} + +// TestVerifyPassword tests password verification +func TestVerifyPassword(t *testing.T) { + s := &AuthService{} + + password := "testPassword123!" + hash, _ := s.HashPassword(password) + + // Should verify correct password + if !s.VerifyPassword(password, hash) { + t.Error("VerifyPassword should return true for correct password") + } + + // Should reject incorrect password + if s.VerifyPassword("wrongPassword", hash) { + t.Error("VerifyPassword should return false for incorrect password") + } + + // Should reject empty password + if s.VerifyPassword("", hash) { + t.Error("VerifyPassword should return false for empty password") + } +} + +// TestGenerateSecureToken tests token generation +func TestGenerateSecureToken(t *testing.T) { + s := &AuthService{} + + tests := []struct { + name string + length int + }{ + {"short token", 16}, + {"standard token", 32}, + {"long token", 64}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + token, err := s.GenerateSecureToken(tt.length) + if err != nil { + t.Fatalf("GenerateSecureToken failed: %v", err) + } + + if token == "" { + t.Error("Token should not be empty") + } + + // Tokens should be unique + token2, _ := s.GenerateSecureToken(tt.length) + if token == token2 { + t.Error("Generated tokens should be unique") + } + }) + } +} + +// TestHashToken tests token hashing for storage +func TestHashToken(t *testing.T) { + s := &AuthService{} + + token := "test-token-123" + hash := s.HashToken(token) + + if hash == "" { + t.Error("Hash should not be empty") + } + + if hash == token { + t.Error("Hash should not equal the original token") + } + + // Same token should produce same hash (deterministic) + hash2 := s.HashToken(token) + if hash != hash2 { + t.Error("Same token should produce same hash") + } + + // Different tokens should produce different hashes + differentHash := s.HashToken("different-token") + if hash == differentHash { + t.Error("Different tokens should produce different hashes") + } +} + +// TestGenerateAccessToken tests JWT access token generation +func TestGenerateAccessToken(t *testing.T) { + s := &AuthService{ + jwtSecret: "test-secret-key-for-testing-purposes", + accessTokenExp: time.Hour, + } + + user := &models.User{ + ID: uuid.New(), + Email: "test@example.com", + Role: "user", + AccountStatus: "active", + } + + token, err := s.GenerateAccessToken(user) + if err != nil { + t.Fatalf("GenerateAccessToken failed: %v", err) + } + + if token == "" { + t.Error("Token should not be empty") + } + + // Token should have three parts (header.payload.signature) + parts := 0 + for _, c := range token { + if c == '.' { + parts++ + } + } + if parts != 2 { + t.Errorf("JWT token should have 3 parts, got %d dots", parts) + } +} + +// TestValidateAccessToken tests JWT token validation +func TestValidateAccessToken(t *testing.T) { + secret := "test-secret-key-for-testing-purposes" + s := &AuthService{ + jwtSecret: secret, + accessTokenExp: time.Hour, + } + + user := &models.User{ + ID: uuid.New(), + Email: "test@example.com", + Role: "admin", + AccountStatus: "active", + } + + token, _ := s.GenerateAccessToken(user) + + // Should validate valid token + claims, err := s.ValidateAccessToken(token) + if err != nil { + t.Fatalf("ValidateAccessToken failed: %v", err) + } + + if claims.UserID != user.ID.String() { + t.Errorf("Expected UserID %s, got %s", user.ID.String(), claims.UserID) + } + + if claims.Email != user.Email { + t.Errorf("Expected Email %s, got %s", user.Email, claims.Email) + } + + if claims.Role != user.Role { + t.Errorf("Expected Role %s, got %s", user.Role, claims.Role) + } +} + +// TestValidateAccessToken_Invalid tests invalid token scenarios +func TestValidateAccessToken_Invalid(t *testing.T) { + s := &AuthService{ + jwtSecret: "test-secret-key-for-testing-purposes", + accessTokenExp: time.Hour, + } + + tests := []struct { + name string + token string + }{ + {"empty token", ""}, + {"invalid format", "not-a-jwt-token"}, + {"invalid signature", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMTIzIn0.invalidsignature"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := s.ValidateAccessToken(tt.token) + if err == nil { + t.Error("ValidateAccessToken should fail for invalid token") + } + }) + } +} + +// TestValidateAccessToken_WrongSecret tests token with wrong secret +func TestValidateAccessToken_WrongSecret(t *testing.T) { + s1 := &AuthService{ + jwtSecret: "secret-one", + accessTokenExp: time.Hour, + } + + s2 := &AuthService{ + jwtSecret: "secret-two", + accessTokenExp: time.Hour, + } + + user := &models.User{ + ID: uuid.New(), + Email: "test@example.com", + Role: "user", + AccountStatus: "active", + } + + // Generate token with first secret + token, _ := s1.GenerateAccessToken(user) + + // Try to validate with second secret (should fail) + _, err := s2.ValidateAccessToken(token) + if err == nil { + t.Error("ValidateAccessToken should fail when using wrong secret") + } +} + +// TestGenerateRefreshToken tests refresh token generation +func TestGenerateRefreshToken(t *testing.T) { + s := &AuthService{} + + token, hash, err := s.GenerateRefreshToken() + if err != nil { + t.Fatalf("GenerateRefreshToken failed: %v", err) + } + + if token == "" { + t.Error("Token should not be empty") + } + + if hash == "" { + t.Error("Hash should not be empty") + } + + // Verify hash matches token + expectedHash := s.HashToken(token) + if hash != expectedHash { + t.Error("Returned hash should match hashed token") + } + + // Tokens should be unique + token2, hash2, _ := s.GenerateRefreshToken() + if token == token2 { + t.Error("Generated tokens should be unique") + } + if hash == hash2 { + t.Error("Generated hashes should be unique") + } +} + +// TestPasswordStrength tests various password scenarios +func TestPasswordStrength(t *testing.T) { + s := &AuthService{} + + passwords := []struct { + password string + valid bool + }{ + {"short", true}, // bcrypt accepts any length + {"12345678", true}, // numbers only + {"password", true}, // letters only + {"Pass123!", true}, // mixed + {"", true}, // empty (bcrypt allows) + {string(make([]byte, 72)), true}, // max bcrypt length + } + + for _, p := range passwords { + hash, err := s.HashPassword(p.password) + if p.valid && err != nil { + t.Errorf("HashPassword failed for valid password %q: %v", p.password, err) + } + if p.valid && !s.VerifyPassword(p.password, hash) { + t.Errorf("VerifyPassword failed for password %q", p.password) + } + } +} + +// BenchmarkHashPassword benchmarks password hashing +func BenchmarkHashPassword(b *testing.B) { + s := &AuthService{} + password := "testPassword123!" + + for i := 0; i < b.N; i++ { + s.HashPassword(password) + } +} + +// BenchmarkVerifyPassword benchmarks password verification +func BenchmarkVerifyPassword(b *testing.B) { + s := &AuthService{} + password := "testPassword123!" + hash, _ := s.HashPassword(password) + + for i := 0; i < b.N; i++ { + s.VerifyPassword(password, hash) + } +} + +// BenchmarkGenerateAccessToken benchmarks JWT token generation +func BenchmarkGenerateAccessToken(b *testing.B) { + s := &AuthService{ + jwtSecret: "test-secret-key-for-testing-purposes", + accessTokenExp: time.Hour, + } + + user := &models.User{ + ID: uuid.New(), + Email: "test@example.com", + Role: "user", + AccountStatus: "active", + } + + for i := 0; i < b.N; i++ { + s.GenerateAccessToken(user) + } +} + +// BenchmarkValidateAccessToken benchmarks JWT token validation +func BenchmarkValidateAccessToken(b *testing.B) { + s := &AuthService{ + jwtSecret: "test-secret-key-for-testing-purposes", + accessTokenExp: time.Hour, + } + + user := &models.User{ + ID: uuid.New(), + Email: "test@example.com", + Role: "user", + AccountStatus: "active", + } + + token, _ := s.GenerateAccessToken(user) + + for i := 0; i < b.N; i++ { + s.ValidateAccessToken(token) + } +} diff --git a/consent-service/internal/services/consent_service_test.go b/consent-service/internal/services/consent_service_test.go new file mode 100644 index 0000000..7a64dba --- /dev/null +++ b/consent-service/internal/services/consent_service_test.go @@ -0,0 +1,518 @@ +package services + +import ( + "testing" + "time" + + "github.com/google/uuid" +) + +// TestConsentService_CreateConsent tests creating a new consent +func TestConsentService_CreateConsent(t *testing.T) { + // This is a unit test with table-driven approach + tests := []struct { + name string + userID uuid.UUID + versionID uuid.UUID + consented bool + expectError bool + errorContains string + }{ + { + name: "valid consent - accepted", + userID: uuid.New(), + versionID: uuid.New(), + consented: true, + expectError: false, + }, + { + name: "valid consent - declined", + userID: uuid.New(), + versionID: uuid.New(), + consented: false, + expectError: false, + }, + { + name: "empty user ID", + userID: uuid.Nil, + versionID: uuid.New(), + consented: true, + expectError: true, + errorContains: "user ID", + }, + { + name: "empty version ID", + userID: uuid.New(), + versionID: uuid.Nil, + consented: true, + expectError: true, + errorContains: "version ID", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Validate inputs (in real implementation this would be in the service) + var hasError bool + if tt.userID == uuid.Nil { + hasError = true + } else if tt.versionID == uuid.Nil { + hasError = true + } + + // Assert + if tt.expectError && !hasError { + t.Errorf("Expected error containing '%s', got nil", tt.errorContains) + } + if !tt.expectError && hasError { + t.Error("Expected no error, got error") + } + }) + } +} + +// TestConsentService_WithdrawConsent tests withdrawing consent +func TestConsentService_WithdrawConsent(t *testing.T) { + tests := []struct { + name string + consentID uuid.UUID + userID uuid.UUID + expectError bool + errorContains string + }{ + { + name: "valid withdrawal", + consentID: uuid.New(), + userID: uuid.New(), + expectError: false, + }, + { + name: "empty consent ID", + consentID: uuid.Nil, + userID: uuid.New(), + expectError: true, + errorContains: "consent ID", + }, + { + name: "empty user ID", + consentID: uuid.New(), + userID: uuid.Nil, + expectError: true, + errorContains: "user ID", + }, + { + name: "both empty", + consentID: uuid.Nil, + userID: uuid.Nil, + expectError: true, + errorContains: "ID", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Validate + var hasError bool + if tt.consentID == uuid.Nil || tt.userID == uuid.Nil { + hasError = true + } + + // Assert + if tt.expectError && !hasError { + t.Errorf("Expected error containing '%s', got nil", tt.errorContains) + } + if !tt.expectError && hasError { + t.Error("Expected no error, got error") + } + }) + } +} + +// TestConsentService_CheckConsent tests checking consent status +func TestConsentService_CheckConsent(t *testing.T) { + tests := []struct { + name string + userID uuid.UUID + documentType string + language string + hasConsent bool + needsUpdate bool + expectedConsent bool + expectedNeedsUpd bool + }{ + { + name: "user has current consent", + userID: uuid.New(), + documentType: "terms", + language: "de", + hasConsent: true, + needsUpdate: false, + expectedConsent: true, + expectedNeedsUpd: false, + }, + { + name: "user has outdated consent", + userID: uuid.New(), + documentType: "privacy", + language: "de", + hasConsent: true, + needsUpdate: true, + expectedConsent: true, + expectedNeedsUpd: true, + }, + { + name: "user has no consent", + userID: uuid.New(), + documentType: "cookies", + language: "de", + hasConsent: false, + needsUpdate: true, + expectedConsent: false, + expectedNeedsUpd: true, + }, + { + name: "english language", + userID: uuid.New(), + documentType: "terms", + language: "en", + hasConsent: true, + needsUpdate: false, + expectedConsent: true, + expectedNeedsUpd: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Simulate consent check logic + hasConsent := tt.hasConsent + needsUpdate := tt.needsUpdate + + // Assert + if hasConsent != tt.expectedConsent { + t.Errorf("Expected hasConsent=%v, got %v", tt.expectedConsent, hasConsent) + } + if needsUpdate != tt.expectedNeedsUpd { + t.Errorf("Expected needsUpdate=%v, got %v", tt.expectedNeedsUpd, needsUpdate) + } + }) + } +} + +// TestConsentService_GetConsentHistory tests retrieving consent history +func TestConsentService_GetConsentHistory(t *testing.T) { + tests := []struct { + name string + userID uuid.UUID + expectError bool + expectEmpty bool + }{ + { + name: "valid user with consents", + userID: uuid.New(), + expectError: false, + expectEmpty: false, + }, + { + name: "valid user without consents", + userID: uuid.New(), + expectError: false, + expectEmpty: true, + }, + { + name: "invalid user ID", + userID: uuid.Nil, + expectError: true, + expectEmpty: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Validate + var err error + if tt.userID == uuid.Nil { + err = &ValidationError{Field: "user ID", Message: "required"} + } + + // Assert error expectation + if tt.expectError && err == nil { + t.Error("Expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +// TestConsentService_UpdateConsent tests updating existing consent +func TestConsentService_UpdateConsent(t *testing.T) { + tests := []struct { + name string + consentID uuid.UUID + userID uuid.UUID + newConsented bool + expectError bool + }{ + { + name: "update to consented", + consentID: uuid.New(), + userID: uuid.New(), + newConsented: true, + expectError: false, + }, + { + name: "update to not consented", + consentID: uuid.New(), + userID: uuid.New(), + newConsented: false, + expectError: false, + }, + { + name: "invalid consent ID", + consentID: uuid.Nil, + userID: uuid.New(), + newConsented: true, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + if tt.consentID == uuid.Nil { + err = &ValidationError{Field: "consent ID", Message: "required"} + } + + if tt.expectError && err == nil { + t.Error("Expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +// TestConsentService_GetConsentStats tests getting consent statistics +func TestConsentService_GetConsentStats(t *testing.T) { + tests := []struct { + name string + documentType string + totalUsers int + consentedUsers int + expectedRate float64 + }{ + { + name: "100% consent rate", + documentType: "terms", + totalUsers: 100, + consentedUsers: 100, + expectedRate: 100.0, + }, + { + name: "50% consent rate", + documentType: "privacy", + totalUsers: 100, + consentedUsers: 50, + expectedRate: 50.0, + }, + { + name: "0% consent rate", + documentType: "cookies", + totalUsers: 100, + consentedUsers: 0, + expectedRate: 0.0, + }, + { + name: "no users", + documentType: "terms", + totalUsers: 0, + consentedUsers: 0, + expectedRate: 0.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Calculate consent rate + var consentRate float64 + if tt.totalUsers > 0 { + consentRate = float64(tt.consentedUsers) / float64(tt.totalUsers) * 100 + } + + // Assert + if consentRate != tt.expectedRate { + t.Errorf("Expected consent rate %.2f%%, got %.2f%%", tt.expectedRate, consentRate) + } + }) + } +} + +// TestConsentService_BulkConsentCheck tests checking multiple consents at once +func TestConsentService_BulkConsentCheck(t *testing.T) { + tests := []struct { + name string + userID uuid.UUID + documentTypes []string + expectError bool + }{ + { + name: "check multiple documents", + userID: uuid.New(), + documentTypes: []string{"terms", "privacy", "cookies"}, + expectError: false, + }, + { + name: "check single document", + userID: uuid.New(), + documentTypes: []string{"terms"}, + expectError: false, + }, + { + name: "empty document list", + userID: uuid.New(), + documentTypes: []string{}, + expectError: false, + }, + { + name: "invalid user ID", + userID: uuid.Nil, + documentTypes: []string{"terms"}, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + if tt.userID == uuid.Nil { + err = &ValidationError{Field: "user ID", Message: "required"} + } + + if tt.expectError && err == nil { + t.Error("Expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +// TestConsentService_ConsentVersionComparison tests version comparison logic +func TestConsentService_ConsentVersionComparison(t *testing.T) { + tests := []struct { + name string + currentVersion string + consentedVersion string + needsUpdate bool + }{ + { + name: "same version", + currentVersion: "1.0.0", + consentedVersion: "1.0.0", + needsUpdate: false, + }, + { + name: "minor version update", + currentVersion: "1.1.0", + consentedVersion: "1.0.0", + needsUpdate: true, + }, + { + name: "major version update", + currentVersion: "2.0.0", + consentedVersion: "1.0.0", + needsUpdate: true, + }, + { + name: "patch version update", + currentVersion: "1.0.1", + consentedVersion: "1.0.0", + needsUpdate: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Simple version comparison (in real implementation use proper semver) + needsUpdate := tt.currentVersion != tt.consentedVersion + + if needsUpdate != tt.needsUpdate { + t.Errorf("Expected needsUpdate=%v, got %v", tt.needsUpdate, needsUpdate) + } + }) + } +} + +// TestConsentService_ConsentDeadlineCheck tests deadline validation +func TestConsentService_ConsentDeadlineCheck(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + deadline time.Time + isOverdue bool + daysLeft int + }{ + { + name: "deadline in 30 days", + deadline: now.AddDate(0, 0, 30), + isOverdue: false, + daysLeft: 30, + }, + { + name: "deadline in 7 days", + deadline: now.AddDate(0, 0, 7), + isOverdue: false, + daysLeft: 7, + }, + { + name: "deadline today", + deadline: now, + isOverdue: false, + daysLeft: 0, + }, + { + name: "deadline 1 day overdue", + deadline: now.AddDate(0, 0, -1), + isOverdue: true, + daysLeft: -1, + }, + { + name: "deadline 30 days overdue", + deadline: now.AddDate(0, 0, -30), + isOverdue: true, + daysLeft: -30, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Calculate if overdue + isOverdue := tt.deadline.Before(now) + daysLeft := int(tt.deadline.Sub(now).Hours() / 24) + + if isOverdue != tt.isOverdue { + t.Errorf("Expected isOverdue=%v, got %v", tt.isOverdue, isOverdue) + } + + // Allow 1 day difference due to time precision + if abs(daysLeft-tt.daysLeft) > 1 { + t.Errorf("Expected daysLeft=%d, got %d", tt.daysLeft, daysLeft) + } + }) + } +} + +// Helper functions + +// abs returns the absolute value of an integer +func abs(n int) int { + if n < 0 { + return -n + } + return n +} diff --git a/consent-service/internal/services/deadline_service.go b/consent-service/internal/services/deadline_service.go new file mode 100644 index 0000000..dd49b57 --- /dev/null +++ b/consent-service/internal/services/deadline_service.go @@ -0,0 +1,434 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +// DeadlineService handles consent deadlines and account suspensions +type DeadlineService struct { + pool *pgxpool.Pool + notificationService *NotificationService +} + +// ConsentDeadline represents a consent deadline for a user +type ConsentDeadline struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + DocumentVersionID uuid.UUID `json:"document_version_id"` + DeadlineAt time.Time `json:"deadline_at"` + ReminderCount int `json:"reminder_count"` + LastReminderAt *time.Time `json:"last_reminder_at"` + ConsentGivenAt *time.Time `json:"consent_given_at"` + CreatedAt time.Time `json:"created_at"` + // Joined fields + DocumentName string `json:"document_name"` + VersionNumber string `json:"version_number"` +} + +// AccountSuspension represents an account suspension +type AccountSuspension struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Reason string `json:"reason"` + Details map[string]interface{} `json:"details"` + SuspendedAt time.Time `json:"suspended_at"` + LiftedAt *time.Time `json:"lifted_at"` + LiftedBy *uuid.UUID `json:"lifted_by"` +} + +// NewDeadlineService creates a new deadline service +func NewDeadlineService(pool *pgxpool.Pool, notificationService *NotificationService) *DeadlineService { + return &DeadlineService{ + pool: pool, + notificationService: notificationService, + } +} + +// CreateDeadlinesForPublishedVersion creates consent deadlines for all active users +// when a new mandatory document version is published +func (s *DeadlineService) CreateDeadlinesForPublishedVersion(ctx context.Context, versionID uuid.UUID) error { + // Get version info + var documentName, versionNumber string + var isMandatory bool + err := s.pool.QueryRow(ctx, ` + SELECT ld.name, dv.version, ld.is_mandatory + FROM document_versions dv + JOIN legal_documents ld ON dv.document_id = ld.id + WHERE dv.id = $1 + `, versionID).Scan(&documentName, &versionNumber, &isMandatory) + if err != nil { + return fmt.Errorf("failed to get version info: %w", err) + } + + // Only create deadlines for mandatory documents + if !isMandatory { + return nil + } + + // Deadline is 30 days from now + deadlineAt := time.Now().AddDate(0, 0, 30) + + // Get all active users who haven't given consent to this version + _, err = s.pool.Exec(ctx, ` + INSERT INTO consent_deadlines (user_id, document_version_id, deadline_at) + SELECT u.id, $1, $2 + FROM users u + WHERE u.account_status = 'active' + AND NOT EXISTS ( + SELECT 1 FROM user_consents uc + WHERE uc.user_id = u.id AND uc.document_version_id = $1 AND uc.consented = TRUE + ) + ON CONFLICT (user_id, document_version_id) DO NOTHING + `, versionID, deadlineAt) + + if err != nil { + return fmt.Errorf("failed to create deadlines: %w", err) + } + + // Notify users via notification service + if s.notificationService != nil { + go s.notificationService.NotifyConsentRequired(ctx, documentName, versionID.String()) + } + + return nil +} + +// MarkConsentGiven marks a deadline as fulfilled when user gives consent +func (s *DeadlineService) MarkConsentGiven(ctx context.Context, userID, versionID uuid.UUID) error { + _, err := s.pool.Exec(ctx, ` + UPDATE consent_deadlines + SET consent_given_at = NOW() + WHERE user_id = $1 AND document_version_id = $2 AND consent_given_at IS NULL + `, userID, versionID) + + if err != nil { + return err + } + + // Check if user should be unsuspended + return s.checkAndLiftSuspension(ctx, userID) +} + +// GetPendingDeadlines returns all pending deadlines for a user +func (s *DeadlineService) GetPendingDeadlines(ctx context.Context, userID uuid.UUID) ([]ConsentDeadline, error) { + rows, err := s.pool.Query(ctx, ` + SELECT cd.id, cd.user_id, cd.document_version_id, cd.deadline_at, + cd.reminder_count, cd.last_reminder_at, cd.consent_given_at, cd.created_at, + ld.name as document_name, dv.version as version_number + FROM consent_deadlines cd + JOIN document_versions dv ON cd.document_version_id = dv.id + JOIN legal_documents ld ON dv.document_id = ld.id + WHERE cd.user_id = $1 AND cd.consent_given_at IS NULL + ORDER BY cd.deadline_at ASC + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var deadlines []ConsentDeadline + for rows.Next() { + var d ConsentDeadline + if err := rows.Scan(&d.ID, &d.UserID, &d.DocumentVersionID, &d.DeadlineAt, + &d.ReminderCount, &d.LastReminderAt, &d.ConsentGivenAt, &d.CreatedAt, + &d.DocumentName, &d.VersionNumber); err != nil { + continue + } + deadlines = append(deadlines, d) + } + + return deadlines, nil +} + +// ProcessDailyDeadlines is meant to be called by a cron job daily +// It sends reminders and suspends accounts that have missed deadlines +func (s *DeadlineService) ProcessDailyDeadlines(ctx context.Context) error { + now := time.Now() + + // 1. Send reminders for upcoming deadlines + if err := s.sendReminders(ctx, now); err != nil { + fmt.Printf("Error sending reminders: %v\n", err) + } + + // 2. Suspend accounts with expired deadlines + if err := s.suspendExpiredAccounts(ctx, now); err != nil { + fmt.Printf("Error suspending accounts: %v\n", err) + } + + return nil +} + +// sendReminders sends reminder notifications based on days remaining +func (s *DeadlineService) sendReminders(ctx context.Context, now time.Time) error { + // Reminder schedule: Day 7, 14, 21, 28 + reminderDays := []int{7, 14, 21, 28} + + for _, days := range reminderDays { + targetDate := now.AddDate(0, 0, days) + dayStart := time.Date(targetDate.Year(), targetDate.Month(), targetDate.Day(), 0, 0, 0, 0, targetDate.Location()) + dayEnd := dayStart.AddDate(0, 0, 1) + + // Find deadlines that fall on this reminder day + rows, err := s.pool.Query(ctx, ` + SELECT cd.id, cd.user_id, cd.document_version_id, cd.deadline_at, cd.reminder_count, + ld.name as document_name + FROM consent_deadlines cd + JOIN document_versions dv ON cd.document_version_id = dv.id + JOIN legal_documents ld ON dv.document_id = ld.id + WHERE cd.consent_given_at IS NULL + AND cd.deadline_at >= $1 AND cd.deadline_at < $2 + AND (cd.last_reminder_at IS NULL OR cd.last_reminder_at < $3) + `, dayStart, dayEnd, dayStart) + + if err != nil { + continue + } + + for rows.Next() { + var id, userID, versionID uuid.UUID + var deadlineAt time.Time + var reminderCount int + var documentName string + + if err := rows.Scan(&id, &userID, &versionID, &deadlineAt, &reminderCount, &documentName); err != nil { + continue + } + + // Send reminder notification + daysLeft := 30 - (30 - days) + urgency := "freundlich" + if days <= 7 { + urgency = "dringend" + } else if days <= 14 { + urgency = "wichtig" + } + + title := fmt.Sprintf("Erinnerung: Zustimmung erforderlich (%s)", urgency) + body := fmt.Sprintf("Bitte bestätigen Sie '%s' innerhalb von %d Tagen.", documentName, daysLeft) + + if s.notificationService != nil { + s.notificationService.CreateNotification(ctx, userID, NotificationTypeConsentReminder, title, body, map[string]interface{}{ + "document_name": documentName, + "days_left": daysLeft, + "version_id": versionID.String(), + }) + } + + // Update reminder count and timestamp + s.pool.Exec(ctx, ` + UPDATE consent_deadlines + SET reminder_count = reminder_count + 1, last_reminder_at = NOW() + WHERE id = $1 + `, id) + } + rows.Close() + } + + return nil +} + +// suspendExpiredAccounts suspends accounts that have missed their deadline +func (s *DeadlineService) suspendExpiredAccounts(ctx context.Context, now time.Time) error { + // Find users with expired deadlines + rows, err := s.pool.Query(ctx, ` + SELECT DISTINCT cd.user_id, array_agg(ld.name) as documents + FROM consent_deadlines cd + JOIN document_versions dv ON cd.document_version_id = dv.id + JOIN legal_documents ld ON dv.document_id = ld.id + JOIN users u ON cd.user_id = u.id + WHERE cd.consent_given_at IS NULL + AND cd.deadline_at < $1 + AND u.account_status = 'active' + AND ld.is_mandatory = TRUE + GROUP BY cd.user_id + `, now) + + if err != nil { + return err + } + defer rows.Close() + + for rows.Next() { + var userID uuid.UUID + var documents []string + + if err := rows.Scan(&userID, &documents); err != nil { + continue + } + + // Suspend the account + if err := s.suspendAccount(ctx, userID, "consent_deadline_missed", documents); err != nil { + fmt.Printf("Failed to suspend user %s: %v\n", userID, err) + } + } + + return nil +} + +// suspendAccount suspends a user account +func (s *DeadlineService) suspendAccount(ctx context.Context, userID uuid.UUID, reason string, documents []string) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + // Update user status + _, err = tx.Exec(ctx, ` + UPDATE users SET account_status = 'suspended', updated_at = NOW() + WHERE id = $1 AND account_status = 'active' + `, userID) + if err != nil { + return err + } + + // Create suspension record + _, err = tx.Exec(ctx, ` + INSERT INTO account_suspensions (user_id, reason, details) + VALUES ($1, $2, $3) + `, userID, reason, map[string]interface{}{"documents": documents}) + if err != nil { + return err + } + + // Log to audit + _, err = tx.Exec(ctx, ` + INSERT INTO consent_audit_log (user_id, action, entity_type, entity_id, details) + VALUES ($1, 'account_suspended', 'user', $1, $2) + `, userID, map[string]interface{}{"reason": reason, "documents": documents}) + if err != nil { + return err + } + + if err := tx.Commit(ctx); err != nil { + return err + } + + // Send suspension notification + if s.notificationService != nil { + title := "Account vorübergehend gesperrt" + body := "Ihr Account wurde gesperrt, da ausstehende Zustimmungen nicht innerhalb der Frist erteilt wurden. Bitte bestätigen Sie die ausstehenden Dokumente." + s.notificationService.CreateNotification(ctx, userID, NotificationTypeAccountSuspended, title, body, map[string]interface{}{ + "documents": documents, + }) + } + + return nil +} + +// checkAndLiftSuspension checks if user has completed all required consents and lifts suspension +func (s *DeadlineService) checkAndLiftSuspension(ctx context.Context, userID uuid.UUID) error { + // Check if user is currently suspended + var accountStatus string + err := s.pool.QueryRow(ctx, `SELECT account_status FROM users WHERE id = $1`, userID).Scan(&accountStatus) + if err != nil || accountStatus != "suspended" { + return nil + } + + // Check if there are any pending mandatory consents + var pendingCount int + err = s.pool.QueryRow(ctx, ` + SELECT COUNT(*) + FROM consent_deadlines cd + JOIN document_versions dv ON cd.document_version_id = dv.id + JOIN legal_documents ld ON dv.document_id = ld.id + WHERE cd.user_id = $1 + AND cd.consent_given_at IS NULL + AND ld.is_mandatory = TRUE + `, userID).Scan(&pendingCount) + + if err != nil { + return err + } + + // If no pending consents, lift the suspension + if pendingCount == 0 { + return s.liftSuspension(ctx, userID) + } + + return nil +} + +// liftSuspension lifts a user's suspension +func (s *DeadlineService) liftSuspension(ctx context.Context, userID uuid.UUID) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + // Update user status + _, err = tx.Exec(ctx, ` + UPDATE users SET account_status = 'active', updated_at = NOW() + WHERE id = $1 AND account_status = 'suspended' + `, userID) + if err != nil { + return err + } + + // Update suspension record + _, err = tx.Exec(ctx, ` + UPDATE account_suspensions + SET lifted_at = NOW() + WHERE user_id = $1 AND lifted_at IS NULL + `, userID) + if err != nil { + return err + } + + // Log to audit + _, err = tx.Exec(ctx, ` + INSERT INTO consent_audit_log (user_id, action, entity_type, entity_id) + VALUES ($1, 'account_restored', 'user', $1) + `, userID) + if err != nil { + return err + } + + if err := tx.Commit(ctx); err != nil { + return err + } + + // Send restoration notification + if s.notificationService != nil { + title := "Account wiederhergestellt" + body := "Vielen Dank! Ihr Account wurde wiederhergestellt. Sie können die Anwendung wieder vollständig nutzen." + s.notificationService.CreateNotification(ctx, userID, NotificationTypeAccountRestored, title, body, nil) + } + + return nil +} + +// GetAccountSuspension returns the current suspension for a user +func (s *DeadlineService) GetAccountSuspension(ctx context.Context, userID uuid.UUID) (*AccountSuspension, error) { + var suspension AccountSuspension + err := s.pool.QueryRow(ctx, ` + SELECT id, user_id, reason, details, suspended_at, lifted_at, lifted_by + FROM account_suspensions + WHERE user_id = $1 AND lifted_at IS NULL + ORDER BY suspended_at DESC + LIMIT 1 + `, userID).Scan(&suspension.ID, &suspension.UserID, &suspension.Reason, &suspension.Details, + &suspension.SuspendedAt, &suspension.LiftedAt, &suspension.LiftedBy) + + if err != nil { + return nil, err + } + + return &suspension, nil +} + +// IsUserSuspended checks if a user is currently suspended +func (s *DeadlineService) IsUserSuspended(ctx context.Context, userID uuid.UUID) (bool, error) { + var status string + err := s.pool.QueryRow(ctx, `SELECT account_status FROM users WHERE id = $1`, userID).Scan(&status) + if err != nil { + return false, err + } + return status == "suspended", nil +} diff --git a/consent-service/internal/services/deadline_service_test.go b/consent-service/internal/services/deadline_service_test.go new file mode 100644 index 0000000..d15dfdb --- /dev/null +++ b/consent-service/internal/services/deadline_service_test.go @@ -0,0 +1,439 @@ +package services + +import ( + "testing" + "time" + + "github.com/google/uuid" +) + +// TestDeadlineService_CreateDeadline tests creating consent deadlines +func TestDeadlineService_CreateDeadline(t *testing.T) { + tests := []struct { + name string + userID uuid.UUID + versionID uuid.UUID + deadlineAt time.Time + expectError bool + }{ + { + name: "valid deadline - 30 days", + userID: uuid.New(), + versionID: uuid.New(), + deadlineAt: time.Now().AddDate(0, 0, 30), + expectError: false, + }, + { + name: "valid deadline - 14 days", + userID: uuid.New(), + versionID: uuid.New(), + deadlineAt: time.Now().AddDate(0, 0, 14), + expectError: false, + }, + { + name: "invalid user ID", + userID: uuid.Nil, + versionID: uuid.New(), + deadlineAt: time.Now().AddDate(0, 0, 30), + expectError: true, + }, + { + name: "invalid version ID", + userID: uuid.New(), + versionID: uuid.Nil, + deadlineAt: time.Now().AddDate(0, 0, 30), + expectError: true, + }, + { + name: "deadline in past", + userID: uuid.New(), + versionID: uuid.New(), + deadlineAt: time.Now().AddDate(0, 0, -1), + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + if tt.userID == uuid.Nil { + err = &ValidationError{Field: "user ID", Message: "required"} + } else if tt.versionID == uuid.Nil { + err = &ValidationError{Field: "version ID", Message: "required"} + } else if tt.deadlineAt.Before(time.Now()) { + err = &ValidationError{Field: "deadline", Message: "must be in the future"} + } + + if tt.expectError && err == nil { + t.Error("Expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +// TestDeadlineService_CheckDeadlineStatus tests deadline status checking +func TestDeadlineService_CheckDeadlineStatus(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + deadlineAt time.Time + isOverdue bool + daysLeft int + urgency string + }{ + { + name: "30 days left", + deadlineAt: now.AddDate(0, 0, 30), + isOverdue: false, + daysLeft: 30, + urgency: "normal", + }, + { + name: "7 days left - warning", + deadlineAt: now.AddDate(0, 0, 7), + isOverdue: false, + daysLeft: 7, + urgency: "warning", + }, + { + name: "3 days left - urgent", + deadlineAt: now.AddDate(0, 0, 3), + isOverdue: false, + daysLeft: 3, + urgency: "urgent", + }, + { + name: "1 day left - critical", + deadlineAt: now.AddDate(0, 0, 1), + isOverdue: false, + daysLeft: 1, + urgency: "critical", + }, + { + name: "overdue by 1 day", + deadlineAt: now.AddDate(0, 0, -1), + isOverdue: true, + daysLeft: -1, + urgency: "overdue", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isOverdue := tt.deadlineAt.Before(now) + daysLeft := int(tt.deadlineAt.Sub(now).Hours() / 24) + + var urgency string + if isOverdue { + urgency = "overdue" + } else if daysLeft <= 1 { + urgency = "critical" + } else if daysLeft <= 3 { + urgency = "urgent" + } else if daysLeft <= 7 { + urgency = "warning" + } else { + urgency = "normal" + } + + if isOverdue != tt.isOverdue { + t.Errorf("Expected isOverdue=%v, got %v", tt.isOverdue, isOverdue) + } + + if abs(daysLeft-tt.daysLeft) > 1 { // Allow 1 day difference + t.Errorf("Expected daysLeft=%d, got %d", tt.daysLeft, daysLeft) + } + + if urgency != tt.urgency { + t.Errorf("Expected urgency=%s, got %s", tt.urgency, urgency) + } + }) + } +} + +// TestDeadlineService_SendReminders tests reminder scheduling +func TestDeadlineService_SendReminders(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + deadlineAt time.Time + lastReminderAt *time.Time + reminderCount int + shouldSend bool + nextReminder int // days before deadline + }{ + { + name: "first reminder - 14 days before", + deadlineAt: now.AddDate(0, 0, 14), + lastReminderAt: nil, + reminderCount: 0, + shouldSend: true, + nextReminder: 14, + }, + { + name: "second reminder - 7 days before", + deadlineAt: now.AddDate(0, 0, 7), + lastReminderAt: ptrTime(now.AddDate(0, 0, -7)), + reminderCount: 1, + shouldSend: true, + nextReminder: 7, + }, + { + name: "third reminder - 3 days before", + deadlineAt: now.AddDate(0, 0, 3), + lastReminderAt: ptrTime(now.AddDate(0, 0, -4)), + reminderCount: 2, + shouldSend: true, + nextReminder: 3, + }, + { + name: "final reminder - 1 day before", + deadlineAt: now.AddDate(0, 0, 1), + lastReminderAt: ptrTime(now.AddDate(0, 0, -2)), + reminderCount: 3, + shouldSend: true, + nextReminder: 1, + }, + { + name: "too soon for next reminder", + deadlineAt: now.AddDate(0, 0, 10), + lastReminderAt: ptrTime(now.AddDate(0, 0, -1)), + reminderCount: 1, + shouldSend: false, + nextReminder: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + daysUntilDeadline := int(tt.deadlineAt.Sub(now).Hours() / 24) + + // Reminder schedule: 14, 7, 3, 1 days before deadline + reminderDays := []int{14, 7, 3, 1} + shouldSend := false + + for _, day := range reminderDays { + if daysUntilDeadline == day { + // Check if enough time passed since last reminder + if tt.lastReminderAt == nil || now.Sub(*tt.lastReminderAt) > 12*time.Hour { + shouldSend = true + break + } + } + } + + if shouldSend != tt.shouldSend { + t.Errorf("Expected shouldSend=%v, got %v", tt.shouldSend, shouldSend) + } + }) + } +} + +// TestDeadlineService_SuspendAccount tests account suspension logic +func TestDeadlineService_SuspendAccount(t *testing.T) { + tests := []struct { + name string + userID uuid.UUID + reason string + shouldSuspend bool + expectError bool + }{ + { + name: "suspend for missed deadline", + userID: uuid.New(), + reason: "consent_deadline_exceeded", + shouldSuspend: true, + expectError: false, + }, + { + name: "invalid user ID", + userID: uuid.Nil, + reason: "consent_deadline_exceeded", + shouldSuspend: false, + expectError: true, + }, + { + name: "invalid reason", + userID: uuid.New(), + reason: "", + shouldSuspend: false, + expectError: true, + }, + } + + validReasons := map[string]bool{ + "consent_deadline_exceeded": true, + "mandatory_consent_missing": true, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + if tt.userID == uuid.Nil { + err = &ValidationError{Field: "user ID", Message: "required"} + } else if !validReasons[tt.reason] && tt.reason != "" { + err = &ValidationError{Field: "reason", Message: "invalid suspension reason"} + } else if tt.reason == "" { + err = &ValidationError{Field: "reason", Message: "required"} + } + + if tt.expectError && err == nil { + t.Error("Expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +// TestDeadlineService_LiftSuspension tests lifting account suspension +func TestDeadlineService_LiftSuspension(t *testing.T) { + tests := []struct { + name string + userID uuid.UUID + adminID uuid.UUID + reason string + expectError bool + }{ + { + name: "lift valid suspension", + userID: uuid.New(), + adminID: uuid.New(), + reason: "consent provided", + expectError: false, + }, + { + name: "invalid user ID", + userID: uuid.Nil, + adminID: uuid.New(), + reason: "consent provided", + expectError: true, + }, + { + name: "invalid admin ID", + userID: uuid.New(), + adminID: uuid.Nil, + reason: "consent provided", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + if tt.userID == uuid.Nil { + err = &ValidationError{Field: "user ID", Message: "required"} + } else if tt.adminID == uuid.Nil { + err = &ValidationError{Field: "admin ID", Message: "required"} + } + + if tt.expectError && err == nil { + t.Error("Expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +// TestDeadlineService_GetOverdueDeadlines tests finding overdue deadlines +func TestDeadlineService_GetOverdueDeadlines(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + deadlines []time.Time + expected int // number of overdue + }{ + { + name: "no overdue deadlines", + deadlines: []time.Time{ + now.AddDate(0, 0, 1), + now.AddDate(0, 0, 7), + now.AddDate(0, 0, 30), + }, + expected: 0, + }, + { + name: "some overdue", + deadlines: []time.Time{ + now.AddDate(0, 0, -1), + now.AddDate(0, 0, -5), + now.AddDate(0, 0, 7), + }, + expected: 2, + }, + { + name: "all overdue", + deadlines: []time.Time{ + now.AddDate(0, 0, -1), + now.AddDate(0, 0, -7), + now.AddDate(0, 0, -30), + }, + expected: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + overdueCount := 0 + for _, deadline := range tt.deadlines { + if deadline.Before(now) { + overdueCount++ + } + } + + if overdueCount != tt.expected { + t.Errorf("Expected %d overdue, got %d", tt.expected, overdueCount) + } + }) + } +} + +// TestDeadlineService_ProcessScheduledTasks tests scheduled task processing +func TestDeadlineService_ProcessScheduledTasks(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + task string + scheduledAt time.Time + shouldProcess bool + }{ + { + name: "process due task", + task: "send_reminder", + scheduledAt: now.Add(-1 * time.Hour), + shouldProcess: true, + }, + { + name: "skip future task", + task: "send_reminder", + scheduledAt: now.Add(1 * time.Hour), + shouldProcess: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + shouldProcess := tt.scheduledAt.Before(now) || tt.scheduledAt.Equal(now) + + if shouldProcess != tt.shouldProcess { + t.Errorf("Expected shouldProcess=%v, got %v", tt.shouldProcess, shouldProcess) + } + }) + } +} + +// Helper functions + +func ptrTime(t time.Time) *time.Time { + return &t +} diff --git a/consent-service/internal/services/document_service_test.go b/consent-service/internal/services/document_service_test.go new file mode 100644 index 0000000..ed5f582 --- /dev/null +++ b/consent-service/internal/services/document_service_test.go @@ -0,0 +1,728 @@ +package services + +import ( + "regexp" + "testing" + "time" + + "github.com/google/uuid" +) + +// TestDocumentService_CreateDocument tests creating a new legal document +func TestDocumentService_CreateDocument(t *testing.T) { + tests := []struct { + name string + docType string + docName string + description string + isMandatory bool + expectError bool + errorContains string + }{ + { + name: "valid mandatory document", + docType: "terms", + docName: "Terms of Service", + description: "Our terms and conditions", + isMandatory: true, + expectError: false, + }, + { + name: "valid optional document", + docType: "cookies", + docName: "Cookie Policy", + description: "How we use cookies", + isMandatory: false, + expectError: false, + }, + { + name: "empty document type", + docType: "", + docName: "Test Document", + description: "Test", + isMandatory: true, + expectError: true, + errorContains: "type", + }, + { + name: "empty document name", + docType: "privacy", + docName: "", + description: "Test", + isMandatory: true, + expectError: true, + errorContains: "name", + }, + { + name: "invalid document type", + docType: "invalid_type", + docName: "Test", + description: "Test", + isMandatory: false, + expectError: true, + errorContains: "type", + }, + } + + validTypes := map[string]bool{ + "terms": true, + "privacy": true, + "cookies": true, + "community_guidelines": true, + "imprint": true, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Validate inputs + var err error + if tt.docType == "" { + err = &ValidationError{Field: "type", Message: "required"} + } else if !validTypes[tt.docType] { + err = &ValidationError{Field: "type", Message: "invalid document type"} + } else if tt.docName == "" { + err = &ValidationError{Field: "name", Message: "required"} + } + + // Assert + if tt.expectError { + if err == nil { + t.Errorf("Expected error containing '%s', got nil", tt.errorContains) + } + } else { + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + } + }) + } +} + +// TestDocumentService_UpdateDocument tests updating a document +func TestDocumentService_UpdateDocument(t *testing.T) { + tests := []struct { + name string + documentID uuid.UUID + newName string + newActive bool + expectError bool + }{ + { + name: "valid update", + documentID: uuid.New(), + newName: "Updated Name", + newActive: true, + expectError: false, + }, + { + name: "deactivate document", + documentID: uuid.New(), + newName: "Test", + newActive: false, + expectError: false, + }, + { + name: "invalid document ID", + documentID: uuid.Nil, + newName: "Test", + newActive: true, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + if tt.documentID == uuid.Nil { + err = &ValidationError{Field: "document ID", Message: "required"} + } + + if tt.expectError && err == nil { + t.Error("Expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +// TestDocumentService_CreateVersion tests creating a document version +func TestDocumentService_CreateVersion(t *testing.T) { + tests := []struct { + name string + documentID uuid.UUID + version string + language string + title string + content string + expectError bool + errorContains string + }{ + { + name: "valid version - German", + documentID: uuid.New(), + version: "1.0.0", + language: "de", + title: "Nutzungsbedingungen", + content: "

              Terms

              Content...

              ", + expectError: false, + }, + { + name: "valid version - English", + documentID: uuid.New(), + version: "1.0.0", + language: "en", + title: "Terms of Service", + content: "

              Terms

              Content...

              ", + expectError: false, + }, + { + name: "invalid version format", + documentID: uuid.New(), + version: "1.0", + language: "de", + title: "Test", + content: "Content", + expectError: true, + errorContains: "version", + }, + { + name: "invalid language", + documentID: uuid.New(), + version: "1.0.0", + language: "fr", + title: "Test", + content: "Content", + expectError: true, + errorContains: "language", + }, + { + name: "empty title", + documentID: uuid.New(), + version: "1.0.0", + language: "de", + title: "", + content: "Content", + expectError: true, + errorContains: "title", + }, + { + name: "empty content", + documentID: uuid.New(), + version: "1.0.0", + language: "de", + title: "Test", + content: "", + expectError: true, + errorContains: "content", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Validate semver format (X.Y.Z pattern) + validVersion := regexp.MustCompile(`^\d+\.\d+\.\d+$`).MatchString(tt.version) + validLanguage := tt.language == "de" || tt.language == "en" + + var err error + if !validVersion { + err = &ValidationError{Field: "version", Message: "invalid format"} + } else if !validLanguage { + err = &ValidationError{Field: "language", Message: "must be 'de' or 'en'"} + } else if tt.title == "" { + err = &ValidationError{Field: "title", Message: "required"} + } else if tt.content == "" { + err = &ValidationError{Field: "content", Message: "required"} + } + + if tt.expectError { + if err == nil { + t.Errorf("Expected error containing '%s', got nil", tt.errorContains) + } + } else { + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + } + }) + } +} + +// TestDocumentService_VersionStatusTransitions tests version status workflow +func TestDocumentService_VersionStatusTransitions(t *testing.T) { + tests := []struct { + name string + fromStatus string + toStatus string + isAllowed bool + }{ + // Valid transitions + {"draft to review", "draft", "review", true}, + {"review to approved", "review", "approved", true}, + {"review to rejected", "review", "rejected", true}, + {"approved to published", "approved", "published", true}, + {"approved to scheduled", "approved", "scheduled", true}, + {"scheduled to published", "scheduled", "published", true}, + {"published to archived", "published", "archived", true}, + {"rejected to draft", "rejected", "draft", true}, + + // Invalid transitions + {"draft to published", "draft", "published", false}, + {"draft to approved", "draft", "approved", false}, + {"review to published", "review", "published", false}, + {"published to draft", "published", "draft", false}, + {"published to review", "published", "review", false}, + {"archived to draft", "archived", "draft", false}, + {"archived to published", "archived", "published", false}, + } + + // Define valid transitions + validTransitions := map[string][]string{ + "draft": {"review"}, + "review": {"approved", "rejected"}, + "approved": {"published", "scheduled"}, + "scheduled": {"published"}, + "published": {"archived"}, + "rejected": {"draft"}, + "archived": {}, // terminal state + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Check if transition is allowed + allowed := false + if transitions, ok := validTransitions[tt.fromStatus]; ok { + for _, validTo := range transitions { + if validTo == tt.toStatus { + allowed = true + break + } + } + } + + if allowed != tt.isAllowed { + t.Errorf("Transition %s->%s: expected allowed=%v, got %v", + tt.fromStatus, tt.toStatus, tt.isAllowed, allowed) + } + }) + } +} + +// TestDocumentService_PublishVersion tests publishing a version +func TestDocumentService_PublishVersion(t *testing.T) { + tests := []struct { + name string + versionID uuid.UUID + currentStatus string + expectError bool + errorContains string + }{ + { + name: "publish approved version", + versionID: uuid.New(), + currentStatus: "approved", + expectError: false, + }, + { + name: "publish scheduled version", + versionID: uuid.New(), + currentStatus: "scheduled", + expectError: false, + }, + { + name: "cannot publish draft", + versionID: uuid.New(), + currentStatus: "draft", + expectError: true, + errorContains: "draft", + }, + { + name: "cannot publish review", + versionID: uuid.New(), + currentStatus: "review", + expectError: true, + errorContains: "review", + }, + { + name: "invalid version ID", + versionID: uuid.Nil, + currentStatus: "approved", + expectError: true, + errorContains: "ID", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + if tt.versionID == uuid.Nil { + err = &ValidationError{Field: "version ID", Message: "required"} + } else if tt.currentStatus != "approved" && tt.currentStatus != "scheduled" { + err = &ValidationError{Field: "status", Message: "only approved or scheduled versions can be published"} + } + + if tt.expectError { + if err == nil { + t.Errorf("Expected error containing '%s', got nil", tt.errorContains) + } + } else { + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + } + }) + } +} + +// TestDocumentService_ArchiveVersion tests archiving a version +func TestDocumentService_ArchiveVersion(t *testing.T) { + tests := []struct { + name string + versionID uuid.UUID + expectError bool + }{ + { + name: "archive valid version", + versionID: uuid.New(), + expectError: false, + }, + { + name: "invalid version ID", + versionID: uuid.Nil, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + if tt.versionID == uuid.Nil { + err = &ValidationError{Field: "version ID", Message: "required"} + } + + if tt.expectError && err == nil { + t.Error("Expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +// TestDocumentService_DeleteVersion tests deleting a version +func TestDocumentService_DeleteVersion(t *testing.T) { + tests := []struct { + name string + versionID uuid.UUID + status string + canDelete bool + expectError bool + }{ + { + name: "delete draft version", + versionID: uuid.New(), + status: "draft", + canDelete: true, + expectError: false, + }, + { + name: "delete rejected version", + versionID: uuid.New(), + status: "rejected", + canDelete: true, + expectError: false, + }, + { + name: "cannot delete published version", + versionID: uuid.New(), + status: "published", + canDelete: false, + expectError: true, + }, + { + name: "cannot delete approved version", + versionID: uuid.New(), + status: "approved", + canDelete: false, + expectError: true, + }, + { + name: "cannot delete archived version", + versionID: uuid.New(), + status: "archived", + canDelete: false, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Only draft and rejected can be deleted + canDelete := tt.status == "draft" || tt.status == "rejected" + + var err error + if !canDelete { + err = &ValidationError{Field: "status", Message: "only draft or rejected versions can be deleted"} + } + + if tt.expectError { + if err == nil { + t.Error("Expected error, got nil") + } + } else { + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + } + + if canDelete != tt.canDelete { + t.Errorf("Expected canDelete=%v, got %v", tt.canDelete, canDelete) + } + }) + } +} + +// TestDocumentService_GetLatestVersion tests retrieving the latest version +func TestDocumentService_GetLatestVersion(t *testing.T) { + tests := []struct { + name string + documentID uuid.UUID + language string + status string + expectError bool + }{ + { + name: "get latest German version", + documentID: uuid.New(), + language: "de", + status: "published", + expectError: false, + }, + { + name: "get latest English version", + documentID: uuid.New(), + language: "en", + status: "published", + expectError: false, + }, + { + name: "invalid document ID", + documentID: uuid.Nil, + language: "de", + status: "published", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + if tt.documentID == uuid.Nil { + err = &ValidationError{Field: "document ID", Message: "required"} + } + + if tt.expectError && err == nil { + t.Error("Expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +// TestDocumentService_CompareVersions tests version comparison +func TestDocumentService_CompareVersions(t *testing.T) { + tests := []struct { + name string + version1 string + version2 string + isDifferent bool + }{ + { + name: "same version", + version1: "1.0.0", + version2: "1.0.0", + isDifferent: false, + }, + { + name: "different major version", + version1: "2.0.0", + version2: "1.0.0", + isDifferent: true, + }, + { + name: "different minor version", + version1: "1.1.0", + version2: "1.0.0", + isDifferent: true, + }, + { + name: "different patch version", + version1: "1.0.1", + version2: "1.0.0", + isDifferent: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isDifferent := tt.version1 != tt.version2 + + if isDifferent != tt.isDifferent { + t.Errorf("Expected isDifferent=%v, got %v", tt.isDifferent, isDifferent) + } + }) + } +} + +// TestDocumentService_ScheduledPublishing tests scheduled publishing +func TestDocumentService_ScheduledPublishing(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + scheduledAt time.Time + shouldPublish bool + }{ + { + name: "scheduled for past - should publish", + scheduledAt: now.Add(-1 * time.Hour), + shouldPublish: true, + }, + { + name: "scheduled for now - should publish", + scheduledAt: now, + shouldPublish: true, + }, + { + name: "scheduled for future - should not publish", + scheduledAt: now.Add(1 * time.Hour), + shouldPublish: false, + }, + { + name: "scheduled for tomorrow - should not publish", + scheduledAt: now.AddDate(0, 0, 1), + shouldPublish: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + shouldPublish := tt.scheduledAt.Before(now) || tt.scheduledAt.Equal(now) + + if shouldPublish != tt.shouldPublish { + t.Errorf("Expected shouldPublish=%v, got %v", tt.shouldPublish, shouldPublish) + } + }) + } +} + +// TestDocumentService_ApprovalWorkflow tests the approval workflow +func TestDocumentService_ApprovalWorkflow(t *testing.T) { + tests := []struct { + name string + action string + userRole string + isAllowed bool + }{ + // Admin permissions + {"admin submit for review", "submit_review", "admin", true}, + {"admin cannot approve", "approve", "admin", false}, + {"admin can publish", "publish", "admin", true}, + + // DSB permissions + {"dsb can approve", "approve", "data_protection_officer", true}, + {"dsb can reject", "reject", "data_protection_officer", true}, + {"dsb can publish", "publish", "data_protection_officer", true}, + + // User permissions + {"user cannot submit", "submit_review", "user", false}, + {"user cannot approve", "approve", "user", false}, + {"user cannot publish", "publish", "user", false}, + } + + permissions := map[string]map[string]bool{ + "admin": { + "submit_review": true, + "approve": false, + "reject": false, + "publish": true, + }, + "data_protection_officer": { + "submit_review": true, + "approve": true, + "reject": true, + "publish": true, + }, + "user": { + "submit_review": false, + "approve": false, + "reject": false, + "publish": false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rolePerms, ok := permissions[tt.userRole] + if !ok { + t.Fatalf("Unknown role: %s", tt.userRole) + } + + isAllowed := rolePerms[tt.action] + + if isAllowed != tt.isAllowed { + t.Errorf("Role %s action %s: expected allowed=%v, got %v", + tt.userRole, tt.action, tt.isAllowed, isAllowed) + } + }) + } +} + +// TestDocumentService_FourEyesPrinciple tests the four-eyes principle +func TestDocumentService_FourEyesPrinciple(t *testing.T) { + tests := []struct { + name string + createdBy uuid.UUID + approver uuid.UUID + approverRole string + canApprove bool + }{ + { + name: "different users - DSB can approve", + createdBy: uuid.New(), + approver: uuid.New(), + approverRole: "data_protection_officer", + canApprove: true, + }, + { + name: "same user - DSB cannot approve own", + createdBy: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), + approver: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), + approverRole: "data_protection_officer", + canApprove: false, + }, + { + name: "same user - admin CAN approve own (exception)", + createdBy: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), + approver: uuid.MustParse("123e4567-e89b-12d3-a456-426614174000"), + approverRole: "admin", + canApprove: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Four-eyes principle: DSB cannot approve their own work + // Exception: Admins can (for development/testing) + canApprove := tt.createdBy != tt.approver || tt.approverRole == "admin" + + if canApprove != tt.canApprove { + t.Errorf("Expected canApprove=%v, got %v", tt.canApprove, canApprove) + } + }) + } +} diff --git a/consent-service/internal/services/dsr_service.go b/consent-service/internal/services/dsr_service.go new file mode 100644 index 0000000..7b44c0d --- /dev/null +++ b/consent-service/internal/services/dsr_service.go @@ -0,0 +1,947 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/breakpilot/consent-service/internal/models" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +// DSRService handles Data Subject Request business logic +type DSRService struct { + pool *pgxpool.Pool + notificationService *NotificationService + emailService *EmailService +} + +// NewDSRService creates a new DSRService +func NewDSRService(pool *pgxpool.Pool, notificationService *NotificationService, emailService *EmailService) *DSRService { + return &DSRService{ + pool: pool, + notificationService: notificationService, + emailService: emailService, + } +} + +// GetPool returns the database pool for direct queries +func (s *DSRService) GetPool() *pgxpool.Pool { + return s.pool +} + +// generateRequestNumber generates a unique request number like DSR-2025-000001 +func (s *DSRService) generateRequestNumber(ctx context.Context) (string, error) { + var seqNum int64 + err := s.pool.QueryRow(ctx, "SELECT nextval('dsr_request_number_seq')").Scan(&seqNum) + if err != nil { + return "", fmt.Errorf("failed to get next sequence number: %w", err) + } + year := time.Now().Year() + return fmt.Sprintf("DSR-%d-%06d", year, seqNum), nil +} + +// CreateRequest creates a new data subject request +func (s *DSRService) CreateRequest(ctx context.Context, req models.CreateDSRRequest, createdBy *uuid.UUID) (*models.DataSubjectRequest, error) { + // Validate request type + requestType := models.DSRRequestType(req.RequestType) + if !isValidRequestType(requestType) { + return nil, fmt.Errorf("invalid request type: %s", req.RequestType) + } + + // Generate request number + requestNumber, err := s.generateRequestNumber(ctx) + if err != nil { + return nil, err + } + + // Calculate deadline + deadlineDays := requestType.DeadlineDays() + deadline := time.Now().AddDate(0, 0, deadlineDays) + + // Determine priority + priority := models.DSRPriorityNormal + if req.Priority != "" { + priority = models.DSRPriority(req.Priority) + } else if requestType.IsExpedited() { + priority = models.DSRPriorityExpedited + } + + // Determine source + source := models.DSRSourceAPI + if req.Source != "" { + source = models.DSRSource(req.Source) + } + + // Serialize request details + detailsJSON, err := json.Marshal(req.RequestDetails) + if err != nil { + detailsJSON = []byte("{}") + } + + // Try to find existing user by email + var userID *uuid.UUID + var foundUserID uuid.UUID + err = s.pool.QueryRow(ctx, "SELECT id FROM users WHERE email = $1", req.RequesterEmail).Scan(&foundUserID) + if err == nil { + userID = &foundUserID + } + + // Insert request + var dsr models.DataSubjectRequest + err = s.pool.QueryRow(ctx, ` + INSERT INTO data_subject_requests ( + user_id, request_number, request_type, status, priority, source, + requester_email, requester_name, requester_phone, + request_details, deadline_at, legal_deadline_days, created_by + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + RETURNING id, user_id, request_number, request_type, status, priority, source, + requester_email, requester_name, requester_phone, identity_verified, + request_details, deadline_at, legal_deadline_days, created_at, updated_at, created_by + `, userID, requestNumber, requestType, models.DSRStatusIntake, priority, source, + req.RequesterEmail, req.RequesterName, req.RequesterPhone, + detailsJSON, deadline, deadlineDays, createdBy, + ).Scan( + &dsr.ID, &dsr.UserID, &dsr.RequestNumber, &dsr.RequestType, &dsr.Status, + &dsr.Priority, &dsr.Source, &dsr.RequesterEmail, &dsr.RequesterName, + &dsr.RequesterPhone, &dsr.IdentityVerified, &detailsJSON, + &dsr.DeadlineAt, &dsr.LegalDeadlineDays, &dsr.CreatedAt, &dsr.UpdatedAt, &dsr.CreatedBy, + ) + if err != nil { + return nil, fmt.Errorf("failed to create DSR: %w", err) + } + + // Parse details back + json.Unmarshal(detailsJSON, &dsr.RequestDetails) + + // Record initial status + s.recordStatusChange(ctx, dsr.ID, nil, models.DSRStatusIntake, createdBy, "Anfrage eingegangen") + + // Notify DPOs about new request + go s.notifyNewRequest(context.Background(), &dsr) + + return &dsr, nil +} + +// GetByID retrieves a DSR by ID +func (s *DSRService) GetByID(ctx context.Context, id uuid.UUID) (*models.DataSubjectRequest, error) { + var dsr models.DataSubjectRequest + var detailsJSON, resultDataJSON []byte + + err := s.pool.QueryRow(ctx, ` + SELECT id, user_id, request_number, request_type, status, priority, source, + requester_email, requester_name, requester_phone, + identity_verified, identity_verified_at, identity_verified_by, identity_verification_method, + request_details, deadline_at, legal_deadline_days, extended_deadline_at, extension_reason, + assigned_to, processing_notes, completed_at, completed_by, result_summary, result_data, + rejected_at, rejected_by, rejection_reason, rejection_legal_basis, + created_at, updated_at, created_by + FROM data_subject_requests WHERE id = $1 + `, id).Scan( + &dsr.ID, &dsr.UserID, &dsr.RequestNumber, &dsr.RequestType, &dsr.Status, + &dsr.Priority, &dsr.Source, &dsr.RequesterEmail, &dsr.RequesterName, + &dsr.RequesterPhone, &dsr.IdentityVerified, &dsr.IdentityVerifiedAt, + &dsr.IdentityVerifiedBy, &dsr.IdentityVerificationMethod, + &detailsJSON, &dsr.DeadlineAt, &dsr.LegalDeadlineDays, + &dsr.ExtendedDeadlineAt, &dsr.ExtensionReason, &dsr.AssignedTo, + &dsr.ProcessingNotes, &dsr.CompletedAt, &dsr.CompletedBy, + &dsr.ResultSummary, &resultDataJSON, &dsr.RejectedAt, &dsr.RejectedBy, + &dsr.RejectionReason, &dsr.RejectionLegalBasis, + &dsr.CreatedAt, &dsr.UpdatedAt, &dsr.CreatedBy, + ) + if err != nil { + return nil, fmt.Errorf("DSR not found: %w", err) + } + + json.Unmarshal(detailsJSON, &dsr.RequestDetails) + json.Unmarshal(resultDataJSON, &dsr.ResultData) + + return &dsr, nil +} + +// GetByNumber retrieves a DSR by request number +func (s *DSRService) GetByNumber(ctx context.Context, requestNumber string) (*models.DataSubjectRequest, error) { + var id uuid.UUID + err := s.pool.QueryRow(ctx, "SELECT id FROM data_subject_requests WHERE request_number = $1", requestNumber).Scan(&id) + if err != nil { + return nil, fmt.Errorf("DSR not found: %w", err) + } + return s.GetByID(ctx, id) +} + +// List retrieves DSRs with filters and pagination +func (s *DSRService) List(ctx context.Context, filters models.DSRListFilters, limit, offset int) ([]models.DataSubjectRequest, int, error) { + // Build query + baseQuery := "FROM data_subject_requests WHERE 1=1" + args := []interface{}{} + argIndex := 1 + + if filters.Status != nil && *filters.Status != "" { + baseQuery += fmt.Sprintf(" AND status = $%d", argIndex) + args = append(args, *filters.Status) + argIndex++ + } + + if filters.RequestType != nil && *filters.RequestType != "" { + baseQuery += fmt.Sprintf(" AND request_type = $%d", argIndex) + args = append(args, *filters.RequestType) + argIndex++ + } + + if filters.AssignedTo != nil && *filters.AssignedTo != "" { + baseQuery += fmt.Sprintf(" AND assigned_to = $%d", argIndex) + args = append(args, *filters.AssignedTo) + argIndex++ + } + + if filters.Priority != nil && *filters.Priority != "" { + baseQuery += fmt.Sprintf(" AND priority = $%d", argIndex) + args = append(args, *filters.Priority) + argIndex++ + } + + if filters.OverdueOnly { + baseQuery += " AND deadline_at < NOW() AND status NOT IN ('completed', 'rejected', 'cancelled')" + } + + if filters.FromDate != nil { + baseQuery += fmt.Sprintf(" AND created_at >= $%d", argIndex) + args = append(args, *filters.FromDate) + argIndex++ + } + + if filters.ToDate != nil { + baseQuery += fmt.Sprintf(" AND created_at <= $%d", argIndex) + args = append(args, *filters.ToDate) + argIndex++ + } + + if filters.Search != nil && *filters.Search != "" { + searchPattern := "%" + *filters.Search + "%" + baseQuery += fmt.Sprintf(" AND (request_number ILIKE $%d OR requester_email ILIKE $%d OR requester_name ILIKE $%d)", argIndex, argIndex, argIndex) + args = append(args, searchPattern) + argIndex++ + } + + // Get total count + var total int + err := s.pool.QueryRow(ctx, "SELECT COUNT(*) "+baseQuery, args...).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("failed to count DSRs: %w", err) + } + + // Get paginated results + query := fmt.Sprintf(` + SELECT id, user_id, request_number, request_type, status, priority, source, + requester_email, requester_name, requester_phone, identity_verified, + deadline_at, legal_deadline_days, assigned_to, created_at, updated_at + %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d + `, baseQuery, argIndex, argIndex+1) + args = append(args, limit, offset) + + rows, err := s.pool.Query(ctx, query, args...) + if err != nil { + return nil, 0, fmt.Errorf("failed to query DSRs: %w", err) + } + defer rows.Close() + + var dsrs []models.DataSubjectRequest + for rows.Next() { + var dsr models.DataSubjectRequest + err := rows.Scan( + &dsr.ID, &dsr.UserID, &dsr.RequestNumber, &dsr.RequestType, &dsr.Status, + &dsr.Priority, &dsr.Source, &dsr.RequesterEmail, &dsr.RequesterName, + &dsr.RequesterPhone, &dsr.IdentityVerified, &dsr.DeadlineAt, + &dsr.LegalDeadlineDays, &dsr.AssignedTo, &dsr.CreatedAt, &dsr.UpdatedAt, + ) + if err != nil { + return nil, 0, fmt.Errorf("failed to scan DSR: %w", err) + } + dsrs = append(dsrs, dsr) + } + + return dsrs, total, nil +} + +// ListByUser retrieves DSRs for a specific user +func (s *DSRService) ListByUser(ctx context.Context, userID uuid.UUID) ([]models.DataSubjectRequest, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, user_id, request_number, request_type, status, priority, source, + requester_email, requester_name, deadline_at, created_at, updated_at + FROM data_subject_requests + WHERE user_id = $1 OR requester_email = (SELECT email FROM users WHERE id = $1) + ORDER BY created_at DESC + `, userID) + if err != nil { + return nil, fmt.Errorf("failed to query user DSRs: %w", err) + } + defer rows.Close() + + var dsrs []models.DataSubjectRequest + for rows.Next() { + var dsr models.DataSubjectRequest + err := rows.Scan( + &dsr.ID, &dsr.UserID, &dsr.RequestNumber, &dsr.RequestType, &dsr.Status, + &dsr.Priority, &dsr.Source, &dsr.RequesterEmail, &dsr.RequesterName, + &dsr.DeadlineAt, &dsr.CreatedAt, &dsr.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan DSR: %w", err) + } + dsrs = append(dsrs, dsr) + } + + return dsrs, nil +} + +// UpdateStatus changes the status of a DSR +func (s *DSRService) UpdateStatus(ctx context.Context, id uuid.UUID, newStatus models.DSRStatus, comment string, changedBy *uuid.UUID) error { + // Get current status + var currentStatus models.DSRStatus + err := s.pool.QueryRow(ctx, "SELECT status FROM data_subject_requests WHERE id = $1", id).Scan(¤tStatus) + if err != nil { + return fmt.Errorf("DSR not found: %w", err) + } + + // Validate transition + if !isValidStatusTransition(currentStatus, newStatus) { + return fmt.Errorf("invalid status transition from %s to %s", currentStatus, newStatus) + } + + // Update status + _, err = s.pool.Exec(ctx, ` + UPDATE data_subject_requests SET status = $1, updated_at = NOW() WHERE id = $2 + `, newStatus, id) + if err != nil { + return fmt.Errorf("failed to update status: %w", err) + } + + // Record status change + s.recordStatusChange(ctx, id, ¤tStatus, newStatus, changedBy, comment) + + return nil +} + +// VerifyIdentity marks identity as verified +func (s *DSRService) VerifyIdentity(ctx context.Context, id uuid.UUID, method string, verifiedBy uuid.UUID) error { + _, err := s.pool.Exec(ctx, ` + UPDATE data_subject_requests + SET identity_verified = TRUE, + identity_verified_at = NOW(), + identity_verified_by = $1, + identity_verification_method = $2, + status = CASE WHEN status = 'intake' THEN 'identity_verification' ELSE status END, + updated_at = NOW() + WHERE id = $3 + `, verifiedBy, method, id) + if err != nil { + return fmt.Errorf("failed to verify identity: %w", err) + } + + s.recordStatusChange(ctx, id, nil, models.DSRStatusIdentityVerification, &verifiedBy, "Identität verifiziert via "+method) + + return nil +} + +// AssignRequest assigns a DSR to a handler +func (s *DSRService) AssignRequest(ctx context.Context, id uuid.UUID, assigneeID uuid.UUID, assignedBy uuid.UUID) error { + _, err := s.pool.Exec(ctx, ` + UPDATE data_subject_requests SET assigned_to = $1, updated_at = NOW() WHERE id = $2 + `, assigneeID, id) + if err != nil { + return fmt.Errorf("failed to assign DSR: %w", err) + } + + // Get assignee name for comment + var assigneeName string + s.pool.QueryRow(ctx, "SELECT COALESCE(name, email) FROM users WHERE id = $1", assigneeID).Scan(&assigneeName) + + s.recordStatusChange(ctx, id, nil, "", &assignedBy, "Zugewiesen an "+assigneeName) + + // Notify assignee + go s.notifyAssignment(context.Background(), id, assigneeID) + + return nil +} + +// ExtendDeadline extends the deadline for a DSR +func (s *DSRService) ExtendDeadline(ctx context.Context, id uuid.UUID, reason string, days int, extendedBy uuid.UUID) error { + // Default extension is 2 months (60 days) per Art. 12(3) + if days <= 0 { + days = 60 + } + + _, err := s.pool.Exec(ctx, ` + UPDATE data_subject_requests + SET extended_deadline_at = deadline_at + ($1 || ' days')::INTERVAL, + extension_reason = $2, + updated_at = NOW() + WHERE id = $3 + `, days, reason, id) + if err != nil { + return fmt.Errorf("failed to extend deadline: %w", err) + } + + s.recordStatusChange(ctx, id, nil, "", &extendedBy, fmt.Sprintf("Frist um %d Tage verlängert: %s", days, reason)) + + return nil +} + +// CompleteRequest marks a DSR as completed +func (s *DSRService) CompleteRequest(ctx context.Context, id uuid.UUID, summary string, resultData map[string]interface{}, completedBy uuid.UUID) error { + resultJSON, _ := json.Marshal(resultData) + + // Get current status + var currentStatus models.DSRStatus + s.pool.QueryRow(ctx, "SELECT status FROM data_subject_requests WHERE id = $1", id).Scan(¤tStatus) + + _, err := s.pool.Exec(ctx, ` + UPDATE data_subject_requests + SET status = 'completed', + completed_at = NOW(), + completed_by = $1, + result_summary = $2, + result_data = $3, + updated_at = NOW() + WHERE id = $4 + `, completedBy, summary, resultJSON, id) + if err != nil { + return fmt.Errorf("failed to complete DSR: %w", err) + } + + s.recordStatusChange(ctx, id, ¤tStatus, models.DSRStatusCompleted, &completedBy, summary) + + return nil +} + +// RejectRequest rejects a DSR with legal basis +func (s *DSRService) RejectRequest(ctx context.Context, id uuid.UUID, reason, legalBasis string, rejectedBy uuid.UUID) error { + // Get current status + var currentStatus models.DSRStatus + s.pool.QueryRow(ctx, "SELECT status FROM data_subject_requests WHERE id = $1", id).Scan(¤tStatus) + + _, err := s.pool.Exec(ctx, ` + UPDATE data_subject_requests + SET status = 'rejected', + rejected_at = NOW(), + rejected_by = $1, + rejection_reason = $2, + rejection_legal_basis = $3, + updated_at = NOW() + WHERE id = $4 + `, rejectedBy, reason, legalBasis, id) + if err != nil { + return fmt.Errorf("failed to reject DSR: %w", err) + } + + s.recordStatusChange(ctx, id, ¤tStatus, models.DSRStatusRejected, &rejectedBy, fmt.Sprintf("Abgelehnt (%s): %s", legalBasis, reason)) + + return nil +} + +// CancelRequest cancels a DSR (by user) +func (s *DSRService) CancelRequest(ctx context.Context, id uuid.UUID, cancelledBy uuid.UUID) error { + // Verify ownership + var userID *uuid.UUID + err := s.pool.QueryRow(ctx, "SELECT user_id FROM data_subject_requests WHERE id = $1", id).Scan(&userID) + if err != nil { + return fmt.Errorf("DSR not found: %w", err) + } + if userID == nil || *userID != cancelledBy { + return fmt.Errorf("unauthorized: can only cancel own requests") + } + + // Get current status + var currentStatus models.DSRStatus + s.pool.QueryRow(ctx, "SELECT status FROM data_subject_requests WHERE id = $1", id).Scan(¤tStatus) + + _, err = s.pool.Exec(ctx, ` + UPDATE data_subject_requests SET status = 'cancelled', updated_at = NOW() WHERE id = $1 + `, id) + if err != nil { + return fmt.Errorf("failed to cancel DSR: %w", err) + } + + s.recordStatusChange(ctx, id, ¤tStatus, models.DSRStatusCancelled, &cancelledBy, "Vom Antragsteller storniert") + + return nil +} + +// GetDashboardStats returns statistics for the admin dashboard +func (s *DSRService) GetDashboardStats(ctx context.Context) (*models.DSRDashboardStats, error) { + stats := &models.DSRDashboardStats{ + ByType: make(map[string]int), + ByStatus: make(map[string]int), + } + + // Total requests + s.pool.QueryRow(ctx, "SELECT COUNT(*) FROM data_subject_requests").Scan(&stats.TotalRequests) + + // Pending requests (not completed, rejected, or cancelled) + s.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM data_subject_requests + WHERE status NOT IN ('completed', 'rejected', 'cancelled') + `).Scan(&stats.PendingRequests) + + // Overdue requests + s.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM data_subject_requests + WHERE COALESCE(extended_deadline_at, deadline_at) < NOW() + AND status NOT IN ('completed', 'rejected', 'cancelled') + `).Scan(&stats.OverdueRequests) + + // Completed this month + s.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM data_subject_requests + WHERE status = 'completed' + AND completed_at >= DATE_TRUNC('month', NOW()) + `).Scan(&stats.CompletedThisMonth) + + // Average processing days + s.pool.QueryRow(ctx, ` + SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (completed_at - created_at)) / 86400), 0) + FROM data_subject_requests WHERE status = 'completed' + `).Scan(&stats.AverageProcessingDays) + + // Count by type + rows, _ := s.pool.Query(ctx, ` + SELECT request_type, COUNT(*) FROM data_subject_requests GROUP BY request_type + `) + for rows.Next() { + var t string + var count int + rows.Scan(&t, &count) + stats.ByType[t] = count + } + rows.Close() + + // Count by status + rows, _ = s.pool.Query(ctx, ` + SELECT status, COUNT(*) FROM data_subject_requests GROUP BY status + `) + for rows.Next() { + var s string + var count int + rows.Scan(&s, &count) + stats.ByStatus[s] = count + } + rows.Close() + + // Upcoming deadlines (next 7 days) + rows, _ = s.pool.Query(ctx, ` + SELECT id, request_number, request_type, status, requester_email, deadline_at + FROM data_subject_requests + WHERE COALESCE(extended_deadline_at, deadline_at) BETWEEN NOW() AND NOW() + INTERVAL '7 days' + AND status NOT IN ('completed', 'rejected', 'cancelled') + ORDER BY deadline_at ASC LIMIT 10 + `) + for rows.Next() { + var dsr models.DataSubjectRequest + rows.Scan(&dsr.ID, &dsr.RequestNumber, &dsr.RequestType, &dsr.Status, &dsr.RequesterEmail, &dsr.DeadlineAt) + stats.UpcomingDeadlines = append(stats.UpcomingDeadlines, dsr) + } + rows.Close() + + return stats, nil +} + +// GetStatusHistory retrieves the status history for a DSR +func (s *DSRService) GetStatusHistory(ctx context.Context, requestID uuid.UUID) ([]models.DSRStatusHistory, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, request_id, from_status, to_status, changed_by, comment, metadata, created_at + FROM dsr_status_history WHERE request_id = $1 ORDER BY created_at DESC + `, requestID) + if err != nil { + return nil, fmt.Errorf("failed to query status history: %w", err) + } + defer rows.Close() + + var history []models.DSRStatusHistory + for rows.Next() { + var h models.DSRStatusHistory + var metadataJSON []byte + err := rows.Scan(&h.ID, &h.RequestID, &h.FromStatus, &h.ToStatus, &h.ChangedBy, &h.Comment, &metadataJSON, &h.CreatedAt) + if err != nil { + continue + } + json.Unmarshal(metadataJSON, &h.Metadata) + history = append(history, h) + } + + return history, nil +} + +// GetCommunications retrieves communications for a DSR +func (s *DSRService) GetCommunications(ctx context.Context, requestID uuid.UUID) ([]models.DSRCommunication, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, request_id, direction, channel, communication_type, template_version_id, + subject, body_html, body_text, recipient_email, sent_at, error_message, + attachments, created_at, created_by + FROM dsr_communications WHERE request_id = $1 ORDER BY created_at DESC + `, requestID) + if err != nil { + return nil, fmt.Errorf("failed to query communications: %w", err) + } + defer rows.Close() + + var comms []models.DSRCommunication + for rows.Next() { + var c models.DSRCommunication + var attachmentsJSON []byte + err := rows.Scan(&c.ID, &c.RequestID, &c.Direction, &c.Channel, &c.CommunicationType, + &c.TemplateVersionID, &c.Subject, &c.BodyHTML, &c.BodyText, &c.RecipientEmail, + &c.SentAt, &c.ErrorMessage, &attachmentsJSON, &c.CreatedAt, &c.CreatedBy) + if err != nil { + continue + } + json.Unmarshal(attachmentsJSON, &c.Attachments) + comms = append(comms, c) + } + + return comms, nil +} + +// SendCommunication sends a communication for a DSR +func (s *DSRService) SendCommunication(ctx context.Context, requestID uuid.UUID, req models.SendDSRCommunicationRequest, sentBy uuid.UUID) error { + // Get DSR details + dsr, err := s.GetByID(ctx, requestID) + if err != nil { + return err + } + + // Get template if specified + var subject, bodyHTML, bodyText string + if req.TemplateVersionID != nil { + templateVersionID, _ := uuid.Parse(*req.TemplateVersionID) + err := s.pool.QueryRow(ctx, ` + SELECT subject, body_html, body_text FROM dsr_template_versions WHERE id = $1 AND status = 'published' + `, templateVersionID).Scan(&subject, &bodyHTML, &bodyText) + if err != nil { + return fmt.Errorf("template version not found or not published: %w", err) + } + } + + // Use custom content if provided + if req.CustomSubject != nil { + subject = *req.CustomSubject + } + if req.CustomBody != nil { + bodyHTML = *req.CustomBody + bodyText = stripHTML(*req.CustomBody) + } + + // Replace variables + variables := map[string]string{ + "requester_name": stringOrDefault(dsr.RequesterName, "Antragsteller/in"), + "request_number": dsr.RequestNumber, + "request_type_de": dsr.RequestType.Label(), + "request_date": dsr.CreatedAt.Format("02.01.2006"), + "deadline_date": dsr.DeadlineAt.Format("02.01.2006"), + } + for k, v := range req.Variables { + variables[k] = v + } + subject = replaceVariables(subject, variables) + bodyHTML = replaceVariables(bodyHTML, variables) + bodyText = replaceVariables(bodyText, variables) + + // Send email + if s.emailService != nil { + err = s.emailService.SendEmail(dsr.RequesterEmail, subject, bodyHTML, bodyText) + if err != nil { + // Log error but continue + _, _ = s.pool.Exec(ctx, ` + INSERT INTO dsr_communications (request_id, direction, channel, communication_type, + template_version_id, subject, body_html, body_text, recipient_email, error_message, created_by) + VALUES ($1, 'outbound', 'email', $2, $3, $4, $5, $6, $7, $8, $9) + `, requestID, req.CommunicationType, req.TemplateVersionID, subject, bodyHTML, bodyText, + dsr.RequesterEmail, err.Error(), sentBy) + return fmt.Errorf("failed to send email: %w", err) + } + } + + // Log communication + now := time.Now() + _, err = s.pool.Exec(ctx, ` + INSERT INTO dsr_communications (request_id, direction, channel, communication_type, + template_version_id, subject, body_html, body_text, recipient_email, sent_at, created_by) + VALUES ($1, 'outbound', 'email', $2, $3, $4, $5, $6, $7, $8, $9) + `, requestID, req.CommunicationType, req.TemplateVersionID, subject, bodyHTML, bodyText, + dsr.RequesterEmail, now, sentBy) + + return err +} + +// InitErasureExceptionChecks initializes exception checks for an erasure request +func (s *DSRService) InitErasureExceptionChecks(ctx context.Context, requestID uuid.UUID) error { + exceptions := []struct { + Type string + Description string + }{ + {models.DSRExceptionFreedomExpression, "Ausübung des Rechts auf freie Meinungsäußerung und Information (Art. 17 Abs. 3 lit. a)"}, + {models.DSRExceptionLegalObligation, "Erfüllung einer rechtlichen Verpflichtung oder öffentlichen Aufgabe (Art. 17 Abs. 3 lit. b)"}, + {models.DSRExceptionPublicHealth, "Gründe des öffentlichen Interesses im Bereich der öffentlichen Gesundheit (Art. 17 Abs. 3 lit. c)"}, + {models.DSRExceptionArchiving, "Im öffentlichen Interesse liegende Archivzwecke, Forschung oder Statistik (Art. 17 Abs. 3 lit. d)"}, + {models.DSRExceptionLegalClaims, "Geltendmachung, Ausübung oder Verteidigung von Rechtsansprüchen (Art. 17 Abs. 3 lit. e)"}, + } + + for _, exc := range exceptions { + _, err := s.pool.Exec(ctx, ` + INSERT INTO dsr_exception_checks (request_id, exception_type, description) + VALUES ($1, $2, $3) ON CONFLICT DO NOTHING + `, requestID, exc.Type, exc.Description) + if err != nil { + return fmt.Errorf("failed to create exception check: %w", err) + } + } + + return nil +} + +// GetExceptionChecks retrieves exception checks for a DSR +func (s *DSRService) GetExceptionChecks(ctx context.Context, requestID uuid.UUID) ([]models.DSRExceptionCheck, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id, request_id, exception_type, description, applies, checked_by, checked_at, notes, created_at + FROM dsr_exception_checks WHERE request_id = $1 ORDER BY created_at + `, requestID) + if err != nil { + return nil, fmt.Errorf("failed to query exception checks: %w", err) + } + defer rows.Close() + + var checks []models.DSRExceptionCheck + for rows.Next() { + var c models.DSRExceptionCheck + err := rows.Scan(&c.ID, &c.RequestID, &c.ExceptionType, &c.Description, &c.Applies, + &c.CheckedBy, &c.CheckedAt, &c.Notes, &c.CreatedAt) + if err != nil { + continue + } + checks = append(checks, c) + } + + return checks, nil +} + +// UpdateExceptionCheck updates an exception check +func (s *DSRService) UpdateExceptionCheck(ctx context.Context, checkID uuid.UUID, applies bool, notes *string, checkedBy uuid.UUID) error { + _, err := s.pool.Exec(ctx, ` + UPDATE dsr_exception_checks + SET applies = $1, notes = $2, checked_by = $3, checked_at = NOW() + WHERE id = $4 + `, applies, notes, checkedBy, checkID) + return err +} + +// ProcessDeadlines checks for approaching and overdue deadlines +func (s *DSRService) ProcessDeadlines(ctx context.Context) error { + now := time.Now() + + // Find requests with deadlines in 3 days + threeDaysAhead := now.AddDate(0, 0, 3) + rows, _ := s.pool.Query(ctx, ` + SELECT id, request_number, request_type, assigned_to, deadline_at + FROM data_subject_requests + WHERE COALESCE(extended_deadline_at, deadline_at) BETWEEN $1 AND $2 + AND status NOT IN ('completed', 'rejected', 'cancelled') + `, now, threeDaysAhead) + for rows.Next() { + var id uuid.UUID + var requestNumber, requestType string + var assignedTo *uuid.UUID + var deadline time.Time + rows.Scan(&id, &requestNumber, &requestType, &assignedTo, &deadline) + + // Notify assigned user or all DPOs + if assignedTo != nil { + s.notifyDeadlineWarning(ctx, id, *assignedTo, requestNumber, deadline, 3) + } else { + s.notifyAllDPOs(ctx, id, requestNumber, "Frist in 3 Tagen", deadline) + } + } + rows.Close() + + // Find requests with deadlines in 1 day + oneDayAhead := now.AddDate(0, 0, 1) + rows, _ = s.pool.Query(ctx, ` + SELECT id, request_number, request_type, assigned_to, deadline_at + FROM data_subject_requests + WHERE COALESCE(extended_deadline_at, deadline_at) BETWEEN $1 AND $2 + AND status NOT IN ('completed', 'rejected', 'cancelled') + `, now, oneDayAhead) + for rows.Next() { + var id uuid.UUID + var requestNumber, requestType string + var assignedTo *uuid.UUID + var deadline time.Time + rows.Scan(&id, &requestNumber, &requestType, &assignedTo, &deadline) + + if assignedTo != nil { + s.notifyDeadlineWarning(ctx, id, *assignedTo, requestNumber, deadline, 1) + } else { + s.notifyAllDPOs(ctx, id, requestNumber, "Frist morgen!", deadline) + } + } + rows.Close() + + // Find overdue requests + rows, _ = s.pool.Query(ctx, ` + SELECT id, request_number, request_type, assigned_to, deadline_at + FROM data_subject_requests + WHERE COALESCE(extended_deadline_at, deadline_at) < $1 + AND status NOT IN ('completed', 'rejected', 'cancelled') + `, now) + for rows.Next() { + var id uuid.UUID + var requestNumber, requestType string + var assignedTo *uuid.UUID + var deadline time.Time + rows.Scan(&id, &requestNumber, &requestType, &assignedTo, &deadline) + + // Notify all DPOs for overdue + s.notifyAllDPOs(ctx, id, requestNumber, "ÜBERFÄLLIG!", deadline) + + // Log to audit + s.pool.Exec(ctx, ` + INSERT INTO consent_audit_log (action, entity_type, entity_id, details) + VALUES ('dsr_overdue', 'dsr', $1, $2) + `, id, fmt.Sprintf(`{"request_number": "%s", "deadline": "%s"}`, requestNumber, deadline.Format(time.RFC3339))) + } + rows.Close() + + return nil +} + +// Helper functions + +func (s *DSRService) recordStatusChange(ctx context.Context, requestID uuid.UUID, fromStatus *models.DSRStatus, toStatus models.DSRStatus, changedBy *uuid.UUID, comment string) { + s.pool.Exec(ctx, ` + INSERT INTO dsr_status_history (request_id, from_status, to_status, changed_by, comment) + VALUES ($1, $2, $3, $4, $5) + `, requestID, fromStatus, toStatus, changedBy, comment) +} + +func (s *DSRService) notifyNewRequest(ctx context.Context, dsr *models.DataSubjectRequest) { + if s.notificationService == nil { + return + } + // Notify all DPOs + rows, _ := s.pool.Query(ctx, "SELECT id FROM users WHERE role = 'data_protection_officer'") + defer rows.Close() + for rows.Next() { + var userID uuid.UUID + rows.Scan(&userID) + s.notificationService.CreateNotification(ctx, userID, NotificationTypeDSRReceived, + "Neue Betroffenenanfrage", + fmt.Sprintf("Neue %s eingegangen: %s", dsr.RequestType.Label(), dsr.RequestNumber), + map[string]interface{}{"dsr_id": dsr.ID, "request_number": dsr.RequestNumber}) + } +} + +func (s *DSRService) notifyAssignment(ctx context.Context, dsrID, assigneeID uuid.UUID) { + if s.notificationService == nil { + return + } + dsr, _ := s.GetByID(ctx, dsrID) + if dsr != nil { + s.notificationService.CreateNotification(ctx, assigneeID, NotificationTypeDSRAssigned, + "Betroffenenanfrage zugewiesen", + fmt.Sprintf("Ihnen wurde die Anfrage %s zugewiesen", dsr.RequestNumber), + map[string]interface{}{"dsr_id": dsrID, "request_number": dsr.RequestNumber}) + } +} + +func (s *DSRService) notifyDeadlineWarning(ctx context.Context, dsrID, userID uuid.UUID, requestNumber string, deadline time.Time, daysLeft int) { + if s.notificationService == nil { + return + } + s.notificationService.CreateNotification(ctx, userID, NotificationTypeDSRDeadline, + fmt.Sprintf("Fristwarnung: %s", requestNumber), + fmt.Sprintf("Die Frist für %s läuft in %d Tag(en) ab (%s)", requestNumber, daysLeft, deadline.Format("02.01.2006")), + map[string]interface{}{"dsr_id": dsrID, "deadline": deadline, "days_left": daysLeft}) +} + +func (s *DSRService) notifyAllDPOs(ctx context.Context, dsrID uuid.UUID, requestNumber, message string, deadline time.Time) { + if s.notificationService == nil { + return + } + rows, _ := s.pool.Query(ctx, "SELECT id FROM users WHERE role = 'data_protection_officer'") + defer rows.Close() + for rows.Next() { + var userID uuid.UUID + rows.Scan(&userID) + s.notificationService.CreateNotification(ctx, userID, NotificationTypeDSRDeadline, + fmt.Sprintf("%s: %s", message, requestNumber), + fmt.Sprintf("Anfrage %s: %s (Frist: %s)", requestNumber, message, deadline.Format("02.01.2006")), + map[string]interface{}{"dsr_id": dsrID, "deadline": deadline}) + } +} + +func isValidRequestType(rt models.DSRRequestType) bool { + switch rt { + case models.DSRTypeAccess, models.DSRTypeRectification, models.DSRTypeErasure, + models.DSRTypeRestriction, models.DSRTypePortability: + return true + } + return false +} + +func isValidStatusTransition(from, to models.DSRStatus) bool { + validTransitions := map[models.DSRStatus][]models.DSRStatus{ + models.DSRStatusIntake: {models.DSRStatusIdentityVerification, models.DSRStatusProcessing, models.DSRStatusRejected, models.DSRStatusCancelled}, + models.DSRStatusIdentityVerification: {models.DSRStatusProcessing, models.DSRStatusRejected, models.DSRStatusCancelled}, + models.DSRStatusProcessing: {models.DSRStatusCompleted, models.DSRStatusRejected, models.DSRStatusCancelled}, + models.DSRStatusCompleted: {}, + models.DSRStatusRejected: {}, + models.DSRStatusCancelled: {}, + } + + allowed, exists := validTransitions[from] + if !exists { + return false + } + for _, s := range allowed { + if s == to { + return true + } + } + return false +} + +func stringOrDefault(s *string, def string) string { + if s != nil { + return *s + } + return def +} + +func replaceVariables(text string, variables map[string]string) string { + for k, v := range variables { + text = strings.ReplaceAll(text, "{{"+k+"}}", v) + } + return text +} + +func stripHTML(html string) string { + // Simple HTML stripping - in production use a proper library + text := strings.ReplaceAll(html, "
              ", "\n") + text = strings.ReplaceAll(text, "
              ", "\n") + text = strings.ReplaceAll(text, "
              ", "\n") + text = strings.ReplaceAll(text, "

              ", "\n\n") + // Remove all remaining tags + for { + start := strings.Index(text, "<") + if start == -1 { + break + } + end := strings.Index(text[start:], ">") + if end == -1 { + break + } + text = text[:start] + text[start+end+1:] + } + return strings.TrimSpace(text) +} diff --git a/consent-service/internal/services/dsr_service_test.go b/consent-service/internal/services/dsr_service_test.go new file mode 100644 index 0000000..825b49d --- /dev/null +++ b/consent-service/internal/services/dsr_service_test.go @@ -0,0 +1,420 @@ +package services + +import ( + "testing" + "time" + + "github.com/breakpilot/consent-service/internal/models" +) + +// TestDSRRequestTypeLabel tests label generation for request types +func TestDSRRequestTypeLabel(t *testing.T) { + tests := []struct { + name string + reqType models.DSRRequestType + expected string + }{ + {"access type", models.DSRTypeAccess, "Auskunftsanfrage (Art. 15)"}, + {"rectification type", models.DSRTypeRectification, "Berichtigungsanfrage (Art. 16)"}, + {"erasure type", models.DSRTypeErasure, "Löschanfrage (Art. 17)"}, + {"restriction type", models.DSRTypeRestriction, "Einschränkungsanfrage (Art. 18)"}, + {"portability type", models.DSRTypePortability, "Datenübertragung (Art. 20)"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.reqType.Label() + if result != tt.expected { + t.Errorf("Expected %s, got %s", tt.expected, result) + } + }) + } +} + +// TestDSRRequestTypeDeadlineDays tests deadline calculation for different request types +func TestDSRRequestTypeDeadlineDays(t *testing.T) { + tests := []struct { + name string + reqType models.DSRRequestType + expectedDays int + }{ + {"access has 30 days", models.DSRTypeAccess, 30}, + {"portability has 30 days", models.DSRTypePortability, 30}, + {"rectification has 14 days", models.DSRTypeRectification, 14}, + {"erasure has 14 days", models.DSRTypeErasure, 14}, + {"restriction has 14 days", models.DSRTypeRestriction, 14}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.reqType.DeadlineDays() + if result != tt.expectedDays { + t.Errorf("Expected %d days, got %d", tt.expectedDays, result) + } + }) + } +} + +// TestDSRRequestTypeIsExpedited tests expedited flag for request types +func TestDSRRequestTypeIsExpedited(t *testing.T) { + tests := []struct { + name string + reqType models.DSRRequestType + isExpedited bool + }{ + {"access not expedited", models.DSRTypeAccess, false}, + {"portability not expedited", models.DSRTypePortability, false}, + {"rectification is expedited", models.DSRTypeRectification, true}, + {"erasure is expedited", models.DSRTypeErasure, true}, + {"restriction is expedited", models.DSRTypeRestriction, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.reqType.IsExpedited() + if result != tt.isExpedited { + t.Errorf("Expected IsExpedited=%v, got %v", tt.isExpedited, result) + } + }) + } +} + +// TestDSRStatusLabel tests label generation for statuses +func TestDSRStatusLabel(t *testing.T) { + tests := []struct { + name string + status models.DSRStatus + expected string + }{ + {"intake status", models.DSRStatusIntake, "Eingang"}, + {"identity verification", models.DSRStatusIdentityVerification, "Identitätsprüfung"}, + {"processing status", models.DSRStatusProcessing, "In Bearbeitung"}, + {"completed status", models.DSRStatusCompleted, "Abgeschlossen"}, + {"rejected status", models.DSRStatusRejected, "Abgelehnt"}, + {"cancelled status", models.DSRStatusCancelled, "Storniert"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.status.Label() + if result != tt.expected { + t.Errorf("Expected %s, got %s", tt.expected, result) + } + }) + } +} + +// TestValidDSRRequestType tests request type validation +func TestValidDSRRequestType(t *testing.T) { + tests := []struct { + name string + reqType string + valid bool + }{ + {"valid access", "access", true}, + {"valid rectification", "rectification", true}, + {"valid erasure", "erasure", true}, + {"valid restriction", "restriction", true}, + {"valid portability", "portability", true}, + {"invalid type", "invalid", false}, + {"empty type", "", false}, + {"random string", "delete_everything", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := models.IsValidDSRRequestType(tt.reqType) + if result != tt.valid { + t.Errorf("Expected IsValidDSRRequestType=%v for %s, got %v", tt.valid, tt.reqType, result) + } + }) + } +} + +// TestValidDSRStatus tests status validation +func TestValidDSRStatus(t *testing.T) { + tests := []struct { + name string + status string + valid bool + }{ + {"valid intake", "intake", true}, + {"valid identity_verification", "identity_verification", true}, + {"valid processing", "processing", true}, + {"valid completed", "completed", true}, + {"valid rejected", "rejected", true}, + {"valid cancelled", "cancelled", true}, + {"invalid status", "invalid", false}, + {"empty status", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := models.IsValidDSRStatus(tt.status) + if result != tt.valid { + t.Errorf("Expected IsValidDSRStatus=%v for %s, got %v", tt.valid, tt.status, result) + } + }) + } +} + +// TestDSRStatusTransitionValidation tests allowed status transitions +func TestDSRStatusTransitionValidation(t *testing.T) { + tests := []struct { + name string + fromStatus models.DSRStatus + toStatus models.DSRStatus + allowed bool + }{ + // From intake + {"intake to identity_verification", models.DSRStatusIntake, models.DSRStatusIdentityVerification, true}, + {"intake to processing", models.DSRStatusIntake, models.DSRStatusProcessing, true}, + {"intake to rejected", models.DSRStatusIntake, models.DSRStatusRejected, true}, + {"intake to cancelled", models.DSRStatusIntake, models.DSRStatusCancelled, true}, + {"intake to completed invalid", models.DSRStatusIntake, models.DSRStatusCompleted, false}, + + // From identity_verification + {"identity to processing", models.DSRStatusIdentityVerification, models.DSRStatusProcessing, true}, + {"identity to rejected", models.DSRStatusIdentityVerification, models.DSRStatusRejected, true}, + {"identity to cancelled", models.DSRStatusIdentityVerification, models.DSRStatusCancelled, true}, + + // From processing + {"processing to completed", models.DSRStatusProcessing, models.DSRStatusCompleted, true}, + {"processing to rejected", models.DSRStatusProcessing, models.DSRStatusRejected, true}, + {"processing to intake invalid", models.DSRStatusProcessing, models.DSRStatusIntake, false}, + + // From completed + {"completed to anything invalid", models.DSRStatusCompleted, models.DSRStatusProcessing, false}, + + // From rejected + {"rejected to anything invalid", models.DSRStatusRejected, models.DSRStatusProcessing, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := testIsValidStatusTransition(tt.fromStatus, tt.toStatus) + if result != tt.allowed { + t.Errorf("Expected transition %s->%s allowed=%v, got %v", + tt.fromStatus, tt.toStatus, tt.allowed, result) + } + }) + } +} + +// testIsValidStatusTransition is a test helper for validating status transitions +// This mirrors the logic in dsr_service.go for testing purposes +func testIsValidStatusTransition(from, to models.DSRStatus) bool { + validTransitions := map[models.DSRStatus][]models.DSRStatus{ + models.DSRStatusIntake: { + models.DSRStatusIdentityVerification, + models.DSRStatusProcessing, + models.DSRStatusRejected, + models.DSRStatusCancelled, + }, + models.DSRStatusIdentityVerification: { + models.DSRStatusProcessing, + models.DSRStatusRejected, + models.DSRStatusCancelled, + }, + models.DSRStatusProcessing: { + models.DSRStatusCompleted, + models.DSRStatusRejected, + models.DSRStatusCancelled, + }, + models.DSRStatusCompleted: {}, + models.DSRStatusRejected: {}, + models.DSRStatusCancelled: {}, + } + + allowed, exists := validTransitions[from] + if !exists { + return false + } + + for _, s := range allowed { + if s == to { + return true + } + } + return false +} + +// TestCalculateDeadline tests deadline calculation +func TestCalculateDeadline(t *testing.T) { + tests := []struct { + name string + reqType models.DSRRequestType + expectedDays int + }{ + {"access 30 days", models.DSRTypeAccess, 30}, + {"erasure 14 days", models.DSRTypeErasure, 14}, + {"rectification 14 days", models.DSRTypeRectification, 14}, + {"restriction 14 days", models.DSRTypeRestriction, 14}, + {"portability 30 days", models.DSRTypePortability, 30}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := time.Now() + deadline := now.AddDate(0, 0, tt.expectedDays) + days := tt.reqType.DeadlineDays() + + if days != tt.expectedDays { + t.Errorf("Expected %d days, got %d", tt.expectedDays, days) + } + + // Verify deadline is approximately correct (within 1 day due to test timing) + calculatedDeadline := now.AddDate(0, 0, days) + diff := calculatedDeadline.Sub(deadline) + if diff > time.Hour*24 || diff < -time.Hour*24 { + t.Errorf("Deadline calculation off by more than a day") + } + }) + } +} + +// TestCreateDSRRequest_Validation tests validation of create request +func TestCreateDSRRequest_Validation(t *testing.T) { + tests := []struct { + name string + request models.CreateDSRRequest + expectError bool + }{ + { + name: "valid access request", + request: models.CreateDSRRequest{ + RequestType: "access", + RequesterEmail: "test@example.com", + }, + expectError: false, + }, + { + name: "valid erasure request with name", + request: models.CreateDSRRequest{ + RequestType: "erasure", + RequesterEmail: "test@example.com", + RequesterName: stringPtr("Max Mustermann"), + }, + expectError: false, + }, + { + name: "missing email", + request: models.CreateDSRRequest{ + RequestType: "access", + }, + expectError: true, + }, + { + name: "invalid request type", + request: models.CreateDSRRequest{ + RequestType: "invalid_type", + RequesterEmail: "test@example.com", + }, + expectError: true, + }, + { + name: "empty request type", + request: models.CreateDSRRequest{ + RequestType: "", + RequesterEmail: "test@example.com", + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := testValidateCreateDSRRequest(tt.request) + hasError := err != nil + + if hasError != tt.expectError { + t.Errorf("Expected error=%v, got error=%v (err: %v)", tt.expectError, hasError, err) + } + }) + } +} + +// testValidateCreateDSRRequest is a test helper for validating create DSR requests +func testValidateCreateDSRRequest(req models.CreateDSRRequest) error { + if req.RequesterEmail == "" { + return &dsrValidationError{"requester_email is required"} + } + if !models.IsValidDSRRequestType(req.RequestType) { + return &dsrValidationError{"invalid request_type"} + } + return nil +} + +type dsrValidationError struct { + Message string +} + +func (e *dsrValidationError) Error() string { + return e.Message +} + +// TestDSRTemplateTypes tests the template types +func TestDSRTemplateTypes(t *testing.T) { + expectedTemplates := []string{ + "dsr_receipt_access", + "dsr_receipt_rectification", + "dsr_receipt_erasure", + "dsr_receipt_restriction", + "dsr_receipt_portability", + "dsr_identity_request", + "dsr_processing_started", + "dsr_processing_update", + "dsr_clarification_request", + "dsr_completed_access", + "dsr_completed_rectification", + "dsr_completed_erasure", + "dsr_completed_restriction", + "dsr_completed_portability", + "dsr_restriction_lifted", + "dsr_rejected_identity", + "dsr_rejected_exception", + "dsr_rejected_unfounded", + "dsr_deadline_warning", + } + + // This test documents the expected template types + // The actual templates are created in database migration + for _, template := range expectedTemplates { + if template == "" { + t.Error("Template type should not be empty") + } + } + + if len(expectedTemplates) != 19 { + t.Errorf("Expected 19 template types, got %d", len(expectedTemplates)) + } +} + +// TestErasureExceptionTypes tests Art. 17(3) exception types +func TestErasureExceptionTypes(t *testing.T) { + exceptions := []struct { + code string + description string + }{ + {"art_17_3_a", "Meinungs- und Informationsfreiheit"}, + {"art_17_3_b", "Rechtliche Verpflichtung"}, + {"art_17_3_c", "Öffentliches Interesse im Gesundheitsbereich"}, + {"art_17_3_d", "Archivzwecke, wissenschaftliche/historische Forschung"}, + {"art_17_3_e", "Geltendmachung, Ausübung oder Verteidigung von Rechtsansprüchen"}, + } + + if len(exceptions) != 5 { + t.Errorf("Expected 5 Art. 17(3) exceptions, got %d", len(exceptions)) + } + + for _, ex := range exceptions { + if ex.code == "" || ex.description == "" { + t.Error("Exception code and description should not be empty") + } + } +} + +// stringPtr returns a pointer to the given string +func stringPtr(s string) *string { + return &s +} diff --git a/consent-service/internal/services/email_service.go b/consent-service/internal/services/email_service.go new file mode 100644 index 0000000..1d36cd2 --- /dev/null +++ b/consent-service/internal/services/email_service.go @@ -0,0 +1,554 @@ +package services + +import ( + "bytes" + "fmt" + "html/template" + "net/smtp" + "strings" +) + +// EmailConfig holds SMTP configuration +type EmailConfig struct { + Host string + Port int + Username string + Password string + FromName string + FromAddr string + BaseURL string // Frontend URL for links +} + +// EmailService handles sending emails +type EmailService struct { + config EmailConfig +} + +// NewEmailService creates a new EmailService +func NewEmailService(config EmailConfig) *EmailService { + return &EmailService{config: config} +} + +// SendEmail sends an email +func (s *EmailService) SendEmail(to, subject, htmlBody, textBody string) error { + // Build MIME message + var msg bytes.Buffer + + msg.WriteString(fmt.Sprintf("From: %s <%s>\r\n", s.config.FromName, s.config.FromAddr)) + msg.WriteString(fmt.Sprintf("To: %s\r\n", to)) + msg.WriteString(fmt.Sprintf("Subject: %s\r\n", subject)) + msg.WriteString("MIME-Version: 1.0\r\n") + msg.WriteString("Content-Type: multipart/alternative; boundary=\"boundary42\"\r\n") + msg.WriteString("\r\n") + + // Text part + msg.WriteString("--boundary42\r\n") + msg.WriteString("Content-Type: text/plain; charset=\"UTF-8\"\r\n") + msg.WriteString("\r\n") + msg.WriteString(textBody) + msg.WriteString("\r\n") + + // HTML part + msg.WriteString("--boundary42\r\n") + msg.WriteString("Content-Type: text/html; charset=\"UTF-8\"\r\n") + msg.WriteString("\r\n") + msg.WriteString(htmlBody) + msg.WriteString("\r\n") + msg.WriteString("--boundary42--\r\n") + + // Send email + addr := fmt.Sprintf("%s:%d", s.config.Host, s.config.Port) + auth := smtp.PlainAuth("", s.config.Username, s.config.Password, s.config.Host) + + err := smtp.SendMail(addr, auth, s.config.FromAddr, []string{to}, msg.Bytes()) + if err != nil { + return fmt.Errorf("failed to send email: %w", err) + } + + return nil +} + +// SendVerificationEmail sends an email verification email +func (s *EmailService) SendVerificationEmail(to, name, token string) error { + verifyLink := fmt.Sprintf("%s/verify-email?token=%s", s.config.BaseURL, token) + + subject := "Bitte bestätigen Sie Ihre E-Mail-Adresse - BreakPilot" + + textBody := fmt.Sprintf(`Hallo %s, + +Willkommen bei BreakPilot! + +Bitte bestätigen Sie Ihre E-Mail-Adresse, indem Sie den folgenden Link öffnen: +%s + +Dieser Link ist 24 Stunden gültig. + +Falls Sie sich nicht bei BreakPilot registriert haben, können Sie diese E-Mail ignorieren. + +Mit freundlichen Grüßen, +Ihr BreakPilot Team`, getDisplayName(name), verifyLink) + + htmlBody := s.renderTemplate("verification", map[string]interface{}{ + "Name": getDisplayName(name), + "VerifyLink": verifyLink, + }) + + return s.SendEmail(to, subject, htmlBody, textBody) +} + +// SendPasswordResetEmail sends a password reset email +func (s *EmailService) SendPasswordResetEmail(to, name, token string) error { + resetLink := fmt.Sprintf("%s/reset-password?token=%s", s.config.BaseURL, token) + + subject := "Passwort zurücksetzen - BreakPilot" + + textBody := fmt.Sprintf(`Hallo %s, + +Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt. + +Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen: +%s + +Dieser Link ist 1 Stunde gültig. + +Falls Sie keine Passwort-Zurücksetzung angefordert haben, können Sie diese E-Mail ignorieren. + +Mit freundlichen Grüßen, +Ihr BreakPilot Team`, getDisplayName(name), resetLink) + + htmlBody := s.renderTemplate("password_reset", map[string]interface{}{ + "Name": getDisplayName(name), + "ResetLink": resetLink, + }) + + return s.SendEmail(to, subject, htmlBody, textBody) +} + +// SendNewVersionNotification sends a notification about new document version +func (s *EmailService) SendNewVersionNotification(to, name, documentName, documentType string, deadlineDays int) error { + consentLink := fmt.Sprintf("%s/app?consent=pending", s.config.BaseURL) + + subject := fmt.Sprintf("Neue Version: %s - Bitte bestätigen Sie innerhalb von %d Tagen", documentName, deadlineDays) + + textBody := fmt.Sprintf(`Hallo %s, + +Wir haben unsere %s aktualisiert. + +Bitte lesen und bestätigen Sie die neuen Bedingungen innerhalb der nächsten %d Tage: +%s + +Falls Sie nicht innerhalb dieser Frist bestätigen, wird Ihr Account vorübergehend gesperrt. + +Mit freundlichen Grüßen, +Ihr BreakPilot Team`, getDisplayName(name), documentName, deadlineDays, consentLink) + + htmlBody := s.renderTemplate("new_version", map[string]interface{}{ + "Name": getDisplayName(name), + "DocumentName": documentName, + "DeadlineDays": deadlineDays, + "ConsentLink": consentLink, + }) + + return s.SendEmail(to, subject, htmlBody, textBody) +} + +// SendConsentReminder sends a consent reminder email +func (s *EmailService) SendConsentReminder(to, name string, documents []string, daysLeft int) error { + consentLink := fmt.Sprintf("%s/app?consent=pending", s.config.BaseURL) + + urgency := "Erinnerung" + if daysLeft <= 7 { + urgency = "Dringend" + } + if daysLeft <= 2 { + urgency = "Letzte Warnung" + } + + subject := fmt.Sprintf("%s: Noch %d Tage um ausstehende Dokumente zu bestätigen", urgency, daysLeft) + + docList := strings.Join(documents, "\n- ") + + textBody := fmt.Sprintf(`Hallo %s, + +Dies ist eine freundliche Erinnerung, dass Sie noch ausstehende rechtliche Dokumente bestätigen müssen. + +Ausstehende Dokumente: +- %s + +Sie haben noch %d Tage Zeit. Nach Ablauf dieser Frist wird Ihr Account vorübergehend gesperrt. + +Bitte bestätigen Sie hier: +%s + +Mit freundlichen Grüßen, +Ihr BreakPilot Team`, getDisplayName(name), docList, daysLeft, consentLink) + + htmlBody := s.renderTemplate("reminder", map[string]interface{}{ + "Name": getDisplayName(name), + "Documents": documents, + "DaysLeft": daysLeft, + "Urgency": urgency, + "ConsentLink": consentLink, + }) + + return s.SendEmail(to, subject, htmlBody, textBody) +} + +// SendAccountSuspendedNotification sends notification when account is suspended +func (s *EmailService) SendAccountSuspendedNotification(to, name string, documents []string) error { + consentLink := fmt.Sprintf("%s/app?consent=pending", s.config.BaseURL) + + subject := "Ihr Account wurde vorübergehend gesperrt - BreakPilot" + + docList := strings.Join(documents, "\n- ") + + textBody := fmt.Sprintf(`Hallo %s, + +Ihr Account wurde vorübergehend gesperrt, da Sie die folgenden rechtlichen Dokumente nicht innerhalb der Frist bestätigt haben: + +- %s + +Um Ihren Account zu entsperren, bestätigen Sie bitte alle ausstehenden Dokumente: +%s + +Sobald Sie alle Dokumente bestätigt haben, wird Ihr Account automatisch entsperrt. + +Mit freundlichen Grüßen, +Ihr BreakPilot Team`, getDisplayName(name), docList, consentLink) + + htmlBody := s.renderTemplate("suspended", map[string]interface{}{ + "Name": getDisplayName(name), + "Documents": documents, + "ConsentLink": consentLink, + }) + + return s.SendEmail(to, subject, htmlBody, textBody) +} + +// SendAccountReactivatedNotification sends notification when account is reactivated +func (s *EmailService) SendAccountReactivatedNotification(to, name string) error { + appLink := fmt.Sprintf("%s/app", s.config.BaseURL) + + subject := "Ihr Account wurde wieder aktiviert - BreakPilot" + + textBody := fmt.Sprintf(`Hallo %s, + +Vielen Dank für die Bestätigung der rechtlichen Dokumente! + +Ihr Account wurde wieder aktiviert und Sie können BreakPilot wie gewohnt nutzen: +%s + +Mit freundlichen Grüßen, +Ihr BreakPilot Team`, getDisplayName(name), appLink) + + htmlBody := s.renderTemplate("reactivated", map[string]interface{}{ + "Name": getDisplayName(name), + "AppLink": appLink, + }) + + return s.SendEmail(to, subject, htmlBody, textBody) +} + +// renderTemplate renders an email HTML template +func (s *EmailService) renderTemplate(templateName string, data map[string]interface{}) string { + templates := map[string]string{ + "verification": ` + + + + + + + +
              +

              Willkommen bei BreakPilot!

              +
              +
              +

              Hallo {{.Name}},

              +

              Vielen Dank für Ihre Registrierung! Bitte bestätigen Sie Ihre E-Mail-Adresse, um Ihr Konto zu aktivieren.

              +

              + E-Mail bestätigen +

              +

              Dieser Link ist 24 Stunden gültig.

              +

              Falls Sie sich nicht bei BreakPilot registriert haben, können Sie diese E-Mail ignorieren.

              +
              + + +`, + + "password_reset": ` + + + + + + + +
              +

              Passwort zurücksetzen

              +
              +
              +

              Hallo {{.Name}},

              +

              Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.

              +

              + Passwort zurücksetzen +

              +
              + Hinweis: Dieser Link ist nur 1 Stunde gültig. +
              +

              Falls Sie keine Passwort-Zurücksetzung angefordert haben, können Sie diese E-Mail ignorieren.

              +
              + + +`, + + "new_version": ` + + + + + + + +
              +

              Neue Version: {{.DocumentName}}

              +
              +
              +

              Hallo {{.Name}},

              +

              Wir haben unsere {{.DocumentName}} aktualisiert.

              +
              + Wichtig: Bitte bestätigen Sie die neuen Bedingungen innerhalb der nächsten {{.DeadlineDays}} Tage. +
              +

              + Dokument ansehen & bestätigen +

              +

              Falls Sie nicht innerhalb dieser Frist bestätigen, wird Ihr Account vorübergehend gesperrt.

              +
              + + +`, + + "reminder": ` + + + + + + + +
              +

              {{.Urgency}}: Ausstehende Bestätigungen

              +
              +
              +

              Hallo {{.Name}},

              +

              Dies ist eine freundliche Erinnerung, dass Sie noch ausstehende rechtliche Dokumente bestätigen müssen.

              +
              + Ausstehende Dokumente: +
                + {{range .Documents}}
              • {{.}}
              • {{end}} +
              +
              +
              + Sie haben noch {{.DaysLeft}} Tage Zeit. Nach Ablauf dieser Frist wird Ihr Account vorübergehend gesperrt. +
              +

              + Jetzt bestätigen +

              +
              + + +`, + + "suspended": ` + + + + + + + +
              +

              Account vorübergehend gesperrt

              +
              +
              +

              Hallo {{.Name}},

              +
              + Ihr Account wurde vorübergehend gesperrt, da Sie die folgenden rechtlichen Dokumente nicht innerhalb der Frist bestätigt haben. +
              +
              + Nicht bestätigte Dokumente: +
                + {{range .Documents}}
              • {{.}}
              • {{end}} +
              +
              +

              Um Ihren Account zu entsperren, bestätigen Sie bitte alle ausstehenden Dokumente:

              +

              + Dokumente bestätigen & Account entsperren +

              +

              Sobald Sie alle Dokumente bestätigt haben, wird Ihr Account automatisch entsperrt.

              +
              + + +`, + + "reactivated": ` + + + + + + + +
              +

              Account wieder aktiviert!

              +
              +
              +

              Hallo {{.Name}},

              +
              + Vielen Dank! Ihr Account wurde erfolgreich wieder aktiviert. +
              +

              Sie können BreakPilot ab sofort wieder wie gewohnt nutzen.

              +

              + Zu BreakPilot +

              +
              + + +`, + + "generic_notification": ` + + + + + + + +
              +

              {{.Title}}

              +
              +
              +

              {{.Body}}

              +

              + Zu BreakPilot +

              +
              + + +`, + } + + tmplStr, ok := templates[templateName] + if !ok { + return "" + } + + tmpl, err := template.New(templateName).Parse(tmplStr) + if err != nil { + return "" + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return "" + } + + return buf.String() +} + +// SendConsentReminderEmail sends a simplified consent reminder email +func (s *EmailService) SendConsentReminderEmail(to, title, body string) error { + subject := title + + htmlBody := s.renderTemplate("generic_notification", map[string]interface{}{ + "Title": title, + "Body": body, + "BaseURL": s.config.BaseURL, + }) + + return s.SendEmail(to, subject, htmlBody, body) +} + +// SendGenericNotificationEmail sends a generic notification email +func (s *EmailService) SendGenericNotificationEmail(to, title, body string) error { + subject := title + + htmlBody := s.renderTemplate("generic_notification", map[string]interface{}{ + "Title": title, + "Body": body, + "BaseURL": s.config.BaseURL, + }) + + return s.SendEmail(to, subject, htmlBody, body) +} + +// getDisplayName returns display name or fallback +func getDisplayName(name string) string { + if name != "" { + return name + } + return "Nutzer" +} diff --git a/consent-service/internal/services/email_service_test.go b/consent-service/internal/services/email_service_test.go new file mode 100644 index 0000000..698527b --- /dev/null +++ b/consent-service/internal/services/email_service_test.go @@ -0,0 +1,624 @@ +package services + +import ( + "fmt" + "net/smtp" + "regexp" + "strings" + "testing" +) + +// MockSMTPSender is a mock SMTP sender for testing +type MockSMTPSender struct { + SentEmails []SentEmail + ShouldFail bool + FailError error +} + +// SentEmail represents a sent email for testing +type SentEmail struct { + To []string + Subject string + Body string +} + +// SendMail is a mock implementation of smtp.SendMail +func (m *MockSMTPSender) SendMail(addr string, auth smtp.Auth, from string, to []string, msg []byte) error { + if m.ShouldFail { + return m.FailError + } + + // Parse the email to extract subject and body + msgStr := string(msg) + subject := extractSubject(msgStr) + + m.SentEmails = append(m.SentEmails, SentEmail{ + To: to, + Subject: subject, + Body: msgStr, + }) + + return nil +} + +// extractSubject extracts the subject from an email message +func extractSubject(msg string) string { + lines := strings.Split(msg, "\r\n") + for _, line := range lines { + if strings.HasPrefix(line, "Subject: ") { + return strings.TrimPrefix(line, "Subject: ") + } + } + return "" +} + +// TestEmailService_SendEmail tests basic email sending +func TestEmailService_SendEmail(t *testing.T) { + tests := []struct { + name string + to string + subject string + htmlBody string + textBody string + shouldFail bool + expectError bool + }{ + { + name: "valid email", + to: "user@example.com", + subject: "Test Email", + htmlBody: "

              Hello

              World

              ", + textBody: "Hello\nWorld", + shouldFail: false, + expectError: false, + }, + { + name: "email with special characters", + to: "user+test@example.com", + subject: "Test: Öäü Special Characters", + htmlBody: "

              Special: €£¥

              ", + textBody: "Special: €£¥", + shouldFail: false, + expectError: false, + }, + { + name: "SMTP failure", + to: "user@example.com", + subject: "Test", + htmlBody: "

              Test

              ", + textBody: "Test", + shouldFail: true, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Validate email format + isValidEmail := strings.Contains(tt.to, "@") && strings.Contains(tt.to, ".") + + if !isValidEmail && !tt.expectError { + t.Error("Invalid email format should produce error") + } + + // Validate subject is not empty + if tt.subject == "" && !tt.expectError { + t.Error("Empty subject should produce error") + } + + // Validate body content exists + if (tt.htmlBody == "" && tt.textBody == "") && !tt.expectError { + t.Error("Both bodies empty should produce error") + } + + // Simulate SMTP send + var err error + if tt.shouldFail { + err = fmt.Errorf("SMTP error: connection refused") + } + + if tt.expectError && err == nil { + t.Error("Expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +// TestEmailService_SendVerificationEmail tests verification email sending +func TestEmailService_SendVerificationEmail(t *testing.T) { + tests := []struct { + name string + to string + userName string + token string + expectError bool + }{ + { + name: "valid verification email", + to: "newuser@example.com", + userName: "Max Mustermann", + token: "abc123def456", + expectError: false, + }, + { + name: "user without name", + to: "user@example.com", + userName: "", + token: "token123", + expectError: false, + }, + { + name: "empty token", + to: "user@example.com", + userName: "Test User", + token: "", + expectError: true, + }, + { + name: "invalid email", + to: "invalid-email", + userName: "Test", + token: "token123", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Validate inputs + var err error + if tt.token == "" { + err = &ValidationError{Field: "token", Message: "required"} + } else if !strings.Contains(tt.to, "@") { + err = &ValidationError{Field: "email", Message: "invalid format"} + } + + // Build verification link + if tt.token != "" { + verifyLink := fmt.Sprintf("https://example.com/verify-email?token=%s", tt.token) + if verifyLink == "" { + t.Error("Verification link should not be empty") + } + + // Verify link contains token + if !strings.Contains(verifyLink, tt.token) { + t.Error("Verification link should contain token") + } + } + + if tt.expectError && err == nil { + t.Error("Expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +// TestEmailService_SendPasswordResetEmail tests password reset email +func TestEmailService_SendPasswordResetEmail(t *testing.T) { + tests := []struct { + name string + to string + userName string + token string + expectError bool + }{ + { + name: "valid password reset", + to: "user@example.com", + userName: "John Doe", + token: "reset-token-123", + expectError: false, + }, + { + name: "empty token", + to: "user@example.com", + userName: "John Doe", + token: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + if tt.token == "" { + err = &ValidationError{Field: "token", Message: "required"} + } + + // Build reset link + if tt.token != "" { + resetLink := fmt.Sprintf("https://example.com/reset-password?token=%s", tt.token) + + // Verify link is secure (HTTPS) + if !strings.HasPrefix(resetLink, "https://") { + t.Error("Reset link should use HTTPS") + } + } + + if tt.expectError && err == nil { + t.Error("Expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +// TestEmailService_Send2FAEmail tests 2FA notification emails +func TestEmailService_Send2FAEmail(t *testing.T) { + tests := []struct { + name string + to string + action string + expectError bool + }{ + { + name: "2FA enabled notification", + to: "user@example.com", + action: "enabled", + expectError: false, + }, + { + name: "2FA disabled notification", + to: "user@example.com", + action: "disabled", + expectError: false, + }, + { + name: "invalid action", + to: "user@example.com", + action: "invalid", + expectError: true, + }, + } + + validActions := map[string]bool{ + "enabled": true, + "disabled": true, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + if !validActions[tt.action] { + err = &ValidationError{Field: "action", Message: "must be 'enabled' or 'disabled'"} + } + + if tt.expectError && err == nil { + t.Error("Expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +// TestEmailService_SendConsentReminderEmail tests consent reminder +func TestEmailService_SendConsentReminderEmail(t *testing.T) { + tests := []struct { + name string + to string + documentName string + daysLeft int + expectError bool + }{ + { + name: "reminder with 7 days left", + to: "user@example.com", + documentName: "Terms of Service", + daysLeft: 7, + expectError: false, + }, + { + name: "reminder with 1 day left", + to: "user@example.com", + documentName: "Privacy Policy", + daysLeft: 1, + expectError: false, + }, + { + name: "urgent reminder - overdue", + to: "user@example.com", + documentName: "Terms", + daysLeft: 0, + expectError: false, + }, + { + name: "empty document name", + to: "user@example.com", + documentName: "", + daysLeft: 7, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + if tt.documentName == "" { + err = &ValidationError{Field: "document name", Message: "required"} + } + + // Check urgency level + var urgency string + if tt.daysLeft <= 0 { + urgency = "critical" + } else if tt.daysLeft <= 3 { + urgency = "urgent" + } else { + urgency = "normal" + } + + if urgency == "" && !tt.expectError { + t.Error("Urgency should be set") + } + + if tt.expectError && err == nil { + t.Error("Expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +// TestEmailService_MIMEFormatting tests MIME message formatting +func TestEmailService_MIMEFormatting(t *testing.T) { + tests := []struct { + name string + htmlBody string + textBody string + checkFor []string + }{ + { + name: "multipart alternative", + htmlBody: "

              Test

              ", + textBody: "Test", + checkFor: []string{ + "MIME-Version: 1.0", + "Content-Type: multipart/alternative", + "Content-Type: text/plain", + "Content-Type: text/html", + }, + }, + { + name: "UTF-8 encoding", + htmlBody: "

              Öäü

              ", + textBody: "Öäü", + checkFor: []string{ + "charset=\"UTF-8\"", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Build MIME message (simplified) + message := fmt.Sprintf("MIME-Version: 1.0\r\n"+ + "Content-Type: multipart/alternative; boundary=\"boundary\"\r\n"+ + "\r\n"+ + "--boundary\r\n"+ + "Content-Type: text/plain; charset=\"UTF-8\"\r\n"+ + "\r\n%s\r\n"+ + "--boundary\r\n"+ + "Content-Type: text/html; charset=\"UTF-8\"\r\n"+ + "\r\n%s\r\n"+ + "--boundary--\r\n", + tt.textBody, tt.htmlBody) + + // Verify required headers are present + for _, required := range tt.checkFor { + if !strings.Contains(message, required) { + t.Errorf("Message should contain '%s'", required) + } + } + + // Verify both bodies are included + if !strings.Contains(message, tt.textBody) { + t.Error("Message should contain text body") + } + if !strings.Contains(message, tt.htmlBody) { + t.Error("Message should contain HTML body") + } + }) + } +} + +// TestEmailService_TemplateRendering tests email template rendering +func TestEmailService_TemplateRendering(t *testing.T) { + tests := []struct { + name string + template string + variables map[string]string + expectVars []string + }{ + { + name: "verification template", + template: "verification", + variables: map[string]string{ + "Name": "John Doe", + "VerifyLink": "https://example.com/verify?token=abc", + }, + expectVars: []string{"John Doe", "https://example.com/verify?token=abc"}, + }, + { + name: "password reset template", + template: "password_reset", + variables: map[string]string{ + "Name": "Jane Smith", + "ResetLink": "https://example.com/reset?token=xyz", + }, + expectVars: []string{"Jane Smith", "https://example.com/reset?token=xyz"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Simulate template rendering + rendered := fmt.Sprintf("Hello %s, please visit %s", + tt.variables["Name"], + getLink(tt.variables)) + + // Verify all variables are in rendered output + for _, expectedVar := range tt.expectVars { + if !strings.Contains(rendered, expectedVar) { + t.Errorf("Rendered template should contain '%s'", expectedVar) + } + } + }) + } +} + +// TestEmailService_EmailValidation tests email address validation +func TestEmailService_EmailValidation(t *testing.T) { + tests := []struct { + email string + isValid bool + }{ + {"user@example.com", true}, + {"user+tag@example.com", true}, + {"user.name@example.co.uk", true}, + {"user@subdomain.example.com", true}, + {"invalid", false}, + {"@example.com", false}, + {"user@", false}, + {"user@.com", false}, + {"", false}, + } + + for _, tt := range tests { + t.Run(tt.email, func(t *testing.T) { + // RFC 5322 compliant email validation pattern + emailRegex := regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) + isValid := emailRegex.MatchString(tt.email) + + if isValid != tt.isValid { + t.Errorf("Email %s: expected valid=%v, got %v", tt.email, tt.isValid, isValid) + } + }) + } +} + +// TestEmailService_SMTPConfig tests SMTP configuration +func TestEmailService_SMTPConfig(t *testing.T) { + tests := []struct { + name string + config EmailConfig + expectError bool + }{ + { + name: "valid config", + config: EmailConfig{ + Host: "smtp.example.com", + Port: 587, + Username: "user@example.com", + Password: "password", + FromName: "BreakPilot", + FromAddr: "noreply@example.com", + BaseURL: "https://example.com", + }, + expectError: false, + }, + { + name: "missing host", + config: EmailConfig{ + Port: 587, + Username: "user@example.com", + Password: "password", + }, + expectError: true, + }, + { + name: "invalid port", + config: EmailConfig{ + Host: "smtp.example.com", + Port: 0, + Username: "user@example.com", + Password: "password", + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + if tt.config.Host == "" { + err = &ValidationError{Field: "host", Message: "required"} + } else if tt.config.Port <= 0 || tt.config.Port > 65535 { + err = &ValidationError{Field: "port", Message: "must be between 1 and 65535"} + } + + if tt.expectError && err == nil { + t.Error("Expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +// TestEmailService_RateLimiting tests email rate limiting logic +func TestEmailService_RateLimiting(t *testing.T) { + tests := []struct { + name string + emailsSent int + timeWindow int // minutes + limit int + expectThrottle bool + }{ + { + name: "under limit", + emailsSent: 5, + timeWindow: 60, + limit: 10, + expectThrottle: false, + }, + { + name: "at limit", + emailsSent: 10, + timeWindow: 60, + limit: 10, + expectThrottle: false, + }, + { + name: "over limit", + emailsSent: 15, + timeWindow: 60, + limit: 10, + expectThrottle: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + shouldThrottle := tt.emailsSent > tt.limit + + if shouldThrottle != tt.expectThrottle { + t.Errorf("Expected throttle=%v, got %v", tt.expectThrottle, shouldThrottle) + } + }) + } +} + +// Helper functions + +func getLink(vars map[string]string) string { + if link, ok := vars["VerifyLink"]; ok { + return link + } + if link, ok := vars["ResetLink"]; ok { + return link + } + return "" +} diff --git a/consent-service/internal/services/email_template_service.go b/consent-service/internal/services/email_template_service.go new file mode 100644 index 0000000..afc50e7 --- /dev/null +++ b/consent-service/internal/services/email_template_service.go @@ -0,0 +1,1673 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "strings" + "time" + + "github.com/breakpilot/consent-service/internal/models" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +// EmailTemplateService handles email template management +type EmailTemplateService struct { + db *pgxpool.Pool +} + +// NewEmailTemplateService creates a new email template service +func NewEmailTemplateService(db *pgxpool.Pool) *EmailTemplateService { + return &EmailTemplateService{db: db} +} + +// GetAllTemplateTypes returns all available email template types with their variables +func (s *EmailTemplateService) GetAllTemplateTypes() []models.EmailTemplateVariables { + return []models.EmailTemplateVariables{ + { + TemplateType: models.EmailTypeWelcome, + Variables: []string{"user_name", "user_email", "login_url", "support_email"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "user_email": "E-Mail-Adresse des Benutzers", + "login_url": "URL zur Login-Seite", + "support_email": "Support E-Mail-Adresse", + }, + }, + { + TemplateType: models.EmailTypeEmailVerification, + Variables: []string{"user_name", "verification_url", "verification_code", "expires_in"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "verification_url": "URL zur E-Mail-Verifizierung", + "verification_code": "Verifizierungscode", + "expires_in": "Gültigkeit des Links (z.B. '24 Stunden')", + }, + }, + { + TemplateType: models.EmailTypePasswordReset, + Variables: []string{"user_name", "reset_url", "reset_code", "expires_in", "ip_address"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "reset_url": "URL zum Passwort-Reset", + "reset_code": "Reset-Code", + "expires_in": "Gültigkeit des Links", + "ip_address": "IP-Adresse der Anfrage", + }, + }, + { + TemplateType: models.EmailTypePasswordChanged, + Variables: []string{"user_name", "changed_at", "ip_address", "device_info", "support_url"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "changed_at": "Zeitpunkt der Änderung", + "ip_address": "IP-Adresse", + "device_info": "Geräte-Informationen", + "support_url": "URL zum Support", + }, + }, + { + TemplateType: models.EmailType2FAEnabled, + Variables: []string{"user_name", "enabled_at", "device_info", "security_url"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "enabled_at": "Zeitpunkt der Aktivierung", + "device_info": "Geräte-Informationen", + "security_url": "URL zu Sicherheitseinstellungen", + }, + }, + { + TemplateType: models.EmailType2FADisabled, + Variables: []string{"user_name", "disabled_at", "ip_address", "security_url"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "disabled_at": "Zeitpunkt der Deaktivierung", + "ip_address": "IP-Adresse", + "security_url": "URL zu Sicherheitseinstellungen", + }, + }, + { + TemplateType: models.EmailTypeNewDeviceLogin, + Variables: []string{"user_name", "login_time", "ip_address", "device_info", "location", "security_url"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "login_time": "Zeitpunkt des Logins", + "ip_address": "IP-Adresse", + "device_info": "Geräte-Informationen", + "location": "Ungefährer Standort", + "security_url": "URL zu Sicherheitseinstellungen", + }, + }, + { + TemplateType: models.EmailTypeSuspiciousActivity, + Variables: []string{"user_name", "activity_type", "activity_time", "ip_address", "security_url"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "activity_type": "Art der Aktivität", + "activity_time": "Zeitpunkt", + "ip_address": "IP-Adresse", + "security_url": "URL zu Sicherheitseinstellungen", + }, + }, + { + TemplateType: models.EmailTypeAccountLocked, + Variables: []string{"user_name", "locked_at", "reason", "unlock_time", "support_url"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "locked_at": "Zeitpunkt der Sperrung", + "reason": "Grund der Sperrung", + "unlock_time": "Zeitpunkt der automatischen Entsperrung", + "support_url": "URL zum Support", + }, + }, + { + TemplateType: models.EmailTypeAccountUnlocked, + Variables: []string{"user_name", "unlocked_at", "login_url"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "unlocked_at": "Zeitpunkt der Entsperrung", + "login_url": "URL zur Login-Seite", + }, + }, + { + TemplateType: models.EmailTypeDeletionRequested, + Variables: []string{"user_name", "requested_at", "deletion_date", "cancel_url", "data_info"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "requested_at": "Zeitpunkt der Anfrage", + "deletion_date": "Datum der endgültigen Löschung", + "cancel_url": "URL zum Abbrechen", + "data_info": "Info über zu löschende Daten", + }, + }, + { + TemplateType: models.EmailTypeDeletionConfirmed, + Variables: []string{"user_name", "deleted_at", "feedback_url"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "deleted_at": "Zeitpunkt der Löschung", + "feedback_url": "URL für Feedback", + }, + }, + { + TemplateType: models.EmailTypeDataExportReady, + Variables: []string{"user_name", "download_url", "expires_in", "file_size"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "download_url": "URL zum Download", + "expires_in": "Gültigkeit des Download-Links", + "file_size": "Dateigröße", + }, + }, + { + TemplateType: models.EmailTypeEmailChanged, + Variables: []string{"user_name", "old_email", "new_email", "changed_at", "support_url"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "old_email": "Alte E-Mail-Adresse", + "new_email": "Neue E-Mail-Adresse", + "changed_at": "Zeitpunkt der Änderung", + "support_url": "URL zum Support", + }, + }, + { + TemplateType: models.EmailTypeEmailChangeVerify, + Variables: []string{"user_name", "new_email", "verification_url", "expires_in"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "new_email": "Neue E-Mail-Adresse", + "verification_url": "URL zur Verifizierung", + "expires_in": "Gültigkeit des Links", + }, + }, + { + TemplateType: models.EmailTypeNewVersionPublished, + Variables: []string{"user_name", "document_name", "document_type", "version", "consent_url", "deadline"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "document_name": "Name des Dokuments", + "document_type": "Typ des Dokuments", + "version": "Versionsnummer", + "consent_url": "URL zur Zustimmung", + "deadline": "Frist für die Zustimmung", + }, + }, + { + TemplateType: models.EmailTypeConsentReminder, + Variables: []string{"user_name", "document_name", "days_left", "consent_url", "deadline"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "document_name": "Name des Dokuments", + "days_left": "Verbleibende Tage", + "consent_url": "URL zur Zustimmung", + "deadline": "Frist für die Zustimmung", + }, + }, + { + TemplateType: models.EmailTypeConsentDeadlineWarning, + Variables: []string{"user_name", "document_name", "hours_left", "consent_url", "consequences"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "document_name": "Name des Dokuments", + "hours_left": "Verbleibende Stunden", + "consent_url": "URL zur Zustimmung", + "consequences": "Konsequenzen bei Nicht-Zustimmung", + }, + }, + { + TemplateType: models.EmailTypeAccountSuspended, + Variables: []string{"user_name", "suspended_at", "reason", "documents", "consent_url"}, + Descriptions: map[string]string{ + "user_name": "Name des Benutzers", + "suspended_at": "Zeitpunkt der Suspendierung", + "reason": "Grund der Suspendierung", + "documents": "Liste der fehlenden Zustimmungen", + "consent_url": "URL zur Zustimmung", + }, + }, + } +} + +// CreateEmailTemplate creates a new email template type +func (s *EmailTemplateService) CreateEmailTemplate(ctx context.Context, req *models.CreateEmailTemplateRequest) (*models.EmailTemplate, error) { + template := &models.EmailTemplate{ + ID: uuid.New(), + Type: req.Type, + Name: req.Name, + Description: req.Description, + IsActive: true, + SortOrder: 0, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + _, err := s.db.Exec(ctx, ` + INSERT INTO email_templates (id, type, name, description, is_active, sort_order, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, template.ID, template.Type, template.Name, template.Description, template.IsActive, template.SortOrder, template.CreatedAt, template.UpdatedAt) + + if err != nil { + return nil, fmt.Errorf("failed to create email template: %w", err) + } + + return template, nil +} + +// GetAllTemplates returns all email templates with their latest published versions +func (s *EmailTemplateService) GetAllTemplates(ctx context.Context) ([]models.EmailTemplateWithVersion, error) { + rows, err := s.db.Query(ctx, ` + SELECT + t.id, t.type, t.name, t.description, t.is_active, t.sort_order, t.created_at, t.updated_at, + v.id, v.template_id, v.version, v.language, v.subject, v.body_html, v.body_text, + v.summary, v.status, v.published_at, v.scheduled_publish_at, v.created_by, + v.approved_by, v.approved_at, v.created_at, v.updated_at + FROM email_templates t + LEFT JOIN email_template_versions v ON t.id = v.template_id AND v.status = 'published' + ORDER BY t.sort_order, t.name + `) + if err != nil { + return nil, fmt.Errorf("failed to get email templates: %w", err) + } + defer rows.Close() + + var results []models.EmailTemplateWithVersion + for rows.Next() { + var template models.EmailTemplate + var versionID, templateID, createdBy, approvedBy *uuid.UUID + var publishedAt, scheduledPublishAt, approvedAt, vCreatedAt, vUpdatedAt *time.Time + var vVersion, vLanguage, vSubject, vBodyHTML, vBodyText, vSummary, vStatus *string + + err := rows.Scan( + &template.ID, &template.Type, &template.Name, &template.Description, + &template.IsActive, &template.SortOrder, &template.CreatedAt, &template.UpdatedAt, + &versionID, &templateID, &vVersion, &vLanguage, &vSubject, &vBodyHTML, &vBodyText, + &vSummary, &vStatus, &publishedAt, &scheduledPublishAt, &createdBy, + &approvedBy, &approvedAt, &vCreatedAt, &vUpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan email template: %w", err) + } + + result := models.EmailTemplateWithVersion{Template: template} + if versionID != nil { + result.LatestVersion = &models.EmailTemplateVersion{ + ID: *versionID, + TemplateID: *templateID, + Version: *vVersion, + Language: *vLanguage, + Subject: *vSubject, + BodyHTML: *vBodyHTML, + BodyText: *vBodyText, + Status: *vStatus, + PublishedAt: publishedAt, + ScheduledPublishAt: scheduledPublishAt, + CreatedBy: createdBy, + ApprovedBy: approvedBy, + ApprovedAt: approvedAt, + } + if vSummary != nil { + result.LatestVersion.Summary = vSummary + } + if vCreatedAt != nil { + result.LatestVersion.CreatedAt = *vCreatedAt + } + if vUpdatedAt != nil { + result.LatestVersion.UpdatedAt = *vUpdatedAt + } + } + results = append(results, result) + } + + return results, nil +} + +// GetTemplateByID returns a template by ID +func (s *EmailTemplateService) GetTemplateByID(ctx context.Context, id uuid.UUID) (*models.EmailTemplate, error) { + var template models.EmailTemplate + err := s.db.QueryRow(ctx, ` + SELECT id, type, name, description, is_active, sort_order, created_at, updated_at + FROM email_templates WHERE id = $1 + `, id).Scan(&template.ID, &template.Type, &template.Name, &template.Description, + &template.IsActive, &template.SortOrder, &template.CreatedAt, &template.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("failed to get email template: %w", err) + } + return &template, nil +} + +// GetTemplateByType returns a template by type +func (s *EmailTemplateService) GetTemplateByType(ctx context.Context, templateType string) (*models.EmailTemplate, error) { + var template models.EmailTemplate + err := s.db.QueryRow(ctx, ` + SELECT id, type, name, description, is_active, sort_order, created_at, updated_at + FROM email_templates WHERE type = $1 + `, templateType).Scan(&template.ID, &template.Type, &template.Name, &template.Description, + &template.IsActive, &template.SortOrder, &template.CreatedAt, &template.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("failed to get email template: %w", err) + } + return &template, nil +} + +// CreateTemplateVersion creates a new version of an email template +func (s *EmailTemplateService) CreateTemplateVersion(ctx context.Context, req *models.CreateEmailTemplateVersionRequest, createdBy uuid.UUID) (*models.EmailTemplateVersion, error) { + templateID, err := uuid.Parse(req.TemplateID) + if err != nil { + return nil, fmt.Errorf("invalid template ID: %w", err) + } + + version := &models.EmailTemplateVersion{ + ID: uuid.New(), + TemplateID: templateID, + Version: req.Version, + Language: req.Language, + Subject: req.Subject, + BodyHTML: req.BodyHTML, + BodyText: req.BodyText, + Summary: req.Summary, + Status: "draft", + CreatedBy: &createdBy, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + _, err = s.db.Exec(ctx, ` + INSERT INTO email_template_versions + (id, template_id, version, language, subject, body_html, body_text, summary, status, created_by, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + `, version.ID, version.TemplateID, version.Version, version.Language, version.Subject, + version.BodyHTML, version.BodyText, version.Summary, version.Status, version.CreatedBy, + version.CreatedAt, version.UpdatedAt) + + if err != nil { + return nil, fmt.Errorf("failed to create email template version: %w", err) + } + + return version, nil +} + +// GetVersionsByTemplateID returns all versions for a template +func (s *EmailTemplateService) GetVersionsByTemplateID(ctx context.Context, templateID uuid.UUID) ([]models.EmailTemplateVersion, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, template_id, version, language, subject, body_html, body_text, summary, status, + published_at, scheduled_publish_at, created_by, approved_by, approved_at, created_at, updated_at + FROM email_template_versions + WHERE template_id = $1 + ORDER BY created_at DESC + `, templateID) + if err != nil { + return nil, fmt.Errorf("failed to get template versions: %w", err) + } + defer rows.Close() + + var versions []models.EmailTemplateVersion + for rows.Next() { + var v models.EmailTemplateVersion + err := rows.Scan(&v.ID, &v.TemplateID, &v.Version, &v.Language, &v.Subject, &v.BodyHTML, &v.BodyText, + &v.Summary, &v.Status, &v.PublishedAt, &v.ScheduledPublishAt, &v.CreatedBy, &v.ApprovedBy, + &v.ApprovedAt, &v.CreatedAt, &v.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("failed to scan template version: %w", err) + } + versions = append(versions, v) + } + + return versions, nil +} + +// GetVersionByID returns a version by ID +func (s *EmailTemplateService) GetVersionByID(ctx context.Context, id uuid.UUID) (*models.EmailTemplateVersion, error) { + var v models.EmailTemplateVersion + err := s.db.QueryRow(ctx, ` + SELECT id, template_id, version, language, subject, body_html, body_text, summary, status, + published_at, scheduled_publish_at, created_by, approved_by, approved_at, created_at, updated_at + FROM email_template_versions WHERE id = $1 + `, id).Scan(&v.ID, &v.TemplateID, &v.Version, &v.Language, &v.Subject, &v.BodyHTML, &v.BodyText, + &v.Summary, &v.Status, &v.PublishedAt, &v.ScheduledPublishAt, &v.CreatedBy, &v.ApprovedBy, + &v.ApprovedAt, &v.CreatedAt, &v.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("failed to get template version: %w", err) + } + return &v, nil +} + +// GetPublishedVersion returns the published version for a template and language +func (s *EmailTemplateService) GetPublishedVersion(ctx context.Context, templateType, language string) (*models.EmailTemplateVersion, error) { + var v models.EmailTemplateVersion + err := s.db.QueryRow(ctx, ` + SELECT v.id, v.template_id, v.version, v.language, v.subject, v.body_html, v.body_text, + v.summary, v.status, v.published_at, v.scheduled_publish_at, v.created_by, + v.approved_by, v.approved_at, v.created_at, v.updated_at + FROM email_template_versions v + JOIN email_templates t ON t.id = v.template_id + WHERE t.type = $1 AND v.language = $2 AND v.status = 'published' + ORDER BY v.published_at DESC + LIMIT 1 + `, templateType, language).Scan(&v.ID, &v.TemplateID, &v.Version, &v.Language, &v.Subject, &v.BodyHTML, &v.BodyText, + &v.Summary, &v.Status, &v.PublishedAt, &v.ScheduledPublishAt, &v.CreatedBy, &v.ApprovedBy, + &v.ApprovedAt, &v.CreatedAt, &v.UpdatedAt) + if err != nil { + return nil, fmt.Errorf("failed to get published version: %w", err) + } + return &v, nil +} + +// UpdateVersion updates a version +func (s *EmailTemplateService) UpdateVersion(ctx context.Context, id uuid.UUID, req *models.UpdateEmailTemplateVersionRequest) error { + query := "UPDATE email_template_versions SET updated_at = $1" + args := []interface{}{time.Now()} + argIdx := 2 + + if req.Subject != nil { + query += fmt.Sprintf(", subject = $%d", argIdx) + args = append(args, *req.Subject) + argIdx++ + } + if req.BodyHTML != nil { + query += fmt.Sprintf(", body_html = $%d", argIdx) + args = append(args, *req.BodyHTML) + argIdx++ + } + if req.BodyText != nil { + query += fmt.Sprintf(", body_text = $%d", argIdx) + args = append(args, *req.BodyText) + argIdx++ + } + if req.Summary != nil { + query += fmt.Sprintf(", summary = $%d", argIdx) + args = append(args, *req.Summary) + argIdx++ + } + if req.Status != nil { + query += fmt.Sprintf(", status = $%d", argIdx) + args = append(args, *req.Status) + argIdx++ + } + + query += fmt.Sprintf(" WHERE id = $%d", argIdx) + args = append(args, id) + + _, err := s.db.Exec(ctx, query, args...) + if err != nil { + return fmt.Errorf("failed to update version: %w", err) + } + return nil +} + +// SubmitForReview submits a version for review +func (s *EmailTemplateService) SubmitForReview(ctx context.Context, versionID, submitterID uuid.UUID, comment *string) error { + tx, err := s.db.Begin(ctx) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback(ctx) + + // Update status + _, err = tx.Exec(ctx, ` + UPDATE email_template_versions SET status = 'review', updated_at = $1 WHERE id = $2 + `, time.Now(), versionID) + if err != nil { + return fmt.Errorf("failed to update status: %w", err) + } + + // Create approval record + _, err = tx.Exec(ctx, ` + INSERT INTO email_template_approvals (id, version_id, approver_id, action, comment, created_at) + VALUES ($1, $2, $3, $4, $5, $6) + `, uuid.New(), versionID, submitterID, "submitted_for_review", comment, time.Now()) + if err != nil { + return fmt.Errorf("failed to create approval record: %w", err) + } + + return tx.Commit(ctx) +} + +// ApproveVersion approves a version (DSB) +func (s *EmailTemplateService) ApproveVersion(ctx context.Context, versionID, approverID uuid.UUID, comment *string, scheduledPublishAt *time.Time) error { + tx, err := s.db.Begin(ctx) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback(ctx) + + now := time.Now() + status := "approved" + if scheduledPublishAt != nil { + status = "scheduled" + } + + _, err = tx.Exec(ctx, ` + UPDATE email_template_versions + SET status = $1, approved_by = $2, approved_at = $3, scheduled_publish_at = $4, updated_at = $5 + WHERE id = $6 + `, status, approverID, now, scheduledPublishAt, now, versionID) + if err != nil { + return fmt.Errorf("failed to approve version: %w", err) + } + + _, err = tx.Exec(ctx, ` + INSERT INTO email_template_approvals (id, version_id, approver_id, action, comment, created_at) + VALUES ($1, $2, $3, $4, $5, $6) + `, uuid.New(), versionID, approverID, "approved", comment, now) + if err != nil { + return fmt.Errorf("failed to create approval record: %w", err) + } + + return tx.Commit(ctx) +} + +// PublishVersion publishes an approved version +func (s *EmailTemplateService) PublishVersion(ctx context.Context, versionID, publisherID uuid.UUID) error { + tx, err := s.db.Begin(ctx) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback(ctx) + + // Get version info to find template and language + var templateID uuid.UUID + var language string + err = tx.QueryRow(ctx, `SELECT template_id, language FROM email_template_versions WHERE id = $1`, versionID).Scan(&templateID, &language) + if err != nil { + return fmt.Errorf("failed to get version info: %w", err) + } + + // Archive old published versions for same template and language + _, err = tx.Exec(ctx, ` + UPDATE email_template_versions + SET status = 'archived', updated_at = $1 + WHERE template_id = $2 AND language = $3 AND status = 'published' + `, time.Now(), templateID, language) + if err != nil { + return fmt.Errorf("failed to archive old versions: %w", err) + } + + // Publish the new version + now := time.Now() + _, err = tx.Exec(ctx, ` + UPDATE email_template_versions + SET status = 'published', published_at = $1, updated_at = $2 + WHERE id = $3 + `, now, now, versionID) + if err != nil { + return fmt.Errorf("failed to publish version: %w", err) + } + + // Create approval record + _, err = tx.Exec(ctx, ` + INSERT INTO email_template_approvals (id, version_id, approver_id, action, created_at) + VALUES ($1, $2, $3, $4, $5) + `, uuid.New(), versionID, publisherID, "published", now) + if err != nil { + return fmt.Errorf("failed to create approval record: %w", err) + } + + return tx.Commit(ctx) +} + +// RejectVersion rejects a version +func (s *EmailTemplateService) RejectVersion(ctx context.Context, versionID, rejectorID uuid.UUID, comment string) error { + tx, err := s.db.Begin(ctx) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback(ctx) + + now := time.Now() + _, err = tx.Exec(ctx, ` + UPDATE email_template_versions SET status = 'draft', updated_at = $1 WHERE id = $2 + `, now, versionID) + if err != nil { + return fmt.Errorf("failed to reject version: %w", err) + } + + _, err = tx.Exec(ctx, ` + INSERT INTO email_template_approvals (id, version_id, approver_id, action, comment, created_at) + VALUES ($1, $2, $3, $4, $5, $6) + `, uuid.New(), versionID, rejectorID, "rejected", &comment, now) + if err != nil { + return fmt.Errorf("failed to create approval record: %w", err) + } + + return tx.Commit(ctx) +} + +// GetApprovals returns approval history for a version +func (s *EmailTemplateService) GetApprovals(ctx context.Context, versionID uuid.UUID) ([]models.EmailTemplateApproval, error) { + rows, err := s.db.Query(ctx, ` + SELECT id, version_id, approver_id, action, comment, created_at + FROM email_template_approvals + WHERE version_id = $1 + ORDER BY created_at DESC + `, versionID) + if err != nil { + return nil, fmt.Errorf("failed to get approvals: %w", err) + } + defer rows.Close() + + var approvals []models.EmailTemplateApproval + for rows.Next() { + var a models.EmailTemplateApproval + err := rows.Scan(&a.ID, &a.VersionID, &a.ApproverID, &a.Action, &a.Comment, &a.CreatedAt) + if err != nil { + return nil, fmt.Errorf("failed to scan approval: %w", err) + } + approvals = append(approvals, a) + } + + return approvals, nil +} + +// GetSettings returns global email settings +func (s *EmailTemplateService) GetSettings(ctx context.Context) (*models.EmailTemplateSettings, error) { + var settings models.EmailTemplateSettings + err := s.db.QueryRow(ctx, ` + SELECT id, logo_url, logo_base64, company_name, sender_name, sender_email, + reply_to_email, footer_html, footer_text, primary_color, secondary_color, + updated_at, updated_by + FROM email_template_settings + LIMIT 1 + `).Scan(&settings.ID, &settings.LogoURL, &settings.LogoBase64, &settings.CompanyName, + &settings.SenderName, &settings.SenderEmail, &settings.ReplyToEmail, &settings.FooterHTML, + &settings.FooterText, &settings.PrimaryColor, &settings.SecondaryColor, + &settings.UpdatedAt, &settings.UpdatedBy) + if err != nil { + return nil, fmt.Errorf("failed to get email settings: %w", err) + } + return &settings, nil +} + +// UpdateSettings updates global email settings +func (s *EmailTemplateService) UpdateSettings(ctx context.Context, req *models.UpdateEmailTemplateSettingsRequest, updatedBy uuid.UUID) error { + query := "UPDATE email_template_settings SET updated_at = $1, updated_by = $2" + args := []interface{}{time.Now(), updatedBy} + argIdx := 3 + + if req.LogoURL != nil { + query += fmt.Sprintf(", logo_url = $%d", argIdx) + args = append(args, *req.LogoURL) + argIdx++ + } + if req.LogoBase64 != nil { + query += fmt.Sprintf(", logo_base64 = $%d", argIdx) + args = append(args, *req.LogoBase64) + argIdx++ + } + if req.CompanyName != nil { + query += fmt.Sprintf(", company_name = $%d", argIdx) + args = append(args, *req.CompanyName) + argIdx++ + } + if req.SenderName != nil { + query += fmt.Sprintf(", sender_name = $%d", argIdx) + args = append(args, *req.SenderName) + argIdx++ + } + if req.SenderEmail != nil { + query += fmt.Sprintf(", sender_email = $%d", argIdx) + args = append(args, *req.SenderEmail) + argIdx++ + } + if req.ReplyToEmail != nil { + query += fmt.Sprintf(", reply_to_email = $%d", argIdx) + args = append(args, *req.ReplyToEmail) + argIdx++ + } + if req.FooterHTML != nil { + query += fmt.Sprintf(", footer_html = $%d", argIdx) + args = append(args, *req.FooterHTML) + argIdx++ + } + if req.FooterText != nil { + query += fmt.Sprintf(", footer_text = $%d", argIdx) + args = append(args, *req.FooterText) + argIdx++ + } + if req.PrimaryColor != nil { + query += fmt.Sprintf(", primary_color = $%d", argIdx) + args = append(args, *req.PrimaryColor) + argIdx++ + } + if req.SecondaryColor != nil { + query += fmt.Sprintf(", secondary_color = $%d", argIdx) + args = append(args, *req.SecondaryColor) + argIdx++ + } + + _, err := s.db.Exec(ctx, query, args...) + if err != nil { + return fmt.Errorf("failed to update settings: %w", err) + } + return nil +} + +// RenderTemplate renders a template with variables +func (s *EmailTemplateService) RenderTemplate(version *models.EmailTemplateVersion, variables map[string]string) (*models.EmailPreviewResponse, error) { + subject := version.Subject + bodyHTML := version.BodyHTML + bodyText := version.BodyText + + // Replace variables in format {{variable_name}} + re := regexp.MustCompile(`\{\{(\w+)\}\}`) + + replaceFunc := func(content string) string { + return re.ReplaceAllStringFunc(content, func(match string) string { + varName := strings.Trim(match, "{}") + if val, ok := variables[varName]; ok { + return val + } + return match // Keep placeholder if variable not provided + }) + } + + return &models.EmailPreviewResponse{ + Subject: replaceFunc(subject), + BodyHTML: replaceFunc(bodyHTML), + BodyText: replaceFunc(bodyText), + }, nil +} + +// LogEmailSend logs a sent email +func (s *EmailTemplateService) LogEmailSend(ctx context.Context, log *models.EmailSendLog) error { + _, err := s.db.Exec(ctx, ` + INSERT INTO email_send_logs + (id, user_id, version_id, recipient, subject, status, error_msg, variables, sent_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + `, log.ID, log.UserID, log.VersionID, log.Recipient, log.Subject, log.Status, + log.ErrorMsg, log.Variables, log.SentAt, log.CreatedAt) + if err != nil { + return fmt.Errorf("failed to log email send: %w", err) + } + return nil +} + +// GetEmailStats returns email statistics +func (s *EmailTemplateService) GetEmailStats(ctx context.Context) (*models.EmailStats, error) { + var stats models.EmailStats + + err := s.db.QueryRow(ctx, ` + SELECT + COUNT(*) as total_sent, + COUNT(*) FILTER (WHERE status = 'delivered') as delivered, + COUNT(*) FILTER (WHERE status = 'bounced') as bounced, + COUNT(*) FILTER (WHERE status = 'failed') as failed, + COUNT(*) FILTER (WHERE created_at > NOW() - INTERVAL '7 days') as recent_sent + FROM email_send_logs + `).Scan(&stats.TotalSent, &stats.Delivered, &stats.Bounced, &stats.Failed, &stats.RecentSent) + if err != nil { + return nil, fmt.Errorf("failed to get email stats: %w", err) + } + + if stats.TotalSent > 0 { + stats.DeliveryRate = float64(stats.Delivered) / float64(stats.TotalSent) * 100 + } + + return &stats, nil +} + +// GetDefaultTemplateContent returns default content for a template type +func (s *EmailTemplateService) GetDefaultTemplateContent(templateType, language string) (subject, bodyHTML, bodyText string) { + // Default templates in German + if language == "de" { + switch templateType { + case models.EmailTypeWelcome: + return s.getWelcomeTemplateDE() + case models.EmailTypeEmailVerification: + return s.getEmailVerificationTemplateDE() + case models.EmailTypePasswordReset: + return s.getPasswordResetTemplateDE() + case models.EmailTypePasswordChanged: + return s.getPasswordChangedTemplateDE() + case models.EmailType2FAEnabled: + return s.get2FAEnabledTemplateDE() + case models.EmailType2FADisabled: + return s.get2FADisabledTemplateDE() + case models.EmailTypeNewDeviceLogin: + return s.getNewDeviceLoginTemplateDE() + case models.EmailTypeSuspiciousActivity: + return s.getSuspiciousActivityTemplateDE() + case models.EmailTypeAccountLocked: + return s.getAccountLockedTemplateDE() + case models.EmailTypeAccountUnlocked: + return s.getAccountUnlockedTemplateDE() + case models.EmailTypeDeletionRequested: + return s.getDeletionRequestedTemplateDE() + case models.EmailTypeDeletionConfirmed: + return s.getDeletionConfirmedTemplateDE() + case models.EmailTypeDataExportReady: + return s.getDataExportReadyTemplateDE() + case models.EmailTypeEmailChanged: + return s.getEmailChangedTemplateDE() + case models.EmailTypeNewVersionPublished: + return s.getNewVersionPublishedTemplateDE() + case models.EmailTypeConsentReminder: + return s.getConsentReminderTemplateDE() + case models.EmailTypeConsentDeadlineWarning: + return s.getConsentDeadlineWarningTemplateDE() + case models.EmailTypeAccountSuspended: + return s.getAccountSuspendedTemplateDE() + } + } + + // Default English fallback + return "No template", "

              No template available

              ", "No template available" +} + +// ======================================== +// Default German Templates +// ======================================== + +func (s *EmailTemplateService) getWelcomeTemplateDE() (string, string, string) { + subject := "Willkommen bei BreakPilot!" + bodyHTML := ` + + + +
              +

              Willkommen bei BreakPilot!

              +

              Hallo {{user_name}},

              +

              vielen Dank für Ihre Registrierung bei BreakPilot. Ihr Konto wurde erfolgreich erstellt.

              +

              Sie können sich jetzt mit Ihrer E-Mail-Adresse {{user_email}} anmelden:

              +

              + Jetzt anmelden +

              +

              Bei Fragen stehen wir Ihnen gerne zur Verfügung unter {{support_email}}.

              +

              Mit freundlichen Grüßen,
              Ihr BreakPilot-Team

              +
              + +` + bodyText := `Willkommen bei BreakPilot! + +Hallo {{user_name}}, + +vielen Dank für Ihre Registrierung bei BreakPilot. Ihr Konto wurde erfolgreich erstellt. + +Sie können sich jetzt mit Ihrer E-Mail-Adresse {{user_email}} anmelden: +{{login_url}} + +Bei Fragen stehen wir Ihnen gerne zur Verfügung unter {{support_email}}. + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getEmailVerificationTemplateDE() (string, string, string) { + subject := "Bitte bestätigen Sie Ihre E-Mail-Adresse" + bodyHTML := ` + + + +
              +

              E-Mail-Adresse bestätigen

              +

              Hallo {{user_name}},

              +

              bitte bestätigen Sie Ihre E-Mail-Adresse, indem Sie auf den folgenden Link klicken:

              +

              + E-Mail bestätigen +

              +

              Alternativ können Sie auch diesen Bestätigungscode eingeben: {{verification_code}}

              +

              Hinweis: Dieser Link ist nur {{expires_in}} gültig.

              +

              Falls Sie diese E-Mail nicht angefordert haben, können Sie sie ignorieren.

              +

              Mit freundlichen Grüßen,
              Ihr BreakPilot-Team

              +
              + +` + bodyText := `E-Mail-Adresse bestätigen + +Hallo {{user_name}}, + +bitte bestätigen Sie Ihre E-Mail-Adresse, indem Sie auf den folgenden Link klicken: +{{verification_url}} + +Alternativ können Sie auch diesen Bestätigungscode eingeben: {{verification_code}} + +Hinweis: Dieser Link ist nur {{expires_in}} gültig. + +Falls Sie diese E-Mail nicht angefordert haben, können Sie sie ignorieren. + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getPasswordResetTemplateDE() (string, string, string) { + subject := "Passwort zurücksetzen" + bodyHTML := ` + + + +
              +

              Passwort zurücksetzen

              +

              Hallo {{user_name}},

              +

              Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt. Klicken Sie auf den folgenden Link, um ein neues Passwort festzulegen:

              +

              + Neues Passwort festlegen +

              +

              Alternativ können Sie auch diesen Code verwenden: {{reset_code}}

              +

              Hinweis: Dieser Link ist nur {{expires_in}} gültig.

              +

              + Sicherheitshinweis: Diese Anfrage wurde von der IP-Adresse {{ip_address}} gestellt. Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail und Ihr Passwort bleibt unverändert. +

              +

              Mit freundlichen Grüßen,
              Ihr BreakPilot-Team

              +
              + +` + bodyText := `Passwort zurücksetzen + +Hallo {{user_name}}, + +Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt. Klicken Sie auf den folgenden Link, um ein neues Passwort festzulegen: +{{reset_url}} + +Alternativ können Sie auch diesen Code verwenden: {{reset_code}} + +Hinweis: Dieser Link ist nur {{expires_in}} gültig. + +Sicherheitshinweis: Diese Anfrage wurde von der IP-Adresse {{ip_address}} gestellt. Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail und Ihr Passwort bleibt unverändert. + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getPasswordChangedTemplateDE() (string, string, string) { + subject := "Ihr Passwort wurde geändert" + bodyHTML := ` + + + +
              +

              Passwort geändert

              +

              Hallo {{user_name}},

              +

              Ihr Passwort wurde am {{changed_at}} erfolgreich geändert.

              +

              Details:

              +
                +
              • IP-Adresse: {{ip_address}}
              • +
              • Gerät: {{device_info}}
              • +
              +

              + Nicht Sie? Falls Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns sofort unter {{support_url}}. +

              +

              Mit freundlichen Grüßen,
              Ihr BreakPilot-Team

              +
              + +` + bodyText := `Passwort geändert + +Hallo {{user_name}}, + +Ihr Passwort wurde am {{changed_at}} erfolgreich geändert. + +Details: +- IP-Adresse: {{ip_address}} +- Gerät: {{device_info}} + +Nicht Sie? Falls Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns sofort unter {{support_url}}. + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) get2FAEnabledTemplateDE() (string, string, string) { + subject := "Zwei-Faktor-Authentifizierung aktiviert" + bodyHTML := ` + + + +
              +

              2FA aktiviert

              +

              Hallo {{user_name}},

              +

              Die Zwei-Faktor-Authentifizierung wurde am {{enabled_at}} für Ihr Konto aktiviert.

              +

              Gerät: {{device_info}}

              +

              + Sicherheitstipp: Bewahren Sie Ihre Recovery-Codes sicher auf. Sie benötigen diese, falls Sie den Zugang zu Ihrer Authenticator-App verlieren. +

              +

              Sie können Ihre 2FA-Einstellungen jederzeit unter {{security_url}} verwalten.

              +

              Mit freundlichen Grüßen,
              Ihr BreakPilot-Team

              +
              + +` + bodyText := `2FA aktiviert + +Hallo {{user_name}}, + +Die Zwei-Faktor-Authentifizierung wurde am {{enabled_at}} für Ihr Konto aktiviert. + +Gerät: {{device_info}} + +Sicherheitstipp: Bewahren Sie Ihre Recovery-Codes sicher auf. Sie benötigen diese, falls Sie den Zugang zu Ihrer Authenticator-App verlieren. + +Sie können Ihre 2FA-Einstellungen jederzeit unter {{security_url}} verwalten. + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) get2FADisabledTemplateDE() (string, string, string) { + subject := "Zwei-Faktor-Authentifizierung deaktiviert" + bodyHTML := ` + + + +
              +

              2FA deaktiviert

              +

              Hallo {{user_name}},

              +

              Die Zwei-Faktor-Authentifizierung wurde am {{disabled_at}} für Ihr Konto deaktiviert.

              +

              IP-Adresse: {{ip_address}}

              +

              + Warnung: Ihr Konto ist jetzt weniger sicher. Wir empfehlen dringend, 2FA wieder zu aktivieren. +

              +

              Sie können 2FA jederzeit unter {{security_url}} wieder aktivieren.

              +

              Mit freundlichen Grüßen,
              Ihr BreakPilot-Team

              +
              + +` + bodyText := `2FA deaktiviert + +Hallo {{user_name}}, + +Die Zwei-Faktor-Authentifizierung wurde am {{disabled_at}} für Ihr Konto deaktiviert. + +IP-Adresse: {{ip_address}} + +Warnung: Ihr Konto ist jetzt weniger sicher. Wir empfehlen dringend, 2FA wieder zu aktivieren. + +Sie können 2FA jederzeit unter {{security_url}} wieder aktivieren. + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getNewDeviceLoginTemplateDE() (string, string, string) { + subject := "Neuer Login auf Ihrem Konto" + bodyHTML := ` + + + +
              +

              Neuer Login erkannt

              +

              Hallo {{user_name}},

              +

              Wir haben einen Login auf Ihrem Konto von einem neuen Gerät oder Standort erkannt:

              +
                +
              • Zeitpunkt: {{login_time}}
              • +
              • IP-Adresse: {{ip_address}}
              • +
              • Gerät: {{device_info}}
              • +
              • Standort: {{location}}
              • +
              +

              + Nicht Sie? Falls Sie diesen Login nicht durchgeführt haben, ändern Sie sofort Ihr Passwort unter {{security_url}}. +

              +

              Mit freundlichen Grüßen,
              Ihr BreakPilot-Team

              +
              + +` + bodyText := `Neuer Login erkannt + +Hallo {{user_name}}, + +Wir haben einen Login auf Ihrem Konto von einem neuen Gerät oder Standort erkannt: + +- Zeitpunkt: {{login_time}} +- IP-Adresse: {{ip_address}} +- Gerät: {{device_info}} +- Standort: {{location}} + +Nicht Sie? Falls Sie diesen Login nicht durchgeführt haben, ändern Sie sofort Ihr Passwort unter {{security_url}}. + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getSuspiciousActivityTemplateDE() (string, string, string) { + subject := "Verdächtige Aktivität auf Ihrem Konto" + bodyHTML := ` + + + +
              +

              Verdächtige Aktivität erkannt

              +

              Hallo {{user_name}},

              +

              Wir haben verdächtige Aktivität auf Ihrem Konto festgestellt:

              +
                +
              • Art: {{activity_type}}
              • +
              • Zeitpunkt: {{activity_time}}
              • +
              • IP-Adresse: {{ip_address}}
              • +
              +

              + Wichtig: Bitte überprüfen Sie Ihre Sicherheitseinstellungen unter {{security_url}} und ändern Sie Ihr Passwort, falls Sie diese Aktivität nicht selbst durchgeführt haben. +

              +

              Mit freundlichen Grüßen,
              Ihr BreakPilot-Team

              +
              + +` + bodyText := `Verdächtige Aktivität erkannt + +Hallo {{user_name}}, + +Wir haben verdächtige Aktivität auf Ihrem Konto festgestellt: + +- Art: {{activity_type}} +- Zeitpunkt: {{activity_time}} +- IP-Adresse: {{ip_address}} + +Wichtig: Bitte überprüfen Sie Ihre Sicherheitseinstellungen unter {{security_url}} und ändern Sie Ihr Passwort, falls Sie diese Aktivität nicht selbst durchgeführt haben. + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getAccountLockedTemplateDE() (string, string, string) { + subject := "Ihr Konto wurde gesperrt" + bodyHTML := ` + + + +
              +

              Konto gesperrt

              +

              Hallo {{user_name}},

              +

              Ihr Konto wurde am {{locked_at}} aus folgendem Grund gesperrt:

              +

              + {{reason}} +

              +

              Ihr Konto wird automatisch entsperrt am: {{unlock_time}}

              +

              Falls Sie Hilfe benötigen, kontaktieren Sie uns unter {{support_url}}.

              +

              Mit freundlichen Grüßen,
              Ihr BreakPilot-Team

              +
              + +` + bodyText := `Konto gesperrt + +Hallo {{user_name}}, + +Ihr Konto wurde am {{locked_at}} aus folgendem Grund gesperrt: + +{{reason}} + +Ihr Konto wird automatisch entsperrt am: {{unlock_time}} + +Falls Sie Hilfe benötigen, kontaktieren Sie uns unter {{support_url}}. + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getAccountUnlockedTemplateDE() (string, string, string) { + subject := "Ihr Konto wurde entsperrt" + bodyHTML := ` + + + +
              +

              Konto entsperrt

              +

              Hallo {{user_name}},

              +

              Ihr Konto wurde am {{unlocked_at}} erfolgreich entsperrt.

              +

              + Jetzt anmelden +

              +

              Mit freundlichen Grüßen,
              Ihr BreakPilot-Team

              +
              + +` + bodyText := `Konto entsperrt + +Hallo {{user_name}}, + +Ihr Konto wurde am {{unlocked_at}} erfolgreich entsperrt. + +Sie können sich jetzt wieder anmelden: {{login_url}} + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getDeletionRequestedTemplateDE() (string, string, string) { + subject := "Bestätigung: Kontolöschung angefordert" + bodyHTML := ` + + + +
              +

              Kontolöschung angefordert

              +

              Hallo {{user_name}},

              +

              Sie haben am {{requested_at}} die Löschung Ihres Kontos beantragt.

              +

              + Wichtig: Ihr Konto und alle zugehörigen Daten werden endgültig am {{deletion_date}} gelöscht. +

              +

              Folgende Daten werden gelöscht:

              +

              {{data_info}}

              +

              Sie können die Löschung bis zum genannten Datum abbrechen:

              +

              + Löschung abbrechen +

              +

              Mit freundlichen Grüßen,
              Ihr BreakPilot-Team

              +
              + +` + bodyText := `Kontolöschung angefordert + +Hallo {{user_name}}, + +Sie haben am {{requested_at}} die Löschung Ihres Kontos beantragt. + +Wichtig: Ihr Konto und alle zugehörigen Daten werden endgültig am {{deletion_date}} gelöscht. + +Folgende Daten werden gelöscht: +{{data_info}} + +Sie können die Löschung bis zum genannten Datum abbrechen: {{cancel_url}} + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getDeletionConfirmedTemplateDE() (string, string, string) { + subject := "Ihr Konto wurde gelöscht" + bodyHTML := ` + + + +
              +

              Konto gelöscht

              +

              Hallo {{user_name}},

              +

              Ihr Konto und alle zugehörigen Daten wurden am {{deleted_at}} erfolgreich und endgültig gelöscht.

              +

              Wir bedauern, dass Sie uns verlassen. Falls Sie uns Feedback geben möchten:

              +

              + Feedback geben +

              +

              Vielen Dank für Ihre Zeit bei BreakPilot.

              +

              Mit freundlichen Grüßen,
              Ihr BreakPilot-Team

              +
              + +` + bodyText := `Konto gelöscht + +Hallo {{user_name}}, + +Ihr Konto und alle zugehörigen Daten wurden am {{deleted_at}} erfolgreich und endgültig gelöscht. + +Wir bedauern, dass Sie uns verlassen. Falls Sie uns Feedback geben möchten: {{feedback_url}} + +Vielen Dank für Ihre Zeit bei BreakPilot. + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getDataExportReadyTemplateDE() (string, string, string) { + subject := "Ihr Datenexport ist bereit" + bodyHTML := ` + + + +
              +

              Datenexport bereit

              +

              Hallo {{user_name}},

              +

              Ihr angeforderte Datenexport wurde erstellt und steht zum Download bereit.

              +

              + Daten herunterladen ({{file_size}}) +

              +

              + Hinweis: Der Download-Link ist nur {{expires_in}} gültig. +

              +

              Mit freundlichen Grüßen,
              Ihr BreakPilot-Team

              +
              + +` + bodyText := `Datenexport bereit + +Hallo {{user_name}}, + +Ihr angeforderte Datenexport wurde erstellt und steht zum Download bereit: +{{download_url}} + +Dateigröße: {{file_size}} + +Hinweis: Der Download-Link ist nur {{expires_in}} gültig. + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getEmailChangedTemplateDE() (string, string, string) { + subject := "Ihre E-Mail-Adresse wurde geändert" + bodyHTML := ` + + + +
              +

              E-Mail-Adresse geändert

              +

              Hallo {{user_name}},

              +

              Die E-Mail-Adresse Ihres Kontos wurde am {{changed_at}} geändert.

              +
                +
              • Alte Adresse: {{old_email}}
              • +
              • Neue Adresse: {{new_email}}
              • +
              +

              + Nicht Sie? Falls Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns sofort unter {{support_url}}. +

              +

              Mit freundlichen Grüßen,
              Ihr BreakPilot-Team

              +
              + +` + bodyText := `E-Mail-Adresse geändert + +Hallo {{user_name}}, + +Die E-Mail-Adresse Ihres Kontos wurde am {{changed_at}} geändert. + +- Alte Adresse: {{old_email}} +- Neue Adresse: {{new_email}} + +Nicht Sie? Falls Sie diese Änderung nicht vorgenommen haben, kontaktieren Sie uns sofort unter {{support_url}}. + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getNewVersionPublishedTemplateDE() (string, string, string) { + subject := "Neue Version: {{document_name}} - Ihre Zustimmung ist erforderlich" + bodyHTML := ` + + + +
              +

              Neue Dokumentversion

              +

              Hallo {{user_name}},

              +

              Eine neue Version unserer {{document_name}} ({{document_type}}) wurde veröffentlicht.

              +

              Version: {{version}}

              +

              + Bitte prüfen und bestätigen Sie die aktualisierte Version bis zum {{deadline}}. +

              +

              + Jetzt prüfen und zustimmen +

              +

              Mit freundlichen Grüßen,
              Ihr BreakPilot-Team

              +
              + +` + bodyText := `Neue Dokumentversion + +Hallo {{user_name}}, + +Eine neue Version unserer {{document_name}} ({{document_type}}) wurde veröffentlicht. + +Version: {{version}} + +Bitte prüfen und bestätigen Sie die aktualisierte Version bis zum {{deadline}}. + +Jetzt prüfen und zustimmen: {{consent_url}} + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getConsentReminderTemplateDE() (string, string, string) { + subject := "Erinnerung: Zustimmung zu {{document_name}} erforderlich" + bodyHTML := ` + + + +
              +

              Erinnerung: Zustimmung erforderlich

              +

              Hallo {{user_name}},

              +

              Dies ist eine freundliche Erinnerung, dass Ihre Zustimmung zur {{document_name}} noch aussteht.

              +

              + Noch {{days_left}} Tage bis zur Frist am {{deadline}}. +

              +

              + Jetzt zustimmen +

              +

              Mit freundlichen Grüßen,
              Ihr BreakPilot-Team

              +
              + +` + bodyText := `Erinnerung: Zustimmung erforderlich + +Hallo {{user_name}}, + +Dies ist eine freundliche Erinnerung, dass Ihre Zustimmung zur {{document_name}} noch aussteht. + +Noch {{days_left}} Tage bis zur Frist am {{deadline}}. + +Jetzt zustimmen: {{consent_url}} + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getConsentDeadlineWarningTemplateDE() (string, string, string) { + subject := "DRINGEND: Zustimmung zu {{document_name}} läuft bald ab" + bodyHTML := ` + + + +
              +

              Dringende Erinnerung

              +

              Hallo {{user_name}},

              +

              Ihre Frist zur Zustimmung zur {{document_name}} läuft in {{hours_left}} ab!

              +

              + Wichtig: {{consequences}} +

              +

              + Sofort zustimmen +

              +

              Mit freundlichen Grüßen,
              Ihr BreakPilot-Team

              +
              + +` + bodyText := `Dringende Erinnerung + +Hallo {{user_name}}, + +Ihre Frist zur Zustimmung zur {{document_name}} läuft in {{hours_left}} ab! + +Wichtig: {{consequences}} + +Sofort zustimmen: {{consent_url}} + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +func (s *EmailTemplateService) getAccountSuspendedTemplateDE() (string, string, string) { + subject := "Ihr Konto wurde suspendiert" + bodyHTML := ` + + + +
              +

              Konto suspendiert

              +

              Hallo {{user_name}},

              +

              Ihr Konto wurde am {{suspended_at}} suspendiert.

              +

              Grund: {{reason}}

              +

              Fehlende Zustimmungen:

              +

              {{documents}}

              +

              Um Ihr Konto zu reaktivieren, stimmen Sie bitte den ausstehenden Dokumenten zu:

              +

              + Konto reaktivieren +

              +

              Mit freundlichen Grüßen,
              Ihr BreakPilot-Team

              +
              + +` + bodyText := `Konto suspendiert + +Hallo {{user_name}}, + +Ihr Konto wurde am {{suspended_at}} suspendiert. + +Grund: {{reason}} + +Fehlende Zustimmungen: +{{documents}} + +Um Ihr Konto zu reaktivieren, stimmen Sie bitte den ausstehenden Dokumenten zu: {{consent_url}} + +Mit freundlichen Grüßen, +Ihr BreakPilot-Team` + return subject, bodyHTML, bodyText +} + +// InitDefaultTemplates creates default email templates if they don't exist +func (s *EmailTemplateService) InitDefaultTemplates(ctx context.Context) error { + templateTypes := []struct { + Type string + Name string + Description string + SortOrder int + }{ + {models.EmailTypeWelcome, "Willkommens-E-Mail", "Wird nach erfolgreicher Registrierung gesendet", 1}, + {models.EmailTypeEmailVerification, "E-Mail-Verifizierung", "Enthält Link zur E-Mail-Bestätigung", 2}, + {models.EmailTypePasswordReset, "Passwort zurücksetzen", "Enthält Link zum Passwort-Reset", 3}, + {models.EmailTypePasswordChanged, "Passwort geändert", "Bestätigung der Passwortänderung", 4}, + {models.EmailType2FAEnabled, "2FA aktiviert", "Bestätigung der 2FA-Aktivierung", 5}, + {models.EmailType2FADisabled, "2FA deaktiviert", "Warnung über 2FA-Deaktivierung", 6}, + {models.EmailTypeNewDeviceLogin, "Neuer Login", "Benachrichtigung über Login von neuem Gerät", 7}, + {models.EmailTypeSuspiciousActivity, "Verdächtige Aktivität", "Warnung über verdächtige Kontoaktivität", 8}, + {models.EmailTypeAccountLocked, "Konto gesperrt", "Benachrichtigung über Kontosperrung", 9}, + {models.EmailTypeAccountUnlocked, "Konto entsperrt", "Bestätigung der Kontoentsperrung", 10}, + {models.EmailTypeDeletionRequested, "Löschung angefordert", "Bestätigung der Löschanfrage", 11}, + {models.EmailTypeDeletionConfirmed, "Löschung bestätigt", "Bestätigung der Kontolöschung", 12}, + {models.EmailTypeDataExportReady, "Datenexport bereit", "Benachrichtigung über fertigen Datenexport", 13}, + {models.EmailTypeEmailChanged, "E-Mail geändert", "Bestätigung der E-Mail-Änderung", 14}, + {models.EmailTypeNewVersionPublished, "Neue Version veröffentlicht", "Benachrichtigung über neue Dokumentversion", 15}, + {models.EmailTypeConsentReminder, "Zustimmungs-Erinnerung", "Erinnerung an ausstehende Zustimmung", 16}, + {models.EmailTypeConsentDeadlineWarning, "Frist-Warnung", "Dringende Warnung vor ablaufender Frist", 17}, + {models.EmailTypeAccountSuspended, "Konto suspendiert", "Benachrichtigung über Kontosuspendierung", 18}, + } + + for _, tt := range templateTypes { + // Check if template exists + var exists bool + err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM email_templates WHERE type = $1)`, tt.Type).Scan(&exists) + if err != nil { + return fmt.Errorf("failed to check template existence: %w", err) + } + + if !exists { + desc := tt.Description + _, err = s.db.Exec(ctx, ` + INSERT INTO email_templates (id, type, name, description, is_active, sort_order, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, uuid.New(), tt.Type, tt.Name, &desc, true, tt.SortOrder, time.Now(), time.Now()) + if err != nil { + return fmt.Errorf("failed to create template %s: %w", tt.Type, err) + } + + // Create default German version + template, err := s.GetTemplateByType(ctx, tt.Type) + if err != nil { + return fmt.Errorf("failed to get template %s: %w", tt.Type, err) + } + + subject, bodyHTML, bodyText := s.GetDefaultTemplateContent(tt.Type, "de") + _, err = s.db.Exec(ctx, ` + INSERT INTO email_template_versions + (id, template_id, version, language, subject, body_html, body_text, status, published_at, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + `, uuid.New(), template.ID, "1.0.0", "de", subject, bodyHTML, bodyText, "published", time.Now(), time.Now(), time.Now()) + if err != nil { + return fmt.Errorf("failed to create template version %s: %w", tt.Type, err) + } + } + } + + return nil +} + +// GetSendLogs returns email send logs with optional filtering +func (s *EmailTemplateService) GetSendLogs(ctx context.Context, limit, offset int) ([]models.EmailSendLog, int, error) { + var total int + err := s.db.QueryRow(ctx, `SELECT COUNT(*) FROM email_send_logs`).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("failed to count send logs: %w", err) + } + + rows, err := s.db.Query(ctx, ` + SELECT id, user_id, version_id, recipient, subject, status, error_msg, variables, sent_at, delivered_at, created_at + FROM email_send_logs + ORDER BY created_at DESC + LIMIT $1 OFFSET $2 + `, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to get send logs: %w", err) + } + defer rows.Close() + + var logs []models.EmailSendLog + for rows.Next() { + var log models.EmailSendLog + err := rows.Scan(&log.ID, &log.UserID, &log.VersionID, &log.Recipient, &log.Subject, + &log.Status, &log.ErrorMsg, &log.Variables, &log.SentAt, &log.DeliveredAt, &log.CreatedAt) + if err != nil { + return nil, 0, fmt.Errorf("failed to scan send log: %w", err) + } + logs = append(logs, log) + } + + return logs, total, nil +} + +// SendEmail sends an email using the specified template (stub - actual sending would use SMTP) +func (s *EmailTemplateService) SendEmail(ctx context.Context, templateType, language, recipient string, variables map[string]string, userID *uuid.UUID) error { + // Get published version + version, err := s.GetPublishedVersion(ctx, templateType, language) + if err != nil { + return fmt.Errorf("failed to get published version: %w", err) + } + + // Render template + rendered, err := s.RenderTemplate(version, variables) + if err != nil { + return fmt.Errorf("failed to render template: %w", err) + } + + // Log the send attempt + variablesJSON, _ := json.Marshal(variables) + now := time.Now() + log := &models.EmailSendLog{ + ID: uuid.New(), + UserID: userID, + VersionID: version.ID, + Recipient: recipient, + Subject: rendered.Subject, + Status: "queued", + Variables: ptr(string(variablesJSON)), + CreatedAt: now, + } + + if err := s.LogEmailSend(ctx, log); err != nil { + return fmt.Errorf("failed to log email send: %w", err) + } + + // TODO: Actual email sending via SMTP would go here + // For now, we just log it as "sent" + _, err = s.db.Exec(ctx, ` + UPDATE email_send_logs SET status = 'sent', sent_at = $1 WHERE id = $2 + `, now, log.ID) + if err != nil { + return fmt.Errorf("failed to update send log status: %w", err) + } + + return nil +} + +func ptr(s string) *string { + return &s +} diff --git a/consent-service/internal/services/email_template_service_test.go b/consent-service/internal/services/email_template_service_test.go new file mode 100644 index 0000000..8474134 --- /dev/null +++ b/consent-service/internal/services/email_template_service_test.go @@ -0,0 +1,698 @@ +package services + +import ( + "regexp" + "strings" + "testing" + + "github.com/breakpilot/consent-service/internal/models" +) + +// ======================================== +// Test All 19 Email Categories +// ======================================== + +// TestEmailTemplateService_GetDefaultTemplateContent tests default content generation for each email type +func TestEmailTemplateService_GetDefaultTemplateContent(t *testing.T) { + service := &EmailTemplateService{} + + // All 19 email categories + tests := []struct { + name string + emailType string + language string + wantSubject bool + wantBodyHTML bool + wantBodyText bool + }{ + // Auth Lifecycle (10 types) + {"welcome_de", models.EmailTypeWelcome, "de", true, true, true}, + {"email_verification_de", models.EmailTypeEmailVerification, "de", true, true, true}, + {"password_reset_de", models.EmailTypePasswordReset, "de", true, true, true}, + {"password_changed_de", models.EmailTypePasswordChanged, "de", true, true, true}, + {"2fa_enabled_de", models.EmailType2FAEnabled, "de", true, true, true}, + {"2fa_disabled_de", models.EmailType2FADisabled, "de", true, true, true}, + {"new_device_login_de", models.EmailTypeNewDeviceLogin, "de", true, true, true}, + {"suspicious_activity_de", models.EmailTypeSuspiciousActivity, "de", true, true, true}, + {"account_locked_de", models.EmailTypeAccountLocked, "de", true, true, true}, + {"account_unlocked_de", models.EmailTypeAccountUnlocked, "de", true, true, true}, + + // GDPR/Privacy (5 types) + {"deletion_requested_de", models.EmailTypeDeletionRequested, "de", true, true, true}, + {"deletion_confirmed_de", models.EmailTypeDeletionConfirmed, "de", true, true, true}, + {"data_export_ready_de", models.EmailTypeDataExportReady, "de", true, true, true}, + {"email_changed_de", models.EmailTypeEmailChanged, "de", true, true, true}, + {"email_change_verify_de", models.EmailTypeEmailChangeVerify, "de", true, true, true}, + + // Consent Management (4 types) + {"new_version_published_de", models.EmailTypeNewVersionPublished, "de", true, true, true}, + {"consent_reminder_de", models.EmailTypeConsentReminder, "de", true, true, true}, + {"consent_deadline_warning_de", models.EmailTypeConsentDeadlineWarning, "de", true, true, true}, + {"account_suspended_de", models.EmailTypeAccountSuspended, "de", true, true, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + subject, bodyHTML, bodyText := service.GetDefaultTemplateContent(tt.emailType, tt.language) + + if tt.wantSubject && subject == "" { + t.Errorf("GetDefaultTemplateContent(%s, %s): expected subject, got empty string", tt.emailType, tt.language) + } + if tt.wantBodyHTML && bodyHTML == "" { + t.Errorf("GetDefaultTemplateContent(%s, %s): expected bodyHTML, got empty string", tt.emailType, tt.language) + } + if tt.wantBodyText && bodyText == "" { + t.Errorf("GetDefaultTemplateContent(%s, %s): expected bodyText, got empty string", tt.emailType, tt.language) + } + }) + } +} + +// TestEmailTemplateService_GetDefaultTemplateContent_UnknownType tests default content for unknown type +func TestEmailTemplateService_GetDefaultTemplateContent_UnknownType(t *testing.T) { + service := &EmailTemplateService{} + + subject, bodyHTML, bodyText := service.GetDefaultTemplateContent("unknown_type", "de") + + // The service returns a fallback for unknown types + if subject == "" { + t.Errorf("GetDefaultTemplateContent(unknown_type, de): expected fallback subject, got empty") + } + if bodyHTML == "" { + t.Errorf("GetDefaultTemplateContent(unknown_type, de): expected fallback bodyHTML, got empty") + } + if bodyText == "" { + t.Errorf("GetDefaultTemplateContent(unknown_type, de): expected fallback bodyText, got empty") + } +} + +// TestEmailTemplateService_GetDefaultTemplateContent_UnsupportedLanguage tests fallback for unsupported language +func TestEmailTemplateService_GetDefaultTemplateContent_UnsupportedLanguage(t *testing.T) { + service := &EmailTemplateService{} + + // Test with unsupported language - should return fallback + subject, bodyHTML, bodyText := service.GetDefaultTemplateContent(models.EmailTypeWelcome, "fr") + + // Should return fallback (not empty, but generic) + if subject == "" || bodyHTML == "" || bodyText == "" { + t.Error("GetDefaultTemplateContent should return fallback for unsupported language") + } +} + +// TestReplaceVariables tests variable replacement in templates +func TestReplaceVariables(t *testing.T) { + tests := []struct { + name string + template string + variables map[string]string + expected string + }{ + { + name: "single variable", + template: "Hallo {{user_name}}!", + variables: map[string]string{"user_name": "Max"}, + expected: "Hallo Max!", + }, + { + name: "multiple variables", + template: "Hallo {{user_name}}, klicken Sie hier: {{reset_link}}", + variables: map[string]string{"user_name": "Max", "reset_link": "https://example.com"}, + expected: "Hallo Max, klicken Sie hier: https://example.com", + }, + { + name: "no variables", + template: "Hallo Welt!", + variables: map[string]string{}, + expected: "Hallo Welt!", + }, + { + name: "missing variable - not replaced", + template: "Hallo {{user_name}} und {{missing}}!", + variables: map[string]string{"user_name": "Max"}, + expected: "Hallo Max und {{missing}}!", + }, + { + name: "empty template", + template: "", + variables: map[string]string{"user_name": "Max"}, + expected: "", + }, + { + name: "variable with special characters", + template: "IP: {{ip_address}}", + variables: map[string]string{"ip_address": "192.168.1.1"}, + expected: "IP: 192.168.1.1", + }, + { + name: "variable with URL", + template: "Link: {{verification_url}}", + variables: map[string]string{"verification_url": "https://example.com/verify?token=abc123&user=test"}, + expected: "Link: https://example.com/verify?token=abc123&user=test", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := testReplaceVariables(tt.template, tt.variables) + if result != tt.expected { + t.Errorf("replaceVariables() = %s, want %s", result, tt.expected) + } + }) + } +} + +// testReplaceVariables is a test helper function for variable replacement +func testReplaceVariables(template string, variables map[string]string) string { + result := template + for key, value := range variables { + placeholder := "{{" + key + "}}" + for i := 0; i < len(result); i++ { + idx := testFindSubstring(result, placeholder) + if idx == -1 { + break + } + result = result[:idx] + value + result[idx+len(placeholder):] + } + } + return result +} + +func testFindSubstring(s, substr string) int { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} + +// TestEmailTypeConstantsExist verifies that all expected email types are defined +func TestEmailTypeConstantsExist(t *testing.T) { + // Test that all 19 email type constants are defined and produce non-empty templates + types := []string{ + // Auth Lifecycle + models.EmailTypeWelcome, + models.EmailTypeEmailVerification, + models.EmailTypePasswordReset, + models.EmailTypePasswordChanged, + models.EmailType2FAEnabled, + models.EmailType2FADisabled, + models.EmailTypeNewDeviceLogin, + models.EmailTypeSuspiciousActivity, + models.EmailTypeAccountLocked, + models.EmailTypeAccountUnlocked, + // GDPR/Privacy + models.EmailTypeDeletionRequested, + models.EmailTypeDeletionConfirmed, + models.EmailTypeDataExportReady, + models.EmailTypeEmailChanged, + models.EmailTypeEmailChangeVerify, + // Consent Management + models.EmailTypeNewVersionPublished, + models.EmailTypeConsentReminder, + models.EmailTypeConsentDeadlineWarning, + models.EmailTypeAccountSuspended, + } + + service := &EmailTemplateService{} + + for _, emailType := range types { + t.Run(emailType, func(t *testing.T) { + subject, bodyHTML, _ := service.GetDefaultTemplateContent(emailType, "de") + if subject == "" { + t.Errorf("Email type %s has no default subject", emailType) + } + if bodyHTML == "" { + t.Errorf("Email type %s has no default body HTML", emailType) + } + }) + } + + // Verify we have exactly 19 types + if len(types) != 19 { + t.Errorf("Expected 19 email types, got %d", len(types)) + } +} + +// TestEmailTemplateService_ValidateTemplateContent tests template content validation +func TestEmailTemplateService_ValidateTemplateContent(t *testing.T) { + tests := []struct { + name string + subject string + bodyHTML string + wantError bool + }{ + { + name: "valid content", + subject: "Test Subject", + bodyHTML: "

              Test Body

              ", + wantError: false, + }, + { + name: "empty subject", + subject: "", + bodyHTML: "

              Test Body

              ", + wantError: true, + }, + { + name: "empty body", + subject: "Test Subject", + bodyHTML: "", + wantError: true, + }, + { + name: "both empty", + subject: "", + bodyHTML: "", + wantError: true, + }, + { + name: "whitespace only subject", + subject: " ", + bodyHTML: "

              Test Body

              ", + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := testValidateTemplateContent(tt.subject, tt.bodyHTML) + if (err != nil) != tt.wantError { + t.Errorf("validateTemplateContent() error = %v, wantError %v", err, tt.wantError) + } + }) + } +} + +// testValidateTemplateContent is a test helper function to validate template content +func testValidateTemplateContent(subject, bodyHTML string) error { + if strings.TrimSpace(subject) == "" { + return &templateValidationError{Field: "subject", Message: "subject is required"} + } + if strings.TrimSpace(bodyHTML) == "" { + return &templateValidationError{Field: "body_html", Message: "body_html is required"} + } + return nil +} + +// templateValidationError represents a validation error in email templates +type templateValidationError struct { + Field string + Message string +} + +func (e *templateValidationError) Error() string { + return e.Field + ": " + e.Message +} + +// TestGetTestVariablesForType tests that test variables are properly generated for each email type +func TestGetTestVariablesForType(t *testing.T) { + tests := []struct { + emailType string + expectedVars []string + }{ + // Auth Lifecycle + {models.EmailTypeWelcome, []string{"user_name", "app_name"}}, + {models.EmailTypeEmailVerification, []string{"user_name", "verification_url"}}, + {models.EmailTypePasswordReset, []string{"reset_url"}}, + {models.EmailTypePasswordChanged, []string{"user_name", "changed_at"}}, + {models.EmailType2FAEnabled, []string{"user_name", "enabled_at"}}, + {models.EmailType2FADisabled, []string{"user_name", "disabled_at"}}, + {models.EmailTypeNewDeviceLogin, []string{"device", "location", "ip_address", "login_time"}}, + {models.EmailTypeSuspiciousActivity, []string{"activity_type", "activity_time"}}, + {models.EmailTypeAccountLocked, []string{"locked_at", "reason"}}, + {models.EmailTypeAccountUnlocked, []string{"unlocked_at"}}, + // GDPR/Privacy + {models.EmailTypeDeletionRequested, []string{"deletion_date", "cancel_url"}}, + {models.EmailTypeDeletionConfirmed, []string{"deleted_at"}}, + {models.EmailTypeDataExportReady, []string{"download_url", "expires_in"}}, + {models.EmailTypeEmailChanged, []string{"old_email", "new_email"}}, + // Consent Management + {models.EmailTypeNewVersionPublished, []string{"document_name", "version"}}, + {models.EmailTypeConsentReminder, []string{"document_name", "days_left"}}, + {models.EmailTypeConsentDeadlineWarning, []string{"document_name", "hours_left"}}, + {models.EmailTypeAccountSuspended, []string{"suspended_at", "reason"}}, + } + + for _, tt := range tests { + t.Run(tt.emailType, func(t *testing.T) { + vars := getTestVariablesForType(tt.emailType) + for _, expected := range tt.expectedVars { + if _, ok := vars[expected]; !ok { + t.Errorf("getTestVariablesForType(%s) missing variable %s", tt.emailType, expected) + } + } + }) + } +} + +// getTestVariablesForType returns test variables for a given email type +func getTestVariablesForType(emailType string) map[string]string { + // Common variables + vars := map[string]string{ + "user_name": "Max Mustermann", + "user_email": "max@example.com", + "app_name": "BreakPilot", + "app_url": "https://breakpilot.app", + "support_url": "https://breakpilot.app/support", + "support_email": "support@breakpilot.app", + "security_url": "https://breakpilot.app/security", + "login_url": "https://breakpilot.app/login", + } + + switch emailType { + case models.EmailTypeEmailVerification: + vars["verification_url"] = "https://breakpilot.app/verify?token=xyz789" + vars["verification_code"] = "ABC123" + vars["expires_in"] = "24 Stunden" + + case models.EmailTypePasswordReset: + vars["reset_url"] = "https://breakpilot.app/reset?token=abc123" + vars["reset_code"] = "RST456" + vars["expires_in"] = "1 Stunde" + vars["ip_address"] = "192.168.1.1" + + case models.EmailTypePasswordChanged: + vars["changed_at"] = "14.12.2025 15:30 Uhr" + vars["ip_address"] = "192.168.1.1" + vars["device_info"] = "Chrome auf MacOS" + + case models.EmailType2FAEnabled: + vars["enabled_at"] = "14.12.2025 15:30 Uhr" + vars["device_info"] = "Chrome auf MacOS" + + case models.EmailType2FADisabled: + vars["disabled_at"] = "14.12.2025 15:30 Uhr" + vars["ip_address"] = "192.168.1.1" + + case models.EmailTypeNewDeviceLogin: + vars["device"] = "Chrome auf MacOS" + vars["device_info"] = "Chrome auf MacOS" + vars["location"] = "Berlin, Deutschland" + vars["ip_address"] = "192.168.1.1" + vars["login_time"] = "14.12.2025 15:30 Uhr" + + case models.EmailTypeSuspiciousActivity: + vars["activity_type"] = "Mehrere fehlgeschlagene Logins" + vars["activity_time"] = "14.12.2025 15:30 Uhr" + vars["ip_address"] = "192.168.1.1" + + case models.EmailTypeAccountLocked: + vars["locked_at"] = "14.12.2025 15:30 Uhr" + vars["reason"] = "Zu viele fehlgeschlagene Login-Versuche" + vars["unlock_time"] = "14.12.2025 16:30 Uhr" + + case models.EmailTypeAccountUnlocked: + vars["unlocked_at"] = "14.12.2025 16:30 Uhr" + + case models.EmailTypeDeletionRequested: + vars["requested_at"] = "14.12.2025 15:30 Uhr" + vars["deletion_date"] = "14.01.2026" + vars["cancel_url"] = "https://breakpilot.app/cancel-deletion?token=del123" + vars["data_info"] = "Profildaten, Consent-Historie, Audit-Logs" + + case models.EmailTypeDeletionConfirmed: + vars["deleted_at"] = "14.01.2026 00:00 Uhr" + vars["feedback_url"] = "https://breakpilot.app/feedback" + + case models.EmailTypeDataExportReady: + vars["download_url"] = "https://breakpilot.app/download/export123" + vars["expires_in"] = "7 Tage" + vars["file_size"] = "2.5 MB" + + case models.EmailTypeEmailChanged: + vars["old_email"] = "old@example.com" + vars["new_email"] = "new@example.com" + vars["changed_at"] = "14.12.2025 15:30 Uhr" + + case models.EmailTypeEmailChangeVerify: + vars["new_email"] = "new@example.com" + vars["verification_url"] = "https://breakpilot.app/verify-email?token=ver123" + vars["expires_in"] = "24 Stunden" + + case models.EmailTypeNewVersionPublished: + vars["document_name"] = "Datenschutzerklärung" + vars["document_type"] = "privacy" + vars["version"] = "2.0.0" + vars["consent_url"] = "https://breakpilot.app/consent" + vars["deadline"] = "31.12.2025" + + case models.EmailTypeConsentReminder: + vars["document_name"] = "Nutzungsbedingungen" + vars["days_left"] = "7" + vars["consent_url"] = "https://breakpilot.app/consent" + vars["deadline"] = "21.12.2025" + + case models.EmailTypeConsentDeadlineWarning: + vars["document_name"] = "Nutzungsbedingungen" + vars["hours_left"] = "24 Stunden" + vars["consent_url"] = "https://breakpilot.app/consent" + vars["consequences"] = "Ihr Konto wird temporär suspendiert." + + case models.EmailTypeAccountSuspended: + vars["suspended_at"] = "14.12.2025 15:30 Uhr" + vars["reason"] = "Fehlende Zustimmung zu Pflichtdokumenten" + vars["documents"] = "- Nutzungsbedingungen v2.0\n- Datenschutzerklärung v3.0" + vars["consent_url"] = "https://breakpilot.app/consent" + } + + return vars +} + +// TestEmailTemplateService_HTMLEscape tests that HTML is properly escaped in text version +func TestEmailTemplateService_HTMLEscape(t *testing.T) { + tests := []struct { + name string + html string + expected string + }{ + { + name: "simple paragraph", + html: "

              Hello World

              ", + expected: "Hello World", + }, + { + name: "link", + html: `Click here`, + expected: "Click here", + }, + { + name: "bold text", + html: "Important", + expected: "Important", + }, + { + name: "nested tags", + html: "

              Nested text

              ", + expected: "Nested text", + }, + { + name: "multiple tags", + html: "

              Title

              Paragraph

              ", + expected: "TitleParagraph", + }, + { + name: "self-closing tag", + html: "Line1
              Line2", + expected: "Line1Line2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := stripHTMLTags(tt.html) + if result != tt.expected { + t.Errorf("stripHTMLTags() = %s, want %s", result, tt.expected) + } + }) + } +} + +// stripHTMLTags removes HTML tags from a string +func stripHTMLTags(html string) string { + result := "" + inTag := false + for _, r := range html { + if r == '<' { + inTag = true + continue + } + if r == '>' { + inTag = false + continue + } + if !inTag { + result += string(r) + } + } + return result +} + +// TestEmailTemplateService_AllTemplatesHaveVariables tests that all templates define their required variables +func TestEmailTemplateService_AllTemplatesHaveVariables(t *testing.T) { + service := &EmailTemplateService{} + templateTypes := service.GetAllTemplateTypes() + + for _, tt := range templateTypes { + t.Run(tt.TemplateType, func(t *testing.T) { + // Get default template content + subject, bodyHTML, bodyText := service.GetDefaultTemplateContent(tt.TemplateType, "de") + + // Check that variables defined in template type are present in the content + for _, varName := range tt.Variables { + placeholder := "{{" + varName + "}}" + foundInSubject := strings.Contains(subject, placeholder) + foundInHTML := strings.Contains(bodyHTML, placeholder) + foundInText := strings.Contains(bodyText, placeholder) + + // Variable should be present in at least one of subject, HTML or text + if !foundInSubject && !foundInHTML && !foundInText { + // Note: This is a warning, not an error, as some variables might be optional + t.Logf("Warning: Variable %s defined for %s but not found in template content", varName, tt.TemplateType) + } + } + + // Check that all variables in content are defined + re := regexp.MustCompile(`\{\{(\w+)\}\}`) + allMatches := re.FindAllStringSubmatch(subject+bodyHTML+bodyText, -1) + definedVars := make(map[string]bool) + for _, v := range tt.Variables { + definedVars[v] = true + } + + for _, match := range allMatches { + if len(match) > 1 { + varName := match[1] + if !definedVars[varName] { + t.Logf("Warning: Variable {{%s}} found in template but not defined in variables list for %s", varName, tt.TemplateType) + } + } + } + }) + } +} + +// TestEmailTemplateService_TemplateVariableDescriptions tests that all variables have descriptions +func TestEmailTemplateService_TemplateVariableDescriptions(t *testing.T) { + service := &EmailTemplateService{} + templateTypes := service.GetAllTemplateTypes() + + for _, tt := range templateTypes { + t.Run(tt.TemplateType, func(t *testing.T) { + for _, varName := range tt.Variables { + if desc, ok := tt.Descriptions[varName]; !ok || desc == "" { + t.Errorf("Variable %s in %s has no description", varName, tt.TemplateType) + } + } + }) + } +} + +// TestEmailTemplateService_GermanTemplatesAreComplete tests that all German templates are fully translated +func TestEmailTemplateService_GermanTemplatesAreComplete(t *testing.T) { + service := &EmailTemplateService{} + + emailTypes := []string{ + models.EmailTypeWelcome, + models.EmailTypeEmailVerification, + models.EmailTypePasswordReset, + models.EmailTypePasswordChanged, + models.EmailType2FAEnabled, + models.EmailType2FADisabled, + models.EmailTypeNewDeviceLogin, + models.EmailTypeSuspiciousActivity, + models.EmailTypeAccountLocked, + models.EmailTypeAccountUnlocked, + models.EmailTypeDeletionRequested, + models.EmailTypeDeletionConfirmed, + models.EmailTypeDataExportReady, + models.EmailTypeEmailChanged, + models.EmailTypeNewVersionPublished, + models.EmailTypeConsentReminder, + models.EmailTypeConsentDeadlineWarning, + models.EmailTypeAccountSuspended, + } + + germanKeywords := []string{"Hallo", "freundlichen", "Grüßen", "BreakPilot", "Ihr"} + + for _, emailType := range emailTypes { + t.Run(emailType, func(t *testing.T) { + subject, bodyHTML, bodyText := service.GetDefaultTemplateContent(emailType, "de") + + // Check that German text is present + foundGerman := false + for _, keyword := range germanKeywords { + if strings.Contains(bodyHTML, keyword) || strings.Contains(bodyText, keyword) { + foundGerman = true + break + } + } + + if !foundGerman { + t.Errorf("Template %s does not appear to be in German", emailType) + } + + // Check that subject is not just the fallback + if subject == "No template" { + t.Errorf("Template %s has fallback subject instead of German subject", emailType) + } + }) + } +} + +// TestEmailTemplateService_HTMLStructure tests that HTML templates have valid structure +func TestEmailTemplateService_HTMLStructure(t *testing.T) { + service := &EmailTemplateService{} + + emailTypes := []string{ + models.EmailTypeWelcome, + models.EmailTypeEmailVerification, + models.EmailTypePasswordReset, + } + + for _, emailType := range emailTypes { + t.Run(emailType, func(t *testing.T) { + _, bodyHTML, _ := service.GetDefaultTemplateContent(emailType, "de") + + // Check for basic HTML structure + if !strings.Contains(bodyHTML, "") { + t.Errorf("Template %s missing DOCTYPE", emailType) + } + if !strings.Contains(bodyHTML, "") { + t.Errorf("Template %s missing tag", emailType) + } + if !strings.Contains(bodyHTML, "") { + t.Errorf("Template %s missing closing tag", emailType) + } + if !strings.Contains(bodyHTML, " tag", emailType) + } + if !strings.Contains(bodyHTML, "") { + t.Errorf("Template %s missing closing tag", emailType) + } + }) + } +} + +// BenchmarkReplaceVariables benchmarks variable replacement +func BenchmarkReplaceVariables(b *testing.B) { + template := "Hallo {{user_name}}, Ihr Link: {{reset_url}}, gültig bis {{expires_in}}" + variables := map[string]string{ + "user_name": "Max Mustermann", + "reset_url": "https://example.com/reset?token=abc123", + "expires_in": "24 Stunden", + } + + for i := 0; i < b.N; i++ { + replaceVariables(template, variables) + } +} + +// BenchmarkStripHTMLTags benchmarks HTML tag stripping +func BenchmarkStripHTMLTags(b *testing.B) { + html := "

              Title

              This is a test paragraph with links.

              " + + for i := 0; i < b.N; i++ { + stripHTMLTags(html) + } +} diff --git a/consent-service/internal/services/grade_service.go b/consent-service/internal/services/grade_service.go new file mode 100644 index 0000000..bd3ff9b --- /dev/null +++ b/consent-service/internal/services/grade_service.go @@ -0,0 +1,543 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/breakpilot/consent-service/internal/database" + "github.com/breakpilot/consent-service/internal/models" + "github.com/breakpilot/consent-service/internal/services/matrix" + + "github.com/google/uuid" +) + +// GradeService handles grade management and notifications +type GradeService struct { + db *database.DB + matrix *matrix.MatrixService +} + +// NewGradeService creates a new grade service +func NewGradeService(db *database.DB, matrixService *matrix.MatrixService) *GradeService { + return &GradeService{ + db: db, + matrix: matrixService, + } +} + +// ======================================== +// Grade CRUD +// ======================================== + +// CreateGrade creates a new grade for a student +func (s *GradeService) CreateGrade(ctx context.Context, req models.CreateGradeRequest, teacherID uuid.UUID) (*models.Grade, error) { + studentID, err := uuid.Parse(req.StudentID) + if err != nil { + return nil, fmt.Errorf("invalid student ID: %w", err) + } + + subjectID, err := uuid.Parse(req.SubjectID) + if err != nil { + return nil, fmt.Errorf("invalid subject ID: %w", err) + } + + schoolYearID, err := uuid.Parse(req.SchoolYearID) + if err != nil { + return nil, fmt.Errorf("invalid school year ID: %w", err) + } + + date, err := time.Parse("2006-01-02", req.Date) + if err != nil { + return nil, fmt.Errorf("invalid date format: %w", err) + } + + // Get default grade scale for the school + var gradeScaleID uuid.UUID + var schoolID uuid.UUID + err = s.db.Pool.QueryRow(ctx, ` + SELECT gs.id, gs.school_id + FROM grade_scales gs + JOIN students st ON st.school_id = gs.school_id + WHERE st.id = $1 AND gs.is_default = true`, studentID).Scan(&gradeScaleID, &schoolID) + if err != nil { + return nil, fmt.Errorf("failed to get grade scale: %w", err) + } + + weight := req.Weight + if weight == 0 { + weight = 1.0 + } + + grade := &models.Grade{ + ID: uuid.New(), + StudentID: studentID, + SubjectID: subjectID, + TeacherID: teacherID, + SchoolYearID: schoolYearID, + GradeScaleID: gradeScaleID, + Type: req.Type, + Value: req.Value, + Weight: weight, + Date: date, + Title: req.Title, + Description: req.Description, + IsVisible: true, + Semester: req.Semester, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + query := ` + INSERT INTO grades (id, student_id, subject_id, teacher_id, school_year_id, grade_scale_id, type, value, weight, date, title, description, is_visible, semester, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) + RETURNING id` + + err = s.db.Pool.QueryRow(ctx, query, + grade.ID, grade.StudentID, grade.SubjectID, grade.TeacherID, + grade.SchoolYearID, grade.GradeScaleID, grade.Type, grade.Value, + grade.Weight, grade.Date, grade.Title, grade.Description, + grade.IsVisible, grade.Semester, grade.CreatedAt, grade.UpdatedAt, + ).Scan(&grade.ID) + + if err != nil { + return nil, fmt.Errorf("failed to create grade: %w", err) + } + + // Send notification to parents if grade is visible + if grade.IsVisible { + go s.notifyParentsOfNewGrade(context.Background(), grade) + } + + return grade, nil +} + +// GetGrade retrieves a grade by ID +func (s *GradeService) GetGrade(ctx context.Context, gradeID uuid.UUID) (*models.Grade, error) { + query := ` + SELECT id, student_id, subject_id, teacher_id, school_year_id, grade_scale_id, type, value, weight, date, title, description, is_visible, semester, created_at, updated_at + FROM grades + WHERE id = $1` + + grade := &models.Grade{} + err := s.db.Pool.QueryRow(ctx, query, gradeID).Scan( + &grade.ID, &grade.StudentID, &grade.SubjectID, &grade.TeacherID, + &grade.SchoolYearID, &grade.GradeScaleID, &grade.Type, &grade.Value, + &grade.Weight, &grade.Date, &grade.Title, &grade.Description, + &grade.IsVisible, &grade.Semester, &grade.CreatedAt, &grade.UpdatedAt, + ) + + if err != nil { + return nil, fmt.Errorf("failed to get grade: %w", err) + } + + return grade, nil +} + +// UpdateGrade updates an existing grade +func (s *GradeService) UpdateGrade(ctx context.Context, gradeID uuid.UUID, value float64, title, description *string) error { + query := ` + UPDATE grades + SET value = $1, title = COALESCE($2, title), description = COALESCE($3, description), updated_at = NOW() + WHERE id = $4` + + result, err := s.db.Pool.Exec(ctx, query, value, title, description, gradeID) + if err != nil { + return fmt.Errorf("failed to update grade: %w", err) + } + + if result.RowsAffected() == 0 { + return fmt.Errorf("grade not found") + } + + return nil +} + +// DeleteGrade deletes a grade +func (s *GradeService) DeleteGrade(ctx context.Context, gradeID uuid.UUID) error { + result, err := s.db.Pool.Exec(ctx, `DELETE FROM grades WHERE id = $1`, gradeID) + if err != nil { + return fmt.Errorf("failed to delete grade: %w", err) + } + + if result.RowsAffected() == 0 { + return fmt.Errorf("grade not found") + } + + return nil +} + +// ======================================== +// Grade Queries +// ======================================== + +// GetStudentGrades gets all grades for a student in a school year +func (s *GradeService) GetStudentGrades(ctx context.Context, studentID, schoolYearID uuid.UUID) ([]models.Grade, error) { + query := ` + SELECT id, student_id, subject_id, teacher_id, school_year_id, grade_scale_id, type, value, weight, date, title, description, is_visible, semester, created_at, updated_at + FROM grades + WHERE student_id = $1 AND school_year_id = $2 AND is_visible = true + ORDER BY date DESC` + + rows, err := s.db.Pool.Query(ctx, query, studentID, schoolYearID) + if err != nil { + return nil, fmt.Errorf("failed to get student grades: %w", err) + } + defer rows.Close() + + var grades []models.Grade + for rows.Next() { + var grade models.Grade + err := rows.Scan( + &grade.ID, &grade.StudentID, &grade.SubjectID, &grade.TeacherID, + &grade.SchoolYearID, &grade.GradeScaleID, &grade.Type, &grade.Value, + &grade.Weight, &grade.Date, &grade.Title, &grade.Description, + &grade.IsVisible, &grade.Semester, &grade.CreatedAt, &grade.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan grade: %w", err) + } + grades = append(grades, grade) + } + + return grades, nil +} + +// GetStudentGradesBySubject gets grades for a student in a specific subject +func (s *GradeService) GetStudentGradesBySubject(ctx context.Context, studentID, subjectID, schoolYearID uuid.UUID, semester int) ([]models.Grade, error) { + query := ` + SELECT id, student_id, subject_id, teacher_id, school_year_id, grade_scale_id, type, value, weight, date, title, description, is_visible, semester, created_at, updated_at + FROM grades + WHERE student_id = $1 AND subject_id = $2 AND school_year_id = $3 AND semester = $4 AND is_visible = true + ORDER BY date DESC` + + rows, err := s.db.Pool.Query(ctx, query, studentID, subjectID, schoolYearID, semester) + if err != nil { + return nil, fmt.Errorf("failed to get grades by subject: %w", err) + } + defer rows.Close() + + var grades []models.Grade + for rows.Next() { + var grade models.Grade + err := rows.Scan( + &grade.ID, &grade.StudentID, &grade.SubjectID, &grade.TeacherID, + &grade.SchoolYearID, &grade.GradeScaleID, &grade.Type, &grade.Value, + &grade.Weight, &grade.Date, &grade.Title, &grade.Description, + &grade.IsVisible, &grade.Semester, &grade.CreatedAt, &grade.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan grade: %w", err) + } + grades = append(grades, grade) + } + + return grades, nil +} + +// GetClassGradesBySubject gets all grades for a class in a subject (Notenspiegel) +func (s *GradeService) GetClassGradesBySubject(ctx context.Context, classID, subjectID, schoolYearID uuid.UUID, semester int) ([]models.StudentGradeOverview, error) { + // Get all students in the class + studentsQuery := ` + SELECT id, first_name, last_name + FROM students + WHERE class_id = $1 AND is_active = true + ORDER BY last_name, first_name` + + rows, err := s.db.Pool.Query(ctx, studentsQuery, classID) + if err != nil { + return nil, fmt.Errorf("failed to get students: %w", err) + } + defer rows.Close() + + var students []struct { + ID uuid.UUID + FirstName string + LastName string + } + + for rows.Next() { + var student struct { + ID uuid.UUID + FirstName string + LastName string + } + if err := rows.Scan(&student.ID, &student.FirstName, &student.LastName); err != nil { + return nil, fmt.Errorf("failed to scan student: %w", err) + } + students = append(students, student) + } + + // Get subject info + var subject models.Subject + err = s.db.Pool.QueryRow(ctx, `SELECT id, school_id, name, short_name, color, is_active, created_at FROM subjects WHERE id = $1`, subjectID).Scan( + &subject.ID, &subject.SchoolID, &subject.Name, &subject.ShortName, &subject.Color, &subject.IsActive, &subject.CreatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to get subject: %w", err) + } + + var overviews []models.StudentGradeOverview + + for _, student := range students { + grades, err := s.GetStudentGradesBySubject(ctx, student.ID, subjectID, schoolYearID, semester) + if err != nil { + continue + } + + // Calculate averages + var totalWeight, weightedSum float64 + var oralWeight, oralSum float64 + var examWeight, examSum float64 + + for _, grade := range grades { + totalWeight += grade.Weight + weightedSum += grade.Value * grade.Weight + + if grade.Type == models.GradeTypeOral || grade.Type == models.GradeTypeParticipation { + oralWeight += grade.Weight + oralSum += grade.Value * grade.Weight + } else if grade.Type == models.GradeTypeExam || grade.Type == models.GradeTypeTest { + examWeight += grade.Weight + examSum += grade.Value * grade.Weight + } + } + + var average, oralAverage, examAverage float64 + if totalWeight > 0 { + average = weightedSum / totalWeight + } + if oralWeight > 0 { + oralAverage = oralSum / oralWeight + } + if examWeight > 0 { + examAverage = examSum / examWeight + } + + overview := models.StudentGradeOverview{ + Student: models.Student{ + ID: student.ID, + FirstName: student.FirstName, + LastName: student.LastName, + }, + Subject: subject, + Grades: grades, + Average: average, + OralAverage: oralAverage, + ExamAverage: examAverage, + Semester: semester, + } + + overviews = append(overviews, overview) + } + + return overviews, nil +} + +// ======================================== +// Grade Statistics +// ======================================== + +// GetStudentGradeAverage calculates the overall grade average for a student +func (s *GradeService) GetStudentGradeAverage(ctx context.Context, studentID, schoolYearID uuid.UUID, semester int) (float64, error) { + query := ` + SELECT COALESCE(SUM(value * weight) / NULLIF(SUM(weight), 0), 0) + FROM grades + WHERE student_id = $1 AND school_year_id = $2 AND semester = $3 AND is_visible = true` + + var average float64 + err := s.db.Pool.QueryRow(ctx, query, studentID, schoolYearID, semester).Scan(&average) + if err != nil { + return 0, fmt.Errorf("failed to calculate average: %w", err) + } + + return average, nil +} + +// GetSubjectGradeStatistics gets grade statistics for a subject in a class +func (s *GradeService) GetSubjectGradeStatistics(ctx context.Context, classID, subjectID, schoolYearID uuid.UUID, semester int) (map[string]interface{}, error) { + query := ` + SELECT + COUNT(DISTINCT g.student_id) as student_count, + AVG(g.value) as class_average, + MIN(g.value) as best_grade, + MAX(g.value) as worst_grade, + COUNT(*) as total_grades + FROM grades g + JOIN students s ON g.student_id = s.id + WHERE s.class_id = $1 AND g.subject_id = $2 AND g.school_year_id = $3 AND g.semester = $4 AND g.is_visible = true` + + var studentCount, totalGrades int + var classAverage, bestGrade, worstGrade float64 + + err := s.db.Pool.QueryRow(ctx, query, classID, subjectID, schoolYearID, semester).Scan( + &studentCount, &classAverage, &bestGrade, &worstGrade, &totalGrades, + ) + if err != nil { + return nil, fmt.Errorf("failed to get statistics: %w", err) + } + + // Grade distribution (for German grades 1-6) + distributionQuery := ` + SELECT + COUNT(CASE WHEN value >= 1 AND value < 1.5 THEN 1 END) as grade_1, + COUNT(CASE WHEN value >= 1.5 AND value < 2.5 THEN 1 END) as grade_2, + COUNT(CASE WHEN value >= 2.5 AND value < 3.5 THEN 1 END) as grade_3, + COUNT(CASE WHEN value >= 3.5 AND value < 4.5 THEN 1 END) as grade_4, + COUNT(CASE WHEN value >= 4.5 AND value < 5.5 THEN 1 END) as grade_5, + COUNT(CASE WHEN value >= 5.5 THEN 1 END) as grade_6 + FROM grades g + JOIN students s ON g.student_id = s.id + WHERE s.class_id = $1 AND g.subject_id = $2 AND g.school_year_id = $3 AND g.semester = $4 AND g.is_visible = true AND g.type IN ('exam', 'test')` + + var g1, g2, g3, g4, g5, g6 int + err = s.db.Pool.QueryRow(ctx, distributionQuery, classID, subjectID, schoolYearID, semester).Scan( + &g1, &g2, &g3, &g4, &g5, &g6, + ) + if err != nil { + // Non-fatal, continue without distribution + g1, g2, g3, g4, g5, g6 = 0, 0, 0, 0, 0, 0 + } + + return map[string]interface{}{ + "student_count": studentCount, + "class_average": classAverage, + "best_grade": bestGrade, + "worst_grade": worstGrade, + "total_grades": totalGrades, + "distribution": map[string]int{ + "1": g1, + "2": g2, + "3": g3, + "4": g4, + "5": g5, + "6": g6, + }, + }, nil +} + +// ======================================== +// Grade Comments +// ======================================== + +// AddGradeComment adds a comment to a grade +func (s *GradeService) AddGradeComment(ctx context.Context, gradeID, teacherID uuid.UUID, comment string, isPrivate bool) (*models.GradeComment, error) { + gradeComment := &models.GradeComment{ + ID: uuid.New(), + GradeID: gradeID, + TeacherID: teacherID, + Comment: comment, + IsPrivate: isPrivate, + CreatedAt: time.Now(), + } + + query := ` + INSERT INTO grade_comments (id, grade_id, teacher_id, comment, is_private, created_at) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id` + + err := s.db.Pool.QueryRow(ctx, query, + gradeComment.ID, gradeComment.GradeID, gradeComment.TeacherID, + gradeComment.Comment, gradeComment.IsPrivate, gradeComment.CreatedAt, + ).Scan(&gradeComment.ID) + + if err != nil { + return nil, fmt.Errorf("failed to add grade comment: %w", err) + } + + return gradeComment, nil +} + +// GetGradeComments gets comments for a grade +func (s *GradeService) GetGradeComments(ctx context.Context, gradeID uuid.UUID, includePrivate bool) ([]models.GradeComment, error) { + query := ` + SELECT id, grade_id, teacher_id, comment, is_private, created_at + FROM grade_comments + WHERE grade_id = $1` + + if !includePrivate { + query += ` AND is_private = false` + } + + query += ` ORDER BY created_at DESC` + + rows, err := s.db.Pool.Query(ctx, query, gradeID) + if err != nil { + return nil, fmt.Errorf("failed to get grade comments: %w", err) + } + defer rows.Close() + + var comments []models.GradeComment + for rows.Next() { + var comment models.GradeComment + err := rows.Scan( + &comment.ID, &comment.GradeID, &comment.TeacherID, + &comment.Comment, &comment.IsPrivate, &comment.CreatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan grade comment: %w", err) + } + comments = append(comments, comment) + } + + return comments, nil +} + +// ======================================== +// Parent Notifications +// ======================================== + +func (s *GradeService) notifyParentsOfNewGrade(ctx context.Context, grade *models.Grade) { + if s.matrix == nil { + return + } + + // Get student info and Matrix room + var studentFirstName, studentLastName, matrixDMRoom string + err := s.db.Pool.QueryRow(ctx, ` + SELECT first_name, last_name, matrix_dm_room + FROM students + WHERE id = $1`, grade.StudentID).Scan(&studentFirstName, &studentLastName, &matrixDMRoom) + if err != nil || matrixDMRoom == "" { + return + } + + // Get subject name + var subjectName string + err = s.db.Pool.QueryRow(ctx, `SELECT name FROM subjects WHERE id = $1`, grade.SubjectID).Scan(&subjectName) + if err != nil { + return + } + + studentName := studentFirstName + " " + studentLastName + gradeType := s.getGradeTypeDisplayName(grade.Type) + + // Send Matrix notification + err = s.matrix.SendGradeNotification(ctx, matrixDMRoom, studentName, subjectName, gradeType, grade.Value) + if err != nil { + fmt.Printf("Failed to send grade notification: %v\n", err) + } +} + +func (s *GradeService) getGradeTypeDisplayName(gradeType string) string { + switch gradeType { + case models.GradeTypeExam: + return "Klassenarbeit" + case models.GradeTypeTest: + return "Test" + case models.GradeTypeOral: + return "Mündliche Note" + case models.GradeTypeHomework: + return "Hausaufgabe" + case models.GradeTypeProject: + return "Projekt" + case models.GradeTypeParticipation: + return "Mitarbeit" + case models.GradeTypeSemester: + return "Halbjahreszeugnis" + case models.GradeTypeFinal: + return "Zeugnisnote" + default: + return gradeType + } +} diff --git a/consent-service/internal/services/grade_service_test.go b/consent-service/internal/services/grade_service_test.go new file mode 100644 index 0000000..e796b93 --- /dev/null +++ b/consent-service/internal/services/grade_service_test.go @@ -0,0 +1,532 @@ +package services + +import ( + "testing" + "time" + + "github.com/breakpilot/consent-service/internal/models" + + "github.com/google/uuid" +) + +// TestValidateGrade tests grade validation +func TestValidateGrade(t *testing.T) { + schoolYearID := uuid.New() + gradeScaleID := uuid.New() + + tests := []struct { + name string + grade models.Grade + expectValid bool + }{ + { + name: "valid grade 1", + grade: models.Grade{ + StudentID: uuid.New(), + SubjectID: uuid.New(), + TeacherID: uuid.New(), + SchoolYearID: schoolYearID, + GradeScaleID: gradeScaleID, + Value: 1.0, + Type: models.GradeTypeExam, + Weight: 1.0, + Date: time.Now(), + Semester: 1, + }, + expectValid: true, + }, + { + name: "valid grade 6", + grade: models.Grade{ + StudentID: uuid.New(), + SubjectID: uuid.New(), + TeacherID: uuid.New(), + SchoolYearID: schoolYearID, + GradeScaleID: gradeScaleID, + Value: 6.0, + Type: models.GradeTypeOral, + Weight: 0.5, + Date: time.Now(), + Semester: 2, + }, + expectValid: true, + }, + { + name: "valid grade with plus (1.3)", + grade: models.Grade{ + StudentID: uuid.New(), + SubjectID: uuid.New(), + TeacherID: uuid.New(), + SchoolYearID: schoolYearID, + GradeScaleID: gradeScaleID, + Value: 1.3, + Type: models.GradeTypeTest, + Weight: 0.25, + Date: time.Now(), + Semester: 1, + }, + expectValid: true, + }, + { + name: "invalid grade 0", + grade: models.Grade{ + StudentID: uuid.New(), + SubjectID: uuid.New(), + TeacherID: uuid.New(), + SchoolYearID: schoolYearID, + GradeScaleID: gradeScaleID, + Value: 0.0, + Type: models.GradeTypeExam, + Weight: 1.0, + Date: time.Now(), + Semester: 1, + }, + expectValid: false, + }, + { + name: "invalid grade 7", + grade: models.Grade{ + StudentID: uuid.New(), + SubjectID: uuid.New(), + TeacherID: uuid.New(), + SchoolYearID: schoolYearID, + GradeScaleID: gradeScaleID, + Value: 7.0, + Type: models.GradeTypeExam, + Weight: 1.0, + Date: time.Now(), + Semester: 1, + }, + expectValid: false, + }, + { + name: "missing student ID", + grade: models.Grade{ + StudentID: uuid.Nil, + SubjectID: uuid.New(), + TeacherID: uuid.New(), + SchoolYearID: schoolYearID, + GradeScaleID: gradeScaleID, + Value: 2.0, + Type: models.GradeTypeExam, + Weight: 1.0, + Date: time.Now(), + Semester: 1, + }, + expectValid: false, + }, + { + name: "invalid weight negative", + grade: models.Grade{ + StudentID: uuid.New(), + SubjectID: uuid.New(), + TeacherID: uuid.New(), + SchoolYearID: schoolYearID, + GradeScaleID: gradeScaleID, + Value: 2.0, + Type: models.GradeTypeExam, + Weight: -0.5, + Date: time.Now(), + Semester: 1, + }, + expectValid: false, + }, + { + name: "invalid semester 0", + grade: models.Grade{ + StudentID: uuid.New(), + SubjectID: uuid.New(), + TeacherID: uuid.New(), + SchoolYearID: schoolYearID, + GradeScaleID: gradeScaleID, + Value: 2.0, + Type: models.GradeTypeExam, + Weight: 1.0, + Date: time.Now(), + Semester: 0, + }, + expectValid: false, + }, + { + name: "invalid semester 3", + grade: models.Grade{ + StudentID: uuid.New(), + SubjectID: uuid.New(), + TeacherID: uuid.New(), + SchoolYearID: schoolYearID, + GradeScaleID: gradeScaleID, + Value: 2.0, + Type: models.GradeTypeExam, + Weight: 1.0, + Date: time.Now(), + Semester: 3, + }, + expectValid: false, + }, + { + name: "invalid type", + grade: models.Grade{ + StudentID: uuid.New(), + SubjectID: uuid.New(), + TeacherID: uuid.New(), + SchoolYearID: schoolYearID, + GradeScaleID: gradeScaleID, + Value: 2.0, + Type: "invalid_type", + Weight: 1.0, + Date: time.Now(), + Semester: 1, + }, + expectValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isValid := validateGrade(tt.grade) + if isValid != tt.expectValid { + t.Errorf("expected valid=%v, got valid=%v", tt.expectValid, isValid) + } + }) + } +} + +// validateGrade validates a grade +func validateGrade(grade models.Grade) bool { + if grade.StudentID == uuid.Nil { + return false + } + if grade.SubjectID == uuid.Nil { + return false + } + if grade.TeacherID == uuid.Nil { + return false + } + // German grading scale: 1 (best) to 6 (worst) + if grade.Value < 1.0 || grade.Value > 6.0 { + return false + } + if grade.Weight < 0 { + return false + } + if grade.Semester < 1 || grade.Semester > 2 { + return false + } + + // Validate type + validTypes := map[string]bool{ + models.GradeTypeExam: true, + models.GradeTypeTest: true, + models.GradeTypeOral: true, + models.GradeTypeHomework: true, + models.GradeTypeProject: true, + models.GradeTypeParticipation: true, + models.GradeTypeSemester: true, + models.GradeTypeFinal: true, + } + + if !validTypes[grade.Type] { + return false + } + + return true +} + +// TestCalculateWeightedAverage tests weighted average calculation +func TestCalculateWeightedAverage(t *testing.T) { + tests := []struct { + name string + grades []models.Grade + expectedAverage float64 + }{ + { + name: "simple average equal weights", + grades: []models.Grade{ + {Value: 1.0, Weight: 1.0}, + {Value: 2.0, Weight: 1.0}, + {Value: 3.0, Weight: 1.0}, + }, + expectedAverage: 2.0, + }, + { + name: "weighted average different weights", + grades: []models.Grade{ + {Value: 1.0, Weight: 2.0}, // Exam counts double + {Value: 3.0, Weight: 1.0}, + }, + // (1*2 + 3*1) / (2+1) = 5/3 = 1.67 + expectedAverage: 1.67, + }, + { + name: "single grade", + grades: []models.Grade{ + {Value: 2.5, Weight: 1.0}, + }, + expectedAverage: 2.5, + }, + { + name: "empty grades", + grades: []models.Grade{}, + expectedAverage: 0.0, + }, + { + name: "all same grades", + grades: []models.Grade{ + {Value: 2.0, Weight: 1.0}, + {Value: 2.0, Weight: 1.0}, + {Value: 2.0, Weight: 1.0}, + }, + expectedAverage: 2.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + avg := calculateWeightedAverage(tt.grades) + // Allow small floating point differences + if avg < tt.expectedAverage-0.01 || avg > tt.expectedAverage+0.01 { + t.Errorf("expected average=%.2f, got average=%.2f", tt.expectedAverage, avg) + } + }) + } +} + +// calculateWeightedAverage calculates weighted average of grades +func calculateWeightedAverage(grades []models.Grade) float64 { + if len(grades) == 0 { + return 0.0 + } + + var weightedSum float64 + var totalWeight float64 + + for _, g := range grades { + weightedSum += g.Value * g.Weight + totalWeight += g.Weight + } + + if totalWeight == 0 { + return 0.0 + } + + avg := weightedSum / totalWeight + // Round to 2 decimal places + return float64(int(avg*100)) / 100 +} + +// TestGradeDistribution tests grade distribution calculation +func TestGradeDistribution(t *testing.T) { + tests := []struct { + name string + grades []models.Grade + expectedDist map[int]int + }{ + { + name: "varied distribution", + grades: []models.Grade{ + {Value: 1.0}, {Value: 1.3}, + {Value: 2.0}, {Value: 2.0}, {Value: 2.7}, + {Value: 3.0}, {Value: 3.0}, {Value: 3.0}, + {Value: 4.0}, {Value: 4.3}, + {Value: 5.0}, + }, + expectedDist: map[int]int{ + 1: 2, // 1.0, 1.3 (rounded: 1, 1) + 2: 2, // 2.0, 2.0 (rounded: 2, 2) + 3: 4, // 2.7, 3.0, 3.0, 3.0 (rounded: 3, 3, 3, 3) + 4: 2, // 4.0, 4.3 (rounded: 4, 4) + 5: 1, // 5.0 (rounded: 5) + 6: 0, + }, + }, + { + name: "empty grades", + grades: []models.Grade{}, + expectedDist: map[int]int{1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0}, + }, + { + name: "all same grade", + grades: []models.Grade{ + {Value: 2.0}, + {Value: 2.0}, + {Value: 2.0}, + }, + expectedDist: map[int]int{1: 0, 2: 3, 3: 0, 4: 0, 5: 0, 6: 0}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dist := calculateGradeDistribution(tt.grades) + for grade, count := range tt.expectedDist { + if dist[grade] != count { + t.Errorf("grade %d: expected count=%d, got count=%d", grade, count, dist[grade]) + } + } + }) + } +} + +// calculateGradeDistribution calculates how many grades fall into each category +func calculateGradeDistribution(grades []models.Grade) map[int]int { + dist := map[int]int{1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0} + + for _, g := range grades { + // Round to nearest integer for distribution + roundedGrade := int(g.Value + 0.5) + if roundedGrade < 1 { + roundedGrade = 1 + } + if roundedGrade > 6 { + roundedGrade = 6 + } + dist[roundedGrade]++ + } + + return dist +} + +// TestGradePointConversion tests conversion between grades and points (Oberstufe) +func TestGradePointConversion(t *testing.T) { + tests := []struct { + name string + grade float64 + expectedPoints int + }{ + {"grade 1.0 = 15 points", 1.0, 15}, + {"grade 1.3 = 14 points", 1.3, 14}, + {"grade 1.7 = 13 points", 1.7, 13}, + {"grade 2.0 = 12 points", 2.0, 12}, + {"grade 2.3 = 11 points", 2.3, 11}, + {"grade 2.7 = 10 points", 2.7, 10}, + {"grade 3.0 = 9 points", 3.0, 9}, + {"grade 3.3 = 8 points", 3.3, 8}, + {"grade 3.7 = 7 points", 3.7, 7}, + {"grade 4.0 = 6 points", 4.0, 6}, + {"grade 4.3 = 5 points", 4.3, 5}, + {"grade 4.7 = 4 points", 4.7, 4}, + {"grade 5.0 = 3 points", 5.0, 3}, + {"grade 5.3 = 2 points", 5.3, 2}, + {"grade 5.7 = 1 point", 5.7, 1}, + {"grade 6.0 = 0 points", 6.0, 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + points := gradeToPoints(tt.grade) + if points != tt.expectedPoints { + t.Errorf("expected points=%d, got points=%d", tt.expectedPoints, points) + } + }) + } +} + +// gradeToPoints converts German grade (1-6) to Oberstufe points (0-15) +func gradeToPoints(grade float64) int { + // Mapping based on German school system + if grade <= 1.0 { + return 15 + } else if grade <= 1.3 { + return 14 + } else if grade <= 1.7 { + return 13 + } else if grade <= 2.0 { + return 12 + } else if grade <= 2.3 { + return 11 + } else if grade <= 2.7 { + return 10 + } else if grade <= 3.0 { + return 9 + } else if grade <= 3.3 { + return 8 + } else if grade <= 3.7 { + return 7 + } else if grade <= 4.0 { + return 6 + } else if grade <= 4.3 { + return 5 + } else if grade <= 4.7 { + return 4 + } else if grade <= 5.0 { + return 3 + } else if grade <= 5.3 { + return 2 + } else if grade <= 5.7 { + return 1 + } + return 0 +} + +// TestFindBestAndWorstGrade tests finding best and worst grades +func TestFindBestAndWorstGrade(t *testing.T) { + tests := []struct { + name string + grades []models.Grade + expectedBest float64 + expectedWorst float64 + }{ + { + name: "varied grades", + grades: []models.Grade{ + {Value: 2.0}, + {Value: 1.0}, + {Value: 3.0}, + {Value: 5.0}, + {Value: 2.0}, + }, + expectedBest: 1.0, + expectedWorst: 5.0, + }, + { + name: "all same", + grades: []models.Grade{ + {Value: 2.0}, + {Value: 2.0}, + }, + expectedBest: 2.0, + expectedWorst: 2.0, + }, + { + name: "single grade", + grades: []models.Grade{ + {Value: 3.0}, + }, + expectedBest: 3.0, + expectedWorst: 3.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + best, worst := findBestAndWorstGrade(tt.grades) + if best != tt.expectedBest { + t.Errorf("expected best=%.1f, got best=%.1f", tt.expectedBest, best) + } + if worst != tt.expectedWorst { + t.Errorf("expected worst=%.1f, got worst=%.1f", tt.expectedWorst, worst) + } + }) + } +} + +// findBestAndWorstGrade finds the best (lowest) and worst (highest) grade +func findBestAndWorstGrade(grades []models.Grade) (best, worst float64) { + if len(grades) == 0 { + return 0, 0 + } + + best = grades[0].Value + worst = grades[0].Value + + for _, g := range grades[1:] { + if g.Value < best { + best = g.Value + } + if g.Value > worst { + worst = g.Value + } + } + + return best, worst +} diff --git a/consent-service/internal/services/jitsi/game_meetings.go b/consent-service/internal/services/jitsi/game_meetings.go new file mode 100644 index 0000000..8277efd --- /dev/null +++ b/consent-service/internal/services/jitsi/game_meetings.go @@ -0,0 +1,340 @@ +package jitsi + +import ( + "context" + "fmt" + "time" +) + +// ======================================== +// Breakpilot Drive Game Meeting Types +// ======================================== + +// GameMeetingMode represents different game video call modes +type GameMeetingMode string + +const ( + GameMeetingCoop GameMeetingMode = "coop" // Co-Op voice/video + GameMeetingChallenge GameMeetingMode = "challenge" // 1v1 face-off + GameMeetingClassRace GameMeetingMode = "class_race" // Teacher supervises + GameMeetingTeamHuddle GameMeetingMode = "team_huddle" // Quick team sync +) + +// GameMeetingConfig holds configuration for game video meetings +type GameMeetingConfig struct { + SessionID string `json:"session_id"` + Mode GameMeetingMode `json:"mode"` + HostID string `json:"host_id"` + HostName string `json:"host_name"` + Players []GamePlayer `json:"players"` + EnableVideo bool `json:"enable_video"` + EnableVoice bool `json:"enable_voice"` + TeacherID string `json:"teacher_id,omitempty"` + TeacherName string `json:"teacher_name,omitempty"` + ClassName string `json:"class_name,omitempty"` +} + +// GamePlayer represents a player in the meeting +type GamePlayer struct { + ID string `json:"id"` + Name string `json:"name"` + IsModerator bool `json:"is_moderator,omitempty"` +} + +// GameMeetingLink extends MeetingLink with game-specific info +type GameMeetingLink struct { + *MeetingLink + SessionID string `json:"session_id"` + Mode GameMeetingMode `json:"mode"` + Players []string `json:"players"` +} + +// ======================================== +// Game Meeting Creation +// ======================================== + +// CreateCoopMeeting creates a video call for Co-Op gameplay (2-4 players) +func (s *JitsiService) CreateCoopMeeting(ctx context.Context, config GameMeetingConfig) (*GameMeetingLink, error) { + roomName := fmt.Sprintf("bp-coop-%s", config.SessionID[:8]) + + meeting := Meeting{ + RoomName: roomName, + DisplayName: config.HostName, + Subject: "Breakpilot Drive - Co-Op Session", + Moderator: true, + Config: &MeetingConfig{ + StartWithAudioMuted: !config.EnableVoice, + StartWithVideoMuted: !config.EnableVideo, + RequireDisplayName: true, + EnableLobby: false, // Direct join for co-op + DisableDeepLinking: true, + }, + } + + link, err := s.CreateMeetingLink(ctx, meeting) + if err != nil { + return nil, fmt.Errorf("failed to create co-op meeting: %w", err) + } + + playerIDs := make([]string, len(config.Players)) + for i, p := range config.Players { + playerIDs[i] = p.ID + } + + return &GameMeetingLink{ + MeetingLink: link, + SessionID: config.SessionID, + Mode: GameMeetingCoop, + Players: playerIDs, + }, nil +} + +// CreateChallengeMeeting creates a 1v1 video call for challenges +func (s *JitsiService) CreateChallengeMeeting(ctx context.Context, config GameMeetingConfig, challengerName string, opponentName string) (*GameMeetingLink, error) { + roomName := fmt.Sprintf("bp-challenge-%s", config.SessionID[:8]) + + meeting := Meeting{ + RoomName: roomName, + DisplayName: challengerName, + Subject: fmt.Sprintf("Challenge: %s vs %s", challengerName, opponentName), + Moderator: false, // Both players are equal + Config: &MeetingConfig{ + StartWithAudioMuted: false, // Voice enabled for trash talk + StartWithVideoMuted: !config.EnableVideo, + RequireDisplayName: true, + EnableLobby: false, + DisableDeepLinking: true, + }, + } + + link, err := s.CreateMeetingLink(ctx, meeting) + if err != nil { + return nil, fmt.Errorf("failed to create challenge meeting: %w", err) + } + + return &GameMeetingLink{ + MeetingLink: link, + SessionID: config.SessionID, + Mode: GameMeetingChallenge, + Players: []string{config.HostID}, + }, nil +} + +// CreateClassRaceMeeting creates a video call for teacher-supervised class races +func (s *JitsiService) CreateClassRaceMeeting(ctx context.Context, config GameMeetingConfig) (*GameMeetingLink, error) { + roomName := fmt.Sprintf("bp-klasse-%s-%s", + s.sanitizeRoomName(config.ClassName), + time.Now().Format("150405")) + + // Teacher is moderator + meeting := Meeting{ + RoomName: roomName, + DisplayName: config.TeacherName, + Subject: fmt.Sprintf("Klassenrennen: %s", config.ClassName), + Moderator: true, + Config: &MeetingConfig{ + StartWithAudioMuted: true, // Students muted by default + StartWithVideoMuted: true, // Video off for performance + RequireDisplayName: true, + EnableLobby: true, // Teacher admits students + EnableRecording: false, // No recording for minors + DisableDeepLinking: true, + }, + Features: &MeetingFeatures{ + Recording: false, + Transcription: false, + }, + } + + link, err := s.CreateMeetingLink(ctx, meeting) + if err != nil { + return nil, fmt.Errorf("failed to create class race meeting: %w", err) + } + + playerIDs := make([]string, len(config.Players)) + for i, p := range config.Players { + playerIDs[i] = p.ID + } + + return &GameMeetingLink{ + MeetingLink: link, + SessionID: config.SessionID, + Mode: GameMeetingClassRace, + Players: playerIDs, + }, nil +} + +// CreateTeamHuddleMeeting creates a quick sync meeting for teams +func (s *JitsiService) CreateTeamHuddleMeeting(ctx context.Context, config GameMeetingConfig, teamName string) (*GameMeetingLink, error) { + roomName := fmt.Sprintf("bp-team-%s-%s", + s.sanitizeRoomName(teamName), + config.SessionID[:8]) + + meeting := Meeting{ + RoomName: roomName, + DisplayName: config.HostName, + Subject: fmt.Sprintf("Team %s - Huddle", teamName), + Duration: 5, // Short 5-minute huddles + Moderator: true, + Config: &MeetingConfig{ + StartWithAudioMuted: false, // Voice on for quick sync + StartWithVideoMuted: true, // Video optional + RequireDisplayName: true, + EnableLobby: false, + DisableDeepLinking: true, + }, + } + + link, err := s.CreateMeetingLink(ctx, meeting) + if err != nil { + return nil, fmt.Errorf("failed to create team huddle: %w", err) + } + + playerIDs := make([]string, len(config.Players)) + for i, p := range config.Players { + playerIDs[i] = p.ID + } + + return &GameMeetingLink{ + MeetingLink: link, + SessionID: config.SessionID, + Mode: GameMeetingTeamHuddle, + Players: playerIDs, + }, nil +} + +// ======================================== +// Game-Specific Meeting Configurations +// ======================================== + +// GetGameEmbedConfig returns optimized config for embedding in Unity WebGL +func (s *JitsiService) GetGameEmbedConfig(enableVideo bool, enableVoice bool) *MeetingConfig { + return &MeetingConfig{ + StartWithAudioMuted: !enableVoice, + StartWithVideoMuted: !enableVideo, + RequireDisplayName: true, + EnableLobby: false, + DisableDeepLinking: true, // Important for iframe embedding + } +} + +// BuildGameEmbedURL creates a URL optimized for Unity WebGL embedding +func (s *JitsiService) BuildGameEmbedURL(roomName string, playerName string, enableVideo bool, enableVoice bool) string { + config := s.GetGameEmbedConfig(enableVideo, enableVoice) + return s.BuildEmbedURL(roomName, playerName, config) +} + +// BuildUnityIFrameParams returns parameters for Unity's WebGL iframe +func (s *JitsiService) BuildUnityIFrameParams(link *GameMeetingLink, playerName string) map[string]interface{} { + return map[string]interface{}{ + "domain": s.extractDomain(), + "roomName": link.RoomName, + "displayName": playerName, + "jwt": link.JWT, + "configOverwrite": map[string]interface{}{ + "startWithAudioMuted": false, + "startWithVideoMuted": true, + "disableDeepLinking": true, + "prejoinPageEnabled": false, + "enableWelcomePage": false, + "enableClosePage": false, + "disableInviteFunctions": true, + }, + "interfaceConfigOverwrite": map[string]interface{}{ + "DISABLE_JOIN_LEAVE_NOTIFICATIONS": true, + "MOBILE_APP_PROMO": false, + "SHOW_CHROME_EXTENSION_BANNER": false, + "TOOLBAR_BUTTONS": []string{ + "microphone", "camera", "hangup", "chat", + }, + }, + } +} + +// ======================================== +// Spectator Mode (for teachers/parents) +// ======================================== + +// CreateSpectatorLink creates a view-only link for observers +func (s *JitsiService) CreateSpectatorLink(ctx context.Context, roomName string, spectatorName string) (*MeetingLink, error) { + meeting := Meeting{ + RoomName: roomName, + DisplayName: fmt.Sprintf("[Zuschauer] %s", spectatorName), + Moderator: false, + Config: &MeetingConfig{ + StartWithAudioMuted: true, + StartWithVideoMuted: true, + DisableDeepLinking: true, + }, + } + + return s.CreateMeetingLink(ctx, meeting) +} + +// ======================================== +// Helper Functions +// ======================================== + +// extractDomain extracts the domain from baseURL +func (s *JitsiService) extractDomain() string { + // Remove protocol prefix + domain := s.baseURL + if len(domain) > 8 && domain[:8] == "https://" { + domain = domain[8:] + } else if len(domain) > 7 && domain[:7] == "http://" { + domain = domain[7:] + } + // Remove port if present + for i, c := range domain { + if c == ':' || c == '/' { + domain = domain[:i] + break + } + } + return domain +} + +// ValidateGameMeetingConfig validates configuration before creating meeting +func ValidateGameMeetingConfig(config GameMeetingConfig) error { + if config.SessionID == "" { + return fmt.Errorf("session_id is required") + } + + if config.Mode == "" { + return fmt.Errorf("mode is required") + } + + if config.HostID == "" { + return fmt.Errorf("host_id is required") + } + + if config.HostName == "" { + return fmt.Errorf("host_name is required") + } + + switch config.Mode { + case GameMeetingCoop: + if len(config.Players) < 2 || len(config.Players) > 4 { + return fmt.Errorf("co-op mode requires 2-4 players") + } + case GameMeetingChallenge: + if len(config.Players) != 2 { + return fmt.Errorf("challenge mode requires exactly 2 players") + } + case GameMeetingClassRace: + if config.TeacherID == "" || config.TeacherName == "" { + return fmt.Errorf("class race mode requires teacher info") + } + if config.ClassName == "" { + return fmt.Errorf("class race mode requires class name") + } + case GameMeetingTeamHuddle: + if len(config.Players) < 2 { + return fmt.Errorf("team huddle requires at least 2 players") + } + default: + return fmt.Errorf("unknown game meeting mode: %s", config.Mode) + } + + return nil +} diff --git a/consent-service/internal/services/jitsi/jitsi_service.go b/consent-service/internal/services/jitsi/jitsi_service.go new file mode 100644 index 0000000..e82fea9 --- /dev/null +++ b/consent-service/internal/services/jitsi/jitsi_service.go @@ -0,0 +1,566 @@ +package jitsi + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/google/uuid" +) + +// JitsiService handles Jitsi Meet integration for video conferences +type JitsiService struct { + baseURL string + appID string + appSecret string + httpClient *http.Client +} + +// Config holds Jitsi service configuration +type Config struct { + BaseURL string // e.g., "http://localhost:8443" + AppID string // Application ID for JWT (optional) + AppSecret string // Secret for JWT signing (optional) +} + +// NewJitsiService creates a new Jitsi service instance +func NewJitsiService(cfg Config) *JitsiService { + return &JitsiService{ + baseURL: strings.TrimSuffix(cfg.BaseURL, "/"), + appID: cfg.AppID, + appSecret: cfg.AppSecret, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// ======================================== +// Types +// ======================================== + +// Meeting represents a Jitsi meeting configuration +type Meeting struct { + RoomName string `json:"room_name"` + DisplayName string `json:"display_name,omitempty"` + Email string `json:"email,omitempty"` + Avatar string `json:"avatar,omitempty"` + Subject string `json:"subject,omitempty"` + Password string `json:"password,omitempty"` + StartTime *time.Time `json:"start_time,omitempty"` + Duration int `json:"duration,omitempty"` // in minutes + Config *MeetingConfig `json:"config,omitempty"` + Moderator bool `json:"moderator,omitempty"` + Features *MeetingFeatures `json:"features,omitempty"` +} + +// MeetingConfig holds Jitsi room configuration options +type MeetingConfig struct { + StartWithAudioMuted bool `json:"start_with_audio_muted,omitempty"` + StartWithVideoMuted bool `json:"start_with_video_muted,omitempty"` + DisableDeepLinking bool `json:"disable_deep_linking,omitempty"` + RequireDisplayName bool `json:"require_display_name,omitempty"` + EnableLobby bool `json:"enable_lobby,omitempty"` + EnableRecording bool `json:"enable_recording,omitempty"` +} + +// MeetingFeatures controls which features are enabled +type MeetingFeatures struct { + Livestreaming bool `json:"livestreaming,omitempty"` + Recording bool `json:"recording,omitempty"` + Transcription bool `json:"transcription,omitempty"` + OutboundCall bool `json:"outbound_call,omitempty"` +} + +// MeetingLink contains the generated meeting URL and metadata +type MeetingLink struct { + URL string `json:"url"` + RoomName string `json:"room_name"` + JoinURL string `json:"join_url"` + ModeratorURL string `json:"moderator_url,omitempty"` + Password string `json:"password,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + JWT string `json:"jwt,omitempty"` +} + +// JWTClaims represents the JWT payload for Jitsi +type JWTClaims struct { + Audience string `json:"aud,omitempty"` + Issuer string `json:"iss,omitempty"` + Subject string `json:"sub,omitempty"` + Room string `json:"room,omitempty"` + ExpiresAt int64 `json:"exp,omitempty"` + NotBefore int64 `json:"nbf,omitempty"` + Context *JWTContext `json:"context,omitempty"` + Moderator bool `json:"moderator,omitempty"` + Features *JWTFeatures `json:"features,omitempty"` +} + +// JWTContext contains user information for JWT +type JWTContext struct { + User *JWTUser `json:"user,omitempty"` + Group string `json:"group,omitempty"` + Callee *JWTCallee `json:"callee,omitempty"` +} + +// JWTUser represents user info in JWT +type JWTUser struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + Avatar string `json:"avatar,omitempty"` + Moderator bool `json:"moderator,omitempty"` + HiddenFromRecorder bool `json:"hidden-from-recorder,omitempty"` +} + +// JWTCallee represents callee info (for 1:1 calls) +type JWTCallee struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Avatar string `json:"avatar,omitempty"` +} + +// JWTFeatures controls JWT-based feature access +type JWTFeatures struct { + Livestreaming string `json:"livestreaming,omitempty"` // "true" or "false" + Recording string `json:"recording,omitempty"` + Transcription string `json:"transcription,omitempty"` + OutboundCall string `json:"outbound-call,omitempty"` +} + +// ScheduledMeeting represents a scheduled training/meeting +type ScheduledMeeting struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + RoomName string `json:"room_name"` + HostID string `json:"host_id"` + HostName string `json:"host_name"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + Duration int `json:"duration"` // in minutes + Password string `json:"password,omitempty"` + MaxParticipants int `json:"max_participants,omitempty"` + Features *MeetingFeatures `json:"features,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ======================================== +// Meeting Management +// ======================================== + +// CreateMeetingLink generates a meeting URL with optional JWT authentication +func (s *JitsiService) CreateMeetingLink(ctx context.Context, meeting Meeting) (*MeetingLink, error) { + // Generate room name if not provided + roomName := meeting.RoomName + if roomName == "" { + roomName = s.generateRoomName() + } + + // Sanitize room name (Jitsi-compatible) + roomName = s.sanitizeRoomName(roomName) + + link := &MeetingLink{ + RoomName: roomName, + URL: fmt.Sprintf("%s/%s", s.baseURL, roomName), + JoinURL: fmt.Sprintf("%s/%s", s.baseURL, roomName), + Password: meeting.Password, + } + + // Generate JWT if authentication is configured + if s.appSecret != "" { + jwt, expiresAt, err := s.generateJWT(meeting, roomName) + if err != nil { + return nil, fmt.Errorf("failed to generate JWT: %w", err) + } + link.JWT = jwt + link.ExpiresAt = expiresAt + link.JoinURL = fmt.Sprintf("%s/%s?jwt=%s", s.baseURL, roomName, jwt) + + // Generate moderator URL if user is moderator + if meeting.Moderator { + link.ModeratorURL = link.JoinURL + } + } + + // Add config parameters to URL + if meeting.Config != nil { + params := s.buildConfigParams(meeting.Config) + if params != "" { + separator := "?" + if strings.Contains(link.JoinURL, "?") { + separator = "&" + } + link.JoinURL += separator + params + } + } + + return link, nil +} + +// CreateTrainingSession creates a meeting link optimized for training sessions +func (s *JitsiService) CreateTrainingSession(ctx context.Context, title string, hostName string, hostEmail string, duration int) (*MeetingLink, error) { + meeting := Meeting{ + RoomName: s.generateTrainingRoomName(title), + DisplayName: hostName, + Email: hostEmail, + Subject: title, + Duration: duration, + Moderator: true, + Config: &MeetingConfig{ + StartWithAudioMuted: true, // Participants start muted + StartWithVideoMuted: false, // Video on for training + RequireDisplayName: true, // Know who's attending + EnableLobby: true, // Waiting room + EnableRecording: true, // Allow recording + }, + Features: &MeetingFeatures{ + Recording: true, + Transcription: false, + }, + } + + return s.CreateMeetingLink(ctx, meeting) +} + +// CreateQuickMeeting creates a simple ad-hoc meeting +func (s *JitsiService) CreateQuickMeeting(ctx context.Context, displayName string) (*MeetingLink, error) { + meeting := Meeting{ + DisplayName: displayName, + Config: &MeetingConfig{ + StartWithAudioMuted: false, + StartWithVideoMuted: false, + }, + } + + return s.CreateMeetingLink(ctx, meeting) +} + +// CreateParentTeacherMeeting creates a meeting for parent-teacher conferences +func (s *JitsiService) CreateParentTeacherMeeting(ctx context.Context, teacherName string, parentName string, studentName string, scheduledTime time.Time) (*MeetingLink, error) { + roomName := fmt.Sprintf("elterngespraech-%s-%s", + s.sanitizeRoomName(studentName), + scheduledTime.Format("20060102-1504")) + + meeting := Meeting{ + RoomName: roomName, + DisplayName: teacherName, + Subject: fmt.Sprintf("Elterngespräch: %s", studentName), + StartTime: &scheduledTime, + Duration: 30, // 30 minutes default + Moderator: true, + Password: s.generatePassword(), + Config: &MeetingConfig{ + StartWithAudioMuted: false, + StartWithVideoMuted: false, + RequireDisplayName: true, + EnableLobby: true, // Teacher admits parent + DisableDeepLinking: true, + }, + } + + return s.CreateMeetingLink(ctx, meeting) +} + +// CreateClassMeeting creates a meeting for an entire class +func (s *JitsiService) CreateClassMeeting(ctx context.Context, className string, teacherName string, subject string) (*MeetingLink, error) { + roomName := fmt.Sprintf("klasse-%s-%s", + s.sanitizeRoomName(className), + time.Now().Format("20060102")) + + meeting := Meeting{ + RoomName: roomName, + DisplayName: teacherName, + Subject: fmt.Sprintf("%s - %s", className, subject), + Moderator: true, + Config: &MeetingConfig{ + StartWithAudioMuted: true, // Students muted by default + StartWithVideoMuted: false, + RequireDisplayName: true, + EnableLobby: false, // Direct join for classes + }, + } + + return s.CreateMeetingLink(ctx, meeting) +} + +// ======================================== +// JWT Generation +// ======================================== + +// generateJWT creates a signed JWT for Jitsi authentication +func (s *JitsiService) generateJWT(meeting Meeting, roomName string) (string, *time.Time, error) { + if s.appSecret == "" { + return "", nil, fmt.Errorf("app secret not configured") + } + + now := time.Now() + + // Default expiration: 24 hours or based on meeting duration + expiration := now.Add(24 * time.Hour) + if meeting.Duration > 0 { + expiration = now.Add(time.Duration(meeting.Duration+30) * time.Minute) + } + if meeting.StartTime != nil { + expiration = meeting.StartTime.Add(time.Duration(meeting.Duration+60) * time.Minute) + } + + claims := JWTClaims{ + Audience: "jitsi", + Issuer: s.appID, + Subject: "meet.jitsi", + Room: roomName, + ExpiresAt: expiration.Unix(), + NotBefore: now.Add(-5 * time.Minute).Unix(), // 5 min grace period + Moderator: meeting.Moderator, + Context: &JWTContext{ + User: &JWTUser{ + ID: uuid.New().String(), + Name: meeting.DisplayName, + Email: meeting.Email, + Avatar: meeting.Avatar, + Moderator: meeting.Moderator, + }, + }, + } + + // Add features if specified + if meeting.Features != nil { + claims.Features = &JWTFeatures{ + Recording: boolToString(meeting.Features.Recording), + Livestreaming: boolToString(meeting.Features.Livestreaming), + Transcription: boolToString(meeting.Features.Transcription), + OutboundCall: boolToString(meeting.Features.OutboundCall), + } + } + + // Create JWT + token, err := s.signJWT(claims) + if err != nil { + return "", nil, err + } + + return token, &expiration, nil +} + +// signJWT creates and signs a JWT token +func (s *JitsiService) signJWT(claims JWTClaims) (string, error) { + // Header + header := map[string]string{ + "alg": "HS256", + "typ": "JWT", + } + + headerJSON, err := json.Marshal(header) + if err != nil { + return "", err + } + + // Payload + payloadJSON, err := json.Marshal(claims) + if err != nil { + return "", err + } + + // Encode + headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON) + payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON) + + // Sign + message := headerB64 + "." + payloadB64 + h := hmac.New(sha256.New, []byte(s.appSecret)) + h.Write([]byte(message)) + signature := base64.RawURLEncoding.EncodeToString(h.Sum(nil)) + + return message + "." + signature, nil +} + +// ======================================== +// Health Check +// ======================================== + +// HealthCheck verifies the Jitsi server is accessible +func (s *JitsiService) HealthCheck(ctx context.Context) error { + req, err := http.NewRequestWithContext(ctx, "GET", s.baseURL, nil) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + resp, err := s.httpClient.Do(req) + if err != nil { + return fmt.Errorf("jitsi server unreachable: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 500 { + return fmt.Errorf("jitsi server error: status %d", resp.StatusCode) + } + + return nil +} + +// GetServerInfo returns information about the Jitsi server +func (s *JitsiService) GetServerInfo() map[string]string { + return map[string]string{ + "base_url": s.baseURL, + "app_id": s.appID, + "auth_enabled": boolToString(s.appSecret != ""), + } +} + +// ======================================== +// URL Building +// ======================================== + +// BuildEmbedURL creates an embeddable iframe URL +func (s *JitsiService) BuildEmbedURL(roomName string, displayName string, config *MeetingConfig) string { + params := url.Values{} + + if displayName != "" { + params.Set("userInfo.displayName", displayName) + } + + if config != nil { + if config.StartWithAudioMuted { + params.Set("config.startWithAudioMuted", "true") + } + if config.StartWithVideoMuted { + params.Set("config.startWithVideoMuted", "true") + } + if config.DisableDeepLinking { + params.Set("config.disableDeepLinking", "true") + } + } + + embedURL := fmt.Sprintf("%s/%s", s.baseURL, s.sanitizeRoomName(roomName)) + if len(params) > 0 { + embedURL += "#" + params.Encode() + } + + return embedURL +} + +// BuildIFrameCode generates HTML iframe code for embedding +func (s *JitsiService) BuildIFrameCode(roomName string, width int, height int) string { + if width == 0 { + width = 800 + } + if height == 0 { + height = 600 + } + + return fmt.Sprintf( + ``, + s.baseURL, + s.sanitizeRoomName(roomName), + width, + height, + ) +} + +// ======================================== +// Helper Functions +// ======================================== + +// generateRoomName creates a unique room name +func (s *JitsiService) generateRoomName() string { + return fmt.Sprintf("breakpilot-%s", uuid.New().String()[:8]) +} + +// generateTrainingRoomName creates a room name for training sessions +func (s *JitsiService) generateTrainingRoomName(title string) string { + sanitized := s.sanitizeRoomName(title) + if sanitized == "" { + sanitized = "schulung" + } + return fmt.Sprintf("%s-%s", sanitized, time.Now().Format("20060102-1504")) +} + +// sanitizeRoomName removes invalid characters from room names +func (s *JitsiService) sanitizeRoomName(name string) string { + // Replace spaces and special characters + result := strings.ToLower(name) + result = strings.ReplaceAll(result, " ", "-") + result = strings.ReplaceAll(result, "ä", "ae") + result = strings.ReplaceAll(result, "ö", "oe") + result = strings.ReplaceAll(result, "ü", "ue") + result = strings.ReplaceAll(result, "ß", "ss") + + // Remove any remaining non-alphanumeric characters except hyphen + var cleaned strings.Builder + for _, r := range result { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + cleaned.WriteRune(r) + } + } + + // Remove consecutive hyphens + result = cleaned.String() + for strings.Contains(result, "--") { + result = strings.ReplaceAll(result, "--", "-") + } + + // Trim hyphens from start and end + result = strings.Trim(result, "-") + + // Limit length + if len(result) > 50 { + result = result[:50] + } + + return result +} + +// generatePassword creates a random meeting password +func (s *JitsiService) generatePassword() string { + return uuid.New().String()[:8] +} + +// buildConfigParams creates URL parameters from config +func (s *JitsiService) buildConfigParams(config *MeetingConfig) string { + params := url.Values{} + + if config.StartWithAudioMuted { + params.Set("config.startWithAudioMuted", "true") + } + if config.StartWithVideoMuted { + params.Set("config.startWithVideoMuted", "true") + } + if config.DisableDeepLinking { + params.Set("config.disableDeepLinking", "true") + } + if config.RequireDisplayName { + params.Set("config.requireDisplayName", "true") + } + if config.EnableLobby { + params.Set("config.enableLobby", "true") + } + + return params.Encode() +} + +// boolToString converts bool to "true"/"false" string +func boolToString(b bool) string { + if b { + return "true" + } + return "false" +} + +// GetBaseURL returns the configured base URL +func (s *JitsiService) GetBaseURL() string { + return s.baseURL +} + +// IsAuthEnabled returns whether JWT authentication is configured +func (s *JitsiService) IsAuthEnabled() bool { + return s.appSecret != "" +} diff --git a/consent-service/internal/services/jitsi/jitsi_service_test.go b/consent-service/internal/services/jitsi/jitsi_service_test.go new file mode 100644 index 0000000..2c28655 --- /dev/null +++ b/consent-service/internal/services/jitsi/jitsi_service_test.go @@ -0,0 +1,687 @@ +package jitsi + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// ======================================== +// Test Helpers +// ======================================== + +func createTestService() *JitsiService { + return NewJitsiService(Config{ + BaseURL: "http://localhost:8443", + AppID: "breakpilot", + AppSecret: "test-secret-key", + }) +} + +func createTestServiceWithoutAuth() *JitsiService { + return NewJitsiService(Config{ + BaseURL: "http://localhost:8443", + }) +} + +// ======================================== +// Unit Tests: Service Creation +// ======================================== + +func TestNewJitsiService_ValidConfig_CreatesService(t *testing.T) { + cfg := Config{ + BaseURL: "http://localhost:8443", + AppID: "test-app", + AppSecret: "test-secret", + } + + service := NewJitsiService(cfg) + + if service == nil { + t.Fatal("Expected service to be created, got nil") + } + if service.baseURL != cfg.BaseURL { + t.Errorf("Expected baseURL %s, got %s", cfg.BaseURL, service.baseURL) + } + if service.appID != cfg.AppID { + t.Errorf("Expected appID %s, got %s", cfg.AppID, service.appID) + } + if service.appSecret != cfg.AppSecret { + t.Errorf("Expected appSecret %s, got %s", cfg.AppSecret, service.appSecret) + } + if service.httpClient == nil { + t.Error("Expected httpClient to be initialized") + } +} + +func TestNewJitsiService_TrailingSlash_Removed(t *testing.T) { + service := NewJitsiService(Config{ + BaseURL: "http://localhost:8443/", + }) + + if service.baseURL != "http://localhost:8443" { + t.Errorf("Expected trailing slash to be removed, got %s", service.baseURL) + } +} + +func TestGetBaseURL_ReturnsConfiguredURL(t *testing.T) { + service := createTestService() + + result := service.GetBaseURL() + + if result != "http://localhost:8443" { + t.Errorf("Expected 'http://localhost:8443', got '%s'", result) + } +} + +func TestIsAuthEnabled_WithSecret_ReturnsTrue(t *testing.T) { + service := createTestService() + + if !service.IsAuthEnabled() { + t.Error("Expected auth to be enabled when secret is configured") + } +} + +func TestIsAuthEnabled_WithoutSecret_ReturnsFalse(t *testing.T) { + service := createTestServiceWithoutAuth() + + if service.IsAuthEnabled() { + t.Error("Expected auth to be disabled when secret is not configured") + } +} + +// ======================================== +// Unit Tests: Room Name Generation +// ======================================== + +func TestSanitizeRoomName_ValidInput_ReturnsCleanName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple name", + input: "meeting", + expected: "meeting", + }, + { + name: "with spaces", + input: "My Meeting Room", + expected: "my-meeting-room", + }, + { + name: "german umlauts", + input: "Schüler Müller", + expected: "schueler-mueller", + }, + { + name: "special characters", + input: "Test@#$%Meeting!", + expected: "testmeeting", + }, + { + name: "consecutive hyphens", + input: "test---meeting", + expected: "test-meeting", + }, + { + name: "leading trailing hyphens", + input: "-test-meeting-", + expected: "test-meeting", + }, + { + name: "eszett", + input: "Straße", + expected: "strasse", + }, + { + name: "numbers", + input: "Klasse 5a", + expected: "klasse-5a", + }, + } + + service := createTestService() + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := service.sanitizeRoomName(tt.input) + if result != tt.expected { + t.Errorf("Expected '%s', got '%s'", tt.expected, result) + } + }) + } +} + +func TestSanitizeRoomName_LongName_Truncated(t *testing.T) { + service := createTestService() + longName := strings.Repeat("a", 100) + + result := service.sanitizeRoomName(longName) + + if len(result) > 50 { + t.Errorf("Expected max 50 chars, got %d", len(result)) + } +} + +func TestGenerateRoomName_ReturnsUniqueNames(t *testing.T) { + service := createTestService() + + name1 := service.generateRoomName() + name2 := service.generateRoomName() + + if name1 == name2 { + t.Error("Expected unique room names") + } + if !strings.HasPrefix(name1, "breakpilot-") { + t.Errorf("Expected prefix 'breakpilot-', got '%s'", name1) + } +} + +func TestGenerateTrainingRoomName_IncludesTitle(t *testing.T) { + service := createTestService() + + result := service.generateTrainingRoomName("Go Workshop") + + if !strings.HasPrefix(result, "go-workshop-") { + t.Errorf("Expected to start with 'go-workshop-', got '%s'", result) + } +} + +func TestGeneratePassword_ReturnsValidPassword(t *testing.T) { + service := createTestService() + + password := service.generatePassword() + + if len(password) != 8 { + t.Errorf("Expected 8 char password, got %d", len(password)) + } +} + +// ======================================== +// Unit Tests: Meeting Link Creation +// ======================================== + +func TestCreateMeetingLink_BasicMeeting_ReturnsValidLink(t *testing.T) { + service := createTestServiceWithoutAuth() + + meeting := Meeting{ + RoomName: "test-room", + DisplayName: "Test User", + } + + link, err := service.CreateMeetingLink(context.Background(), meeting) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if link.RoomName != "test-room" { + t.Errorf("Expected room name 'test-room', got '%s'", link.RoomName) + } + if link.URL != "http://localhost:8443/test-room" { + t.Errorf("Expected URL 'http://localhost:8443/test-room', got '%s'", link.URL) + } + if link.JoinURL != "http://localhost:8443/test-room" { + t.Errorf("Expected JoinURL 'http://localhost:8443/test-room', got '%s'", link.JoinURL) + } +} + +func TestCreateMeetingLink_NoRoomName_GeneratesName(t *testing.T) { + service := createTestServiceWithoutAuth() + + meeting := Meeting{ + DisplayName: "Test User", + } + + link, err := service.CreateMeetingLink(context.Background(), meeting) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if link.RoomName == "" { + t.Error("Expected room name to be generated") + } + if !strings.HasPrefix(link.RoomName, "breakpilot-") { + t.Errorf("Expected generated room name to start with 'breakpilot-', got '%s'", link.RoomName) + } +} + +func TestCreateMeetingLink_WithPassword_IncludesPassword(t *testing.T) { + service := createTestServiceWithoutAuth() + + meeting := Meeting{ + RoomName: "test-room", + Password: "secret123", + } + + link, err := service.CreateMeetingLink(context.Background(), meeting) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if link.Password != "secret123" { + t.Errorf("Expected password 'secret123', got '%s'", link.Password) + } +} + +func TestCreateMeetingLink_WithAuth_IncludesJWT(t *testing.T) { + service := createTestService() + + meeting := Meeting{ + RoomName: "test-room", + DisplayName: "Test User", + Email: "test@example.com", + } + + link, err := service.CreateMeetingLink(context.Background(), meeting) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if link.JWT == "" { + t.Error("Expected JWT to be generated") + } + if !strings.Contains(link.JoinURL, "jwt=") { + t.Error("Expected JoinURL to contain JWT parameter") + } + if link.ExpiresAt == nil { + t.Error("Expected ExpiresAt to be set") + } +} + +func TestCreateMeetingLink_WithConfig_IncludesParams(t *testing.T) { + service := createTestServiceWithoutAuth() + + meeting := Meeting{ + RoomName: "test-room", + Config: &MeetingConfig{ + StartWithAudioMuted: true, + StartWithVideoMuted: true, + RequireDisplayName: true, + }, + } + + link, err := service.CreateMeetingLink(context.Background(), meeting) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if !strings.Contains(link.JoinURL, "startWithAudioMuted=true") { + t.Error("Expected JoinURL to contain audio muted config") + } + if !strings.Contains(link.JoinURL, "startWithVideoMuted=true") { + t.Error("Expected JoinURL to contain video muted config") + } +} + +func TestCreateMeetingLink_Moderator_SetsModeratorURL(t *testing.T) { + service := createTestService() + + meeting := Meeting{ + RoomName: "test-room", + DisplayName: "Admin", + Moderator: true, + } + + link, err := service.CreateMeetingLink(context.Background(), meeting) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if link.ModeratorURL == "" { + t.Error("Expected ModeratorURL to be set for moderator") + } +} + +// ======================================== +// Unit Tests: Specialized Meeting Types +// ======================================== + +func TestCreateTrainingSession_ReturnsOptimizedConfig(t *testing.T) { + service := createTestServiceWithoutAuth() + + link, err := service.CreateTrainingSession( + context.Background(), + "Go Grundlagen", + "Max Trainer", + "trainer@example.com", + 60, + ) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if !strings.Contains(link.RoomName, "go-grundlagen") { + t.Errorf("Expected room name to contain 'go-grundlagen', got '%s'", link.RoomName) + } + // Config should have lobby enabled for training + if !strings.Contains(link.JoinURL, "enableLobby=true") { + t.Error("Expected training to have lobby enabled") + } +} + +func TestCreateQuickMeeting_ReturnsSimpleMeeting(t *testing.T) { + service := createTestServiceWithoutAuth() + + link, err := service.CreateQuickMeeting(context.Background(), "Quick User") + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if link.RoomName == "" { + t.Error("Expected room name to be generated") + } +} + +func TestCreateParentTeacherMeeting_ReturnsSecureMeeting(t *testing.T) { + service := createTestServiceWithoutAuth() + scheduledTime := time.Now().Add(24 * time.Hour) + + link, err := service.CreateParentTeacherMeeting( + context.Background(), + "Frau Müller", + "Herr Schmidt", + "Max Mustermann", + scheduledTime, + ) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if !strings.Contains(link.RoomName, "elterngespraech") { + t.Errorf("Expected room name to contain 'elterngespraech', got '%s'", link.RoomName) + } + if link.Password == "" { + t.Error("Expected password for parent-teacher meeting") + } + if !strings.Contains(link.JoinURL, "enableLobby=true") { + t.Error("Expected lobby to be enabled") + } +} + +func TestCreateClassMeeting_ReturnsMeetingForClass(t *testing.T) { + service := createTestServiceWithoutAuth() + + link, err := service.CreateClassMeeting( + context.Background(), + "5a", + "Herr Lehrer", + "Mathematik", + ) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if !strings.Contains(link.RoomName, "klasse-5a") { + t.Errorf("Expected room name to contain 'klasse-5a', got '%s'", link.RoomName) + } + // Students should be muted by default + if !strings.Contains(link.JoinURL, "startWithAudioMuted=true") { + t.Error("Expected students to start muted") + } +} + +// ======================================== +// Unit Tests: JWT Generation +// ======================================== + +func TestGenerateJWT_ValidClaims_ReturnsValidToken(t *testing.T) { + service := createTestService() + + meeting := Meeting{ + RoomName: "test-room", + DisplayName: "Test User", + Email: "test@example.com", + Moderator: true, + Duration: 60, + } + + token, expiresAt, err := service.generateJWT(meeting, "test-room") + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if token == "" { + t.Error("Expected token to be generated") + } + if expiresAt == nil { + t.Error("Expected expiration time to be set") + } + + // Verify token structure (header.payload.signature) + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Errorf("Expected 3 JWT parts, got %d", len(parts)) + } + + // Decode and verify payload + payloadJSON, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + t.Fatalf("Failed to decode payload: %v", err) + } + + var claims JWTClaims + if err := json.Unmarshal(payloadJSON, &claims); err != nil { + t.Fatalf("Failed to unmarshal claims: %v", err) + } + + if claims.Room != "test-room" { + t.Errorf("Expected room 'test-room', got '%s'", claims.Room) + } + if !claims.Moderator { + t.Error("Expected moderator to be true") + } + if claims.Context == nil || claims.Context.User == nil { + t.Error("Expected user context to be set") + } + if claims.Context.User.Name != "Test User" { + t.Errorf("Expected user name 'Test User', got '%s'", claims.Context.User.Name) + } +} + +func TestGenerateJWT_WithFeatures_IncludesFeatures(t *testing.T) { + service := createTestService() + + meeting := Meeting{ + RoomName: "test-room", + Features: &MeetingFeatures{ + Recording: true, + Transcription: true, + }, + } + + token, _, err := service.generateJWT(meeting, "test-room") + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + parts := strings.Split(token, ".") + payloadJSON, _ := base64.RawURLEncoding.DecodeString(parts[1]) + + var claims JWTClaims + json.Unmarshal(payloadJSON, &claims) + + if claims.Features == nil { + t.Error("Expected features to be set") + } + if claims.Features.Recording != "true" { + t.Errorf("Expected recording 'true', got '%s'", claims.Features.Recording) + } +} + +func TestGenerateJWT_NoSecret_ReturnsError(t *testing.T) { + service := createTestServiceWithoutAuth() + + meeting := Meeting{RoomName: "test"} + + _, _, err := service.generateJWT(meeting, "test") + + if err == nil { + t.Error("Expected error when secret is not configured") + } +} + +// ======================================== +// Unit Tests: Health Check +// ======================================== + +func TestHealthCheck_ServerAvailable_ReturnsNil(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + service := NewJitsiService(Config{BaseURL: server.URL}) + + err := service.HealthCheck(context.Background()) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } +} + +func TestHealthCheck_ServerError_ReturnsError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + service := NewJitsiService(Config{BaseURL: server.URL}) + + err := service.HealthCheck(context.Background()) + + if err == nil { + t.Error("Expected error for server error response") + } +} + +func TestHealthCheck_ServerUnreachable_ReturnsError(t *testing.T) { + service := NewJitsiService(Config{BaseURL: "http://localhost:59999"}) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + err := service.HealthCheck(ctx) + + if err == nil { + t.Error("Expected error for unreachable server") + } +} + +// ======================================== +// Unit Tests: URL Building +// ======================================== + +func TestBuildEmbedURL_BasicRoom_ReturnsURL(t *testing.T) { + service := createTestService() + + url := service.BuildEmbedURL("test-room", "", nil) + + if url != "http://localhost:8443/test-room" { + t.Errorf("Expected 'http://localhost:8443/test-room', got '%s'", url) + } +} + +func TestBuildEmbedURL_WithDisplayName_IncludesParam(t *testing.T) { + service := createTestService() + + url := service.BuildEmbedURL("test-room", "Max Mustermann", nil) + + if !strings.Contains(url, "displayName=Max") { + t.Errorf("Expected URL to contain display name, got '%s'", url) + } +} + +func TestBuildEmbedURL_WithConfig_IncludesParams(t *testing.T) { + service := createTestService() + + config := &MeetingConfig{ + StartWithAudioMuted: true, + StartWithVideoMuted: true, + } + + url := service.BuildEmbedURL("test-room", "", config) + + if !strings.Contains(url, "startWithAudioMuted=true") { + t.Error("Expected URL to contain audio muted config") + } + if !strings.Contains(url, "startWithVideoMuted=true") { + t.Error("Expected URL to contain video muted config") + } +} + +func TestBuildIFrameCode_DefaultSize_Returns800x600(t *testing.T) { + service := createTestService() + + code := service.BuildIFrameCode("test-room", 0, 0) + + if !strings.Contains(code, "width=\"800\"") { + t.Error("Expected default width 800") + } + if !strings.Contains(code, "height=\"600\"") { + t.Error("Expected default height 600") + } + if !strings.Contains(code, "test-room") { + t.Error("Expected room name in iframe") + } + if !strings.Contains(code, "allow=\"camera; microphone") { + t.Error("Expected camera/microphone permissions") + } +} + +func TestBuildIFrameCode_CustomSize_ReturnsCorrectDimensions(t *testing.T) { + service := createTestService() + + code := service.BuildIFrameCode("test-room", 1920, 1080) + + if !strings.Contains(code, "width=\"1920\"") { + t.Error("Expected width 1920") + } + if !strings.Contains(code, "height=\"1080\"") { + t.Error("Expected height 1080") + } +} + +// ======================================== +// Unit Tests: Server Info +// ======================================== + +func TestGetServerInfo_ReturnsInfo(t *testing.T) { + service := createTestService() + + info := service.GetServerInfo() + + if info["base_url"] != "http://localhost:8443" { + t.Errorf("Expected base_url, got '%s'", info["base_url"]) + } + if info["app_id"] != "breakpilot" { + t.Errorf("Expected app_id 'breakpilot', got '%s'", info["app_id"]) + } + if info["auth_enabled"] != "true" { + t.Errorf("Expected auth_enabled 'true', got '%s'", info["auth_enabled"]) + } +} + +// ======================================== +// Unit Tests: Helper Functions +// ======================================== + +func TestBoolToString_True_ReturnsTrue(t *testing.T) { + result := boolToString(true) + if result != "true" { + t.Errorf("Expected 'true', got '%s'", result) + } +} + +func TestBoolToString_False_ReturnsFalse(t *testing.T) { + result := boolToString(false) + if result != "false" { + t.Errorf("Expected 'false', got '%s'", result) + } +} diff --git a/consent-service/internal/services/matrix/game_rooms.go b/consent-service/internal/services/matrix/game_rooms.go new file mode 100644 index 0000000..819d96f --- /dev/null +++ b/consent-service/internal/services/matrix/game_rooms.go @@ -0,0 +1,368 @@ +package matrix + +import ( + "context" + "fmt" + "time" +) + +// ======================================== +// Breakpilot Drive Game Room Types +// ======================================== + +// GameMode represents different multiplayer game modes +type GameMode string + +const ( + GameModeSolo GameMode = "solo" + GameModeCoop GameMode = "coop" // 2 players, same track + GameModeChallenge GameMode = "challenge" // 1v1 competition + GameModeClassRace GameMode = "class_race" // Whole class competition +) + +// GameRoomConfig holds configuration for game rooms +type GameRoomConfig struct { + GameMode GameMode `json:"game_mode"` + SessionID string `json:"session_id"` + HostUserID string `json:"host_user_id"` + HostName string `json:"host_name"` + ClassName string `json:"class_name,omitempty"` + MaxPlayers int `json:"max_players,omitempty"` + TeacherIDs []string `json:"teacher_ids,omitempty"` + EnableVoice bool `json:"enable_voice,omitempty"` +} + +// GameRoom represents an active game room +type GameRoom struct { + RoomID string `json:"room_id"` + SessionID string `json:"session_id"` + GameMode GameMode `json:"game_mode"` + HostUserID string `json:"host_user_id"` + Players []string `json:"players"` + CreatedAt time.Time `json:"created_at"` + IsActive bool `json:"is_active"` +} + +// GameEvent represents game events to broadcast +type GameEvent struct { + Type string `json:"type"` + SessionID string `json:"session_id"` + PlayerID string `json:"player_id"` + Data interface{} `json:"data"` + Timestamp time.Time `json:"timestamp"` +} + +// GameEventType constants +const ( + GameEventPlayerJoined = "player_joined" + GameEventPlayerLeft = "player_left" + GameEventGameStarted = "game_started" + GameEventQuizAnswered = "quiz_answered" + GameEventScoreUpdate = "score_update" + GameEventAchievement = "achievement" + GameEventChallengeWon = "challenge_won" + GameEventRaceFinished = "race_finished" +) + +// ======================================== +// Game Room Management +// ======================================== + +// CreateGameTeamRoom creates a private room for 2-4 players (Co-Op mode) +func (s *MatrixService) CreateGameTeamRoom(ctx context.Context, config GameRoomConfig) (*CreateRoomResponse, error) { + roomName := fmt.Sprintf("Breakpilot Drive - Team %s", config.SessionID[:8]) + topic := "Co-Op Spielsession - Arbeitet zusammen!" + + // All players can write + users := make(map[string]int) + users[s.GenerateUserID(config.HostUserID)] = 50 + + req := CreateRoomRequest{ + Name: roomName, + Topic: topic, + Visibility: "private", + Preset: "private_chat", + InitialState: []StateEvent{ + { + Type: "m.room.encryption", + StateKey: "", + Content: map[string]string{ + "algorithm": "m.megolm.v1.aes-sha2", + }, + }, + // Custom game state + { + Type: "breakpilot.game.session", + StateKey: "", + Content: map[string]interface{}{ + "session_id": config.SessionID, + "game_mode": string(config.GameMode), + "host_id": config.HostUserID, + "created_at": time.Now().UTC().Format(time.RFC3339), + }, + }, + }, + PowerLevelContentOverride: &PowerLevels{ + EventsDefault: 0, // All players can send messages + UsersDefault: 50, + Users: users, + Events: map[string]int{ + "breakpilot.game.event": 0, // Anyone can send game events + }, + }, + } + + return s.CreateRoom(ctx, req) +} + +// CreateGameChallengeRoom creates a 1v1 challenge room +func (s *MatrixService) CreateGameChallengeRoom(ctx context.Context, config GameRoomConfig, challengerID string, opponentID string) (*CreateRoomResponse, error) { + roomName := fmt.Sprintf("Challenge: %s", config.SessionID[:8]) + topic := "1v1 Wettbewerb - Möge der Bessere gewinnen!" + + allPlayers := []string{ + s.GenerateUserID(challengerID), + s.GenerateUserID(opponentID), + } + + users := make(map[string]int) + for _, id := range allPlayers { + users[id] = 50 + } + + req := CreateRoomRequest{ + Name: roomName, + Topic: topic, + Visibility: "private", + Preset: "private_chat", + Invite: allPlayers, + InitialState: []StateEvent{ + { + Type: "breakpilot.game.session", + StateKey: "", + Content: map[string]interface{}{ + "session_id": config.SessionID, + "game_mode": string(GameModeChallenge), + "challenger_id": challengerID, + "opponent_id": opponentID, + "created_at": time.Now().UTC().Format(time.RFC3339), + }, + }, + }, + PowerLevelContentOverride: &PowerLevels{ + EventsDefault: 0, + UsersDefault: 50, + Users: users, + }, + } + + return s.CreateRoom(ctx, req) +} + +// CreateGameClassRaceRoom creates a room for class-wide competition +func (s *MatrixService) CreateGameClassRaceRoom(ctx context.Context, config GameRoomConfig) (*CreateRoomResponse, error) { + roomName := fmt.Sprintf("Klassenrennen: %s", config.ClassName) + topic := fmt.Sprintf("Klassenrennen der %s - Alle gegen alle!", config.ClassName) + + // Teachers get moderator power level + users := make(map[string]int) + for _, teacherID := range config.TeacherIDs { + users[s.GenerateUserID(teacherID)] = 100 + } + + req := CreateRoomRequest{ + Name: roomName, + Topic: topic, + Visibility: "private", + Preset: "private_chat", + InitialState: []StateEvent{ + { + Type: "breakpilot.game.session", + StateKey: "", + Content: map[string]interface{}{ + "session_id": config.SessionID, + "game_mode": string(GameModeClassRace), + "class_name": config.ClassName, + "teacher_ids": config.TeacherIDs, + "created_at": time.Now().UTC().Format(time.RFC3339), + }, + }, + }, + PowerLevelContentOverride: &PowerLevels{ + EventsDefault: 0, // Students can send messages + UsersDefault: 10, // Default student level + Users: users, + Invite: 100, // Only teachers can invite + Kick: 100, // Only teachers can kick + Events: map[string]int{ + "breakpilot.game.event": 0, // Anyone can send game events + "breakpilot.game.leaderboard": 100, // Only teachers update leaderboard + }, + }, + } + + return s.CreateRoom(ctx, req) +} + +// ======================================== +// Game Event Broadcasting +// ======================================== + +// SendGameEvent sends a game event to a room +func (s *MatrixService) SendGameEvent(ctx context.Context, roomID string, event GameEvent) error { + event.Timestamp = time.Now().UTC() + + return s.sendEvent(ctx, roomID, "breakpilot.game.event", event) +} + +// SendPlayerJoinedEvent notifies room that a player joined +func (s *MatrixService) SendPlayerJoinedEvent(ctx context.Context, roomID string, sessionID string, playerID string, playerName string) error { + event := GameEvent{ + Type: GameEventPlayerJoined, + SessionID: sessionID, + PlayerID: playerID, + Data: map[string]string{ + "player_name": playerName, + }, + } + + // Also send a visible message + msg := fmt.Sprintf("🎮 %s ist dem Spiel beigetreten!", playerName) + if err := s.SendMessage(ctx, roomID, msg); err != nil { + // Log but don't fail + fmt.Printf("Warning: failed to send join message: %v\n", err) + } + + return s.SendGameEvent(ctx, roomID, event) +} + +// SendScoreUpdateEvent broadcasts score updates +func (s *MatrixService) SendScoreUpdateEvent(ctx context.Context, roomID string, sessionID string, playerID string, score int, accuracy float64) error { + event := GameEvent{ + Type: GameEventScoreUpdate, + SessionID: sessionID, + PlayerID: playerID, + Data: map[string]interface{}{ + "score": score, + "accuracy": accuracy, + }, + } + + return s.SendGameEvent(ctx, roomID, event) +} + +// SendQuizAnsweredEvent broadcasts when a player answers a quiz +func (s *MatrixService) SendQuizAnsweredEvent(ctx context.Context, roomID string, sessionID string, playerID string, correct bool, subject string) error { + event := GameEvent{ + Type: GameEventQuizAnswered, + SessionID: sessionID, + PlayerID: playerID, + Data: map[string]interface{}{ + "correct": correct, + "subject": subject, + }, + } + + return s.SendGameEvent(ctx, roomID, event) +} + +// SendAchievementEvent broadcasts when a player earns an achievement +func (s *MatrixService) SendAchievementEvent(ctx context.Context, roomID string, sessionID string, playerID string, achievementID string, achievementName string) error { + event := GameEvent{ + Type: GameEventAchievement, + SessionID: sessionID, + PlayerID: playerID, + Data: map[string]interface{}{ + "achievement_id": achievementID, + "achievement_name": achievementName, + }, + } + + // Also send a visible celebration message + msg := fmt.Sprintf("🏆 Erfolg freigeschaltet: %s!", achievementName) + if err := s.SendMessage(ctx, roomID, msg); err != nil { + fmt.Printf("Warning: failed to send achievement message: %v\n", err) + } + + return s.SendGameEvent(ctx, roomID, event) +} + +// SendChallengeWonEvent broadcasts challenge result +func (s *MatrixService) SendChallengeWonEvent(ctx context.Context, roomID string, sessionID string, winnerID string, winnerName string, loserName string, winnerScore int, loserScore int) error { + event := GameEvent{ + Type: GameEventChallengeWon, + SessionID: sessionID, + PlayerID: winnerID, + Data: map[string]interface{}{ + "winner_name": winnerName, + "loser_name": loserName, + "winner_score": winnerScore, + "loser_score": loserScore, + }, + } + + // Send celebration message + msg := fmt.Sprintf("🎉 %s gewinnt gegen %s mit %d zu %d Punkten!", winnerName, loserName, winnerScore, loserScore) + if err := s.SendHTMLMessage(ctx, roomID, msg, fmt.Sprintf("

              🎉 Challenge beendet!

              %s gewinnt gegen %s

              Endstand: %d : %d

              ", winnerName, loserName, winnerScore, loserScore)); err != nil { + fmt.Printf("Warning: failed to send challenge result message: %v\n", err) + } + + return s.SendGameEvent(ctx, roomID, event) +} + +// SendClassRaceLeaderboard broadcasts current leaderboard in class race +func (s *MatrixService) SendClassRaceLeaderboard(ctx context.Context, roomID string, sessionID string, leaderboard []map[string]interface{}) error { + // Build leaderboard message + msg := "🏁 Aktueller Stand:\n" + htmlMsg := "

              🏁 Aktueller Stand

                " + + for i, entry := range leaderboard { + if i >= 10 { // Top 10 only + break + } + name := entry["name"].(string) + score := entry["score"].(int) + msg += fmt.Sprintf("%d. %s - %d Punkte\n", i+1, name, score) + htmlMsg += fmt.Sprintf("
              1. %s - %d Punkte
              2. ", name, score) + } + htmlMsg += "
              " + + return s.SendHTMLMessage(ctx, roomID, msg, htmlMsg) +} + +// ======================================== +// Game Room Utilities +// ======================================== + +// AddPlayerToGameRoom invites and sets up a player in a game room +func (s *MatrixService) AddPlayerToGameRoom(ctx context.Context, roomID string, playerMatrixID string, playerName string) error { + // Invite the player + if err := s.InviteUser(ctx, roomID, playerMatrixID); err != nil { + return fmt.Errorf("failed to invite player: %w", err) + } + + // Set display name if not already set + if err := s.SetDisplayName(ctx, playerMatrixID, playerName); err != nil { + // Log but don't fail - display name might already be set + fmt.Printf("Warning: failed to set display name: %v\n", err) + } + + return nil +} + +// CloseGameRoom sends end message and archives the room +func (s *MatrixService) CloseGameRoom(ctx context.Context, roomID string, sessionID string) error { + // Send closing message + msg := "🏁 Spiel beendet! Danke fürs Mitspielen. Dieser Raum wird archiviert." + if err := s.SendMessage(ctx, roomID, msg); err != nil { + return fmt.Errorf("failed to send closing message: %w", err) + } + + // Update room state to mark as closed + closeEvent := map[string]interface{}{ + "closed": true, + "closed_at": time.Now().UTC().Format(time.RFC3339), + } + + return s.sendEvent(ctx, roomID, "breakpilot.game.closed", closeEvent) +} diff --git a/consent-service/internal/services/matrix/matrix_service.go b/consent-service/internal/services/matrix/matrix_service.go new file mode 100644 index 0000000..9295cb1 --- /dev/null +++ b/consent-service/internal/services/matrix/matrix_service.go @@ -0,0 +1,548 @@ +package matrix + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/google/uuid" +) + +// MatrixService handles Matrix homeserver communication +type MatrixService struct { + homeserverURL string + accessToken string + serverName string + httpClient *http.Client +} + +// Config holds Matrix service configuration +type Config struct { + HomeserverURL string // e.g., "http://synapse:8008" + AccessToken string // Admin/bot access token + ServerName string // e.g., "breakpilot.local" +} + +// NewMatrixService creates a new Matrix service instance +func NewMatrixService(cfg Config) *MatrixService { + return &MatrixService{ + homeserverURL: cfg.HomeserverURL, + accessToken: cfg.AccessToken, + serverName: cfg.ServerName, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// ======================================== +// Matrix API Types +// ======================================== + +// CreateRoomRequest represents a Matrix room creation request +type CreateRoomRequest struct { + Name string `json:"name,omitempty"` + RoomAliasName string `json:"room_alias_name,omitempty"` + Topic string `json:"topic,omitempty"` + Visibility string `json:"visibility,omitempty"` // "private" or "public" + Preset string `json:"preset,omitempty"` // "private_chat", "public_chat", "trusted_private_chat" + IsDirect bool `json:"is_direct,omitempty"` + Invite []string `json:"invite,omitempty"` + InitialState []StateEvent `json:"initial_state,omitempty"` + PowerLevelContentOverride *PowerLevels `json:"power_level_content_override,omitempty"` +} + +// CreateRoomResponse represents a Matrix room creation response +type CreateRoomResponse struct { + RoomID string `json:"room_id"` +} + +// StateEvent represents a Matrix state event +type StateEvent struct { + Type string `json:"type"` + StateKey string `json:"state_key"` + Content interface{} `json:"content"` +} + +// PowerLevels represents Matrix power levels +type PowerLevels struct { + Ban int `json:"ban,omitempty"` + Events map[string]int `json:"events,omitempty"` + EventsDefault int `json:"events_default,omitempty"` + Invite int `json:"invite,omitempty"` + Kick int `json:"kick,omitempty"` + Redact int `json:"redact,omitempty"` + StateDefault int `json:"state_default,omitempty"` + Users map[string]int `json:"users,omitempty"` + UsersDefault int `json:"users_default,omitempty"` +} + +// SendMessageRequest represents a message to send +type SendMessageRequest struct { + MsgType string `json:"msgtype"` + Body string `json:"body"` + Format string `json:"format,omitempty"` + FormattedBody string `json:"formatted_body,omitempty"` +} + +// UserInfo represents Matrix user information +type UserInfo struct { + UserID string `json:"user_id"` + DisplayName string `json:"displayname,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` +} + +// RegisterRequest for user registration +type RegisterRequest struct { + Username string `json:"username"` + Password string `json:"password,omitempty"` + Admin bool `json:"admin,omitempty"` +} + +// RegisterResponse for user registration +type RegisterResponse struct { + UserID string `json:"user_id"` + AccessToken string `json:"access_token"` + DeviceID string `json:"device_id"` +} + +// InviteRequest for inviting a user to a room +type InviteRequest struct { + UserID string `json:"user_id"` +} + +// JoinRequest for joining a room +type JoinRequest struct { + Reason string `json:"reason,omitempty"` +} + +// ======================================== +// Room Management +// ======================================== + +// CreateRoom creates a new Matrix room +func (s *MatrixService) CreateRoom(ctx context.Context, req CreateRoomRequest) (*CreateRoomResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + resp, err := s.doRequest(ctx, "POST", "/_matrix/client/v3/createRoom", body) + if err != nil { + return nil, fmt.Errorf("failed to create room: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, s.parseError(resp) + } + + var result CreateRoomResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &result, nil +} + +// CreateClassInfoRoom creates a broadcast room for a class (teachers write, parents read) +func (s *MatrixService) CreateClassInfoRoom(ctx context.Context, className string, schoolName string, teacherMatrixIDs []string) (*CreateRoomResponse, error) { + // Set up power levels: teachers can write (50), parents read-only (0) + users := make(map[string]int) + for _, teacherID := range teacherMatrixIDs { + users[teacherID] = 50 + } + + req := CreateRoomRequest{ + Name: fmt.Sprintf("%s - %s (Info)", className, schoolName), + Topic: fmt.Sprintf("Info-Kanal für %s. Nur Lehrer können schreiben.", className), + Visibility: "private", + Preset: "private_chat", + Invite: teacherMatrixIDs, + PowerLevelContentOverride: &PowerLevels{ + EventsDefault: 50, // Only power level 50+ can send messages + UsersDefault: 0, // Parents get power level 0 by default + Users: users, + Invite: 50, + Kick: 50, + Ban: 50, + Redact: 50, + }, + } + + return s.CreateRoom(ctx, req) +} + +// CreateStudentDMRoom creates a direct message room for parent-teacher communication about a student +func (s *MatrixService) CreateStudentDMRoom(ctx context.Context, studentName string, className string, teacherMatrixIDs []string, parentMatrixIDs []string) (*CreateRoomResponse, error) { + allUsers := append(teacherMatrixIDs, parentMatrixIDs...) + + users := make(map[string]int) + for _, id := range allUsers { + users[id] = 50 // All can write + } + + req := CreateRoomRequest{ + Name: fmt.Sprintf("%s (%s) - Dialog", studentName, className), + Topic: fmt.Sprintf("Kommunikation über %s", studentName), + Visibility: "private", + Preset: "trusted_private_chat", + IsDirect: false, + Invite: allUsers, + InitialState: []StateEvent{ + { + Type: "m.room.encryption", + StateKey: "", + Content: map[string]string{ + "algorithm": "m.megolm.v1.aes-sha2", + }, + }, + }, + PowerLevelContentOverride: &PowerLevels{ + EventsDefault: 0, // Everyone can send messages + UsersDefault: 50, + Users: users, + }, + } + + return s.CreateRoom(ctx, req) +} + +// CreateParentRepRoom creates a room for class teacher and parent representatives +func (s *MatrixService) CreateParentRepRoom(ctx context.Context, className string, teacherMatrixIDs []string, parentRepMatrixIDs []string) (*CreateRoomResponse, error) { + allUsers := append(teacherMatrixIDs, parentRepMatrixIDs...) + + users := make(map[string]int) + for _, id := range allUsers { + users[id] = 50 + } + + req := CreateRoomRequest{ + Name: fmt.Sprintf("%s - Elternvertreter", className), + Topic: fmt.Sprintf("Kommunikation zwischen Lehrkräften und Elternvertretern der %s", className), + Visibility: "private", + Preset: "private_chat", + Invite: allUsers, + PowerLevelContentOverride: &PowerLevels{ + EventsDefault: 0, + UsersDefault: 50, + Users: users, + }, + } + + return s.CreateRoom(ctx, req) +} + +// ======================================== +// User Management +// ======================================== + +// RegisterUser registers a new Matrix user (requires admin token) +func (s *MatrixService) RegisterUser(ctx context.Context, username string, displayName string) (*RegisterResponse, error) { + // Use admin API for user registration + req := map[string]interface{}{ + "username": username, + "password": uuid.New().String(), // Generate random password + "admin": false, + } + + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + // Use admin registration endpoint + resp, err := s.doRequest(ctx, "POST", "/_synapse/admin/v2/users/@"+username+":"+s.serverName, body) + if err != nil { + return nil, fmt.Errorf("failed to register user: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, s.parseError(resp) + } + + // Set display name + if displayName != "" { + if err := s.SetDisplayName(ctx, "@"+username+":"+s.serverName, displayName); err != nil { + // Log but don't fail + fmt.Printf("Warning: failed to set display name: %v\n", err) + } + } + + return &RegisterResponse{ + UserID: "@" + username + ":" + s.serverName, + }, nil +} + +// SetDisplayName sets the display name for a user +func (s *MatrixService) SetDisplayName(ctx context.Context, userID string, displayName string) error { + req := map[string]string{ + "displayname": displayName, + } + + body, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + endpoint := fmt.Sprintf("/_matrix/client/v3/profile/%s/displayname", url.PathEscape(userID)) + resp, err := s.doRequest(ctx, "PUT", endpoint, body) + if err != nil { + return fmt.Errorf("failed to set display name: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return s.parseError(resp) + } + + return nil +} + +// ======================================== +// Room Membership +// ======================================== + +// InviteUser invites a user to a room +func (s *MatrixService) InviteUser(ctx context.Context, roomID string, userID string) error { + req := InviteRequest{UserID: userID} + + body, err := json.Marshal(req) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + endpoint := fmt.Sprintf("/_matrix/client/v3/rooms/%s/invite", url.PathEscape(roomID)) + resp, err := s.doRequest(ctx, "POST", endpoint, body) + if err != nil { + return fmt.Errorf("failed to invite user: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return s.parseError(resp) + } + + return nil +} + +// JoinRoom makes the bot join a room +func (s *MatrixService) JoinRoom(ctx context.Context, roomIDOrAlias string) error { + endpoint := fmt.Sprintf("/_matrix/client/v3/join/%s", url.PathEscape(roomIDOrAlias)) + resp, err := s.doRequest(ctx, "POST", endpoint, []byte("{}")) + if err != nil { + return fmt.Errorf("failed to join room: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return s.parseError(resp) + } + + return nil +} + +// SetUserPowerLevel sets a user's power level in a room +func (s *MatrixService) SetUserPowerLevel(ctx context.Context, roomID string, userID string, powerLevel int) error { + // First, get current power levels + endpoint := fmt.Sprintf("/_matrix/client/v3/rooms/%s/state/m.room.power_levels/", url.PathEscape(roomID)) + resp, err := s.doRequest(ctx, "GET", endpoint, nil) + if err != nil { + return fmt.Errorf("failed to get power levels: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return s.parseError(resp) + } + + var powerLevels PowerLevels + if err := json.NewDecoder(resp.Body).Decode(&powerLevels); err != nil { + return fmt.Errorf("failed to decode power levels: %w", err) + } + + // Update user power level + if powerLevels.Users == nil { + powerLevels.Users = make(map[string]int) + } + powerLevels.Users[userID] = powerLevel + + // Send updated power levels + body, err := json.Marshal(powerLevels) + if err != nil { + return fmt.Errorf("failed to marshal power levels: %w", err) + } + + resp2, err := s.doRequest(ctx, "PUT", endpoint, body) + if err != nil { + return fmt.Errorf("failed to set power levels: %w", err) + } + defer resp2.Body.Close() + + if resp2.StatusCode != http.StatusOK { + return s.parseError(resp2) + } + + return nil +} + +// ======================================== +// Messaging +// ======================================== + +// SendMessage sends a text message to a room +func (s *MatrixService) SendMessage(ctx context.Context, roomID string, message string) error { + req := SendMessageRequest{ + MsgType: "m.text", + Body: message, + } + + return s.sendEvent(ctx, roomID, "m.room.message", req) +} + +// SendHTMLMessage sends an HTML-formatted message to a room +func (s *MatrixService) SendHTMLMessage(ctx context.Context, roomID string, plainText string, htmlBody string) error { + req := SendMessageRequest{ + MsgType: "m.text", + Body: plainText, + Format: "org.matrix.custom.html", + FormattedBody: htmlBody, + } + + return s.sendEvent(ctx, roomID, "m.room.message", req) +} + +// SendAbsenceNotification sends an absence notification to parents +func (s *MatrixService) SendAbsenceNotification(ctx context.Context, roomID string, studentName string, date string, lessonNumber int) error { + plainText := fmt.Sprintf("⚠️ Abwesenheitsmeldung\n\nIhr Kind %s war heute (%s) in der %d. Stunde nicht im Unterricht anwesend.\n\nBitte bestätigen Sie den Grund der Abwesenheit.", studentName, date, lessonNumber) + + htmlBody := fmt.Sprintf(`

              ⚠️ Abwesenheitsmeldung

              +

              Ihr Kind %s war heute (%s) in der %d. Stunde nicht im Unterricht anwesend.

              +

              Bitte bestätigen Sie den Grund der Abwesenheit.

              +
                +
              • ✅ Entschuldigt (Krankheit)
              • +
              • 📋 Arztbesuch
              • +
              • ❓ Sonstiges (bitte erläutern)
              • +
              `, studentName, date, lessonNumber) + + return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody) +} + +// SendGradeNotification sends a grade notification to parents +func (s *MatrixService) SendGradeNotification(ctx context.Context, roomID string, studentName string, subject string, gradeType string, grade float64) error { + plainText := fmt.Sprintf("📊 Neue Note eingetragen\n\nFür %s wurde eine neue Note eingetragen:\n\nFach: %s\nArt: %s\nNote: %.1f", studentName, subject, gradeType, grade) + + htmlBody := fmt.Sprintf(`

              📊 Neue Note eingetragen

              +

              Für %s wurde eine neue Note eingetragen:

              + + + + +
              Fach:%s
              Art:%s
              Note:%.1f
              `, studentName, subject, gradeType, grade) + + return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody) +} + +// SendClassAnnouncement sends an announcement to a class info room +func (s *MatrixService) SendClassAnnouncement(ctx context.Context, roomID string, title string, content string, teacherName string) error { + plainText := fmt.Sprintf("📢 %s\n\n%s\n\n— %s", title, content, teacherName) + + htmlBody := fmt.Sprintf(`

              📢 %s

              +

              %s

              +

              — %s

              `, title, content, teacherName) + + return s.SendHTMLMessage(ctx, roomID, plainText, htmlBody) +} + +// ======================================== +// Internal Helpers +// ======================================== + +func (s *MatrixService) sendEvent(ctx context.Context, roomID string, eventType string, content interface{}) error { + body, err := json.Marshal(content) + if err != nil { + return fmt.Errorf("failed to marshal content: %w", err) + } + + txnID := uuid.New().String() + endpoint := fmt.Sprintf("/_matrix/client/v3/rooms/%s/send/%s/%s", + url.PathEscape(roomID), url.PathEscape(eventType), txnID) + + resp, err := s.doRequest(ctx, "PUT", endpoint, body) + if err != nil { + return fmt.Errorf("failed to send event: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return s.parseError(resp) + } + + return nil +} + +func (s *MatrixService) doRequest(ctx context.Context, method string, endpoint string, body []byte) (*http.Response, error) { + fullURL := s.homeserverURL + endpoint + + var bodyReader io.Reader + if body != nil { + bodyReader = bytes.NewReader(body) + } + + req, err := http.NewRequestWithContext(ctx, method, fullURL, bodyReader) + if err != nil { + return nil, err + } + + req.Header.Set("Authorization", "Bearer "+s.accessToken) + req.Header.Set("Content-Type", "application/json") + + return s.httpClient.Do(req) +} + +func (s *MatrixService) parseError(resp *http.Response) error { + body, _ := io.ReadAll(resp.Body) + var errResp struct { + ErrCode string `json:"errcode"` + Error string `json:"error"` + } + if err := json.Unmarshal(body, &errResp); err != nil { + return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body)) + } + return fmt.Errorf("matrix error %s: %s", errResp.ErrCode, errResp.Error) +} + +// ======================================== +// Health Check +// ======================================== + +// HealthCheck checks if the Matrix server is reachable +func (s *MatrixService) HealthCheck(ctx context.Context) error { + resp, err := s.doRequest(ctx, "GET", "/_matrix/client/versions", nil) + if err != nil { + return fmt.Errorf("matrix server unreachable: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("matrix server returned status %d", resp.StatusCode) + } + + return nil +} + +// GetServerName returns the configured server name +func (s *MatrixService) GetServerName() string { + return s.serverName +} + +// GenerateUserID generates a Matrix user ID from a username +func (s *MatrixService) GenerateUserID(username string) string { + return fmt.Sprintf("@%s:%s", username, s.serverName) +} diff --git a/consent-service/internal/services/matrix/matrix_service_test.go b/consent-service/internal/services/matrix/matrix_service_test.go new file mode 100644 index 0000000..c50afcd --- /dev/null +++ b/consent-service/internal/services/matrix/matrix_service_test.go @@ -0,0 +1,791 @@ +package matrix + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// ======================================== +// Test Helpers +// ======================================== + +// createTestServer creates a mock Matrix server for testing +func createTestServer(t *testing.T, handler http.HandlerFunc) (*httptest.Server, *MatrixService) { + server := httptest.NewServer(handler) + service := NewMatrixService(Config{ + HomeserverURL: server.URL, + AccessToken: "test-access-token", + ServerName: "test.local", + }) + return server, service +} + +// ======================================== +// Unit Tests: Service Creation +// ======================================== + +func TestNewMatrixService_ValidConfig_CreatesService(t *testing.T) { + cfg := Config{ + HomeserverURL: "http://localhost:8008", + AccessToken: "test-token", + ServerName: "breakpilot.local", + } + + service := NewMatrixService(cfg) + + if service == nil { + t.Fatal("Expected service to be created, got nil") + } + if service.homeserverURL != cfg.HomeserverURL { + t.Errorf("Expected homeserverURL %s, got %s", cfg.HomeserverURL, service.homeserverURL) + } + if service.accessToken != cfg.AccessToken { + t.Errorf("Expected accessToken %s, got %s", cfg.AccessToken, service.accessToken) + } + if service.serverName != cfg.ServerName { + t.Errorf("Expected serverName %s, got %s", cfg.ServerName, service.serverName) + } + if service.httpClient == nil { + t.Error("Expected httpClient to be initialized") + } + if service.httpClient.Timeout != 30*time.Second { + t.Errorf("Expected timeout 30s, got %v", service.httpClient.Timeout) + } +} + +func TestGetServerName_ReturnsConfiguredName(t *testing.T) { + service := NewMatrixService(Config{ + HomeserverURL: "http://localhost:8008", + AccessToken: "test-token", + ServerName: "school.example.com", + }) + + result := service.GetServerName() + + if result != "school.example.com" { + t.Errorf("Expected 'school.example.com', got '%s'", result) + } +} + +func TestGenerateUserID_ValidUsername_ReturnsFormattedID(t *testing.T) { + tests := []struct { + name string + serverName string + username string + expected string + }{ + { + name: "simple username", + serverName: "breakpilot.local", + username: "max.mustermann", + expected: "@max.mustermann:breakpilot.local", + }, + { + name: "teacher username", + serverName: "school.de", + username: "lehrer_mueller", + expected: "@lehrer_mueller:school.de", + }, + { + name: "parent username with numbers", + serverName: "test.local", + username: "eltern123", + expected: "@eltern123:test.local", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + service := NewMatrixService(Config{ + HomeserverURL: "http://localhost:8008", + AccessToken: "test-token", + ServerName: tt.serverName, + }) + + result := service.GenerateUserID(tt.username) + + if result != tt.expected { + t.Errorf("Expected '%s', got '%s'", tt.expected, result) + } + }) + } +} + +// ======================================== +// Unit Tests: Health Check +// ======================================== + +func TestHealthCheck_ServerHealthy_ReturnsNil(t *testing.T) { + server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/_matrix/client/versions" { + t.Errorf("Expected path /_matrix/client/versions, got %s", r.URL.Path) + } + if r.Method != "GET" { + t.Errorf("Expected GET method, got %s", r.Method) + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "versions": []string{"v1.1", "v1.2"}, + }) + }) + defer server.Close() + + err := service.HealthCheck(context.Background()) + + if err != nil { + t.Errorf("Expected no error, got %v", err) + } +} + +func TestHealthCheck_ServerUnreachable_ReturnsError(t *testing.T) { + service := NewMatrixService(Config{ + HomeserverURL: "http://localhost:59999", // Non-existent server + AccessToken: "test-token", + ServerName: "test.local", + }) + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + err := service.HealthCheck(ctx) + + if err == nil { + t.Error("Expected error for unreachable server, got nil") + } + if !strings.Contains(err.Error(), "unreachable") { + t.Errorf("Expected 'unreachable' in error message, got: %v", err) + } +} + +func TestHealthCheck_ServerReturns500_ReturnsError(t *testing.T) { + server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + defer server.Close() + + err := service.HealthCheck(context.Background()) + + if err == nil { + t.Error("Expected error for 500 response, got nil") + } + if !strings.Contains(err.Error(), "500") { + t.Errorf("Expected '500' in error message, got: %v", err) + } +} + +// ======================================== +// Unit Tests: Room Creation +// ======================================== + +func TestCreateRoom_ValidRequest_ReturnsRoomID(t *testing.T) { + expectedRoomID := "!abc123:test.local" + + server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/_matrix/client/v3/createRoom" { + t.Errorf("Expected path /_matrix/client/v3/createRoom, got %s", r.URL.Path) + } + if r.Method != "POST" { + t.Errorf("Expected POST method, got %s", r.Method) + } + + // Verify authorization header + auth := r.Header.Get("Authorization") + if auth != "Bearer test-access-token" { + t.Errorf("Expected 'Bearer test-access-token', got '%s'", auth) + } + + // Verify content type + contentType := r.Header.Get("Content-Type") + if contentType != "application/json" { + t.Errorf("Expected 'application/json', got '%s'", contentType) + } + + // Decode and verify request body + var req CreateRoomRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("Failed to decode request body: %v", err) + } + + if req.Name != "Test Room" { + t.Errorf("Expected name 'Test Room', got '%s'", req.Name) + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(CreateRoomResponse{ + RoomID: expectedRoomID, + }) + }) + defer server.Close() + + req := CreateRoomRequest{ + Name: "Test Room", + Visibility: "private", + } + + result, err := service.CreateRoom(context.Background(), req) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result.RoomID != expectedRoomID { + t.Errorf("Expected room ID '%s', got '%s'", expectedRoomID, result.RoomID) + } +} + +func TestCreateRoom_ServerError_ReturnsError(t *testing.T) { + server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]string{ + "errcode": "M_FORBIDDEN", + "error": "Not allowed to create rooms", + }) + }) + defer server.Close() + + req := CreateRoomRequest{Name: "Test"} + + _, err := service.CreateRoom(context.Background(), req) + + if err == nil { + t.Error("Expected error, got nil") + } + if !strings.Contains(err.Error(), "M_FORBIDDEN") { + t.Errorf("Expected 'M_FORBIDDEN' in error, got: %v", err) + } +} + +func TestCreateClassInfoRoom_ValidInput_CreatesRoomWithCorrectPowerLevels(t *testing.T) { + var receivedRequest CreateRoomRequest + + server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&receivedRequest) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(CreateRoomResponse{RoomID: "!class:test.local"}) + }) + defer server.Close() + + teacherIDs := []string{"@lehrer1:test.local", "@lehrer2:test.local"} + result, err := service.CreateClassInfoRoom(context.Background(), "5a", "Grundschule Musterstadt", teacherIDs) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result.RoomID != "!class:test.local" { + t.Errorf("Expected room ID '!class:test.local', got '%s'", result.RoomID) + } + + // Verify room name format + expectedName := "5a - Grundschule Musterstadt (Info)" + if receivedRequest.Name != expectedName { + t.Errorf("Expected name '%s', got '%s'", expectedName, receivedRequest.Name) + } + + // Verify power levels + if receivedRequest.PowerLevelContentOverride == nil { + t.Fatal("Expected power level override, got nil") + } + if receivedRequest.PowerLevelContentOverride.EventsDefault != 50 { + t.Errorf("Expected EventsDefault 50, got %d", receivedRequest.PowerLevelContentOverride.EventsDefault) + } + if receivedRequest.PowerLevelContentOverride.UsersDefault != 0 { + t.Errorf("Expected UsersDefault 0, got %d", receivedRequest.PowerLevelContentOverride.UsersDefault) + } + + // Verify teachers have power level 50 + for _, teacherID := range teacherIDs { + if level, ok := receivedRequest.PowerLevelContentOverride.Users[teacherID]; !ok || level != 50 { + t.Errorf("Expected teacher %s to have power level 50, got %d", teacherID, level) + } + } +} + +func TestCreateStudentDMRoom_ValidInput_CreatesEncryptedRoom(t *testing.T) { + var receivedRequest CreateRoomRequest + + server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&receivedRequest) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(CreateRoomResponse{RoomID: "!dm:test.local"}) + }) + defer server.Close() + + teacherIDs := []string{"@lehrer:test.local"} + parentIDs := []string{"@eltern1:test.local", "@eltern2:test.local"} + + result, err := service.CreateStudentDMRoom(context.Background(), "Max Mustermann", "5a", teacherIDs, parentIDs) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result.RoomID != "!dm:test.local" { + t.Errorf("Expected room ID '!dm:test.local', got '%s'", result.RoomID) + } + + // Verify room name + expectedName := "Max Mustermann (5a) - Dialog" + if receivedRequest.Name != expectedName { + t.Errorf("Expected name '%s', got '%s'", expectedName, receivedRequest.Name) + } + + // Verify encryption is enabled + foundEncryption := false + for _, state := range receivedRequest.InitialState { + if state.Type == "m.room.encryption" { + foundEncryption = true + // Content comes as map[string]interface{} from JSON unmarshaling + content, ok := state.Content.(map[string]interface{}) + if !ok { + t.Errorf("Expected encryption content to be map[string]interface{}, got %T", state.Content) + continue + } + if algo, ok := content["algorithm"].(string); !ok || algo != "m.megolm.v1.aes-sha2" { + t.Errorf("Expected algorithm 'm.megolm.v1.aes-sha2', got '%v'", content["algorithm"]) + } + } + } + if !foundEncryption { + t.Error("Expected encryption state event, not found") + } + + // Verify all users are invited + expectedInvites := append(teacherIDs, parentIDs...) + for _, expected := range expectedInvites { + found := false + for _, invited := range receivedRequest.Invite { + if invited == expected { + found = true + break + } + } + if !found { + t.Errorf("Expected user %s to be invited", expected) + } + } +} + +func TestCreateParentRepRoom_ValidInput_CreatesRoom(t *testing.T) { + var receivedRequest CreateRoomRequest + + server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&receivedRequest) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(CreateRoomResponse{RoomID: "!rep:test.local"}) + }) + defer server.Close() + + teacherIDs := []string{"@lehrer:test.local"} + repIDs := []string{"@elternvertreter1:test.local", "@elternvertreter2:test.local"} + + result, err := service.CreateParentRepRoom(context.Background(), "5a", teacherIDs, repIDs) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result.RoomID != "!rep:test.local" { + t.Errorf("Expected room ID '!rep:test.local', got '%s'", result.RoomID) + } + + // Verify room name + expectedName := "5a - Elternvertreter" + if receivedRequest.Name != expectedName { + t.Errorf("Expected name '%s', got '%s'", expectedName, receivedRequest.Name) + } + + // Verify all participants can write (power level 50) + allUsers := append(teacherIDs, repIDs...) + for _, userID := range allUsers { + if level, ok := receivedRequest.PowerLevelContentOverride.Users[userID]; !ok || level != 50 { + t.Errorf("Expected user %s to have power level 50, got %d", userID, level) + } + } +} + +// ======================================== +// Unit Tests: User Management +// ======================================== + +func TestSetDisplayName_ValidRequest_Succeeds(t *testing.T) { + var receivedPath string + var receivedBody map[string]string + + server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) { + receivedPath = r.URL.Path + json.NewDecoder(r.Body).Decode(&receivedBody) + w.WriteHeader(http.StatusOK) + }) + defer server.Close() + + err := service.SetDisplayName(context.Background(), "@user:test.local", "Max Mustermann") + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Path may or may not be URL-encoded depending on Go version + if !strings.Contains(receivedPath, "/profile/") || !strings.Contains(receivedPath, "/displayname") { + t.Errorf("Expected path to contain '/profile/' and '/displayname', got '%s'", receivedPath) + } + + if receivedBody["displayname"] != "Max Mustermann" { + t.Errorf("Expected displayname 'Max Mustermann', got '%s'", receivedBody["displayname"]) + } +} + +// ======================================== +// Unit Tests: Room Membership +// ======================================== + +func TestInviteUser_ValidRequest_Succeeds(t *testing.T) { + var receivedBody InviteRequest + + server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/invite") { + t.Errorf("Expected path to contain '/invite', got '%s'", r.URL.Path) + } + if r.Method != "POST" { + t.Errorf("Expected POST method, got %s", r.Method) + } + json.NewDecoder(r.Body).Decode(&receivedBody) + w.WriteHeader(http.StatusOK) + }) + defer server.Close() + + err := service.InviteUser(context.Background(), "!room:test.local", "@user:test.local") + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if receivedBody.UserID != "@user:test.local" { + t.Errorf("Expected user_id '@user:test.local', got '%s'", receivedBody.UserID) + } +} + +func TestInviteUser_UserAlreadyInRoom_ReturnsError(t *testing.T) { + server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + json.NewEncoder(w).Encode(map[string]string{ + "errcode": "M_FORBIDDEN", + "error": "User is already in the room", + }) + }) + defer server.Close() + + err := service.InviteUser(context.Background(), "!room:test.local", "@user:test.local") + + if err == nil { + t.Error("Expected error, got nil") + } +} + +func TestJoinRoom_ValidRequest_Succeeds(t *testing.T) { + server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/join/") { + t.Errorf("Expected path to contain '/join/', got '%s'", r.URL.Path) + } + if r.Method != "POST" { + t.Errorf("Expected POST method, got %s", r.Method) + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"room_id": "!room:test.local"}) + }) + defer server.Close() + + err := service.JoinRoom(context.Background(), "!room:test.local") + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } +} + +// ======================================== +// Unit Tests: Messaging +// ======================================== + +func TestSendMessage_ValidRequest_Succeeds(t *testing.T) { + var receivedBody SendMessageRequest + + server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) { + if !strings.Contains(r.URL.Path, "/send/m.room.message/") { + t.Errorf("Expected path to contain '/send/m.room.message/', got '%s'", r.URL.Path) + } + if r.Method != "PUT" { + t.Errorf("Expected PUT method, got %s", r.Method) + } + json.NewDecoder(r.Body).Decode(&receivedBody) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"event_id": "$event123"}) + }) + defer server.Close() + + err := service.SendMessage(context.Background(), "!room:test.local", "Hello, World!") + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if receivedBody.MsgType != "m.text" { + t.Errorf("Expected msgtype 'm.text', got '%s'", receivedBody.MsgType) + } + if receivedBody.Body != "Hello, World!" { + t.Errorf("Expected body 'Hello, World!', got '%s'", receivedBody.Body) + } +} + +func TestSendHTMLMessage_ValidRequest_IncludesFormattedBody(t *testing.T) { + var receivedBody SendMessageRequest + + server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&receivedBody) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"event_id": "$event123"}) + }) + defer server.Close() + + err := service.SendHTMLMessage(context.Background(), "!room:test.local", "Plain text", "Bold text") + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if receivedBody.Format != "org.matrix.custom.html" { + t.Errorf("Expected format 'org.matrix.custom.html', got '%s'", receivedBody.Format) + } + if receivedBody.Body != "Plain text" { + t.Errorf("Expected body 'Plain text', got '%s'", receivedBody.Body) + } + if receivedBody.FormattedBody != "Bold text" { + t.Errorf("Expected formatted_body 'Bold text', got '%s'", receivedBody.FormattedBody) + } +} + +func TestSendAbsenceNotification_ValidRequest_FormatsCorrectly(t *testing.T) { + var receivedBody SendMessageRequest + + server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&receivedBody) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"event_id": "$event123"}) + }) + defer server.Close() + + err := service.SendAbsenceNotification(context.Background(), "!room:test.local", "Max Mustermann", "15.12.2025", 3) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Verify plain text contains key information + if !strings.Contains(receivedBody.Body, "Max Mustermann") { + t.Error("Expected body to contain student name") + } + if !strings.Contains(receivedBody.Body, "15.12.2025") { + t.Error("Expected body to contain date") + } + if !strings.Contains(receivedBody.Body, "3. Stunde") { + t.Error("Expected body to contain lesson number") + } + if !strings.Contains(receivedBody.Body, "Abwesenheitsmeldung") { + t.Error("Expected body to contain 'Abwesenheitsmeldung'") + } + + // Verify HTML is set + if receivedBody.FormattedBody == "" { + t.Error("Expected formatted body to be set") + } +} + +func TestSendGradeNotification_ValidRequest_FormatsCorrectly(t *testing.T) { + var receivedBody SendMessageRequest + + server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&receivedBody) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"event_id": "$event123"}) + }) + defer server.Close() + + err := service.SendGradeNotification(context.Background(), "!room:test.local", "Max Mustermann", "Mathematik", "Klassenarbeit", 2.3) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if !strings.Contains(receivedBody.Body, "Max Mustermann") { + t.Error("Expected body to contain student name") + } + if !strings.Contains(receivedBody.Body, "Mathematik") { + t.Error("Expected body to contain subject") + } + if !strings.Contains(receivedBody.Body, "Klassenarbeit") { + t.Error("Expected body to contain grade type") + } + if !strings.Contains(receivedBody.Body, "2.3") { + t.Error("Expected body to contain grade") + } +} + +func TestSendClassAnnouncement_ValidRequest_FormatsCorrectly(t *testing.T) { + var receivedBody SendMessageRequest + + server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) { + json.NewDecoder(r.Body).Decode(&receivedBody) + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"event_id": "$event123"}) + }) + defer server.Close() + + err := service.SendClassAnnouncement(context.Background(), "!room:test.local", "Elternabend", "Am 20.12. findet der Elternabend statt.", "Frau Müller") + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if !strings.Contains(receivedBody.Body, "Elternabend") { + t.Error("Expected body to contain title") + } + if !strings.Contains(receivedBody.Body, "20.12.") { + t.Error("Expected body to contain content") + } + if !strings.Contains(receivedBody.Body, "Frau Müller") { + t.Error("Expected body to contain teacher name") + } +} + +// ======================================== +// Unit Tests: Power Levels +// ======================================== + +func TestSetUserPowerLevel_ValidRequest_UpdatesPowerLevel(t *testing.T) { + callCount := 0 + var putBody PowerLevels + + server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) { + callCount++ + if r.Method == "GET" { + // Return current power levels + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(PowerLevels{ + Users: map[string]int{ + "@admin:test.local": 100, + }, + UsersDefault: 0, + }) + } else if r.Method == "PUT" { + // Update power levels + json.NewDecoder(r.Body).Decode(&putBody) + w.WriteHeader(http.StatusOK) + } + }) + defer server.Close() + + err := service.SetUserPowerLevel(context.Background(), "!room:test.local", "@newuser:test.local", 50) + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if callCount != 2 { + t.Errorf("Expected 2 API calls (GET then PUT), got %d", callCount) + } + if putBody.Users["@newuser:test.local"] != 50 { + t.Errorf("Expected user power level 50, got %d", putBody.Users["@newuser:test.local"]) + } + // Verify existing users are preserved + if putBody.Users["@admin:test.local"] != 100 { + t.Errorf("Expected admin power level 100 to be preserved, got %d", putBody.Users["@admin:test.local"]) + } +} + +// ======================================== +// Unit Tests: Error Handling +// ======================================== + +func TestParseError_MatrixError_ExtractsFields(t *testing.T) { + server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{ + "errcode": "M_UNKNOWN", + "error": "Something went wrong", + }) + }) + defer server.Close() + + _, err := service.CreateRoom(context.Background(), CreateRoomRequest{Name: "Test"}) + + if err == nil { + t.Fatal("Expected error, got nil") + } + if !strings.Contains(err.Error(), "M_UNKNOWN") { + t.Errorf("Expected error to contain 'M_UNKNOWN', got: %v", err) + } + if !strings.Contains(err.Error(), "Something went wrong") { + t.Errorf("Expected error to contain 'Something went wrong', got: %v", err) + } +} + +func TestParseError_NonJSONError_ReturnsRawBody(t *testing.T) { + server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Internal Server Error")) + }) + defer server.Close() + + _, err := service.CreateRoom(context.Background(), CreateRoomRequest{Name: "Test"}) + + if err == nil { + t.Fatal("Expected error, got nil") + } + if !strings.Contains(err.Error(), "500") { + t.Errorf("Expected error to contain '500', got: %v", err) + } +} + +// ======================================== +// Unit Tests: Context Handling +// ======================================== + +func TestCreateRoom_ContextCanceled_ReturnsError(t *testing.T) { + server, service := createTestServer(t, func(w http.ResponseWriter, r *http.Request) { + time.Sleep(100 * time.Millisecond) + w.WriteHeader(http.StatusOK) + }) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + _, err := service.CreateRoom(ctx, CreateRoomRequest{Name: "Test"}) + + if err == nil { + t.Error("Expected error for canceled context, got nil") + } +} + +// ======================================== +// Integration Tests (require running Synapse) +// ======================================== + +// These tests are skipped by default as they require a running Matrix server +// Run with: go test -tags=integration ./... + +func TestIntegration_HealthCheck(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test in short mode") + } + + service := NewMatrixService(Config{ + HomeserverURL: "http://localhost:8008", + AccessToken: "", // Not needed for health check + ServerName: "breakpilot.local", + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + err := service.HealthCheck(ctx) + if err != nil { + t.Skipf("Matrix server not available: %v", err) + } +} diff --git a/consent-service/internal/services/notification_service.go b/consent-service/internal/services/notification_service.go new file mode 100644 index 0000000..c1615cb --- /dev/null +++ b/consent-service/internal/services/notification_service.go @@ -0,0 +1,347 @@ +package services + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +// NotificationType defines the type of notification +type NotificationType string + +const ( + NotificationTypeConsentRequired NotificationType = "consent_required" + NotificationTypeConsentReminder NotificationType = "consent_reminder" + NotificationTypeVersionPublished NotificationType = "version_published" + NotificationTypeVersionApproved NotificationType = "version_approved" + NotificationTypeVersionRejected NotificationType = "version_rejected" + NotificationTypeAccountSuspended NotificationType = "account_suspended" + NotificationTypeAccountRestored NotificationType = "account_restored" + NotificationTypeGeneral NotificationType = "general" + // DSR (Data Subject Request) notification types + NotificationTypeDSRReceived NotificationType = "dsr_received" + NotificationTypeDSRAssigned NotificationType = "dsr_assigned" + NotificationTypeDSRDeadline NotificationType = "dsr_deadline" +) + +// NotificationChannel defines how notification is delivered +type NotificationChannel string + +const ( + ChannelInApp NotificationChannel = "in_app" + ChannelEmail NotificationChannel = "email" + ChannelPush NotificationChannel = "push" +) + +// Notification represents a notification entity +type Notification struct { + ID uuid.UUID `json:"id"` + UserID uuid.UUID `json:"user_id"` + Type NotificationType `json:"type"` + Channel NotificationChannel `json:"channel"` + Title string `json:"title"` + Body string `json:"body"` + Data map[string]interface{} `json:"data,omitempty"` + ReadAt *time.Time `json:"read_at,omitempty"` + SentAt *time.Time `json:"sent_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// NotificationPreferences holds user notification settings +type NotificationPreferences struct { + UserID uuid.UUID `json:"user_id"` + EmailEnabled bool `json:"email_enabled"` + PushEnabled bool `json:"push_enabled"` + InAppEnabled bool `json:"in_app_enabled"` + ReminderFrequency string `json:"reminder_frequency"` +} + +// NotificationService handles notification operations +type NotificationService struct { + pool *pgxpool.Pool + emailService *EmailService +} + +// NewNotificationService creates a new notification service +func NewNotificationService(pool *pgxpool.Pool, emailService *EmailService) *NotificationService { + return &NotificationService{ + pool: pool, + emailService: emailService, + } +} + +// CreateNotification creates and optionally sends a notification +func (s *NotificationService) CreateNotification(ctx context.Context, userID uuid.UUID, notifType NotificationType, title, body string, data map[string]interface{}) error { + // Get user preferences + prefs, err := s.GetPreferences(ctx, userID) + if err != nil { + // Use default preferences if not found + prefs = &NotificationPreferences{ + UserID: userID, + EmailEnabled: true, + PushEnabled: true, + InAppEnabled: true, + } + } + + // Create in-app notification if enabled + if prefs.InAppEnabled { + if err := s.createInAppNotification(ctx, userID, notifType, title, body, data); err != nil { + return fmt.Errorf("failed to create in-app notification: %w", err) + } + } + + // Send email notification if enabled + if prefs.EmailEnabled && s.emailService != nil { + go s.sendEmailNotification(ctx, userID, notifType, title, body, data) + } + + // Push notification would be sent here if enabled + // if prefs.PushEnabled { + // go s.sendPushNotification(ctx, userID, title, body, data) + // } + + return nil +} + +// createInAppNotification creates an in-app notification +func (s *NotificationService) createInAppNotification(ctx context.Context, userID uuid.UUID, notifType NotificationType, title, body string, data map[string]interface{}) error { + dataJSON, _ := json.Marshal(data) + + _, err := s.pool.Exec(ctx, ` + INSERT INTO notifications (user_id, type, channel, title, body, data, created_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW()) + `, userID, notifType, ChannelInApp, title, body, dataJSON) + + return err +} + +// sendEmailNotification sends an email notification +func (s *NotificationService) sendEmailNotification(ctx context.Context, userID uuid.UUID, notifType NotificationType, title, body string, data map[string]interface{}) { + // Get user email + var email string + err := s.pool.QueryRow(ctx, `SELECT email FROM users WHERE id = $1`, userID).Scan(&email) + if err != nil { + return + } + + // Send based on notification type + switch notifType { + case NotificationTypeConsentRequired, NotificationTypeConsentReminder: + s.emailService.SendConsentReminderEmail(email, title, body) + default: + s.emailService.SendGenericNotificationEmail(email, title, body) + } + + // Mark as sent + s.pool.Exec(ctx, ` + UPDATE notifications SET sent_at = NOW() + WHERE user_id = $1 AND type = $2 AND channel = $3 AND sent_at IS NULL + ORDER BY created_at DESC LIMIT 1 + `, userID, notifType, ChannelEmail) +} + +// GetUserNotifications returns notifications for a user +func (s *NotificationService) GetUserNotifications(ctx context.Context, userID uuid.UUID, limit, offset int, unreadOnly bool) ([]Notification, int, error) { + // Count total + var totalQuery string + var total int + + if unreadOnly { + totalQuery = `SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL` + } else { + totalQuery = `SELECT COUNT(*) FROM notifications WHERE user_id = $1` + } + s.pool.QueryRow(ctx, totalQuery, userID).Scan(&total) + + // Get notifications + var query string + if unreadOnly { + query = ` + SELECT id, user_id, type, channel, title, body, data, read_at, sent_at, created_at + FROM notifications + WHERE user_id = $1 AND read_at IS NULL + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + ` + } else { + query = ` + SELECT id, user_id, type, channel, title, body, data, read_at, sent_at, created_at + FROM notifications + WHERE user_id = $1 + ORDER BY created_at DESC + LIMIT $2 OFFSET $3 + ` + } + + rows, err := s.pool.Query(ctx, query, userID, limit, offset) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + var notifications []Notification + for rows.Next() { + var n Notification + var dataJSON []byte + if err := rows.Scan(&n.ID, &n.UserID, &n.Type, &n.Channel, &n.Title, &n.Body, &dataJSON, &n.ReadAt, &n.SentAt, &n.CreatedAt); err != nil { + continue + } + if dataJSON != nil { + json.Unmarshal(dataJSON, &n.Data) + } + notifications = append(notifications, n) + } + + return notifications, total, nil +} + +// GetUnreadCount returns the count of unread notifications +func (s *NotificationService) GetUnreadCount(ctx context.Context, userID uuid.UUID) (int, error) { + var count int + err := s.pool.QueryRow(ctx, ` + SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL + `, userID).Scan(&count) + return count, err +} + +// MarkAsRead marks a notification as read +func (s *NotificationService) MarkAsRead(ctx context.Context, userID uuid.UUID, notificationID uuid.UUID) error { + result, err := s.pool.Exec(ctx, ` + UPDATE notifications SET read_at = NOW() + WHERE id = $1 AND user_id = $2 AND read_at IS NULL + `, notificationID, userID) + + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return fmt.Errorf("notification not found or already read") + } + return nil +} + +// MarkAllAsRead marks all notifications as read for a user +func (s *NotificationService) MarkAllAsRead(ctx context.Context, userID uuid.UUID) error { + _, err := s.pool.Exec(ctx, ` + UPDATE notifications SET read_at = NOW() + WHERE user_id = $1 AND read_at IS NULL + `, userID) + return err +} + +// DeleteNotification deletes a notification +func (s *NotificationService) DeleteNotification(ctx context.Context, userID uuid.UUID, notificationID uuid.UUID) error { + result, err := s.pool.Exec(ctx, ` + DELETE FROM notifications WHERE id = $1 AND user_id = $2 + `, notificationID, userID) + + if err != nil { + return err + } + if result.RowsAffected() == 0 { + return fmt.Errorf("notification not found") + } + return nil +} + +// GetPreferences returns notification preferences for a user +func (s *NotificationService) GetPreferences(ctx context.Context, userID uuid.UUID) (*NotificationPreferences, error) { + var prefs NotificationPreferences + prefs.UserID = userID + + err := s.pool.QueryRow(ctx, ` + SELECT email_enabled, push_enabled, in_app_enabled, reminder_frequency + FROM notification_preferences + WHERE user_id = $1 + `, userID).Scan(&prefs.EmailEnabled, &prefs.PushEnabled, &prefs.InAppEnabled, &prefs.ReminderFrequency) + + if err != nil { + // Return defaults if not found + return &NotificationPreferences{ + UserID: userID, + EmailEnabled: true, + PushEnabled: true, + InAppEnabled: true, + ReminderFrequency: "weekly", + }, nil + } + + return &prefs, nil +} + +// UpdatePreferences updates notification preferences for a user +func (s *NotificationService) UpdatePreferences(ctx context.Context, userID uuid.UUID, prefs *NotificationPreferences) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO notification_preferences (user_id, email_enabled, push_enabled, in_app_enabled, reminder_frequency, updated_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + ON CONFLICT (user_id) DO UPDATE SET + email_enabled = $2, + push_enabled = $3, + in_app_enabled = $4, + reminder_frequency = $5, + updated_at = NOW() + `, userID, prefs.EmailEnabled, prefs.PushEnabled, prefs.InAppEnabled, prefs.ReminderFrequency) + + return err +} + +// NotifyConsentRequired sends consent required notifications to all active users +func (s *NotificationService) NotifyConsentRequired(ctx context.Context, documentName, versionID string) error { + // Get all active users + rows, err := s.pool.Query(ctx, ` + SELECT id FROM users WHERE account_status = 'active' + `) + if err != nil { + return err + } + defer rows.Close() + + title := "Neue Zustimmung erforderlich" + body := fmt.Sprintf("Eine neue Version von '%s' wurde veröffentlicht. Bitte überprüfen und bestätigen Sie diese.", documentName) + data := map[string]interface{}{ + "version_id": versionID, + "document_name": documentName, + } + + for rows.Next() { + var userID uuid.UUID + if err := rows.Scan(&userID); err != nil { + continue + } + go s.CreateNotification(ctx, userID, NotificationTypeConsentRequired, title, body, data) + } + + return nil +} + +// NotifyVersionApproved notifies the creator that their version was approved +func (s *NotificationService) NotifyVersionApproved(ctx context.Context, creatorID uuid.UUID, documentName, versionNumber, approverEmail string) error { + title := "Version genehmigt" + body := fmt.Sprintf("Ihre Version %s von '%s' wurde von %s genehmigt und kann nun veröffentlicht werden.", versionNumber, documentName, approverEmail) + data := map[string]interface{}{ + "document_name": documentName, + "version_number": versionNumber, + "approver": approverEmail, + } + + return s.CreateNotification(ctx, creatorID, NotificationTypeVersionApproved, title, body, data) +} + +// NotifyVersionRejected notifies the creator that their version was rejected +func (s *NotificationService) NotifyVersionRejected(ctx context.Context, creatorID uuid.UUID, documentName, versionNumber, reason, rejecterEmail string) error { + title := "Version abgelehnt" + body := fmt.Sprintf("Ihre Version %s von '%s' wurde von %s abgelehnt. Grund: %s", versionNumber, documentName, rejecterEmail, reason) + data := map[string]interface{}{ + "document_name": documentName, + "version_number": versionNumber, + "rejecter": rejecterEmail, + "reason": reason, + } + + return s.CreateNotification(ctx, creatorID, NotificationTypeVersionRejected, title, body, data) +} diff --git a/consent-service/internal/services/notification_service_test.go b/consent-service/internal/services/notification_service_test.go new file mode 100644 index 0000000..182b69e --- /dev/null +++ b/consent-service/internal/services/notification_service_test.go @@ -0,0 +1,660 @@ +package services + +import ( + "testing" + "time" + + "github.com/google/uuid" +) + +// TestNotificationService_CreateNotification tests notification creation +func TestNotificationService_CreateNotification(t *testing.T) { + tests := []struct { + name string + userID uuid.UUID + notifType NotificationType + title string + body string + data map[string]interface{} + expectError bool + }{ + { + name: "valid notification", + userID: uuid.New(), + notifType: NotificationTypeConsentRequired, + title: "Consent Required", + body: "Please review and accept the new terms", + data: map[string]interface{}{"document_id": "123"}, + expectError: false, + }, + { + name: "notification without data", + userID: uuid.New(), + notifType: NotificationTypeGeneral, + title: "General Notification", + body: "This is a test", + data: nil, + expectError: false, + }, + { + name: "empty user ID", + userID: uuid.Nil, + notifType: NotificationTypeGeneral, + title: "Test", + body: "Test", + expectError: true, + }, + { + name: "empty title", + userID: uuid.New(), + notifType: NotificationTypeGeneral, + title: "", + body: "Test body", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + if tt.userID == uuid.Nil { + err = &ValidationError{Field: "user ID", Message: "required"} + } else if tt.title == "" { + err = &ValidationError{Field: "title", Message: "required"} + } + + if tt.expectError && err == nil { + t.Error("Expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +// TestNotificationService_NotificationTypes tests notification type validation +func TestNotificationService_NotificationTypes(t *testing.T) { + tests := []struct { + notifType NotificationType + isValid bool + }{ + {NotificationTypeConsentRequired, true}, + {NotificationTypeConsentReminder, true}, + {NotificationTypeVersionPublished, true}, + {NotificationTypeVersionApproved, true}, + {NotificationTypeVersionRejected, true}, + {NotificationTypeAccountSuspended, true}, + {NotificationTypeAccountRestored, true}, + {NotificationTypeGeneral, true}, + {NotificationType("invalid_type"), false}, + {NotificationType(""), false}, + } + + validTypes := map[NotificationType]bool{ + NotificationTypeConsentRequired: true, + NotificationTypeConsentReminder: true, + NotificationTypeVersionPublished: true, + NotificationTypeVersionApproved: true, + NotificationTypeVersionRejected: true, + NotificationTypeAccountSuspended: true, + NotificationTypeAccountRestored: true, + NotificationTypeGeneral: true, + } + + for _, tt := range tests { + t.Run(string(tt.notifType), func(t *testing.T) { + isValid := validTypes[tt.notifType] + + if isValid != tt.isValid { + t.Errorf("Type %s: expected valid=%v, got %v", tt.notifType, tt.isValid, isValid) + } + }) + } +} + +// TestNotificationService_NotificationChannels tests channel validation +func TestNotificationService_NotificationChannels(t *testing.T) { + tests := []struct { + channel NotificationChannel + isValid bool + }{ + {ChannelInApp, true}, + {ChannelEmail, true}, + {ChannelPush, true}, + {NotificationChannel("sms"), false}, + {NotificationChannel(""), false}, + } + + validChannels := map[NotificationChannel]bool{ + ChannelInApp: true, + ChannelEmail: true, + ChannelPush: true, + } + + for _, tt := range tests { + t.Run(string(tt.channel), func(t *testing.T) { + isValid := validChannels[tt.channel] + + if isValid != tt.isValid { + t.Errorf("Channel %s: expected valid=%v, got %v", tt.channel, tt.isValid, isValid) + } + }) + } +} + +// TestNotificationService_GetUserNotifications tests retrieving notifications +func TestNotificationService_GetUserNotifications(t *testing.T) { + tests := []struct { + name string + userID uuid.UUID + limit int + offset int + unreadOnly bool + expectError bool + }{ + { + name: "get all notifications", + userID: uuid.New(), + limit: 50, + offset: 0, + unreadOnly: false, + expectError: false, + }, + { + name: "get unread only", + userID: uuid.New(), + limit: 50, + offset: 0, + unreadOnly: true, + expectError: false, + }, + { + name: "with pagination", + userID: uuid.New(), + limit: 10, + offset: 20, + unreadOnly: false, + expectError: false, + }, + { + name: "invalid user ID", + userID: uuid.Nil, + limit: 50, + offset: 0, + unreadOnly: false, + expectError: true, + }, + { + name: "negative limit", + userID: uuid.New(), + limit: -1, + offset: 0, + unreadOnly: false, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + if tt.userID == uuid.Nil { + err = &ValidationError{Field: "user ID", Message: "required"} + } else if tt.limit < 0 { + err = &ValidationError{Field: "limit", Message: "must be >= 0"} + } + + if tt.expectError && err == nil { + t.Error("Expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +// TestNotificationService_MarkAsRead tests marking notifications as read +func TestNotificationService_MarkAsRead(t *testing.T) { + tests := []struct { + name string + notificationID uuid.UUID + userID uuid.UUID + expectError bool + }{ + { + name: "mark valid notification as read", + notificationID: uuid.New(), + userID: uuid.New(), + expectError: false, + }, + { + name: "invalid notification ID", + notificationID: uuid.Nil, + userID: uuid.New(), + expectError: true, + }, + { + name: "invalid user ID", + notificationID: uuid.New(), + userID: uuid.Nil, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + if tt.notificationID == uuid.Nil { + err = &ValidationError{Field: "notification ID", Message: "required"} + } else if tt.userID == uuid.Nil { + err = &ValidationError{Field: "user ID", Message: "required"} + } + + if tt.expectError && err == nil { + t.Error("Expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +// TestNotificationService_GetPreferences tests retrieving user preferences +func TestNotificationService_GetPreferences(t *testing.T) { + tests := []struct { + name string + userID uuid.UUID + expectError bool + }{ + { + name: "get valid user preferences", + userID: uuid.New(), + expectError: false, + }, + { + name: "invalid user ID", + userID: uuid.Nil, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + if tt.userID == uuid.Nil { + err = &ValidationError{Field: "user ID", Message: "required"} + } + + if tt.expectError && err == nil { + t.Error("Expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +// TestNotificationService_UpdatePreferences tests updating notification preferences +func TestNotificationService_UpdatePreferences(t *testing.T) { + tests := []struct { + name string + userID uuid.UUID + emailEnabled bool + pushEnabled bool + inAppEnabled bool + reminderFrequency string + expectError bool + }{ + { + name: "enable all notifications", + userID: uuid.New(), + emailEnabled: true, + pushEnabled: true, + inAppEnabled: true, + reminderFrequency: "daily", + expectError: false, + }, + { + name: "disable email notifications", + userID: uuid.New(), + emailEnabled: false, + pushEnabled: true, + inAppEnabled: true, + reminderFrequency: "weekly", + expectError: false, + }, + { + name: "set reminder frequency to never", + userID: uuid.New(), + emailEnabled: true, + pushEnabled: false, + inAppEnabled: true, + reminderFrequency: "never", + expectError: false, + }, + { + name: "invalid reminder frequency", + userID: uuid.New(), + emailEnabled: true, + pushEnabled: true, + inAppEnabled: true, + reminderFrequency: "hourly", + expectError: true, + }, + } + + validFrequencies := map[string]bool{ + "daily": true, + "weekly": true, + "never": true, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + if !validFrequencies[tt.reminderFrequency] { + err = &ValidationError{Field: "reminder_frequency", Message: "must be daily, weekly, or never"} + } + + if tt.expectError && err == nil { + t.Error("Expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +// TestNotificationService_NotifyConsentRequired tests consent required notification +func TestNotificationService_NotifyConsentRequired(t *testing.T) { + tests := []struct { + name string + documentName string + versionID string + expectError bool + }{ + { + name: "valid consent notification", + documentName: "Terms of Service", + versionID: uuid.New().String(), + expectError: false, + }, + { + name: "empty document name", + documentName: "", + versionID: uuid.New().String(), + expectError: true, + }, + { + name: "empty version ID", + documentName: "Privacy Policy", + versionID: "", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + if tt.documentName == "" { + err = &ValidationError{Field: "document name", Message: "required"} + } else if tt.versionID == "" { + err = &ValidationError{Field: "version ID", Message: "required"} + } + + if tt.expectError && err == nil { + t.Error("Expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +// TestNotificationService_DeleteNotification tests deleting notifications +func TestNotificationService_DeleteNotification(t *testing.T) { + tests := []struct { + name string + notificationID uuid.UUID + userID uuid.UUID + expectError bool + }{ + { + name: "delete valid notification", + notificationID: uuid.New(), + userID: uuid.New(), + expectError: false, + }, + { + name: "invalid notification ID", + notificationID: uuid.Nil, + userID: uuid.New(), + expectError: true, + }, + { + name: "invalid user ID", + notificationID: uuid.New(), + userID: uuid.Nil, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + if tt.notificationID == uuid.Nil { + err = &ValidationError{Field: "notification ID", Message: "required"} + } else if tt.userID == uuid.Nil { + err = &ValidationError{Field: "user ID", Message: "required"} + } + + if tt.expectError && err == nil { + t.Error("Expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +// TestNotificationService_BatchMarkAsRead tests batch marking as read +func TestNotificationService_BatchMarkAsRead(t *testing.T) { + tests := []struct { + name string + notificationIDs []uuid.UUID + userID uuid.UUID + expectError bool + }{ + { + name: "mark multiple notifications", + notificationIDs: []uuid.UUID{uuid.New(), uuid.New(), uuid.New()}, + userID: uuid.New(), + expectError: false, + }, + { + name: "empty list", + notificationIDs: []uuid.UUID{}, + userID: uuid.New(), + expectError: false, + }, + { + name: "invalid user ID", + notificationIDs: []uuid.UUID{uuid.New()}, + userID: uuid.Nil, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + if tt.userID == uuid.Nil { + err = &ValidationError{Field: "user ID", Message: "required"} + } + + if tt.expectError && err == nil { + t.Error("Expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +// TestNotificationService_GetUnreadCount tests getting unread count +func TestNotificationService_GetUnreadCount(t *testing.T) { + tests := []struct { + name string + userID uuid.UUID + expectError bool + }{ + { + name: "get count for valid user", + userID: uuid.New(), + expectError: false, + }, + { + name: "invalid user ID", + userID: uuid.Nil, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var err error + if tt.userID == uuid.Nil { + err = &ValidationError{Field: "user ID", Message: "required"} + } + + if tt.expectError && err == nil { + t.Error("Expected error, got nil") + } + if !tt.expectError && err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + } +} + +// TestNotificationService_NotificationPriority tests notification priority +func TestNotificationService_NotificationPriority(t *testing.T) { + tests := []struct { + name string + notifType NotificationType + expectedPrio string + }{ + { + name: "consent required - high priority", + notifType: NotificationTypeConsentRequired, + expectedPrio: "high", + }, + { + name: "account suspended - critical", + notifType: NotificationTypeAccountSuspended, + expectedPrio: "critical", + }, + { + name: "version published - normal", + notifType: NotificationTypeVersionPublished, + expectedPrio: "normal", + }, + { + name: "general - low", + notifType: NotificationTypeGeneral, + expectedPrio: "low", + }, + } + + priorityMap := map[NotificationType]string{ + NotificationTypeConsentRequired: "high", + NotificationTypeConsentReminder: "high", + NotificationTypeAccountSuspended: "critical", + NotificationTypeAccountRestored: "normal", + NotificationTypeVersionPublished: "normal", + NotificationTypeVersionApproved: "normal", + NotificationTypeVersionRejected: "normal", + NotificationTypeGeneral: "low", + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + priority := priorityMap[tt.notifType] + + if priority != tt.expectedPrio { + t.Errorf("Expected priority %s, got %s", tt.expectedPrio, priority) + } + }) + } +} + +// TestNotificationService_ReminderFrequency tests reminder frequency logic +func TestNotificationService_ReminderFrequency(t *testing.T) { + now := time.Now() + + tests := []struct { + name string + frequency string + lastReminder time.Time + shouldSend bool + }{ + { + name: "daily - last sent yesterday", + frequency: "daily", + lastReminder: now.AddDate(0, 0, -1), + shouldSend: true, + }, + { + name: "daily - last sent today", + frequency: "daily", + lastReminder: now.Add(-1 * time.Hour), + shouldSend: false, + }, + { + name: "weekly - last sent 8 days ago", + frequency: "weekly", + lastReminder: now.AddDate(0, 0, -8), + shouldSend: true, + }, + { + name: "weekly - last sent 5 days ago", + frequency: "weekly", + lastReminder: now.AddDate(0, 0, -5), + shouldSend: false, + }, + { + name: "never - should not send", + frequency: "never", + lastReminder: now.AddDate(0, 0, -30), + shouldSend: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var shouldSend bool + + switch tt.frequency { + case "daily": + daysSince := int(now.Sub(tt.lastReminder).Hours() / 24) + shouldSend = daysSince >= 1 + case "weekly": + daysSince := int(now.Sub(tt.lastReminder).Hours() / 24) + shouldSend = daysSince >= 7 + case "never": + shouldSend = false + } + + if shouldSend != tt.shouldSend { + t.Errorf("Expected shouldSend=%v, got %v", tt.shouldSend, shouldSend) + } + }) + } +} diff --git a/consent-service/internal/services/oauth_service.go b/consent-service/internal/services/oauth_service.go new file mode 100644 index 0000000..22abfca --- /dev/null +++ b/consent-service/internal/services/oauth_service.go @@ -0,0 +1,524 @@ +package services + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/breakpilot/consent-service/internal/models" +) + +var ( + ErrInvalidClient = errors.New("invalid_client") + ErrInvalidGrant = errors.New("invalid_grant") + ErrInvalidScope = errors.New("invalid_scope") + ErrInvalidRequest = errors.New("invalid_request") + ErrUnauthorizedClient = errors.New("unauthorized_client") + ErrAccessDenied = errors.New("access_denied") + ErrInvalidRedirectURI = errors.New("invalid redirect_uri") + ErrCodeExpired = errors.New("authorization code expired") + ErrCodeUsed = errors.New("authorization code already used") + ErrPKCERequired = errors.New("PKCE code_challenge required for public clients") + ErrPKCEVerifyFailed = errors.New("PKCE verification failed") +) + +// OAuthService handles OAuth 2.0 Authorization Code Flow with PKCE +type OAuthService struct { + db *pgxpool.Pool + jwtSecret string + authCodeExpiration time.Duration + accessTokenExpiration time.Duration + refreshTokenExpiration time.Duration +} + +// NewOAuthService creates a new OAuthService +func NewOAuthService(db *pgxpool.Pool, jwtSecret string) *OAuthService { + return &OAuthService{ + db: db, + jwtSecret: jwtSecret, + authCodeExpiration: 10 * time.Minute, // Authorization codes expire quickly + accessTokenExpiration: time.Hour, // 1 hour + refreshTokenExpiration: 30 * 24 * time.Hour, // 30 days + } +} + +// ValidateClient validates an OAuth client +func (s *OAuthService) ValidateClient(ctx context.Context, clientID string) (*models.OAuthClient, error) { + var client models.OAuthClient + var redirectURIsJSON, scopesJSON, grantTypesJSON []byte + + err := s.db.QueryRow(ctx, ` + SELECT id, client_id, client_secret, name, description, redirect_uris, scopes, grant_types, is_public, is_active, created_at + FROM oauth_clients WHERE client_id = $1 + `, clientID).Scan( + &client.ID, &client.ClientID, &client.ClientSecret, &client.Name, &client.Description, + &redirectURIsJSON, &scopesJSON, &grantTypesJSON, &client.IsPublic, &client.IsActive, &client.CreatedAt, + ) + + if err != nil { + return nil, ErrInvalidClient + } + + if !client.IsActive { + return nil, ErrInvalidClient + } + + // Parse JSON arrays + json.Unmarshal(redirectURIsJSON, &client.RedirectURIs) + json.Unmarshal(scopesJSON, &client.Scopes) + json.Unmarshal(grantTypesJSON, &client.GrantTypes) + + return &client, nil +} + +// ValidateClientSecret validates client credentials for confidential clients +func (s *OAuthService) ValidateClientSecret(client *models.OAuthClient, clientSecret string) error { + if client.IsPublic { + // Public clients don't have a secret + return nil + } + + if client.ClientSecret != clientSecret { + return ErrInvalidClient + } + + return nil +} + +// ValidateRedirectURI validates the redirect URI against registered URIs +func (s *OAuthService) ValidateRedirectURI(client *models.OAuthClient, redirectURI string) error { + for _, uri := range client.RedirectURIs { + if uri == redirectURI { + return nil + } + } + return ErrInvalidRedirectURI +} + +// ValidateScopes validates requested scopes against client's allowed scopes +func (s *OAuthService) ValidateScopes(client *models.OAuthClient, requestedScopes string) ([]string, error) { + if requestedScopes == "" { + // Return default scopes + return []string{"openid", "profile", "email"}, nil + } + + requested := strings.Split(requestedScopes, " ") + allowedMap := make(map[string]bool) + for _, scope := range client.Scopes { + allowedMap[scope] = true + } + + var validScopes []string + for _, scope := range requested { + if allowedMap[scope] { + validScopes = append(validScopes, scope) + } + } + + if len(validScopes) == 0 { + return nil, ErrInvalidScope + } + + return validScopes, nil +} + +// GenerateAuthorizationCode generates a new authorization code +func (s *OAuthService) GenerateAuthorizationCode( + ctx context.Context, + client *models.OAuthClient, + userID uuid.UUID, + redirectURI string, + scopes []string, + codeChallenge, codeChallengeMethod string, +) (string, error) { + // For public clients, PKCE is required + if client.IsPublic && codeChallenge == "" { + return "", ErrPKCERequired + } + + // Generate a secure random code + codeBytes := make([]byte, 32) + if _, err := rand.Read(codeBytes); err != nil { + return "", fmt.Errorf("failed to generate code: %w", err) + } + code := base64.URLEncoding.EncodeToString(codeBytes) + + // Hash the code for storage + codeHash := sha256.Sum256([]byte(code)) + hashedCode := hex.EncodeToString(codeHash[:]) + + scopesJSON, _ := json.Marshal(scopes) + + var challengePtr, methodPtr *string + if codeChallenge != "" { + challengePtr = &codeChallenge + if codeChallengeMethod == "" { + codeChallengeMethod = "plain" + } + methodPtr = &codeChallengeMethod + } + + _, err := s.db.Exec(ctx, ` + INSERT INTO oauth_authorization_codes (code, client_id, user_id, redirect_uri, scopes, code_challenge, code_challenge_method, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, hashedCode, client.ClientID, userID, redirectURI, scopesJSON, challengePtr, methodPtr, time.Now().Add(s.authCodeExpiration)) + + if err != nil { + return "", fmt.Errorf("failed to store authorization code: %w", err) + } + + return code, nil +} + +// ExchangeAuthorizationCode exchanges an authorization code for tokens +func (s *OAuthService) ExchangeAuthorizationCode( + ctx context.Context, + code string, + clientID string, + redirectURI string, + codeVerifier string, +) (*models.OAuthTokenResponse, error) { + // Hash the code to look it up + codeHash := sha256.Sum256([]byte(code)) + hashedCode := hex.EncodeToString(codeHash[:]) + + var authCode models.OAuthAuthorizationCode + var scopesJSON []byte + + err := s.db.QueryRow(ctx, ` + SELECT id, client_id, user_id, redirect_uri, scopes, code_challenge, code_challenge_method, expires_at, used_at + FROM oauth_authorization_codes WHERE code = $1 + `, hashedCode).Scan( + &authCode.ID, &authCode.ClientID, &authCode.UserID, &authCode.RedirectURI, + &scopesJSON, &authCode.CodeChallenge, &authCode.CodeChallengeMethod, + &authCode.ExpiresAt, &authCode.UsedAt, + ) + + if err != nil { + return nil, ErrInvalidGrant + } + + // Check if code was already used + if authCode.UsedAt != nil { + return nil, ErrCodeUsed + } + + // Check if code is expired + if time.Now().After(authCode.ExpiresAt) { + return nil, ErrCodeExpired + } + + // Verify client_id matches + if authCode.ClientID != clientID { + return nil, ErrInvalidGrant + } + + // Verify redirect_uri matches + if authCode.RedirectURI != redirectURI { + return nil, ErrInvalidGrant + } + + // Verify PKCE if code_challenge was provided + if authCode.CodeChallenge != nil && *authCode.CodeChallenge != "" { + if codeVerifier == "" { + return nil, ErrPKCEVerifyFailed + } + + var expectedChallenge string + if authCode.CodeChallengeMethod != nil && *authCode.CodeChallengeMethod == "S256" { + // SHA256 hash of verifier + hash := sha256.Sum256([]byte(codeVerifier)) + expectedChallenge = base64.RawURLEncoding.EncodeToString(hash[:]) + } else { + // Plain method + expectedChallenge = codeVerifier + } + + if expectedChallenge != *authCode.CodeChallenge { + return nil, ErrPKCEVerifyFailed + } + } + + // Mark code as used + _, err = s.db.Exec(ctx, `UPDATE oauth_authorization_codes SET used_at = NOW() WHERE id = $1`, authCode.ID) + if err != nil { + return nil, fmt.Errorf("failed to mark code as used: %w", err) + } + + // Parse scopes + var scopes []string + json.Unmarshal(scopesJSON, &scopes) + + // Generate tokens + return s.generateTokens(ctx, clientID, authCode.UserID, scopes) +} + +// RefreshAccessToken refreshes an access token using a refresh token +func (s *OAuthService) RefreshAccessToken(ctx context.Context, refreshToken, clientID string, requestedScope string) (*models.OAuthTokenResponse, error) { + // Hash the refresh token + tokenHash := sha256.Sum256([]byte(refreshToken)) + hashedToken := hex.EncodeToString(tokenHash[:]) + + var rt models.OAuthRefreshToken + var scopesJSON []byte + + err := s.db.QueryRow(ctx, ` + SELECT id, client_id, user_id, scopes, expires_at, revoked_at + FROM oauth_refresh_tokens WHERE token_hash = $1 + `, hashedToken).Scan( + &rt.ID, &rt.ClientID, &rt.UserID, &scopesJSON, &rt.ExpiresAt, &rt.RevokedAt, + ) + + if err != nil { + return nil, ErrInvalidGrant + } + + // Check if token is revoked + if rt.RevokedAt != nil { + return nil, ErrInvalidGrant + } + + // Check if token is expired + if time.Now().After(rt.ExpiresAt) { + return nil, ErrInvalidGrant + } + + // Verify client_id matches + if rt.ClientID != clientID { + return nil, ErrInvalidGrant + } + + // Parse original scopes + var originalScopes []string + json.Unmarshal(scopesJSON, &originalScopes) + + // Determine scopes for new tokens + var scopes []string + if requestedScope != "" { + // Validate that requested scopes are subset of original scopes + originalMap := make(map[string]bool) + for _, s := range originalScopes { + originalMap[s] = true + } + + for _, s := range strings.Split(requestedScope, " ") { + if originalMap[s] { + scopes = append(scopes, s) + } + } + + if len(scopes) == 0 { + return nil, ErrInvalidScope + } + } else { + scopes = originalScopes + } + + // Revoke old refresh token (rotate) + _, _ = s.db.Exec(ctx, `UPDATE oauth_refresh_tokens SET revoked_at = NOW() WHERE id = $1`, rt.ID) + + // Generate new tokens + return s.generateTokens(ctx, clientID, rt.UserID, scopes) +} + +// generateTokens generates access and refresh tokens +func (s *OAuthService) generateTokens(ctx context.Context, clientID string, userID uuid.UUID, scopes []string) (*models.OAuthTokenResponse, error) { + // Get user info for JWT + var user models.User + err := s.db.QueryRow(ctx, ` + SELECT id, email, name, role, account_status FROM users WHERE id = $1 + `, userID).Scan(&user.ID, &user.Email, &user.Name, &user.Role, &user.AccountStatus) + + if err != nil { + return nil, ErrInvalidGrant + } + + // Generate access token (JWT) + accessTokenClaims := jwt.MapClaims{ + "sub": userID.String(), + "email": user.Email, + "role": user.Role, + "account_status": user.AccountStatus, + "client_id": clientID, + "scope": strings.Join(scopes, " "), + "iat": time.Now().Unix(), + "exp": time.Now().Add(s.accessTokenExpiration).Unix(), + "iss": "breakpilot-consent-service", + "aud": clientID, + } + + if user.Name != nil { + accessTokenClaims["name"] = *user.Name + } + + accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessTokenClaims) + accessTokenString, err := accessToken.SignedString([]byte(s.jwtSecret)) + if err != nil { + return nil, fmt.Errorf("failed to sign access token: %w", err) + } + + // Hash access token for storage + accessTokenHash := sha256.Sum256([]byte(accessTokenString)) + hashedAccessToken := hex.EncodeToString(accessTokenHash[:]) + + scopesJSON, _ := json.Marshal(scopes) + + // Store access token + var accessTokenID uuid.UUID + err = s.db.QueryRow(ctx, ` + INSERT INTO oauth_access_tokens (token_hash, client_id, user_id, scopes, expires_at) + VALUES ($1, $2, $3, $4, $5) + RETURNING id + `, hashedAccessToken, clientID, userID, scopesJSON, time.Now().Add(s.accessTokenExpiration)).Scan(&accessTokenID) + + if err != nil { + return nil, fmt.Errorf("failed to store access token: %w", err) + } + + // Generate refresh token (opaque) + refreshTokenBytes := make([]byte, 32) + if _, err := rand.Read(refreshTokenBytes); err != nil { + return nil, fmt.Errorf("failed to generate refresh token: %w", err) + } + refreshTokenString := base64.URLEncoding.EncodeToString(refreshTokenBytes) + + // Hash refresh token for storage + refreshTokenHash := sha256.Sum256([]byte(refreshTokenString)) + hashedRefreshToken := hex.EncodeToString(refreshTokenHash[:]) + + // Store refresh token + _, err = s.db.Exec(ctx, ` + INSERT INTO oauth_refresh_tokens (token_hash, access_token_id, client_id, user_id, scopes, expires_at) + VALUES ($1, $2, $3, $4, $5, $6) + `, hashedRefreshToken, accessTokenID, clientID, userID, scopesJSON, time.Now().Add(s.refreshTokenExpiration)) + + if err != nil { + return nil, fmt.Errorf("failed to store refresh token: %w", err) + } + + return &models.OAuthTokenResponse{ + AccessToken: accessTokenString, + TokenType: "Bearer", + ExpiresIn: int(s.accessTokenExpiration.Seconds()), + RefreshToken: refreshTokenString, + Scope: strings.Join(scopes, " "), + }, nil +} + +// RevokeToken revokes an access or refresh token +func (s *OAuthService) RevokeToken(ctx context.Context, token, tokenTypeHint string) error { + tokenHash := sha256.Sum256([]byte(token)) + hashedToken := hex.EncodeToString(tokenHash[:]) + + // Try to revoke as access token + if tokenTypeHint == "" || tokenTypeHint == "access_token" { + result, err := s.db.Exec(ctx, `UPDATE oauth_access_tokens SET revoked_at = NOW() WHERE token_hash = $1`, hashedToken) + if err == nil && result.RowsAffected() > 0 { + return nil + } + } + + // Try to revoke as refresh token + if tokenTypeHint == "" || tokenTypeHint == "refresh_token" { + result, err := s.db.Exec(ctx, `UPDATE oauth_refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1`, hashedToken) + if err == nil && result.RowsAffected() > 0 { + return nil + } + } + + return nil // RFC 7009: Always return success +} + +// ValidateAccessToken validates an OAuth access token +func (s *OAuthService) ValidateAccessToken(ctx context.Context, tokenString string) (*jwt.MapClaims, error) { + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(s.jwtSecret), nil + }) + + if err != nil { + return nil, ErrInvalidToken + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok || !token.Valid { + return nil, ErrInvalidToken + } + + // Check if token is revoked in database + tokenHash := sha256.Sum256([]byte(tokenString)) + hashedToken := hex.EncodeToString(tokenHash[:]) + + var revokedAt *time.Time + err = s.db.QueryRow(ctx, `SELECT revoked_at FROM oauth_access_tokens WHERE token_hash = $1`, hashedToken).Scan(&revokedAt) + if err == nil && revokedAt != nil { + return nil, ErrInvalidToken + } + + return &claims, nil +} + +// GetClientByID retrieves an OAuth client by its client_id +func (s *OAuthService) GetClientByID(ctx context.Context, clientID string) (*models.OAuthClient, error) { + return s.ValidateClient(ctx, clientID) +} + +// CreateClient creates a new OAuth client (admin only) +func (s *OAuthService) CreateClient( + ctx context.Context, + name, description string, + redirectURIs, scopes, grantTypes []string, + isPublic bool, + createdBy *uuid.UUID, +) (*models.OAuthClient, string, error) { + // Generate client_id + clientIDBytes := make([]byte, 16) + rand.Read(clientIDBytes) + clientID := hex.EncodeToString(clientIDBytes) + + // Generate client_secret for confidential clients + var clientSecret string + var clientSecretPtr *string + if !isPublic { + secretBytes := make([]byte, 32) + rand.Read(secretBytes) + clientSecret = base64.URLEncoding.EncodeToString(secretBytes) + clientSecretPtr = &clientSecret + } + + redirectURIsJSON, _ := json.Marshal(redirectURIs) + scopesJSON, _ := json.Marshal(scopes) + grantTypesJSON, _ := json.Marshal(grantTypes) + + var client models.OAuthClient + err := s.db.QueryRow(ctx, ` + INSERT INTO oauth_clients (client_id, client_secret, name, description, redirect_uris, scopes, grant_types, is_public, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id, client_id, name, is_public, is_active, created_at + `, clientID, clientSecretPtr, name, description, redirectURIsJSON, scopesJSON, grantTypesJSON, isPublic, createdBy).Scan( + &client.ID, &client.ClientID, &client.Name, &client.IsPublic, &client.IsActive, &client.CreatedAt, + ) + + if err != nil { + return nil, "", fmt.Errorf("failed to create client: %w", err) + } + + client.RedirectURIs = redirectURIs + client.Scopes = scopes + client.GrantTypes = grantTypes + + return &client, clientSecret, nil +} diff --git a/consent-service/internal/services/oauth_service_test.go b/consent-service/internal/services/oauth_service_test.go new file mode 100644 index 0000000..456066a --- /dev/null +++ b/consent-service/internal/services/oauth_service_test.go @@ -0,0 +1,855 @@ +package services + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "strings" + "testing" + "time" +) + +// TestPKCEVerification tests PKCE code_challenge and code_verifier validation +func TestPKCEVerification_S256_ValidVerifier(t *testing.T) { + // Generate a code_verifier + codeVerifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + + // Calculate expected code_challenge (S256) + hash := sha256.Sum256([]byte(codeVerifier)) + codeChallenge := base64.RawURLEncoding.EncodeToString(hash[:]) + + // Verify the challenge matches + verifierHash := sha256.Sum256([]byte(codeVerifier)) + calculatedChallenge := base64.RawURLEncoding.EncodeToString(verifierHash[:]) + + if calculatedChallenge != codeChallenge { + t.Errorf("PKCE verification failed: expected %s, got %s", codeChallenge, calculatedChallenge) + } +} + +func TestPKCEVerification_S256_InvalidVerifier(t *testing.T) { + codeVerifier := "correct-verifier-12345678901234567890" + wrongVerifier := "wrong-verifier-00000000000000000000" + + // Calculate code_challenge from correct verifier + hash := sha256.Sum256([]byte(codeVerifier)) + codeChallenge := base64.RawURLEncoding.EncodeToString(hash[:]) + + // Calculate challenge from wrong verifier + wrongHash := sha256.Sum256([]byte(wrongVerifier)) + wrongChallenge := base64.RawURLEncoding.EncodeToString(wrongHash[:]) + + if wrongChallenge == codeChallenge { + t.Error("PKCE verification should fail for wrong verifier") + } +} + +func TestPKCEVerification_Plain_ValidVerifier(t *testing.T) { + codeVerifier := "plain-text-verifier-12345" + codeChallenge := codeVerifier // Plain method: challenge = verifier + + if codeVerifier != codeChallenge { + t.Error("Plain PKCE verification failed") + } +} + +// TestTokenHashing tests that token hashing is consistent +func TestTokenHashing_Consistency(t *testing.T) { + token := "sample-access-token-12345" + + hash1 := sha256.Sum256([]byte(token)) + hash2 := sha256.Sum256([]byte(token)) + + if hash1 != hash2 { + t.Error("Token hashing should be consistent") + } +} + +func TestTokenHashing_DifferentTokens(t *testing.T) { + token1 := "token-1-abcdefgh" + token2 := "token-2-ijklmnop" + + hash1 := sha256.Sum256([]byte(token1)) + hash2 := sha256.Sum256([]byte(token2)) + + if hash1 == hash2 { + t.Error("Different tokens should produce different hashes") + } +} + +// TestScopeValidation tests scope parsing and validation +func TestScopeValidation_ParseScopes(t *testing.T) { + tests := []struct { + name string + requestedScope string + allowedScopes []string + expectedCount int + }{ + { + name: "all scopes allowed", + requestedScope: "openid profile email", + allowedScopes: []string{"openid", "profile", "email", "offline_access"}, + expectedCount: 3, + }, + { + name: "some scopes allowed", + requestedScope: "openid profile admin", + allowedScopes: []string{"openid", "profile", "email"}, + expectedCount: 2, // admin not allowed + }, + { + name: "no scopes allowed", + requestedScope: "admin superuser", + allowedScopes: []string{"openid", "profile", "email"}, + expectedCount: 0, + }, + { + name: "empty request defaults", + requestedScope: "", + allowedScopes: []string{"openid", "profile", "email"}, + expectedCount: 0, // Empty request returns 0 from this test logic + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.requestedScope == "" { + // Empty scope should use defaults in actual service + return + } + + allowedMap := make(map[string]bool) + for _, scope := range tt.allowedScopes { + allowedMap[scope] = true + } + + var validScopes []string + requestedScopes := splitScopes(tt.requestedScope) + for _, scope := range requestedScopes { + if allowedMap[scope] { + validScopes = append(validScopes, scope) + } + } + + if len(validScopes) != tt.expectedCount { + t.Errorf("Expected %d valid scopes, got %d", tt.expectedCount, len(validScopes)) + } + }) + } +} + +// Helper function for scope splitting +func splitScopes(scopes string) []string { + if scopes == "" { + return nil + } + var result []string + start := 0 + for i := 0; i <= len(scopes); i++ { + if i == len(scopes) || scopes[i] == ' ' { + if start < i { + result = append(result, scopes[start:i]) + } + start = i + 1 + } + } + return result +} + +// TestRedirectURIValidation tests redirect URI validation +func TestRedirectURIValidation(t *testing.T) { + tests := []struct { + name string + registeredURIs []string + requestURI string + shouldMatch bool + }{ + { + name: "exact match", + registeredURIs: []string{"https://example.com/callback"}, + requestURI: "https://example.com/callback", + shouldMatch: true, + }, + { + name: "no match different domain", + registeredURIs: []string{"https://example.com/callback"}, + requestURI: "https://evil.com/callback", + shouldMatch: false, + }, + { + name: "no match different path", + registeredURIs: []string{"https://example.com/callback"}, + requestURI: "https://example.com/other", + shouldMatch: false, + }, + { + name: "multiple URIs - second matches", + registeredURIs: []string{"https://example.com/callback", "https://example.com/auth"}, + requestURI: "https://example.com/auth", + shouldMatch: true, + }, + { + name: "localhost for development", + registeredURIs: []string{"http://localhost:3000/callback"}, + requestURI: "http://localhost:3000/callback", + shouldMatch: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matched := false + for _, uri := range tt.registeredURIs { + if uri == tt.requestURI { + matched = true + break + } + } + + if matched != tt.shouldMatch { + t.Errorf("Expected match=%v, got match=%v", tt.shouldMatch, matched) + } + }) + } +} + +// TestGrantTypeValidation tests grant type validation +func TestGrantTypeValidation(t *testing.T) { + tests := []struct { + name string + allowedGrants []string + requestedGrant string + shouldAllow bool + }{ + { + name: "authorization_code allowed", + allowedGrants: []string{"authorization_code", "refresh_token"}, + requestedGrant: "authorization_code", + shouldAllow: true, + }, + { + name: "refresh_token allowed", + allowedGrants: []string{"authorization_code", "refresh_token"}, + requestedGrant: "refresh_token", + shouldAllow: true, + }, + { + name: "password not allowed", + allowedGrants: []string{"authorization_code", "refresh_token"}, + requestedGrant: "password", + shouldAllow: false, + }, + { + name: "client_credentials not allowed", + allowedGrants: []string{"authorization_code", "refresh_token"}, + requestedGrant: "client_credentials", + shouldAllow: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + allowed := false + for _, grant := range tt.allowedGrants { + if grant == tt.requestedGrant { + allowed = true + break + } + } + + if allowed != tt.shouldAllow { + t.Errorf("Expected allow=%v, got allow=%v", tt.shouldAllow, allowed) + } + }) + } +} + +// TestAuthorizationCodeExpiry tests that expired codes should be rejected +func TestAuthorizationCodeExpiry_Logic(t *testing.T) { + tests := []struct { + name string + expiryMins int + usedAfter int // minutes after creation + shouldAllow bool + }{ + { + name: "code used within expiry", + expiryMins: 10, + usedAfter: 5, + shouldAllow: true, + }, + { + name: "code used at expiry boundary", + expiryMins: 10, + usedAfter: 10, + shouldAllow: false, // Expired at exactly 10 mins + }, + { + name: "code used after expiry", + expiryMins: 10, + usedAfter: 15, + shouldAllow: false, + }, + { + name: "code used immediately", + expiryMins: 10, + usedAfter: 0, + shouldAllow: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isValid := tt.usedAfter < tt.expiryMins + + if isValid != tt.shouldAllow { + t.Errorf("Expected allow=%v for code used after %d mins (expiry: %d mins)", + tt.shouldAllow, tt.usedAfter, tt.expiryMins) + } + }) + } +} + +// TestClientSecretValidation tests confidential client authentication +func TestClientSecretValidation(t *testing.T) { + tests := []struct { + name string + isPublic bool + storedSecret string + providedSecret string + shouldAllow bool + }{ + { + name: "public client - no secret needed", + isPublic: true, + storedSecret: "", + providedSecret: "", + shouldAllow: true, + }, + { + name: "confidential client - correct secret", + isPublic: false, + storedSecret: "super-secret-123", + providedSecret: "super-secret-123", + shouldAllow: true, + }, + { + name: "confidential client - wrong secret", + isPublic: false, + storedSecret: "super-secret-123", + providedSecret: "wrong-secret", + shouldAllow: false, + }, + { + name: "confidential client - empty secret", + isPublic: false, + storedSecret: "super-secret-123", + providedSecret: "", + shouldAllow: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var isValid bool + if tt.isPublic { + isValid = true + } else { + isValid = tt.storedSecret == tt.providedSecret + } + + if isValid != tt.shouldAllow { + t.Errorf("Expected allow=%v, got allow=%v", tt.shouldAllow, isValid) + } + }) + } +} + +// ======================================== +// Extended OAuth 2.0 Tests +// ======================================== + +// TestCodeVerifierGeneration tests that code verifiers meet RFC 7636 requirements +func TestCodeVerifierGeneration_RFC7636(t *testing.T) { + tests := []struct { + name string + length int + expectedLength int + description string + }{ + {"minimum length (43)", 43, 43, "RFC 7636 minimum"}, + {"standard length (64)", 64, 64, "Recommended length"}, + {"maximum length (128)", 128, 128, "RFC 7636 maximum"}, + {"too short (42) - corrected to minimum", 42, 43, "Should be corrected to minimum"}, + {"too long (129) - corrected to maximum", 129, 128, "Should be corrected to maximum"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + verifier := generateCodeVerifier(tt.length) + + // Check that length is corrected to valid range + if len(verifier) != tt.expectedLength { + t.Errorf("Expected length %d, got %d", tt.expectedLength, len(verifier)) + } + + // Check character set (unreserved characters only: A-Z, a-z, 0-9, -, ., _, ~) + for _, c := range verifier { + if !isUnreservedChar(c) { + t.Errorf("Code verifier contains invalid character: %c", c) + } + } + }) + } +} + +// TestCodeVerifierLength_Validation tests length validation logic +func TestCodeVerifierLength_Validation(t *testing.T) { + tests := []struct { + name string + length int + isValid bool + }{ + {"length 42 - too short", 42, false}, + {"length 43 - minimum valid", 43, true}, + {"length 64 - recommended", 64, true}, + {"length 128 - maximum valid", 128, true}, + {"length 129 - too long", 129, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isValid := tt.length >= 43 && tt.length <= 128 + if isValid != tt.isValid { + t.Errorf("Expected valid=%v for length %d, got valid=%v", + tt.isValid, tt.length, isValid) + } + }) + } +} + +// generateCodeVerifier generates a code verifier of specified length +func generateCodeVerifier(length int) string { + // Ensure minimum and maximum bounds + if length < 43 { + length = 43 + } + if length > 128 { + length = 128 + } + + const unreserved = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + + bytes := make([]byte, length) + rand.Read(bytes) + + result := make([]byte, length) + for i, b := range bytes { + result[i] = unreserved[int(b)%len(unreserved)] + } + + return string(result) +} + +// isUnreservedChar checks if a character is an unreserved character per RFC 3986 +func isUnreservedChar(c rune) bool { + return (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '.' || c == '_' || c == '~' +} + +// TestCodeChallengeGeneration tests S256 challenge generation +func TestCodeChallengeGeneration_S256(t *testing.T) { + // Known test vector from RFC 7636 Appendix B + verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + expectedChallenge := "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + + hash := sha256.Sum256([]byte(verifier)) + challenge := base64.RawURLEncoding.EncodeToString(hash[:]) + + if challenge != expectedChallenge { + t.Errorf("S256 challenge mismatch: expected %s, got %s", expectedChallenge, challenge) + } +} + +// TestRefreshTokenRotation tests that refresh tokens are rotated on use +func TestRefreshTokenRotation_Logic(t *testing.T) { + // Simulate refresh token rotation + oldToken := "old-refresh-token-123" + oldTokenHash := hashToken(oldToken) + + // Generate new token + newToken := generateSecureToken(32) + newTokenHash := hashToken(newToken) + + // Verify tokens are different + if oldTokenHash == newTokenHash { + t.Error("New refresh token should be different from old token") + } + + // Verify old token would be revoked (simulated by marking revoked_at) + oldTokenRevoked := true + if !oldTokenRevoked { + t.Error("Old refresh token should be revoked after rotation") + } +} + +func hashToken(token string) string { + hash := sha256.Sum256([]byte(token)) + return hex.EncodeToString(hash[:]) +} + +func generateSecureToken(length int) string { + bytes := make([]byte, length) + rand.Read(bytes) + return base64.URLEncoding.EncodeToString(bytes) +} + +// TestAccessTokenExpiry tests access token expiration handling +func TestAccessTokenExpiry_Scenarios(t *testing.T) { + tests := []struct { + name string + tokenDuration time.Duration + usedAfter time.Duration + shouldBeValid bool + }{ + { + name: "token used immediately", + tokenDuration: time.Hour, + usedAfter: 0, + shouldBeValid: true, + }, + { + name: "token used within validity", + tokenDuration: time.Hour, + usedAfter: 30 * time.Minute, + shouldBeValid: true, + }, + { + name: "token used at expiry", + tokenDuration: time.Hour, + usedAfter: time.Hour, + shouldBeValid: false, + }, + { + name: "token used after expiry", + tokenDuration: time.Hour, + usedAfter: 2 * time.Hour, + shouldBeValid: false, + }, + { + name: "short-lived token", + tokenDuration: 5 * time.Minute, + usedAfter: 6 * time.Minute, + shouldBeValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + issuedAt := time.Now() + expiresAt := issuedAt.Add(tt.tokenDuration) + usedAt := issuedAt.Add(tt.usedAfter) + + isValid := usedAt.Before(expiresAt) + + if isValid != tt.shouldBeValid { + t.Errorf("Expected valid=%v for token used after %v (duration: %v)", + tt.shouldBeValid, tt.usedAfter, tt.tokenDuration) + } + }) + } +} + +// TestOAuthErrors tests that OAuth error codes are correct +func TestOAuthErrors_RFC6749(t *testing.T) { + tests := []struct { + scenario string + errorCode string + description string + }{ + {"invalid client_id", "invalid_client", "Client authentication failed"}, + {"invalid grant (code)", "invalid_grant", "Authorization code invalid or expired"}, + {"invalid scope", "invalid_scope", "Requested scope is invalid"}, + {"invalid request", "invalid_request", "Request is missing required parameter"}, + {"unauthorized client", "unauthorized_client", "Client not authorized for this grant type"}, + {"access denied", "access_denied", "Resource owner denied the request"}, + } + + for _, tt := range tests { + t.Run(tt.scenario, func(t *testing.T) { + // Verify error codes match RFC 6749 Section 5.2 + validErrors := map[string]bool{ + "invalid_request": true, + "invalid_client": true, + "invalid_grant": true, + "unauthorized_client": true, + "unsupported_grant_type": true, + "invalid_scope": true, + "access_denied": true, + "unsupported_response_type": true, + "server_error": true, + "temporarily_unavailable": true, + } + + if !validErrors[tt.errorCode] { + t.Errorf("Error code %s is not a valid OAuth 2.0 error code", tt.errorCode) + } + }) + } +} + +// TestStateParameter tests state parameter handling for CSRF protection +func TestStateParameter_CSRF(t *testing.T) { + tests := []struct { + name string + requestState string + responseState string + shouldMatch bool + }{ + { + name: "matching state", + requestState: "abc123xyz", + responseState: "abc123xyz", + shouldMatch: true, + }, + { + name: "non-matching state", + requestState: "abc123xyz", + responseState: "different", + shouldMatch: false, + }, + { + name: "empty request state", + requestState: "", + responseState: "abc123xyz", + shouldMatch: false, + }, + { + name: "empty response state", + requestState: "abc123xyz", + responseState: "", + shouldMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + matches := tt.requestState != "" && tt.requestState == tt.responseState + + if matches != tt.shouldMatch { + t.Errorf("Expected match=%v, got match=%v", tt.shouldMatch, matches) + } + }) + } +} + +// TestResponseType tests response_type validation +func TestResponseType_Validation(t *testing.T) { + tests := []struct { + name string + responseType string + isValid bool + }{ + {"code - valid", "code", true}, + {"token - implicit flow (disabled)", "token", false}, + {"id_token - OIDC", "id_token", false}, + {"code token - hybrid", "code token", false}, + {"empty", "", false}, + {"invalid", "password", false}, + } + + supportedResponseTypes := map[string]bool{ + "code": true, // Only authorization code flow is supported + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isValid := supportedResponseTypes[tt.responseType] + if isValid != tt.isValid { + t.Errorf("Expected valid=%v for response_type=%s, got valid=%v", + tt.isValid, tt.responseType, isValid) + } + }) + } +} + +// TestCodeChallengeMethod tests code_challenge_method validation +func TestCodeChallengeMethod_Validation(t *testing.T) { + tests := []struct { + name string + method string + isValid bool + }{ + {"S256 - recommended", "S256", true}, + {"plain - discouraged but valid", "plain", true}, + {"empty - defaults to plain", "", true}, + {"sha512 - not supported", "sha512", false}, + {"invalid", "md5", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isValid := tt.method == "S256" || tt.method == "plain" || tt.method == "" + if isValid != tt.isValid { + t.Errorf("Expected valid=%v for method=%s, got valid=%v", + tt.isValid, tt.method, isValid) + } + }) + } +} + +// TestTokenRevocation tests token revocation behavior per RFC 7009 +func TestTokenRevocation_RFC7009(t *testing.T) { + tests := []struct { + name string + tokenExists bool + tokenRevoked bool + expectSuccess bool + }{ + { + name: "revoke existing active token", + tokenExists: true, + tokenRevoked: false, + expectSuccess: true, + }, + { + name: "revoke already revoked token", + tokenExists: true, + tokenRevoked: true, + expectSuccess: true, // RFC 7009: Always return 200 + }, + { + name: "revoke non-existent token", + tokenExists: false, + tokenRevoked: false, + expectSuccess: true, // RFC 7009: Always return 200 + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Simulate revocation logic + // Per RFC 7009, revocation endpoint always returns 200 OK + success := true + + if success != tt.expectSuccess { + t.Errorf("Expected success=%v, got success=%v", tt.expectSuccess, success) + } + }) + } +} + +// TestClientIDGeneration tests client_id format +func TestClientIDGeneration_Format(t *testing.T) { + // Generate multiple client IDs + clientIDs := make(map[string]bool) + for i := 0; i < 100; i++ { + bytes := make([]byte, 16) + rand.Read(bytes) + clientID := hex.EncodeToString(bytes) + + // Check format (32 hex characters) + if len(clientID) != 32 { + t.Errorf("Client ID should be 32 characters, got %d", len(clientID)) + } + + // Check uniqueness + if clientIDs[clientID] { + t.Error("Client ID should be unique") + } + clientIDs[clientID] = true + + // Check only hex characters + for _, c := range clientID { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + t.Errorf("Client ID should only contain hex characters, found %c", c) + } + } + } +} + +// TestScopeNormalization tests scope string normalization +func TestScopeNormalization(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "single scope", + input: "openid", + expected: []string{"openid"}, + }, + { + name: "multiple scopes", + input: "openid profile email", + expected: []string{"openid", "profile", "email"}, + }, + { + name: "extra spaces", + input: "openid profile email", + expected: []string{"openid", "profile", "email"}, + }, + { + name: "empty string", + input: "", + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + scopes := normalizeScopes(tt.input) + + if len(scopes) != len(tt.expected) { + t.Errorf("Expected %d scopes, got %d", len(tt.expected), len(scopes)) + return + } + + for i, scope := range scopes { + if scope != tt.expected[i] { + t.Errorf("Expected scope[%d]=%s, got %s", i, tt.expected[i], scope) + } + } + }) + } +} + +func normalizeScopes(scope string) []string { + if scope == "" { + return []string{} + } + + parts := strings.Fields(scope) // Handles multiple spaces + return parts +} + +// BenchmarkPKCEVerification benchmarks PKCE S256 verification +func BenchmarkPKCEVerification_S256(b *testing.B) { + verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + + for i := 0; i < b.N; i++ { + hash := sha256.Sum256([]byte(verifier)) + base64.RawURLEncoding.EncodeToString(hash[:]) + } +} + +// BenchmarkTokenHashing benchmarks token hashing for storage +func BenchmarkTokenHashing(b *testing.B) { + token := "sample-access-token-12345678901234567890" + + for i := 0; i < b.N; i++ { + hash := sha256.Sum256([]byte(token)) + hex.EncodeToString(hash[:]) + } +} + +// BenchmarkCodeVerifierGeneration benchmarks code verifier generation +func BenchmarkCodeVerifierGeneration(b *testing.B) { + for i := 0; i < b.N; i++ { + generateCodeVerifier(64) + } +} diff --git a/consent-service/internal/services/school_service.go b/consent-service/internal/services/school_service.go new file mode 100644 index 0000000..3d63f00 --- /dev/null +++ b/consent-service/internal/services/school_service.go @@ -0,0 +1,698 @@ +package services + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "time" + + "github.com/breakpilot/consent-service/internal/database" + "github.com/breakpilot/consent-service/internal/models" + "github.com/breakpilot/consent-service/internal/services/matrix" + + "github.com/google/uuid" +) + +// SchoolService handles school management operations +type SchoolService struct { + db *database.DB + matrix *matrix.MatrixService +} + +// NewSchoolService creates a new school service +func NewSchoolService(db *database.DB, matrixService *matrix.MatrixService) *SchoolService { + return &SchoolService{ + db: db, + matrix: matrixService, + } +} + +// ======================================== +// School CRUD +// ======================================== + +// CreateSchool creates a new school +func (s *SchoolService) CreateSchool(ctx context.Context, req models.CreateSchoolRequest) (*models.School, error) { + school := &models.School{ + ID: uuid.New(), + Name: req.Name, + ShortName: req.ShortName, + Type: req.Type, + Address: req.Address, + City: req.City, + PostalCode: req.PostalCode, + State: req.State, + Country: "DE", + Phone: req.Phone, + Email: req.Email, + Website: req.Website, + IsActive: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + query := ` + INSERT INTO schools (id, name, short_name, type, address, city, postal_code, state, country, phone, email, website, is_active, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + RETURNING id` + + err := s.db.Pool.QueryRow(ctx, query, + school.ID, school.Name, school.ShortName, school.Type, school.Address, + school.City, school.PostalCode, school.State, school.Country, school.Phone, + school.Email, school.Website, school.IsActive, school.CreatedAt, school.UpdatedAt, + ).Scan(&school.ID) + + if err != nil { + return nil, fmt.Errorf("failed to create school: %w", err) + } + + // Create default timetable slots for the school + if err := s.createDefaultTimetableSlots(ctx, school.ID); err != nil { + // Log but don't fail + fmt.Printf("Warning: failed to create default timetable slots: %v\n", err) + } + + // Create default grade scale + if err := s.createDefaultGradeScale(ctx, school.ID); err != nil { + fmt.Printf("Warning: failed to create default grade scale: %v\n", err) + } + + return school, nil +} + +// GetSchool retrieves a school by ID +func (s *SchoolService) GetSchool(ctx context.Context, schoolID uuid.UUID) (*models.School, error) { + query := ` + SELECT id, name, short_name, type, address, city, postal_code, state, country, phone, email, website, matrix_server_name, logo_url, is_active, created_at, updated_at + FROM schools + WHERE id = $1` + + school := &models.School{} + err := s.db.Pool.QueryRow(ctx, query, schoolID).Scan( + &school.ID, &school.Name, &school.ShortName, &school.Type, &school.Address, + &school.City, &school.PostalCode, &school.State, &school.Country, &school.Phone, + &school.Email, &school.Website, &school.MatrixServerName, &school.LogoURL, + &school.IsActive, &school.CreatedAt, &school.UpdatedAt, + ) + + if err != nil { + return nil, fmt.Errorf("failed to get school: %w", err) + } + + return school, nil +} + +// ListSchools lists all active schools +func (s *SchoolService) ListSchools(ctx context.Context) ([]models.School, error) { + query := ` + SELECT id, name, short_name, type, address, city, postal_code, state, country, phone, email, website, matrix_server_name, logo_url, is_active, created_at, updated_at + FROM schools + WHERE is_active = true + ORDER BY name` + + rows, err := s.db.Pool.Query(ctx, query) + if err != nil { + return nil, fmt.Errorf("failed to list schools: %w", err) + } + defer rows.Close() + + var schools []models.School + for rows.Next() { + var school models.School + err := rows.Scan( + &school.ID, &school.Name, &school.ShortName, &school.Type, &school.Address, + &school.City, &school.PostalCode, &school.State, &school.Country, &school.Phone, + &school.Email, &school.Website, &school.MatrixServerName, &school.LogoURL, + &school.IsActive, &school.CreatedAt, &school.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan school: %w", err) + } + schools = append(schools, school) + } + + return schools, nil +} + +// ======================================== +// School Year Management +// ======================================== + +// CreateSchoolYear creates a new school year +func (s *SchoolService) CreateSchoolYear(ctx context.Context, schoolID uuid.UUID, name string, startDate, endDate time.Time) (*models.SchoolYear, error) { + schoolYear := &models.SchoolYear{ + ID: uuid.New(), + SchoolID: schoolID, + Name: name, + StartDate: startDate, + EndDate: endDate, + IsCurrent: false, + CreatedAt: time.Now(), + } + + query := ` + INSERT INTO school_years (id, school_id, name, start_date, end_date, is_current, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id` + + err := s.db.Pool.QueryRow(ctx, query, + schoolYear.ID, schoolYear.SchoolID, schoolYear.Name, + schoolYear.StartDate, schoolYear.EndDate, schoolYear.IsCurrent, schoolYear.CreatedAt, + ).Scan(&schoolYear.ID) + + if err != nil { + return nil, fmt.Errorf("failed to create school year: %w", err) + } + + return schoolYear, nil +} + +// SetCurrentSchoolYear sets a school year as the current one +func (s *SchoolService) SetCurrentSchoolYear(ctx context.Context, schoolID, schoolYearID uuid.UUID) error { + // First, unset all current school years for this school + _, err := s.db.Pool.Exec(ctx, `UPDATE school_years SET is_current = false WHERE school_id = $1`, schoolID) + if err != nil { + return fmt.Errorf("failed to unset current school years: %w", err) + } + + // Then set the specified school year as current + _, err = s.db.Pool.Exec(ctx, `UPDATE school_years SET is_current = true WHERE id = $1 AND school_id = $2`, schoolYearID, schoolID) + if err != nil { + return fmt.Errorf("failed to set current school year: %w", err) + } + + return nil +} + +// GetCurrentSchoolYear gets the current school year for a school +func (s *SchoolService) GetCurrentSchoolYear(ctx context.Context, schoolID uuid.UUID) (*models.SchoolYear, error) { + query := ` + SELECT id, school_id, name, start_date, end_date, is_current, created_at + FROM school_years + WHERE school_id = $1 AND is_current = true` + + schoolYear := &models.SchoolYear{} + err := s.db.Pool.QueryRow(ctx, query, schoolID).Scan( + &schoolYear.ID, &schoolYear.SchoolID, &schoolYear.Name, + &schoolYear.StartDate, &schoolYear.EndDate, &schoolYear.IsCurrent, &schoolYear.CreatedAt, + ) + + if err != nil { + return nil, fmt.Errorf("failed to get current school year: %w", err) + } + + return schoolYear, nil +} + +// ======================================== +// Class Management +// ======================================== + +// CreateClass creates a new class +func (s *SchoolService) CreateClass(ctx context.Context, schoolID uuid.UUID, req models.CreateClassRequest) (*models.Class, error) { + schoolYearID, err := uuid.Parse(req.SchoolYearID) + if err != nil { + return nil, fmt.Errorf("invalid school year ID: %w", err) + } + + class := &models.Class{ + ID: uuid.New(), + SchoolID: schoolID, + SchoolYearID: schoolYearID, + Name: req.Name, + Grade: req.Grade, + Section: req.Section, + Room: req.Room, + IsActive: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + query := ` + INSERT INTO classes (id, school_id, school_year_id, name, grade, section, room, is_active, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id` + + err = s.db.Pool.QueryRow(ctx, query, + class.ID, class.SchoolID, class.SchoolYearID, class.Name, + class.Grade, class.Section, class.Room, class.IsActive, class.CreatedAt, class.UpdatedAt, + ).Scan(&class.ID) + + if err != nil { + return nil, fmt.Errorf("failed to create class: %w", err) + } + + return class, nil +} + +// GetClass retrieves a class by ID +func (s *SchoolService) GetClass(ctx context.Context, classID uuid.UUID) (*models.Class, error) { + query := ` + SELECT id, school_id, school_year_id, name, grade, section, room, matrix_info_room, matrix_rep_room, is_active, created_at, updated_at + FROM classes + WHERE id = $1` + + class := &models.Class{} + err := s.db.Pool.QueryRow(ctx, query, classID).Scan( + &class.ID, &class.SchoolID, &class.SchoolYearID, &class.Name, + &class.Grade, &class.Section, &class.Room, &class.MatrixInfoRoom, + &class.MatrixRepRoom, &class.IsActive, &class.CreatedAt, &class.UpdatedAt, + ) + + if err != nil { + return nil, fmt.Errorf("failed to get class: %w", err) + } + + return class, nil +} + +// ListClasses lists all classes for a school in a school year +func (s *SchoolService) ListClasses(ctx context.Context, schoolID, schoolYearID uuid.UUID) ([]models.Class, error) { + query := ` + SELECT id, school_id, school_year_id, name, grade, section, room, matrix_info_room, matrix_rep_room, is_active, created_at, updated_at + FROM classes + WHERE school_id = $1 AND school_year_id = $2 AND is_active = true + ORDER BY grade, name` + + rows, err := s.db.Pool.Query(ctx, query, schoolID, schoolYearID) + if err != nil { + return nil, fmt.Errorf("failed to list classes: %w", err) + } + defer rows.Close() + + var classes []models.Class + for rows.Next() { + var class models.Class + err := rows.Scan( + &class.ID, &class.SchoolID, &class.SchoolYearID, &class.Name, + &class.Grade, &class.Section, &class.Room, &class.MatrixInfoRoom, + &class.MatrixRepRoom, &class.IsActive, &class.CreatedAt, &class.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan class: %w", err) + } + classes = append(classes, class) + } + + return classes, nil +} + +// ======================================== +// Student Management +// ======================================== + +// CreateStudent creates a new student +func (s *SchoolService) CreateStudent(ctx context.Context, schoolID uuid.UUID, req models.CreateStudentRequest) (*models.Student, error) { + classID, err := uuid.Parse(req.ClassID) + if err != nil { + return nil, fmt.Errorf("invalid class ID: %w", err) + } + + student := &models.Student{ + ID: uuid.New(), + SchoolID: schoolID, + ClassID: classID, + StudentNumber: req.StudentNumber, + FirstName: req.FirstName, + LastName: req.LastName, + Gender: req.Gender, + IsActive: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + if req.DateOfBirth != nil { + dob, err := time.Parse("2006-01-02", *req.DateOfBirth) + if err == nil { + student.DateOfBirth = &dob + } + } + + query := ` + INSERT INTO students (id, school_id, class_id, student_number, first_name, last_name, date_of_birth, gender, is_active, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING id` + + err = s.db.Pool.QueryRow(ctx, query, + student.ID, student.SchoolID, student.ClassID, student.StudentNumber, + student.FirstName, student.LastName, student.DateOfBirth, student.Gender, + student.IsActive, student.CreatedAt, student.UpdatedAt, + ).Scan(&student.ID) + + if err != nil { + return nil, fmt.Errorf("failed to create student: %w", err) + } + + return student, nil +} + +// GetStudent retrieves a student by ID +func (s *SchoolService) GetStudent(ctx context.Context, studentID uuid.UUID) (*models.Student, error) { + query := ` + SELECT id, school_id, class_id, user_id, student_number, first_name, last_name, date_of_birth, gender, matrix_user_id, matrix_dm_room, is_active, created_at, updated_at + FROM students + WHERE id = $1` + + student := &models.Student{} + err := s.db.Pool.QueryRow(ctx, query, studentID).Scan( + &student.ID, &student.SchoolID, &student.ClassID, &student.UserID, + &student.StudentNumber, &student.FirstName, &student.LastName, + &student.DateOfBirth, &student.Gender, &student.MatrixUserID, + &student.MatrixDMRoom, &student.IsActive, &student.CreatedAt, &student.UpdatedAt, + ) + + if err != nil { + return nil, fmt.Errorf("failed to get student: %w", err) + } + + return student, nil +} + +// ListStudentsByClass lists all students in a class +func (s *SchoolService) ListStudentsByClass(ctx context.Context, classID uuid.UUID) ([]models.Student, error) { + query := ` + SELECT id, school_id, class_id, user_id, student_number, first_name, last_name, date_of_birth, gender, matrix_user_id, matrix_dm_room, is_active, created_at, updated_at + FROM students + WHERE class_id = $1 AND is_active = true + ORDER BY last_name, first_name` + + rows, err := s.db.Pool.Query(ctx, query, classID) + if err != nil { + return nil, fmt.Errorf("failed to list students: %w", err) + } + defer rows.Close() + + var students []models.Student + for rows.Next() { + var student models.Student + err := rows.Scan( + &student.ID, &student.SchoolID, &student.ClassID, &student.UserID, + &student.StudentNumber, &student.FirstName, &student.LastName, + &student.DateOfBirth, &student.Gender, &student.MatrixUserID, + &student.MatrixDMRoom, &student.IsActive, &student.CreatedAt, &student.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan student: %w", err) + } + students = append(students, student) + } + + return students, nil +} + +// ======================================== +// Teacher Management +// ======================================== + +// CreateTeacher creates a new teacher linked to a user account +func (s *SchoolService) CreateTeacher(ctx context.Context, schoolID, userID uuid.UUID, firstName, lastName string, teacherCode, title *string) (*models.Teacher, error) { + teacher := &models.Teacher{ + ID: uuid.New(), + SchoolID: schoolID, + UserID: userID, + TeacherCode: teacherCode, + Title: title, + FirstName: firstName, + LastName: lastName, + IsActive: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + query := ` + INSERT INTO teachers (id, school_id, user_id, teacher_code, title, first_name, last_name, is_active, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id` + + err := s.db.Pool.QueryRow(ctx, query, + teacher.ID, teacher.SchoolID, teacher.UserID, teacher.TeacherCode, + teacher.Title, teacher.FirstName, teacher.LastName, + teacher.IsActive, teacher.CreatedAt, teacher.UpdatedAt, + ).Scan(&teacher.ID) + + if err != nil { + return nil, fmt.Errorf("failed to create teacher: %w", err) + } + + return teacher, nil +} + +// GetTeacher retrieves a teacher by ID +func (s *SchoolService) GetTeacher(ctx context.Context, teacherID uuid.UUID) (*models.Teacher, error) { + query := ` + SELECT id, school_id, user_id, teacher_code, title, first_name, last_name, matrix_user_id, is_active, created_at, updated_at + FROM teachers + WHERE id = $1` + + teacher := &models.Teacher{} + err := s.db.Pool.QueryRow(ctx, query, teacherID).Scan( + &teacher.ID, &teacher.SchoolID, &teacher.UserID, &teacher.TeacherCode, + &teacher.Title, &teacher.FirstName, &teacher.LastName, &teacher.MatrixUserID, + &teacher.IsActive, &teacher.CreatedAt, &teacher.UpdatedAt, + ) + + if err != nil { + return nil, fmt.Errorf("failed to get teacher: %w", err) + } + + return teacher, nil +} + +// GetTeacherByUserID retrieves a teacher by their user ID +func (s *SchoolService) GetTeacherByUserID(ctx context.Context, userID uuid.UUID) (*models.Teacher, error) { + query := ` + SELECT id, school_id, user_id, teacher_code, title, first_name, last_name, matrix_user_id, is_active, created_at, updated_at + FROM teachers + WHERE user_id = $1 AND is_active = true` + + teacher := &models.Teacher{} + err := s.db.Pool.QueryRow(ctx, query, userID).Scan( + &teacher.ID, &teacher.SchoolID, &teacher.UserID, &teacher.TeacherCode, + &teacher.Title, &teacher.FirstName, &teacher.LastName, &teacher.MatrixUserID, + &teacher.IsActive, &teacher.CreatedAt, &teacher.UpdatedAt, + ) + + if err != nil { + return nil, fmt.Errorf("failed to get teacher by user ID: %w", err) + } + + return teacher, nil +} + +// AssignClassTeacher assigns a teacher to a class +func (s *SchoolService) AssignClassTeacher(ctx context.Context, classID, teacherID uuid.UUID, isPrimary bool) error { + query := ` + INSERT INTO class_teachers (id, class_id, teacher_id, is_primary, created_at) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (class_id, teacher_id) DO UPDATE SET is_primary = EXCLUDED.is_primary` + + _, err := s.db.Pool.Exec(ctx, query, uuid.New(), classID, teacherID, isPrimary, time.Now()) + if err != nil { + return fmt.Errorf("failed to assign class teacher: %w", err) + } + + return nil +} + +// ======================================== +// Subject Management +// ======================================== + +// CreateSubject creates a new subject +func (s *SchoolService) CreateSubject(ctx context.Context, schoolID uuid.UUID, name, shortName string, color *string) (*models.Subject, error) { + subject := &models.Subject{ + ID: uuid.New(), + SchoolID: schoolID, + Name: name, + ShortName: shortName, + Color: color, + IsActive: true, + CreatedAt: time.Now(), + } + + query := ` + INSERT INTO subjects (id, school_id, name, short_name, color, is_active, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id` + + err := s.db.Pool.QueryRow(ctx, query, + subject.ID, subject.SchoolID, subject.Name, subject.ShortName, + subject.Color, subject.IsActive, subject.CreatedAt, + ).Scan(&subject.ID) + + if err != nil { + return nil, fmt.Errorf("failed to create subject: %w", err) + } + + return subject, nil +} + +// ListSubjects lists all subjects for a school +func (s *SchoolService) ListSubjects(ctx context.Context, schoolID uuid.UUID) ([]models.Subject, error) { + query := ` + SELECT id, school_id, name, short_name, color, is_active, created_at + FROM subjects + WHERE school_id = $1 AND is_active = true + ORDER BY name` + + rows, err := s.db.Pool.Query(ctx, query, schoolID) + if err != nil { + return nil, fmt.Errorf("failed to list subjects: %w", err) + } + defer rows.Close() + + var subjects []models.Subject + for rows.Next() { + var subject models.Subject + err := rows.Scan( + &subject.ID, &subject.SchoolID, &subject.Name, &subject.ShortName, + &subject.Color, &subject.IsActive, &subject.CreatedAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to scan subject: %w", err) + } + subjects = append(subjects, subject) + } + + return subjects, nil +} + +// ======================================== +// Parent Onboarding +// ======================================== + +// GenerateParentOnboardingToken generates a QR code token for parent onboarding +func (s *SchoolService) GenerateParentOnboardingToken(ctx context.Context, schoolID, classID, studentID, createdByUserID uuid.UUID, role string) (*models.ParentOnboardingToken, error) { + // Generate secure random token + tokenBytes := make([]byte, 32) + if _, err := rand.Read(tokenBytes); err != nil { + return nil, fmt.Errorf("failed to generate token: %w", err) + } + token := hex.EncodeToString(tokenBytes) + + onboardingToken := &models.ParentOnboardingToken{ + ID: uuid.New(), + SchoolID: schoolID, + ClassID: classID, + StudentID: studentID, + Token: token, + Role: role, + ExpiresAt: time.Now().Add(72 * time.Hour), // Valid for 72 hours + CreatedAt: time.Now(), + CreatedBy: createdByUserID, + } + + query := ` + INSERT INTO parent_onboarding_tokens (id, school_id, class_id, student_id, token, role, expires_at, created_at, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id` + + err := s.db.Pool.QueryRow(ctx, query, + onboardingToken.ID, onboardingToken.SchoolID, onboardingToken.ClassID, + onboardingToken.StudentID, onboardingToken.Token, onboardingToken.Role, + onboardingToken.ExpiresAt, onboardingToken.CreatedAt, onboardingToken.CreatedBy, + ).Scan(&onboardingToken.ID) + + if err != nil { + return nil, fmt.Errorf("failed to create onboarding token: %w", err) + } + + return onboardingToken, nil +} + +// ValidateOnboardingToken validates and retrieves info for an onboarding token +func (s *SchoolService) ValidateOnboardingToken(ctx context.Context, token string) (*models.ParentOnboardingToken, error) { + query := ` + SELECT id, school_id, class_id, student_id, token, role, expires_at, used_at, used_by_user_id, created_at, created_by + FROM parent_onboarding_tokens + WHERE token = $1 AND used_at IS NULL AND expires_at > NOW()` + + onboardingToken := &models.ParentOnboardingToken{} + err := s.db.Pool.QueryRow(ctx, query, token).Scan( + &onboardingToken.ID, &onboardingToken.SchoolID, &onboardingToken.ClassID, + &onboardingToken.StudentID, &onboardingToken.Token, &onboardingToken.Role, + &onboardingToken.ExpiresAt, &onboardingToken.UsedAt, &onboardingToken.UsedByUserID, + &onboardingToken.CreatedAt, &onboardingToken.CreatedBy, + ) + + if err != nil { + return nil, fmt.Errorf("invalid or expired token: %w", err) + } + + return onboardingToken, nil +} + +// RedeemOnboardingToken marks a token as used and creates the parent account +func (s *SchoolService) RedeemOnboardingToken(ctx context.Context, token string, userID uuid.UUID) error { + query := ` + UPDATE parent_onboarding_tokens + SET used_at = NOW(), used_by_user_id = $1 + WHERE token = $2 AND used_at IS NULL AND expires_at > NOW()` + + result, err := s.db.Pool.Exec(ctx, query, userID, token) + if err != nil { + return fmt.Errorf("failed to redeem token: %w", err) + } + + if result.RowsAffected() == 0 { + return fmt.Errorf("token not found or already used") + } + + return nil +} + +// ======================================== +// Helper Functions +// ======================================== + +func (s *SchoolService) createDefaultTimetableSlots(ctx context.Context, schoolID uuid.UUID) error { + slots := []struct { + Number int + StartTime string + EndTime string + IsBreak bool + Name string + }{ + {1, "08:00", "08:45", false, "1. Stunde"}, + {2, "08:45", "09:30", false, "2. Stunde"}, + {3, "09:30", "09:50", true, "Erste Pause"}, + {4, "09:50", "10:35", false, "3. Stunde"}, + {5, "10:35", "11:20", false, "4. Stunde"}, + {6, "11:20", "11:40", true, "Zweite Pause"}, + {7, "11:40", "12:25", false, "5. Stunde"}, + {8, "12:25", "13:10", false, "6. Stunde"}, + {9, "13:10", "14:00", true, "Mittagspause"}, + {10, "14:00", "14:45", false, "7. Stunde"}, + {11, "14:45", "15:30", false, "8. Stunde"}, + } + + query := ` + INSERT INTO timetable_slots (id, school_id, slot_number, start_time, end_time, is_break, name) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (school_id, slot_number) DO NOTHING` + + for _, slot := range slots { + _, err := s.db.Pool.Exec(ctx, query, + uuid.New(), schoolID, slot.Number, slot.StartTime, slot.EndTime, slot.IsBreak, slot.Name, + ) + if err != nil { + return err + } + } + + return nil +} + +func (s *SchoolService) createDefaultGradeScale(ctx context.Context, schoolID uuid.UUID) error { + query := ` + INSERT INTO grade_scales (id, school_id, name, min_value, max_value, passing_value, is_ascending, is_default, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT DO NOTHING` + + _, err := s.db.Pool.Exec(ctx, query, + uuid.New(), schoolID, "1-6 (Noten)", 1.0, 6.0, 4.0, false, true, time.Now(), + ) + + return err +} diff --git a/consent-service/internal/services/school_service_test.go b/consent-service/internal/services/school_service_test.go new file mode 100644 index 0000000..98ba8ac --- /dev/null +++ b/consent-service/internal/services/school_service_test.go @@ -0,0 +1,424 @@ +package services + +import ( + "testing" + "time" + + "github.com/breakpilot/consent-service/internal/models" + + "github.com/google/uuid" +) + +// TestGenerateOnboardingToken tests the QR code token generation +func TestGenerateOnboardingToken(t *testing.T) { + tests := []struct { + name string + studentID uuid.UUID + createdBy uuid.UUID + role string + expectError bool + }{ + { + name: "valid parent token", + studentID: uuid.New(), + createdBy: uuid.New(), + role: "parent", + expectError: false, + }, + { + name: "valid parent_representative token", + studentID: uuid.New(), + createdBy: uuid.New(), + role: "parent_representative", + expectError: false, + }, + { + name: "empty student ID", + studentID: uuid.Nil, + createdBy: uuid.New(), + role: "parent", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + token := &models.ParentOnboardingToken{ + StudentID: tt.studentID, + CreatedBy: tt.createdBy, + Role: tt.role, + ExpiresAt: time.Now().Add(7 * 24 * time.Hour), + } + + // Validate token fields + if tt.studentID == uuid.Nil && !tt.expectError { + t.Errorf("expected error for empty student ID") + } + + if token.Role != "parent" && token.Role != "parent_representative" { + if !tt.expectError { + t.Errorf("invalid role: %s", token.Role) + } + } + + // Check expiration is in the future + if token.ExpiresAt.Before(time.Now()) { + t.Errorf("token expiration should be in the future") + } + }) + } +} + +// TestValidateSchoolData tests school data validation +func TestValidateSchoolData(t *testing.T) { + address1 := "Musterstraße 1, 20095 Hamburg" + address2 := "Musterstraße 1" + address3 := "Parkweg 5" + + tests := []struct { + name string + school models.School + expectValid bool + }{ + { + name: "valid school", + school: models.School{ + Name: "Testschule Hamburg", + Address: &address1, + Type: "gymnasium", + }, + expectValid: true, + }, + { + name: "empty name", + school: models.School{ + Name: "", + Address: &address2, + Type: "gymnasium", + }, + expectValid: false, + }, + { + name: "valid grundschule", + school: models.School{ + Name: "Grundschule Am Park", + Address: &address3, + Type: "grundschule", + }, + expectValid: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isValid := validateSchool(tt.school) + if isValid != tt.expectValid { + t.Errorf("expected valid=%v, got valid=%v", tt.expectValid, isValid) + } + }) + } +} + +// validateSchool is a helper function for validation +func validateSchool(school models.School) bool { + if school.Name == "" { + return false + } + if school.Type == "" { + return false + } + return true +} + +// TestValidateClassData tests class data validation +func TestValidateClassData(t *testing.T) { + tests := []struct { + name string + class models.Class + expectValid bool + }{ + { + name: "valid class 5a", + class: models.Class{ + Name: "5a", + Grade: 5, + SchoolID: uuid.New(), + SchoolYearID: uuid.New(), + }, + expectValid: true, + }, + { + name: "invalid grade level 0", + class: models.Class{ + Name: "0a", + Grade: 0, + SchoolID: uuid.New(), + SchoolYearID: uuid.New(), + }, + expectValid: false, + }, + { + name: "invalid grade level 14", + class: models.Class{ + Name: "14a", + Grade: 14, + SchoolID: uuid.New(), + SchoolYearID: uuid.New(), + }, + expectValid: false, + }, + { + name: "missing school ID", + class: models.Class{ + Name: "5a", + Grade: 5, + SchoolID: uuid.Nil, + SchoolYearID: uuid.New(), + }, + expectValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isValid := validateClass(tt.class) + if isValid != tt.expectValid { + t.Errorf("expected valid=%v, got valid=%v", tt.expectValid, isValid) + } + }) + } +} + +// validateClass is a helper function for class validation +func validateClass(class models.Class) bool { + if class.Name == "" { + return false + } + if class.Grade < 1 || class.Grade > 13 { + return false + } + if class.SchoolID == uuid.Nil { + return false + } + if class.SchoolYearID == uuid.Nil { + return false + } + return true +} + +// TestValidateStudentData tests student data validation +func TestValidateStudentData(t *testing.T) { + dob := time.Date(2014, 5, 15, 0, 0, 0, 0, time.UTC) + futureDob := time.Now().AddDate(1, 0, 0) + studentNum := "2024-001" + + tests := []struct { + name string + student models.Student + expectValid bool + }{ + { + name: "valid student", + student: models.Student{ + FirstName: "Max", + LastName: "Mustermann", + DateOfBirth: &dob, + SchoolID: uuid.New(), + ClassID: uuid.New(), + StudentNumber: &studentNum, + }, + expectValid: true, + }, + { + name: "empty first name", + student: models.Student{ + FirstName: "", + LastName: "Mustermann", + DateOfBirth: &dob, + SchoolID: uuid.New(), + ClassID: uuid.New(), + StudentNumber: &studentNum, + }, + expectValid: false, + }, + { + name: "future birth date", + student: models.Student{ + FirstName: "Max", + LastName: "Mustermann", + DateOfBirth: &futureDob, + SchoolID: uuid.New(), + ClassID: uuid.New(), + StudentNumber: &studentNum, + }, + expectValid: false, + }, + { + name: "missing class ID", + student: models.Student{ + FirstName: "Max", + LastName: "Mustermann", + DateOfBirth: &dob, + SchoolID: uuid.New(), + ClassID: uuid.Nil, + StudentNumber: &studentNum, + }, + expectValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isValid := validateStudent(tt.student) + if isValid != tt.expectValid { + t.Errorf("expected valid=%v, got valid=%v", tt.expectValid, isValid) + } + }) + } +} + +// validateStudent is a helper function for student validation +func validateStudent(student models.Student) bool { + if student.FirstName == "" || student.LastName == "" { + return false + } + if student.DateOfBirth != nil && student.DateOfBirth.After(time.Now()) { + return false + } + if student.ClassID == uuid.Nil { + return false + } + return true +} + +// TestValidateTeacherData tests teacher data validation +func TestValidateTeacherData(t *testing.T) { + code := "SCH" + codeLong := "SCHMI" + + tests := []struct { + name string + teacher models.Teacher + expectValid bool + }{ + { + name: "valid teacher", + teacher: models.Teacher{ + FirstName: "Anna", + LastName: "Schmidt", + UserID: uuid.New(), + TeacherCode: &code, + SchoolID: uuid.New(), + }, + expectValid: true, + }, + { + name: "empty first name", + teacher: models.Teacher{ + FirstName: "", + LastName: "Schmidt", + UserID: uuid.New(), + TeacherCode: &code, + SchoolID: uuid.New(), + }, + expectValid: false, + }, + { + name: "teacher code too long", + teacher: models.Teacher{ + FirstName: "Anna", + LastName: "Schmidt", + UserID: uuid.New(), + TeacherCode: &codeLong, + SchoolID: uuid.New(), + }, + expectValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isValid := validateTeacher(tt.teacher) + if isValid != tt.expectValid { + t.Errorf("expected valid=%v, got valid=%v", tt.expectValid, isValid) + } + }) + } +} + +// validateTeacher is a helper function for teacher validation +func validateTeacher(teacher models.Teacher) bool { + if teacher.FirstName == "" || teacher.LastName == "" { + return false + } + if teacher.TeacherCode != nil && len(*teacher.TeacherCode) > 4 { + return false + } + if teacher.SchoolID == uuid.Nil { + return false + } + return true +} + +// TestSchoolYearValidation tests school year date validation +func TestSchoolYearValidation(t *testing.T) { + tests := []struct { + name string + year models.SchoolYear + expectValid bool + }{ + { + name: "valid school year 2024/2025", + year: models.SchoolYear{ + Name: "2024/2025", + StartDate: time.Date(2024, 8, 1, 0, 0, 0, 0, time.UTC), + EndDate: time.Date(2025, 7, 31, 0, 0, 0, 0, time.UTC), + SchoolID: uuid.New(), + }, + expectValid: true, + }, + { + name: "end before start", + year: models.SchoolYear{ + Name: "2024/2025", + StartDate: time.Date(2025, 8, 1, 0, 0, 0, 0, time.UTC), + EndDate: time.Date(2024, 7, 31, 0, 0, 0, 0, time.UTC), + SchoolID: uuid.New(), + }, + expectValid: false, + }, + { + name: "same start and end", + year: models.SchoolYear{ + Name: "2024/2025", + StartDate: time.Date(2024, 8, 1, 0, 0, 0, 0, time.UTC), + EndDate: time.Date(2024, 8, 1, 0, 0, 0, 0, time.UTC), + SchoolID: uuid.New(), + }, + expectValid: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isValid := validateSchoolYear(tt.year) + if isValid != tt.expectValid { + t.Errorf("expected valid=%v, got valid=%v", tt.expectValid, isValid) + } + }) + } +} + +// validateSchoolYear is a helper function for school year validation +func validateSchoolYear(year models.SchoolYear) bool { + if year.Name == "" { + return false + } + if year.SchoolID == uuid.Nil { + return false + } + if !year.EndDate.After(year.StartDate) { + return false + } + return true +} diff --git a/consent-service/internal/services/test_helpers.go b/consent-service/internal/services/test_helpers.go new file mode 100644 index 0000000..bbc7575 --- /dev/null +++ b/consent-service/internal/services/test_helpers.go @@ -0,0 +1,15 @@ +package services + +// ValidationError represents a validation error in tests +// This is a shared test helper type used across multiple test files +type ValidationError struct { + Field string + Message string +} + +func (e *ValidationError) Error() string { + if e.Field != "" { + return e.Field + ": " + e.Message + } + return e.Message +} diff --git a/consent-service/internal/services/totp_service.go b/consent-service/internal/services/totp_service.go new file mode 100644 index 0000000..dc262a4 --- /dev/null +++ b/consent-service/internal/services/totp_service.go @@ -0,0 +1,485 @@ +package services + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "crypto/sha256" + "encoding/base32" + "encoding/base64" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "image/png" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + qrcode "github.com/skip2/go-qrcode" + + "github.com/breakpilot/consent-service/internal/models" +) + +var ( + ErrTOTPNotEnabled = errors.New("2FA is not enabled for this user") + ErrTOTPAlreadyEnabled = errors.New("2FA is already enabled for this user") + ErrTOTPInvalidCode = errors.New("invalid 2FA code") + ErrTOTPChallengeExpired = errors.New("2FA challenge expired") + ErrRecoveryCodeInvalid = errors.New("invalid recovery code") + ErrRecoveryCodeUsed = errors.New("recovery code already used") +) + +const ( + TOTPPeriod = 30 // TOTP period in seconds + TOTPDigits = 6 // Number of digits in TOTP code + TOTPSecretLen = 20 // Length of TOTP secret in bytes + RecoveryCodeCount = 10 // Number of recovery codes to generate + RecoveryCodeLen = 8 // Length of each recovery code + ChallengeExpiry = 5 * time.Minute // 2FA challenge expiry +) + +// TOTPService handles Two-Factor Authentication using TOTP +type TOTPService struct { + db *pgxpool.Pool + issuer string +} + +// NewTOTPService creates a new TOTPService +func NewTOTPService(db *pgxpool.Pool, issuer string) *TOTPService { + return &TOTPService{ + db: db, + issuer: issuer, + } +} + +// GenerateSecret generates a new TOTP secret +func (s *TOTPService) GenerateSecret() (string, error) { + secret := make([]byte, TOTPSecretLen) + if _, err := rand.Read(secret); err != nil { + return "", fmt.Errorf("failed to generate secret: %w", err) + } + return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(secret), nil +} + +// GenerateRecoveryCodes generates a set of recovery codes +func (s *TOTPService) GenerateRecoveryCodes() ([]string, error) { + codes := make([]string, RecoveryCodeCount) + for i := 0; i < RecoveryCodeCount; i++ { + codeBytes := make([]byte, RecoveryCodeLen/2) + if _, err := rand.Read(codeBytes); err != nil { + return nil, fmt.Errorf("failed to generate recovery code: %w", err) + } + codes[i] = strings.ToUpper(hex.EncodeToString(codeBytes)) + } + return codes, nil +} + +// GenerateQRCode generates a QR code for TOTP setup +func (s *TOTPService) GenerateQRCode(secret, email string) (string, error) { + // Create otpauth URL + otpauthURL := fmt.Sprintf("otpauth://totp/%s:%s?secret=%s&issuer=%s&algorithm=SHA1&digits=%d&period=%d", + s.issuer, email, secret, s.issuer, TOTPDigits, TOTPPeriod) + + // Generate QR code + qr, err := qrcode.New(otpauthURL, qrcode.Medium) + if err != nil { + return "", fmt.Errorf("failed to generate QR code: %w", err) + } + + // Convert to PNG + var buf bytes.Buffer + if err := png.Encode(&buf, qr.Image(256)); err != nil { + return "", fmt.Errorf("failed to encode QR code: %w", err) + } + + // Convert to data URL + dataURL := fmt.Sprintf("data:image/png;base64,%s", base64.StdEncoding.EncodeToString(buf.Bytes())) + return dataURL, nil +} + +// GenerateTOTP generates the current TOTP code for a secret +func (s *TOTPService) GenerateTOTP(secret string) (string, error) { + return s.GenerateTOTPAt(secret, time.Now()) +} + +// GenerateTOTPAt generates a TOTP code for a specific time +func (s *TOTPService) GenerateTOTPAt(secret string, t time.Time) (string, error) { + // Decode secret + secretBytes, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(strings.ToUpper(secret)) + if err != nil { + return "", fmt.Errorf("invalid secret: %w", err) + } + + // Calculate counter + counter := uint64(t.Unix()) / TOTPPeriod + + // Generate HOTP + buf := make([]byte, 8) + binary.BigEndian.PutUint64(buf, counter) + + mac := hmac.New(sha1.New, secretBytes) + mac.Write(buf) + hash := mac.Sum(nil) + + // Dynamic truncation + offset := hash[len(hash)-1] & 0x0f + code := binary.BigEndian.Uint32(hash[offset:offset+4]) & 0x7fffffff + + // Format code + codeStr := fmt.Sprintf("%0*d", TOTPDigits, code%1000000) + return codeStr, nil +} + +// ValidateTOTP validates a TOTP code (allows 1 period drift) +func (s *TOTPService) ValidateTOTP(secret, code string) bool { + now := time.Now() + + // Check current, previous, and next period + for _, offset := range []int{0, -1, 1} { + t := now.Add(time.Duration(offset*TOTPPeriod) * time.Second) + expected, err := s.GenerateTOTPAt(secret, t) + if err == nil && expected == code { + return true + } + } + + return false +} + +// Setup2FA initiates 2FA setup for a user +func (s *TOTPService) Setup2FA(ctx context.Context, userID uuid.UUID, email string) (*models.Setup2FAResponse, error) { + // Check if 2FA is already enabled + var exists bool + err := s.db.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM user_totp WHERE user_id = $1 AND verified = true)`, userID).Scan(&exists) + if err == nil && exists { + return nil, ErrTOTPAlreadyEnabled + } + + // Generate secret + secret, err := s.GenerateSecret() + if err != nil { + return nil, err + } + + // Generate recovery codes + recoveryCodes, err := s.GenerateRecoveryCodes() + if err != nil { + return nil, err + } + + // Generate QR code + qrCode, err := s.GenerateQRCode(secret, email) + if err != nil { + return nil, err + } + + // Hash recovery codes for storage + hashedCodes := make([]string, len(recoveryCodes)) + for i, code := range recoveryCodes { + hash := sha256.Sum256([]byte(code)) + hashedCodes[i] = hex.EncodeToString(hash[:]) + } + + recoveryCodesJSON, _ := json.Marshal(hashedCodes) + + // Store or update TOTP record (unverified) + _, err = s.db.Exec(ctx, ` + INSERT INTO user_totp (user_id, secret, verified, recovery_codes, created_at, updated_at) + VALUES ($1, $2, false, $3, NOW(), NOW()) + ON CONFLICT (user_id) DO UPDATE SET + secret = $2, + verified = false, + recovery_codes = $3, + updated_at = NOW() + `, userID, secret, recoveryCodesJSON) + + if err != nil { + return nil, fmt.Errorf("failed to store TOTP: %w", err) + } + + return &models.Setup2FAResponse{ + Secret: secret, + QRCodeDataURL: qrCode, + RecoveryCodes: recoveryCodes, + }, nil +} + +// Verify2FASetup verifies the 2FA setup with a code +func (s *TOTPService) Verify2FASetup(ctx context.Context, userID uuid.UUID, code string) error { + // Get TOTP record + var secret string + var verified bool + err := s.db.QueryRow(ctx, `SELECT secret, verified FROM user_totp WHERE user_id = $1`, userID).Scan(&secret, &verified) + if err != nil { + return ErrTOTPNotEnabled + } + + if verified { + return ErrTOTPAlreadyEnabled + } + + // Validate code + if !s.ValidateTOTP(secret, code) { + return ErrTOTPInvalidCode + } + + // Mark as verified and enable 2FA + _, err = s.db.Exec(ctx, ` + UPDATE user_totp SET verified = true, enabled_at = NOW(), updated_at = NOW() WHERE user_id = $1 + `, userID) + if err != nil { + return fmt.Errorf("failed to verify TOTP: %w", err) + } + + // Update user record + _, err = s.db.Exec(ctx, ` + UPDATE users SET two_factor_enabled = true, two_factor_verified_at = NOW(), updated_at = NOW() WHERE id = $1 + `, userID) + if err != nil { + return fmt.Errorf("failed to update user: %w", err) + } + + return nil +} + +// CreateChallenge creates a 2FA challenge for login +func (s *TOTPService) CreateChallenge(ctx context.Context, userID uuid.UUID, ipAddress, userAgent string) (string, error) { + // Generate challenge ID + challengeBytes := make([]byte, 32) + if _, err := rand.Read(challengeBytes); err != nil { + return "", fmt.Errorf("failed to generate challenge: %w", err) + } + challengeID := base64.URLEncoding.EncodeToString(challengeBytes) + + // Store challenge + _, err := s.db.Exec(ctx, ` + INSERT INTO two_factor_challenges (user_id, challenge_id, ip_address, user_agent, expires_at, created_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + `, userID, challengeID, ipAddress, userAgent, time.Now().Add(ChallengeExpiry)) + + if err != nil { + return "", fmt.Errorf("failed to create challenge: %w", err) + } + + return challengeID, nil +} + +// VerifyChallenge verifies a 2FA challenge with a TOTP code +func (s *TOTPService) VerifyChallenge(ctx context.Context, challengeID, code string) (*uuid.UUID, error) { + var challenge models.TwoFactorChallenge + err := s.db.QueryRow(ctx, ` + SELECT id, user_id, expires_at, used_at FROM two_factor_challenges WHERE challenge_id = $1 + `, challengeID).Scan(&challenge.ID, &challenge.UserID, &challenge.ExpiresAt, &challenge.UsedAt) + + if err != nil { + return nil, ErrInvalidToken + } + + if challenge.UsedAt != nil { + return nil, ErrInvalidToken + } + + if time.Now().After(challenge.ExpiresAt) { + return nil, ErrTOTPChallengeExpired + } + + // Get TOTP secret + var secret string + err = s.db.QueryRow(ctx, `SELECT secret FROM user_totp WHERE user_id = $1 AND verified = true`, challenge.UserID).Scan(&secret) + if err != nil { + return nil, ErrTOTPNotEnabled + } + + // Validate TOTP code + if !s.ValidateTOTP(secret, code) { + return nil, ErrTOTPInvalidCode + } + + // Mark challenge as used + _, err = s.db.Exec(ctx, `UPDATE two_factor_challenges SET used_at = NOW() WHERE id = $1`, challenge.ID) + if err != nil { + return nil, fmt.Errorf("failed to mark challenge as used: %w", err) + } + + // Update last used time + _, _ = s.db.Exec(ctx, `UPDATE user_totp SET last_used_at = NOW() WHERE user_id = $1`, challenge.UserID) + + return &challenge.UserID, nil +} + +// VerifyChallengeWithRecoveryCode verifies a 2FA challenge with a recovery code +func (s *TOTPService) VerifyChallengeWithRecoveryCode(ctx context.Context, challengeID, recoveryCode string) (*uuid.UUID, error) { + var challenge models.TwoFactorChallenge + err := s.db.QueryRow(ctx, ` + SELECT id, user_id, expires_at, used_at FROM two_factor_challenges WHERE challenge_id = $1 + `, challengeID).Scan(&challenge.ID, &challenge.UserID, &challenge.ExpiresAt, &challenge.UsedAt) + + if err != nil { + return nil, ErrInvalidToken + } + + if challenge.UsedAt != nil { + return nil, ErrInvalidToken + } + + if time.Now().After(challenge.ExpiresAt) { + return nil, ErrTOTPChallengeExpired + } + + // Get recovery codes + var recoveryCodesJSON []byte + err = s.db.QueryRow(ctx, `SELECT recovery_codes FROM user_totp WHERE user_id = $1 AND verified = true`, challenge.UserID).Scan(&recoveryCodesJSON) + if err != nil { + return nil, ErrTOTPNotEnabled + } + + var hashedCodes []string + json.Unmarshal(recoveryCodesJSON, &hashedCodes) + + // Hash the provided recovery code + codeHash := sha256.Sum256([]byte(strings.ToUpper(recoveryCode))) + codeHashStr := hex.EncodeToString(codeHash[:]) + + // Find and remove the recovery code + found := false + newCodes := make([]string, 0, len(hashedCodes)-1) + for _, hc := range hashedCodes { + if hc == codeHashStr && !found { + found = true + continue + } + newCodes = append(newCodes, hc) + } + + if !found { + return nil, ErrRecoveryCodeInvalid + } + + // Update recovery codes + newCodesJSON, _ := json.Marshal(newCodes) + _, err = s.db.Exec(ctx, `UPDATE user_totp SET recovery_codes = $1, updated_at = NOW() WHERE user_id = $2`, newCodesJSON, challenge.UserID) + if err != nil { + return nil, fmt.Errorf("failed to update recovery codes: %w", err) + } + + // Mark challenge as used + _, err = s.db.Exec(ctx, `UPDATE two_factor_challenges SET used_at = NOW() WHERE id = $1`, challenge.ID) + if err != nil { + return nil, fmt.Errorf("failed to mark challenge as used: %w", err) + } + + return &challenge.UserID, nil +} + +// Disable2FA disables 2FA for a user +func (s *TOTPService) Disable2FA(ctx context.Context, userID uuid.UUID, code string) error { + // Get TOTP secret + var secret string + err := s.db.QueryRow(ctx, `SELECT secret FROM user_totp WHERE user_id = $1 AND verified = true`, userID).Scan(&secret) + if err != nil { + return ErrTOTPNotEnabled + } + + // Validate code + if !s.ValidateTOTP(secret, code) { + return ErrTOTPInvalidCode + } + + // Delete TOTP record + _, err = s.db.Exec(ctx, `DELETE FROM user_totp WHERE user_id = $1`, userID) + if err != nil { + return fmt.Errorf("failed to delete TOTP: %w", err) + } + + // Update user record + _, err = s.db.Exec(ctx, ` + UPDATE users SET two_factor_enabled = false, two_factor_verified_at = NULL, updated_at = NOW() WHERE id = $1 + `, userID) + if err != nil { + return fmt.Errorf("failed to update user: %w", err) + } + + return nil +} + +// GetStatus returns the 2FA status for a user +func (s *TOTPService) GetStatus(ctx context.Context, userID uuid.UUID) (*models.TwoFactorStatusResponse, error) { + var totp models.UserTOTP + var recoveryCodesJSON []byte + + err := s.db.QueryRow(ctx, ` + SELECT id, verified, enabled_at, recovery_codes FROM user_totp WHERE user_id = $1 + `, userID).Scan(&totp.ID, &totp.Verified, &totp.EnabledAt, &recoveryCodesJSON) + + if err != nil { + // 2FA not set up + return &models.TwoFactorStatusResponse{ + Enabled: false, + Verified: false, + RecoveryCodesCount: 0, + }, nil + } + + var hashedCodes []string + json.Unmarshal(recoveryCodesJSON, &hashedCodes) + + return &models.TwoFactorStatusResponse{ + Enabled: totp.Verified, + Verified: totp.Verified, + EnabledAt: totp.EnabledAt, + RecoveryCodesCount: len(hashedCodes), + }, nil +} + +// RegenerateRecoveryCodes generates new recovery codes (requires current TOTP code) +func (s *TOTPService) RegenerateRecoveryCodes(ctx context.Context, userID uuid.UUID, code string) ([]string, error) { + // Get TOTP secret + var secret string + err := s.db.QueryRow(ctx, `SELECT secret FROM user_totp WHERE user_id = $1 AND verified = true`, userID).Scan(&secret) + if err != nil { + return nil, ErrTOTPNotEnabled + } + + // Validate code + if !s.ValidateTOTP(secret, code) { + return nil, ErrTOTPInvalidCode + } + + // Generate new recovery codes + recoveryCodes, err := s.GenerateRecoveryCodes() + if err != nil { + return nil, err + } + + // Hash recovery codes for storage + hashedCodes := make([]string, len(recoveryCodes)) + for i, rc := range recoveryCodes { + hash := sha256.Sum256([]byte(rc)) + hashedCodes[i] = hex.EncodeToString(hash[:]) + } + + recoveryCodesJSON, _ := json.Marshal(hashedCodes) + + // Update recovery codes + _, err = s.db.Exec(ctx, `UPDATE user_totp SET recovery_codes = $1, updated_at = NOW() WHERE user_id = $2`, recoveryCodesJSON, userID) + if err != nil { + return nil, fmt.Errorf("failed to update recovery codes: %w", err) + } + + return recoveryCodes, nil +} + +// IsTwoFactorEnabled checks if 2FA is enabled for a user +func (s *TOTPService) IsTwoFactorEnabled(ctx context.Context, userID uuid.UUID) (bool, error) { + var enabled bool + err := s.db.QueryRow(ctx, `SELECT two_factor_enabled FROM users WHERE id = $1`, userID).Scan(&enabled) + if err != nil { + return false, err + } + return enabled, nil +} diff --git a/consent-service/internal/services/totp_service_test.go b/consent-service/internal/services/totp_service_test.go new file mode 100644 index 0000000..27c53a2 --- /dev/null +++ b/consent-service/internal/services/totp_service_test.go @@ -0,0 +1,378 @@ +package services + +import ( + "crypto/hmac" + "crypto/sha1" + "crypto/sha256" + "encoding/base32" + "encoding/binary" + "encoding/hex" + "strings" + "testing" + "time" +) + +// TestTOTPGeneration tests TOTP code generation +func TestTOTPGeneration_ValidSecret(t *testing.T) { + // Test secret (Base32 encoded) + secret := "JBSWY3DPEHPK3PXP" // This is "Hello!" in Base32 + + // Decode secret + secretBytes, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret) + if err != nil { + t.Fatalf("Failed to decode secret: %v", err) + } + + // Generate TOTP for current time + now := time.Now() + counter := uint64(now.Unix()) / 30 + + buf := make([]byte, 8) + binary.BigEndian.PutUint64(buf, counter) + + mac := hmac.New(sha1.New, secretBytes) + mac.Write(buf) + hash := mac.Sum(nil) + + // Dynamic truncation + offset := hash[len(hash)-1] & 0x0f + code := binary.BigEndian.Uint32(hash[offset:offset+4]) & 0x7fffffff + totpCode := code % 1000000 + + // Check that code is 6 digits + if totpCode < 0 || totpCode > 999999 { + t.Errorf("TOTP code should be 6 digits, got %d", totpCode) + } +} + +// TestTOTPGeneration_SameTimeProducesSameCode tests deterministic generation +func TestTOTPGeneration_Deterministic(t *testing.T) { + secret := "JBSWY3DPEHPK3PXP" + secretBytes, _ := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret) + + fixedTime := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + + code1 := generateTOTPAt(secretBytes, fixedTime) + code2 := generateTOTPAt(secretBytes, fixedTime) + + if code1 != code2 { + t.Errorf("Same time should produce same code: got %s and %s", code1, code2) + } +} + +// TestTOTPGeneration_DifferentTimesProduceDifferentCodes tests time sensitivity +func TestTOTPGeneration_TimeSensitive(t *testing.T) { + secret := "JBSWY3DPEHPK3PXP" + secretBytes, _ := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret) + + time1 := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC) + time2 := time1.Add(30 * time.Second) // Next TOTP period + + code1 := generateTOTPAt(secretBytes, time1) + code2 := generateTOTPAt(secretBytes, time2) + + if code1 == code2 { + t.Error("Different TOTP periods should produce different codes") + } +} + +// Helper function for TOTP generation at specific time +func generateTOTPAt(secretBytes []byte, t time.Time) string { + counter := uint64(t.Unix()) / 30 + + buf := make([]byte, 8) + binary.BigEndian.PutUint64(buf, counter) + + mac := hmac.New(sha1.New, secretBytes) + mac.Write(buf) + hash := mac.Sum(nil) + + offset := hash[len(hash)-1] & 0x0f + code := binary.BigEndian.Uint32(hash[offset:offset+4]) & 0x7fffffff + + return padCode(code % 1000000) +} + +func padCode(code uint32) string { + s := "" + for i := 0; i < 6; i++ { + s = string(rune('0'+code%10)) + s + code /= 10 + } + return s +} + +// TestTOTPValidation_WithDrift tests validation with clock drift allowance +func TestTOTPValidation_WithDrift(t *testing.T) { + secret := "JBSWY3DPEHPK3PXP" + secretBytes, _ := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret) + + now := time.Now() + + // Generate current code + currentCode := generateTOTPAt(secretBytes, now) + + // Generate previous period code + previousCode := generateTOTPAt(secretBytes, now.Add(-30*time.Second)) + + // Generate next period code + nextCode := generateTOTPAt(secretBytes, now.Add(30*time.Second)) + + // All three should be valid for current validation (allowing 1 period drift) + validCodes := []string{currentCode, previousCode, nextCode} + + for _, code := range validCodes { + isValid := validateTOTPWithDrift(secretBytes, code, now) + if !isValid { + t.Errorf("Code %s should be valid with drift allowance", code) + } + } +} + +// validateTOTPWithDrift validates a TOTP code allowing for clock drift +func validateTOTPWithDrift(secretBytes []byte, code string, now time.Time) bool { + for _, offset := range []int{0, -1, 1} { + t := now.Add(time.Duration(offset*30) * time.Second) + expected := generateTOTPAt(secretBytes, t) + if expected == code { + return true + } + } + return false +} + +// TestRecoveryCodeGeneration tests recovery code format +func TestRecoveryCodeGeneration_Format(t *testing.T) { + // Simulate recovery code generation + codeBytes := make([]byte, 4) // 8 hex chars = 4 bytes + for i := range codeBytes { + codeBytes[i] = byte(i + 1) // Deterministic for testing + } + code := strings.ToUpper(hex.EncodeToString(codeBytes)) + + // Check format + if len(code) != 8 { + t.Errorf("Recovery code should be 8 characters, got %d", len(code)) + } + + // Check uppercase + if code != strings.ToUpper(code) { + t.Error("Recovery code should be uppercase") + } + + // Check alphanumeric (hex only contains 0-9 and A-F) + for _, c := range code { + if !((c >= '0' && c <= '9') || (c >= 'A' && c <= 'F')) { + t.Errorf("Recovery code should only contain hex characters, found '%c'", c) + } + } +} + +// TestRecoveryCodeHashing tests that recovery codes are hashed for storage +func TestRecoveryCodeHashing_Consistency(t *testing.T) { + code := "ABCD1234" + + hash1 := sha256.Sum256([]byte(code)) + hash2 := sha256.Sum256([]byte(code)) + + if hash1 != hash2 { + t.Error("Recovery code hashing should be consistent") + } +} + +func TestRecoveryCodeHashing_CaseInsensitive(t *testing.T) { + code1 := "ABCD1234" + code2 := "abcd1234" + + hash1 := sha256.Sum256([]byte(strings.ToUpper(code1))) + hash2 := sha256.Sum256([]byte(strings.ToUpper(code2))) + + if hash1 != hash2 { + t.Error("Recovery codes should be case-insensitive when normalized to uppercase") + } +} + +// TestSecretGeneration tests that secrets are valid Base32 +func TestSecretGeneration_ValidBase32(t *testing.T) { + // Simulate secret generation (20 bytes -> Base32 without padding) + secretBytes := make([]byte, 20) + for i := range secretBytes { + secretBytes[i] = byte(i * 13) // Deterministic for testing + } + + secret := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(secretBytes) + + // Verify it can be decoded + decoded, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret) + if err != nil { + t.Errorf("Generated secret should be valid Base32: %v", err) + } + + if len(decoded) != 20 { + t.Errorf("Decoded secret should be 20 bytes, got %d", len(decoded)) + } +} + +// TestQRCodeOtpauthURL tests otpauth URL format +func TestQRCodeOtpauthURL_Format(t *testing.T) { + issuer := "BreakPilot" + email := "test@example.com" + secret := "JBSWY3DPEHPK3PXP" + period := 30 + digits := 6 + + url := "otpauth://totp/" + issuer + ":" + email + + "?secret=" + secret + + "&issuer=" + issuer + + "&algorithm=SHA1" + + "&digits=" + string(rune('0'+digits)) + + "&period=" + string(rune('0'+period/10)) + string(rune('0'+period%10)) + + // Check URL starts with otpauth://totp/ + if !strings.HasPrefix(url, "otpauth://totp/") { + t.Error("OTP auth URL should start with otpauth://totp/") + } + + // Check contains required parameters + if !strings.Contains(url, "secret=") { + t.Error("OTP auth URL should contain secret parameter") + } + if !strings.Contains(url, "issuer=") { + t.Error("OTP auth URL should contain issuer parameter") + } +} + +// TestChallengeExpiry tests 2FA challenge expiration +func TestChallengeExpiry_Logic(t *testing.T) { + tests := []struct { + name string + expiryMins int + usedAfter int // minutes after creation + shouldAllow bool + }{ + { + name: "challenge used within expiry", + expiryMins: 5, + usedAfter: 2, + shouldAllow: true, + }, + { + name: "challenge used at expiry", + expiryMins: 5, + usedAfter: 5, + shouldAllow: false, // Expired + }, + { + name: "challenge used after expiry", + expiryMins: 5, + usedAfter: 10, + shouldAllow: false, + }, + { + name: "challenge used immediately", + expiryMins: 5, + usedAfter: 0, + shouldAllow: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isValid := tt.usedAfter < tt.expiryMins + + if isValid != tt.shouldAllow { + t.Errorf("Expected allow=%v for challenge used after %d mins (expiry: %d mins)", + tt.shouldAllow, tt.usedAfter, tt.expiryMins) + } + }) + } +} + +// TestRecoveryCodeOneTimeUse tests that recovery codes can only be used once +func TestRecoveryCodeOneTimeUse(t *testing.T) { + initialCodes := []string{ + sha256Hash("CODE0001"), + sha256Hash("CODE0002"), + sha256Hash("CODE0003"), + } + + // Use CODE0002 + usedCodeHash := sha256Hash("CODE0002") + + // Remove used code from list + var remainingCodes []string + for _, code := range initialCodes { + if code != usedCodeHash { + remainingCodes = append(remainingCodes, code) + } + } + + if len(remainingCodes) != 2 { + t.Errorf("Should have 2 remaining codes after using one, got %d", len(remainingCodes)) + } + + // Verify used code is not in remaining + for _, code := range remainingCodes { + if code == usedCodeHash { + t.Error("Used recovery code should be removed from list") + } + } +} + +func sha256Hash(s string) string { + h := sha256.Sum256([]byte(s)) + return hex.EncodeToString(h[:]) +} + +// TestTwoFactorEnableFlow tests the 2FA enable workflow +func TestTwoFactorEnableFlow_States(t *testing.T) { + tests := []struct { + name string + initialState bool // verified + action string + expectedState bool + }{ + { + name: "fresh user - not verified", + initialState: false, + action: "none", + expectedState: false, + }, + { + name: "user verifies 2FA", + initialState: false, + action: "verify", + expectedState: true, + }, + { + name: "already verified - stays verified", + initialState: true, + action: "verify", + expectedState: true, + }, + { + name: "user disables 2FA", + initialState: true, + action: "disable", + expectedState: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + state := tt.initialState + + switch tt.action { + case "verify": + state = true + case "disable": + state = false + } + + if state != tt.expectedState { + t.Errorf("Expected state=%v after action '%s', got state=%v", + tt.expectedState, tt.action, state) + } + }) + } +} diff --git a/consent-service/internal/session/rbac_middleware.go b/consent-service/internal/session/rbac_middleware.go new file mode 100644 index 0000000..d2512a3 --- /dev/null +++ b/consent-service/internal/session/rbac_middleware.go @@ -0,0 +1,403 @@ +package session + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// Employee permissions +var EmployeePermissions = []string{ + "grades:read", "grades:write", + "attendance:read", "attendance:write", + "students:read", "students:write", + "reports:generate", "consent:admin", + "corrections:read", "corrections:write", + "classes:read", "classes:write", + "timetable:read", "timetable:write", + "substitutions:read", "substitutions:write", + "parent_communication:read", "parent_communication:write", +} + +// Customer permissions +var CustomerPermissions = []string{ + "own_data:read", "own_grades:read", "own_attendance:read", + "consent:manage", + "meetings:join", + "messages:read", "messages:write", + "children:read", "children:grades:read", "children:attendance:read", +} + +// Admin permissions +var AdminPermissions = []string{ + "users:read", "users:write", "users:manage", + "rbac:read", "rbac:write", + "audit:read", + "settings:read", "settings:write", + "dsr:read", "dsr:process", +} + +// Employee roles +var EmployeeRoles = map[string]bool{ + "admin": true, + "schul_admin": true, + "schulleitung": true, + "pruefungsvorsitz": true, + "klassenlehrer": true, + "fachlehrer": true, + "sekretariat": true, + "erstkorrektor": true, + "zweitkorrektor": true, + "drittkorrektor": true, + "teacher_assistant": true, + "teacher": true, + "lehrer": true, + "data_protection_officer": true, +} + +// Customer roles +var CustomerRoles = map[string]bool{ + "parent": true, + "student": true, + "user": true, + "guardian": true, +} + +// RequireEmployee requires the user to be an employee +func RequireEmployee() gin.HandlerFunc { + return func(c *gin.Context) { + session := GetSession(c) + if session == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "unauthorized", + "message": "Authentication required", + }) + return + } + + if !session.IsEmployee() { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "forbidden", + "message": "Employee access required", + }) + return + } + + c.Next() + } +} + +// RequireCustomer requires the user to be a customer +func RequireCustomer() gin.HandlerFunc { + return func(c *gin.Context) { + session := GetSession(c) + if session == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "unauthorized", + "message": "Authentication required", + }) + return + } + + if !session.IsCustomer() { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "forbidden", + "message": "Customer access required", + }) + return + } + + c.Next() + } +} + +// RequireUserType requires a specific user type +func RequireUserType(userType UserType) gin.HandlerFunc { + return func(c *gin.Context) { + session := GetSession(c) + if session == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "unauthorized", + "message": "Authentication required", + }) + return + } + + if session.UserType != userType { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "forbidden", + "message": "User type '" + string(userType) + "' required", + }) + return + } + + c.Next() + } +} + +// RequirePermission requires a specific permission +func RequirePermission(permission string) gin.HandlerFunc { + return func(c *gin.Context) { + session := GetSession(c) + if session == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "unauthorized", + "message": "Authentication required", + }) + return + } + + if !session.HasPermission(permission) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "forbidden", + "message": "Permission '" + permission + "' required", + }) + return + } + + c.Next() + } +} + +// RequireAnyPermission requires at least one of the permissions +func RequireAnyPermission(permissions ...string) gin.HandlerFunc { + return func(c *gin.Context) { + session := GetSession(c) + if session == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "unauthorized", + "message": "Authentication required", + }) + return + } + + if !session.HasAnyPermission(permissions) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "forbidden", + "message": "One of the required permissions is missing", + }) + return + } + + c.Next() + } +} + +// RequireAllPermissions requires all specified permissions +func RequireAllPermissions(permissions ...string) gin.HandlerFunc { + return func(c *gin.Context) { + session := GetSession(c) + if session == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "unauthorized", + "message": "Authentication required", + }) + return + } + + if !session.HasAllPermissions(permissions) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "forbidden", + "message": "Missing required permissions", + }) + return + } + + c.Next() + } +} + +// RequireRole requires a specific role +func RequireRole(role string) gin.HandlerFunc { + return func(c *gin.Context) { + session := GetSession(c) + if session == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "unauthorized", + "message": "Authentication required", + }) + return + } + + if !session.HasRole(role) { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "forbidden", + "message": "Role '" + role + "' required", + }) + return + } + + c.Next() + } +} + +// RequireAnyRole requires at least one of the roles +func RequireAnyRole(roles ...string) gin.HandlerFunc { + return func(c *gin.Context) { + session := GetSession(c) + if session == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "unauthorized", + "message": "Authentication required", + }) + return + } + + for _, role := range roles { + if session.HasRole(role) { + c.Next() + return + } + } + + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "forbidden", + "message": "One of the required roles is missing", + }) + } +} + +// RequireSameTenant ensures user can only access their tenant's data +func RequireSameTenant(tenantIDParam string) gin.HandlerFunc { + return func(c *gin.Context) { + session := GetSession(c) + if session == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "unauthorized", + "message": "Authentication required", + }) + return + } + + requestTenantID := c.Param(tenantIDParam) + if requestTenantID == "" { + requestTenantID = c.Query(tenantIDParam) + } + + if requestTenantID != "" && session.TenantID != nil && *session.TenantID != requestTenantID { + // Check if user is super admin + if !session.HasRole("super_admin") { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "error": "forbidden", + "message": "Access denied to this tenant", + }) + return + } + } + + c.Next() + } +} + +// DetermineUserType determines user type based on roles +func DetermineUserType(roles []string) UserType { + for _, role := range roles { + if EmployeeRoles[role] { + return UserTypeEmployee + } + } + + for _, role := range roles { + if CustomerRoles[role] { + return UserTypeCustomer + } + } + + return UserTypeCustomer +} + +// GetPermissionsForRoles returns permissions based on roles and user type +func GetPermissionsForRoles(roles []string, userType UserType) []string { + permSet := make(map[string]bool) + + // Base permissions by user type + if userType == UserTypeEmployee { + for _, p := range EmployeePermissions { + permSet[p] = true + } + } else { + for _, p := range CustomerPermissions { + permSet[p] = true + } + } + + // Admin permissions + adminRoles := map[string]bool{ + "admin": true, + "schul_admin": true, + "super_admin": true, + "data_protection_officer": true, + } + + for _, role := range roles { + if adminRoles[role] { + for _, p := range AdminPermissions { + permSet[p] = true + } + break + } + } + + // Convert to slice + permissions := make([]string, 0, len(permSet)) + for p := range permSet { + permissions = append(permissions, p) + } + + return permissions +} + +// CheckResourceOwnership checks if user owns a resource or is admin +func CheckResourceOwnership(session *Session, resourceUserID string, allowAdmin bool) bool { + if session == nil { + return false + } + + // User owns the resource + if session.UserID == resourceUserID { + return true + } + + // Admin can access all + if allowAdmin && (session.HasRole("admin") || session.HasRole("super_admin")) { + return true + } + + return false +} + +// IsSessionEmployee checks if current session belongs to an employee +func IsSessionEmployee(c *gin.Context) bool { + session := GetSession(c) + if session == nil { + return false + } + return session.IsEmployee() +} + +// IsSessionCustomer checks if current session belongs to a customer +func IsSessionCustomer(c *gin.Context) bool { + session := GetSession(c) + if session == nil { + return false + } + return session.IsCustomer() +} + +// HasSessionPermission checks if session has a permission +func HasSessionPermission(c *gin.Context, permission string) bool { + session := GetSession(c) + if session == nil { + return false + } + return session.HasPermission(permission) +} + +// HasSessionRole checks if session has a role +func HasSessionRole(c *gin.Context, role string) bool { + session := GetSession(c) + if session == nil { + return false + } + return session.HasRole(role) +} diff --git a/consent-service/internal/session/session_middleware.go b/consent-service/internal/session/session_middleware.go new file mode 100644 index 0000000..420e4ed --- /dev/null +++ b/consent-service/internal/session/session_middleware.go @@ -0,0 +1,196 @@ +package session + +import ( + "net/http" + "os" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +// SessionMiddleware extracts session from request and adds to context +func SessionMiddleware(pgPool *pgxpool.Pool) gin.HandlerFunc { + store := GetSessionStore(pgPool) + + return func(c *gin.Context) { + sessionID := extractSessionID(c) + + if sessionID != "" { + session, err := store.GetSession(c.Request.Context(), sessionID) + if err == nil && session != nil { + // Add session to context + c.Set("session", session) + c.Set("session_id", session.SessionID) + c.Set("user_id", session.UserID) + c.Set("email", session.Email) + c.Set("user_type", string(session.UserType)) + c.Set("roles", session.Roles) + c.Set("permissions", session.Permissions) + if session.TenantID != nil { + c.Set("tenant_id", *session.TenantID) + } + } + } + + c.Next() + } +} + +// extractSessionID extracts session ID from request +func extractSessionID(c *gin.Context) string { + // Try Authorization header first + authHeader := c.GetHeader("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + return strings.TrimPrefix(authHeader, "Bearer ") + } + + // Try X-Session-ID header + if sessionID := c.GetHeader("X-Session-ID"); sessionID != "" { + return sessionID + } + + // Try cookie + if cookie, err := c.Cookie("session_id"); err == nil { + return cookie + } + + return "" +} + +// RequireSession requires a valid session +func RequireSession() gin.HandlerFunc { + return func(c *gin.Context) { + session := GetSession(c) + + if session == nil { + // Check development bypass + if os.Getenv("ENVIRONMENT") == "development" { + // Set demo session + demoSession := getDemoSession() + c.Set("session", demoSession) + c.Set("session_id", demoSession.SessionID) + c.Set("user_id", demoSession.UserID) + c.Set("email", demoSession.Email) + c.Set("user_type", string(demoSession.UserType)) + c.Set("roles", demoSession.Roles) + c.Set("permissions", demoSession.Permissions) + c.Next() + return + } + + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "unauthorized", + "message": "Authentication required", + }) + return + } + + c.Next() + } +} + +// GetSession retrieves session from context +func GetSession(c *gin.Context) *Session { + if session, exists := c.Get("session"); exists { + if s, ok := session.(*Session); ok { + return s + } + } + return nil +} + +// GetSessionUserID retrieves user ID from session context +func GetSessionUserID(c *gin.Context) (uuid.UUID, error) { + userIDStr, exists := c.Get("user_id") + if !exists { + return uuid.Nil, nil + } + + return uuid.Parse(userIDStr.(string)) +} + +// GetSessionEmail retrieves email from session context +func GetSessionEmail(c *gin.Context) string { + email, exists := c.Get("email") + if !exists { + return "" + } + return email.(string) +} + +// GetSessionUserType retrieves user type from session context +func GetSessionUserType(c *gin.Context) UserType { + userType, exists := c.Get("user_type") + if !exists { + return UserTypeCustomer + } + return UserType(userType.(string)) +} + +// GetSessionRoles retrieves roles from session context +func GetSessionRoles(c *gin.Context) []string { + roles, exists := c.Get("roles") + if !exists { + return nil + } + if r, ok := roles.([]string); ok { + return r + } + return nil +} + +// GetSessionPermissions retrieves permissions from session context +func GetSessionPermissions(c *gin.Context) []string { + perms, exists := c.Get("permissions") + if !exists { + return nil + } + if p, ok := perms.([]string); ok { + return p + } + return nil +} + +// GetSessionTenantID retrieves tenant ID from session context +func GetSessionTenantID(c *gin.Context) *string { + tenantID, exists := c.Get("tenant_id") + if !exists { + return nil + } + if t, ok := tenantID.(string); ok { + return &t + } + return nil +} + +// getDemoSession returns a demo session for development +func getDemoSession() *Session { + tenantID := "a0000000-0000-0000-0000-000000000001" + ip := "127.0.0.1" + ua := "Development" + + return &Session{ + SessionID: "demo-session-id", + UserID: "10000000-0000-0000-0000-000000000024", + Email: "demo@breakpilot.app", + UserType: UserTypeEmployee, + Roles: []string{ + "admin", "schul_admin", "teacher", + }, + Permissions: []string{ + "grades:read", "grades:write", + "attendance:read", "attendance:write", + "students:read", "students:write", + "reports:generate", "consent:admin", + "own_data:read", "users:manage", + }, + TenantID: &tenantID, + IPAddress: &ip, + UserAgent: &ua, + CreatedAt: time.Now().UTC(), + LastActivityAt: time.Now().UTC(), + } +} diff --git a/consent-service/internal/session/session_store.go b/consent-service/internal/session/session_store.go new file mode 100644 index 0000000..d0eec26 --- /dev/null +++ b/consent-service/internal/session/session_store.go @@ -0,0 +1,463 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strconv" + "sync" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/redis/go-redis/v9" +) + +// UserType distinguishes between internal employees and external customers +type UserType string + +const ( + UserTypeEmployee UserType = "employee" + UserTypeCustomer UserType = "customer" +) + +// Session represents a user session with RBAC data +type Session struct { + SessionID string `json:"session_id"` + UserID string `json:"user_id"` + Email string `json:"email"` + UserType UserType `json:"user_type"` + Roles []string `json:"roles"` + Permissions []string `json:"permissions"` + TenantID *string `json:"tenant_id,omitempty"` + IPAddress *string `json:"ip_address,omitempty"` + UserAgent *string `json:"user_agent,omitempty"` + CreatedAt time.Time `json:"created_at"` + LastActivityAt time.Time `json:"last_activity_at"` +} + +// HasPermission checks if session has a specific permission +func (s *Session) HasPermission(permission string) bool { + for _, p := range s.Permissions { + if p == permission { + return true + } + } + return false +} + +// HasAnyPermission checks if session has any of the specified permissions +func (s *Session) HasAnyPermission(permissions []string) bool { + for _, needed := range permissions { + for _, has := range s.Permissions { + if needed == has { + return true + } + } + } + return false +} + +// HasAllPermissions checks if session has all specified permissions +func (s *Session) HasAllPermissions(permissions []string) bool { + for _, needed := range permissions { + found := false + for _, has := range s.Permissions { + if needed == has { + found = true + break + } + } + if !found { + return false + } + } + return true +} + +// HasRole checks if session has a specific role +func (s *Session) HasRole(role string) bool { + for _, r := range s.Roles { + if r == role { + return true + } + } + return false +} + +// IsEmployee checks if user is an employee (internal staff) +func (s *Session) IsEmployee() bool { + return s.UserType == UserTypeEmployee +} + +// IsCustomer checks if user is a customer (external user) +func (s *Session) IsCustomer() bool { + return s.UserType == UserTypeCustomer +} + +// SessionStore provides hybrid Valkey + PostgreSQL session storage +type SessionStore struct { + valkeyClient *redis.Client + pgPool *pgxpool.Pool + sessionTTL time.Duration + valkeyEnabled bool + mu sync.RWMutex +} + +// NewSessionStore creates a new session store +func NewSessionStore(pgPool *pgxpool.Pool) *SessionStore { + ttlHours := 24 + if ttlStr := os.Getenv("SESSION_TTL_HOURS"); ttlStr != "" { + if val, err := strconv.Atoi(ttlStr); err == nil { + ttlHours = val + } + } + + store := &SessionStore{ + pgPool: pgPool, + sessionTTL: time.Duration(ttlHours) * time.Hour, + valkeyEnabled: false, + } + + // Try to connect to Valkey + valkeyURL := os.Getenv("VALKEY_URL") + if valkeyURL == "" { + valkeyURL = "redis://localhost:6379" + } + + opt, err := redis.ParseURL(valkeyURL) + if err == nil { + store.valkeyClient = redis.NewClient(opt) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := store.valkeyClient.Ping(ctx).Err(); err == nil { + store.valkeyEnabled = true + } + } + + return store +} + +// Close closes all connections +func (s *SessionStore) Close() { + if s.valkeyClient != nil { + s.valkeyClient.Close() + } +} + +// getValkeyKey returns the Valkey key for a session +func (s *SessionStore) getValkeyKey(sessionID string) string { + return fmt.Sprintf("session:%s", sessionID) +} + +// CreateSession creates a new session +func (s *SessionStore) CreateSession(ctx context.Context, userID, email string, userType UserType, roles, permissions []string, tenantID, ipAddress, userAgent *string) (*Session, error) { + session := &Session{ + SessionID: uuid.New().String(), + UserID: userID, + Email: email, + UserType: userType, + Roles: roles, + Permissions: permissions, + TenantID: tenantID, + IPAddress: ipAddress, + UserAgent: userAgent, + CreatedAt: time.Now().UTC(), + LastActivityAt: time.Now().UTC(), + } + + // Store in Valkey (primary cache) + if s.valkeyEnabled { + data, err := json.Marshal(session) + if err == nil { + key := s.getValkeyKey(session.SessionID) + s.valkeyClient.SetEx(ctx, key, data, s.sessionTTL) + } + } + + // Store in PostgreSQL (persistent + audit) + if s.pgPool != nil { + rolesJSON, _ := json.Marshal(roles) + permsJSON, _ := json.Marshal(permissions) + + expiresAt := time.Now().UTC().Add(s.sessionTTL) + + _, err := s.pgPool.Exec(ctx, ` + INSERT INTO user_sessions ( + id, user_id, token_hash, email, user_type, roles, + permissions, tenant_id, ip_address, user_agent, + expires_at, created_at, last_activity_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + `, + session.SessionID, + session.UserID, + session.SessionID, // token_hash = session_id for session-based auth + session.Email, + string(session.UserType), + rolesJSON, + permsJSON, + tenantID, + ipAddress, + userAgent, + expiresAt, + session.CreatedAt, + session.LastActivityAt, + ) + if err != nil { + return nil, fmt.Errorf("failed to store session in PostgreSQL: %w", err) + } + } + + return session, nil +} + +// GetSession retrieves a session by ID +func (s *SessionStore) GetSession(ctx context.Context, sessionID string) (*Session, error) { + // Try Valkey first + if s.valkeyEnabled { + key := s.getValkeyKey(sessionID) + data, err := s.valkeyClient.Get(ctx, key).Bytes() + if err == nil { + var session Session + if err := json.Unmarshal(data, &session); err == nil { + // Update last activity + go s.updateLastActivity(sessionID) + return &session, nil + } + } + } + + // Fallback to PostgreSQL + if s.pgPool != nil { + var session Session + var rolesJSON, permsJSON []byte + var tenantID, ipAddress, userAgent *string + + err := s.pgPool.QueryRow(ctx, ` + SELECT id, user_id, email, user_type, roles, permissions, + tenant_id, ip_address, user_agent, created_at, last_activity_at + FROM user_sessions + WHERE id = $1 + AND revoked_at IS NULL + AND expires_at > NOW() + `, sessionID).Scan( + &session.SessionID, + &session.UserID, + &session.Email, + &session.UserType, + &rolesJSON, + &permsJSON, + &tenantID, + &ipAddress, + &userAgent, + &session.CreatedAt, + &session.LastActivityAt, + ) + + if err != nil { + return nil, errors.New("session not found or expired") + } + + json.Unmarshal(rolesJSON, &session.Roles) + json.Unmarshal(permsJSON, &session.Permissions) + session.TenantID = tenantID + session.IPAddress = ipAddress + session.UserAgent = userAgent + + // Re-cache in Valkey + if s.valkeyEnabled { + data, _ := json.Marshal(session) + key := s.getValkeyKey(sessionID) + s.valkeyClient.SetEx(ctx, key, data, s.sessionTTL) + } + + return &session, nil + } + + return nil, errors.New("session not found") +} + +// updateLastActivity updates the last activity timestamp +func (s *SessionStore) updateLastActivity(sessionID string) { + ctx := context.Background() + now := time.Now().UTC() + + // Update Valkey TTL + if s.valkeyEnabled { + key := s.getValkeyKey(sessionID) + s.valkeyClient.Expire(ctx, key, s.sessionTTL) + } + + // Update PostgreSQL + if s.pgPool != nil { + s.pgPool.Exec(ctx, ` + UPDATE user_sessions + SET last_activity_at = $1, expires_at = $2 + WHERE id = $3 + `, now, now.Add(s.sessionTTL), sessionID) + } +} + +// RevokeSession revokes a session (logout) +func (s *SessionStore) RevokeSession(ctx context.Context, sessionID string) error { + // Remove from Valkey + if s.valkeyEnabled { + key := s.getValkeyKey(sessionID) + s.valkeyClient.Del(ctx, key) + } + + // Mark as revoked in PostgreSQL + if s.pgPool != nil { + _, err := s.pgPool.Exec(ctx, ` + UPDATE user_sessions + SET revoked_at = NOW() + WHERE id = $1 + `, sessionID) + if err != nil { + return fmt.Errorf("failed to revoke session: %w", err) + } + } + + return nil +} + +// RevokeAllUserSessions revokes all sessions for a user +func (s *SessionStore) RevokeAllUserSessions(ctx context.Context, userID string) (int, error) { + if s.pgPool == nil { + return 0, nil + } + + // Get all session IDs + rows, err := s.pgPool.Query(ctx, ` + SELECT id FROM user_sessions + WHERE user_id = $1 + AND revoked_at IS NULL + AND expires_at > NOW() + `, userID) + if err != nil { + return 0, err + } + defer rows.Close() + + var sessionIDs []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err == nil { + sessionIDs = append(sessionIDs, id) + } + } + + // Revoke in PostgreSQL + result, err := s.pgPool.Exec(ctx, ` + UPDATE user_sessions + SET revoked_at = NOW() + WHERE user_id = $1 AND revoked_at IS NULL + `, userID) + if err != nil { + return 0, err + } + + // Remove from Valkey + if s.valkeyEnabled { + for _, sessionID := range sessionIDs { + key := s.getValkeyKey(sessionID) + s.valkeyClient.Del(ctx, key) + } + } + + return int(result.RowsAffected()), nil +} + +// GetActiveSessions returns all active sessions for a user +func (s *SessionStore) GetActiveSessions(ctx context.Context, userID string) ([]*Session, error) { + if s.pgPool == nil { + return nil, nil + } + + rows, err := s.pgPool.Query(ctx, ` + SELECT id, user_id, email, user_type, roles, permissions, + tenant_id, ip_address, user_agent, created_at, last_activity_at + FROM user_sessions + WHERE user_id = $1 + AND revoked_at IS NULL + AND expires_at > NOW() + ORDER BY last_activity_at DESC + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var sessions []*Session + for rows.Next() { + var session Session + var rolesJSON, permsJSON []byte + var tenantID, ipAddress, userAgent *string + + err := rows.Scan( + &session.SessionID, + &session.UserID, + &session.Email, + &session.UserType, + &rolesJSON, + &permsJSON, + &tenantID, + &ipAddress, + &userAgent, + &session.CreatedAt, + &session.LastActivityAt, + ) + if err != nil { + continue + } + + json.Unmarshal(rolesJSON, &session.Roles) + json.Unmarshal(permsJSON, &session.Permissions) + session.TenantID = tenantID + session.IPAddress = ipAddress + session.UserAgent = userAgent + + sessions = append(sessions, &session) + } + + return sessions, nil +} + +// CleanupExpiredSessions removes old expired sessions from PostgreSQL +func (s *SessionStore) CleanupExpiredSessions(ctx context.Context) (int, error) { + if s.pgPool == nil { + return 0, nil + } + + result, err := s.pgPool.Exec(ctx, ` + DELETE FROM user_sessions + WHERE expires_at < NOW() - INTERVAL '7 days' + `) + if err != nil { + return 0, err + } + + return int(result.RowsAffected()), nil +} + +// Global session store instance +var ( + globalStore *SessionStore + globalStoreMu sync.Mutex + globalStoreOnce sync.Once +) + +// GetSessionStore returns the global session store instance +func GetSessionStore(pgPool *pgxpool.Pool) *SessionStore { + globalStoreMu.Lock() + defer globalStoreMu.Unlock() + + if globalStore == nil { + globalStore = NewSessionStore(pgPool) + } + + return globalStore +} diff --git a/consent-service/internal/session/session_test.go b/consent-service/internal/session/session_test.go new file mode 100644 index 0000000..c1d2181 --- /dev/null +++ b/consent-service/internal/session/session_test.go @@ -0,0 +1,342 @@ +package session + +import ( + "testing" + "time" +) + +func TestSessionHasPermission(t *testing.T) { + session := &Session{ + SessionID: "test-session-id", + UserID: "test-user-id", + Email: "test@example.com", + UserType: UserTypeEmployee, + Permissions: []string{"grades:read", "grades:write", "attendance:read"}, + } + + tests := []struct { + name string + permission string + expected bool + }{ + {"has grades:read", "grades:read", true}, + {"has grades:write", "grades:write", true}, + {"missing users:manage", "users:manage", false}, + {"empty permission", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := session.HasPermission(tt.permission) + if result != tt.expected { + t.Errorf("HasPermission(%q) = %v, want %v", tt.permission, result, tt.expected) + } + }) + } +} + +func TestSessionHasAnyPermission(t *testing.T) { + session := &Session{ + SessionID: "test-session-id", + UserID: "test-user-id", + Email: "test@example.com", + UserType: UserTypeEmployee, + Permissions: []string{"grades:read"}, + } + + tests := []struct { + name string + permissions []string + expected bool + }{ + {"has one of the permissions", []string{"grades:read", "grades:write"}, true}, + {"missing all permissions", []string{"users:manage", "audit:read"}, false}, + {"empty list", []string{}, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := session.HasAnyPermission(tt.permissions) + if result != tt.expected { + t.Errorf("HasAnyPermission(%v) = %v, want %v", tt.permissions, result, tt.expected) + } + }) + } +} + +func TestSessionHasAllPermissions(t *testing.T) { + session := &Session{ + SessionID: "test-session-id", + UserID: "test-user-id", + Email: "test@example.com", + UserType: UserTypeEmployee, + Permissions: []string{"grades:read", "grades:write", "attendance:read"}, + } + + tests := []struct { + name string + permissions []string + expected bool + }{ + {"has all permissions", []string{"grades:read", "grades:write"}, true}, + {"missing one permission", []string{"grades:read", "users:manage"}, false}, + {"empty list", []string{}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := session.HasAllPermissions(tt.permissions) + if result != tt.expected { + t.Errorf("HasAllPermissions(%v) = %v, want %v", tt.permissions, result, tt.expected) + } + }) + } +} + +func TestSessionHasRole(t *testing.T) { + session := &Session{ + SessionID: "test-session-id", + UserID: "test-user-id", + Email: "test@example.com", + UserType: UserTypeEmployee, + Roles: []string{"teacher", "klassenlehrer"}, + } + + tests := []struct { + name string + role string + expected bool + }{ + {"has teacher role", "teacher", true}, + {"has klassenlehrer role", "klassenlehrer", true}, + {"missing admin role", "admin", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := session.HasRole(tt.role) + if result != tt.expected { + t.Errorf("HasRole(%q) = %v, want %v", tt.role, result, tt.expected) + } + }) + } +} + +func TestSessionIsEmployee(t *testing.T) { + employeeSession := &Session{ + SessionID: "test", + UserID: "test", + Email: "test@test.com", + UserType: UserTypeEmployee, + } + + customerSession := &Session{ + SessionID: "test", + UserID: "test", + Email: "test@test.com", + UserType: UserTypeCustomer, + } + + if !employeeSession.IsEmployee() { + t.Error("Employee session should return true for IsEmployee()") + } + if employeeSession.IsCustomer() { + t.Error("Employee session should return false for IsCustomer()") + } + if !customerSession.IsCustomer() { + t.Error("Customer session should return true for IsCustomer()") + } + if customerSession.IsEmployee() { + t.Error("Customer session should return false for IsEmployee()") + } +} + +func TestDetermineUserType(t *testing.T) { + tests := []struct { + name string + roles []string + expected UserType + }{ + {"teacher is employee", []string{"teacher"}, UserTypeEmployee}, + {"admin is employee", []string{"admin"}, UserTypeEmployee}, + {"klassenlehrer is employee", []string{"klassenlehrer"}, UserTypeEmployee}, + {"parent is customer", []string{"parent"}, UserTypeCustomer}, + {"student is customer", []string{"student"}, UserTypeCustomer}, + {"employee takes precedence", []string{"teacher", "parent"}, UserTypeEmployee}, + {"unknown role defaults to customer", []string{"unknown_role"}, UserTypeCustomer}, + {"empty roles defaults to customer", []string{}, UserTypeCustomer}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DetermineUserType(tt.roles) + if result != tt.expected { + t.Errorf("DetermineUserType(%v) = %v, want %v", tt.roles, result, tt.expected) + } + }) + } +} + +func TestGetPermissionsForRoles(t *testing.T) { + t.Run("employee gets employee permissions", func(t *testing.T) { + permissions := GetPermissionsForRoles([]string{"teacher"}, UserTypeEmployee) + + hasGradesRead := false + for _, p := range permissions { + if p == "grades:read" { + hasGradesRead = true + break + } + } + + if !hasGradesRead { + t.Error("Employee should have grades:read permission") + } + }) + + t.Run("customer gets customer permissions", func(t *testing.T) { + permissions := GetPermissionsForRoles([]string{"parent"}, UserTypeCustomer) + + hasChildrenRead := false + for _, p := range permissions { + if p == "children:read" { + hasChildrenRead = true + break + } + } + + if !hasChildrenRead { + t.Error("Customer should have children:read permission") + } + }) + + t.Run("admin gets admin permissions", func(t *testing.T) { + permissions := GetPermissionsForRoles([]string{"admin"}, UserTypeEmployee) + + hasUsersManage := false + for _, p := range permissions { + if p == "users:manage" { + hasUsersManage = true + break + } + } + + if !hasUsersManage { + t.Error("Admin should have users:manage permission") + } + }) +} + +func TestCheckResourceOwnership(t *testing.T) { + userID := "user-123" + adminSession := &Session{ + SessionID: "test", + UserID: "admin-456", + Email: "admin@test.com", + UserType: UserTypeEmployee, + Roles: []string{"admin"}, + } + regularSession := &Session{ + SessionID: "test", + UserID: userID, + Email: "user@test.com", + UserType: UserTypeEmployee, + Roles: []string{"teacher"}, + } + otherSession := &Session{ + SessionID: "test", + UserID: "other-789", + Email: "other@test.com", + UserType: UserTypeEmployee, + Roles: []string{"teacher"}, + } + + tests := []struct { + name string + session *Session + resourceUID string + allowAdmin bool + expected bool + }{ + {"owner can access", regularSession, userID, true, true}, + {"admin can access with allowAdmin", adminSession, userID, true, true}, + {"admin cannot access without allowAdmin", adminSession, userID, false, false}, + {"other user cannot access", otherSession, userID, true, false}, + {"nil session returns false", nil, userID, true, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := CheckResourceOwnership(tt.session, tt.resourceUID, tt.allowAdmin) + if result != tt.expected { + t.Errorf("CheckResourceOwnership() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestEmployeeRolesMap(t *testing.T) { + expectedRoles := []string{ + "admin", "schul_admin", "teacher", "klassenlehrer", + "fachlehrer", "sekretariat", "data_protection_officer", + } + + for _, role := range expectedRoles { + if !EmployeeRoles[role] { + t.Errorf("Expected employee role %q not found in EmployeeRoles map", role) + } + } +} + +func TestCustomerRolesMap(t *testing.T) { + expectedRoles := []string{"parent", "student", "user"} + + for _, role := range expectedRoles { + if !CustomerRoles[role] { + t.Errorf("Expected customer role %q not found in CustomerRoles map", role) + } + } +} + +func TestPermissionSlicesNotEmpty(t *testing.T) { + if len(EmployeePermissions) == 0 { + t.Error("EmployeePermissions should not be empty") + } + if len(CustomerPermissions) == 0 { + t.Error("CustomerPermissions should not be empty") + } + if len(AdminPermissions) == 0 { + t.Error("AdminPermissions should not be empty") + } +} + +func TestSessionTimestamps(t *testing.T) { + now := time.Now().UTC() + session := &Session{ + SessionID: "test", + UserID: "test", + Email: "test@test.com", + UserType: UserTypeEmployee, + CreatedAt: now, + LastActivityAt: now, + } + + if session.CreatedAt.IsZero() { + t.Error("CreatedAt should not be zero") + } + if session.LastActivityAt.IsZero() { + t.Error("LastActivityAt should not be zero") + } + if session.CreatedAt.After(time.Now().UTC()) { + t.Error("CreatedAt should not be in the future") + } +} + +func TestUserTypeConstants(t *testing.T) { + if UserTypeEmployee != "employee" { + t.Errorf("UserTypeEmployee = %q, want %q", UserTypeEmployee, "employee") + } + if UserTypeCustomer != "customer" { + t.Errorf("UserTypeCustomer = %q, want %q", UserTypeCustomer, "customer") + } +} diff --git a/consent-service/migrations/005_banner_consent_tables.sql b/consent-service/migrations/005_banner_consent_tables.sql new file mode 100644 index 0000000..c654e30 --- /dev/null +++ b/consent-service/migrations/005_banner_consent_tables.sql @@ -0,0 +1,223 @@ +-- Migration: Banner Consent Tables +-- Für @breakpilot/consent-sdk +-- DSGVO/TTDSG-konforme Speicherung von Cookie-Einwilligungen + +-- ======================================== +-- Banner Consents (anonyme Einwilligungen) +-- ======================================== + +CREATE TABLE IF NOT EXISTS banner_consents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + site_id VARCHAR(50) NOT NULL, + device_fingerprint VARCHAR(64) NOT NULL, + user_id VARCHAR(100), -- Optional: Für eingeloggte Nutzer + + -- Consent-Daten + categories JSONB NOT NULL DEFAULT '{}', -- { "analytics": true, "marketing": false } + vendors JSONB DEFAULT '{}', -- { "google-analytics": true } + tcf_string TEXT, -- IAB TCF String + + -- Metadaten (anonymisiert) + ip_hash VARCHAR(64), -- Anonymisierte IP + user_agent TEXT, + language VARCHAR(10), + platform VARCHAR(20), -- web, ios, android + app_version VARCHAR(20), + + -- Versionierung + version VARCHAR(20) DEFAULT '1.0.0', + + -- Zeitstempel + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + expires_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + + -- Constraints + CONSTRAINT unique_site_device UNIQUE (site_id, device_fingerprint) +); + +-- Indizes für schnelle Abfragen +CREATE INDEX IF NOT EXISTS idx_banner_consents_site ON banner_consents(site_id); +CREATE INDEX IF NOT EXISTS idx_banner_consents_user ON banner_consents(user_id) WHERE user_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_banner_consents_device ON banner_consents(device_fingerprint); +CREATE INDEX IF NOT EXISTS idx_banner_consents_created ON banner_consents(created_at); +CREATE INDEX IF NOT EXISTS idx_banner_consents_expires ON banner_consents(expires_at) WHERE expires_at IS NOT NULL; + +-- ======================================== +-- Audit Log (unveränderbar) +-- ======================================== + +CREATE TABLE IF NOT EXISTS banner_consent_audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + consent_id UUID NOT NULL, + action VARCHAR(20) NOT NULL, -- created, updated, revoked + details JSONB, + ip_hash VARCHAR(64), + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Kein UPDATE/DELETE auf Audit-Log +-- REVOKE UPDATE, DELETE ON banner_consent_audit_log FROM PUBLIC; + +CREATE INDEX IF NOT EXISTS idx_banner_audit_consent ON banner_consent_audit_log(consent_id); +CREATE INDEX IF NOT EXISTS idx_banner_audit_created ON banner_consent_audit_log(created_at); + +-- ======================================== +-- Site-Konfigurationen +-- ======================================== + +CREATE TABLE IF NOT EXISTS banner_site_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + site_id VARCHAR(50) UNIQUE NOT NULL, + site_name VARCHAR(100) NOT NULL, + + -- UI-Konfiguration + ui_theme VARCHAR(20) DEFAULT 'auto', + ui_position VARCHAR(20) DEFAULT 'bottom', + ui_layout VARCHAR(20) DEFAULT 'modal', + custom_css TEXT, + + -- Rechtliche Links + privacy_policy_url VARCHAR(255), + imprint_url VARCHAR(255), + dpo_name VARCHAR(100), + dpo_email VARCHAR(100), + + -- TCF 2.2 + tcf_enabled BOOLEAN DEFAULT FALSE, + tcf_cmp_id INTEGER, + tcf_cmp_version INTEGER, + + -- Zeitstempel + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); + +-- ======================================== +-- Kategorie-Konfigurationen pro Site +-- ======================================== + +CREATE TABLE IF NOT EXISTS banner_category_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + site_id VARCHAR(50) NOT NULL, + category_id VARCHAR(50) NOT NULL, + + -- Namen (mehrsprachig) + name_de VARCHAR(100) NOT NULL, + name_en VARCHAR(100), + + -- Beschreibungen (mehrsprachig) + description_de TEXT, + description_en TEXT, + + -- Einstellungen + is_required BOOLEAN DEFAULT FALSE, + sort_order INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT TRUE, + + -- Zeitstempel + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT unique_site_category UNIQUE (site_id, category_id) +); + +-- ======================================== +-- Vendor-Konfigurationen +-- ======================================== + +CREATE TABLE IF NOT EXISTS banner_vendor_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + site_id VARCHAR(50) NOT NULL, + category_id VARCHAR(50) NOT NULL, + vendor_id VARCHAR(100) NOT NULL, + + -- Vendor-Informationen + name VARCHAR(100) NOT NULL, + privacy_policy_url VARCHAR(255), + data_retention VARCHAR(50), + data_transfer VARCHAR(100), + + -- TCF + tcf_vendor_id INTEGER, + tcf_purposes JSONB, + tcf_legitimate_interests JSONB, + + -- Cookies + cookies JSONB DEFAULT '[]', + + -- Zeitstempel + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT unique_site_vendor UNIQUE (site_id, vendor_id) +); + +-- ======================================== +-- Helper-Funktionen +-- ======================================== + +-- Abgelaufene Consents bereinigen +CREATE OR REPLACE FUNCTION cleanup_expired_banner_consents() +RETURNS INTEGER AS $$ +DECLARE + deleted_count INTEGER; +BEGIN + -- Soft-Delete nach 30 Tagen nach Ablauf + WITH deleted AS ( + DELETE FROM banner_consents + WHERE expires_at < NOW() - INTERVAL '30 days' + AND revoked_at IS NULL + RETURNING id + ) + SELECT COUNT(*) INTO deleted_count FROM deleted; + + RETURN deleted_count; +END; +$$ LANGUAGE plpgsql; + +-- Statistik-View +CREATE OR REPLACE VIEW banner_consent_stats AS +SELECT + site_id, + DATE(created_at) as consent_date, + COUNT(*) as total_consents, + COUNT(*) FILTER (WHERE revoked_at IS NOT NULL) as revoked_consents, + COUNT(*) FILTER (WHERE (categories->>'analytics')::boolean = true) as analytics_accepted, + COUNT(*) FILTER (WHERE (categories->>'marketing')::boolean = true) as marketing_accepted, + COUNT(*) FILTER (WHERE (categories->>'functional')::boolean = true) as functional_accepted, + COUNT(*) FILTER (WHERE (categories->>'social')::boolean = true) as social_accepted +FROM banner_consents +GROUP BY site_id, DATE(created_at); + +-- ======================================== +-- Standard-Kategorien einfügen +-- ======================================== + +INSERT INTO banner_category_configs (site_id, category_id, name_de, name_en, description_de, description_en, is_required, sort_order) +VALUES + ('default', 'essential', 'Essentiell', 'Essential', + 'Notwendig für die Grundfunktionen der Website.', + 'Required for basic website functionality.', + TRUE, 1), + ('default', 'functional', 'Funktional', 'Functional', + 'Ermöglicht Personalisierung und Komfortfunktionen.', + 'Enables personalization and comfort features.', + FALSE, 2), + ('default', 'analytics', 'Statistik', 'Analytics', + 'Hilft uns, die Website zu verbessern.', + 'Helps us improve the website.', + FALSE, 3), + ('default', 'marketing', 'Marketing', 'Marketing', + 'Ermöglicht personalisierte Werbung.', + 'Enables personalized advertising.', + FALSE, 4), + ('default', 'social', 'Soziale Medien', 'Social Media', + 'Ermöglicht Inhalte von sozialen Netzwerken.', + 'Enables content from social networks.', + FALSE, 5) +ON CONFLICT (site_id, category_id) DO NOTHING; + +-- Fertig +SELECT 'Banner Consent Tables created successfully' as status; diff --git a/consent-service/tests/integration_test.go b/consent-service/tests/integration_test.go new file mode 100644 index 0000000..03ba09d --- /dev/null +++ b/consent-service/tests/integration_test.go @@ -0,0 +1,739 @@ +package tests + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// Integration tests for the Consent Service +// These tests simulate complete user workflows + +func init() { + gin.SetMode(gin.TestMode) +} + +// TestFullAuthFlow tests the complete authentication workflow +func TestFullAuthFlow(t *testing.T) { + t.Log("Starting full authentication flow integration test") + + // Step 1: Register a new user + t.Run("Step 1: Register User", func(t *testing.T) { + reqBody := map[string]string{ + "email": "testuser@example.com", + "password": "SecurePassword123!", + "name": "Test User", + } + + // Validate registration data + if reqBody["email"] == "" || reqBody["password"] == "" { + t.Fatal("Registration data incomplete") + } + + // Check password strength + if len(reqBody["password"]) < 8 { + t.Error("Password too weak") + } + + t.Log("User registration data validated") + }) + + // Step 2: Verify email + t.Run("Step 2: Verify Email", func(t *testing.T) { + token := "verification-token-123" + + if token == "" { + t.Fatal("Verification token missing") + } + + t.Log("Email verification simulated") + }) + + // Step 3: Login + t.Run("Step 3: Login", func(t *testing.T) { + loginReq := map[string]string{ + "email": "testuser@example.com", + "password": "SecurePassword123!", + } + + if loginReq["email"] == "" || loginReq["password"] == "" { + t.Fatal("Login credentials incomplete") + } + + // Simulate successful login + accessToken := "jwt-access-token-123" + refreshToken := "jwt-refresh-token-456" + + if accessToken == "" || refreshToken == "" { + t.Fatal("Tokens not generated") + } + + t.Log("Login successful, tokens generated") + }) + + // Step 4: Setup 2FA + t.Run("Step 4: Setup 2FA", func(t *testing.T) { + // Generate TOTP secret + totpSecret := "JBSWY3DPEHPK3PXP" + + if totpSecret == "" { + t.Fatal("TOTP secret not generated") + } + + // Verify TOTP code + totpCode := "123456" + if len(totpCode) != 6 { + t.Error("Invalid TOTP code format") + } + + t.Log("2FA setup completed") + }) + + // Step 5: Login with 2FA + t.Run("Step 5: Login with 2FA", func(t *testing.T) { + // First phase - email/password + challengeID := uuid.New().String() + + if challengeID == "" { + t.Fatal("Challenge ID not generated") + } + + // Second phase - TOTP verification + totpCode := "654321" + if len(totpCode) != 6 { + t.Error("Invalid TOTP code") + } + + t.Log("2FA login flow completed") + }) + + // Step 6: Refresh token + t.Run("Step 6: Refresh Access Token", func(t *testing.T) { + refreshToken := "jwt-refresh-token-456" + + if refreshToken == "" { + t.Fatal("Refresh token missing") + } + + // Generate new access token + newAccessToken := "jwt-access-token-789" + + if newAccessToken == "" { + t.Fatal("New access token not generated") + } + + t.Log("Token refresh successful") + }) + + // Step 7: Logout + t.Run("Step 7: Logout", func(t *testing.T) { + // Revoke tokens + sessionRevoked := true + + if !sessionRevoked { + t.Error("Session not properly revoked") + } + + t.Log("Logout successful") + }) +} + +// TestDocumentLifecycle tests the complete document workflow +func TestDocumentLifecycle(t *testing.T) { + t.Log("Starting document lifecycle integration test") + + var documentID uuid.UUID + var versionID uuid.UUID + + // Step 1: Create document (Admin) + t.Run("Step 1: Admin Creates Document", func(t *testing.T) { + docReq := map[string]interface{}{ + "type": "terms", + "name": "Terms of Service", + "description": "Our terms and conditions", + "is_mandatory": true, + } + + // Validate + if docReq["type"] == "" || docReq["name"] == "" { + t.Fatal("Document data incomplete") + } + + documentID = uuid.New() + t.Logf("Document created with ID: %s", documentID) + }) + + // Step 2: Create version (Admin) + t.Run("Step 2: Admin Creates Version", func(t *testing.T) { + versionReq := map[string]interface{}{ + "document_id": documentID.String(), + "version": "1.0.0", + "language": "de", + "title": "Nutzungsbedingungen", + "content": "

              Terms

              Content...

              ", + "summary": "Initial version", + } + + if versionReq["version"] == "" || versionReq["content"] == "" { + t.Fatal("Version data incomplete") + } + + versionID = uuid.New() + t.Logf("Version created with ID: %s", versionID) + }) + + // Step 3: Submit for review (Admin) + t.Run("Step 3: Submit for Review", func(t *testing.T) { + currentStatus := "draft" + newStatus := "review" + + if currentStatus != "draft" { + t.Error("Can only submit drafts for review") + } + + t.Logf("Status changed: %s -> %s", currentStatus, newStatus) + }) + + // Step 4: Approve version (DSB) + t.Run("Step 4: DSB Approves Version", func(t *testing.T) { + approverRole := "data_protection_officer" + currentStatus := "review" + + if approverRole != "data_protection_officer" { + t.Error("Only DSB can approve") + } + + if currentStatus != "review" { + t.Error("Can only approve review versions") + } + + newStatus := "approved" + t.Logf("Version approved, status: %s", newStatus) + }) + + // Step 5: Publish version (Admin/DSB) + t.Run("Step 5: Publish Version", func(t *testing.T) { + currentStatus := "approved" + + if currentStatus != "approved" && currentStatus != "scheduled" { + t.Error("Can only publish approved/scheduled versions") + } + + publishedAt := time.Now() + t.Logf("Version published at: %s", publishedAt) + }) + + // Step 6: User views published document + t.Run("Step 6: User Views Document", func(t *testing.T) { + language := "de" + + // Fetch latest published version + if language == "" { + language = "de" // default + } + + t.Log("User retrieved latest published version") + }) + + // Step 7: Archive old version + t.Run("Step 7: Archive Old Version", func(t *testing.T) { + status := "published" + + if status != "published" { + t.Error("Can only archive published versions") + } + + newStatus := "archived" + t.Logf("Version archived, status: %s", newStatus) + }) +} + +// TestConsentFlow tests the complete consent workflow +func TestConsentFlow(t *testing.T) { + t.Log("Starting consent flow integration test") + + userID := uuid.New() + versionID := uuid.New() + + // Step 1: User checks consent status + t.Run("Step 1: Check Consent Status", func(t *testing.T) { + documentType := "terms" + + hasConsent := false + needsUpdate := true + + if hasConsent { + t.Log("User already has consent") + } + + if !needsUpdate { + t.Error("Should need consent for new document") + } + + t.Logf("User %s needs consent for %s", userID, documentType) + }) + + // Step 2: User retrieves document details + t.Run("Step 2: Get Document Details", func(t *testing.T) { + language := "de" + + if language == "" { + t.Error("Language required") + } + + t.Log("Document details retrieved") + }) + + // Step 3: User gives consent + t.Run("Step 3: Give Consent", func(t *testing.T) { + consentReq := map[string]interface{}{ + "version_id": versionID.String(), + "consented": true, + } + + if consentReq["version_id"] == "" { + t.Fatal("Version ID required") + } + + consentID := uuid.New() + consentedAt := time.Now() + + t.Logf("Consent given, ID: %s, At: %s", consentID, consentedAt) + }) + + // Step 4: Verify consent recorded + t.Run("Step 4: Verify Consent", func(t *testing.T) { + hasConsent := true + needsUpdate := false + + if !hasConsent { + t.Error("Consent should be recorded") + } + + if needsUpdate { + t.Error("Should not need update after consent") + } + + t.Log("Consent verified") + }) + + // Step 5: New version published + t.Run("Step 5: New Version Published", func(t *testing.T) { + newVersionID := uuid.New() + + // Check if consent needs update + currentVersionID := versionID + latestVersionID := newVersionID + + needsUpdate := currentVersionID != latestVersionID + + if !needsUpdate { + t.Error("Should need update for new version") + } + + t.Log("New version published, consent update needed") + }) + + // Step 6: User withdraws consent + t.Run("Step 6: Withdraw Consent", func(t *testing.T) { + withdrawnConsentID := uuid.New() + + withdrawnAt := time.Now() + consented := false + + if consented { + t.Error("Consent should be withdrawn") + } + + t.Logf("Consent %s withdrawn at: %s", withdrawnConsentID, withdrawnAt) + }) + + // Step 7: User gives consent again + t.Run("Step 7: Re-consent", func(t *testing.T) { + newConsentID := uuid.New() + consented := true + + if !consented { + t.Error("Should be consented") + } + + t.Logf("Re-consented, ID: %s", newConsentID) + }) + + // Step 8: Get consent history + t.Run("Step 8: View Consent History", func(t *testing.T) { + // Fetch all consents for user + consentCount := 2 // initial + re-consent + + if consentCount < 1 { + t.Error("Should have consent history") + } + + t.Logf("User has %d consent records", consentCount) + }) +} + +// TestOAuthFlow tests the OAuth 2.0 authorization code flow +func TestOAuthFlow(t *testing.T) { + t.Log("Starting OAuth flow integration test") + + clientID := "client-app-123" + _ = uuid.New() // userID simulated + + // Step 1: Authorization request + t.Run("Step 1: Authorization Request", func(t *testing.T) { + authReq := map[string]string{ + "response_type": "code", + "client_id": clientID, + "redirect_uri": "https://app.example.com/callback", + "scope": "read:consents write:consents", + "state": "random-state-123", + } + + if authReq["response_type"] != "code" { + t.Error("Must use authorization code flow") + } + + if authReq["state"] == "" { + t.Error("State required for CSRF protection") + } + + t.Log("Authorization request validated") + }) + + // Step 2: User approves + t.Run("Step 2: User Approves", func(t *testing.T) { + approved := true + + if !approved { + t.Skip("User denied authorization") + } + + authCode := "auth-code-abc123" + t.Logf("Authorization code issued: %s", authCode) + }) + + // Step 3: Exchange code for token + t.Run("Step 3: Token Exchange", func(t *testing.T) { + tokenReq := map[string]string{ + "grant_type": "authorization_code", + "code": "auth-code-abc123", + "redirect_uri": "https://app.example.com/callback", + "client_id": clientID, + } + + if tokenReq["grant_type"] != "authorization_code" { + t.Error("Invalid grant type") + } + + accessToken := "access-token-xyz" + refreshToken := "refresh-token-def" + expiresIn := 3600 + + if accessToken == "" || refreshToken == "" { + t.Fatal("Tokens not issued") + } + + t.Logf("Tokens issued, expires in %d seconds", expiresIn) + }) + + // Step 4: Use access token + t.Run("Step 4: Access Protected Resource", func(t *testing.T) { + accessToken := "access-token-xyz" + + if accessToken == "" { + t.Fatal("Access token required") + } + + // Make API request + authorized := true + + if !authorized { + t.Error("Token should grant access") + } + + t.Log("Successfully accessed protected resource") + }) + + // Step 5: Refresh token + t.Run("Step 5: Refresh Access Token", func(t *testing.T) { + refreshReq := map[string]string{ + "grant_type": "refresh_token", + "refresh_token": "refresh-token-def", + "client_id": clientID, + } + + if refreshReq["grant_type"] != "refresh_token" { + t.Error("Invalid grant type") + } + + newAccessToken := "access-token-new" + t.Logf("New access token issued: %s", newAccessToken) + }) +} + +// TestConsentDeadlineFlow tests consent deadline and suspension workflow +func TestConsentDeadlineFlow(t *testing.T) { + t.Log("Starting consent deadline flow test") + + _ = uuid.New() // userID simulated + _ = uuid.New() // versionID simulated + + // Step 1: New mandatory version published + t.Run("Step 1: Mandatory Version Published", func(t *testing.T) { + isMandatory := true + + if !isMandatory { + t.Skip("Only for mandatory documents") + } + + // Create deadline + deadlineAt := time.Now().AddDate(0, 0, 30) + + t.Logf("Deadline created: %s (30 days)", deadlineAt) + }) + + // Step 2: Send reminder (14 days before) + t.Run("Step 2: First Reminder", func(t *testing.T) { + daysLeft := 14 + urgency := "normal" + + if daysLeft <= 7 { + urgency = "warning" + } + + t.Logf("Reminder sent, %d days left, urgency: %s", daysLeft, urgency) + }) + + // Step 3: Send urgent reminder (3 days before) + t.Run("Step 3: Urgent Reminder", func(t *testing.T) { + daysLeft := 3 + urgency := "urgent" + + if daysLeft > 3 { + t.Skip("Not yet urgent") + } + + t.Logf("Urgent reminder sent, urgency level: %s", urgency) + }) + + // Step 4: Deadline passes without consent + t.Run("Step 4: Deadline Exceeded", func(t *testing.T) { + deadlineAt := time.Now().AddDate(0, 0, -1) + hasConsent := false + isOverdue := deadlineAt.Before(time.Now()) + + if !isOverdue { + t.Skip("Not yet overdue") + } + + if hasConsent { + t.Skip("User has consent") + } + + t.Log("Deadline exceeded without consent") + }) + + // Step 5: Suspend account + t.Run("Step 5: Suspend Account", func(t *testing.T) { + accountStatus := "suspended" + suspensionReason := "consent_deadline_exceeded" + + if accountStatus != "suspended" { + t.Error("Account should be suspended") + } + + t.Logf("Account suspended: %s", suspensionReason) + }) + + // Step 6: User gives consent + t.Run("Step 6: User Gives Consent", func(t *testing.T) { + consentGiven := true + + if !consentGiven { + t.Error("Consent should be given") + } + + t.Log("Consent provided") + }) + + // Step 7: Lift suspension + t.Run("Step 7: Lift Suspension", func(t *testing.T) { + accountStatus := "active" + liftedAt := time.Now() + + if accountStatus != "active" { + t.Error("Account should be active") + } + + t.Logf("Suspension lifted at: %s", liftedAt) + }) +} + +// TestGDPRDataExport tests GDPR data export workflow +func TestGDPRDataExport(t *testing.T) { + t.Log("Starting GDPR data export test") + + userID := uuid.New() + + // Step 1: Request data export + t.Run("Step 1: Request Data Export", func(t *testing.T) { + exportID := uuid.New() + status := "pending" + + if status != "pending" { + t.Error("Export should start as pending") + } + + t.Logf("Export requested, ID: %s", exportID) + }) + + // Step 2: Process export + t.Run("Step 2: Process Export", func(t *testing.T) { + status := "processing" + + // Collect user data + userData := map[string]interface{}{ + "user": map[string]string{"id": userID.String(), "email": "user@example.com"}, + "consents": []interface{}{}, + "cookie_consents": []interface{}{}, + "audit_log": []interface{}{}, + } + + if userData == nil { + t.Fatal("User data not collected") + } + + t.Logf("Export %s", status) + }) + + // Step 3: Export complete + t.Run("Step 3: Export Complete", func(t *testing.T) { + status := "completed" + downloadURL := "https://example.com/exports/user-data.json" + expiresAt := time.Now().AddDate(0, 0, 7) + + if status != "completed" { + t.Error("Export should be completed") + } + + if downloadURL == "" { + t.Error("Download URL required") + } + + t.Logf("Export complete, expires at: %s", expiresAt) + }) + + // Step 4: User downloads data + t.Run("Step 4: Download Export", func(t *testing.T) { + downloaded := true + + if !downloaded { + t.Error("Export should be downloadable") + } + + t.Log("Export downloaded") + }) +} + +// Helper functions for integration tests + +// makeRequest simulates an HTTP request +func makeRequest(t *testing.T, method, endpoint string, body interface{}, headers map[string]string) *httptest.ResponseRecorder { + var req *http.Request + + if body != nil { + jsonBody, _ := json.Marshal(body) + req, _ = http.NewRequest(method, endpoint, bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + } else { + req, _ = http.NewRequest(method, endpoint, nil) + } + + // Add custom headers + for key, value := range headers { + req.Header.Set(key, value) + } + + w := httptest.NewRecorder() + return w +} + +// assertStatus checks HTTP status code +func assertStatus(t *testing.T, expected, actual int) { + if actual != expected { + t.Errorf("Expected status %d, got %d", expected, actual) + } +} + +// assertJSONField checks a JSON field value +func assertJSONField(t *testing.T, body []byte, field string, expected interface{}) { + var response map[string]interface{} + if err := json.Unmarshal(body, &response); err != nil { + t.Fatalf("Failed to parse JSON: %v", err) + } + + actual, ok := response[field] + if !ok { + t.Errorf("Field %s not found in response", field) + return + } + + if actual != expected { + t.Errorf("Field %s: expected %v, got %v", field, expected, actual) + } +} + +// logTestStep logs a test step with context +func logTestStep(t *testing.T, step int, description string) { + t.Logf("Step %d: %s", step, description) +} + +// TestEndToEndScenario runs a complete end-to-end scenario +func TestEndToEndScenario(t *testing.T) { + t.Log("Running complete end-to-end scenario") + + scenario := []struct { + step int + description string + action func(t *testing.T) + }{ + {1, "User registers", func(t *testing.T) { + t.Log("User registration") + }}, + {2, "User verifies email", func(t *testing.T) { + t.Log("Email verified") + }}, + {3, "User logs in", func(t *testing.T) { + t.Log("User logged in") + }}, + {4, "User views documents", func(t *testing.T) { + t.Log("Documents retrieved") + }}, + {5, "User gives consent", func(t *testing.T) { + t.Log("Consent given") + }}, + {6, "Admin publishes new version", func(t *testing.T) { + t.Log("New version published") + }}, + {7, "User updates consent", func(t *testing.T) { + t.Log("Consent updated") + }}, + {8, "User exports data", func(t *testing.T) { + t.Log("Data exported") + }}, + } + + for _, s := range scenario { + t.Run(fmt.Sprintf("Step_%d_%s", s.step, s.description), s.action) + } + + t.Log("End-to-end scenario completed successfully") +} diff --git a/docker-compose.content.yml b/docker-compose.content.yml new file mode 100644 index 0000000..3598569 --- /dev/null +++ b/docker-compose.content.yml @@ -0,0 +1,135 @@ +# BreakPilot Content Service Stack +# Usage: docker-compose -f docker-compose.yml -f docker-compose.content.yml up -d + +services: + # MinIO Object Storage (S3-compatible) + minio: + image: minio/minio:latest + container_name: breakpilot-pwa-minio + ports: + - "9000:9000" # API + - "9001:9001" # Console + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin123 + command: server /data --console-address ":9001" + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] + interval: 30s + timeout: 20s + retries: 3 + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Content Service Database (separate from main DB) + content-db: + image: postgres:16-alpine + container_name: breakpilot-pwa-content-db + ports: + - "5433:5432" + environment: + POSTGRES_USER: breakpilot + POSTGRES_PASSWORD: breakpilot123 + POSTGRES_DB: breakpilot_content + volumes: + - content_db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U breakpilot -d breakpilot_content"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Content Service API + content-service: + build: + context: ./backend/content_service + dockerfile: Dockerfile + container_name: breakpilot-pwa-content-service + ports: + - "8002:8002" + environment: + - CONTENT_DB_URL=postgresql://breakpilot:breakpilot123@content-db:5432/breakpilot_content + - MINIO_ENDPOINT=minio:9000 + - MINIO_ACCESS_KEY=minioadmin + - MINIO_SECRET_KEY=minioadmin123 + - MINIO_SECURE=false + - MINIO_BUCKET=breakpilot-content + - CONSENT_SERVICE_URL=http://consent-service:8081 + - JWT_SECRET=${JWT_SECRET:-your-super-secret-jwt-key-change-in-production} + - MATRIX_HOMESERVER=${MATRIX_HOMESERVER:-http://synapse:8008} + - MATRIX_ACCESS_TOKEN=${MATRIX_ACCESS_TOKEN:-} + depends_on: + content-db: + condition: service_healthy + minio: + condition: service_healthy + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # H5P Interactive Content Service + h5p-service: + build: + context: ./h5p-service + dockerfile: Dockerfile + container_name: breakpilot-pwa-h5p + ports: + - "8003:8080" + environment: + - H5P_STORAGE_PATH=/h5p-content + - CONTENT_SERVICE_URL=http://content-service:8002 + volumes: + - h5p_content:/h5p-content + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # AI Content Generator Service + ai-content-generator: + build: + context: ./ai-content-generator + dockerfile: Dockerfile + container_name: breakpilot-pwa-ai-generator + ports: + - "8004:8004" + environment: + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - YOUTUBE_API_KEY=${YOUTUBE_API_KEY:-} + - H5P_SERVICE_URL=http://h5p-service:8080 + - CONTENT_SERVICE_URL=http://content-service:8002 + - SERVICE_HOST=0.0.0.0 + - SERVICE_PORT=8004 + - MAX_UPLOAD_SIZE=10485760 + - MAX_CONCURRENT_JOBS=5 + - JOB_TIMEOUT=300 + volumes: + - ai_generator_temp:/app/temp + - ai_generator_uploads:/app/uploads + depends_on: + - h5p-service + - content-service + networks: + - breakpilot-pwa-network + restart: unless-stopped + +volumes: + minio_data: + driver: local + content_db_data: + driver: local + h5p_content: + driver: local + ai_generator_temp: + driver: local + ai_generator_uploads: + driver: local + +networks: + breakpilot-pwa-network: + external: true diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..0bb6678 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,28 @@ +# Development-specific overrides +# Use with: docker-compose -f docker-compose.yml -f docker-compose.dev.yml up + +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + volumes: + # Mount source code for hot-reload + - ./backend:/app + # Don't override the venv + - /app/venv + environment: + - DEBUG=true + command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload + + consent-service: + # For development, you might want to use the local binary instead + # Uncomment below to mount source and rebuild on changes + # volumes: + # - ./consent-service:/app + environment: + - GIN_MODE=debug + + postgres: + ports: + - "5432:5432" # Expose for local tools diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..0fb0cb2 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,108 @@ +# ============================================ +# BreakPilot PWA - Development Overrides +# ============================================ +# This file is AUTOMATICALLY loaded with: docker compose up +# No need to specify -f flag for development! +# +# For staging: docker compose -f docker-compose.yml -f docker-compose.staging.yml up +# ============================================ + +services: + # ========================================== + # Python Backend (FastAPI) + # ========================================== + backend: + build: + context: ./backend + dockerfile: Dockerfile + volumes: + # Mount source code for hot-reload + - ./backend:/app + # Don't override the venv + - /app/venv + environment: + - DEBUG=true + - ENVIRONMENT=development + - LOG_LEVEL=debug + command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload + + # ========================================== + # Go Consent Service + # ========================================== + consent-service: + environment: + - GIN_MODE=debug + - ENVIRONMENT=development + - LOG_LEVEL=debug + + # ========================================== + # Go School Service + # ========================================== + school-service: + environment: + - GIN_MODE=debug + - ENVIRONMENT=development + + # ========================================== + # Go Billing Service + # ========================================== + billing-service: + environment: + - GIN_MODE=debug + - ENVIRONMENT=development + + # ========================================== + # Klausur Service (Python + React) + # ========================================== + klausur-service: + environment: + - DEBUG=true + - ENVIRONMENT=development + + # ========================================== + # Website (Next.js) + # ========================================== + website: + environment: + - NODE_ENV=development + + # ========================================== + # PostgreSQL + # ========================================== + postgres: + ports: + - "5432:5432" # Expose for local DB tools + environment: + - POSTGRES_DB=${POSTGRES_DB:-breakpilot_dev} + + # ========================================== + # MinIO (Object Storage) + # ========================================== + minio: + ports: + - "9000:9000" + - "9001:9001" # Console + + # ========================================== + # Qdrant (Vector DB) + # ========================================== + qdrant: + ports: + - "6333:6333" + - "6334:6334" + + # ========================================== + # Mailpit (Email Testing) + # ========================================== + mailpit: + ports: + - "8025:8025" # Web UI + - "1025:1025" # SMTP + + # ========================================== + # DSMS Gateway + # ========================================== + dsms-gateway: + environment: + - DEBUG=true + - ENVIRONMENT=development diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml new file mode 100644 index 0000000..efd77f2 --- /dev/null +++ b/docker-compose.staging.yml @@ -0,0 +1,133 @@ +# ============================================ +# BreakPilot PWA - Staging Overrides +# ============================================ +# Usage: docker compose -f docker-compose.yml -f docker-compose.staging.yml up -d +# +# Or use the helper script: +# ./scripts/start.sh staging +# ============================================ + +services: + # ========================================== + # Python Backend (FastAPI) + # ========================================== + backend: + environment: + - DEBUG=false + - ENVIRONMENT=staging + - LOG_LEVEL=info + restart: unless-stopped + # No hot-reload in staging + command: uvicorn main:app --host 0.0.0.0 --port 8000 + + # ========================================== + # Go Consent Service + # ========================================== + consent-service: + environment: + - GIN_MODE=release + - ENVIRONMENT=staging + - LOG_LEVEL=info + restart: unless-stopped + + # ========================================== + # Go School Service + # ========================================== + school-service: + environment: + - GIN_MODE=release + - ENVIRONMENT=staging + restart: unless-stopped + + # ========================================== + # Go Billing Service + # ========================================== + billing-service: + environment: + - GIN_MODE=release + - ENVIRONMENT=staging + restart: unless-stopped + + # ========================================== + # Klausur Service (Python + React) + # ========================================== + klausur-service: + environment: + - DEBUG=false + - ENVIRONMENT=staging + restart: unless-stopped + + # ========================================== + # Website (Next.js) + # ========================================== + website: + environment: + - NODE_ENV=production + restart: unless-stopped + + # ========================================== + # PostgreSQL (Separate Database for Staging) + # ========================================== + postgres: + ports: + - "5433:5432" # Different port for staging! + environment: + - POSTGRES_DB=${POSTGRES_DB:-breakpilot_staging} + volumes: + - breakpilot_staging_postgres:/var/lib/postgresql/data + + # ========================================== + # MinIO (Object Storage - Different Ports) + # ========================================== + minio: + ports: + - "9002:9000" + - "9003:9001" + volumes: + - breakpilot_staging_minio:/data + + # ========================================== + # Qdrant (Vector DB - Different Ports) + # ========================================== + qdrant: + ports: + - "6335:6333" + - "6336:6334" + volumes: + - breakpilot_staging_qdrant:/qdrant/storage + + # ========================================== + # Mailpit (Still using Mailpit for Safety) + # ========================================== + mailpit: + ports: + - "8026:8025" # Different Web UI port + - "1026:1025" # Different SMTP port + + # ========================================== + # DSMS Gateway + # ========================================== + dsms-gateway: + environment: + - DEBUG=false + - ENVIRONMENT=staging + restart: unless-stopped + + # ========================================== + # Enable Backup Service in Staging + # ========================================== + backup: + profiles: [] # Remove profile restriction = always start + environment: + - PGDATABASE=breakpilot_staging + +# ========================================== +# Separate Volumes for Staging +# ========================================== +volumes: + breakpilot_staging_postgres: + name: breakpilot_staging_postgres + breakpilot_staging_minio: + name: breakpilot_staging_minio + breakpilot_staging_qdrant: + name: breakpilot_staging_qdrant diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..87ea225 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,153 @@ +# BreakPilot PWA - Test-Infrastruktur +# +# Vollstaendige Integration-Test Umgebung fuer CI/CD Pipeline. +# Startet alle Services isoliert fuer Integration-Tests. +# +# Verwendung: +# docker compose -f docker-compose.test.yml up -d +# docker compose -f docker-compose.test.yml down -v +# +# Verbindungen: +# PostgreSQL: localhost:55432 (breakpilot_test/breakpilot/breakpilot) +# Valkey/Redis: localhost:56379 +# Consent Service: localhost:58081 +# Backend: localhost:58000 +# Mailpit Web: localhost:58025 +# Mailpit SMTP: localhost:51025 + +version: "3.9" + +services: + # ======================================== + # Datenbank-Services + # ======================================== + + postgres-test: + image: postgres:16-alpine + container_name: breakpilot-postgres-test + environment: + POSTGRES_DB: breakpilot_test + POSTGRES_USER: breakpilot + POSTGRES_PASSWORD: breakpilot_test + ports: + - "55432:5432" + volumes: + - postgres_test_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U breakpilot -d breakpilot_test"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - breakpilot-test-network + restart: unless-stopped + + valkey-test: + image: valkey/valkey:7-alpine + container_name: breakpilot-valkey-test + ports: + - "56379:6379" + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - breakpilot-test-network + restart: unless-stopped + + # ======================================== + # Application Services + # ======================================== + + # Consent Service (Go) + consent-service-test: + build: + context: ./consent-service + dockerfile: Dockerfile + container_name: breakpilot-consent-service-test + ports: + - "58081:8081" + depends_on: + postgres-test: + condition: service_healthy + valkey-test: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8081/health"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 30s + environment: + - DATABASE_URL=postgres://breakpilot:breakpilot_test@postgres-test:5432/breakpilot_test + - VALKEY_URL=redis://valkey-test:6379 + - REDIS_URL=redis://valkey-test:6379 + - JWT_SECRET=test-jwt-secret-for-integration-tests + - ENVIRONMENT=test + - LOG_LEVEL=debug + networks: + - breakpilot-test-network + restart: unless-stopped + + # Backend (Python FastAPI) + backend-test: + build: + context: ./backend + dockerfile: Dockerfile + container_name: breakpilot-backend-test + ports: + - "58000:8000" + depends_on: + postgres-test: + condition: service_healthy + valkey-test: + condition: service_healthy + consent-service-test: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 45s + environment: + - DATABASE_URL=postgresql://breakpilot:breakpilot_test@postgres-test:5432/breakpilot_test + - CONSENT_SERVICE_URL=http://consent-service-test:8081 + - VALKEY_URL=redis://valkey-test:6379 + - REDIS_URL=redis://valkey-test:6379 + - JWT_SECRET=test-jwt-secret-for-integration-tests + - ENVIRONMENT=test + - SMTP_HOST=mailpit-test + - SMTP_PORT=1025 + - SKIP_INTEGRATION_TESTS=false + networks: + - breakpilot-test-network + restart: unless-stopped + + # ======================================== + # Development/Testing Tools + # ======================================== + + # Mailpit (E-Mail Testing) + mailpit-test: + image: axllent/mailpit:latest + container_name: breakpilot-mailpit-test + ports: + - "58025:8025" # Web UI + - "51025:1025" # SMTP + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:8025/api/v1/info"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - breakpilot-test-network + restart: unless-stopped + +networks: + breakpilot-test-network: + driver: bridge + +volumes: + postgres_test_data: diff --git a/docker-compose.vault.yml b/docker-compose.vault.yml new file mode 100644 index 0000000..e6da8ce --- /dev/null +++ b/docker-compose.vault.yml @@ -0,0 +1,98 @@ +# HashiCorp Vault Configuration for BreakPilot +# +# Usage: +# Development mode (unsealed, no auth required): +# docker-compose -f docker-compose.vault.yml up -d vault +# +# Production mode: +# docker-compose -f docker-compose.vault.yml --profile production up -d +# +# After starting Vault in dev mode: +# export VAULT_ADDR=http://localhost:8200 +# export VAULT_TOKEN=breakpilot-dev-token +# +# License: HashiCorp Vault is BSL 1.1 (open source for non-commercial use) +# Vault clients (hvac) are Apache-2.0 + +services: + # HashiCorp Vault - Secrets Management + vault: + image: hashicorp/vault:1.15 + container_name: breakpilot-pwa-vault + ports: + - "8200:8200" + environment: + # Development mode settings + VAULT_DEV_ROOT_TOKEN_ID: ${VAULT_DEV_TOKEN:-breakpilot-dev-token} + VAULT_DEV_LISTEN_ADDRESS: "0.0.0.0:8200" + VAULT_ADDR: "http://127.0.0.1:8200" + VAULT_API_ADDR: "http://0.0.0.0:8200" + cap_add: + - IPC_LOCK # Required for mlock + volumes: + - vault_data:/vault/data + - vault_logs:/vault/logs + - ./vault/config:/vault/config:ro + - ./vault/policies:/vault/policies:ro + command: server -dev -dev-root-token-id=${VAULT_DEV_TOKEN:-breakpilot-dev-token} + healthcheck: + test: ["CMD", "vault", "status"] + interval: 10s + timeout: 5s + retries: 3 + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Vault Agent for automatic secret injection (production) + vault-agent: + image: hashicorp/vault:1.15 + container_name: breakpilot-pwa-vault-agent + profiles: + - production + depends_on: + vault: + condition: service_healthy + environment: + VAULT_ADDR: "http://vault:8200" + volumes: + - ./vault/agent-config.hcl:/vault/config/agent-config.hcl:ro + - vault_agent_secrets:/vault/secrets + command: agent -config=/vault/config/agent-config.hcl + networks: + - breakpilot-pwa-network + restart: unless-stopped + + # Vault initializer - Seeds secrets in development + vault-init: + image: hashicorp/vault:1.15 + container_name: breakpilot-pwa-vault-init + depends_on: + vault: + condition: service_healthy + environment: + VAULT_ADDR: "http://vault:8200" + VAULT_TOKEN: ${VAULT_DEV_TOKEN:-breakpilot-dev-token} + volumes: + - ./vault/init-secrets.sh:/vault/init-secrets.sh:ro + entrypoint: ["/bin/sh", "-c"] + command: + - | + sleep 5 + chmod +x /vault/init-secrets.sh + /vault/init-secrets.sh + echo "Vault initialized with development secrets" + networks: + - breakpilot-pwa-network + +volumes: + vault_data: + name: breakpilot_vault_data + vault_logs: + name: breakpilot_vault_logs + vault_agent_secrets: + name: breakpilot_vault_agent_secrets + +networks: + breakpilot-pwa-network: + external: true diff --git a/docker/jibri/Dockerfile b/docker/jibri/Dockerfile new file mode 100644 index 0000000..abbb4d2 --- /dev/null +++ b/docker/jibri/Dockerfile @@ -0,0 +1,39 @@ +# Jibri mit MinIO Client, ffmpeg und Xvfb +# Erweitert das offizielle Jibri-Image um Upload-Funktionalitaet und X11 Display + +FROM jitsi/jibri:stable-9823 + +USER root + +# Xvfb, MinIO Client und ffmpeg installieren +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + ffmpeg \ + xvfb \ + x11vnc \ + && curl -fsSL https://dl.min.io/client/mc/release/linux-amd64/mc -o /usr/local/bin/mc \ + && chmod +x /usr/local/bin/mc \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# finalize.sh Script kopieren und ausfuehrbar machen +COPY finalize.sh /config/finalize.sh +RUN chmod +x /config/finalize.sh + +# Xvfb Startup Script +COPY start-xvfb.sh /usr/local/bin/start-xvfb.sh +RUN chmod +x /usr/local/bin/start-xvfb.sh + +# Fix permissions for s6 init system +RUN mkdir -p /var/run/s6 && chown -R jibri:jibri /var/run/s6 +RUN chown -R jibri:jibri /config + +# Xvfb Display Konfiguration +ENV DISPLAY=:0 +ENV RESOLUTION=1920x1080x24 + +# Custom entrypoint that starts Xvfb +COPY docker-entrypoint.sh /docker-entrypoint.sh +RUN chmod +x /docker-entrypoint.sh + +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/docker/jibri/docker-entrypoint.sh b/docker/jibri/docker-entrypoint.sh new file mode 100644 index 0000000..0b5c2fb --- /dev/null +++ b/docker/jibri/docker-entrypoint.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Jibri Entrypoint mit Xvfb Support +# Startet zuerst Xvfb, dann den normalen Jibri-Prozess + +set -e + +echo "=== BreakPilot Jibri Entrypoint ===" +echo "DISPLAY: ${DISPLAY}" +echo "RESOLUTION: ${RESOLUTION}" + +# Start Xvfb +echo "[Entrypoint] Starting Xvfb..." +/usr/local/bin/start-xvfb.sh + +# Export DISPLAY for child processes +export DISPLAY=${DISPLAY:-:0} + +# Optional: Start x11vnc for debugging (only if VNC_PASSWORD is set) +if [ -n "${VNC_PASSWORD}" ]; then + echo "[Entrypoint] Starting x11vnc on port 5900..." + x11vnc -display ${DISPLAY} -forever -passwd "${VNC_PASSWORD}" -bg -rfbport 5900 +fi + +echo "[Entrypoint] Starting Jibri..." + +# Execute the original Jibri entrypoint (s6-based init) +exec /init "$@" diff --git a/docker/jibri/finalize.sh b/docker/jibri/finalize.sh new file mode 100755 index 0000000..7560677 --- /dev/null +++ b/docker/jibri/finalize.sh @@ -0,0 +1,92 @@ +#!/bin/bash +# Jibri Finalize Script +# Wird aufgerufen wenn eine Aufzeichnung beendet ist +# Uploaded die Aufzeichnung zu MinIO und benachrichtigt das Backend + +set -e + +RECORDINGS_DIR="$1" +RECORDING_NAME=$(basename "$RECORDINGS_DIR") + +echo "[Finalize] Starting finalization for: $RECORDING_NAME" +echo "[Finalize] Recordings directory: $RECORDINGS_DIR" + +# Finde die Aufzeichnungsdatei +RECORDING_FILE=$(find "$RECORDINGS_DIR" -name "*.mp4" | head -1) + +if [ -z "$RECORDING_FILE" ]; then + echo "[Finalize] ERROR: No MP4 file found in $RECORDINGS_DIR" + exit 1 +fi + +echo "[Finalize] Found recording file: $RECORDING_FILE" +FILE_SIZE=$(stat -c%s "$RECORDING_FILE" 2>/dev/null || stat -f%z "$RECORDING_FILE") +echo "[Finalize] File size: $FILE_SIZE bytes" + +# MinIO Upload mit mc (MinIO Client) +MINIO_ALIAS="minio" +BUCKET="${MINIO_BUCKET:-breakpilot-recordings}" +DEST_PATH="recordings/${RECORDING_NAME}/video.mp4" + +# MinIO Client konfigurieren (wenn noch nicht konfiguriert) +if ! mc alias list | grep -q "$MINIO_ALIAS"; then + echo "[Finalize] Configuring MinIO client..." + mc alias set "$MINIO_ALIAS" "http://${MINIO_ENDPOINT:-minio:9000}" \ + "${MINIO_ACCESS_KEY:-minioadmin}" \ + "${MINIO_SECRET_KEY:-minioadmin}" +fi + +# Bucket erstellen falls nicht vorhanden +mc mb --ignore-existing "${MINIO_ALIAS}/${BUCKET}" + +# Upload +echo "[Finalize] Uploading to MinIO: ${BUCKET}/${DEST_PATH}" +mc cp "$RECORDING_FILE" "${MINIO_ALIAS}/${BUCKET}/${DEST_PATH}" + +if [ $? -eq 0 ]; then + echo "[Finalize] Upload successful!" + + # Audio extrahieren fuer Transkription + AUDIO_FILE="${RECORDINGS_DIR}/audio.wav" + echo "[Finalize] Extracting audio for transcription..." + ffmpeg -i "$RECORDING_FILE" -vn -acodec pcm_s16le -ar 16000 -ac 1 "$AUDIO_FILE" -y 2>/dev/null + + if [ -f "$AUDIO_FILE" ]; then + AUDIO_DEST_PATH="recordings/${RECORDING_NAME}/audio.wav" + mc cp "$AUDIO_FILE" "${MINIO_ALIAS}/${BUCKET}/${AUDIO_DEST_PATH}" + echo "[Finalize] Audio extracted and uploaded: ${AUDIO_DEST_PATH}" + fi + + # Backend Webhook benachrichtigen + if [ -n "$BACKEND_WEBHOOK_URL" ]; then + echo "[Finalize] Notifying backend webhook..." + METADATA_FILE=$(find "$RECORDINGS_DIR" -name "*.json" | head -1) + + WEBHOOK_PAYLOAD=$(cat </dev/null 2>&1; then + echo "[Xvfb] Display :${DISPLAY_NUM} is ready" +else + echo "[Xvfb] ERROR: Display :${DISPLAY_NUM} failed to start" + exit 1 +fi diff --git a/docs-src/Dockerfile b/docs-src/Dockerfile new file mode 100644 index 0000000..a8711c6 --- /dev/null +++ b/docs-src/Dockerfile @@ -0,0 +1,58 @@ +# ============================================ +# Breakpilot Dokumentation - MkDocs Build +# Multi-stage build fuer minimale Image-Groesse +# ============================================ + +# Stage 1: Build MkDocs Site +FROM python:3.11-slim AS builder + +WORKDIR /docs + +# Install MkDocs with Material theme and plugins +RUN pip install --no-cache-dir \ + mkdocs==1.6.1 \ + mkdocs-material==9.5.47 \ + pymdown-extensions==10.12 + +# Copy configuration and source files +COPY mkdocs.yml /docs/ +COPY docs-src/ /docs/docs-src/ + +# Build static site +RUN mkdocs build + +# Stage 2: Serve with Nginx +FROM nginx:alpine + +# Copy built site from builder stage +COPY --from=builder /docs/docs-site /usr/share/nginx/html + +# Custom nginx config for SPA routing +RUN echo 'server { \ + listen 80; \ + server_name localhost; \ + root /usr/share/nginx/html; \ + index index.html; \ + \ + location / { \ + try_files $uri $uri/ /index.html; \ + } \ + \ + # Enable gzip compression \ + gzip on; \ + gzip_types text/plain text/css application/json application/javascript text/xml application/xml; \ + gzip_min_length 1000; \ + \ + # Cache static assets \ + location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { \ + expires 1y; \ + add_header Cache-Control "public, immutable"; \ + } \ +}' > /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/docs-src/api/backend-api.md b/docs-src/api/backend-api.md new file mode 100644 index 0000000..796270b --- /dev/null +++ b/docs-src/api/backend-api.md @@ -0,0 +1,263 @@ +# BreakPilot Backend API Dokumentation + +## Übersicht + +Base URL: `http://localhost:8000/api` + +Alle Endpoints erfordern Authentifizierung via JWT im Authorization-Header: +``` +Authorization: Bearer +``` + +--- + +## Worksheets API + +Generiert Lernmaterialien (MC-Tests, Lückentexte, Mindmaps, Quiz). + +### POST /worksheets/generate/multiple-choice + +Generiert Multiple-Choice-Fragen aus Quelltext. + +**Request Body:** +```json +{ + "source_text": "Der Text, aus dem Fragen generiert werden sollen...", + "num_questions": 5, + "difficulty": "medium", + "topic": "Thema", + "subject": "Deutsch" +} +``` + +**Response (200):** +```json +{ + "success": true, + "content": { + "type": "multiple_choice", + "data": { + "questions": [ + { + "question": "Was ist...?", + "options": ["A", "B", "C", "D"], + "correct": 0, + "explanation": "Erklärung..." + } + ] + } + } +} +``` + +### POST /worksheets/generate/cloze + +Generiert Lückentexte. + +### POST /worksheets/generate/mindmap + +Generiert Mindmap als Mermaid-Diagramm. + +### POST /worksheets/generate/quiz + +Generiert Mix aus verschiedenen Fragetypen. + +--- + +## Corrections API + +OCR-basierte Klausurkorrektur mit automatischer Bewertung. + +### POST /corrections/ + +Erstellt neue Korrektur-Session. + +### POST /corrections/{id}/upload + +Lädt gescannte Klausur hoch und startet OCR im Hintergrund. + +### GET /corrections/{id} + +Ruft Korrektur-Status ab. + +**Status-Werte:** +- `uploaded` - Datei hochgeladen +- `processing` - OCR läuft +- `ocr_complete` - OCR fertig +- `analyzing` - Analyse läuft +- `analyzed` - Analyse abgeschlossen +- `completed` - Fertig +- `error` - Fehler + +### POST /corrections/{id}/analyze + +Analysiert extrahierten Text und bewertet Antworten. + +### GET /corrections/{id}/export-pdf + +Exportiert korrigierte Arbeit als PDF. + +--- + +## Letters API + +Elternbriefe mit GFK-Integration und PDF-Export. + +### POST /letters/ + +Erstellt neuen Elternbrief. + +**letter_type Werte:** +- `general` - Allgemeine Information +- `halbjahr` - Halbjahresinformation +- `fehlzeiten` - Fehlzeiten-Mitteilung +- `elternabend` - Einladung Elternabend +- `lob` - Positives Feedback +- `custom` - Benutzerdefiniert + +### POST /letters/improve + +Verbessert Text nach GFK-Prinzipien. + +--- + +## State Engine API + +Begleiter-Modus mit Phasen-Management und Antizipation. + +### GET /state/dashboard + +Komplettes Dashboard für Begleiter-Modus. + +### GET /state/suggestions + +Ruft Vorschläge für Lehrer ab. + +### POST /state/milestone + +Schließt Meilenstein ab. + +--- + +## Klausur-Korrektur API (Abitur) + +Abitur-Klausurkorrektur mit 15-Punkte-System, Erst-/Zweitprüfer-Workflow und KI-gestützter Bewertung. + +### Klausur-Modi + +| Modus | Beschreibung | +|-------|--------------| +| `landes_abitur` | NiBiS Niedersachsen - rechtlich geklärte Aufgaben | +| `vorabitur` | Lehrer-erstellte Klausuren mit Rights-Gate | + +### POST /klausur-korrektur/klausuren + +Erstellt neue Abitur-Klausur. + +### POST /klausur-korrektur/students/{id}/evaluate + +Startet KI-Bewertung. + +**Response (200):** +```json +{ + "criteria_scores": { + "rechtschreibung": {"score": 85, "weight": 0.15}, + "grammatik": {"score": 90, "weight": 0.15}, + "inhalt": {"score": 75, "weight": 0.40}, + "struktur": {"score": 80, "weight": 0.15}, + "stil": {"score": 85, "weight": 0.15} + }, + "raw_points": 80, + "grade_points": 11, + "grade_label": "2" +} +``` + +### 15-Punkte-Notenschlüssel + +| Punkte | Prozent | Note | +|--------|---------|------| +| 15 | ≥95% | 1+ | +| 14 | ≥90% | 1 | +| 13 | ≥85% | 1- | +| 12 | ≥80% | 2+ | +| 11 | ≥75% | 2 | +| 10 | ≥70% | 2- | +| 9 | ≥65% | 3+ | +| 8 | ≥60% | 3 | +| 7 | ≥55% | 3- | +| 6 | ≥50% | 4+ | +| 5 | ≥45% | 4 | +| 4 | ≥40% | 4- | +| 3 | ≥33% | 5+ | +| 2 | ≥27% | 5 | +| 1 | ≥20% | 5- | +| 0 | <20% | 6 | + +### Bewertungskriterien + +| Kriterium | Gewicht | Beschreibung | +|-----------|---------|--------------| +| `rechtschreibung` | 15% | Orthografie | +| `grammatik` | 15% | Grammatik & Syntax | +| `inhalt` | 40% | Inhaltliche Qualität | +| `struktur` | 15% | Aufbau & Gliederung | +| `stil` | 15% | Ausdruck & Stil | + +--- + +## Security API (DevSecOps Dashboard) + +API fuer das Security Dashboard mit DevSecOps-Tools Integration. + +### GET /v1/security/tools + +Gibt Status aller DevSecOps-Tools zurueck. + +### GET /v1/security/findings + +Gibt alle Security-Findings zurueck. + +### GET /v1/security/sbom + +Gibt SBOM (Software Bill of Materials) zurueck. + +### POST /v1/security/scan/{type} + +Startet einen Security-Scan. + +**Path Parameter:** +- `type`: Scan-Typ (secrets, sast, deps, containers, sbom, all) + +--- + +## Fehler-Responses + +### 400 Bad Request +```json +{ + "detail": "Beschreibung des Fehlers" +} +``` + +### 401 Unauthorized +```json +{ + "detail": "Not authenticated" +} +``` + +### 404 Not Found +```json +{ + "detail": "Ressource nicht gefunden" +} +``` + +### 500 Internal Server Error +```json +{ + "detail": "Interner Serverfehler" +} +``` diff --git a/docs-src/architecture/auth-system.md b/docs-src/architecture/auth-system.md new file mode 100644 index 0000000..aed865e --- /dev/null +++ b/docs-src/architecture/auth-system.md @@ -0,0 +1,294 @@ +# BreakPilot Authentifizierung & Autorisierung + +## Uebersicht + +BreakPilot verwendet einen **Hybrid-Ansatz** fuer Authentifizierung und Autorisierung: + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ AUTHENTIFIZIERUNG │ +│ "Wer bist du?" │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ HybridAuthenticator │ │ +│ │ ┌─────────────────────┐ ┌─────────────────────────────────┐ │ │ +│ │ │ Keycloak │ │ Lokales JWT │ │ │ +│ │ │ (Produktion) │ OR │ (Entwicklung) │ │ │ +│ │ │ RS256 + JWKS │ │ HS256 + Secret │ │ │ +│ │ └─────────────────────┘ └─────────────────────────────────┘ │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ AUTORISIERUNG │ +│ "Was darfst du?" │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ rbac.py (Eigenentwicklung) │ │ +│ │ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────────┐ │ │ +│ │ │ Rollen-Hierarchie│ │ PolicySet │ │ DEFAULT_PERMISSIONS│ │ │ +│ │ │ 15+ Rollen │ │ Bundesland- │ │ Matrix │ │ │ +│ │ │ - Erstkorrektor │ │ spezifisch │ │ Rolle→Ressource→ │ │ │ +│ │ │ - Klassenlehrer │ │ - Niedersachsen │ │ Aktion │ │ │ +│ │ │ - Schulleitung │ │ - Bayern │ │ │ │ │ +│ │ └─────────────────┘ └─────────────────┘ └───────────────────┘ │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +## Warum dieser Ansatz? + +### Alternative Loesungen (verworfen) + +| Tool | Problem fuer BreakPilot | +|------|-------------------------| +| **Casbin** | Zu generisch fuer Bundesland-spezifische Policies | +| **Cerbos** | Overhead: Externer PDP-Service fuer ~15 Rollen ueberdimensioniert | +| **OpenFGA** | Zanzibar-Modell optimiert fuer Graph-Beziehungen, nicht Hierarchien | +| **Keycloak RBAC** | Kann keine ressourcen-spezifischen Zuweisungen (User X ist Erstkorrektor fuer Package Y) | + +### Vorteile des Hybrid-Ansatzes + +1. **Keycloak fuer Authentifizierung:** + - Bewährtes IAM-System + - SSO, Federation, MFA + - Apache-2.0 Lizenz + +2. **Eigenes rbac.py fuer Autorisierung:** + - Domaenenspezifische Logik (Korrekturkette, Zeugnis-Workflow) + - Bundesland-spezifische Regeln + - Zeitlich begrenzte Zuweisungen + - Key-Sharing fuer verschluesselte Klausuren + +--- + +## Authentifizierung (auth/keycloak_auth.py) + +### Konfiguration + +```python +# Entwicklung: Lokales JWT (Standard) +JWT_SECRET=your-secret-key + +# Produktion: Keycloak +KEYCLOAK_SERVER_URL=https://keycloak.breakpilot.app +KEYCLOAK_REALM=breakpilot +KEYCLOAK_CLIENT_ID=breakpilot-backend +KEYCLOAK_CLIENT_SECRET=your-client-secret +``` + +### Token-Erkennung + +Der `HybridAuthenticator` erkennt automatisch den Token-Typ: + +```python +# Keycloak-Token (RS256) +{ + "iss": "https://keycloak.breakpilot.app/realms/breakpilot", + "sub": "user-uuid", + "realm_access": {"roles": ["teacher", "admin"]}, + ... +} + +# Lokales JWT (HS256) +{ + "iss": "breakpilot", + "user_id": "user-uuid", + "role": "admin", + ... +} +``` + +### FastAPI Integration + +```python +from auth import get_current_user + +@app.get("/api/protected") +async def protected_endpoint(user: dict = Depends(get_current_user)): + # user enthält: user_id, email, role, realm_roles, tenant_id + return {"user_id": user["user_id"]} +``` + +--- + +## Autorisierung (klausur-service/backend/rbac.py) + +### Rollen (15+) + +| Rolle | Beschreibung | Bereich | +|-------|--------------|---------| +| `erstkorrektor` | Erster Prüfer | Klausur | +| `zweitkorrektor` | Zweiter Prüfer | Klausur | +| `drittkorrektor` | Dritter Prüfer | Klausur | +| `klassenlehrer` | Klassenleitung | Zeugnis | +| `fachlehrer` | Fachlehrkraft | Noten | +| `fachvorsitz` | Fachkonferenz-Leitung | Fachschaft | +| `schulleitung` | Schulleiter/in | Schule | +| `zeugnisbeauftragter` | Zeugnis-Koordination | Zeugnis | +| `sekretariat` | Verwaltung | Schule | +| `data_protection_officer` | DSB | DSGVO | +| ... | | | + +### Ressourcentypen (25+) + +```python +class ResourceType(str, Enum): + EXAM_PACKAGE = "exam_package" # Klausurpaket + STUDENT_SUBMISSION = "student_submission" + CORRECTION = "correction" + ZEUGNIS = "zeugnis" + FACHNOTE = "fachnote" + KOPFNOTE = "kopfnote" + BEMERKUNG = "bemerkung" + ... +``` + +### Aktionen (17) + +```python +class Action(str, Enum): + CREATE = "create" + READ = "read" + UPDATE = "update" + DELETE = "delete" + SIGN_OFF = "sign_off" # Freigabe + BREAK_GLASS = "break_glass" # Notfall-Zugriff + SHARE_KEY = "share_key" # Schlüssel teilen + ... +``` + +### Permission-Pruefung + +```python +from klausur_service.backend.rbac import PolicyEngine + +engine = PolicyEngine() + +# Pruefe ob User X Klausur Y korrigieren darf +allowed = engine.check_permission( + user_id="user-uuid", + action=Action.UPDATE, + resource_type=ResourceType.CORRECTION, + resource_id="klausur-uuid" +) +``` + +--- + +## Bundesland-spezifische Policies + +```python +@dataclass +class PolicySet: + bundesland: str + abitur_type: str # "landesabitur" | "zentralabitur" + + # Korrekturkette + korrektoren_anzahl: int # 2 oder 3 + anonyme_erstkorrektur: bool + + # Sichtbarkeit + zk_visibility_mode: ZKVisibilityMode # BLIND | SEMI | FULL + eh_visibility_mode: EHVisibilityMode + + # Zeugnis + kopfnoten_enabled: bool + ... +``` + +### Beispiel: Niedersachsen + +```python +NIEDERSACHSEN_POLICY = PolicySet( + bundesland="niedersachsen", + abitur_type="landesabitur", + korrektoren_anzahl=2, + anonyme_erstkorrektur=True, + zk_visibility_mode=ZKVisibilityMode.BLIND, + eh_visibility_mode=EHVisibilityMode.SUMMARY_ONLY, + kopfnoten_enabled=True, +) +``` + +--- + +## Workflow-Beispiele + +### Klausurkorrektur-Workflow + +``` +1. Lehrer laedt Klausuren hoch + └── Rolle: "lehrer" + Action.CREATE auf EXAM_PACKAGE + +2. Erstkorrektor korrigiert + └── Rolle: "erstkorrektor" (ressourcen-spezifisch) + Action.UPDATE auf CORRECTION + +3. Zweitkorrektor ueberprueft + └── Rolle: "zweitkorrektor" + Action.READ auf CORRECTION + └── Policy: zk_visibility_mode bestimmt Sichtbarkeit + +4. Drittkorrektor (bei Abweichung) + └── Rolle: "drittkorrektor" + Action.SIGN_OFF +``` + +### Zeugnis-Workflow + +``` +1. Fachlehrer traegt Noten ein + └── Rolle: "fachlehrer" + Action.CREATE auf FACHNOTE + +2. Klassenlehrer prueft + └── Rolle: "klassenlehrer" + Action.READ auf ZEUGNIS + └── Action.SIGN_OFF freigeben + +3. Zeugnisbeauftragter final + └── Rolle: "zeugnisbeauftragter" + Action.SIGN_OFF + +4. Schulleitung unterzeichnet + └── Rolle: "schulleitung" + Action.SIGN_OFF +``` + +--- + +## Dateien + +| Datei | Beschreibung | +|-------|--------------| +| `backend/auth/__init__.py` | Auth-Modul Exports | +| `backend/auth/keycloak_auth.py` | Hybrid-Authentifizierung | +| `klausur-service/backend/rbac.py` | Autorisierungs-Engine | +| `backend/rbac_api.py` | REST API fuer Rollenverwaltung | + +--- + +## Konfiguration + +### Entwicklung (ohne Keycloak) + +```bash +# .env +ENVIRONMENT=development +JWT_SECRET=dev-secret-32-chars-minimum-here +``` + +### Produktion (mit Keycloak) + +```bash +# .env +ENVIRONMENT=production +JWT_SECRET= +KEYCLOAK_SERVER_URL=https://keycloak.breakpilot.app +KEYCLOAK_REALM=breakpilot +KEYCLOAK_CLIENT_ID=breakpilot-backend +KEYCLOAK_CLIENT_SECRET= +``` + +--- + +## Sicherheitshinweise + +1. **Secrets niemals im Code** - Immer Umgebungsvariablen verwenden +2. **JWT_SECRET in Produktion** - Mindestens 32 Bytes, generiert mit `openssl rand -hex 32` +3. **Keycloak HTTPS** - KEYCLOAK_VERIFY_SSL=true in Produktion +4. **Token-Expiration** - Keycloak-Tokens kurz halten (5-15 Minuten) +5. **Audit-Trail** - Alle Berechtigungspruefungen werden geloggt diff --git a/docs-src/architecture/devsecops.md b/docs-src/architecture/devsecops.md new file mode 100644 index 0000000..bef6f19 --- /dev/null +++ b/docs-src/architecture/devsecops.md @@ -0,0 +1,215 @@ +# BreakPilot DevSecOps Architecture + +## Uebersicht + +BreakPilot implementiert einen umfassenden DevSecOps-Ansatz mit Security-by-Design fuer die Entwicklung und den Betrieb der Bildungsplattform. + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ DEVSECOPS PIPELINE │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Pre-Commit │───►│ CI/CD │───►│ Build │───►│ Deploy │ │ +│ │ Hooks │ │ Pipeline │ │ & Scan │ │ & Monitor │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ │ │ │ │ +│ ▼ ▼ ▼ ▼ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Gitleaks │ │ Semgrep │ │ Trivy │ │ Falco │ │ +│ │ Bandit │ │ OWASP DC │ │ Grype │ │ (optional) │ │ +│ │ Secrets │ │ SAST/SCA │ │ SBOM │ │ Runtime │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +## Security Tools Stack + +### 1. Secrets Detection + +| Tool | Version | Lizenz | Verwendung | +|------|---------|--------|------------| +| **Gitleaks** | 8.18.x | MIT | Pre-commit Hook, CI/CD | +| **detect-secrets** | 1.4.x | Apache-2.0 | Zusaetzliche Baseline-Pruefung | + +**Konfiguration:** `.gitleaks.toml` + +```bash +# Lokal ausfuehren +gitleaks detect --source . -v + +# Pre-commit (automatisch) +gitleaks protect --staged -v +``` + +### 2. Static Application Security Testing (SAST) + +| Tool | Version | Lizenz | Sprachen | +|------|---------|--------|----------| +| **Semgrep** | 1.52.x | LGPL-2.1 | Python, Go, JavaScript, TypeScript | +| **Bandit** | 1.7.x | Apache-2.0 | Python (spezialisiert) | + +**Konfiguration:** `.semgrep.yml` + +```bash +# Semgrep ausfuehren +semgrep scan --config auto --config .semgrep.yml + +# Bandit ausfuehren +bandit -r backend/ -ll +``` + +### 3. Software Composition Analysis (SCA) + +| Tool | Version | Lizenz | Verwendung | +|------|---------|--------|------------| +| **Trivy** | 0.48.x | Apache-2.0 | Filesystem, Container, IaC | +| **Grype** | 0.74.x | Apache-2.0 | Vulnerability Scanning | +| **OWASP Dependency-Check** | 9.x | Apache-2.0 | CVE/NVD Abgleich | + +**Konfiguration:** `.trivy.yaml` + +```bash +# Filesystem-Scan +trivy fs . --severity HIGH,CRITICAL + +# Container-Scan +trivy image breakpilot-pwa-backend:latest +``` + +### 4. SBOM (Software Bill of Materials) + +| Tool | Version | Lizenz | Formate | +|------|---------|--------|---------| +| **Syft** | 0.100.x | Apache-2.0 | CycloneDX, SPDX | + +```bash +# SBOM generieren +syft dir:. -o cyclonedx-json=sbom.json +syft dir:. -o spdx-json=sbom-spdx.json +``` + +### 5. Dynamic Application Security Testing (DAST) + +| Tool | Version | Lizenz | Verwendung | +|------|---------|--------|------------| +| **OWASP ZAP** | 2.14.x | Apache-2.0 | Staging-Scans (nightly) | + +```bash +# ZAP Scan gegen Staging +docker run -t owasp/zap2docker-stable zap-baseline.py \ + -t http://staging.breakpilot.app -r zap-report.html +``` + +## Pre-Commit Hooks + +Die Pre-Commit-Konfiguration (`.pre-commit-config.yaml`) fuehrt automatisch bei jedem Commit aus: + +1. **Schnelle Checks** (< 10 Sekunden): + - Gitleaks (Secrets) + - Trailing Whitespace + - YAML/JSON Validierung + +2. **Code Quality** (< 30 Sekunden): + - Black/Ruff (Python Formatting) + - Go fmt/vet + - ESLint (JavaScript) + +3. **Security Checks** (< 60 Sekunden): + - Bandit (Python Security) + - Semgrep (Error-Severity) + +### Installation + +```bash +# Pre-commit installieren +pip install pre-commit + +# Hooks aktivieren +pre-commit install + +# Alle Checks manuell ausfuehren +pre-commit run --all-files +``` + +## Severity-Gates + +| Phase | Severity | Aktion | +|-------|----------|--------| +| Pre-Commit | ERROR | Commit blockiert | +| PR/CI | CRITICAL, HIGH | Pipeline blockiert | +| Nightly Scan | MEDIUM+ | Report generiert | +| Production Deploy | CRITICAL | Deploy blockiert | + +## Security Dashboard + +Das BreakPilot Admin Panel enthaelt ein integriertes Security Dashboard unter **Verwaltung > Security**. + +### Features + +**Fuer Entwickler:** +- Scan-Ergebnisse auf einen Blick +- Pre-commit Hook Status +- Quick-Fix Suggestions +- SBOM Viewer mit Suchfunktion + +**Fuer Security-Experten:** +- Vulnerability Severity Distribution (Critical/High/Medium/Low) +- CVE-Tracking mit Fix-Verfuegbarkeit +- Compliance-Status (OWASP Top 10, DSGVO) +- Secrets Detection History + +**Fuer Ops:** +- Container Image Scan Results +- Dependency Update Status +- Security Scan Scheduling +- Auto-Refresh alle 30 Sekunden + +### API Endpoints + +``` +GET /api/v1/security/tools - Tool-Status +GET /api/v1/security/findings - Alle Findings +GET /api/v1/security/summary - Severity-Zusammenfassung +GET /api/v1/security/sbom - SBOM-Daten +GET /api/v1/security/history - Scan-Historie +GET /api/v1/security/reports/{tool} - Tool-spezifischer Report +POST /api/v1/security/scan/{type} - Scan starten +GET /api/v1/security/health - Health-Check +``` + +## Compliance + +Die DevSecOps-Pipeline unterstuetzt folgende Compliance-Anforderungen: + +- **DSGVO/GDPR**: Automatische Erkennung von PII-Leaks +- **OWASP Top 10**: SAST/DAST-Scans gegen bekannte Schwachstellen +- **Supply Chain Security**: SBOM-Generierung fuer Audit-Trails +- **CVE Tracking**: Automatischer Abgleich mit NVD/CVE-Datenbanken + +## Tool-Installation + +### macOS (Homebrew) + +```bash +# Security Tools +brew install gitleaks +brew install trivy +brew install syft +brew install grype + +# Python Tools +pip install semgrep bandit pre-commit +``` + +### Linux (apt/snap) + +```bash +# Gitleaks +sudo snap install gitleaks + +# Trivy +sudo apt-get install trivy + +# Python Tools +pip install semgrep bandit pre-commit +``` diff --git a/docs-src/architecture/environments.md b/docs-src/architecture/environments.md new file mode 100644 index 0000000..f4cc9d5 --- /dev/null +++ b/docs-src/architecture/environments.md @@ -0,0 +1,197 @@ +# Umgebungs-Architektur + +## Übersicht + +BreakPilot verwendet eine 3-Umgebungs-Strategie für sichere Entwicklung und Deployment: + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Development │────▶│ Staging │────▶│ Production │ +│ (develop) │ │ (staging) │ │ (main) │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + Tägliche Getesteter Code Produktionsreif + Entwicklung +``` + +## Umgebungen + +### Development (Dev) + +**Zweck:** Tägliche Entwicklungsarbeit + +| Eigenschaft | Wert | +|-------------|------| +| Git Branch | `develop` | +| Compose File | `docker-compose.yml` + `docker-compose.override.yml` (auto) | +| Env File | `.env.dev` | +| Database | `breakpilot_dev` | +| Debug | Aktiviert | +| Hot-Reload | Aktiviert | + +**Start:** +```bash +./scripts/start.sh dev +# oder einfach: +docker compose up -d +``` + +### Staging + +**Zweck:** Getesteter, freigegebener Code vor Produktion + +| Eigenschaft | Wert | +|-------------|------| +| Git Branch | `staging` | +| Compose File | `docker-compose.yml` + `docker-compose.staging.yml` | +| Env File | `.env.staging` | +| Database | `breakpilot_staging` (separates Volume) | +| Debug | Deaktiviert | +| Hot-Reload | Deaktiviert | + +**Start:** +```bash +./scripts/start.sh staging +# oder: +docker compose -f docker-compose.yml -f docker-compose.staging.yml up -d +``` + +### Production (Prod) + +**Zweck:** Live-System für Endbenutzer (ab Launch) + +| Eigenschaft | Wert | +|-------------|------| +| Git Branch | `main` | +| Compose File | `docker-compose.yml` + `docker-compose.prod.yml` | +| Env File | `.env.prod` (NICHT im Repository!) | +| Database | `breakpilot_prod` (separates Volume) | +| Debug | Deaktiviert | +| Vault | Pflicht (keine Env-Fallbacks) | + +## Datenbank-Trennung + +Jede Umgebung verwendet separate Docker Volumes für vollständige Datenisolierung: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ PostgreSQL Volumes │ +├─────────────────────────────────────────────────────────────┤ +│ breakpilot-dev_postgres_data │ Development Database │ +│ breakpilot_staging_postgres │ Staging Database │ +│ breakpilot_prod_postgres │ Production Database │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Port-Mapping + +Um mehrere Umgebungen gleichzeitig laufen zu lassen, verwenden sie unterschiedliche Ports: + +| Service | Dev Port | Staging Port | Prod Port | +|---------|----------|--------------|-----------| +| Backend | 8000 | 8001 | 8000 | +| PostgreSQL | 5432 | 5433 | - (intern) | +| MinIO | 9000/9001 | 9002/9003 | - (intern) | +| Qdrant | 6333/6334 | 6335/6336 | - (intern) | +| Mailpit | 8025/1025 | 8026/1026 | - (deaktiviert) | + +## Git Branching Strategie + +``` +main (Prod) ← Nur Release-Merges, geschützt + │ + ▼ +staging ← Getesteter Code, Review erforderlich + │ + ▼ +develop (Dev) ← Tägliche Arbeit, Default-Branch + │ + ▼ +feature/* ← Feature-Branches (optional) +``` + +### Workflow + +1. **Entwicklung:** Arbeite auf `develop` +2. **Code-Review:** Erstelle PR von Feature-Branch → `develop` +3. **Staging:** Promote `develop` → `staging` mit Tests +4. **Release:** Promote `staging` → `main` nach Freigabe + +### Promotion-Befehle + +```bash +# develop → staging +./scripts/promote.sh dev-to-staging + +# staging → main (Production) +./scripts/promote.sh staging-to-prod +``` + +## Secrets Management + +### Development +- `.env.dev` enthält Entwicklungs-Credentials +- Vault optional (Dev-Token) +- Mailpit für E-Mail-Tests + +### Staging +- `.env.staging` enthält Test-Credentials +- Vault empfohlen +- Mailpit für E-Mail-Sicherheit + +### Production +- `.env.prod` NICHT im Repository +- Vault PFLICHT +- Echte SMTP-Konfiguration + +Siehe auch: [Secrets Management](./secrets-management.md) + +## Docker Compose Architektur + +``` +docker-compose.yml ← Basis-Konfiguration + │ + ├── docker-compose.override.yml ← Dev (auto-geladen) + │ + ├── docker-compose.staging.yml ← Staging (explizit) + │ + └── docker-compose.prod.yml ← Production (explizit) +``` + +### Automatisches Laden + +Docker Compose lädt automatisch: +1. `docker-compose.yml` +2. `docker-compose.override.yml` (falls vorhanden) + +Daher startet `docker compose up` automatisch die Dev-Umgebung. + +## Helper Scripts + +| Script | Beschreibung | +|--------|--------------| +| `scripts/env-switch.sh` | Wechselt zwischen Umgebungen | +| `scripts/start.sh` | Startet Services für Umgebung | +| `scripts/stop.sh` | Stoppt Services | +| `scripts/promote.sh` | Promotet Code zwischen Branches | +| `scripts/status.sh` | Zeigt aktuellen Status | + +## Verifikation + +Nach Setup prüfen: + +```bash +# Status anzeigen +./scripts/status.sh + +# Branches prüfen +git branch -v + +# Volumes prüfen +docker volume ls | grep breakpilot +``` + +## Verwandte Dokumentation + +- [Secrets Management](./secrets-management.md) - Vault & Secrets +- [DevSecOps](./devsecops.md) - CI/CD & Security +- [System-Architektur](./system-architecture.md) - Gesamtarchitektur diff --git a/docs-src/architecture/mail-rbac-architecture.md b/docs-src/architecture/mail-rbac-architecture.md new file mode 100644 index 0000000..2f2aa71 --- /dev/null +++ b/docs-src/architecture/mail-rbac-architecture.md @@ -0,0 +1,215 @@ +# Mail-RBAC Architektur mit Mitarbeiter-Anonymisierung + +**Version:** 1.0.0 +**Status:** Architekturplanung + +--- + +## Executive Summary + +Dieses Dokument beschreibt eine neuartige Architektur, die E-Mail, Kalender und Videokonferenzen mit rollenbasierter Zugriffskontrolle (RBAC) verbindet. Das Kernkonzept ermöglicht die **vollständige Anonymisierung von Mitarbeiterdaten** bei Verlassen des Unternehmens, während geschäftliche Kommunikationshistorie erhalten bleibt. + +--- + +## 1. Das Problem + +### Traditionelle E-Mail-Systeme +``` +max.mustermann@firma.de → Person gebunden + → DSGVO: Daten müssen gelöscht werden + → Geschäftshistorie geht verloren +``` + +### BreakPilot-Lösung: Rollenbasierte E-Mail +``` +klassenlehrer.5a@schule.breakpilot.app → Rolle gebunden + → Person kann anonymisiert werden + → Kommunikationshistorie bleibt erhalten +``` + +--- + +## 2. Architektur-Übersicht + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ BreakPilot Groupware │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Webmail │ │ Kalender │ │ Jitsi │ │ +│ │ (SOGo) │ │ (SOGo) │ │ Meeting │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ │ +│ └────────────────┼────────────────┘ │ +│ │ │ +│ ┌───────────┴───────────┐ │ +│ │ RBAC-Mail-Bridge │ ◄─── Neue Komponente │ +│ │ (Python/Go) │ │ +│ └───────────┬───────────┘ │ +│ │ │ +│ ┌─────────────────────┼─────────────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌──────────┐ ┌──────────────┐ ┌────────────┐ │ +│ │PostgreSQL│ │ Mail Server │ │ MinIO │ │ +│ │(RBAC DB) │ │ (Stalwart) │ │ (Backups) │ │ +│ └──────────┘ └──────────────┘ └────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. Komponenten-Auswahl + +### 3.1 E-Mail Server: Stalwart Mail Server + +**Empfehlung:** [Stalwart Mail Server](https://stalw.art/) + +| Kriterium | Bewertung | +|-----------|-----------| +| Lizenz | AGPL-3.0 (Open Source) | +| Sprache | Rust (performant, sicher) | +| Features | IMAP, SMTP, JMAP, WebSocket | +| Kalender | CalDAV integriert | +| Kontakte | CardDAV integriert | +| Spam/Virus | Integriert | +| API | REST API für Administration | + +### 3.2 Webmail-Client: SOGo oder Roundcube + +**Option A: SOGo** (empfohlen) +- Lizenz: GPL-2.0 / LGPL-2.1 +- Kalender, Kontakte, Mail in einem +- ActiveSync Support +- Outlook-ähnliche Oberfläche + +**Option B: Roundcube** +- Lizenz: GPL-3.0 +- Nur Webmail +- Benötigt separaten Kalender + +--- + +## 4. Anonymisierungs-Workflow + +``` +Mitarbeiter kündigt + │ + ▼ +┌───────────────────────────┐ +│ 1. Functional Mailboxes │ +│ → Neu zuweisen oder │ +│ → Deaktivieren │ +└───────────┬───────────────┘ + │ + ▼ +┌───────────────────────────┐ +│ 2. Personal Email Account │ +│ → Anonymisieren: │ +│ max.mustermann@... │ +│ → mitarbeiter_a7x2@... │ +└───────────────────────────┘ + │ + ▼ +┌───────────────────────────┐ +│ 3. Users-Tabelle │ +│ → Pseudonymisieren: │ +│ name: "Max Mustermann" │ +│ → "Ehem. Mitarbeiter" │ +└───────────────────────────┘ + │ + ▼ +┌───────────────────────────┐ +│ 4. Mailbox Assignments │ +│ → Bleiben für Audit │ +│ → User-Referenz zeigt │ +│ auf anonymisierte │ +│ Daten │ +└───────────────────────────┘ + │ + ▼ +┌───────────────────────────┐ +│ 5. E-Mail-Archiv │ +│ → Header anonymisieren │ +│ → Inhalte optional │ +│ löschen │ +└───────────────────────────┘ +``` + +--- + +## 5. Unified Inbox Implementation + +### Implementierte Komponenten + +Die Unified Inbox wurde als Teil des klausur-service implementiert: + +| Komponente | Pfad | Beschreibung | +|------------|------|--------------| +| **Models** | `klausur-service/backend/mail/models.py` | Pydantic Models für Accounts, E-Mails, Tasks | +| **Database** | `klausur-service/backend/mail/mail_db.py` | PostgreSQL-Operationen mit asyncpg | +| **Credentials** | `klausur-service/backend/mail/credentials.py` | Vault-Integration für IMAP/SMTP-Passwörter | +| **Aggregator** | `klausur-service/backend/mail/aggregator.py` | Multi-Account IMAP Sync | +| **AI Service** | `klausur-service/backend/mail/ai_service.py` | KI-Analyse (Absender, Fristen, Kategorien) | +| **Task Service** | `klausur-service/backend/mail/task_service.py` | Arbeitsvorrat-Management | +| **API** | `klausur-service/backend/mail/api.py` | FastAPI Router mit 30+ Endpoints | + +### API-Endpoints (Port 8086) + +``` +# Account Management +POST /api/v1/mail/accounts - Neues Konto hinzufügen +GET /api/v1/mail/accounts - Alle Konten auflisten +DELETE /api/v1/mail/accounts/{id} - Konto entfernen +POST /api/v1/mail/accounts/{id}/test - Verbindung testen + +# Unified Inbox +GET /api/v1/mail/inbox - Aggregierte Inbox +GET /api/v1/mail/inbox/{id} - Einzelne E-Mail +POST /api/v1/mail/send - E-Mail senden + +# KI-Features +POST /api/v1/mail/analyze/{id} - E-Mail analysieren +GET /api/v1/mail/suggestions/{id} - Antwortvorschläge + +# Arbeitsvorrat +GET /api/v1/mail/tasks - Alle Tasks +POST /api/v1/mail/tasks - Manuelle Task erstellen +PATCH /api/v1/mail/tasks/{id} - Task aktualisieren +GET /api/v1/mail/tasks/dashboard - Dashboard-Statistiken +``` + +### Niedersachsen-spezifische Absendererkennung + +```python +KNOWN_AUTHORITIES_NI = { + "@mk.niedersachsen.de": "Kultusministerium Niedersachsen", + "@rlsb.de": "Regionales Landesamt für Schule und Bildung", + "@landesschulbehoerde-nds.de": "Landesschulbehörde", + "@nibis.de": "NiBiS", +} +``` + +--- + +## 6. Lizenz-Übersicht + +| Komponente | Lizenz | Kommerzielle Nutzung | Veröffentlichungspflicht | +|------------|--------|---------------------|-------------------------| +| Stalwart Mail | AGPL-3.0 | Ja | Nur bei Code-Änderungen | +| SOGo | GPL-2.0/LGPL | Ja | Nur bei Code-Änderungen | +| Roundcube | GPL-3.0 | Ja | Nur bei Code-Änderungen | +| RBAC-Mail-Bridge | Eigene | N/A | Kann proprietär bleiben | +| BreakPilot Backend | Eigene | N/A | Proprietär | + +--- + +## 7. Referenzen + +- [Stalwart Mail Server](https://stalw.art/) +- [SOGo Groupware](https://www.sogo.nu/) +- [Roundcube Webmail](https://roundcube.net/) +- [CalDAV Standard](https://tools.ietf.org/html/rfc4791) +- [DSGVO Art. 17 - Recht auf Löschung](https://dsgvo-gesetz.de/art-17-dsgvo/) diff --git a/docs-src/architecture/multi-agent.md b/docs-src/architecture/multi-agent.md new file mode 100644 index 0000000..15ff572 --- /dev/null +++ b/docs-src/architecture/multi-agent.md @@ -0,0 +1,286 @@ +# Multi-Agent Architektur - Entwicklerdokumentation + +**Status:** Implementiert +**Modul:** `/agent-core/` + +--- + +## 1. Übersicht + +Die Multi-Agent-Architektur erweitert Breakpilot um ein verteiltes Agent-System basierend auf Mission Control Konzepten. + +### Kernkomponenten + +| Komponente | Pfad | Beschreibung | +|------------|------|--------------| +| Session Management | `/agent-core/sessions/` | Lifecycle & Recovery | +| Shared Brain | `/agent-core/brain/` | Langzeit-Gedächtnis | +| Orchestrator | `/agent-core/orchestrator/` | Koordination | +| SOUL Files | `/agent-core/soul/` | Agent-Persönlichkeiten | + +--- + +## 2. Agent-Typen + +| Agent | Aufgabe | SOUL-Datei | +|-------|---------|------------| +| **TutorAgent** | Lernbegleitung, Fragen beantworten | `tutor-agent.soul.md` | +| **GraderAgent** | Klausur-Korrektur, Bewertung | `grader-agent.soul.md` | +| **QualityJudge** | BQAS Qualitätsprüfung | `quality-judge.soul.md` | +| **AlertAgent** | Monitoring, Benachrichtigungen | `alert-agent.soul.md` | +| **Orchestrator** | Task-Koordination | `orchestrator.soul.md` | + +--- + +## 3. Wichtige Dateien + +### Session Management +``` +agent-core/sessions/ +├── session_manager.py # AgentSession, SessionManager, SessionState +├── heartbeat.py # HeartbeatMonitor, HeartbeatClient +└── checkpoint.py # CheckpointManager +``` + +### Shared Brain +``` +agent-core/brain/ +├── memory_store.py # MemoryStore, Memory (mit TTL) +├── context_manager.py # ConversationContext, ContextManager +└── knowledge_graph.py # KnowledgeGraph, Entity, Relationship +``` + +### Orchestrator +``` +agent-core/orchestrator/ +├── message_bus.py # MessageBus, AgentMessage, MessagePriority +├── supervisor.py # AgentSupervisor, AgentInfo, AgentStatus +└── task_router.py # TaskRouter, RoutingRule, RoutingResult +``` + +--- + +## 4. Datenbank-Schema + +Die Migration befindet sich in: +`/backend/migrations/add_agent_core_tables.sql` + +### Tabellen + +1. **agent_sessions** - Session-Daten mit Checkpoints +2. **agent_memory** - Langzeit-Gedächtnis mit TTL +3. **agent_messages** - Audit-Trail für Inter-Agent Kommunikation + +### Helper-Funktionen + +```sql +-- Abgelaufene Memories bereinigen +SELECT cleanup_expired_agent_memory(); + +-- Inaktive Sessions bereinigen +SELECT cleanup_stale_agent_sessions(48); -- 48 Stunden +``` + +--- + +## 5. Integration Voice-Service + +Der `EnhancedTaskOrchestrator` erweitert den bestehenden `TaskOrchestrator`: + +```python +# voice-service/services/enhanced_task_orchestrator.py + +from agent_core.sessions import SessionManager +from agent_core.orchestrator import MessageBus + +class EnhancedTaskOrchestrator(TaskOrchestrator): + # Nutzt Session-Checkpoints für Recovery + # Routet komplexe Tasks an spezialisierte Agents + # Führt Quality-Checks via BQAS durch +``` + +**Wichtig:** Der Enhanced Orchestrator ist abwärtskompatibel und kann parallel zum Original verwendet werden. + +--- + +## 6. Integration BQAS + +Der `QualityJudgeAgent` integriert BQAS mit dem Multi-Agent-System: + +```python +# voice-service/bqas/quality_judge_agent.py + +from bqas.judge import LLMJudge +from agent_core.orchestrator import MessageBus + +class QualityJudgeAgent: + # Wertet Responses in Echtzeit aus + # Nutzt Memory für konsistente Bewertungen + # Empfängt Evaluierungs-Requests via Message Bus +``` + +--- + +## 7. Code-Beispiele + +### Session erstellen + +```python +from agent_core.sessions import SessionManager + +manager = SessionManager(redis_client=redis, db_pool=pool) +session = await manager.create_session( + agent_type="tutor-agent", + user_id="user-123" +) +``` + +### Memory speichern + +```python +from agent_core.brain import MemoryStore + +store = MemoryStore(redis_client=redis, db_pool=pool) +await store.remember( + key="student:123:progress", + value={"level": 5, "score": 85}, + agent_id="tutor-agent", + ttl_days=30 +) +``` + +### Nachricht senden + +```python +from agent_core.orchestrator import MessageBus, AgentMessage + +bus = MessageBus(redis_client=redis) +await bus.publish(AgentMessage( + sender="orchestrator", + receiver="grader-agent", + message_type="grade_request", + payload={"exam_id": "exam-1"} +)) +``` + +--- + +## 8. Tests ausführen + +```bash +# Alle Agent-Core Tests +cd agent-core && pytest -v + +# Mit Coverage-Report +pytest --cov=. --cov-report=html + +# Einzelne Module +pytest tests/test_session_manager.py -v +pytest tests/test_message_bus.py -v +``` + +--- + +## 9. Deployment-Schritte + +### 1. Migration ausführen + +```bash +psql -h localhost -U breakpilot -d breakpilot \ + -f backend/migrations/add_agent_core_tables.sql +``` + +### 2. Voice-Service aktualisieren + +```bash +# Sync zu Server +rsync -avz --exclude 'node_modules' --exclude '.git' \ + /path/to/breakpilot-pwa/ server:/path/to/breakpilot-pwa/ + +# Container neu bauen +docker compose build --no-cache voice-service + +# Starten +docker compose up -d voice-service +``` + +### 3. Verifizieren + +```bash +# Session-Tabelle prüfen +psql -c "SELECT COUNT(*) FROM agent_sessions;" + +# Memory-Tabelle prüfen +psql -c "SELECT COUNT(*) FROM agent_memory;" +``` + +--- + +## 10. Monitoring + +### Metriken + +| Metrik | Beschreibung | +|--------|--------------| +| `agent_session_count` | Anzahl aktiver Sessions | +| `agent_heartbeat_delay_ms` | Zeit seit letztem Heartbeat | +| `agent_message_latency_ms` | Nachrichtenlatenz | +| `agent_memory_count` | Gespeicherte Memories | +| `agent_routing_success_rate` | Erfolgreiche Routings | + +### Health-Check-Endpunkte + +``` +GET /api/v1/agents/health # Supervisor Status +GET /api/v1/agents/sessions # Aktive Sessions +GET /api/v1/agents/memory/stats # Memory-Statistiken +``` + +--- + +## 11. Troubleshooting + +### Problem: Session nicht gefunden + +1. Prüfen ob Valkey läuft: `redis-cli ping` +2. Session-Timeout prüfen (default 24h) +3. Heartbeat-Status checken + +### Problem: Message Bus Timeout + +1. Redis Pub/Sub Status prüfen +2. Ziel-Agent registriert? +3. Timeout erhöhen (default 30s) + +### Problem: Memory nicht gefunden + +1. Namespace korrekt? +2. TTL abgelaufen? +3. Cleanup-Job gelaufen? + +--- + +## 12. Erweiterungen + +### Neuen Agent hinzufügen + +1. SOUL-Datei erstellen in `/agent-core/soul/` +2. Routing-Regel in `task_router.py` hinzufügen +3. Handler beim Supervisor registrieren +4. Tests schreiben + +### Neuen Memory-Typ hinzufügen + +1. Key-Schema definieren (z.B. `student:*:progress`) +2. TTL festlegen +3. Access-Pattern dokumentieren + +--- + +## 13. Referenzen + +- **Agent-Core README:** `/agent-core/README.md` +- **Migration:** `/backend/migrations/add_agent_core_tables.sql` +- **Voice-Service Integration:** `/voice-service/services/enhanced_task_orchestrator.py` +- **BQAS Integration:** `/voice-service/bqas/quality_judge_agent.py` +- **Tests:** `/agent-core/tests/` diff --git a/docs-src/architecture/secrets-management.md b/docs-src/architecture/secrets-management.md new file mode 100644 index 0000000..0c99e9a --- /dev/null +++ b/docs-src/architecture/secrets-management.md @@ -0,0 +1,251 @@ +# BreakPilot Secrets Management + +## Uebersicht + +BreakPilot verwendet **HashiCorp Vault** als zentrales Secrets-Management-System. + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ SECRETS MANAGEMENT │ +│ │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ HashiCorp Vault │ │ +│ │ Port 8200 │ │ +│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │ │ +│ │ │ KV v2 Engine │ │ AppRole Auth │ │ Audit Logging │ │ │ +│ │ │ secret/ │ │ Token Auth │ │ Verschluesselung │ │ │ +│ │ └──────────────┘ └──────────────┘ └──────────────────────────┘ │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌─────────────────────┼─────────────────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ Python Backend │ │ Go Services │ │ Frontend │ │ +│ │ (hvac client) │ │ (vault-client) │ │ (via Backend) │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +## Warum Vault? + +| Alternative | Nachteil | +|-------------|----------| +| Environment Variables | Keine Audit-Logs, keine Verschluesselung, keine Rotation | +| Docker Secrets | Nur fuer Docker Swarm, keine zentrale Verwaltung | +| AWS Secrets Manager | Cloud Lock-in, Kosten | +| Kubernetes Secrets | Keine Verschluesselung by default, nur K8s | +| **HashiCorp Vault** | Open Source (BSL 1.1), Self-Hosted, Enterprise Features | + +## Architektur + +### Secret-Hierarchie + +``` +secret/breakpilot/ +├── api_keys/ +│ ├── anthropic # Anthropic Claude API Key +│ ├── vast # vast.ai GPU API Key +│ ├── stripe # Stripe Payment Key +│ ├── stripe_webhook +│ └── tavily # Tavily Search API Key +├── database/ +│ ├── postgres # username, password, url +│ └── synapse # Matrix Synapse DB +├── auth/ +│ ├── jwt # secret, refresh_secret +│ └── keycloak # client_secret +├── communication/ +│ ├── matrix # access_token, db_password +│ └── jitsi # app_secret, jicofo, jvb passwords +├── storage/ +│ └── minio # access_key, secret_key +└── infra/ + └── vast # api_key, instance_id, control_key +``` + +### Python Integration + +```python +from secrets import get_secret + +# Einzelnes Secret abrufen +api_key = get_secret("ANTHROPIC_API_KEY") + +# Mit Default-Wert +debug = get_secret("DEBUG", default="false") + +# Als Pflicht-Secret +db_url = get_secret("DATABASE_URL", required=True) +``` + +### Fallback-Reihenfolge + +``` +1. HashiCorp Vault (wenn VAULT_ADDR gesetzt) + ↓ falls nicht verfuegbar +2. Environment Variables + ↓ falls nicht gesetzt +3. Docker Secrets (/run/secrets/) + ↓ falls nicht vorhanden +4. Default-Wert (wenn angegeben) + ↓ sonst +5. SecretNotFoundError (wenn required=True) +``` + +## Setup + +### Entwicklung (Dev Mode) + +```bash +# Vault starten (Dev Mode - NICHT fuer Produktion!) +docker-compose -f docker-compose.vault.yml up -d vault + +# Warten bis healthy +docker-compose -f docker-compose.vault.yml up vault-init + +# Environment setzen +export VAULT_ADDR=http://localhost:8200 +export VAULT_TOKEN=breakpilot-dev-token +``` + +### Secrets setzen + +```bash +# Anthropic API Key +vault kv put secret/breakpilot/api_keys/anthropic value='sk-ant-api03-...' + +# vast.ai Credentials +vault kv put secret/breakpilot/infra/vast \ + api_key='xxx' \ + instance_id='123' \ + control_key='yyy' + +# Database +vault kv put secret/breakpilot/database/postgres \ + username='breakpilot' \ + password='supersecret' \ + url='postgres://breakpilot:supersecret@localhost:5432/breakpilot_db' +``` + +### Secrets lesen + +```bash +# Liste aller Secrets +vault kv list secret/breakpilot/ + +# Secret anzeigen +vault kv get secret/breakpilot/api_keys/anthropic + +# Nur den Wert +vault kv get -field=value secret/breakpilot/api_keys/anthropic +``` + +## Produktion + +### AppRole Authentication + +In Produktion verwenden Services AppRole statt Token-Auth: + +```bash +# 1. AppRole aktivieren (einmalig) +vault auth enable approle + +# 2. Policy erstellen +vault policy write breakpilot-backend - < +VAULT_SECRET_ID= +VAULT_SECRETS_PATH=breakpilot +``` + +## Sicherheits-Checkliste + +### Muss erfuellt sein + +- [ ] Keine echten Secrets in `.env` Dateien +- [ ] `.env` in `.gitignore` +- [ ] Vault im Sealed-State wenn nicht in Verwendung +- [ ] TLS fuer Vault in Produktion +- [ ] AppRole statt Token-Auth in Produktion +- [ ] Audit-Logging aktiviert +- [ ] Minimale Policies (Least Privilege) + +### Sollte erfuellt sein + +- [ ] Automatische Secret-Rotation +- [ ] Separate Vault-Instanz fuer Produktion +- [ ] HSM-basiertes Auto-Unseal +- [ ] Disaster Recovery Plan + +## Dateien + +| Datei | Beschreibung | +|-------|--------------| +| `backend/secrets/__init__.py` | Secrets-Modul Exports | +| `backend/secrets/vault_client.py` | Vault Client Implementation | +| `docker-compose.vault.yml` | Vault Docker Configuration | +| `vault/init-secrets.sh` | Entwicklungs-Secrets Initialisierung | +| `vault/policies/` | Vault Policy Files | + +## Fehlerbehebung + +### Vault nicht erreichbar + +```bash +# Status pruefen +vault status + +# Falls sealed +vault operator unseal +``` + +### Secret nicht gefunden + +```bash +# Pfad pruefen +vault kv list secret/breakpilot/ + +# Cache leeren (Python) +from secrets import get_secrets_manager +get_secrets_manager().clear_cache() +``` + +### Token abgelaufen + +```bash +# Neuen Token holen (AppRole) +vault write auth/approle/login \ + role_id=$VAULT_ROLE_ID \ + secret_id=$VAULT_SECRET_ID +``` + +--- + +## Referenzen + +- [HashiCorp Vault Documentation](https://developer.hashicorp.com/vault/docs) +- [hvac Python Client](https://hvac.readthedocs.io/) +- [Vault Best Practices](https://developer.hashicorp.com/vault/tutorials/recommended-patterns) diff --git a/docs-src/architecture/system-architecture.md b/docs-src/architecture/system-architecture.md new file mode 100644 index 0000000..9147faa --- /dev/null +++ b/docs-src/architecture/system-architecture.md @@ -0,0 +1,311 @@ +# BreakPilot PWA - System-Architektur + +## Übersicht + +BreakPilot ist eine modulare Bildungsplattform für Lehrkräfte mit folgenden Hauptkomponenten: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Browser │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ Frontend (Studio UI) │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │ +│ │ │Dashboard │ │Worksheets│ │Correction│ │Letters/Companion │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +└───────────────────────────┬─────────────────────────────────────────┘ + │ HTTP/REST + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Python Backend (FastAPI) │ +│ Port 8000 │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ API Layer │ │ +│ │ /api/worksheets /api/corrections /api/letters /api/state │ │ +│ │ /api/school /api/certificates /api/messenger /api/jitsi │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ Service Layer │ │ +│ │ FileProcessor │ PDFService │ ContentGenerators │ StateEngine │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +└───────────────────────────┬─────────────────────────────────────────┘ + │ + ┌─────────────┼─────────────┐ + ▼ ▼ ▼ +┌─────────────────┐ ┌───────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Go Consent │ │ PostgreSQL │ │ LLM Gateway │ │ HashiCorp │ +│ Service │ │ Database │ │ (optional) │ │ Vault │ +│ Port 8081 │ │ Port 5432 │ │ │ │ Port 8200 │ +└─────────────────┘ └───────────────┘ └──────────────┘ └──────────────┘ +``` + +## Komponenten + +### 1. Admin Frontend (Next.js Website) + +Das **Admin Frontend** ist eine vollständige Next.js 15 Anwendung für Developer und Administratoren: + +**Technologie:** Next.js 15, React 18, TypeScript, Tailwind CSS + +**Container:** `breakpilot-pwa-website` auf **Port 3000** + +**Verzeichnis:** `/website` + +| Modul | Route | Beschreibung | +|-------|-------|--------------| +| Dashboard | `/admin` | Übersicht & Statistiken | +| GPU Infrastruktur | `/admin/gpu` | vast.ai GPU Management | +| Consent Verwaltung | `/admin/consent` | Rechtliche Dokumente & Versionen | +| Datenschutzanfragen | `/admin/dsr` | DSGVO Art. 15-21 Anfragen | +| DSMS | `/admin/dsms` | Datenschutz-Management-System | +| Education Search | `/admin/edu-search` | Bildungsquellen & Crawler | +| Personensuche | `/admin/staff-search` | Uni-Mitarbeiter & Publikationen | +| Uni-Crawler | `/admin/uni-crawler` | Universitäts-Crawling Orchestrator | +| LLM Vergleich | `/admin/llm-compare` | KI-Provider Vergleich | +| PCA Platform | `/admin/pca-platform` | Bot-Erkennung & Monetarisierung | +| Production Backlog | `/admin/backlog` | Go-Live Checkliste | +| Developer Docs | `/admin/docs` | API & Architektur Dokumentation | +| Kommunikation | `/admin/communication` | Matrix & Jitsi Monitoring | +| **Security** | `/admin/security` | DevSecOps Dashboard, Scans, Findings | +| **SBOM** | `/admin/sbom` | Software Bill of Materials | + +### 2. Lehrer Frontend (Studio UI) + +Das **Lehrer Frontend** ist ein Single-Page-Application-ähnliches System für Lehrkräfte, das in Python-Modulen organisiert ist: + +| Modul | Datei | Beschreibung | +|-------|-------|--------------| +| Base | `frontend/modules/base.py` | TopBar, Sidebar, Theme, Login | +| Dashboard | `frontend/modules/dashboard.py` | Übersichtsseite | +| Worksheets | `frontend/modules/worksheets.py` | Lerneinheiten-Generator | +| Correction | `frontend/modules/correction.py` | OCR-Klausurkorrektur | +| Letters | `frontend/modules/letters.py` | Elternkommunikation | +| Companion | `frontend/modules/companion.py` | Begleiter-Modus mit State Engine | +| School | `frontend/modules/school.py` | Schulverwaltung | +| Gradebook | `frontend/modules/gradebook.py` | Notenbuch | +| ContentCreator | `frontend/modules/content_creator.py` | H5P Content Creator | +| ContentFeed | `frontend/modules/content_feed.py` | Content Discovery | +| Messenger | `frontend/modules/messenger.py` | Matrix Messenger | +| Jitsi | `frontend/modules/jitsi.py` | Videokonferenzen | +| **KlausurKorrektur** | `frontend/modules/klausur_korrektur.py` | **Abitur-Klausurkorrektur (15-Punkte-System)** | +| **AbiturDocsAdmin** | `frontend/modules/abitur_docs_admin.py` | **Admin für Abitur-Dokumente (NiBiS)** | + +Jedes Modul exportiert: +- `get_css()` - CSS-Styles +- `get_html()` - HTML-Template +- `get_js()` - JavaScript-Logik + +### 3. Python Backend (FastAPI) + +#### API-Router + +| Router | Präfix | Beschreibung | +|--------|--------|--------------| +| `worksheets_api` | `/api/worksheets` | Content-Generatoren (MC, Cloze, Mindmap, Quiz) | +| `correction_api` | `/api/corrections` | OCR-Pipeline für Klausurkorrektur | +| `letters_api` | `/api/letters` | Elternbriefe mit GFK-Integration | +| `state_engine_api` | `/api/state` | Begleiter-Modus Phasen & Vorschläge | +| `school_api` | `/api/school` | Schulverwaltung (Proxy zu school-service) | +| `certificates_api` | `/api/certificates` | Zeugniserstellung | +| `messenger_api` | `/api/messenger` | Matrix Messenger Integration | +| `jitsi_api` | `/api/jitsi` | Jitsi Meeting-Einladungen | +| `consent_api` | `/api/consent` | DSGVO Consent-Verwaltung | +| `gdpr_api` | `/api/gdpr` | GDPR-Export | +| **`klausur_korrektur_api`** | `/api/klausur-korrektur` | **Abitur-Klausuren (15-Punkte, Gutachten, Fairness)** | +| **`abitur_docs_api`** | `/api/abitur-docs` | **NiBiS-Dokumentenverwaltung für RAG** | + +#### Services + +| Service | Datei | Beschreibung | +|---------|-------|--------------| +| FileProcessor | `services/file_processor.py` | OCR mit PaddleOCR | +| PDFService | `services/pdf_service.py` | PDF-Generierung | +| ContentGenerators | `services/content_generators/` | MC, Cloze, Mindmap, Quiz | +| StateEngine | `state_engine/` | Phasen-Management & Antizipation | + +### 4. Klausur-Korrektur System (Abitur) + +Das Klausur-Korrektur-System implementiert die vollständige Abitur-Bewertungspipeline: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Klausur-Korrektur Modul │ +│ │ +│ ┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐ │ +│ │ Modus-Wahl │───►│ Text-Quellen & │───►│ Erwartungs- │ │ +│ │ LandesAbi/ │ │ Rights-Gate │ │ horizont │ │ +│ │ Vorabitur │ └──────────────────┘ └─────────────────┘ │ +│ └─────────────┘ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Schülerarbeiten-Pipeline │ │ +│ │ Upload → OCR → KI-Bewertung → Gutachten → 15-Punkte-Note │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌────────────────────┐ ┌──────────────────────────────────┐ │ +│ │ Erst-/Zweitprüfer │───►│ Fairness-Analyse & PDF-Export │ │ +│ └────────────────────┘ └──────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +#### 15-Punkte-Notensystem + +Das System verwendet den deutschen Abitur-Notenschlüssel: + +| Punkte | Prozent | Note | +|--------|---------|------| +| 15-13 | 95-85% | 1+/1/1- | +| 12-10 | 80-70% | 2+/2/2- | +| 9-7 | 65-55% | 3+/3/3- | +| 6-4 | 50-40% | 4+/4/4- | +| 3-1 | 33-20% | 5+/5/5- | +| 0 | <20% | 6 | + +#### Bewertungskriterien + +| Kriterium | Gewicht | Beschreibung | +|-----------|---------|--------------| +| Rechtschreibung | 15% | Orthografie | +| Grammatik | 15% | Grammatik & Syntax | +| **Inhalt** | **40%** | Inhaltliche Qualität (höchste Gewichtung) | +| Struktur | 15% | Aufbau & Gliederung | +| Stil | 15% | Ausdruck & Stil | + +### 5. Go Consent Service + +Verwaltet DSGVO-Einwilligungen: + +``` +consent-service/ +├── cmd/server/ # Main entry point +├── internal/ +│ ├── handlers/ # HTTP Handler +│ ├── services/ # Business Logic +│ ├── models/ # Data Models +│ └── middleware/ # Auth Middleware +└── migrations/ # SQL Migrations +``` + +### 6. LLM Gateway (Optional) + +Wenn `LLM_GATEWAY_ENABLED=true`: + +``` +llm_gateway/ +├── routes/ +│ ├── chat.py # Chat-Completion API +│ ├── communication.py # GFK-Validierung +│ ├── edu_search_seeds.py # Bildungssuche +│ └── legal_crawler.py # Schulgesetz-Crawler +└── services/ + └── communication_service.py +``` + +## Datenfluss + +### Worksheet-Generierung + +``` +User Input → Frontend (worksheets.py) + ↓ +POST /api/worksheets/generate/multiple-choice + ↓ +worksheets_api.py → MCGenerator (services/content_generators/) + ↓ +Optional: LLM für erweiterte Generierung + ↓ +Response: WorksheetContent → Frontend rendert Ergebnis +``` + +### Klausurkorrektur + +``` +File Upload → Frontend (correction.py) + ↓ +POST /api/corrections/ (erstellen) +POST /api/corrections/{id}/upload (Datei) + ↓ +Background Task: OCR via FileProcessor + ↓ +Poll GET /api/corrections/{id} bis status="ocr_complete" + ↓ +POST /api/corrections/{id}/analyze + ↓ +Review Interface → PUT /api/corrections/{id} (Anpassungen) + ↓ +GET /api/corrections/{id}/export-pdf +``` + +## Sicherheit + +### Authentifizierung & Autorisierung + +BreakPilot verwendet einen **Hybrid-Ansatz**: + +| Schicht | Komponente | Beschreibung | +|---------|------------|--------------| +| **Authentifizierung** | Keycloak (Prod) / Lokales JWT (Dev) | Token-Validierung via JWKS oder HS256 | +| **Autorisierung** | rbac.py (Eigenentwicklung) | Domaenenspezifische Berechtigungen | + +Siehe: [Auth-System](auth-system.md) + +### Basis-Rollen + +| Rolle | Beschreibung | +|-------|--------------| +| `user` | Normaler Benutzer | +| `teacher` / `lehrer` | Lehrkraft | +| `admin` | Administrator | +| `data_protection_officer` | Datenschutzbeauftragter | + +### Erweiterte Rollen (rbac.py) + +15+ domaenenspezifische Rollen fuer Klausurkorrektur und Zeugnisse: +- `erstkorrektor`, `zweitkorrektor`, `drittkorrektor` +- `klassenlehrer`, `fachlehrer`, `fachvorsitz` +- `schulleitung`, `zeugnisbeauftragter`, `sekretariat` + +### Sicherheitsfeatures + +- JWT-basierte Authentifizierung (RS256/HS256) +- CORS konfiguriert für Frontend-Zugriff +- DSGVO-konformes Consent-Management +- **HashiCorp Vault** fuer Secrets-Management (keine hardcodierten Secrets) +- Bundesland-spezifische Policy-Sets +- **DevSecOps Pipeline** mit automatisierten Security-Scans (SAST, SCA, Secrets Detection) + +Siehe: +- [Secrets Management](secrets-management.md) +- [DevSecOps](devsecops.md) + +## Deployment + +```yaml +services: + backend: + build: ./backend + ports: ["8000:8000"] + environment: + - DATABASE_URL=postgresql://... + - LLM_GATEWAY_ENABLED=false + + consent-service: + build: ./consent-service + ports: ["8081:8081"] + + postgres: + image: postgres:15 + volumes: + - pgdata:/var/lib/postgresql/data +``` + +## Erweiterung + +Neues Frontend-Modul hinzufügen: + +1. Modul erstellen: `frontend/modules/new_module.py` +2. Klasse mit `get_css()`, `get_html()`, `get_js()` implementieren +3. In `frontend/modules/__init__.py` importieren und exportieren +4. Optional: Zugehörige API in `new_module_api.py` erstellen +5. In `main.py` Router registrieren diff --git a/docs-src/architecture/zeugnis-system.md b/docs-src/architecture/zeugnis-system.md new file mode 100644 index 0000000..ce04ea0 --- /dev/null +++ b/docs-src/architecture/zeugnis-system.md @@ -0,0 +1,169 @@ +# Zeugnis-System - Architecture Documentation + +## Overview + +The Zeugnis (Certificate) System enables schools to generate official school certificates with grades, attendance data, and remarks. It extends the existing School-Service with comprehensive grade management and certificate generation workflows. + +## Architecture Diagram + +``` + ┌─────────────────────────────────────┐ + │ Python Backend (Port 8000) │ + │ backend/frontend/modules/school.py │ + │ │ + │ ┌─────────────────────────────────┐ │ + │ │ panel-school-certificates │ │ + │ │ - Klassenauswahl │ │ + │ │ - Notenspiegel │ │ + │ │ - Zeugnis-Wizard (5 Steps) │ │ + │ │ - Workflow-Status │ │ + │ └─────────────────────────────────┘ │ + └──────────────────┬──────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────────────┐ +│ School-Service (Go, Port 8084) │ +├─────────────────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────────────────┐ │ +│ │ Grade Handlers │ │ Statistics Handlers │ │ Certificate Handlers │ │ +│ │ │ │ │ │ │ │ +│ │ GetClassGrades │ │ GetClassStatistics │ │ GetCertificateTemplates │ │ +│ │ GetStudentGrades │ │ GetSubjectStatistics│ │ GetClassCertificates │ │ +│ │ UpdateOralGrade │ │ GetStudentStatistics│ │ GenerateCertificate │ │ +│ │ CalculateFinalGrades│ │ GetNotenspiegel │ │ BulkGenerateCertificates │ │ +│ │ LockFinalGrade │ │ │ │ FinalizeCertificate │ │ +│ │ UpdateGradeWeights │ │ │ │ GetCertificatePDF │ │ +│ └─────────────────────┘ └─────────────────────┘ └─────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────┐ + │ PostgreSQL Database │ + │ │ + │ Tables: │ + │ - grade_overview │ + │ - exam_results │ + │ - students │ + │ - classes │ + │ - subjects │ + │ - certificates │ + │ - attendance │ + └─────────────────────────────────────┘ +``` + +## Zeugnis Workflow (Role Chain) + +The certificate workflow follows a strict approval chain from subject teachers to school principal: + +``` +┌──────────────────┐ ┌──────────────────┐ ┌────────────────────────┐ ┌────────────────────┐ ┌──────────────────┐ +│ FACHLEHRER │───▶│ KLASSENLEHRER │───▶│ ZEUGNISBEAUFTRAGTER │───▶│ SCHULLEITUNG │───▶│ SEKRETARIAT │ +│ (Subject │ │ (Class │ │ (Certificate │ │ (Principal) │ │ (Secretary) │ +│ Teacher) │ │ Teacher) │ │ Coordinator) │ │ │ │ │ +└──────────────────┘ └──────────────────┘ └────────────────────────┘ └────────────────────┘ └──────────────────┘ + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ + Grades Entry Approve Quality Check Sign-off & Lock Print & Archive + (Oral/Written) Grades & Review +``` + +### Workflow States + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ DRAFT │────▶│ SUBMITTED │────▶│ REVIEWED │────▶│ SIGNED │────▶│ PRINTED │ +│ (Entwurf) │ │ (Eingereicht)│ │ (Geprueft) │ │(Unterzeichnet) │ (Gedruckt) │ +└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ + Fachlehrer Klassenlehrer Zeugnisbeauftragter Schulleitung +``` + +## RBAC Integration + +### Certificate-Related Roles + +| Role | German | Description | +|------|--------|-------------| +| `FACHLEHRER` | Fachlehrer | Subject teacher - enters grades | +| `KLASSENLEHRER` | Klassenlehrer | Class teacher - approves class grades | +| `ZEUGNISBEAUFTRAGTER` | Zeugnisbeauftragter | Certificate coordinator - quality control | +| `SCHULLEITUNG` | Schulleitung | Principal - final sign-off | +| `SEKRETARIAT` | Sekretariat | Secretary - printing & archiving | + +### Certificate Resource Types + +| ResourceType | Description | +|--------------|-------------| +| `ZEUGNIS` | Final certificate document | +| `ZEUGNIS_VORLAGE` | Certificate template (per Bundesland) | +| `ZEUGNIS_ENTWURF` | Draft certificate (before approval) | +| `FACHNOTE` | Subject grade | +| `KOPFNOTE` | Head grade (Arbeits-/Sozialverhalten) | +| `BEMERKUNG` | Certificate remarks | +| `STATISTIK` | Class/subject statistics | +| `NOTENSPIEGEL` | Grade distribution chart | + +## German Grading System + +| Grade | Meaning | Points | +|-------|---------|--------| +| 1 | sehr gut (excellent) | 15-13 | +| 2 | gut (good) | 12-10 | +| 3 | befriedigend (satisfactory) | 9-7 | +| 4 | ausreichend (adequate) | 6-4 | +| 5 | mangelhaft (poor) | 3-1 | +| 6 | ungenuegend (inadequate) | 0 | + +### Grade Calculation + +``` +Final Grade = (Written Weight * Written Avg) + (Oral Weight * Oral Avg) + +Default weights: +- Written (Klassenarbeiten): 50% +- Oral (muendliche Note): 50% + +Customizable per subject/student via UpdateGradeWeights endpoint. +``` + +## API Routes (School-Service) + +### Grade Management + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/school/grades/:classId` | Get class grades | +| GET | `/api/v1/school/grades/student/:studentId` | Get student grades | +| PUT | `/api/v1/school/grades/:studentId/:subjectId/oral` | Update oral grade | +| POST | `/api/v1/school/grades/calculate` | Calculate final grades | +| PUT | `/api/v1/school/grades/:studentId/:subjectId/lock` | Lock final grade | + +### Statistics + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/school/statistics/:classId` | Class statistics | +| GET | `/api/v1/school/statistics/:classId/subject/:subjectId` | Subject statistics | +| GET | `/api/v1/school/statistics/student/:studentId` | Student statistics | +| GET | `/api/v1/school/statistics/:classId/notenspiegel` | Grade distribution | + +### Certificates + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/school/certificates/templates` | List templates | +| GET | `/api/v1/school/certificates/class/:classId` | Class certificates | +| POST | `/api/v1/school/certificates/generate` | Generate single | +| POST | `/api/v1/school/certificates/generate-bulk` | Generate bulk | +| GET | `/api/v1/school/certificates/detail/:id/pdf` | Download PDF | + +## Security Considerations + +1. **RBAC Enforcement**: All certificate operations check user role permissions +2. **Tenant Isolation**: Teachers only see their own classes/students +3. **Audit Trail**: All grade changes and approvals logged +4. **Lock Mechanism**: Finalized certificates cannot be modified +5. **Workflow Enforcement**: Cannot skip approval steps diff --git a/docs-src/development/ci-cd-pipeline.md b/docs-src/development/ci-cd-pipeline.md new file mode 100644 index 0000000..b6d991e --- /dev/null +++ b/docs-src/development/ci-cd-pipeline.md @@ -0,0 +1,402 @@ +# CI/CD Pipeline + +Übersicht über den Deployment-Prozess für Breakpilot. + +## Übersicht + +| Komponente | Build-Tool | Deployment | +|------------|------------|------------| +| Frontend (Next.js) | Docker | Mac Mini | +| Backend (FastAPI) | Docker | Mac Mini | +| Go Services | Docker (Multi-stage) | Mac Mini | +| Documentation | MkDocs | Docker (Nginx) | + +## Deployment-Architektur + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Entwickler-MacBook │ +│ │ +│ breakpilot-pwa/ │ +│ ├── studio-v2/ (Next.js Frontend) │ +│ ├── admin-v2/ (Next.js Admin) │ +│ ├── backend/ (Python FastAPI) │ +│ ├── consent-service/ (Go Service) │ +│ ├── klausur-service/ (Python FastAPI) │ +│ ├── voice-service/ (Python FastAPI) │ +│ ├── ai-compliance-sdk/ (Go Service) │ +│ └── docs-src/ (MkDocs) │ +│ │ +│ $ ./sync-and-deploy.sh │ +└───────────────────────────────┬─────────────────────────────────┘ + │ + │ rsync + SSH + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Mac Mini Server │ +│ │ +│ Docker Compose │ +│ ├── website (Port 3000) │ +│ ├── studio-v2 (Port 3001) │ +│ ├── admin-v2 (Port 3002) │ +│ ├── backend (Port 8000) │ +│ ├── consent-service (Port 8081) │ +│ ├── klausur-service (Port 8086) │ +│ ├── voice-service (Port 8082) │ +│ ├── ai-compliance-sdk (Port 8090) │ +│ ├── docs (Port 8009) │ +│ ├── postgres │ +│ ├── valkey (Redis) │ +│ ├── qdrant │ +│ └── minio │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Sync & Deploy Workflow + +### 1. Dateien synchronisieren + +```bash +# Sync aller relevanten Verzeichnisse zum Mac Mini +rsync -avz --delete \ + --exclude 'node_modules' \ + --exclude '.next' \ + --exclude '.git' \ + --exclude '__pycache__' \ + --exclude 'venv' \ + --exclude '.pytest_cache' \ + /Users/benjaminadmin/Projekte/breakpilot-pwa/ \ + macmini:/Users/benjaminadmin/Projekte/breakpilot-pwa/ +``` + +### 2. Container bauen + +```bash +# Einzelnen Service bauen +ssh macmini "/usr/local/bin/docker compose \ + -f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \ + build --no-cache " + +# Beispiele: +# studio-v2, admin-v2, website, backend, klausur-service, docs +``` + +### 3. Container deployen + +```bash +# Container neu starten +ssh macmini "/usr/local/bin/docker compose \ + -f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \ + up -d " +``` + +### 4. Logs prüfen + +```bash +# Container-Logs anzeigen +ssh macmini "/usr/local/bin/docker compose \ + -f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \ + logs -f " +``` + +## Service-spezifische Deployments + +### Next.js Frontend (studio-v2, admin-v2, website) + +```bash +# 1. Sync +rsync -avz --delete \ + --exclude 'node_modules' --exclude '.next' --exclude '.git' \ + /Users/benjaminadmin/Projekte/breakpilot-pwa/studio-v2/ \ + macmini:/Users/benjaminadmin/Projekte/breakpilot-pwa/studio-v2/ + +# 2. Build & Deploy +ssh macmini "/usr/local/bin/docker compose \ + -f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \ + build --no-cache studio-v2 && \ + /usr/local/bin/docker compose \ + -f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \ + up -d studio-v2" +``` + +### Python Services (backend, klausur-service, voice-service) + +```bash +# Build mit requirements.txt +ssh macmini "/usr/local/bin/docker compose \ + -f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \ + build klausur-service && \ + /usr/local/bin/docker compose \ + -f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \ + up -d klausur-service" +``` + +### Go Services (consent-service, ai-compliance-sdk) + +```bash +# Multi-stage Build (Go → Alpine) +ssh macmini "/usr/local/bin/docker compose \ + -f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \ + build --no-cache consent-service && \ + /usr/local/bin/docker compose \ + -f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \ + up -d consent-service" +``` + +### MkDocs Dokumentation + +```bash +# Build & Deploy +ssh macmini "/usr/local/bin/docker compose \ + -f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \ + build --no-cache docs && \ + /usr/local/bin/docker compose \ + -f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \ + up -d docs" + +# Verfügbar unter: http://macmini:8009 +``` + +## Health Checks + +### Service-Status prüfen + +```bash +# Alle Container-Status +ssh macmini "docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'" + +# Health-Endpoints prüfen +curl -s http://macmini:8000/health +curl -s http://macmini:8081/health +curl -s http://macmini:8086/health +curl -s http://macmini:8090/health +``` + +### Logs analysieren + +```bash +# Letzte 100 Zeilen +ssh macmini "docker logs --tail 100 breakpilot-pwa-backend-1" + +# Live-Logs folgen +ssh macmini "docker logs -f breakpilot-pwa-backend-1" +``` + +## Rollback + +### Container auf vorherige Version zurücksetzen + +```bash +# 1. Aktuelles Image taggen +ssh macmini "docker tag breakpilot-pwa-backend:latest breakpilot-pwa-backend:backup" + +# 2. Altes Image deployen +ssh macmini "/usr/local/bin/docker compose \ + -f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \ + up -d backend" + +# 3. Bei Problemen: Backup wiederherstellen +ssh macmini "docker tag breakpilot-pwa-backend:backup breakpilot-pwa-backend:latest" +``` + +## Troubleshooting + +### Container startet nicht + +```bash +# 1. Logs prüfen +ssh macmini "docker logs breakpilot-pwa--1" + +# 2. Container manuell starten für Debug-Output +ssh macmini "docker compose -f .../docker-compose.yml run --rm " + +# 3. In Container einloggen +ssh macmini "docker exec -it breakpilot-pwa--1 /bin/sh" +``` + +### Port bereits belegt + +```bash +# Port-Belegung prüfen +ssh macmini "lsof -i :8000" + +# Container mit dem Port finden +ssh macmini "docker ps --filter publish=8000" +``` + +### Build-Fehler + +```bash +# Cache komplett leeren +ssh macmini "docker builder prune -a" + +# Ohne Cache bauen +ssh macmini "docker compose build --no-cache " +``` + +## Monitoring + +### Resource-Nutzung + +```bash +# CPU/Memory aller Container +ssh macmini "docker stats --no-stream" + +# Disk-Nutzung +ssh macmini "docker system df" +``` + +### Cleanup + +```bash +# Ungenutzte Images/Container entfernen +ssh macmini "docker system prune -a --volumes" + +# Nur dangling Images +ssh macmini "docker image prune" +``` + +## Umgebungsvariablen + +Umgebungsvariablen werden über `.env` Dateien und docker-compose.yml verwaltet: + +```yaml +# docker-compose.yml +services: + backend: + environment: + - DATABASE_URL=postgresql://... + - REDIS_URL=redis://valkey:6379 + - SECRET_KEY=${SECRET_KEY} +``` + +**Wichtig**: Sensible Werte niemals in Git committen. Stattdessen: +- `.env` Datei auf dem Server pflegen +- Secrets über HashiCorp Vault (siehe unten) + +## Woodpecker CI - Automatisierte OAuth Integration + +### Überblick + +Die OAuth-Integration zwischen Woodpecker CI und Gitea ist **vollständig automatisiert**. Credentials werden in HashiCorp Vault gespeichert und bei Bedarf automatisch regeneriert. + +!!! info "Warum automatisiert?" + Diese Automatisierung ist eine DevSecOps Best Practice: + + - **Infrastructure-as-Code**: Alles ist reproduzierbar + - **Disaster Recovery**: Verlorene Credentials können automatisch regeneriert werden + - **Security**: Secrets werden zentral in Vault verwaltet + - **Onboarding**: Neue Entwickler müssen nichts manuell konfigurieren + +### Architektur + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Mac Mini Server │ +│ │ +│ ┌───────────────┐ OAuth 2.0 ┌───────────────┐ │ +│ │ Gitea │ ←─────────────────────────→│ Woodpecker │ │ +│ │ (Port 3003) │ Client ID + Secret │ (Port 8090) │ │ +│ └───────────────┘ └───────────────┘ │ +│ │ │ │ +│ │ OAuth App │ Env Vars│ +│ │ (DB: oauth2_application) │ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ HashiCorp Vault (Port 8200) │ │ +│ │ │ │ +│ │ secret/cicd/woodpecker: │ │ +│ │ - gitea_client_id │ │ +│ │ - gitea_client_secret │ │ +│ │ │ │ +│ │ secret/cicd/api-tokens: │ │ +│ │ - gitea_token (für API-Zugriff) │ │ +│ │ - woodpecker_token (für Pipeline-Trigger) │ │ +│ └───────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Credentials-Speicherorte + +| Ort | Pfad | Inhalt | +|-----|------|--------| +| **HashiCorp Vault** | `secret/cicd/woodpecker` | Client ID + Secret (Quelle der Wahrheit) | +| **.env Datei** | `WOODPECKER_GITEA_CLIENT/SECRET` | Für Docker Compose (aus Vault geladen) | +| **Gitea PostgreSQL** | `oauth2_application` Tabelle | OAuth App Registration (gehashtes Secret) | + +### Troubleshooting: OAuth Fehler + +Falls der Fehler "Client ID not registered" oder "user does not exist [uid: 0]" auftritt: + +```bash +# Option 1: Automatisches Regenerieren (empfohlen) +./scripts/sync-woodpecker-credentials.sh --regenerate + +# Option 2: Manuelles Vorgehen +# 1. Credentials aus Vault laden +vault kv get secret/cicd/woodpecker + +# 2. .env aktualisieren +WOODPECKER_GITEA_CLIENT= +WOODPECKER_GITEA_SECRET= + +# 3. Zu Mac Mini synchronisieren +rsync .env macmini:~/Projekte/breakpilot-pwa/ + +# 4. Woodpecker neu starten +ssh macmini "cd ~/Projekte/breakpilot-pwa && \ + docker compose up -d --force-recreate woodpecker-server" +``` + +### Das Sync-Script + +Das Script `scripts/sync-woodpecker-credentials.sh` automatisiert den gesamten Prozess: + +```bash +# Credentials aus Vault laden und .env aktualisieren +./scripts/sync-woodpecker-credentials.sh + +# Neue Credentials generieren (OAuth App in Gitea + Vault + .env) +./scripts/sync-woodpecker-credentials.sh --regenerate +``` + +Was das Script macht: + +1. **Liest** die aktuellen Credentials aus Vault +2. **Aktualisiert** die .env Datei automatisch +3. **Bei `--regenerate`**: + - Löscht alte OAuth Apps in Gitea + - Erstellt neue OAuth App mit neuem Client ID/Secret + - Speichert Credentials in Vault + - Aktualisiert .env + +### Vault-Zugriff + +```bash +# Vault Token (Development) +export VAULT_TOKEN=breakpilot-dev-token + +# Credentials lesen +docker exec -e VAULT_TOKEN=$VAULT_TOKEN breakpilot-pwa-vault \ + vault kv get secret/cicd/woodpecker + +# Credentials setzen +docker exec -e VAULT_TOKEN=$VAULT_TOKEN breakpilot-pwa-vault \ + vault kv put secret/cicd/woodpecker \ + gitea_client_id="..." \ + gitea_client_secret="..." +``` + +### Services neustarten nach Credentials-Änderung + +```bash +# Wichtig: --force-recreate um neue Env Vars zu laden +cd /Users/benjaminadmin/Projekte/breakpilot-pwa +docker compose up -d --force-recreate woodpecker-server + +# Logs prüfen +docker logs breakpilot-pwa-woodpecker-server --tail 50 +``` diff --git a/docs-src/development/documentation.md b/docs-src/development/documentation.md new file mode 100644 index 0000000..8587ef9 --- /dev/null +++ b/docs-src/development/documentation.md @@ -0,0 +1,159 @@ +# Dokumentations-Regeln + +## Automatische Dokumentations-Aktualisierung + +**WICHTIG:** Bei JEDER Code-Aenderung muss die entsprechende Dokumentation aktualisiert werden! + +## Wann Dokumentation aktualisieren? + +### API-Aenderungen + +Wenn du einen Endpoint aenderst, hinzufuegst oder entfernst: + +- Aktualisiere die [Backend API Dokumentation](../api/backend-api.md) +- Aktualisiere Service-spezifische API-Docs + +### Neue Funktionen/Klassen + +Wenn du neue Funktionen, Klassen oder Module erstellst: + +- Aktualisiere die entsprechende Service-Dokumentation +- Fuege Code-Beispiele hinzu + +### Architektur-Aenderungen + +Wenn du die Systemarchitektur aenderst: + +- Aktualisiere die [System-Architektur](../architecture/system-architecture.md) +- Aktualisiere Datenmodell-Dokumentation bei DB-Aenderungen + +### Neue Konfigurationsoptionen + +Wenn du neue Umgebungsvariablen oder Konfigurationen hinzufuegst: + +- Aktualisiere die entsprechende README +- Fuege zur [Umgebungs-Setup](../getting-started/environment-setup.md) hinzu + +## Dokumentations-Format + +### API-Endpoints dokumentieren + +```markdown +### METHOD /path/to/endpoint + +Kurze Beschreibung. + +**Request Body:** +```json +{ + "field": "value" +} +``` + +**Response (200):** +```json +{ + "result": "value" +} +``` + +**Errors:** +- `400`: Beschreibung +- `401`: Beschreibung +``` + +### Funktionen dokumentieren + +```markdown +### FunctionName (file.go:123) + +```go +func FunctionName(param Type) ReturnType +``` + +**Beschreibung:** Was macht die Funktion? + +**Parameter:** +- `param`: Beschreibung + +**Rueckgabe:** Beschreibung +``` + +## Checkliste nach Code-Aenderungen + +Vor dem Abschluss einer Aufgabe pruefen: + +- [ ] Wurden neue API-Endpoints hinzugefuegt? → API-Docs aktualisieren +- [ ] Wurden Datenmodelle geaendert? → Architektur-Docs aktualisieren +- [ ] Wurden neue Konfigurationen hinzugefuegt? → README aktualisieren +- [ ] Wurden neue Abhaengigkeiten hinzugefuegt? → requirements.txt/go.mod UND Docs +- [ ] Wurde die Architektur geaendert? → architecture/ aktualisieren + +## Beispiel: Vollstaendige Dokumentation einer neuen Funktion + +Wenn du z.B. `GetUserStats()` im Go Service hinzufuegst: + +1. **Code schreiben** in `internal/services/stats_service.go` +2. **API-Doc aktualisieren** in der API-Dokumentation +3. **Service-Doc aktualisieren** in der Service-README +4. **Test schreiben** (siehe [Testing](./testing.md)) + +## Dokumentations-Struktur + +Die zentrale Dokumentation befindet sich unter `docs-src/`: + +``` +docs-src/ +├── index.md # Startseite +├── getting-started/ # Erste Schritte +│ ├── environment-setup.md +│ └── mac-mini-setup.md +├── architecture/ # Architektur-Dokumentation +│ ├── system-architecture.md +│ ├── auth-system.md +│ └── ... +├── api/ # API-Dokumentation +│ └── backend-api.md +├── services/ # Service-Dokumentation +│ ├── klausur-service/ +│ ├── agent-core/ +│ └── ... +├── development/ # Entwickler-Guides +│ ├── testing.md +│ └── documentation.md +└── guides/ # Weitere Anleitungen +``` + +## MkDocs Konventionen + +Diese Dokumentation wird mit MkDocs + Material Theme generiert: + +- **Admonitions** fuer Hinweise: + ```markdown + !!! note "Hinweis" + Wichtige Information hier. + + !!! warning "Warnung" + Vorsicht bei dieser Aktion. + ``` + +- **Code-Tabs** fuer mehrere Sprachen: + ```markdown + === "Python" + ```python + print("Hello") + ``` + + === "Go" + ```go + fmt.Println("Hello") + ``` + ``` + +- **Mermaid-Diagramme** fuer Visualisierungen: + ```markdown + ```mermaid + graph LR + A --> B --> C + ``` + ``` diff --git a/docs-src/development/testing.md b/docs-src/development/testing.md new file mode 100644 index 0000000..83d5e53 --- /dev/null +++ b/docs-src/development/testing.md @@ -0,0 +1,211 @@ +# Test-Regeln + +## Automatische Test-Erweiterung + +**WICHTIG:** Bei JEDER Code-Aenderung muessen entsprechende Tests erstellt oder aktualisiert werden! + +## Wann Tests schreiben? + +### IMMER wenn du: + +1. **Neue Funktionen** erstellst → Unit Test +2. **Neue API-Endpoints** hinzufuegst → Handler Test +3. **Bugs fixst** → Regression Test (der Bug sollte nie wieder auftreten) +4. **Bestehenden Code aenderst** → Bestehende Tests anpassen + +## Test-Struktur + +### Go Tests (Consent Service) + +**Speicherort:** Im gleichen Verzeichnis wie der Code + +``` +internal/ +├── services/ +│ ├── auth_service.go +│ └── auth_service_test.go ← Test hier +├── handlers/ +│ ├── handlers.go +│ └── handlers_test.go ← Test hier +└── middleware/ + ├── auth.go + └── middleware_test.go ← Test hier +``` + +**Test-Namenskonvention:** + +```go +func TestFunctionName_Scenario_ExpectedResult(t *testing.T) + +// Beispiele: +func TestHashPassword_ValidPassword_ReturnsHash(t *testing.T) +func TestLogin_InvalidCredentials_Returns401(t *testing.T) +func TestCreateDocument_MissingTitle_ReturnsError(t *testing.T) +``` + +**Test-Template:** + +```go +func TestFunctionName(t *testing.T) { + // Arrange + service := &MyService{} + input := "test-input" + + // Act + result, err := service.DoSomething(input) + + // Assert + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if result != expected { + t.Errorf("Expected %v, got %v", expected, result) + } +} +``` + +**Table-Driven Tests bevorzugen:** + +```go +func TestValidateEmail(t *testing.T) { + tests := []struct { + name string + email string + expected bool + }{ + {"valid email", "test@example.com", true}, + {"missing @", "testexample.com", false}, + {"empty", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ValidateEmail(tt.email) + if result != tt.expected { + t.Errorf("Expected %v, got %v", tt.expected, result) + } + }) + } +} +``` + +### Python Tests (Backend) + +**Speicherort:** `/backend/tests/` + +``` +backend/ +├── consent_client.py +├── gdpr_api.py +└── tests/ + ├── __init__.py + ├── test_consent_client.py ← Tests fuer consent_client.py + └── test_gdpr_api.py ← Tests fuer gdpr_api.py +``` + +**Test-Namenskonvention:** + +```python +class TestClassName: + def test_method_scenario_expected_result(self): + pass + +# Beispiele: +class TestConsentClient: + def test_check_consent_valid_token_returns_status(self): + pass + + def test_check_consent_expired_token_raises_error(self): + pass +``` + +**Test-Template:** + +```python +import pytest +from unittest.mock import AsyncMock, patch, MagicMock + +class TestMyFeature: + def test_sync_function(self): + # Arrange + input_data = "test" + + # Act + result = my_function(input_data) + + # Assert + assert result == expected + + @pytest.mark.asyncio + async def test_async_function(self): + # Arrange + client = MyClient() + + # Act + with patch("httpx.AsyncClient") as mock: + mock_instance = AsyncMock() + mock.return_value = mock_instance + result = await client.fetch_data() + + # Assert + assert result is not None +``` + +## Test-Kategorien + +### 1. Unit Tests (Hoechste Prioritaet) + +- Testen einzelne Funktionen/Methoden +- Keine externen Abhaengigkeiten (Mocks verwenden) +- Schnell ausfuehrbar + +### 2. Integration Tests + +- Testen Zusammenspiel mehrerer Komponenten +- Koennen echte DB verwenden (Test-DB) + +### 3. Security Tests + +- Auth/JWT Validierung +- Passwort-Hashing +- Berechtigungspruefung + +## Checkliste vor Abschluss + +Vor dem Abschluss einer Aufgabe: + +- [ ] Gibt es Tests fuer alle neuen Funktionen? +- [ ] Gibt es Tests fuer alle Edge Cases? +- [ ] Gibt es Tests fuer Fehlerfaelle? +- [ ] Laufen alle bestehenden Tests noch? (`go test ./...` / `pytest`) +- [ ] Ist die Test-Coverage angemessen? + +## Tests ausfuehren + +```bash +# Go - Alle Tests +cd consent-service && go test -v ./... + +# Go - Mit Coverage +cd consent-service && go test -cover ./... + +# Python - Alle Tests +cd backend && source venv/bin/activate && pytest -v + +# Python - Mit Coverage +cd backend && pytest --cov=. --cov-report=html +``` + +## Beispiel: Vollstaendiger Test-Workflow + +Wenn du z.B. eine neue `GetUserStats()` Funktion im Go Service hinzufuegst: + +1. **Funktion schreiben** in `internal/services/stats_service.go` +2. **Test erstellen** in `internal/services/stats_service_test.go`: + ```go + func TestGetUserStats_ValidUser_ReturnsStats(t *testing.T) {...} + func TestGetUserStats_InvalidUser_ReturnsError(t *testing.T) {...} + func TestGetUserStats_NoConsents_ReturnsEmptyStats(t *testing.T) {...} + ``` +3. **Tests ausfuehren**: `go test -v ./internal/services/...` +4. **Dokumentation aktualisieren** (siehe [Dokumentation](./documentation.md)) diff --git a/docs-src/getting-started/environment-setup.md b/docs-src/getting-started/environment-setup.md new file mode 100644 index 0000000..a1d6379 --- /dev/null +++ b/docs-src/getting-started/environment-setup.md @@ -0,0 +1,258 @@ +# Entwickler-Guide: Umgebungs-Setup + +Dieser Guide erklärt das tägliche Arbeiten mit den Dev/Staging/Prod-Umgebungen. + +## Schnellstart + +```bash +# 1. Wechsle in das Projektverzeichnis +cd /Users/benjaminadmin/Projekte/breakpilot-pwa + +# 2. Starte die Entwicklungsumgebung +./scripts/start.sh dev + +# 3. Prüfe den Status +./scripts/status.sh +``` + +## Täglicher Workflow + +### Morgens: Entwicklung starten + +```bash +# Auf develop-Branch wechseln +git checkout develop + +# Neueste Änderungen holen (falls Remote konfiguriert) +git pull origin develop + +# Umgebung starten +./scripts/start.sh dev +``` + +### Während der Arbeit + +```bash +# Logs eines Services anzeigen +docker compose logs -f backend + +# Service neustarten +docker compose restart backend + +# Status prüfen +./scripts/status.sh +``` + +### Änderungen committen + +```bash +# Änderungen anzeigen +git status + +# Dateien hinzufügen +git add . + +# Commit erstellen +git commit -m "Feature: Beschreibung der Änderung" +``` + +### Abends: Umgebung stoppen + +```bash +./scripts/stop.sh dev +``` + +## Umgebung wechseln + +### Von Dev zu Staging + +```bash +# Stoppe Dev +./scripts/stop.sh dev + +# Starte Staging +./scripts/start.sh staging +``` + +### Zurück zu Dev + +```bash +./scripts/stop.sh staging +./scripts/start.sh dev +``` + +## Code promoten + +### Dev → Staging (nach erfolgreichem Test) + +```bash +# Stelle sicher, dass alle Änderungen committet sind +git status + +# Promote zu Staging +./scripts/promote.sh dev-to-staging + +# Push zu Remote (falls konfiguriert) +git push origin staging +``` + +### Staging → Production (Release) + +```bash +# Nur nach vollständigem Test auf Staging! +./scripts/promote.sh staging-to-prod + +# Push zu Remote +git push origin main +``` + +## Nützliche Befehle + +### Docker + +```bash +# Alle Container anzeigen +docker compose ps + +# Logs folgen +docker compose logs -f [service] + +# In Container einsteigen +docker compose exec backend bash +docker compose exec postgres psql -U breakpilot -d breakpilot_dev + +# Container neustarten +docker compose restart [service] + +# Alle Container stoppen und entfernen +docker compose down + +# Mit Volumes löschen (VORSICHT!) +docker compose down -v +``` + +### Git + +```bash +# Aktuellen Branch anzeigen +git branch --show-current + +# Alle Branches anzeigen +git branch -v + +# Änderungen zwischen Branches anzeigen +git diff develop..staging +``` + +### Datenbank + +```bash +# Direkt mit PostgreSQL verbinden (Dev) +docker compose exec postgres psql -U breakpilot -d breakpilot_dev + +# Backup erstellen +./scripts/backup.sh + +# Backup wiederherstellen +./scripts/restore.sh backup-file.sql.gz +``` + +## Häufige Probleme + +### "Port already in use" + +Ein anderer Prozess oder Container verwendet den Port. + +```bash +# Laufende Container prüfen +docker ps + +# Alte Container stoppen +docker compose down + +# Prozess auf Port finden (z.B. 8000) +lsof -i :8000 +``` + +### Container startet nicht + +```bash +# Logs prüfen +docker compose logs backend + +# Container neu bauen +docker compose build backend +docker compose up -d backend +``` + +### Datenbank-Verbindungsfehler + +```bash +# Prüfen ob PostgreSQL läuft +docker compose ps postgres + +# PostgreSQL-Logs prüfen +docker compose logs postgres + +# Neustart +docker compose restart postgres +``` + +### Falsche Umgebung aktiv + +```bash +# Status prüfen +./scripts/status.sh + +# Auf richtige Umgebung wechseln +./scripts/env-switch.sh dev +``` + +## Umgebungs-Dateien + +| Datei | Beschreibung | Im Git? | +|-------|--------------|---------| +| `.env` | Aktive Umgebung | Nein | +| `.env.dev` | Development Werte | Ja | +| `.env.staging` | Staging Werte | Ja | +| `.env.prod` | Production Werte | **NEIN** | +| `.env.example` | Template | Ja | + +## Ports Übersicht + +### Development + +| Service | Port | URL | +|---------|------|-----| +| Backend | 8000 | http://localhost:8000 | +| Website | 3000 | http://localhost:3000 | +| Consent Service | 8081 | http://localhost:8081 | +| PostgreSQL | 5432 | localhost:5432 | +| Mailpit UI | 8025 | http://localhost:8025 | +| MinIO Console | 9001 | http://localhost:9001 | + +### Staging + +| Service | Port | URL | +|---------|------|-----| +| Backend | 8001 | http://localhost:8001 | +| PostgreSQL | 5433 | localhost:5433 | +| Mailpit UI | 8026 | http://localhost:8026 | +| MinIO Console | 9003 | http://localhost:9003 | + +## Hilfe + +```bash +# Status und Übersicht +./scripts/status.sh + +# Script-Hilfe +./scripts/env-switch.sh --help +./scripts/promote.sh --help +``` + +## Verwandte Dokumentation + +- [Architektur: Umgebungen](../architecture/environments.md) +- [Secrets Management](../architecture/secrets-management.md) +- [System-Architektur](../architecture/system-architecture.md) diff --git a/docs-src/getting-started/mac-mini-setup.md b/docs-src/getting-started/mac-mini-setup.md new file mode 100644 index 0000000..17fa0ce --- /dev/null +++ b/docs-src/getting-started/mac-mini-setup.md @@ -0,0 +1,109 @@ +# Mac Mini Headless Setup - Vollständig Automatisch + +## Verbindungsdaten + +- **IP (LAN):** 192.168.178.100 +- **User:** benjaminadmin +- **SSH:** `ssh benjaminadmin@192.168.178.100` + +## Nach Neustart - Alles startet automatisch! + +| Service | Auto-Start | Port | +|---------|------------|------| +| SSH | Ja | 22 | +| Docker Desktop | Ja | - | +| Docker Container | Ja (nach ~2 Min) | 8000, 8081, etc. | +| Ollama Server | Ja | 11434 | +| Unity Hub | Ja | - | +| VS Code | Ja | - | + +**Keine Aktion nötig nach Neustart!** Einfach 2-3 Minuten warten. + +## Status prüfen + +```bash +./scripts/mac-mini/status.sh +``` + +## Services & Ports + +| Service | Port | URL | +|---------|------|-----| +| Backend API | 8000 | http://192.168.178.100:8000/admin | +| Consent Service | 8081 | - | +| PostgreSQL | 5432 | - | +| Valkey/Redis | 6379 | - | +| MinIO | 9000/9001 | http://192.168.178.100:9001 | +| Mailpit | 8025 | http://192.168.178.100:8025 | +| Ollama | 11434 | http://192.168.178.100:11434/api/tags | +| Dokumentation | 8008 | http://192.168.178.100:8008 | + +## LLM Modelle + +- **Qwen 2.5 14B** (14.8 Milliarden Parameter) + +## Scripts (auf MacBook) + +```bash +./scripts/mac-mini/status.sh # Status prüfen +./scripts/mac-mini/sync.sh # Code synchronisieren +./scripts/mac-mini/docker.sh # Docker-Befehle +./scripts/mac-mini/backup.sh # Backup erstellen +``` + +## Docker-Befehle + +```bash +./scripts/mac-mini/docker.sh ps # Container anzeigen +./scripts/mac-mini/docker.sh logs backend # Logs +./scripts/mac-mini/docker.sh restart # Neustart +./scripts/mac-mini/docker.sh build # Image bauen +``` + +## LaunchAgents (Auto-Start) + +Pfad auf Mac Mini: `~/Library/LaunchAgents/` + +| Agent | Funktion | +|-------|----------| +| `com.docker.desktop.plist` | Docker Desktop | +| `com.breakpilot.docker-containers.plist` | Container Auto-Start | +| `com.ollama.serve.plist` | Ollama Server | +| `com.unity.hub.plist` | Unity Hub | +| `com.microsoft.vscode.plist` | VS Code | + +## Projekt-Pfade + +- **MacBook:** `/Users/benjaminadmin/Projekte/breakpilot-pwa/` +- **Mac Mini:** `/Users/benjaminadmin/Projekte/breakpilot-pwa/` + +## Troubleshooting + +### Docker Onboarding erscheint wieder + +Docker-Einstellungen sind gesichert in `~/docker-settings-backup/` + +```bash +# Wiederherstellen: +cp -r ~/docker-settings-backup/* ~/Library/Group\ Containers/group.com.docker/ +``` + +### Container starten nicht automatisch + +Log prüfen: + +```bash +ssh benjaminadmin@192.168.178.100 "cat /tmp/docker-autostart.log" +``` + +Manuell starten: + +```bash +./scripts/mac-mini/docker.sh up +``` + +### SSH nicht erreichbar + +- Prüfe ob Mac Mini an ist (Ping: `ping 192.168.178.100`) +- Warte 1-2 Minuten nach Boot +- Prüfe Netzwerkverbindung diff --git a/docs-src/index.md b/docs-src/index.md new file mode 100644 index 0000000..85537c5 --- /dev/null +++ b/docs-src/index.md @@ -0,0 +1,124 @@ +# Breakpilot Dokumentation + +Willkommen zur zentralen Dokumentation des Breakpilot-Projekts. + +## Was ist Breakpilot? + +Breakpilot ist eine DSGVO-konforme Bildungsplattform fuer Lehrkraefte mit folgenden Kernfunktionen: + +- **Consent-Management** - Datenschutzkonforme Einwilligungsverwaltung +- **KI-gestuetzte Klausurkorrektur** - Automatische Bewertungsvorschlaege fuer Abiturklausuren +- **Zeugnisgenerierung** - Workflow-basierte Zeugniserstellung mit Rollenkonzept +- **Lernmaterial-Generator** - MC-Tests, Lueckentexte, Mindmaps, Quiz +- **Elternbriefe** - GFK-basierte Kommunikation mit PDF-Export + +## Schnellstart + +
              + +- :material-rocket-launch:{ .lg .middle } **Erste Schritte** + + --- + + Entwicklungsumgebung einrichten und das Projekt starten. + + [:octicons-arrow-right-24: Umgebung einrichten](getting-started/environment-setup.md) + +- :material-server:{ .lg .middle } **Mac Mini Setup** + + --- + + Headless Server-Konfiguration fuer den Entwicklungsserver. + + [:octicons-arrow-right-24: Mac Mini Setup](getting-started/mac-mini-setup.md) + +
              + +## Architektur + +
              + +- :material-sitemap:{ .lg .middle } **System-Architektur** + + --- + + Ueberblick ueber alle Komponenten und deren Zusammenspiel. + + [:octicons-arrow-right-24: Architektur](architecture/system-architecture.md) + +- :material-shield-lock:{ .lg .middle } **Auth-System** + + --- + + Hybrid-Authentifizierung mit Keycloak und lokalem JWT. + + [:octicons-arrow-right-24: Auth-System](architecture/auth-system.md) + +- :material-robot:{ .lg .middle } **Multi-Agent System** + + --- + + Verteilte Agent-Architektur fuer KI-Funktionen. + + [:octicons-arrow-right-24: Multi-Agent](architecture/multi-agent.md) + +- :material-key-chain:{ .lg .middle } **Secrets Management** + + --- + + HashiCorp Vault Integration fuer sichere Credentials. + + [:octicons-arrow-right-24: Secrets](architecture/secrets-management.md) + +
              + +## Services + +| Service | Port | Beschreibung | +|---------|------|--------------| +| [Backend (Python)](api/backend-api.md) | 8000 | FastAPI Backend mit Panel UI | +| [Consent Service (Go)](architecture/auth-system.md) | 8081 | DSGVO-konforme Einwilligungsverwaltung | +| [Klausur Service](services/klausur-service/index.md) | 8086 | KI-gestuetzte Klausurkorrektur | +| [Agent Core](services/agent-core/index.md) | - | Multi-Agent Infrastructure | +| PostgreSQL | 5432 | Relationale Datenbank | +| Qdrant | 6333 | Vektor-Datenbank fuer RAG | +| MinIO | 9000 | Object Storage | +| Vault | 8200 | Secrets Management | + +## Entwicklung + +- [Testing](development/testing.md) - Test-Standards und Ausfuehrung +- [Dokumentation](development/documentation.md) - Dokumentations-Richtlinien +- [DevSecOps](architecture/devsecops.md) - Security Pipeline +- [Umgebungen](architecture/environments.md) - Dev/Staging/Prod + +## Weitere Ressourcen + +- **GitHub Repository**: Internes GitLab +- **Issue Tracker**: GitLab Issues +- **API Playground**: [http://macmini:8000/docs](http://macmini:8000/docs) + +--- + +## Projektstruktur + +``` +breakpilot-pwa/ +├── backend/ # Python FastAPI Backend +├── consent-service/ # Go Consent Service +├── klausur-service/ # Klausur-Korrektur Service +├── agent-core/ # Multi-Agent Infrastructure +├── voice-service/ # Voice/Audio Processing +├── website/ # Next.js Frontend +├── studio-v2/ # Admin Dashboard (Next.js) +├── docs-src/ # Diese Dokumentation +└── docker-compose.yml # Container-Orchestrierung +``` + +## Support + +Bei Fragen oder Problemen: + +1. Pruefen Sie zuerst die relevante Dokumentation +2. Suchen Sie im Issue Tracker nach aehnlichen Problemen +3. Erstellen Sie ein neues Issue mit detaillierter Beschreibung diff --git a/docs-src/services/agent-core/index.md b/docs-src/services/agent-core/index.md new file mode 100644 index 0000000..1a7ce6d --- /dev/null +++ b/docs-src/services/agent-core/index.md @@ -0,0 +1,420 @@ +# Breakpilot Agent Core + +Multi-Agent Architecture Infrastructure fuer Breakpilot. + +## Uebersicht + +Das `agent-core` Modul stellt die gemeinsame Infrastruktur fuer Breakpilots Multi-Agent-System bereit: + +- **Session Management**: Agent-Sessions mit Checkpoints und Recovery +- **Shared Brain**: Langzeit-Gedaechtnis und Kontext-Verwaltung +- **Orchestration**: Message Bus, Supervisor und Task-Routing + +## Architektur + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Breakpilot Services │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │ +│ │Voice Service│ │Klausur Svc │ │ Admin-v2 / AlertAgent │ │ +│ └──────┬──────┘ └──────┬──────┘ └───────────┬─────────────┘ │ +│ │ │ │ │ +│ └────────────────┼──────────────────────┘ │ +│ │ │ +│ ┌───────────────────────▼───────────────────────────────────┐ │ +│ │ Agent Core │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌───────────────────┐ │ │ +│ │ │ Sessions │ │Shared Brain │ │ Orchestrator │ │ │ +│ │ │ - Manager │ │ - Memory │ │ - Message Bus │ │ │ +│ │ │ - Heartbeat │ │ - Context │ │ - Supervisor │ │ │ +│ │ │ - Checkpoint│ │ - Knowledge │ │ - Task Router │ │ │ +│ │ └─────────────┘ └─────────────┘ └───────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌───────────────────────▼───────────────────────────────────┐ │ +│ │ Infrastructure │ │ +│ │ Valkey (Redis) PostgreSQL Qdrant │ │ +│ └───────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Verzeichnisstruktur + +``` +agent-core/ +├── __init__.py # Modul-Exports +├── README.md # Diese Datei +├── requirements.txt # Python-Abhaengigkeiten +├── pytest.ini # Test-Konfiguration +│ +├── soul/ # Agent SOUL Files (Persoenlichkeiten) +│ ├── tutor-agent.soul.md +│ ├── grader-agent.soul.md +│ ├── quality-judge.soul.md +│ ├── alert-agent.soul.md +│ └── orchestrator.soul.md +│ +├── brain/ # Shared Brain Implementation +│ ├── __init__.py +│ ├── memory_store.py # Langzeit-Gedaechtnis +│ ├── context_manager.py # Konversations-Kontext +│ └── knowledge_graph.py # Entity-Beziehungen +│ +├── sessions/ # Session Management +│ ├── __init__.py +│ ├── session_manager.py # Session-Lifecycle +│ ├── heartbeat.py # Liveness-Monitoring +│ └── checkpoint.py # Recovery-Checkpoints +│ +├── orchestrator/ # Multi-Agent Orchestration +│ ├── __init__.py +│ ├── message_bus.py # Inter-Agent Kommunikation +│ ├── supervisor.py # Agent-Ueberwachung +│ └── task_router.py # Intent-basiertes Routing +│ +└── tests/ # Unit Tests + ├── conftest.py + ├── test_session_manager.py + ├── test_heartbeat.py + ├── test_message_bus.py + ├── test_memory_store.py + └── test_task_router.py +``` + +## Komponenten + +### 1. Session Management + +Verwaltet Agent-Sessions mit State-Machine und Recovery-Faehigkeiten. + +```python +from agent_core.sessions import SessionManager, AgentSession + +# Session Manager erstellen +manager = SessionManager( + redis_client=redis, + db_pool=pg_pool, + namespace="breakpilot" +) + +# Session erstellen +session = await manager.create_session( + agent_type="tutor-agent", + user_id="user-123", + context={"subject": "math"} +) + +# Checkpoint setzen +session.checkpoint("task_started", {"task_id": "abc"}) + +# Session beenden +session.complete({"result": "success"}) +``` + +**Session States:** + +- `ACTIVE` - Session laeuft +- `PAUSED` - Session pausiert +- `COMPLETED` - Session erfolgreich beendet +- `FAILED` - Session fehlgeschlagen + +### 2. Heartbeat Monitoring + +Ueberwacht Agent-Liveness und triggert Recovery bei Timeout. + +```python +from agent_core.sessions import HeartbeatMonitor, HeartbeatClient + +# Monitor starten +monitor = HeartbeatMonitor( + timeout_seconds=30, + check_interval_seconds=5, + max_missed_beats=3 +) +await monitor.start_monitoring() + +# Agent registrieren +monitor.register("agent-1", "tutor-agent") + +# Heartbeat senden +async with HeartbeatClient("agent-1", monitor) as client: + # Agent-Arbeit... + pass +``` + +### 3. Memory Store + +Langzeit-Gedaechtnis fuer Agents mit TTL und Access-Tracking. + +```python +from agent_core.brain import MemoryStore + +store = MemoryStore(redis_client=redis, db_pool=pg_pool) + +# Erinnerung speichern +await store.remember( + key="evaluation:math:student-1", + value={"score": 85, "feedback": "Gut gemacht!"}, + agent_id="grader-agent", + ttl_days=30 +) + +# Erinnerung abrufen +result = await store.recall("evaluation:math:student-1") + +# Nach Pattern suchen +similar = await store.search("evaluation:math:*") +``` + +### 4. Context Manager + +Verwaltet Konversationskontext mit automatischer Komprimierung. + +```python +from agent_core.brain import ContextManager, MessageRole + +ctx_manager = ContextManager(redis_client=redis) + +# Kontext erstellen +context = ctx_manager.create_context( + session_id="session-123", + system_prompt="Du bist ein hilfreicher Tutor...", + max_messages=50 +) + +# Nachrichten hinzufuegen +context.add_message(MessageRole.USER, "Was ist Photosynthese?") +context.add_message(MessageRole.ASSISTANT, "Photosynthese ist...") + +# Fuer LLM API formatieren +messages = context.get_messages_for_llm() +``` + +### 5. Message Bus + +Inter-Agent Kommunikation via Redis Pub/Sub. + +```python +from agent_core.orchestrator import MessageBus, AgentMessage, MessagePriority + +bus = MessageBus(redis_client=redis) +await bus.start() + +# Handler registrieren +async def handle_message(msg): + return {"status": "processed"} + +await bus.subscribe("grader-agent", handle_message) + +# Nachricht senden +await bus.publish(AgentMessage( + sender="orchestrator", + receiver="grader-agent", + message_type="grade_request", + payload={"exam_id": "exam-1"}, + priority=MessagePriority.HIGH +)) + +# Request-Response Pattern +response = await bus.request(message, timeout=30.0) +``` + +### 6. Agent Supervisor + +Ueberwacht und koordiniert alle Agents. + +```python +from agent_core.orchestrator import AgentSupervisor, RestartPolicy + +supervisor = AgentSupervisor(message_bus=bus, heartbeat_monitor=monitor) + +# Agent registrieren +await supervisor.register_agent( + agent_id="tutor-1", + agent_type="tutor-agent", + restart_policy=RestartPolicy.ON_FAILURE, + max_restarts=3, + capacity=10 +) + +# Agent starten +await supervisor.start_agent("tutor-1") + +# Load Balancing +available = supervisor.get_available_agent("tutor-agent") +``` + +### 7. Task Router + +Intent-basiertes Routing mit Fallback-Ketten. + +```python +from agent_core.orchestrator import TaskRouter, RoutingRule, RoutingStrategy + +router = TaskRouter(supervisor=supervisor) + +# Eigene Regel hinzufuegen +router.add_rule(RoutingRule( + intent_pattern="learning_*", + agent_type="tutor-agent", + priority=10, + fallback_agent="orchestrator" +)) + +# Task routen +result = await router.route( + intent="learning_math", + context={"grade": 10}, + strategy=RoutingStrategy.LEAST_LOADED +) + +if result.success: + print(f"Routed to {result.agent_id}") +``` + +## SOUL Files + +SOUL-Dateien definieren die Persoenlichkeit und Verhaltensregeln jedes Agents. + +| Agent | SOUL File | Verantwortlichkeit | +|-------|-----------|-------------------| +| TutorAgent | tutor-agent.soul.md | Lernbegleitung, Fragen beantworten | +| GraderAgent | grader-agent.soul.md | Klausur-Korrektur, Bewertung | +| QualityJudge | quality-judge.soul.md | BQAS Qualitaetspruefung | +| AlertAgent | alert-agent.soul.md | Monitoring, Benachrichtigungen | +| Orchestrator | orchestrator.soul.md | Task-Koordination | + +## Datenbank-Schema + +### agent_sessions + +```sql +CREATE TABLE agent_sessions ( + id UUID PRIMARY KEY, + agent_type VARCHAR(50) NOT NULL, + user_id UUID REFERENCES users(id), + state VARCHAR(20) NOT NULL DEFAULT 'active', + context JSONB DEFAULT '{}', + checkpoints JSONB DEFAULT '[]', + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + last_heartbeat TIMESTAMPTZ DEFAULT NOW() +); +``` + +### agent_memory + +```sql +CREATE TABLE agent_memory ( + id UUID PRIMARY KEY, + namespace VARCHAR(100) NOT NULL, + key VARCHAR(500) NOT NULL, + value JSONB NOT NULL, + agent_id VARCHAR(50) NOT NULL, + access_count INTEGER DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + expires_at TIMESTAMPTZ, + UNIQUE(namespace, key) +); +``` + +### agent_messages + +```sql +CREATE TABLE agent_messages ( + id UUID PRIMARY KEY, + sender VARCHAR(50) NOT NULL, + receiver VARCHAR(50) NOT NULL, + message_type VARCHAR(50) NOT NULL, + payload JSONB NOT NULL, + priority INTEGER DEFAULT 1, + correlation_id UUID, + created_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +## Integration + +### Mit Voice-Service + +```python +from services.enhanced_task_orchestrator import EnhancedTaskOrchestrator + +orchestrator = EnhancedTaskOrchestrator( + redis_client=redis, + db_pool=pg_pool +) + +await orchestrator.start() + +# Session fuer Voice-Interaktion +session = await orchestrator.create_session( + voice_session_id="voice-123", + user_id="teacher-1" +) + +# Task verarbeiten (nutzt Multi-Agent wenn noetig) +await orchestrator.process_task(task) +``` + +### Mit BQAS + +```python +from bqas.quality_judge_agent import QualityJudgeAgent + +judge = QualityJudgeAgent( + message_bus=bus, + memory_store=memory +) + +await judge.start() + +# Direkte Evaluation +result = await judge.evaluate( + response="Der Satz des Pythagoras...", + task_type="learning_math", + context={"user_input": "Was ist Pythagoras?"} +) + +if result["verdict"] == "production_ready": + # Response ist OK + pass +``` + +## Tests + +```bash +# In agent-core Verzeichnis +cd agent-core + +# Alle Tests ausfuehren +pytest -v + +# Mit Coverage +pytest --cov=. --cov-report=html + +# Einzelnes Test-Modul +pytest tests/test_session_manager.py -v + +# Async-Tests +pytest tests/test_message_bus.py -v +``` + +## Metriken + +Das Agent-Core exportiert folgende Metriken: + +| Metrik | Beschreibung | +|--------|--------------| +| `agent_session_duration_seconds` | Dauer von Agent-Sessions | +| `agent_heartbeat_delay_seconds` | Zeit seit letztem Heartbeat | +| `agent_message_latency_ms` | Latenz der Inter-Agent Kommunikation | +| `agent_memory_access_total` | Memory-Zugriffe pro Agent | +| `agent_error_total` | Fehler pro Agent-Typ | + +## Naechste Schritte + +1. **Migration ausfuehren**: `psql -f backend/migrations/add_agent_core_tables.sql` +2. **Voice-Service erweitern**: Enhanced Orchestrator aktivieren +3. **BQAS integrieren**: Quality Judge Agent starten +4. **Monitoring aufsetzen**: Metriken in Grafana integrieren diff --git a/docs-src/services/ai-compliance-sdk/ARCHITECTURE.md b/docs-src/services/ai-compliance-sdk/ARCHITECTURE.md new file mode 100644 index 0000000..a632737 --- /dev/null +++ b/docs-src/services/ai-compliance-sdk/ARCHITECTURE.md @@ -0,0 +1,947 @@ +# UCCA - Use-Case Compliance & Feasibility Advisor + +## Systemarchitektur + +### 1. Übersicht + +Das UCCA-System ist ein **deterministisches Compliance-Bewertungssystem** für KI-Anwendungsfälle. Es kombiniert regelbasierte Evaluation mit optionaler LLM-Erklärung und semantischer Rechtstextsuche. + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ UCCA System │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Frontend │───>│ SDK API │───>│ PostgreSQL │ │ +│ │ (Next.js) │ │ (Go) │ │ Database │ │ +│ └──────────────┘ └──────┬───────┘ └──────────────┘ │ +│ │ │ +│ ┌────────────────────┼────────────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Policy │ │ Escalation │ │ Legal RAG │ │ +│ │ Engine │ │ Workflow │ │ (Qdrant) │ │ +│ │ (45 Regeln) │ │ (E0-E3) │ │ 2,274 Chunks │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ │ │ │ +│ └────────────────────┴────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────┐ │ +│ │ LLM Provider │ │ +│ │ (Ollama/API) │ │ +│ └──────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### 2. Kernprinzip + +> **"LLM ist NICHT die Quelle der Wahrheit. Wahrheit = Regeln + Evidenz. LLM = Übersetzer + Subsumptionshelfer"** + +Das System folgt einem strikten **Human-in-the-Loop** Ansatz: + +1. **Deterministische Regeln** treffen alle Compliance-Entscheidungen +2. **LLM** erklärt nur Ergebnisse, überschreibt nie BLOCK-Entscheidungen +3. **Menschen** (DSB, Legal) treffen finale Entscheidungen bei kritischen Fällen + +--- + +## 3. Komponenten + +### 3.1 Policy Engine (`internal/ucca/rules.go`) + +Die Policy Engine evaluiert Use-Cases gegen ~45 deterministische Regeln. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Policy Engine │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ UseCaseIntake ──────────────────────────────────────────────> │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Regelkategorien (A-J) │ │ +│ ├─────────────────────────────────────────────────────────────┤ │ +│ │ A. Datenklassifikation │ R-001 bis R-006 │ 6 Regeln │ │ +│ │ B. Zweck & Kontext │ R-010 bis R-013 │ 4 Regeln │ │ +│ │ C. Automatisierung │ R-020 bis R-025 │ 6 Regeln │ │ +│ │ D. Training vs Nutzung │ R-030 bis R-035 │ 6 Regeln │ │ +│ │ E. Speicherung │ R-040 bis R-042 │ 3 Regeln │ │ +│ │ F. Hosting │ R-050 bis R-052 │ 3 Regeln │ │ +│ │ G. Transparenz │ R-060 bis R-062 │ 3 Regeln │ │ +│ │ H. Domain-spezifisch │ R-070 bis R-074 │ 5 Regeln │ │ +│ │ I. Aggregation │ R-090 bis R-092 │ 3 Regeln │ │ +│ │ J. Erklärung │ R-100 │ 1 Regel │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ AssessmentResult │ +│ ├── feasibility: YES | CONDITIONAL | NO │ +│ ├── risk_score: 0-100 │ +│ ├── risk_level: MINIMAL | LOW | MEDIUM | HIGH | CRITICAL │ +│ ├── triggered_rules: []TriggeredRule │ +│ ├── required_controls: []RequiredControl │ +│ ├── recommended_architecture: []PatternRecommendation │ +│ └── forbidden_patterns: []ForbiddenPattern │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Regel-Severities:** +- `INFO`: Informativ, kein Risiko-Impact +- `WARN`: Warnung, erhöht Risk Score +- `BLOCK`: Kritisch, führt zu `feasibility=NO` + +### 3.2 Escalation Workflow (`internal/ucca/escalation_*.go`) + +Das Eskalationssystem routet kritische Assessments zur menschlichen Prüfung. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Escalation Workflow │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ AssessmentResult ─────────────────────────────────────────────> │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Escalation Level Determination │ │ +│ ├─────────────────────────────────────────────────────────────┤ │ +│ │ │ │ +│ │ E0: Nur INFO-Regeln, Risk < 20 │ │ +│ │ → Auto-Approve, keine menschliche Prüfung │ │ +│ │ │ │ +│ │ E1: WARN-Regeln, Risk 20-39 │ │ +│ │ → Team-Lead Review (SLA: 24h) │ │ +│ │ │ │ +│ │ E2: Art.9 Daten ODER Risk 40-59 ODER DSFA empfohlen │ │ +│ │ → DSB Consultation (SLA: 8h) │ │ +│ │ │ │ +│ │ E3: BLOCK-Regel ODER Risk ≥60 ODER Art.22 Risiko │ │ +│ │ → DSB + Legal Review (SLA: 4h) │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ DSB Pool Assignment │ │ +│ ├─────────────────────────────────────────────────────────────┤ │ +│ │ Role │ Level │ Max Concurrent │ Auto-Assign │ │ +│ │ ──────────────┼───────┼────────────────┼────────────────── │ │ +│ │ team_lead │ E1 │ 10 │ Round-Robin │ │ +│ │ dsb │ E2,E3 │ 5 │ Workload-Based │ │ +│ │ legal │ E3 │ 3 │ Workload-Based │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ Escalation Status Flow: │ +│ │ +│ pending → assigned → in_review → approved/rejected/returned │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 3.3 Legal RAG (`internal/llm/legal_rag.go`) + +Semantische Suche in 19 EU-Regulierungen für kontextbasierte Erklärungen. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Legal RAG System │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Explain Request ──────────────────────────────────────────────> │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Qdrant Vector DB │ │ +│ │ Collection: bp_legal_corpus │ │ +│ │ 2,274 Chunks, 1024-dim BGE-M3 │ │ +│ ├─────────────────────────────────────────────────────────────┤ │ +│ │ │ │ +│ │ EU-Verordnungen: │ │ +│ │ ├── DSGVO (128) ├── AI Act (96) ├── NIS2 (128) │ │ +│ │ ├── CRA (256) ├── Data Act (256) ├── DSA (256) │ │ +│ │ ├── DGA (32) ├── EUCSA (32) ├── DPF (714) │ │ +│ │ └── ... │ │ +│ │ │ │ +│ │ Deutsche Gesetze: │ │ +│ │ ├── TDDDG (1) ├── SCC (32) ├── ... │ │ +│ │ │ │ +│ │ BSI-Standards: │ │ +│ │ ├── TR-03161-1 (6) ├── TR-03161-2 (6) ├── TR-03161-3 │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ │ Hybrid Search (Dense + Sparse) │ +│ │ Re-Ranking (Cross-Encoder) │ +│ ▼ │ +│ Top-K Relevant Passages ─────────────────────────────────────> │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ LLM Explanation │ │ +│ │ Provider: Ollama (local) / Anthropic (fallback) │ │ +│ │ Prompt: Assessment + Legal Context → Erklärung │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 4. Datenfluss + +### 4.1 Assessment-Erstellung + +``` +User Input (Frontend) + │ + ▼ +POST /sdk/v1/ucca/assess + │ + ├──────────────────────────────────────────┐ + │ │ + ▼ ▼ +┌──────────────┐ ┌──────────────┐ +│ Policy │ │ Escalation │ +│ Engine │ │ Trigger │ +│ Evaluation │ │ Check │ +└──────┬───────┘ └──────┬───────┘ + │ │ + │ AssessmentResult │ EscalationLevel + │ │ + ▼ ▼ +┌──────────────────────────────────────────────────────┐ +│ PostgreSQL │ +│ ├── ucca_assessments (Assessment + Result) │ +│ └── ucca_escalations (wenn Level > E0) │ +└──────────────────────────────────────────────────────┘ + │ + │ If Level > E0 + ▼ +┌──────────────┐ +│ DSB Pool │ +│ Auto-Assign │ +└──────────────┘ + │ + ▼ +Notification (E-Mail/Webhook) +``` + +### 4.2 Erklärung mit Legal RAG + +``` +POST /sdk/v1/ucca/assessments/:id/explain + │ + ▼ +┌──────────────┐ +│ Load │ +│ Assessment │ +└──────┬───────┘ + │ + ▼ +┌──────────────┐ Query Vector ┌──────────────┐ +│ Extract │ ──────────────────>│ Qdrant │ +│ Keywords │ │ bp_legal_ │ +│ from Rules │<───────────────────│ corpus │ +└──────┬───────┘ Top-K Docs └──────────────┘ + │ + │ Assessment + Legal Context + ▼ +┌──────────────┐ +│ LLM │ +│ Provider │ +│ Registry │ +└──────┬───────┘ + │ + ▼ +Explanation (DE) + Legal References +``` + +--- + +## 5. Entscheidungsdiagramm + +### 5.1 Feasibility-Entscheidung + +``` + UseCaseIntake + │ + ▼ + ┌─────────────────────┐ + │ Hat BLOCK-Regeln? │ + └──────────┬──────────┘ + │ │ + Ja Nein + │ │ + ▼ ▼ + ┌───────────┐ ┌─────────────────────┐ + │ NO │ │ Hat WARN-Regeln? │ + │ (blocked) │ └──────────┬──────────┘ + └───────────┘ │ │ + Ja Nein + │ │ + ▼ ▼ + ┌───────────┐ ┌───────────┐ + │CONDITIONAL│ │ YES │ + │(mit │ │(grünes │ + │Auflagen) │ │Licht) │ + └───────────┘ └───────────┘ +``` + +### 5.2 Escalation-Level-Entscheidung + +``` + AssessmentResult + │ + ▼ + ┌─────────────────────┐ + │ BLOCK-Regel oder │ + │ Art.22 Risiko? │ + └──────────┬──────────┘ + │ │ + Ja Nein + │ │ + ▼ │ + ┌───────────┐ │ + │ E3 │ │ + │ DSB+Legal │ │ + └───────────┘ ▼ + ┌─────────────────────┐ + │ Risk ≥40 oder │ + │ Art.9 Daten oder │ + │ DSFA empfohlen? │ + └──────────┬──────────┘ + │ │ + Ja Nein + │ │ + ▼ │ + ┌───────────┐ │ + │ E2 │ │ + │ DSB │ │ + └───────────┘ ▼ + ┌─────────────────────┐ + │ Risk ≥20 oder │ + │ WARN-Regeln? │ + └──────────┬──────────┘ + │ │ + Ja Nein + │ │ + ▼ ▼ + ┌───────────┐ ┌───────────┐ + │ E1 │ │ E0 │ + │ Team-Lead │ │ Auto-OK │ + └───────────┘ └───────────┘ +``` + +--- + +## 6. Datenbank-Schema + +### 6.1 ucca_assessments + +```sql +CREATE TABLE ucca_assessments ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + namespace_id UUID, + title VARCHAR(500), + policy_version VARCHAR(50) NOT NULL, + status VARCHAR(50) DEFAULT 'completed', + + -- Input + intake JSONB NOT NULL, + use_case_text_stored BOOLEAN DEFAULT FALSE, + use_case_text_hash VARCHAR(64), + domain VARCHAR(50), + + -- Result + feasibility VARCHAR(20) NOT NULL, + risk_level VARCHAR(20) NOT NULL, + risk_score INT NOT NULL DEFAULT 0, + triggered_rules JSONB DEFAULT '[]', + required_controls JSONB DEFAULT '[]', + recommended_architecture JSONB DEFAULT '[]', + forbidden_patterns JSONB DEFAULT '[]', + example_matches JSONB DEFAULT '[]', + + -- Flags + dsfa_recommended BOOLEAN DEFAULT FALSE, + art22_risk BOOLEAN DEFAULT FALSE, + training_allowed VARCHAR(50), + + -- Explanation + explanation_text TEXT, + explanation_generated_at TIMESTAMPTZ, + explanation_model VARCHAR(100), + + -- Audit + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by UUID NOT NULL +); +``` + +### 6.2 ucca_escalations + +```sql +CREATE TABLE ucca_escalations ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + assessment_id UUID NOT NULL REFERENCES ucca_assessments(id), + + -- Level & Status + escalation_level VARCHAR(10) NOT NULL, + escalation_reason TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'pending', + + -- Assignment + assigned_to UUID, + assigned_role VARCHAR(50), + assigned_at TIMESTAMPTZ, + + -- Review + reviewer_id UUID, + reviewer_notes TEXT, + reviewed_at TIMESTAMPTZ, + + -- Decision + decision VARCHAR(50), + decision_notes TEXT, + decision_at TIMESTAMPTZ, + conditions JSONB DEFAULT '[]', + + -- SLA + due_date TIMESTAMPTZ, + notification_sent BOOLEAN DEFAULT FALSE, + notification_sent_at TIMESTAMPTZ, + + -- Audit + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +### 6.3 ucca_dsb_pool + +```sql +CREATE TABLE ucca_dsb_pool ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + user_id UUID NOT NULL, + user_name VARCHAR(255) NOT NULL, + user_email VARCHAR(255) NOT NULL, + role VARCHAR(50) NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + max_concurrent_reviews INT DEFAULT 10, + current_reviews INT DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW() +); +``` + +--- + +## 7. API-Endpunkte + +### 7.1 Assessment + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| POST | `/sdk/v1/ucca/assess` | Assessment erstellen | +| GET | `/sdk/v1/ucca/assessments` | Assessments auflisten | +| GET | `/sdk/v1/ucca/assessments/:id` | Assessment abrufen | +| DELETE | `/sdk/v1/ucca/assessments/:id` | Assessment löschen | +| POST | `/sdk/v1/ucca/assessments/:id/explain` | LLM-Erklärung generieren | +| GET | `/sdk/v1/ucca/export/:id` | Assessment exportieren | + +### 7.2 Kataloge + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| GET | `/sdk/v1/ucca/patterns` | Architektur-Patterns | +| GET | `/sdk/v1/ucca/examples` | Didaktische Beispiele | +| GET | `/sdk/v1/ucca/rules` | Alle Regeln | +| GET | `/sdk/v1/ucca/controls` | Required Controls | +| GET | `/sdk/v1/ucca/problem-solutions` | Problem-Lösungen | + +### 7.3 Eskalation + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| GET | `/sdk/v1/ucca/escalations` | Eskalationen auflisten | +| GET | `/sdk/v1/ucca/escalations/:id` | Eskalation abrufen | +| POST | `/sdk/v1/ucca/escalations` | Manuelle Eskalation | +| POST | `/sdk/v1/ucca/escalations/:id/assign` | Zuweisen | +| POST | `/sdk/v1/ucca/escalations/:id/review` | Review starten | +| POST | `/sdk/v1/ucca/escalations/:id/decide` | Entscheidung treffen | +| GET | `/sdk/v1/ucca/escalations/stats` | Statistiken | + +### 7.4 DSB Pool + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| GET | `/sdk/v1/ucca/dsb-pool` | Pool-Mitglieder auflisten | +| POST | `/sdk/v1/ucca/dsb-pool` | Mitglied hinzufügen | + +--- + +## 8. Sicherheit + +### 8.1 Authentifizierung + +- JWT-basierte Authentifizierung +- Header: `X-User-ID`, `X-Tenant-ID` +- Multi-Tenant-Isolation + +### 8.2 Autorisierung + +- RBAC (Role-Based Access Control) +- Permissions: `ucca:assess`, `ucca:review`, `ucca:admin` +- Namespace-Level Isolation + +### 8.3 Datenschutz + +- Use-Case-Text optional (Opt-in) +- SHA-256 Hash statt Klartext +- Audit-Trail für alle Operationen +- Legal RAG: `training_allowed: false` + +--- + +## 9. Deployment + +### 9.1 Container + +```yaml +ai-compliance-sdk: + build: ./ai-compliance-sdk + ports: + - "8090:8090" + environment: + - DATABASE_URL=postgres://... + - OLLAMA_URL=http://ollama:11434 + - QDRANT_URL=http://qdrant:6333 + depends_on: + - postgres + - qdrant +``` + +### 9.2 Abhängigkeiten + +- PostgreSQL 15+ +- Qdrant 1.12+ +- Embedding Service (BGE-M3) +- Ollama (optional, für LLM) + +--- + +## 10. Monitoring + +### 10.1 Health Check + +``` +GET /sdk/v1/health +→ {"status": "ok"} +``` + +### 10.2 Metriken + +- Assessment-Durchsatz +- Escalation-SLA-Compliance +- LLM-Latenz +- RAG-Trefferqualität + +--- + +## 11. Wizard & Legal Assistant + +### 11.1 Wizard-Architektur + +Der UCCA-Wizard führt Benutzer durch 9 Schritte zur Erfassung aller relevanten Compliance-Fakten. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ UCCA Wizard v1.1 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Step 1: Grundlegende Informationen │ +│ Step 2: Datenarten (Personal Data, Art. 9, etc.) │ +│ Step 3: Verarbeitungszweck (Profiling, Scoring) │ +│ Step 4: Hosting & Provider │ +│ Step 5: Internationaler Datentransfer (SCC, TIA) │ +│ Step 6: KI-Modell und Training │ +│ Step 7: Verträge & Compliance (AVV, DSFA) │ +│ Step 8: Automatisierung & Human Oversight │ +│ Step 9: Standards & Normen (für Maschinenbauer) ← NEU │ +│ │ +│ Features: │ +│ ├── Adaptive Subflows (visible_if Conditions) │ +│ ├── Simple/Expert Mode Toggle │ +│ ├── Legal Assistant Chat pro Step │ +│ └── simple_explanation für Nicht-Juristen │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 11.2 Legal Assistant (Wizard Chat) + +Integrierter Rechtsassistent für Echtzeit-Hilfe bei Wizard-Fragen. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Legal Assistant Flow │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ User Question ─────────────────────────────────────────────────>│ +│ │ │ +│ ▼ │ +│ ┌──────────────────┐ │ +│ │ Build RAG Query │ │ +│ │ + Step Context │ │ +│ └────────┬─────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────┐ Search ┌──────────────────┐ │ +│ │ Legal RAG │ ────────────>│ Qdrant │ │ +│ │ Client │ │ bp_legal_corpus │ │ +│ │ │<────────────│ + SCC Corpus │ │ +│ └────────┬─────────┘ Top-5 └──────────────────┘ │ +│ │ │ +│ │ Question + Legal Context │ +│ ▼ │ +│ ┌──────────────────┐ │ +│ │ Internal 32B LLM │ │ +│ │ (Ollama) │ │ +│ │ temp=0.3 │ │ +│ └────────┬─────────┘ │ +│ │ │ +│ ▼ │ +│ Answer + Sources + Related Fields │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**API-Endpunkte:** + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| GET | `/sdk/v1/ucca/wizard/schema` | Wizard-Schema abrufen | +| POST | `/sdk/v1/ucca/wizard/ask` | Frage an Legal Assistant | + +--- + +## 12. License Policy Engine (Standards Compliance) + +### 12.1 Übersicht + +Die License Policy Engine verwaltet die Lizenz-/Urheberrechts-Compliance für Standards und Normen (DIN, ISO, VDI, etc.). + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ License Policy Engine │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ LicensedContentFacts ─────────────────────────────────────────>│ +│ │ │ +│ │ ├── present: bool │ +│ │ ├── publisher: DIN_MEDIA | VDI | ISO | ... │ +│ │ ├── license_type: SINGLE | NETWORK | ENTERPRISE | AI │ +│ │ ├── ai_use_permitted: YES | NO | UNKNOWN │ +│ │ ├── operation_mode: LINK | NOTES | FULLTEXT | TRAINING │ +│ │ └── proof_uploaded: bool │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ Operation Mode Evaluation ││ +│ ├─────────────────────────────────────────────────────────────┤│ +│ │ ││ +│ │ LINK_ONLY ──────────── Always Allowed ───────────> OK ││ +│ │ NOTES_ONLY ─────────── Usually Allowed ──────────> OK ││ +│ │ FULLTEXT_RAG ────┬──── ai_use=YES + proof ───────> OK ││ +│ │ └──── else ─────────────────────> BLOCK ││ +│ │ TRAINING ────────┬──── AI_LICENSE + proof ───────> OK ││ +│ │ └──── else ─────────────────────> BLOCK ││ +│ │ ││ +│ └─────────────────────────────────────────────────────────────┘│ +│ │ │ +│ ▼ │ +│ LicensePolicyResult │ +│ ├── allowed: bool │ +│ ├── effective_mode: string (may be downgraded) │ +│ ├── gaps: []LicenseGap │ +│ ├── required_controls: []LicenseControl │ +│ ├── stop_line: *StopLine (if hard blocked) │ +│ └── output_restrictions: *OutputRestrictions │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 12.2 Betriebs-Modi (Operation Modes) + +| Modus | Beschreibung | Lizenz-Anforderung | Ingest | Output | +|-------|--------------|-------------------|--------|--------| +| **LINK_ONLY** | Nur Verweise & Checklisten | Keine | Metadata only | Keine Zitate | +| **NOTES_ONLY** | Kundeneigene Zusammenfassungen | Standard | Notes only | Paraphrasen | +| **EXCERPT_ONLY** | Kurze Zitate (Zitatrecht) | Standard + Zitatrecht | Notes | Max 150 Zeichen | +| **FULLTEXT_RAG** | Volltext indexiert | AI-Lizenz + Proof | Fulltext | Max 500 Zeichen | +| **TRAINING** | Modell-Training | AI-Training-Lizenz | Fulltext | N/A | + +### 12.3 Publisher-spezifische Regeln + +**DIN Media (ehem. Beuth):** +- AI-Nutzung aktuell verboten (ohne explizite Genehmigung) +- AI-Lizenzmodell geplant ab Q4/2025 +- Crawler/Scraper verboten (AGB) +- TDM-Vorbehalt nach §44b UrhG + +### 12.4 Stop-Lines (Hard Deny) + +``` +STOP_DIN_FULLTEXT_AI_NOT_ALLOWED + WENN: publisher=DIN_MEDIA AND operation_mode in [FULLTEXT_RAG, TRAINING] + AND ai_use_permitted in [NO, UNKNOWN] + DANN: BLOCKIERT + FALLBACK: LINK_ONLY + +STOP_TRAINING_WITHOUT_PROOF + WENN: operation_mode=TRAINING AND proof_uploaded=false + DANN: BLOCKIERT +``` + +--- + +## 13. SCC & Transfer Impact Assessment + +### 13.1 Drittlandtransfer-Bewertung + +Das System unterstützt die vollständige Bewertung internationaler Datentransfers. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ SCC/Transfer Assessment Flow │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ hosting.region ─────────────────────────────────────────────── │ +│ │ │ +│ ├── EU/EWR ────────────────────────────────> OK (no SCC) │ +│ │ │ +│ ├── Adequacy Country (UK, CH, JP) ─────────> OK (no SCC) │ +│ │ │ +│ └── Third Country (US, etc.) ──────────────────────────── │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐│ +│ │ USA: DPF-Zertifizierung prüfen ││ +│ │ ├── Zertifiziert ───> OK (SCC empfohlen als Backup) ││ +│ │ └── Nicht zertifiziert ───> SCC + TIA erforderlich ││ +│ └─────────────────────────────────────────────────────────┘│ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐│ +│ │ Transfer Impact Assessment (TIA) ││ +│ │ ├── Adequate ─────────────> Transfer OK ││ +│ │ ├── Adequate + Measures ──> + Technical Supplementary ││ +│ │ ├── Inadequate ───────────> Fix required ││ +│ │ └── Not Feasible ─────────> Transfer NOT allowed ││ +│ └─────────────────────────────────────────────────────────┘│ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 13.2 SCC-Versionen + +- Neue SCC (EU 2021/914) - **erforderlich** seit 27.12.2022 +- Alte SCC (vor 2021) - **nicht mehr gültig** + +--- + +## 14. Controls Catalog + +### 14.1 Übersicht + +Der Controls Catalog enthält ~30 Maßnahmenbausteine mit detaillierten Handlungsanweisungen. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Controls Catalog v1.0 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Kategorien: │ +│ ├── DSGVO (Rechtsgrundlagen, Betroffenenrechte, Dokumentation) │ +│ ├── AI_Act (Transparenz, HITL, Risikoeinstufung) │ +│ ├── Technical (Verschlüsselung, Anonymisierung, PII-Gateway) │ +│ └── Contractual (AVV, SCC, TIA) │ +│ │ +│ Struktur pro Control: │ +│ ├── id: CTRL-xxx │ +│ ├── title: Kurztitel │ +│ ├── when_applicable: Wann erforderlich? │ +│ ├── what_to_do: Konkrete Handlungsschritte │ +│ ├── evidence_needed: Erforderliche Nachweise │ +│ ├── effort: low | medium | high │ +│ └── gdpr_ref: Rechtsgrundlage │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 14.2 Beispiel-Controls + +| ID | Titel | Kategorie | +|----|-------|-----------| +| CTRL-CONSENT-EXPLICIT | Ausdrückliche Einwilligung | DSGVO | +| CTRL-AI-TRANSPARENCY | KI-Transparenz-Hinweis | AI_Act | +| CTRL-DSFA | Datenschutz-Folgenabschätzung | DSGVO | +| CTRL-SCC | Standardvertragsklauseln | Contractual | +| CTRL-TIA | Transfer Impact Assessment | Contractual | +| CTRL-LICENSE-PROOF | Lizenz-/Rechte-Nachweis | License | +| CTRL-LINK-ONLY-MODE | Evidence Navigator | License | +| CTRL-PII-GATEWAY | PII-Redaction Gateway | Technical | + +--- + +## 15. Policy-Dateien + +### 15.1 Dateistruktur + +``` +policies/ +├── ucca_policy_v1.yaml # Haupt-Policy (Regeln, Controls) +├── controls_catalog.yaml # Detaillierter Maßnahmenkatalog +├── gap_mapping.yaml # Facts → Gaps → Controls +├── wizard_schema_v1.yaml # Wizard-Fragen (9 Steps) +├── scc_legal_corpus.yaml # SCC/Transfer Rechtstexte +└── licensed_content_policy.yaml # Normen-Lizenz-Compliance (NEU) +``` + +### 15.2 Versions-Management + +- Jedes Assessment speichert die `policy_version` +- Regeländerungen erzeugen neue Version +- Audit-Trail zeigt welche Policy-Version verwendet wurde + +--- + +## 16. Generic Obligations Framework + +### 16.1 Übersicht + +Das Generic Obligations Framework ermöglicht die automatische Ableitung regulatorischer Pflichten aus mehreren Verordnungen basierend auf Unternehmensfakten. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Generic Obligations Framework │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ UnifiedFacts ───────────────────────────────────────────────── │ +│ │ │ +│ │ ├── organization: EmployeeCount, Revenue, Country │ +│ │ ├── sector: PrimarySector, IsKRITIS, SpecialServices │ +│ │ ├── data_protection: ProcessesPersonalData │ +│ │ └── ai_usage: UsesAI, HighRiskCategories, IsGPAI │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ Obligations Registry ││ +│ │ (Module Registration & Evaluation) ││ +│ └──────────────────────────┬──────────────────────────────────┘│ +│ │ │ +│ ┌───────────────────┼───────────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ +│ │ NIS2 │ │ DSGVO │ │ AI Act │ │ +│ │ Module │ │ Module │ │ Module │ │ +│ └─────┬─────┘ └─────┬─────┘ └─────┬─────┘ │ +│ │ │ │ │ +│ └───────────────────┴───────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ ManagementObligationsOverview ││ +│ │ ├── ApplicableRegulations[] ││ +│ │ ├── Obligations[] (sortiert nach Priorität) ││ +│ │ ├── RequiredControls[] ││ +│ │ ├── IncidentDeadlines[] ││ +│ │ ├── SanctionsSummary ││ +│ │ └── ExecutiveSummary ││ +│ └─────────────────────────────────────────────────────────────┘│ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 16.2 Regulation Modules + +Jede Regulierung wird als eigenständiges Modul implementiert: + +**Implementierte Module:** + +| Modul | ID | Datei | Pflichten | Kontrollen | +|-------|-----|-------|-----------|------------| +| NIS2 | `nis2` | `nis2_module.go` | ~15 | ~8 | +| DSGVO | `dsgvo` | `dsgvo_module.go` | ~12 | ~6 | +| AI Act | `ai_act` | `ai_act_module.go` | ~15 | ~6 | + +--- + +## 17. Obligations API-Endpunkte + +### 17.1 Assessment + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| POST | `/sdk/v1/ucca/obligations/assess` | Pflichten-Assessment erstellen | +| GET | `/sdk/v1/ucca/obligations/:id` | Assessment abrufen | +| GET | `/sdk/v1/ucca/obligations` | Assessments auflisten | + +### 17.2 Export + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| POST | `/sdk/v1/ucca/obligations/export/memo` | Memo exportieren (gespeichert) | +| POST | `/sdk/v1/ucca/obligations/export/direct` | Direkt-Export ohne Speicherung | + +### 17.3 Regulations + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| GET | `/sdk/v1/ucca/regulations` | Liste aller Regulierungsmodule | +| GET | `/sdk/v1/ucca/regulations/:id/decision-tree` | Decision Tree für Regulierung | + +--- + +## 18. Dateien des Obligations Framework + +### 18.1 Backend (Go) + +``` +internal/ucca/ +├── obligations_framework.go # Interfaces, Typen, Konstanten +├── obligations_registry.go # Modul-Registry, EvaluateAll() +├── nis2_module.go # NIS2 Decision Tree + Pflichten +├── nis2_module_test.go # NIS2 Tests +├── dsgvo_module.go # DSGVO Pflichten +├── dsgvo_module_test.go # DSGVO Tests +├── ai_act_module.go # AI Act Risk Classification +├── ai_act_module_test.go # AI Act Tests +├── pdf_export.go # PDF/Markdown Export +└── pdf_export_test.go # Export Tests +``` + +### 18.2 Policy-Dateien (YAML) + +``` +policies/obligations/ +├── nis2_obligations.yaml # ~15 NIS2-Pflichten +├── dsgvo_obligations.yaml # ~12 DSGVO-Pflichten +└── ai_act_obligations.yaml # ~15 AI Act-Pflichten +``` + +--- + +*Dokumentation erstellt: 2026-01-29* +*Version: 2.1.0* diff --git a/docs-src/services/ai-compliance-sdk/AUDITOR_DOCUMENTATION.md b/docs-src/services/ai-compliance-sdk/AUDITOR_DOCUMENTATION.md new file mode 100644 index 0000000..89ebe7b --- /dev/null +++ b/docs-src/services/ai-compliance-sdk/AUDITOR_DOCUMENTATION.md @@ -0,0 +1,387 @@ +# UCCA - Dokumentation für externe Auditoren + +## Systemdokumentation nach Art. 30 DSGVO + +**Verantwortlicher:** [Name des Unternehmens] +**Datenschutzbeauftragter:** [Kontakt] +**Dokumentationsstand:** 2026-01-29 +**Version:** 1.0.0 + +--- + +## 1. Zweck und Funktionsweise des Systems + +### 1.1 Systembezeichnung + +**UCCA - Use-Case Compliance & Feasibility Advisor** + +### 1.2 Zweckbeschreibung + +Das UCCA-System ist ein **Compliance-Prüfwerkzeug**, das Organisationen bei der Bewertung geplanter KI-Anwendungsfälle hinsichtlich ihrer datenschutzrechtlichen Zulässigkeit unterstützt. + +**Kernfunktionen:** +- Automatisierte Vorprüfung von KI-Anwendungsfällen gegen EU-Regulierungen +- Identifikation erforderlicher technischer und organisatorischer Maßnahmen +- Eskalation kritischer Fälle zur menschlichen Prüfung +- Dokumentation und Nachvollziehbarkeit aller Prüfentscheidungen + +### 1.3 Rechtsgrundlage + +Die Verarbeitung erfolgt auf Basis von: +- **Art. 6 Abs. 1 lit. c DSGVO** - Erfüllung rechtlicher Verpflichtungen +- **Art. 6 Abs. 1 lit. f DSGVO** - Berechtigte Interessen (Compliance-Management) + +--- + +## 2. Verarbeitete Datenkategorien + +### 2.1 Eingabedaten (Use-Case-Beschreibungen) + +| Datenkategorie | Beschreibung | Speicherung | +|----------------|--------------|-------------| +| Use-Case-Text | Freitextbeschreibung des geplanten Anwendungsfalls | Optional (Opt-in), ansonsten nur Hash | +| Domain | Branchenkategorie (z.B. "education", "healthcare") | Ja | +| Datentyp-Flags | Angaben zu verarbeiteten Datenarten | Ja | +| Automatisierungsgrad | assistiv/teil-/vollautomatisch | Ja | +| Hosting-Informationen | Region, Provider | Ja | + +**Wichtig:** Der System speichert standardmäßig **keine Freitexte**, sondern nur: +- SHA-256 Hash des Textes (zur Deduplizierung) +- Strukturierte Metadaten (Checkboxen, Dropdowns) + +### 2.2 Bewertungsergebnisse + +| Datenkategorie | Beschreibung | Aufbewahrung | +|----------------|--------------|--------------| +| Risk Score | Numerischer Wert 0-100 | Dauerhaft | +| Triggered Rules | Ausgelöste Compliance-Regeln | Dauerhaft | +| Required Controls | Empfohlene Maßnahmen | Dauerhaft | +| Explanation | KI-generierte Erklärung | Dauerhaft | + +### 2.3 Audit-Trail-Daten + +| Datenkategorie | Beschreibung | Aufbewahrung | +|----------------|--------------|--------------| +| Benutzer-ID | UUID des ausführenden Benutzers | 10 Jahre | +| Timestamp | Zeitpunkt der Aktion | 10 Jahre | +| Aktionstyp | created/reviewed/decided | 10 Jahre | +| Entscheidungsnotizen | Begründungen bei Eskalationen | 10 Jahre | + +--- + +## 3. Entscheidungslogik und Automatisierung + +### 3.1 Regelbasierte Bewertung (Deterministische Logik) + +Das System verwendet **ausschließlich deterministische Regeln** für Compliance-Entscheidungen. Diese Regeln sind: + +1. **Transparent** - Alle Regeln sind im Quellcode einsehbar +2. **Nachvollziehbar** - Jede ausgelöste Regel wird dokumentiert +3. **Überprüfbar** - Regellogik basiert auf konkreten DSGVO-Artikeln + +**Beispiel-Regel R-F001:** +``` +WENN: + - Domain = "education" UND + - Automation = "fully_automated" UND + - Output enthält "rankings_or_scores" +DANN: + - Severity = BLOCK + - DSGVO-Referenz = Art. 22 Abs. 1 + - Begründung = "Vollautomatisierte Bewertung von Schülern ohne menschliche Überprüfung" +``` + +### 3.2 Keine autonomen KI-Entscheidungen + +**Das System trifft KEINE autonomen KI-Entscheidungen bezüglich:** +- Zulässigkeit eines Anwendungsfalls (immer regelbasiert) +- Freigabe oder Ablehnung (immer durch Mensch) +- Rechtliche Bewertungen (immer durch DSB/Legal) + +**KI wird ausschließlich verwendet für:** +- Erklärung bereits getroffener Regelentscheidungen +- Zusammenfassung von Rechtstexten +- Sprachliche Formulierung von Hinweisen + +### 3.3 Human-in-the-Loop + +Bei allen kritischen Entscheidungen ist ein **menschlicher Prüfer** eingebunden: + +| Eskalationsstufe | Auslöser | Prüfer | SLA | +|------------------|----------|--------|-----| +| E0 | Nur informative Regeln | Automatisch | - | +| E1 | Warnungen, geringes Risiko | Team-Lead | 24h | +| E2 | Art. 9-Daten, DSFA empfohlen | DSB | 8h | +| E3 | BLOCK-Regeln, hohes Risiko | DSB + Legal | 4h | + +**BLOCK-Entscheidungen können NICHT durch KI überschrieben werden.** + +--- + +## 4. Technische und organisatorische Maßnahmen (Art. 32 DSGVO) + +### 4.1 Vertraulichkeit + +| Maßnahme | Umsetzung | +|----------|-----------| +| Zugriffskontrolle | RBAC mit Tenant-Isolation | +| Verschlüsselung in Transit | TLS 1.3 | +| Verschlüsselung at Rest | AES-256 (PostgreSQL, Qdrant) | +| Authentifizierung | JWT-basiert, Token-Expiry | + +### 4.2 Integrität + +| Maßnahme | Umsetzung | +|----------|-----------| +| Audit-Trail | Unveränderlicher Verlauf aller Aktionen | +| Versionierung | Policy-Version in jedem Assessment | +| Input-Validierung | Schema-Validierung aller API-Eingaben | + +### 4.3 Verfügbarkeit + +| Maßnahme | Umsetzung | +|----------|-----------| +| Backup | Tägliche PostgreSQL-Backups | +| Redundanz | Container-Orchestrierung mit Auto-Restart | +| Monitoring | Health-Checks, SLA-Überwachung | + +### 4.4 Belastbarkeit + +| Maßnahme | Umsetzung | +|----------|-----------| +| Rate Limiting | API-Anfragenbegrenzung | +| Graceful Degradation | LLM-Fallback bei Ausfall | +| Ressourcenlimits | Container-Memory-Limits | + +--- + +## 5. Datenschutz-Folgenabschätzung (Art. 35 DSGVO) + +### 5.1 Risikobewertung + +| Risiko | Bewertung | Mitigierung | +|--------|-----------|-------------| +| Fehleinschätzung durch KI | Mittel | Deterministische Regeln, Human Review | +| Datenverlust | Niedrig | Backup, Verschlüsselung | +| Unbefugter Zugriff | Niedrig | RBAC, Audit-Trail | +| Bias in Regellogik | Niedrig | Transparente Regeln, Review-Prozess | + +### 5.2 DSFA-Trigger im System + +Das System erkennt automatisch, wann eine DSFA erforderlich ist: +- Verarbeitung besonderer Kategorien (Art. 9 DSGVO) +- Systematische Bewertung natürlicher Personen +- Neue Technologien mit hohem Risiko + +--- + +## 6. Betroffenenrechte (Art. 15-22 DSGVO) + +### 6.1 Auskunftsrecht (Art. 15) + +Betroffene können Auskunft erhalten über: +- Gespeicherte Assessments mit ihren Daten +- Audit-Trail ihrer Interaktionen +- Regelbasierte Entscheidungsbegründungen + +### 6.2 Recht auf Berichtigung (Art. 16) + +Betroffene können die Korrektur fehlerhafter Eingabedaten verlangen. + +### 6.3 Recht auf Löschung (Art. 17) + +Assessments können gelöscht werden, sofern: +- Keine gesetzlichen Aufbewahrungspflichten bestehen +- Keine laufenden Eskalationsverfahren existieren + +### 6.4 Recht auf Einschränkung (Art. 18) + +Die Verarbeitung kann eingeschränkt werden durch: +- Archivierung statt Löschung +- Sperrung des Datensatzes + +### 6.5 Automatisierte Entscheidungen (Art. 22) + +**Das System trifft keine automatisierten Einzelentscheidungen** im Sinne von Art. 22 DSGVO, da: + +1. Regelauswertung ist **keine rechtlich bindende Entscheidung** +2. Alle kritischen Fälle werden **menschlich geprüft** (E1-E3) +3. BLOCK-Entscheidungen erfordern **immer menschliche Freigabe** +4. Betroffene haben **Anfechtungsmöglichkeit** über Eskalation + +--- + +## 7. Auftragsverarbeitung + +### 7.1 Unterauftragnehmer + +| Dienst | Anbieter | Standort | Zweck | +|--------|----------|----------|-------| +| Embedding-Service | Lokal (Self-Hosted) | EU | Vektorisierung | +| Vector-DB (Qdrant) | Lokal (Self-Hosted) | EU | Ähnlichkeitssuche | +| LLM (Ollama) | Lokal (Self-Hosted) | EU | Erklärungsgenerierung | + +**Hinweis:** Das System kann vollständig on-premise betrieben werden ohne externe Dienste. + +### 7.2 Internationale Transfers + +Bei Nutzung von Cloud-LLM-Anbietern: +- Anthropic Claude: US (DPF-zertifiziert) +- OpenAI: US (DPF-zertifiziert) + +**Empfehlung:** Nutzung des lokalen Ollama-Providers für sensible Daten. + +--- + +## 8. Audit-Trail und Nachvollziehbarkeit + +### 8.1 Protokollierte Ereignisse + +| Ereignis | Protokollierte Daten | +|----------|---------------------| +| Assessment erstellt | Benutzer, Timestamp, Intake-Hash, Ergebnis | +| Eskalation erstellt | Level, Grund, SLA | +| Zuweisung | Benutzer, Rolle | +| Review gestartet | Benutzer, Timestamp | +| Entscheidung | Benutzer, Entscheidung, Begründung | + +### 8.2 Aufbewahrungsfristen + +| Datenart | Aufbewahrung | Rechtsgrundlage | +|----------|--------------|-----------------| +| Assessments | 10 Jahre | § 147 AO | +| Audit-Trail | 10 Jahre | § 147 AO | +| Eskalationen | 10 Jahre | § 147 AO | +| Löschprotokolle | 3 Jahre | Art. 17 DSGVO | + +--- + +## 9. Lizenzierte Inhalte & Normen-Compliance (§44b UrhG) + +### 9.1 Zweck + +Das System enthält einen spezialisierten **License Policy Engine** zur Compliance-Prüfung bei der Verarbeitung urheberrechtlich geschützter Inhalte, insbesondere: + +- **DIN-Normen** (DIN Media / Beuth Verlag) +- **VDI-Richtlinien** +- **ISO/IEC-Standards** +- **VDE-Normen** + +### 9.2 Rechtlicher Hintergrund + +**§44b UrhG - Text und Data Mining:** +> "Die Vervielfältigung von rechtmäßig zugänglichen Werken für das Text und Data Mining ist zulässig." + +**ABER:** Rechteinhaber können TDM gem. §44b Abs. 3 UrhG vorbehalten: +- **DIN Media:** Expliziter Vorbehalt in AGB – keine KI/TDM-Nutzung ohne Sonderlizenz +- **Geplante KI-Lizenzmodelle:** Ab Q4/2025 (DIN Media) + +### 9.3 Operationsmodi im System + +| Modus | Beschreibung | Lizenzanforderung | +|-------|--------------|-------------------| +| `LINK_ONLY` | Nur Verlinkung zum Original | Keine | +| `NOTES_ONLY` | Eigene Notizen/Zusammenfassungen | Keine (§51 UrhG) | +| `EXCERPT_ONLY` | Kurze Zitate (<100 Wörter) | Standard-Lizenz | +| `FULLTEXT_RAG` | Volltextsuche mit Embedding | Explizite KI-Lizenz | +| `TRAINING` | Modell-Training | Enterprise-Lizenz + Vertrag | + +### 9.4 Stop-Lines (Automatische Sperren) + +Das System **blockiert automatisch** folgende Kombinationen: + +| Stop-Line ID | Bedingung | Aktion | +|--------------|-----------|--------| +| `STOP_DIN_FULLTEXT_AI_NOT_ALLOWED` | DIN Media + FULLTEXT_RAG + keine KI-Lizenz | Ablehnung | +| `STOP_LICENSE_UNKNOWN_FULLTEXT` | Lizenz unbekannt + FULLTEXT_RAG | Warnung + Eskalation | +| `STOP_TRAINING_WITHOUT_ENTERPRISE` | Beliebig + TRAINING + keine Enterprise-Lizenz | Ablehnung | + +### 9.5 License Policy Engine - Entscheidungslogik + +``` +INPUT: +├── licensed_content.present = true +├── licensed_content.publisher = "DIN_MEDIA" +├── licensed_content.license_type = "SINGLE_WORKSTATION" +├── licensed_content.ai_use_permitted = "NO" +└── licensed_content.operation_mode = "FULLTEXT_RAG" + +REGEL-EVALUATION: +├── Prüfe Publisher-spezifische Regeln +├── Prüfe Lizenztyp vs. gewünschter Modus +├── Prüfe AI-Use-Flag +└── Bestimme maximal zulässigen Modus + +OUTPUT: +├── allowed: false +├── max_allowed_mode: "NOTES_ONLY" +├── required_controls: ["CTRL-LICENSE-PROOF", "CTRL-NO-CRAWLING-DIN"] +├── gaps: ["GAP_DIN_MEDIA_WITHOUT_AI_LICENSE"] +├── stop_lines: ["STOP_DIN_FULLTEXT_AI_NOT_ALLOWED"] +└── explanation: "DIN Media verbietet KI-Nutzung ohne explizite Lizenz..." +``` + +### 9.6 Erforderliche Controls bei lizenzierten Inhalten + +| Control ID | Beschreibung | Evidence | +|------------|--------------|----------| +| `CTRL-LICENSE-PROOF` | Lizenznachweis dokumentieren | Lizenzvertrag, Rechnung | +| `CTRL-LICENSE-GATED-INGEST` | Technische Sperre vor Ingest | Konfiguration, Logs | +| `CTRL-NO-CRAWLING-DIN` | Kein automatisches Crawling | System-Konfiguration | +| `CTRL-OUTPUT-GUARD` | Ausgabe-Beschränkung (Zitatlimit) | API-Logs | + +### 9.7 Audit-relevante Protokollierung + +Bei jeder Verarbeitung lizenzierter Inhalte wird dokumentiert: + +| Feld | Beschreibung | Aufbewahrung | +|------|--------------|--------------| +| `license_check_timestamp` | Zeitpunkt der Prüfung | 10 Jahre | +| `license_decision` | Ergebnis (allowed/denied) | 10 Jahre | +| `license_proof_hash` | Hash des Lizenznachweises | 10 Jahre | +| `operation_mode_requested` | Angefragter Modus | 10 Jahre | +| `operation_mode_granted` | Erlaubter Modus | 10 Jahre | +| `publisher` | Rechteinhaber | 10 Jahre | + +### 9.8 On-Premise-Deployment für sensible Normen + +Für Unternehmen mit strengen Compliance-Anforderungen: + +| Komponente | Deployment | Isolation | +|------------|------------|-----------| +| Normen-Datenbank | Lokaler Mac Studio | Air-gapped | +| Embedding-Service | Lokal (bge-m3) | Keine Cloud | +| Vector-DB (Qdrant) | Lokaler Container | Tenant-Isolation | +| LLM (Ollama) | Lokal (Qwen2.5-Coder) | Keine API-Calls | + +--- + +## 10. Kontakt und Verantwortlichkeiten + +### 10.1 Verantwortlicher + +[Name und Adresse des Unternehmens] + +### 10.2 Datenschutzbeauftragter + +Name: [Name] +E-Mail: [E-Mail] +Telefon: [Telefon] + +### 10.3 Technischer Ansprechpartner + +Name: [Name] +E-Mail: [E-Mail] + +--- + +## 11. Änderungshistorie + +| Version | Datum | Änderung | Autor | +|---------|-------|----------|-------| +| 1.1.0 | 2026-01-29 | License Policy Engine & Standards-Compliance (§44b UrhG) | [Autor] | +| 1.0.0 | 2026-01-29 | Erstversion | [Autor] | + +--- + +*Diese Dokumentation erfüllt die Anforderungen nach Art. 30 DSGVO (Verzeichnis von Verarbeitungstätigkeiten) und dient als Grundlage für Audits nach Art. 32 DSGVO (Sicherheit der Verarbeitung).* diff --git a/docs-src/services/ai-compliance-sdk/DEVELOPER.md b/docs-src/services/ai-compliance-sdk/DEVELOPER.md new file mode 100644 index 0000000..e9cd559 --- /dev/null +++ b/docs-src/services/ai-compliance-sdk/DEVELOPER.md @@ -0,0 +1,746 @@ +# AI Compliance SDK - Entwickler-Dokumentation + +## Inhaltsverzeichnis + +1. [Schnellstart](#1-schnellstart) +2. [Architektur-Übersicht](#2-architektur-übersicht) +3. [Policy Engine](#3-policy-engine) +4. [License Policy Engine](#4-license-policy-engine) +5. [Legal RAG Integration](#5-legal-rag-integration) +6. [Wizard & Legal Assistant](#6-wizard--legal-assistant) +7. [Eskalations-System](#7-eskalations-system) +8. [API-Endpoints](#8-api-endpoints) +9. [Policy-Dateien](#9-policy-dateien) +10. [Tests ausführen](#10-tests-ausführen) + +--- + +## 1. Schnellstart + +### Voraussetzungen + +- Go 1.21+ +- PostgreSQL (für Eskalations-Store) +- Qdrant (für Legal RAG) +- Ollama oder Anthropic API Key (für LLM) + +### Build & Run + +```bash +# Build +cd ai-compliance-sdk +go build -o server ./cmd/server + +# Run +./server --config config.yaml + +# Alternativ: mit Docker +docker compose up -d +``` + +### Erste Anfrage + +```bash +# UCCA Assessment erstellen +curl -X POST http://localhost:8080/sdk/v1/ucca/assess \ + -H "Content-Type: application/json" \ + -d '{ + "use_case_text": "Chatbot für Kundenservice mit FAQ-Suche", + "domain": "utilities", + "data_types": { + "personal_data": false, + "public_data": true + }, + "automation": "assistive", + "model_usage": { + "rag": true + }, + "hosting": { + "region": "eu" + } + }' +``` + +--- + +## 2. Architektur-Übersicht + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ API Layer (Gin) │ +│ internal/api/handlers/ │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │ +│ │ UCCA │ │ License │ │ Eskalation │ │ +│ │ Handler │ │ Handler │ │ Handler │ │ +│ └──────┬──────┘ └──────┬──────┘ └───────────┬─────────────┘ │ +│ │ │ │ │ +├─────────┼────────────────┼──────────────────────┼────────────────┤ +│ ▼ ▼ ▼ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │ +│ │ Policy │ │ License │ │ Escalation │ │ +│ │ Engine │ │ Policy │ │ Store │ │ +│ │ │ │ Engine │ │ │ │ +│ └──────┬──────┘ └──────┬──────┘ └───────────┬─────────────┘ │ +│ │ │ │ │ +│ └────────┬───────┴──────────────────────┘ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ Legal RAG System │ │ +│ │ (Qdrant + LLM Integration) │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Kernprinzip + +**LLM ist NICHT die Quelle der Wahrheit!** + +| Komponente | Entscheidet | LLM-Nutzung | +|------------|-------------|-------------| +| Policy Engine | Feasibility, Risk Level | Nein | +| License Engine | Operation Mode, Stop-Lines | Nein | +| Gap Mapping | Facts → Gaps → Controls | Nein | +| Legal RAG | Erklärung generieren | Ja (nur Output) | + +--- + +## 3. Policy Engine + +### Übersicht + +Die Policy Engine (`internal/ucca/policy_engine.go`) evaluiert Use Cases gegen deterministische Regeln. + +### Verwendung + +```go +import "ai-compliance-sdk/internal/ucca" + +// Engine erstellen +engine, err := ucca.NewPolicyEngineFromPath("policies/ucca_policy_v1.yaml") +if err != nil { + log.Fatal(err) +} + +// Intake erstellen +intake := &ucca.UseCaseIntake{ + UseCaseText: "Chatbot für Kundenservice", + Domain: ucca.DomainUtilities, + DataTypes: ucca.DataTypes{ + PersonalData: false, + PublicData: true, + }, + Automation: ucca.AutomationAssistive, + ModelUsage: ucca.ModelUsage{ + RAG: true, + }, + Hosting: ucca.Hosting{ + Region: "eu", + }, +} + +// Evaluieren +result := engine.Evaluate(intake) + +// Ergebnis auswerten +fmt.Println("Feasibility:", result.Feasibility) // YES, NO, CONDITIONAL +fmt.Println("Risk Level:", result.RiskLevel) // MINIMAL, LOW, MEDIUM, HIGH +fmt.Println("Risk Score:", result.RiskScore) // 0-100 +``` + +### Ergebnis-Struktur + +```go +type EvaluationResult struct { + Feasibility Feasibility // YES, NO, CONDITIONAL + RiskLevel RiskLevel // MINIMAL, LOW, MEDIUM, HIGH + RiskScore int // 0-100 + TriggeredRules []TriggeredRule // Ausgelöste Regeln + RequiredControls []Control // Erforderliche Maßnahmen + RecommendedArchitecture []Pattern // Empfohlene Patterns + DSFARecommended bool // DSFA erforderlich? + Art22Risk bool // Art. 22 Risiko? + TrainingAllowed TrainingAllowed // YES, NO, CONDITIONAL + PolicyVersion string // Version der Policy +} +``` + +### Regeln hinzufügen + +Neue Regeln werden in `policies/ucca_policy_v1.yaml` definiert: + +```yaml +rules: + - id: R-CUSTOM-001 + code: R-CUSTOM-001 + category: custom + title: Custom Rule + title_de: Benutzerdefinierte Regel + description: Custom rule description + severity: WARN # INFO, WARN, BLOCK + gdpr_ref: "Art. 6 DSGVO" + condition: + all_of: + - field: domain + equals: custom_domain + - field: data_types.personal_data + equals: true + controls: + - C_CUSTOM_CONTROL +``` + +--- + +## 4. License Policy Engine + +### Übersicht + +Die License Policy Engine (`internal/ucca/license_policy.go`) prüft die Lizenz-Compliance für Standards und Normen. + +### Operationsmodi + +| Modus | Beschreibung | Lizenzanforderung | +|-------|--------------|-------------------| +| `LINK_ONLY` | Nur Verweise | Keine | +| `NOTES_ONLY` | Eigene Notizen | Keine | +| `EXCERPT_ONLY` | Kurzzitate (<150 Zeichen) | Standard-Lizenz | +| `FULLTEXT_RAG` | Volltext-Embedding | Explizite KI-Lizenz | +| `TRAINING` | Modell-Training | Enterprise + Vertrag | + +### Verwendung + +```go +import "ai-compliance-sdk/internal/ucca" + +engine := ucca.NewLicensePolicyEngine() + +facts := &ucca.LicensedContentFacts{ + Present: true, + Publisher: "DIN_MEDIA", + LicenseType: "SINGLE_WORKSTATION", + AIUsePermitted: "NO", + ProofUploaded: false, + OperationMode: "FULLTEXT_RAG", +} + +result := engine.Evaluate(facts) + +if !result.Allowed { + fmt.Println("Blockiert:", result.StopLine.Message) + fmt.Println("Effektiver Modus:", result.EffectiveMode) +} +``` + +### Ingest-Entscheidung + +```go +// Prüfen ob Volltext-Ingest erlaubt ist +canIngest := engine.CanIngestFulltext(facts) + +// Oder detaillierte Entscheidung +decision := engine.DecideIngest(facts) +fmt.Println("Fulltext:", decision.AllowFulltext) +fmt.Println("Notes:", decision.AllowNotes) +fmt.Println("Metadata:", decision.AllowMetadata) +``` + +### Audit-Logging + +```go +// Audit-Entry erstellen +entry := engine.FormatAuditEntry("tenant-123", "doc-456", facts, result) + +// Human-readable Summary +summary := engine.FormatHumanReadableSummary(result) +fmt.Println(summary) +``` + +### Publisher-spezifische Regeln + +DIN Media hat explizite Restriktionen: + +```go +// DIN Media blockiert FULLTEXT_RAG ohne AI-Lizenz +if facts.Publisher == "DIN_MEDIA" && facts.AIUsePermitted != "YES" { + // → STOP_DIN_FULLTEXT_AI_NOT_ALLOWED + // → Downgrade auf LINK_ONLY +} +``` + +--- + +## 5. Legal RAG Integration + +### Übersicht + +Das Legal RAG System (`internal/ucca/legal_rag.go`) generiert Erklärungen mit rechtlichem Kontext. + +### Verwendung + +```go +import "ai-compliance-sdk/internal/ucca" + +rag := ucca.NewLegalRAGService(qdrantClient, llmClient, "bp_legal_corpus") + +// Erklärung generieren +explanation, err := rag.Explain(ctx, result, intake) +if err != nil { + log.Error(err) +} + +fmt.Println("Erklärung:", explanation.Text) +fmt.Println("Rechtsquellen:", explanation.Sources) +``` + +### Rechtsquellen im RAG + +| Quelle | Chunks | Beschreibung | +|--------|--------|--------------| +| DSGVO | 128 | EU Datenschutz-Grundverordnung | +| AI Act | 96 | EU AI-Verordnung | +| NIS2 | 128 | Netzwerk-Informationssicherheit | +| SCC | 32 | Standardvertragsklauseln | +| DPF | 714 | Data Privacy Framework | + +--- + +## 6. Wizard & Legal Assistant + +### Wizard-Schema + +Das Wizard-Schema (`policies/wizard_schema_v1.yaml`) definiert die Fragen für das Frontend. + +### Legal Assistant verwenden + +```go +// Wizard-Frage an Legal Assistant stellen +type WizardAskRequest struct { + Question string `json:"question"` + StepNumber int `json:"step_number"` + FieldID string `json:"field_id,omitempty"` + CurrentData map[string]interface{} `json:"current_data,omitempty"` +} + +// POST /sdk/v1/ucca/wizard/ask +``` + +### Beispiel API-Call + +```bash +curl -X POST http://localhost:8080/sdk/v1/ucca/wizard/ask \ + -H "Content-Type: application/json" \ + -d '{ + "question": "Was sind personenbezogene Daten?", + "step_number": 2, + "field_id": "data_types.personal_data" + }' +``` + +--- + +## 7. Eskalations-System + +### Eskalationsstufen + +| Level | Auslöser | Prüfer | SLA | +|-------|----------|--------|-----| +| E0 | Nur INFO | Automatisch | - | +| E1 | WARN, geringes Risiko | Team-Lead | 24h | +| E2 | Art. 9, DSFA empfohlen | DSB | 8h | +| E3 | BLOCK, hohes Risiko | DSB + Legal | 4h | + +### Eskalation erstellen + +```go +import "ai-compliance-sdk/internal/ucca" + +store := ucca.NewEscalationStore(db) + +escalation := &ucca.Escalation{ + AssessmentID: "assess-123", + Level: ucca.EscalationE2, + TriggerReason: "Art. 9 Daten betroffen", + RequiredReviews: 1, +} + +err := store.CreateEscalation(ctx, escalation) +``` + +### SLA-Monitor + +```go +monitor := ucca.NewSLAMonitor(store, notificationService) + +// Im Hintergrund starten +go monitor.Start(ctx) +``` + +--- + +## 8. API-Endpoints + +### UCCA Endpoints + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| POST | `/sdk/v1/ucca/assess` | Assessment erstellen | +| GET | `/sdk/v1/ucca/assess/:id` | Assessment abrufen | +| POST | `/sdk/v1/ucca/explain` | Erklärung generieren | +| GET | `/sdk/v1/ucca/wizard/schema` | Wizard-Schema abrufen | +| POST | `/sdk/v1/ucca/wizard/ask` | Legal Assistant fragen | + +### License Endpoints + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| POST | `/sdk/v1/license/evaluate` | Lizenz-Prüfung | +| POST | `/sdk/v1/license/decide-ingest` | Ingest-Entscheidung | + +### Eskalations-Endpoints + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| GET | `/sdk/v1/escalations` | Offene Eskalationen | +| GET | `/sdk/v1/escalations/:id` | Eskalation abrufen | +| POST | `/sdk/v1/escalations/:id/decide` | Entscheidung treffen | + +--- + +## 9. Policy-Dateien + +### Dateistruktur + +``` +policies/ +├── ucca_policy_v1.yaml # Haupt-Policy (Regeln, Controls, Patterns) +├── wizard_schema_v1.yaml # Wizard-Fragen und Legal Assistant +├── controls_catalog.yaml # Detaillierte Control-Beschreibungen +├── gap_mapping.yaml # Facts → Gaps → Controls +├── licensed_content_policy.yaml # Standards/Normen Compliance +└── scc_legal_corpus.yaml # SCC Rechtsquellen +``` + +### Policy-Version + +Jede Policy hat eine Version: + +```yaml +metadata: + version: "1.0.0" + effective_date: "2025-01-01" + author: "Compliance Team" +``` + +--- + +## 10. Tests ausführen + +### Alle Tests + +```bash +cd ai-compliance-sdk +go test -v ./... +``` + +### Spezifische Tests + +```bash +# Policy Engine Tests +go test -v ./internal/ucca/policy_engine_test.go + +# License Policy Tests +go test -v ./internal/ucca/license_policy_test.go + +# Eskalation Tests +go test -v ./internal/ucca/escalation_test.go +``` + +### Test-Coverage + +```bash +go test -cover ./... + +# HTML-Report +go test -coverprofile=coverage.out ./... +go tool cover -html=coverage.out +``` + +### Beispiel: Neuen Test hinzufügen + +```go +func TestMyNewFeature(t *testing.T) { + engine := NewLicensePolicyEngine() + + facts := &LicensedContentFacts{ + Present: true, + Publisher: "DIN_MEDIA", + OperationMode: "FULLTEXT_RAG", + } + + result := engine.Evaluate(facts) + + if result.Allowed { + t.Error("Expected blocked for DIN_MEDIA FULLTEXT_RAG") + } +} +``` + +--- + +## 11. Generic Obligations Framework + +### Übersicht + +Das Obligations Framework ermöglicht die automatische Ableitung regulatorischer Pflichten aus NIS2, DSGVO und AI Act. + +### Verwendung + +```go +import "ai-compliance-sdk/internal/ucca" + +// Registry erstellen (lädt alle Module) +registry := ucca.NewObligationsRegistry() + +// UnifiedFacts aufbauen +facts := &ucca.UnifiedFacts{ + Organization: ucca.OrganizationFacts{ + EmployeeCount: 150, + AnnualRevenue: 30000000, + Country: "DE", + EUMember: true, + }, + Sector: ucca.SectorFacts{ + PrimarySector: "digital_infrastructure", + SpecialServices: []string{"cloud", "msp"}, + IsKRITIS: false, + }, + DataProtection: ucca.DataProtectionFacts{ + ProcessesPersonalData: true, + }, + AIUsage: ucca.AIUsageFacts{ + UsesAI: true, + HighRiskCategories: []string{"employment"}, + IsGPAIProvider: false, + }, +} + +// Alle anwendbaren Pflichten evaluieren +overview := registry.EvaluateAll(facts, "Muster GmbH") + +// Ergebnis auswerten +fmt.Println("Anwendbare Regulierungen:", len(overview.ApplicableRegulations)) +fmt.Println("Gesamtzahl Pflichten:", len(overview.Obligations)) +fmt.Println("Kritische Pflichten:", overview.ExecutiveSummary.CriticalObligations) +``` + +### Neues Regulierungsmodul erstellen + +```go +// 1. Module-Interface implementieren +type MyRegulationModule struct { + obligations []ucca.Obligation + controls []ucca.ObligationControl + incidentDeadlines []ucca.IncidentDeadline +} + +func (m *MyRegulationModule) ID() string { return "my_regulation" } +func (m *MyRegulationModule) Name() string { return "My Regulation" } + +func (m *MyRegulationModule) IsApplicable(facts *ucca.UnifiedFacts) bool { + // Prüflogik implementieren + return facts.Organization.Country == "DE" +} + +func (m *MyRegulationModule) DeriveObligations(facts *ucca.UnifiedFacts) []ucca.Obligation { + // Pflichten basierend auf Facts ableiten + return m.obligations +} + +// 2. In Registry registrieren +func NewMyRegulationModule() (*MyRegulationModule, error) { + m := &MyRegulationModule{} + // YAML laden oder hardcoded Pflichten definieren + return m, nil +} + +// In obligations_registry.go: +// r.Register(NewMyRegulationModule()) +``` + +### YAML-basierte Pflichten + +```yaml +# policies/obligations/my_regulation_obligations.yaml +regulation: my_regulation +name: "My Regulation" + +obligations: + - id: "MYREG-OBL-001" + title: "Compliance-Pflicht" + description: "Beschreibung der Pflicht" + applies_when: "classification != 'nicht_betroffen'" + legal_basis: + - norm: "§ 1 MyReg" + category: "Governance" + responsible: "Geschäftsführung" + deadline: + type: "relative" + duration: "12 Monate" + sanctions: + max_fine: "1 Mio. EUR" + priority: "high" + +controls: + - id: "MYREG-CTRL-001" + name: "Kontrollmaßnahme" + category: "Technical" + when_applicable: "immer" + what_to_do: "Maßnahme implementieren" + evidence_needed: + - "Dokumentation" +``` + +### PDF Export + +```go +import "ai-compliance-sdk/internal/ucca" + +// Exporter erstellen +exporter := ucca.NewPDFExporter("de") + +// PDF generieren +response, err := exporter.ExportManagementMemo(overview) +if err != nil { + log.Fatal(err) +} + +// base64-kodierter PDF-Inhalt +fmt.Println("Content-Type:", response.ContentType) // application/pdf +fmt.Println("Filename:", response.Filename) + +// PDF speichern +decoded, _ := base64.StdEncoding.DecodeString(response.Content) +os.WriteFile("memo.pdf", decoded, 0644) + +// Alternativ: Markdown +mdResponse, err := exporter.ExportMarkdown(overview) +fmt.Println(mdResponse.Content) // Markdown-Text +``` + +### API-Endpoints + +```bash +# Assessment erstellen +curl -X POST http://localhost:8090/sdk/v1/ucca/obligations/assess \ + -H "Content-Type: application/json" \ + -d '{ + "facts": { + "organization": {"employee_count": 150, "country": "DE"}, + "sector": {"primary_sector": "healthcare"}, + "data_protection": {"processes_personal_data": true}, + "ai_usage": {"uses_ai": false} + }, + "organization_name": "Test GmbH" + }' + +# PDF Export (direkt) +curl -X POST http://localhost:8090/sdk/v1/ucca/obligations/export/direct \ + -H "Content-Type: application/json" \ + -d '{ + "overview": { ... }, + "format": "pdf", + "language": "de" + }' +``` + +--- + +## 12. Tests für Obligations Framework + +```bash +# Alle Obligations-Tests +go test -v ./internal/ucca/..._module_test.go + +# NIS2 Module Tests +go test -v ./internal/ucca/nis2_module_test.go + +# DSGVO Module Tests +go test -v ./internal/ucca/dsgvo_module_test.go + +# AI Act Module Tests +go test -v ./internal/ucca/ai_act_module_test.go + +# PDF Export Tests +go test -v ./internal/ucca/pdf_export_test.go +``` + +### Beispiel-Tests + +```go +func TestNIS2Module_LargeCompanyInAnnexISector(t *testing.T) { + module, _ := ucca.NewNIS2Module() + + facts := &ucca.UnifiedFacts{ + Organization: ucca.OrganizationFacts{ + EmployeeCount: 500, + AnnualRevenue: 100000000, + Country: "DE", + }, + Sector: ucca.SectorFacts{ + PrimarySector: "energy", + }, + } + + if !module.IsApplicable(facts) { + t.Error("Expected NIS2 to apply to large energy company") + } + + classification := module.Classify(facts) + if classification != "besonders_wichtige_einrichtung" { + t.Errorf("Expected 'besonders_wichtige_einrichtung', got '%s'", classification) + } +} + +func TestAIActModule_HighRiskEmploymentAI(t *testing.T) { + module, _ := ucca.NewAIActModule() + + facts := &ucca.UnifiedFacts{ + AIUsage: ucca.AIUsageFacts{ + UsesAI: true, + HighRiskCategories: []string{"employment"}, + }, + } + + if !module.IsApplicable(facts) { + t.Error("Expected AI Act to apply") + } + + riskLevel := module.ClassifyRisk(facts) + if riskLevel != ucca.AIActHighRisk { + t.Errorf("Expected 'high_risk', got '%s'", riskLevel) + } +} +``` + +--- + +## Anhang: Wichtige Dateien + +| Datei | Beschreibung | +|-------|--------------| +| `internal/ucca/policy_engine.go` | Haupt-Policy-Engine | +| `internal/ucca/license_policy.go` | License Policy Engine | +| `internal/ucca/obligations_framework.go` | Obligations Interfaces & Typen | +| `internal/ucca/obligations_registry.go` | Modul-Registry | +| `internal/ucca/nis2_module.go` | NIS2 Decision Tree | +| `internal/ucca/dsgvo_module.go` | DSGVO Pflichten | +| `internal/ucca/ai_act_module.go` | AI Act Risk Classification | +| `internal/ucca/pdf_export.go` | PDF/Markdown Export | +| `internal/api/handlers/obligations_handlers.go` | Obligations API | +| `policies/obligations/*.yaml` | Pflichten-Kataloge | + +--- + +*Dokumentationsstand: 2026-01-29* diff --git a/docs-src/services/ai-compliance-sdk/SBOM.md b/docs-src/services/ai-compliance-sdk/SBOM.md new file mode 100644 index 0000000..510f150 --- /dev/null +++ b/docs-src/services/ai-compliance-sdk/SBOM.md @@ -0,0 +1,220 @@ +# AI Compliance SDK - Software Bill of Materials (SBOM) + +**Erstellt:** 2026-01-29 +**Go-Version:** 1.24.0 + +--- + +## Zusammenfassung + +| Kategorie | Anzahl | Status | +|-----------|--------|--------| +| Direkte Abhängigkeiten | 7 | ✅ Alle kommerziell nutzbar | +| Indirekte Abhängigkeiten | ~45 | ✅ Alle kommerziell nutzbar | +| **Gesamt** | ~52 | ✅ **Alle Open Source, kommerziell nutzbar** | + +--- + +## Direkte Abhängigkeiten + +| Package | Version | Lizenz | Kommerziell nutzbar | +|---------|---------|--------|---------------------| +| `github.com/gin-gonic/gin` | v1.10.1 | **MIT** | ✅ Ja | +| `github.com/gin-contrib/cors` | v1.7.6 | **MIT** | ✅ Ja | +| `github.com/google/uuid` | v1.6.0 | **BSD-3-Clause** | ✅ Ja | +| `github.com/jackc/pgx/v5` | v5.5.3 | **MIT** | ✅ Ja | +| `github.com/joho/godotenv` | v1.5.1 | **MIT** | ✅ Ja | +| `github.com/xuri/excelize/v2` | v2.9.1 | **BSD-3-Clause** | ✅ Ja | +| `gopkg.in/yaml.v3` | v3.0.1 | **MIT / Apache-2.0** | ✅ Ja | + +--- + +## Indirekte Abhängigkeiten (Transitive) + +### JSON / Serialisierung + +| Package | Version | Lizenz | Kommerziell nutzbar | +|---------|---------|--------|---------------------| +| `github.com/bytedance/sonic` | v1.13.3 | **Apache-2.0** | ✅ Ja | +| `github.com/goccy/go-json` | v0.10.5 | **MIT** | ✅ Ja | +| `github.com/json-iterator/go` | v1.1.12 | **MIT** | ✅ Ja | +| `github.com/pelletier/go-toml/v2` | v2.2.4 | **MIT** | ✅ Ja | +| `gopkg.in/yaml.v3` | v3.0.1 | **MIT / Apache-2.0** | ✅ Ja | +| `github.com/ugorji/go/codec` | v1.3.0 | **MIT** | ✅ Ja | + +### Web Framework (Gin-Ökosystem) + +| Package | Version | Lizenz | Kommerziell nutzbar | +|---------|---------|--------|---------------------| +| `github.com/gin-contrib/sse` | v1.1.0 | **MIT** | ✅ Ja | +| `github.com/go-playground/validator/v10` | v10.26.0 | **MIT** | ✅ Ja | +| `github.com/go-playground/locales` | v0.14.1 | **MIT** | ✅ Ja | +| `github.com/go-playground/universal-translator` | v0.18.1 | **MIT** | ✅ Ja | +| `github.com/leodido/go-urn` | v1.4.0 | **MIT** | ✅ Ja | + +### Datenbank (PostgreSQL) + +| Package | Version | Lizenz | Kommerziell nutzbar | +|---------|---------|--------|---------------------| +| `github.com/jackc/pgpassfile` | v1.0.0 | **MIT** | ✅ Ja | +| `github.com/jackc/pgservicefile` | v0.0.0-... | **MIT** | ✅ Ja | +| `github.com/jackc/puddle/v2` | v2.2.1 | **MIT** | ✅ Ja | + +### Excel-Verarbeitung + +| Package | Version | Lizenz | Kommerziell nutzbar | +|---------|---------|--------|---------------------| +| `github.com/xuri/excelize/v2` | v2.9.1 | **BSD-3-Clause** | ✅ Ja | +| `github.com/xuri/efp` | v0.0.1 | **BSD-3-Clause** | ✅ Ja | +| `github.com/xuri/nfp` | v0.0.2-... | **BSD-3-Clause** | ✅ Ja | +| `github.com/richardlehane/mscfb` | v1.0.4 | **Apache-2.0** | ✅ Ja | +| `github.com/richardlehane/msoleps` | v1.0.4 | **Apache-2.0** | ✅ Ja | + +### PDF-Generierung + +| Package | Version | Lizenz | Kommerziell nutzbar | +|---------|---------|--------|---------------------| +| `github.com/jung-kurt/gofpdf` | v1.16.2 | **MIT** | ✅ Ja | + +### Utilities + +| Package | Version | Lizenz | Kommerziell nutzbar | +|---------|---------|--------|---------------------| +| `github.com/gabriel-vasile/mimetype` | v1.4.9 | **MIT** | ✅ Ja | +| `github.com/mattn/go-isatty` | v0.0.20 | **MIT** | ✅ Ja | +| `github.com/modern-go/concurrent` | v0.0.0-... | **Apache-2.0** | ✅ Ja | +| `github.com/modern-go/reflect2` | v1.0.2 | **Apache-2.0** | ✅ Ja | +| `github.com/klauspost/cpuid/v2` | v2.2.10 | **MIT** | ✅ Ja | +| `github.com/tiendc/go-deepcopy` | v1.7.1 | **MIT** | ✅ Ja | +| `github.com/twitchyliquid64/golang-asm` | v0.15.1 | **MIT** | ✅ Ja | +| `github.com/cloudwego/base64x` | v0.1.5 | **Apache-2.0** | ✅ Ja | + +### Go Standardbibliothek Erweiterungen + +| Package | Version | Lizenz | Kommerziell nutzbar | +|---------|---------|--------|---------------------| +| `golang.org/x/arch` | v0.18.0 | **BSD-3-Clause** | ✅ Ja | +| `golang.org/x/crypto` | v0.43.0 | **BSD-3-Clause** | ✅ Ja | +| `golang.org/x/net` | v0.46.0 | **BSD-3-Clause** | ✅ Ja | +| `golang.org/x/sync` | v0.17.0 | **BSD-3-Clause** | ✅ Ja | +| `golang.org/x/sys` | v0.37.0 | **BSD-3-Clause** | ✅ Ja | +| `golang.org/x/text` | v0.30.0 | **BSD-3-Clause** | ✅ Ja | + +### Protokoll-Bibliotheken + +| Package | Version | Lizenz | Kommerziell nutzbar | +|---------|---------|--------|---------------------| +| `google.golang.org/protobuf` | v1.36.6 | **BSD-3-Clause** | ✅ Ja | + +--- + +## Lizenz-Übersicht + +| Lizenz | Anzahl Packages | Kommerziell nutzbar | Copyleft | +|--------|-----------------|---------------------|----------| +| **MIT** | ~25 | ✅ Ja | ❌ Nein | +| **Apache-2.0** | ~8 | ✅ Ja | ❌ Nein (schwach) | +| **BSD-3-Clause** | ~12 | ✅ Ja | ❌ Nein | +| **BSD-2-Clause** | 0 | ✅ Ja | ❌ Nein | + +### Keine problematischen Lizenzen! + +| Lizenz | Status | +|--------|--------| +| GPL-2.0 | ❌ **Nicht verwendet** | +| GPL-3.0 | ❌ **Nicht verwendet** | +| AGPL | ❌ **Nicht verwendet** | +| LGPL | ❌ **Nicht verwendet** | +| SSPL | ❌ **Nicht verwendet** | +| Commons Clause | ❌ **Nicht verwendet** | + +--- + +## Eigene Komponenten (Keine externen Abhängigkeiten) + +Die folgenden Komponenten wurden im Rahmen des AI Compliance SDK entwickelt und haben **keine zusätzlichen Abhängigkeiten**: + +| Komponente | Dateien | Externe Deps | +|------------|---------|--------------| +| Policy Engine | `internal/ucca/policy_engine.go` | Keine | +| License Policy Engine | `internal/ucca/license_policy.go` | Keine | +| Legal RAG | `internal/ucca/legal_rag.go` | Keine | +| Escalation System | `internal/ucca/escalation_*.go` | Keine | +| SLA Monitor | `internal/ucca/sla_monitor.go` | Keine | +| UCCA Handlers | `internal/api/handlers/ucca_handlers.go` | Gin (MIT) | +| **Obligations Framework** | `internal/ucca/obligations_framework.go` | Keine | +| **Obligations Registry** | `internal/ucca/obligations_registry.go` | Keine | +| **NIS2 Module** | `internal/ucca/nis2_module.go` | Keine | +| **DSGVO Module** | `internal/ucca/dsgvo_module.go` | Keine | +| **AI Act Module** | `internal/ucca/ai_act_module.go` | Keine | +| **PDF Export** | `internal/ucca/pdf_export.go` | gofpdf (MIT) | +| **Obligations Handlers** | `internal/api/handlers/obligations_handlers.go` | Gin (MIT) | +| **Funding Models** | `internal/funding/models.go` | Keine | +| **Funding Store** | `internal/funding/store.go`, `postgres_store.go` | pgx (MIT) | +| **Funding Export** | `internal/funding/export.go` | gofpdf (MIT), excelize (BSD-3) | +| **Funding Handlers** | `internal/api/handlers/funding_handlers.go` | Gin (MIT) | + +### Policy-Dateien (Reine YAML/JSON) + +| Datei | Format | Abhängigkeiten | +|-------|--------|----------------| +| `ucca_policy_v1.yaml` | YAML | Keine | +| `wizard_schema_v1.yaml` | YAML | Keine | +| `controls_catalog.yaml` | YAML | Keine | +| `gap_mapping.yaml` | YAML | Keine | +| `licensed_content_policy.yaml` | YAML | Keine | +| `financial_regulations_policy.yaml` | YAML | Keine | +| `financial_regulations_corpus.yaml` | YAML | Keine | +| `scc_legal_corpus.yaml` | YAML | Keine | +| **`obligations/nis2_obligations.yaml`** | YAML | Keine | +| **`obligations/dsgvo_obligations.yaml`** | YAML | Keine | +| **`obligations/ai_act_obligations.yaml`** | YAML | Keine | +| **`funding/foerderantrag_wizard_v1.yaml`** | YAML | Keine | +| **`funding/bundesland_profiles.yaml`** | YAML | Keine | + +--- + +## Compliance-Erklärung + +### Für kommerzielle Nutzung geeignet: ✅ JA + +Alle verwendeten Abhängigkeiten verwenden **permissive Open-Source-Lizenzen**: + +1. **MIT-Lizenz**: Erlaubt kommerzielle Nutzung, Modifikation, Distribution. Nur Lizenzhinweis erforderlich. + +2. **Apache-2.0-Lizenz**: Erlaubt kommerzielle Nutzung, Modifikation, Distribution. Patentgewährung enthalten. + +3. **BSD-3-Clause**: Erlaubt kommerzielle Nutzung, Modifikation, Distribution. Nur Lizenzhinweis erforderlich. + +### Keine Copyleft-Lizenzen + +Es werden **keine** Copyleft-Lizenzen (GPL, AGPL, LGPL) verwendet, die eine Offenlegung des eigenen Quellcodes erfordern würden. + +### Empfohlene Maßnahmen + +1. **NOTICE-Datei pflegen**: Alle Lizenztexte in einer NOTICE-Datei zusammenfassen +2. **Regelmäßige Updates**: Abhängigkeiten auf bekannte Schwachstellen prüfen +3. **License-Scanner**: Tool wie `go-licenses` oder `fossa` für automatisierte Prüfung + +--- + +## Generierung des SBOM + +```bash +# SBOM im SPDX-Format generieren +go install github.com/spdx/tools-golang/cmd/spdx-tvwriter@latest +go mod download +# Manuell: SPDX-Dokument erstellen + +# Alternativ: CycloneDX Format +go install github.com/CycloneDX/cyclonedx-gomod/cmd/cyclonedx-gomod@latest +cyclonedx-gomod mod -output sbom.json + +# Lizenz-Prüfung +go install github.com/google/go-licenses@latest +go-licenses csv github.com/breakpilot/ai-compliance-sdk/... +``` + +--- + +*Dokumentationsstand: 2026-01-29* diff --git a/docs-src/services/ai-compliance-sdk/index.md b/docs-src/services/ai-compliance-sdk/index.md new file mode 100644 index 0000000..420dc69 --- /dev/null +++ b/docs-src/services/ai-compliance-sdk/index.md @@ -0,0 +1,97 @@ +# AI Compliance SDK + +Das AI Compliance SDK ist ein Go-basierter Service zur Compliance-Bewertung von KI-Anwendungsfällen. + +## Übersicht + +| Eigenschaft | Wert | +|-------------|------| +| **Port** | 8090 | +| **Framework** | Go (Gin) | +| **Datenbank** | PostgreSQL | +| **Vector DB** | Qdrant (Legal RAG) | + +## Kernkomponenten + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ UCCA System │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Frontend │───>│ SDK API │───>│ PostgreSQL │ │ +│ │ (Next.js) │ │ (Go) │ │ Database │ │ +│ └──────────────┘ └──────┬───────┘ └──────────────┘ │ +│ │ │ +│ ┌────────────────────┼────────────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Policy │ │ Escalation │ │ Legal RAG │ │ +│ │ Engine │ │ Workflow │ │ (Qdrant) │ │ +│ │ (45 Regeln) │ │ (E0-E3) │ │ 2,274 Chunks │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +## Features + +- **UCCA (Use-Case Compliance Advisor)**: Deterministische Bewertung von KI-Anwendungsfällen +- **Policy Engine**: 45 regelbasierte Compliance-Prüfungen +- **License Policy Engine**: Standards/Normen-Compliance (DIN, ISO, VDI) +- **Legal RAG**: Semantische Suche in EU-Verordnungen (DSGVO, AI Act, NIS2) +- **Eskalations-Workflow**: E0-E3 Stufen mit Human-in-the-Loop +- **Wizard & Legal Assistant**: Geführte Eingabe mit Rechtsassistent +- **Generic Obligations Framework**: NIS2, DSGVO, AI Act Module + +## Kernprinzip + +> **"LLM ist NICHT die Quelle der Wahrheit. Wahrheit = Regeln + Evidenz. LLM = Übersetzer + Subsumptionshelfer"** + +Das System folgt einem strikten **Human-in-the-Loop** Ansatz: + +1. **Deterministische Regeln** treffen alle Compliance-Entscheidungen +2. **LLM** erklärt nur Ergebnisse, überschreibt nie BLOCK-Entscheidungen +3. **Menschen** (DSB, Legal) treffen finale Entscheidungen bei kritischen Fällen + +## API-Endpunkte + +### Assessment + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| POST | `/sdk/v1/ucca/assess` | Assessment erstellen | +| GET | `/sdk/v1/ucca/assessments` | Assessments auflisten | +| GET | `/sdk/v1/ucca/assessments/:id` | Assessment abrufen | +| POST | `/sdk/v1/ucca/assessments/:id/explain` | LLM-Erklärung generieren | + +### Eskalation + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| GET | `/sdk/v1/ucca/escalations` | Eskalationen auflisten | +| POST | `/sdk/v1/ucca/escalations/:id/decide` | Entscheidung treffen | + +### Obligations Framework + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| POST | `/sdk/v1/ucca/obligations/assess` | Pflichten-Assessment | +| POST | `/sdk/v1/ucca/obligations/export/memo` | PDF-Export | + +## Weiterführende Dokumentation + +- [Architektur](./ARCHITECTURE.md) - Detaillierte Systemarchitektur +- [Entwickler-Guide](./DEVELOPER.md) - Entwickler-Dokumentation +- [Auditor-Dokumentation](./AUDITOR_DOCUMENTATION.md) - Dokumentation für externe Auditoren + +## Tests + +```bash +cd ai-compliance-sdk +go test -v ./... + +# Mit Coverage +go test -cover ./... +``` diff --git a/docs-src/services/ki-daten-pipeline/architecture.md b/docs-src/services/ki-daten-pipeline/architecture.md new file mode 100644 index 0000000..65cf6aa --- /dev/null +++ b/docs-src/services/ki-daten-pipeline/architecture.md @@ -0,0 +1,353 @@ +# KI-Daten-Pipeline Architektur + +Diese Seite dokumentiert die technische Architektur der KI-Daten-Pipeline im Detail. + +## Systemuebersicht + +```mermaid +graph TB + subgraph Users["Benutzer"] + U1[Entwickler] + U2[Data Scientists] + U3[Lehrer] + end + + subgraph Frontend["Frontend (admin-v2)"] + direction TB + F1["OCR-Labeling
              /ai/ocr-labeling"] + F2["RAG Pipeline
              /ai/rag-pipeline"] + F3["Daten & RAG
              /ai/rag"] + F4["Klausur-Korrektur
              /ai/klausur-korrektur"] + end + + subgraph Backend["Backend Services"] + direction TB + B1["klausur-service
              Port 8086"] + B2["embedding-service
              Port 8087"] + end + + subgraph Storage["Persistenz"] + direction TB + D1[(PostgreSQL
              Metadaten)] + D2[(Qdrant
              Vektoren)] + D3[(MinIO
              Bilder/PDFs)] + end + + subgraph External["Externe APIs"] + E1[OpenAI API] + E2[Ollama] + end + + U1 --> F1 + U2 --> F2 + U3 --> F4 + + F1 --> B1 + F2 --> B1 + F3 --> B1 + F4 --> B1 + + B1 --> D1 + B1 --> D2 + B1 --> D3 + B1 --> B2 + + B2 --> E1 + B1 --> E2 +``` + +## Komponenten-Details + +### OCR-Labeling Modul + +```mermaid +flowchart TB + subgraph Upload["Upload-Prozess"] + U1[Bilder hochladen] --> U2[MinIO speichern] + U2 --> U3[Session erstellen] + end + + subgraph OCR["OCR-Verarbeitung"] + O1[Bild laden] --> O2{Modell wählen} + O2 -->|llama3.2-vision| O3a[Vision LLM] + O2 -->|trocr| O3b[Transformer] + O2 -->|paddleocr| O3c[PaddleOCR] + O2 -->|donut| O3d[Document AI] + O3a --> O4[OCR-Text] + O3b --> O4 + O3c --> O4 + O3d --> O4 + end + + subgraph Labeling["Labeling-Prozess"] + L1[Queue laden] --> L2[Item anzeigen] + L2 --> L3{Entscheidung} + L3 -->|korrekt| L4[Bestaetigen] + L3 -->|falsch| L5[Korrigieren] + L3 -->|unklar| L6[Ueberspringen] + L4 --> L7[PostgreSQL] + L5 --> L7 + L6 --> L7 + end + + subgraph Export["Export"] + E1[Gelabelte Items] --> E2{Format} + E2 -->|TrOCR| E3a[Transformer Format] + E2 -->|Llama| E3b[Vision Format] + E2 -->|Generic| E3c[JSON] + end + + Upload --> OCR + OCR --> Labeling + Labeling --> Export +``` + +### RAG Pipeline Modul + +```mermaid +flowchart TB + subgraph Sources["Datenquellen"] + S1[NiBiS PDFs] + S2[Uploads] + S3[Rechtskorpus] + S4[Schulordnungen] + end + + subgraph Processing["Verarbeitung"] + direction TB + P1[PDF Parser] --> P2[OCR falls noetig] + P2 --> P3[Text Cleaning] + P3 --> P4[Chunking
              1000 chars, 200 overlap] + P4 --> P5[Metadata Extraction] + end + + subgraph Embedding["Embedding"] + E1[embedding-service] --> E2[OpenAI API] + E2 --> E3[1536-dim Vektor] + end + + subgraph Indexing["Indexierung"] + I1{Collection waehlen} + I1 -->|EH| I2a[bp_nibis_eh] + I1 -->|Custom| I2b[bp_eh] + I1 -->|Legal| I2c[bp_legal_corpus] + I1 -->|Schul| I2d[bp_schulordnungen] + I2a --> I3[Qdrant upsert] + I2b --> I3 + I2c --> I3 + I2d --> I3 + end + + Sources --> Processing + Processing --> Embedding + Embedding --> Indexing +``` + +### Daten & RAG Modul + +```mermaid +flowchart TB + subgraph Query["Suchanfrage"] + Q1[User Query] --> Q2[Query Embedding] + Q2 --> Q3[1536-dim Vektor] + end + + subgraph Search["Qdrant Suche"] + S1[Collection waehlen] --> S2[Vector Search] + S2 --> S3[Top-k Results] + S3 --> S4[Score Filtering] + end + + subgraph Results["Ergebnisse"] + R1[Chunks] --> R2[Metadata anreichern] + R2 --> R3[Source URLs] + R3 --> R4[Response] + end + + Query --> Search + Search --> Results +``` + +## Datenmodelle + +### OCR-Labeling + +```typescript +interface OCRSession { + id: string + name: string + source_type: 'klausur' | 'handwriting_sample' | 'scan' + ocr_model: 'llama3.2-vision:11b' | 'trocr' | 'paddleocr' | 'donut' + total_items: number + labeled_items: number + status: 'active' | 'completed' | 'archived' + created_at: string +} + +interface OCRItem { + id: string + session_id: string + image_path: string + ocr_text: string | null + ocr_confidence: number | null + ground_truth: string | null + status: 'pending' | 'confirmed' | 'corrected' | 'skipped' + label_time_seconds: number | null +} +``` + +### RAG Pipeline + +```typescript +interface TrainingJob { + id: string + name: string + status: 'queued' | 'preparing' | 'training' | 'validating' | 'completed' | 'failed' | 'paused' + progress: number + current_epoch: number + total_epochs: number + documents_processed: number + total_documents: number + config: { + batch_size: number + bundeslaender: string[] + mixed_precision: boolean + } +} + +interface DataSource { + id: string + name: string + collection: string + document_count: number + chunk_count: number + status: 'active' | 'pending' | 'error' + last_updated: string | null +} +``` + +### Legal Corpus + +```typescript +interface RegulationStatus { + code: string + name: string + fullName: string + type: 'eu_regulation' | 'eu_directive' | 'de_law' | 'bsi_standard' + chunkCount: number + status: 'ready' | 'empty' | 'error' +} + +interface SearchResult { + text: string + regulation_code: string + regulation_name: string + article: string | null + paragraph: string | null + source_url: string + score: number +} +``` + +## Qdrant Collections + +### Konfiguration + +| Collection | Vektor-Dimension | Distanz-Metrik | Payload | +|------------|-----------------|----------------|---------| +| `bp_nibis_eh` | 1536 | COSINE | bundesland, fach, aufgabe | +| `bp_eh` | 1536 | COSINE | user_id, klausur_id | +| `bp_legal_corpus` | 1536 | COSINE | regulation, article, source_url | +| `bp_schulordnungen` | 1536 | COSINE | bundesland, typ, datum | + +### Chunk-Strategie + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Originaldokument │ +│ Lorem ipsum dolor sit amet, consectetur adipiscing elit... │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ +│ Chunk 1 │ │ Chunk 2 │ │ Chunk 3 │ +│ 0-1000 chars │ │ 800-1800 chars │ │ 1600-2600 chars │ +│ │ │ (200 overlap) │ │ (200 overlap) │ +└──────────────────────┘ └──────────────────────┘ └──────────────────────┘ +``` + +## API-Authentifizierung + +Alle Endpunkte nutzen die zentrale Auth-Middleware: + +```mermaid +sequenceDiagram + participant C as Client + participant A as API Gateway + participant S as klausur-service + participant D as Datenbank + + C->>A: Request + JWT Token + A->>A: Token validieren + A->>S: Forwarded Request + S->>D: Daten abfragen + D->>S: Response + S->>C: JSON Response +``` + +## Monitoring & Metriken + +### Verfuegbare Metriken + +| Metrik | Beschreibung | Endpoint | +|--------|--------------|----------| +| `ocr_items_total` | Gesamtzahl OCR-Items | `/api/v1/ocr-label/stats` | +| `ocr_accuracy_rate` | OCR-Genauigkeit | `/api/v1/ocr-label/stats` | +| `rag_chunk_count` | Anzahl indexierter Chunks | `/api/legal-corpus/status` | +| `rag_collection_status` | Collection-Status | `/api/legal-corpus/status` | + +### Logging + +```python +# Strukturiertes Logging im klausur-service +logger.info("OCR processing started", extra={ + "session_id": session_id, + "item_count": item_count, + "model": ocr_model +}) +``` + +## Fehlerbehandlung + +### Retry-Strategien + +| Operation | Max Retries | Backoff | +|-----------|-------------|---------| +| OCR-Verarbeitung | 3 | Exponentiell (1s, 2s, 4s) | +| Embedding-API | 5 | Exponentiell mit Jitter | +| Qdrant-Upsert | 3 | Linear (1s) | + +### Fallback-Verhalten + +```mermaid +flowchart TD + A[Embedding Request] --> B{OpenAI verfuegbar?} + B -->|Ja| C[OpenAI API] + B -->|Nein| D{Lokales Modell?} + D -->|Ja| E[Ollama Embedding] + D -->|Nein| F[Error + Queue] +``` + +## Skalierung + +### Aktueller Stand + +- **Single Node**: Alle Services auf Mac Mini +- **Qdrant**: Standalone, ~50k Chunks +- **PostgreSQL**: Shared mit anderen Services + +### Geplante Erweiterungen + +1. **Qdrant Cluster**: Bei > 1M Chunks +2. **Worker Queue**: Redis-basiert fuer Batch-Jobs +3. **GPU-Offloading**: OCR auf vast.ai GPU-Instanzen diff --git a/docs-src/services/ki-daten-pipeline/index.md b/docs-src/services/ki-daten-pipeline/index.md new file mode 100644 index 0000000..d9f824b --- /dev/null +++ b/docs-src/services/ki-daten-pipeline/index.md @@ -0,0 +1,215 @@ +# KI-Daten-Pipeline + +Die KI-Daten-Pipeline ist ein zusammenhaengendes System aus drei Modulen, das den Datenfluss von der Erfassung bis zur semantischen Suche abbildet. + +## Uebersicht + +```mermaid +flowchart LR + subgraph OCR["OCR-Labeling"] + A[Klausur-Scans] --> B[OCR Erkennung] + B --> C[Ground Truth Labels] + end + + subgraph RAG["RAG Pipeline"] + D[PDF Dokumente] --> E[Text-Extraktion] + E --> F[Chunking] + F --> G[Embedding] + end + + subgraph SEARCH["Daten & RAG"] + H[Qdrant Collections] + I[Semantische Suche] + end + + C -->|Export| D + G -->|Indexierung| H + H --> I + I -->|Ergebnisse| J[Klausur-Korrektur] +``` + +## Module + +| Modul | Pfad | Funktion | Backend | +|-------|------|----------|---------| +| **OCR-Labeling** | `/ai/ocr-labeling` | Ground Truth fuer Handschrift-OCR | klausur-service:8086 | +| **RAG Pipeline** | `/ai/rag-pipeline` | Dokument-Indexierung | klausur-service:8086 | +| **Daten & RAG** | `/ai/rag` | Vektor-Suche & Collection-Mapping | klausur-service:8086 | + +## Datenfluss + +### 1. OCR-Labeling (Eingabe) + +Das OCR-Labeling-Modul erfasst Ground Truth Daten fuer das Training von Handschrift-Erkennungsmodellen: + +- **Upload**: Klausur-Scans (PDF/Bilder) werden hochgeladen +- **OCR-Verarbeitung**: Mehrere OCR-Modelle erkennen den Text + - `llama3.2-vision:11b` - Vision LLM (beste Qualitaet) + - `trocr` - Microsoft Transformer (schnell) + - `paddleocr` - PaddleOCR + LLM (4x schneller) + - `donut` - Document Understanding (strukturiert) +- **Labeling**: Manuelles Pruefen und Korrigieren der OCR-Ergebnisse +- **Export**: Gelabelte Daten koennen exportiert werden fuer: + - TrOCR Fine-Tuning + - Llama Vision Fine-Tuning + - Generic JSON + +### 2. RAG Pipeline (Verarbeitung) + +Die RAG Pipeline verarbeitet Dokumente und macht sie suchbar: + +```mermaid +flowchart TD + A[Datenquellen] --> B[OCR/Text-Extraktion] + B --> C[Chunking] + C --> D[Embedding] + D --> E[Qdrant Indexierung] + + subgraph sources["Datenquellen"] + S1[NiBiS PDFs] + S2[Eigene EH] + S3[Rechtskorpus] + S4[Schulordnungen] + end +``` + +**Verarbeitungsschritte:** + +1. **Dokumentenextraktion**: PDFs und Bilder werden per OCR in Text umgewandelt +2. **Chunking**: Lange Texte werden in Abschnitte aufgeteilt + - Chunk-Groesse: 1000 Zeichen + - Ueberlappung: 200 Zeichen +3. **Embedding**: Jeder Chunk wird in einen Vektor umgewandelt + - Modell: `text-embedding-3-small` + - Dimensionen: 1536 +4. **Indexierung**: Vektoren werden in Qdrant gespeichert + +### 3. Daten & RAG (Ausgabe) + +Das Daten & RAG Modul ermoeglicht die Verwaltung und Suche: + +- **Collection-Uebersicht**: Status aller Qdrant Collections +- **Semantische Suche**: Fragen werden in Vektoren umgewandelt und aehnliche Dokumente gefunden +- **Regulierungs-Mapping**: Zeigt welche Regulierungen indexiert sind + +## Qdrant Collections + +| Collection | Inhalt | Status | +|------------|--------|--------| +| `bp_nibis_eh` | Offizielle NiBiS Erwartungshorizonte | Aktiv | +| `bp_eh` | Benutzerdefinierte Erwartungshorizonte | Aktiv | +| `bp_schulordnungen` | Schulordnungen aller Bundeslaender | In Arbeit | +| `bp_legal_corpus` | Rechtskorpus (DSGVO, AI Act, BSI, etc.) | Aktiv | + +## Technische Architektur + +### Services + +```mermaid +graph TB + subgraph Frontend["Admin-v2 (Next.js)"] + F1["/ai/ocr-labeling"] + F2["/ai/rag-pipeline"] + F3["/ai/rag"] + end + + subgraph Backend["klausur-service (Python)"] + B1[OCR Endpoints] + B2[Indexierungs-Jobs] + B3[Such-API] + end + + subgraph Storage["Datenbanken"] + D1[(PostgreSQL)] + D2[(Qdrant)] + D3[(MinIO)] + end + + F1 --> B1 + F2 --> B2 + F3 --> B3 + + B1 --> D1 + B1 --> D3 + B2 --> D2 + B3 --> D2 +``` + +### Backend-Endpunkte + +#### OCR-Labeling (`/api/v1/ocr-label/`) + +| Endpoint | Methode | Beschreibung | +|----------|---------|--------------| +| `/sessions` | GET/POST | Session-Verwaltung | +| `/sessions/{id}/upload` | POST | Bilder hochladen | +| `/queue` | GET | Labeling-Queue | +| `/confirm` | POST | OCR bestaetigen | +| `/correct` | POST | OCR korrigieren | +| `/skip` | POST | Item ueberspringen | +| `/stats` | GET | Statistiken | +| `/export` | POST | Trainingsdaten exportieren | + +#### RAG Pipeline (`/api/ai/rag-pipeline`) + +| Action | Beschreibung | +|--------|--------------| +| `jobs` | Indexierungs-Jobs auflisten | +| `dataset-stats` | Datensatz-Statistiken | +| `create-job` | Neue Indexierung starten | +| `pause` | Job pausieren | +| `resume` | Job fortsetzen | +| `cancel` | Job abbrechen | + +#### Legal Corpus (`/api/legal-corpus/`) + +| Endpoint | Beschreibung | +|----------|--------------| +| `/status` | Collection-Status | +| `/search` | Semantische Suche | +| `/ingest` | Dokumente indexieren | + +## Integration mit Klausur-Korrektur + +Die KI-Daten-Pipeline liefert Erwartungshorizont-Vorschlaege fuer die Klausur-Korrektur: + +```mermaid +sequenceDiagram + participant L as Lehrer + participant K as Klausur-Korrektur + participant R as RAG-Suche + participant Q as Qdrant + + L->>K: Schueler-Antwort pruefen + K->>R: EH-Vorschlaege laden + R->>Q: Semantische Suche + Q->>R: Top-k Chunks + R->>K: Relevante EH-Passagen + K->>L: Bewertungsvorschlaege +``` + +## Deployment + +Die Module werden als Teil des admin-v2 Containers deployed: + +```bash +# 1. Sync +rsync -avz --delete --exclude 'node_modules' --exclude '.next' --exclude '.git' \ + /Users/benjaminadmin/Projekte/breakpilot-pwa/admin-v2/ \ + macmini:/Users/benjaminadmin/Projekte/breakpilot-pwa/admin-v2/ + +# 2. Build & Deploy +ssh macmini "/usr/local/bin/docker compose \ + -f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \ + build --no-cache admin-v2 && \ + /usr/local/bin/docker compose \ + -f /Users/benjaminadmin/Projekte/breakpilot-pwa/docker-compose.yml \ + up -d admin-v2" +``` + +## Verwandte Dokumentation + +- [OCR Labeling Spezifikation](../klausur-service/OCR-Labeling-Spec.md) +- [RAG Admin Spezifikation](../klausur-service/RAG-Admin-Spec.md) +- [NiBiS Ingestion Pipeline](../klausur-service/NiBiS-Ingestion-Pipeline.md) +- [Multi-Agent Architektur](../../architecture/multi-agent.md) diff --git a/docs-src/services/klausur-service/BYOEH-Architecture.md b/docs-src/services/klausur-service/BYOEH-Architecture.md new file mode 100644 index 0000000..753a8ce --- /dev/null +++ b/docs-src/services/klausur-service/BYOEH-Architecture.md @@ -0,0 +1,322 @@ +# BYOEH (Bring-Your-Own-Expectation-Horizon) - Architecture Documentation + +## Overview + +The BYOEH module enables teachers to upload their own Erwartungshorizonte (expectation horizons/grading rubrics) and use them for RAG-assisted grading suggestions. Key design principles: + +- **Tenant Isolation**: Each teacher/school has an isolated namespace +- **No Training Guarantee**: EH content is only used for RAG, never for model training +- **Operator Blindness**: Client-side encryption ensures Breakpilot cannot view plaintext +- **Rights Confirmation**: Required legal acknowledgment at upload time + +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ klausur-service (Port 8086) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ ┌────────────────────┐ ┌─────────────────────────────────────────┐ │ +│ │ BYOEH REST API │ │ BYOEH Service Layer │ │ +│ │ │ │ │ │ +│ │ POST /api/v1/eh │───▶│ - Upload Wizard Logic │ │ +│ │ GET /api/v1/eh │ │ - Rights Confirmation │ │ +│ │ DELETE /api/v1/eh │ │ - Chunking Pipeline │ │ +│ │ POST /rag-query │ │ - Encryption Service │ │ +│ └────────────────────┘ └────────────────────┬────────────────────┘ │ +└─────────────────────────────────────────────────┼────────────────────────┘ + │ + ┌───────────────────────────────────────┼───────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌──────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────┐ +│ PostgreSQL │ │ Qdrant │ │ Encrypted Storage │ +│ (Metadata + Audit) │ │ (Vector Search) │ │ /app/eh-uploads/ │ +│ │ │ │ │ │ +│ In-Memory Storage: │ │ Collection: bp_eh │ │ {tenant}/{eh_id}/ │ +│ - erwartungshorizonte│ │ - tenant_id (filter) │ │ encrypted.bin │ +│ - eh_chunks │ │ - eh_id │ │ salt.txt │ +│ - eh_key_shares │ │ - embedding[1536] │ │ │ +│ - eh_klausur_links │ │ - encrypted_content │ └──────────────────────┘ +│ - eh_audit_log │ │ │ +└──────────────────────┘ └──────────────────────────┘ +``` + +## Data Flow + +### 1. Upload Flow + +``` +Browser Backend Storage + │ │ │ + │ 1. User selects PDF │ │ + │ 2. User enters passphrase │ │ + │ 3. PBKDF2 key derivation │ │ + │ 4. AES-256-GCM encryption │ │ + │ 5. SHA-256 key hash │ │ + │ │ │ + │──────────────────────────────▶│ │ + │ POST /api/v1/eh/upload │ │ + │ (encrypted blob + key_hash) │ │ + │ │──────────────────────────────▶│ + │ │ Store encrypted.bin + salt │ + │ │◀──────────────────────────────│ + │ │ │ + │ │ Save metadata to DB │ + │◀──────────────────────────────│ │ + │ Return EH record │ │ +``` + +### 2. Indexing Flow (RAG Preparation) + +``` +Browser Backend Qdrant + │ │ │ + │──────────────────────────────▶│ │ + │ POST /api/v1/eh/{id}/index │ │ + │ (passphrase for decryption) │ │ + │ │ │ + │ │ 1. Verify key hash │ + │ │ 2. Decrypt content │ + │ │ 3. Extract text (PDF) │ + │ │ 4. Chunk text │ + │ │ 5. Generate embeddings │ + │ │ 6. Re-encrypt each chunk │ + │ │──────────────────────────────▶│ + │ │ Index vectors + encrypted │ + │ │ chunks with tenant filter │ + │◀──────────────────────────────│ │ + │ Return chunk count │ │ +``` + +### 3. RAG Query Flow + +``` +Browser Backend Qdrant + │ │ │ + │──────────────────────────────▶│ │ + │ POST /api/v1/eh/rag-query │ │ + │ (query + passphrase) │ │ + │ │ │ + │ │ 1. Generate query embedding │ + │ │──────────────────────────────▶│ + │ │ 2. Semantic search │ + │ │ (tenant-filtered) │ + │ │◀──────────────────────────────│ + │ │ 3. Decrypt matched chunks │ + │◀──────────────────────────────│ │ + │ Return decrypted context │ │ +``` + +## Security Architecture + +### Client-Side Encryption + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Browser (Client-Side) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. User enters passphrase (NEVER sent to server) │ +│ │ │ +│ ▼ │ +│ 2. Key Derivation: PBKDF2-SHA256(passphrase, salt, 100k iter) │ +│ │ │ +│ ▼ │ +│ 3. Encryption: AES-256-GCM(key, iv, file_content) │ +│ │ │ +│ ▼ │ +│ 4. Key-Hash: SHA-256(derived_key) → server verification only │ +│ │ │ +│ ▼ │ +│ 5. Upload: encrypted_blob + key_hash + salt (NOT key!) │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Security Guarantees + +| Guarantee | Implementation | +|-----------|----------------| +| **No Training** | `training_allowed: false` on all Qdrant points | +| **Operator Blindness** | Passphrase never leaves browser; server only sees key hash | +| **Tenant Isolation** | Every query filtered by `tenant_id` | +| **Audit Trail** | All actions logged with timestamps | + +## Key Sharing System + +The key sharing system enables first examiners to grant access to their EH to second examiners and supervisors. + +### Share Flow + +``` +First Examiner Backend Second Examiner + │ │ │ + │ 1. Encrypt passphrase for │ │ + │ recipient (client-side) │ │ + │ │ │ + │─────────────────────────────▶ │ + │ POST /eh/{id}/share │ │ + │ (encrypted_passphrase, role)│ │ + │ │ │ + │ │ Store EHKeyShare │ + │◀───────────────────────────── │ + │ │ │ + │ │ │ + │ │◀────────────────────────────│ + │ │ GET /eh/shared-with-me │ + │ │ │ + │ │─────────────────────────────▶ + │ │ Return shared EH list │ + │ │ │ + │ │◀────────────────────────────│ + │ │ RAG query with decrypted │ + │ │ passphrase │ +``` + +### Data Structures + +```python +@dataclass +class EHKeyShare: + id: str + eh_id: str + user_id: str # Recipient + encrypted_passphrase: str # Client-encrypted for recipient + passphrase_hint: str # Optional hint + granted_by: str # Grantor user ID + granted_at: datetime + role: str # second_examiner, third_examiner, supervisor + klausur_id: Optional[str] # Link to specific Klausur + active: bool + +@dataclass +class EHKlausurLink: + id: str + eh_id: str + klausur_id: str + linked_by: str + linked_at: datetime +``` + +## API Endpoints + +### Core EH Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/v1/eh/upload` | Upload encrypted EH | +| GET | `/api/v1/eh` | List user's EH | +| GET | `/api/v1/eh/{id}` | Get single EH | +| DELETE | `/api/v1/eh/{id}` | Soft delete EH | +| POST | `/api/v1/eh/{id}/index` | Index EH for RAG | +| POST | `/api/v1/eh/rag-query` | Query EH content | + +### Key Sharing Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/v1/eh/{id}/share` | Share EH with examiner | +| GET | `/api/v1/eh/{id}/shares` | List shares (owner) | +| DELETE | `/api/v1/eh/{id}/shares/{shareId}` | Revoke share | +| GET | `/api/v1/eh/shared-with-me` | List EH shared with user | + +### Klausur Integration Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/v1/eh/{id}/link-klausur` | Link EH to Klausur | +| DELETE | `/api/v1/eh/{id}/link-klausur/{klausurId}` | Unlink EH | +| GET | `/api/v1/klausuren/{id}/linked-eh` | Get linked EH for Klausur | + +### Audit & Admin Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/eh/audit-log` | Get audit log | +| GET | `/api/v1/eh/rights-text` | Get rights confirmation text | +| GET | `/api/v1/eh/qdrant-status` | Get Qdrant status (admin) | + +## Frontend Components + +### EHUploadWizard + +5-step wizard for uploading Erwartungshorizonte: + +1. **File Selection** - Choose PDF file +2. **Metadata** - Title, Subject, Niveau, Year +3. **Rights Confirmation** - Legal acknowledgment +4. **Encryption** - Set passphrase (2x confirmation) +5. **Summary** - Review and upload + +### Integration Points + +- **KorrekturPage**: Shows EH prompt after first student upload +- **GutachtenGeneration**: Uses RAG context from linked EH +- **Sidebar Badge**: Shows linked EH count + +## File Structure + +``` +klausur-service/ +├── backend/ +│ ├── main.py # API endpoints + data structures +│ ├── qdrant_service.py # Vector database operations +│ ├── eh_pipeline.py # Chunking, embedding, encryption +│ └── requirements.txt # Python dependencies +├── frontend/ +│ └── src/ +│ ├── components/ +│ │ └── EHUploadWizard.tsx +│ ├── services/ +│ │ ├── api.ts # API client +│ │ └── encryption.ts # Client-side crypto +│ ├── pages/ +│ │ └── KorrekturPage.tsx # EH integration +│ └── styles/ +│ └── eh-wizard.css +└── docs/ + ├── BYOEH-Architecture.md + └── BYOEH-Developer-Guide.md +``` + +## Configuration + +### Environment Variables + +```env +QDRANT_URL=http://qdrant:6333 +OPENAI_API_KEY=sk-... # For embeddings +BYOEH_ENCRYPTION_ENABLED=true +EH_UPLOAD_DIR=/app/eh-uploads +``` + +### Docker Services + +```yaml +# docker-compose.yml +services: + qdrant: + image: qdrant/qdrant:v1.7.4 + ports: + - "6333:6333" + volumes: + - qdrant_data:/qdrant/storage +``` + +## Audit Events + +| Action | Description | +|--------|-------------| +| `upload` | EH uploaded | +| `index` | EH indexed for RAG | +| `rag_query` | RAG query executed | +| `delete` | EH soft deleted | +| `share` | EH shared with examiner | +| `revoke_share` | Share revoked | +| `link_klausur` | EH linked to Klausur | +| `unlink_klausur` | EH unlinked from Klausur | + +## See Also + +- [Zeugnis-System Architektur](../../architecture/zeugnis-system.md) +- [Klausur-Service Index](./index.md) diff --git a/docs-src/services/klausur-service/BYOEH-Developer-Guide.md b/docs-src/services/klausur-service/BYOEH-Developer-Guide.md new file mode 100644 index 0000000..0b70503 --- /dev/null +++ b/docs-src/services/klausur-service/BYOEH-Developer-Guide.md @@ -0,0 +1,481 @@ +# BYOEH Developer Guide + +## Quick Start + +### Prerequisites + +- Python 3.10+ +- Node.js 18+ +- Docker & Docker Compose +- OpenAI API Key (for embeddings) + +### Setup + +1. **Start services:** +```bash +docker-compose up -d qdrant +``` + +2. **Configure environment:** +```env +QDRANT_URL=http://localhost:6333 +OPENAI_API_KEY=sk-your-key +BYOEH_ENCRYPTION_ENABLED=true +``` + +3. **Run klausur-service:** +```bash +cd klausur-service/backend +pip install -r requirements.txt +uvicorn main:app --reload --port 8086 +``` + +4. **Run frontend:** +```bash +cd klausur-service/frontend +npm install +npm run dev +``` + +## Client-Side Encryption + +The encryption service (`encryption.ts`) handles all cryptographic operations in the browser: + +### Encrypting a File + +```typescript +import { encryptFile, generateSalt } from '../services/encryption' + +const file = document.getElementById('fileInput').files[0] +const passphrase = 'user-secret-password' + +const encrypted = await encryptFile(file, passphrase) +// Result: +// { +// encryptedData: ArrayBuffer, +// keyHash: string, // SHA-256 hash for verification +// salt: string, // Hex-encoded salt +// iv: string // Hex-encoded initialization vector +// } +``` + +### Decrypting Content + +```typescript +import { decryptText, verifyPassphrase } from '../services/encryption' + +// First verify the passphrase +const isValid = await verifyPassphrase(passphrase, salt, expectedKeyHash) + +if (isValid) { + const decrypted = await decryptText(encryptedBase64, passphrase, salt) +} +``` + +## Backend API Usage + +### Upload an Erwartungshorizont + +```python +# The upload endpoint accepts FormData with: +# - file: encrypted binary blob +# - metadata_json: JSON string with metadata + +POST /api/v1/eh/upload +Content-Type: multipart/form-data + +{ + "file": , + "metadata_json": { + "metadata": { + "title": "Deutsch LK 2025", + "subject": "deutsch", + "niveau": "eA", + "year": 2025, + "aufgaben_nummer": "Aufgabe 1" + }, + "encryption_key_hash": "abc123...", + "salt": "def456...", + "rights_confirmed": true, + "original_filename": "erwartungshorizont.pdf" + } +} +``` + +### Index for RAG + +```python +POST /api/v1/eh/{eh_id}/index +Content-Type: application/json + +{ + "passphrase": "user-secret-password" +} +``` + +The backend will: +1. Verify the passphrase against stored key hash +2. Decrypt the file +3. Extract text from PDF +4. Chunk the text (1000 chars, 200 overlap) +5. Generate OpenAI embeddings +6. Re-encrypt each chunk +7. Index in Qdrant with tenant filter + +### RAG Query + +```python +POST /api/v1/eh/rag-query +Content-Type: application/json + +{ + "query_text": "Wie sollte die Einleitung strukturiert sein?", + "passphrase": "user-secret-password", + "subject": "deutsch", # Optional filter + "limit": 5 # Max results +} +``` + +Response: +```json +{ + "context": "Die Einleitung sollte...", + "sources": [ + { + "text": "Die Einleitung sollte...", + "eh_id": "uuid", + "eh_title": "Deutsch LK 2025", + "chunk_index": 2, + "score": 0.89 + } + ], + "query": "Wie sollte die Einleitung strukturiert sein?" +} +``` + +## Key Sharing Implementation + +### Invitation Flow (Recommended) + +The invitation flow provides a two-phase sharing process: Invite -> Accept + +```typescript +import { ehApi } from '../services/api' + +// 1. First examiner sends invitation to second examiner +const invitation = await ehApi.inviteToEH(ehId, { + invitee_email: 'zweitkorrektor@school.de', + role: 'second_examiner', + klausur_id: 'klausur-uuid', // Optional: link to specific Klausur + message: 'Bitte fuer Zweitkorrektur nutzen', + expires_in_days: 14 // Default: 14 days +}) +// Returns: { invitation_id, eh_id, invitee_email, role, expires_at, eh_title } + +// 2. Second examiner sees pending invitation +const pending = await ehApi.getPendingInvitations() +// [{ invitation: {...}, eh: { id, title, subject, niveau, year } }] + +// 3. Second examiner accepts invitation +const accepted = await ehApi.acceptInvitation( + invitationId, + encryptedPassphrase // Passphrase encrypted for recipient +) +// Returns: { status: 'accepted', share_id, eh_id, role, klausur_id } +``` + +### Invitation Management + +```typescript +// Get invitations sent by current user +const sent = await ehApi.getSentInvitations() + +// Decline an invitation (as invitee) +await ehApi.declineInvitation(invitationId) + +// Revoke a pending invitation (as inviter) +await ehApi.revokeInvitation(invitationId) + +// Get complete access chain for an EH +const chain = await ehApi.getAccessChain(ehId) +// Returns: { eh_id, eh_title, owner, active_shares, pending_invitations, revoked_shares } +``` + +### Direct Sharing (Legacy) + +For immediate sharing without invitation: + +```typescript +// First examiner shares directly with second examiner +await ehApi.shareEH(ehId, { + user_id: 'second-examiner-uuid', + role: 'second_examiner', + encrypted_passphrase: encryptedPassphrase, // Encrypted for recipient + passphrase_hint: 'Das uebliche Passwort', + klausur_id: 'klausur-uuid' // Optional +}) +``` + +### Accessing Shared EH + +```typescript +// Second examiner gets shared EH +const shared = await ehApi.getSharedWithMe() +// [{ eh: {...}, share: {...} }] + +// Query using provided passphrase +const result = await ehApi.ragQuery({ + query_text: 'search query', + passphrase: decryptedPassphrase, + subject: 'deutsch' +}) +``` + +### Revoking Access + +```typescript +// List all shares for an EH +const shares = await ehApi.listShares(ehId) + +// Revoke a share +await ehApi.revokeShare(ehId, shareId) +``` + +## Klausur Integration + +### Automatic EH Prompt + +The `KorrekturPage` shows an EH upload prompt after the first student work is uploaded: + +```typescript +// In KorrekturPage.tsx +useEffect(() => { + if ( + currentKlausur?.students.length === 1 && + linkedEHs.length === 0 && + !ehPromptDismissed + ) { + setShowEHPrompt(true) + } +}, [currentKlausur?.students.length]) +``` + +### Linking EH to Klausur + +```typescript +// After EH upload, auto-link to Klausur +await ehApi.linkToKlausur(ehId, klausurId) + +// Get linked EH for a Klausur +const linked = await klausurEHApi.getLinkedEH(klausurId) +``` + +## Frontend Components + +### EHUploadWizard Props + +```typescript +interface EHUploadWizardProps { + onClose: () => void + onComplete?: (ehId: string) => void + defaultSubject?: string // Pre-fill subject + defaultYear?: number // Pre-fill year + klausurId?: string // Auto-link after upload +} + +// Usage + setShowWizard(false)} + onComplete={(ehId) => console.log('Uploaded:', ehId)} + defaultSubject={klausur.subject} + defaultYear={klausur.year} + klausurId={klausur.id} +/> +``` + +### Wizard Steps + +1. **file** - PDF file selection with drag & drop +2. **metadata** - Form for title, subject, niveau, year +3. **rights** - Rights confirmation checkbox +4. **encryption** - Passphrase input with strength meter +5. **summary** - Review and confirm upload + +## Qdrant Operations + +### Collection Schema + +```python +# Collection: bp_eh +{ + "vectors": { + "size": 1536, # OpenAI text-embedding-3-small + "distance": "Cosine" + } +} + +# Point payload +{ + "tenant_id": "school-uuid", + "eh_id": "eh-uuid", + "chunk_index": 0, + "encrypted_content": "base64...", + "training_allowed": false # ALWAYS false +} +``` + +### Tenant-Isolated Search + +```python +from qdrant_service import search_eh + +results = await search_eh( + query_embedding=embedding, + tenant_id="school-uuid", + subject="deutsch", + limit=5 +) +``` + +## Testing + +### Unit Tests + +```bash +cd klausur-service/backend +pytest tests/test_byoeh.py -v +``` + +### Test Structure + +```python +# tests/test_byoeh.py +class TestBYOEH: + def test_upload_eh(self, client, auth_headers): + """Test EH upload with encryption""" + pass + + def test_index_eh(self, client, auth_headers, uploaded_eh): + """Test EH indexing for RAG""" + pass + + def test_rag_query(self, client, auth_headers, indexed_eh): + """Test RAG query returns relevant chunks""" + pass + + def test_share_eh(self, client, auth_headers, uploaded_eh): + """Test sharing EH with another user""" + pass +``` + +### Frontend Tests + +```typescript +// EHUploadWizard.test.tsx +describe('EHUploadWizard', () => { + it('completes all steps successfully', async () => { + // ... + }) + + it('validates passphrase strength', async () => { + // ... + }) + + it('auto-links to klausur when klausurId provided', async () => { + // ... + }) +}) +``` + +## Error Handling + +### Common Errors + +| Error | Cause | Solution | +|-------|-------|----------| +| `Passphrase verification failed` | Wrong passphrase | Ask user to re-enter | +| `EH not found` | Invalid ID or deleted | Check ID, reload list | +| `Access denied` | User not owner/shared | Check permissions | +| `Qdrant connection failed` | Service unavailable | Check Qdrant container | + +### Error Response Format + +```json +{ + "detail": "Passphrase verification failed" +} +``` + +## Security Considerations + +### Do's + +- Store key hash, never the key itself +- Always filter by tenant_id +- Log all access in audit trail +- Use HTTPS in production + +### Don'ts + +- Never log passphrase or decrypted content +- Never store passphrase in localStorage +- Never send passphrase as URL parameter +- Never return decrypted content without auth + +## Performance Tips + +### Chunking Configuration + +```python +CHUNK_SIZE = 1000 # Characters per chunk +CHUNK_OVERLAP = 200 # Overlap for context continuity +``` + +### Embedding Batching + +```python +# Generate embeddings in batches of 20 +EMBEDDING_BATCH_SIZE = 20 +``` + +### Qdrant Optimization + +```python +# Use HNSW index for fast approximate search +# Collection is automatically optimized on creation +``` + +## Debugging + +### Enable Debug Logging + +```python +import logging +logging.getLogger('byoeh').setLevel(logging.DEBUG) +``` + +### Check Qdrant Status + +```bash +curl http://localhost:6333/collections/bp_eh +``` + +### Verify Encryption + +```typescript +import { isEncryptionSupported } from '../services/encryption' + +if (!isEncryptionSupported()) { + console.error('Web Crypto API not available') +} +``` + +## Migration Notes + +### From v1.0 to v1.1 + +1. Added key sharing system +2. Added Klausur linking +3. EH prompt after student upload + +No database migrations required - all data structures are additive. diff --git a/docs-src/services/klausur-service/NiBiS-Ingestion-Pipeline.md b/docs-src/services/klausur-service/NiBiS-Ingestion-Pipeline.md new file mode 100644 index 0000000..2573014 --- /dev/null +++ b/docs-src/services/klausur-service/NiBiS-Ingestion-Pipeline.md @@ -0,0 +1,227 @@ +# NiBiS Ingestion Pipeline + +## Overview + +Die NiBiS Ingestion Pipeline verarbeitet Abitur-Erwartungshorizonte aus Niedersachsen und indexiert sie in Qdrant für RAG-basierte Klausurkorrektur. + +## Unterstützte Daten + +### Verzeichnisse + +| Verzeichnis | Jahre | Namenskonvention | +|-------------|-------|------------------| +| `docs/za-download` | 2024, 2025 | `{Jahr}_{Fach}_{niveau}_{Nr}_EWH.pdf` | +| `docs/za-download-2` | 2016 | `{Jahr}{Fach}{Niveau}Lehrer/{Jahr}{Fach}{Niveau}A{Nr}L.pdf` | +| `docs/za-download-3` | 2017 | `{Jahr}{Fach}{Niveau}Lehrer/{Jahr}{Fach}{Niveau}A{Nr}L.pdf` | + +### Dokumenttypen + +- **EWH** - Erwartungshorizont (Hauptziel) +- **Aufgabe** - Prüfungsaufgaben +- **Material** - Zusatzmaterialien +- **GBU** - Gefährdungsbeurteilung (Chemie/Biologie) +- **Bewertungsbogen** - Standardisierte Bewertungsbögen + +### Fächer + +Deutsch, Englisch, Mathematik, Informatik, Biologie, Chemie, Physik, Geschichte, Erdkunde, Kunst, Musik, Sport, Latein, Griechisch, Französisch, Spanisch, Katholische Religion, Evangelische Religion, Werte und Normen, BRC, BVW, Gesundheit-Pflege + +## Architektur + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ NiBiS Ingestion Pipeline │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. ZIP Extraction │ +│ └── Entpackt 2024.zip, 2025.zip, etc. │ +│ │ +│ 2. Document Discovery │ +│ ├── Parst alte Namenskonvention (2016/2017) │ +│ └── Parst neue Namenskonvention (2024/2025) │ +│ │ +│ 3. PDF Processing │ +│ ├── Text-Extraktion (PyPDF2) │ +│ └── Chunking (1000 chars, 200 overlap) │ +│ │ +│ 4. Embedding Generation │ +│ └── OpenAI text-embedding-3-small (1536 dim) │ +│ │ +│ 5. Qdrant Indexing │ +│ └── Collection: bp_nibis_eh │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Verwendung + +### Via API (empfohlen) + +```bash +# 1. Vorschau der verfügbaren Dokumente +curl http://localhost:8086/api/v1/admin/nibis/discover + +# 2. ZIP-Dateien entpacken +curl -X POST http://localhost:8086/api/v1/admin/nibis/extract-zips + +# 3. Ingestion starten +curl -X POST http://localhost:8086/api/v1/admin/nibis/ingest \ + -H "Content-Type: application/json" \ + -d '{"ewh_only": true}' + +# 4. Status prüfen +curl http://localhost:8086/api/v1/admin/nibis/status + +# 5. Semantische Suche testen +curl -X POST http://localhost:8086/api/v1/admin/nibis/search \ + -H "Content-Type: application/json" \ + -d '{"query": "Analyse literarischer Texte", "subject": "Deutsch", "limit": 5}' +``` + +### Via CLI + +```bash +# Dry-Run (nur analysieren) +cd klausur-service/backend +python nibis_ingestion.py --dry-run + +# Vollständige Ingestion +python nibis_ingestion.py + +# Nur bestimmtes Jahr +python nibis_ingestion.py --year 2024 + +# Nur bestimmtes Fach +python nibis_ingestion.py --subject Deutsch + +# Manifest erstellen +python nibis_ingestion.py --manifest /tmp/nibis_manifest.json +``` + +### Via Shell Script + +```bash +./klausur-service/scripts/run_nibis_ingestion.sh --dry-run +./klausur-service/scripts/run_nibis_ingestion.sh --year 2024 --subject Deutsch +``` + +## Qdrant Schema + +### Collection: `bp_nibis_eh` + +```json +{ + "id": "nibis_2024_deutsch_ea_1_abc123_chunk_0", + "vector": [1536 dimensions], + "payload": { + "doc_id": "nibis_2024_deutsch_ea_1_abc123", + "chunk_index": 0, + "text": "Der Erwartungshorizont...", + "year": 2024, + "subject": "Deutsch", + "niveau": "eA", + "task_number": 1, + "doc_type": "EWH", + "bundesland": "NI", + "variant": null, + "source": "nibis", + "training_allowed": true + } +} +``` + +## API Endpoints + +| Methode | Endpoint | Beschreibung | +|---------|----------|--------------| +| GET | `/api/v1/admin/nibis/status` | Ingestion-Status | +| POST | `/api/v1/admin/nibis/extract-zips` | ZIP-Dateien entpacken | +| GET | `/api/v1/admin/nibis/discover` | Dokumente finden | +| POST | `/api/v1/admin/nibis/ingest` | Ingestion starten | +| POST | `/api/v1/admin/nibis/search` | Semantische Suche | +| GET | `/api/v1/admin/nibis/stats` | Statistiken | +| GET | `/api/v1/admin/nibis/collections` | Qdrant Collections | +| DELETE | `/api/v1/admin/nibis/collection` | Collection löschen | + +## Erweiterung für andere Bundesländer + +Die Pipeline ist so designed, dass sie leicht erweitert werden kann: + +### 1. Neues Bundesland hinzufügen + +```python +# In nibis_ingestion.py + +# Bundesland-Code (ISO 3166-2:DE) +BUNDESLAND_CODES = { + "NI": "Niedersachsen", + "BE": "Berlin", + "BY": "Bayern", + # ... +} + +# Parsing-Funktion für neues Format +def parse_filename_berlin(filename: str, file_path: Path) -> Optional[Dict]: + # Berlin-spezifische Namenskonvention + pass +``` + +### 2. Neues Verzeichnis registrieren + +```python +# docs/za-download-berlin/ hinzufügen +ZA_DOWNLOAD_DIRS = [ + "za-download", + "za-download-2", + "za-download-3", + "za-download-berlin", # NEU +] +``` + +### 3. Dokumenttyp-Erweiterung + +Für Zeugnisgeneration oder andere Dokumenttypen: + +```python +DOC_TYPES = { + "EWH": "Erwartungshorizont", + "ZEUGNIS_VORLAGE": "Zeugnisvorlage", + "NOTENSPIEGEL": "Notenspiegel", + "BEMERKUNG": "Bemerkungstexte", +} +``` + +## Rechtliche Hinweise + +- NiBiS-Daten sind unter den [NiBiS-Nutzungsbedingungen](https://nibis.de) frei nutzbar +- `training_allowed: true` - Strukturelles Wissen darf für KI-Training genutzt werden +- Für Lehrer-eigene Erwartungshorizonte (BYOEH) gilt: `training_allowed: false` + +## Troubleshooting + +### Qdrant nicht erreichbar + +```bash +# Prüfen ob Qdrant läuft +curl http://localhost:6333/health + +# Docker starten +docker-compose up -d qdrant +``` + +### OpenAI API Fehler + +```bash +# API Key setzen +export OPENAI_API_KEY=sk-... +``` + +### PDF-Extraktion fehlgeschlagen + +Einige PDFs können problematisch sein (gescannte Dokumente ohne OCR). Diese werden übersprungen und im Error-Log protokolliert. + +## Performance + +- ~500-1000 Chunks pro Minute (abhängig von OpenAI API) +- ~2-3 GB Qdrant Storage für alle NiBiS-Daten (2016-2025) +- Embeddings werden nur einmal generiert (idempotent via Hash) diff --git a/docs-src/services/klausur-service/OCR-Compare.md b/docs-src/services/klausur-service/OCR-Compare.md index 449f8d0..34e093a 100644 --- a/docs-src/services/klausur-service/OCR-Compare.md +++ b/docs-src/services/klausur-service/OCR-Compare.md @@ -1,366 +1,235 @@ -# OCR Compare Tool - Dokumentation +# OCR Compare - Block Review Feature **Status:** Produktiv -**Version:** 4.0 **Letzte Aktualisierung:** 2026-02-08 **URL:** https://macmini:3002/ai/ocr-compare --- -## Übersicht +## Uebersicht -Das OCR Compare Tool ermöglicht die automatische Analyse von gescannten Vokabeltabellen mit: -- Grid-basierter OCR-Erkennung -- Automatischer Spalten-Erkennung (Englisch/Deutsch/Beispiel) -- mm-Koordinatensystem für präzise Positionierung -- Deskew-Korrektur für schiefe Scans -- Export zum Worksheet-Editor +Das OCR Compare Tool ermoeglicht den Vergleich verschiedener OCR-Methoden zur Texterkennung aus gescannten Dokumenten. Die Block Review Funktion erlaubt eine zellenweise Ueberpruefung und Korrektur der OCR-Ergebnisse. + +### Hauptfunktionen + +| Feature | Beschreibung | +|---------|--------------| +| **Multi-Method OCR** | Vergleich von Vision LLM, Tesseract, PaddleOCR und Claude Vision | +| **Grid Detection** | Automatische Erkennung von Tabellenstrukturen | +| **Block Review** | Zellenweise Ueberpruefung und Korrektur | +| **Session Persistence** | Sessions bleiben bei Seitenwechsel erhalten | +| **High-Resolution Display** | Hochaufloesende Bildanzeige (zoom=2.0) | --- ## Architektur ``` -┌─────────────────────────────────────────────────────────────────────┐ -│ Frontend (admin-v2) │ -│ /admin-v2/app/(admin)/ai/ocr-compare/page.tsx │ -│ - Bild-Upload │ -│ - Grid-Overlay Visualisierung │ -│ - Cell-Edit Popup │ -│ - Export zum Worksheet-Editor │ -└─────────────────────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────────┐ +│ admin-v2 (Next.js) │ +│ /app/(admin)/ai/ocr-compare/page.tsx │ +│ - PDF Upload & Session Management │ +│ - Grid Visualization mit SVG Overlay │ +│ - Block Review Panel │ +└─────────────────────────────────────────────────────────────┘ │ ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ klausur-service (FastAPI) │ -│ Port 8086 - /klausur-service/backend/ │ -│ - /api/v1/ocr/analyze-grid (Grid-Analyse) │ -│ - services/grid_detection_service.py (v4) │ -└─────────────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────────────┐ -│ PaddleOCR Service │ -│ Port 8088 - OCR-Erkennung │ -└─────────────────────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────────┐ +│ klausur-service (FastAPI) │ +│ Port 8086 │ +│ - /api/v1/vocab/sessions (Session CRUD) │ +│ - /api/v1/vocab/sessions/{id}/pdf-thumbnail (Bild-Export) │ +│ - /api/v1/vocab/sessions/{id}/detect-grid (Grid-Erkennung) │ +│ - /api/v1/vocab/sessions/{id}/run-ocr (OCR-Ausfuehrung) │ +└─────────────────────────────────────────────────────────────┘ ``` --- -## Features (Version 4) +## Komponenten -### 1. mm-Koordinatensystem +### GridOverlay -Alle Koordinaten werden im A4-Format (210x297mm) ausgegeben: +SVG-Overlay zur Visualisierung der erkannten Grid-Struktur. -| Feld | Beschreibung | -|------|--------------| -| `x_mm` | X-Position in mm (0-210) | -| `y_mm` | Y-Position in mm (0-297) | -| `width_mm` | Breite in mm | -| `height_mm` | Höhe in mm | +**Datei:** `/admin-v2/components/ocr/GridOverlay.tsx` -**Konvertierung:** ```typescript -// Prozent zu mm -const x_mm = (x_percent / 100) * 210 -const y_mm = (y_percent / 100) * 297 - -// mm zu Pixel (für Canvas bei 96 DPI) -const MM_TO_PX = 3.7795275591 -const x_px = x_mm * MM_TO_PX -``` - -### 2. Deskew-Korrektur - -Automatische Ausrichtung schiefer Scans basierend auf der ersten Spalte: - -1. **Erkennung:** Alle Wörter in der ersten Spalte (x < 33%) werden analysiert -2. **Berechnung:** Lineare Regression auf den linken Kanten -3. **Korrektur:** Rotation aller Koordinaten um den berechneten Winkel -4. **Limitierung:** Maximal ±5° Korrektur - -```python -# Deskew-Winkel im Response -{ - "deskew_angle_deg": -1.2, # Negativer Wert = nach links geneigt - ... +interface GridOverlayProps { + grid: GridData + imageUrl?: string + onCellClick?: (cell: GridCell) => void + selectedCell?: GridCell | null + showEmpty?: boolean // Leere Zellen anzeigen + showLabels?: boolean // Spaltenlabels (EN, DE, Ex) + showNumbers?: boolean // Block-Nummern anzeigen + highlightedBlockNumber?: number | null // Hervorgehobener Block + className?: string } ``` -### 3. Spalten-Erkennung mit 1mm Margin +**Zellenstatus-Farben:** -Spalten werden automatisch erkannt und beginnen 1mm vor dem ersten Wort: +| Status | Farbe | Bedeutung | +|--------|-------|-----------| +| `recognized` | Gruen | Text erfolgreich erkannt | +| `problematic` | Orange | Niedriger Confidence-Wert | +| `manual` | Blau | Manuell korrigiert | +| `empty` | Transparent | Keine Erkennung | -```json -{ - "detected_columns": [ - { - "column_type": "english", - "x_start": 9.52, // Prozent - "x_end": 35.0, - "x_start_mm": 20.0, // mm (1mm vor erstem Wort) - "x_end_mm": 73.5, - "word_count": 15 - }, - { - "column_type": "german", - "x_start_mm": 74.0, - "x_end_mm": 140.0, - "word_count": 15 - }, - { - "column_type": "example", - "x_start_mm": 141.0, - "x_end_mm": 200.0, - "word_count": 12 - } - ] +### BlockReviewPanel + +Panel zur Block-fuer-Block Ueberpruefung der OCR-Ergebnisse. + +**Datei:** `/admin-v2/components/ocr/BlockReviewPanel.tsx` + +```typescript +interface BlockReviewPanelProps { + grid: GridData + methodResults: Record }> + currentBlockNumber: number + onBlockChange: (blockNumber: number) => void + onApprove: (blockNumber: number, methodId: string, text: string) => void + onCorrect: (blockNumber: number, correctedText: string) => void + onSkip: (blockNumber: number) => void + reviewData: Record + className?: string } ``` -### 4. Zellen-Status +**Review-Status:** | Status | Beschreibung | |--------|--------------| -| `empty` | Keine OCR-Erkennung in dieser Zelle | -| `recognized` | Text erkannt mit Confidence ≥ 50% | -| `problematic` | Text erkannt mit Confidence < 50% | -| `manual` | Manuell korrigiert | +| `pending` | Noch nicht ueberprueft | +| `approved` | OCR-Ergebnis akzeptiert | +| `corrected` | Manuell korrigiert | +| `skipped` | Uebersprungen | ---- +### BlockReviewSummary -## API-Endpoints +Zusammenfassung aller ueberprueften Bloecke. -### POST /api/v1/ocr/analyze-grid - -Analysiert ein Bild und erkennt die Vokabeltabellen-Struktur. - -**Request:** -```json -{ - "image_base64": "data:image/jpeg;base64,...", - "min_confidence": 0.5, - "padding": 2.0 -} -``` - -**Response:** -```json -{ - "cells": [ - [ - { - "row": 0, - "col": 0, - "x": 10.0, - "y": 15.0, - "width": 25.0, - "height": 3.0, - "x_mm": 21.0, - "y_mm": 44.55, - "width_mm": 52.5, - "height_mm": 8.91, - "text": "house", - "confidence": 0.95, - "status": "recognized", - "column_type": "english", - "logical_row": 0, - "logical_col": 0 - } - ] - ], - "detected_columns": [...], - "page_dimensions": { - "width_mm": 210.0, - "height_mm": 297.0, - "format": "A4" - }, - "deskew_angle_deg": -0.5, - "statistics": { - "total_cells": 45, - "recognized_cells": 42, - "problematic_cells": 3, - "empty_cells": 0 - } +```typescript +interface BlockReviewSummaryProps { + reviewData: Record + totalBlocks: number + onBlockClick: (blockNumber: number) => void + className?: string } ``` --- -## Frontend-Komponenten +## OCR-Methoden -### GridOverlay.tsx - -Zeigt die erkannten Zellen als farbiges Overlay über dem Bild. - -**Props:** -```typescript -interface GridOverlayProps { - cells: GridCell[][] - imageWidth: number - imageHeight: number - showLabels?: boolean - onCellClick?: (cell: GridCell) => void -} -``` - -**Farbkodierung:** -- Grün: `recognized` (gut erkannt) -- Gelb: `problematic` (niedrige Confidence) -- Grau: `empty` -- Blau: `manual` (manuell korrigiert) - -### CellEditPopup.tsx - -Popup zum Bearbeiten einer Zelle. - -**Features:** -- Text bearbeiten -- Spaltentyp ändern (English/German/Example) -- Confidence anzeigen -- mm-Koordinaten anzeigen -- Keyboard-Shortcuts: Ctrl+Enter (Speichern), Esc (Abbrechen) +| ID | Name | Beschreibung | +|----|------|--------------| +| `vision_llm` | Vision LLM | Qwen VL 32B ueber Ollama | +| `tesseract` | Tesseract | Klassisches OCR (lokal) | +| `paddleocr` | PaddleOCR | PaddleOCR Engine | +| `claude_vision` | Claude Vision | Anthropic Claude Vision API | --- -## Worksheet-Editor Integration +## API Endpoints -### Export +### Session Management -Der "Zum Editor exportieren" Button speichert die OCR-Daten in localStorage: +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| POST | `/api/v1/vocab/upload-pdf-info` | PDF hochladen | +| GET | `/api/v1/vocab/sessions/{id}` | Session-Details | +| DELETE | `/api/v1/vocab/sessions/{id}` | Session loeschen | -```typescript -interface OCRExportData { - version: '1.0' - source: 'ocr-compare' - exported_at: string - session_id: string - page_number: number - page_dimensions: { - width_mm: number - height_mm: number - format: string - } - words: OCRWord[] - detected_columns: DetectedColumn[] +### Bildexport + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| GET | `/api/v1/vocab/sessions/{id}/pdf-thumbnail/{page}` | Thumbnail (zoom=0.5) | +| GET | `/api/v1/vocab/sessions/{id}/pdf-thumbnail/{page}?hires=true` | High-Res (zoom=2.0) | + +### Grid-Erkennung + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| POST | `/api/v1/vocab/sessions/{id}/detect-grid` | Grid-Struktur erkennen | +| POST | `/api/v1/vocab/sessions/{id}/run-ocr` | OCR auf Grid ausfuehren | + +--- + +## Session Persistence + +Die aktive Session wird im localStorage gespeichert: + +```javascript +// Speichern +localStorage.setItem('ocr-compare-active-session', sessionId) + +// Wiederherstellen beim Seitenladen +const lastSessionId = localStorage.getItem('ocr-compare-active-session') +if (lastSessionId) { + // Session-Daten laden } ``` -**localStorage Keys:** -- `ocr_export_{session_id}_{page_number}`: Export-Daten -- `ocr_export_latest`: Referenz zum neuesten Export +--- -### Import im Worksheet-Editor +## Block Review Workflow -1. Öffnen Sie den Worksheet-Editor: https://macmini/worksheet-editor -2. Klicken Sie auf den OCR-Import Button (grünes Icon) -3. Die Wörter werden auf dem Canvas platziert +1. **PDF hochladen** - Dokument in das System laden +2. **Grid erkennen** - Automatische Tabellenerkennung +3. **OCR ausfuehren** - Alle Methoden parallel ausfuehren +4. **Block Review starten** - "Block Review" Button klicken +5. **Bloecke pruefen** - Fuer jeden Block: + - Ergebnisse aller Methoden vergleichen + - Bestes Ergebnis waehlen oder manuell korrigieren +6. **Zusammenfassung** - Uebersicht der Korrekturen + +--- + +## High-Resolution Bilder + +Fuer die Anzeige werden hochaufloesende Bilder verwendet: -**Konvertierung mm → Pixel:** ```typescript -const MM_TO_PX = 3.7795275591 -const x_px = word.x_mm * MM_TO_PX -const y_px = word.y_mm * MM_TO_PX +// Thumbnail URL mit High-Resolution Parameter +const imageUrl = `${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/pdf-thumbnail/${pageNumber}?hires=true` ``` +| Parameter | Zoom | Verwendung | +|-----------|------|------------| +| Ohne `hires` | 0.5 | Vorschau/Thumbnails | +| Mit `hires=true` | 2.0 | Anzeige/OCR | + --- ## Dateien -### Backend (klausur-service) - -| Datei | Beschreibung | -|-------|--------------| -| `services/grid_detection_service.py` | Grid-Erkennung v4 mit Deskew | -| `tests/test_grid_detection.py` | Unit Tests | - ### Frontend (admin-v2) | Datei | Beschreibung | |-------|--------------| | `app/(admin)/ai/ocr-compare/page.tsx` | Haupt-UI | -| `components/ocr/GridOverlay.tsx` | Grid-Visualisierung | -| `components/ocr/CellEditPopup.tsx` | Zellen-Editor | +| `components/ocr/GridOverlay.tsx` | SVG Grid-Overlay | +| `components/ocr/BlockReviewPanel.tsx` | Review-Panel | +| `components/ocr/CellCorrectionDialog.tsx` | Korrektur-Dialog | +| `components/ocr/index.ts` | Exports | -### Frontend (studio-v2) +### Backend (klausur-service) | Datei | Beschreibung | |-------|--------------| -| `lib/worksheet-editor/ocr-integration.ts` | OCR Import/Export Utility | -| `app/worksheet-editor/page.tsx` | Editor mit OCR-Import | -| `components/worksheet-editor/EditorToolbar.tsx` | Toolbar mit OCR-Button | +| `vocab_worksheet_api.py` | API-Router | +| `hybrid_vocab_extractor.py` | OCR-Extraktion | --- -## Deployment +## Aenderungshistorie -```bash -# 1. Backend synchronisieren -scp grid_detection_service.py macmini:.../klausur-service/backend/services/ - -# 2. Tests synchronisieren -scp test_grid_detection.py macmini:.../klausur-service/backend/tests/ - -# 3. klausur-service neu bauen -ssh macmini "docker compose build --no-cache klausur-service" - -# 4. Container starten -ssh macmini "docker compose up -d klausur-service" - -# 5. Frontend (admin-v2) deployen -ssh macmini "docker compose build --no-cache admin-v2 && docker compose up -d admin-v2" -``` - ---- - -## Verwendete Open-Source-Bibliotheken - -| Bibliothek | Version | Lizenz | Verwendung | -|------------|---------|--------|------------| -| NumPy | ≥1.24 | BSD-3-Clause | Deskew-Berechnung (polyfit) | -| OpenCV | ≥4.8 | Apache-2.0 | Bildverarbeitung (optional) | -| PaddleOCR | 2.7 | Apache-2.0 | OCR-Erkennung | -| Fabric.js | 6.x | MIT | Canvas-Rendering (Frontend) | - ---- - -## Fehlerbehandlung - -### Häufige Probleme - -| Problem | Lösung | -|---------|--------| -| "Grid analysieren" lädt nicht | klausur-service Container prüfen | -| Keine Zellen erkannt | Min. Confidence reduzieren | -| Falsche Spalten-Zuordnung | Manuell im CellEditPopup korrigieren | -| Export funktioniert nicht | Browser-Console auf Fehler prüfen | - -### Logging - -```bash -# klausur-service Logs -docker logs breakpilot-pwa-klausur-service --tail=100 - -# Grid Detection spezifisch -docker logs breakpilot-pwa-klausur-service 2>&1 | grep "grid_detection" -``` - ---- - -## Änderungshistorie - -| Version | Datum | Änderungen | -|---------|-------|------------| -| 4.0 | 2026-02-08 | Deskew-Korrektur, 1mm Column Margin | -| 3.0 | 2026-02-07 | mm-Koordinatensystem | -| 2.0 | 2026-02-06 | Spalten-Erkennung | -| 1.0 | 2026-02-05 | Initiale Implementierung | - ---- - -## Referenzen - -- [Worksheet-Editor Architektur](Worksheet-Editor-Architecture.md) -- [OCR Labeling Spec](OCR-Labeling-Spec.md) -- [SBOM](/infrastructure/sbom) +| Datum | Aenderung | +|-------|-----------| +| 2026-02-08 | Block Review Feature hinzugefuegt | +| 2026-02-08 | High-Resolution Bilder aktiviert | +| 2026-02-08 | Session Persistence implementiert | +| 2026-02-07 | Grid Detection und Multi-Method OCR | diff --git a/docs-src/services/klausur-service/OCR-Labeling-Spec.md b/docs-src/services/klausur-service/OCR-Labeling-Spec.md new file mode 100644 index 0000000..17f9754 --- /dev/null +++ b/docs-src/services/klausur-service/OCR-Labeling-Spec.md @@ -0,0 +1,445 @@ +# OCR-Labeling System Spezifikation + +**Version:** 1.1.0 +**Status:** In Produktion (Mac Mini) + +## Übersicht + +Das OCR-Labeling System ermöglicht das Erstellen von Trainingsdaten für Handschrift-OCR-Modelle aus eingescannten Klausuren. Es unterstützt folgende OCR-Modelle: + +| Modell | Beschreibung | Geschwindigkeit | Empfohlen für | +|--------|--------------|-----------------|---------------| +| **llama3.2-vision:11b** | Vision-LLM (Standard) | Langsam | Handschrift, beste Qualität | +| **TrOCR** | Microsoft Transformer | Schnell | Gedruckter Text | +| **PaddleOCR + LLM** | Hybrid-Ansatz (NEU) | Sehr schnell (4x) | Gemischte Dokumente | +| **Donut** | Document Understanding (NEU) | Mittel | Tabellen, Formulare | +| **qwen2.5:14b** | Korrektur-LLM | - | Klausurbewertung | + +### Neue OCR-Optionen (v1.1.0) + +#### PaddleOCR + LLM (Empfohlen für Geschwindigkeit) + +PaddleOCR ist ein zweistufiger Ansatz: +1. **PaddleOCR** - Schnelle, präzise Texterkennung mit Bounding-Boxes +2. **qwen2.5:14b** - Semantische Strukturierung des erkannten Texts + +**Vorteile:** +- 4x schneller als Vision-LLM (~7-15 Sek vs 30-60 Sek pro Seite) +- Höhere Genauigkeit bei gedrucktem Text (95-99%) +- Weniger Halluzinationen (LLM korrigiert nur, erfindet nicht) +- Position-basierte Spaltenerkennung möglich + +**Dateien:** +- `/klausur-service/backend/hybrid_vocab_extractor.py` - PaddleOCR Integration + +#### Donut (Document Understanding Transformer) + +Donut ist speziell für strukturierte Dokumente optimiert: +- Tabellen und Formulare +- Rechnungen und Quittungen +- Multi-Spalten-Layouts + +**Dateien:** +- `/klausur-service/backend/services/donut_ocr_service.py` - Donut Service + +## Architektur + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ OCR-Labeling System │ +├──────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────────┐ ┌────────────────────────┐ │ +│ │ Frontend │◄──►│ Klausur-Service │◄──►│ PostgreSQL │ │ +│ │ (Next.js) │ │ (FastAPI) │ │ - ocr_labeling_sessions│ │ +│ │ Port 3000 │ │ Port 8086 │ │ - ocr_labeling_items │ │ +│ └─────────────┘ └────────┬─────────┘ │ - ocr_training_samples │ │ +│ │ └────────────────────────┘ │ +│ │ │ +│ ┌──────────┼──────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌───────────┐ ┌─────────┐ ┌───────────────┐ │ +│ │ MinIO │ │ Ollama │ │ Export Service │ │ +│ │ (Images) │ │ (OCR) │ │ (Training) │ │ +│ │ Port 9000 │ │ :11434 │ │ │ │ +│ └───────────┘ └─────────┘ └───────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +## Datenmodell + +### PostgreSQL Tabellen + +```sql +-- Labeling Sessions (gruppiert zusammengehörige Bilder) +CREATE TABLE ocr_labeling_sessions ( + id VARCHAR(36) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + source_type VARCHAR(50) NOT NULL, -- 'klausur', 'handwriting_sample', 'scan' + description TEXT, + ocr_model VARCHAR(100), -- z.B. 'llama3.2-vision:11b' + total_items INTEGER DEFAULT 0, + labeled_items INTEGER DEFAULT 0, + confirmed_items INTEGER DEFAULT 0, + corrected_items INTEGER DEFAULT 0, + skipped_items INTEGER DEFAULT 0, + teacher_id VARCHAR(100), + created_at TIMESTAMP DEFAULT NOW() +); + +-- Einzelne Labeling Items (Bild + OCR + Ground Truth) +CREATE TABLE ocr_labeling_items ( + id VARCHAR(36) PRIMARY KEY, + session_id VARCHAR(36) REFERENCES ocr_labeling_sessions(id), + image_path TEXT NOT NULL, -- MinIO Pfad oder lokaler Pfad + image_hash VARCHAR(64), -- SHA256 für Deduplizierung + ocr_text TEXT, -- Von LLM erkannter Text + ocr_confidence FLOAT, -- Konfidenz (0-1) + ocr_model VARCHAR(100), + ground_truth TEXT, -- Korrigierter/bestätigter Text + status VARCHAR(20) DEFAULT 'pending', -- pending/confirmed/corrected/skipped + labeled_by VARCHAR(100), + labeled_at TIMESTAMP, + label_time_seconds INTEGER, + metadata JSONB, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Exportierte Training Samples +CREATE TABLE ocr_training_samples ( + id VARCHAR(36) PRIMARY KEY, + item_id VARCHAR(36) REFERENCES ocr_labeling_items(id), + image_path TEXT NOT NULL, + ground_truth TEXT NOT NULL, + export_format VARCHAR(50) NOT NULL, -- 'generic', 'trocr', 'llama_vision' + exported_at TIMESTAMP DEFAULT NOW(), + training_batch VARCHAR(100), + used_in_training BOOLEAN DEFAULT FALSE +); +``` + +## API Referenz + +Base URL: `http://macmini:8086/api/v1/ocr-label` + +### Sessions + +#### POST /sessions +Neue Labeling-Session erstellen. + +**Request:** +```json +{ + "name": "Klausur Deutsch 12a Q1", + "source_type": "klausur", + "description": "Gedichtanalyse Expressionismus", + "ocr_model": "llama3.2-vision:11b" +} +``` + +**Response:** +```json +{ + "id": "abc-123-def", + "name": "Klausur Deutsch 12a Q1", + "source_type": "klausur", + "total_items": 0, + "labeled_items": 0, + "created_at": "2026-01-21T10:30:00Z" +} +``` + +#### GET /sessions +Sessions auflisten. + +**Query Parameter:** +- `limit` (int, default: 50) - Maximale Anzahl + +#### GET /sessions/{session_id} +Einzelne Session abrufen. + +### Upload + +#### POST /sessions/{session_id}/upload +Bilder zu einer Session hochladen. + +**Request:** Multipart Form Data +- `files` (File[]) - PNG/JPG/PDF Dateien +- `run_ocr` (bool, default: true) - OCR direkt ausführen +- `metadata` (JSON string) - Optional: Metadaten + +**Response:** +```json +{ + "session_id": "abc-123-def", + "uploaded_count": 5, + "items": [ + { + "id": "item-1", + "filename": "scan_001.png", + "image_path": "ocr-labeling/abc-123/item-1.png", + "ocr_text": "Die Lösung der Aufgabe...", + "ocr_confidence": 0.87, + "status": "pending" + } + ] +} +``` + +### Labeling Queue + +#### GET /queue +Nächste zu labelnde Items abrufen. + +**Query Parameter:** +- `session_id` (str, optional) - Nach Session filtern +- `status` (str, default: "pending") - Status-Filter +- `limit` (int, default: 10) - Maximale Anzahl + +**Response:** +```json +[ + { + "id": "item-456", + "session_id": "abc-123", + "session_name": "Klausur Deutsch", + "image_path": "/app/ocr-labeling/abc-123/item-456.png", + "image_url": "/api/v1/ocr-label/images/abc-123/item-456.png", + "ocr_text": "Erkannter Text...", + "ocr_confidence": 0.87, + "ground_truth": null, + "status": "pending", + "metadata": {"page": 1} + } +] +``` + +### Labeling Actions + +#### POST /confirm +OCR-Text als korrekt bestätigen. + +**Request:** +```json +{ + "item_id": "item-456", + "label_time_seconds": 5 +} +``` + +**Effect:** `ground_truth = ocr_text`, `status = 'confirmed'` + +#### POST /correct +Ground Truth korrigieren. + +**Request:** +```json +{ + "item_id": "item-456", + "ground_truth": "Korrigierter Text hier", + "label_time_seconds": 15 +} +``` + +**Effect:** `ground_truth = `, `status = 'corrected'` + +#### POST /skip +Item überspringen (unbrauchbar). + +**Request:** +```json +{ + "item_id": "item-456" +} +``` + +**Effect:** `status = 'skipped'` (wird nicht exportiert) + +### Statistiken + +#### GET /stats +Labeling-Statistiken abrufen. + +**Query Parameter:** +- `session_id` (str, optional) - Für Session-spezifische Stats + +**Response:** +```json +{ + "total_items": 100, + "labeled_items": 75, + "confirmed_items": 60, + "corrected_items": 15, + "pending_items": 25, + "accuracy_rate": 0.80, + "avg_label_time_seconds": 8.5 +} +``` + +### Training Export + +#### POST /export +Trainingsdaten exportieren. + +**Request:** +```json +{ + "export_format": "trocr", + "session_id": "abc-123", + "batch_id": "batch_20260121" +} +``` + +**Export Formate:** + +| Format | Beschreibung | Output | +|--------|--------------|--------| +| `generic` | Allgemeines JSONL | `{"id", "image_path", "ground_truth", ...}` | +| `trocr` | Microsoft TrOCR | `{"file_name", "text", "id"}` | +| `llama_vision` | Llama 3.2 Vision | OpenAI-style Messages mit image_url | + +**Response:** +```json +{ + "export_format": "trocr", + "batch_id": "batch_20260121", + "exported_count": 75, + "export_path": "/app/ocr-exports/trocr/batch_20260121", + "manifest_path": "/app/ocr-exports/trocr/batch_20260121/manifest.json", + "samples": [...] +} +``` + +#### GET /exports +Verfügbare Exports auflisten. + +**Query Parameter:** +- `export_format` (str, optional) - Nach Format filtern + +## Export Formate im Detail + +### TrOCR Format + +``` +batch_20260121/ +├── manifest.json +├── train.jsonl +└── images/ + ├── item-1.png + └── item-2.png +``` + +**train.jsonl:** +```jsonl +{"file_name": "images/item-1.png", "text": "Ground truth text", "id": "item-1"} +{"file_name": "images/item-2.png", "text": "Another text", "id": "item-2"} +``` + +### Llama Vision Format + +```jsonl +{ + "id": "item-1", + "messages": [ + {"role": "system", "content": "Du bist ein OCR-Experte für deutsche Handschrift..."}, + {"role": "user", "content": [ + {"type": "image_url", "image_url": {"url": "images/item-1.png"}}, + {"type": "text", "text": "Lies den handgeschriebenen Text in diesem Bild."} + ]}, + {"role": "assistant", "content": "Ground truth text"} + ] +} +``` + +### Generic Format + +```jsonl +{ + "id": "item-1", + "image_path": "images/item-1.png", + "ground_truth": "Ground truth text", + "ocr_text": "OCR recognized text", + "ocr_confidence": 0.87, + "metadata": {"page": 1, "session": "Deutsch 12a"} +} +``` + +## Frontend Integration + +Die OCR-Labeling UI ist unter `/admin/ocr-labeling` verfügbar. + +### Keyboard Shortcuts + +| Taste | Aktion | +|-------|--------| +| `Enter` | Bestätigen (OCR korrekt) | +| `Tab` | Ins Korrekturfeld springen | +| `Escape` | Überspringen | +| `←` / `→` | Navigation (Prev/Next) | + +### Workflow + +1. **Session erstellen** - Name, Typ, OCR-Modell wählen +2. **Bilder hochladen** - Drag & Drop oder File-Browser +3. **Labeling durchführen** - Bild + OCR-Text vergleichen + - Korrekt → Bestätigen (Enter) + - Falsch → Korrigieren + Speichern + - Unbrauchbar → Überspringen +4. **Export** - Format wählen (TrOCR, Llama Vision, Generic) +5. **Training starten** - Export-Ordner für Fine-Tuning nutzen + +## Umgebungsvariablen + +```bash +# PostgreSQL +DATABASE_URL=postgres://user:pass@postgres:5432/breakpilot_db + +# MinIO (S3-kompatibel) +MINIO_ENDPOINT=minio:9000 +MINIO_ACCESS_KEY=breakpilot +MINIO_SECRET_KEY=breakpilot123 +MINIO_BUCKET=breakpilot-rag +MINIO_SECURE=false + +# Ollama (Vision-LLM) +OLLAMA_BASE_URL=http://host.docker.internal:11434 +OLLAMA_VISION_MODEL=llama3.2-vision:11b +OLLAMA_CORRECTION_MODEL=qwen2.5:14b + +# Export +OCR_EXPORT_PATH=/app/ocr-exports +OCR_STORAGE_PATH=/app/ocr-labeling +``` + +## Sicherheit & Datenschutz + +- **100% Lokale Verarbeitung** - Alle Daten bleiben auf dem Mac Mini +- **Keine Cloud-Uploads** - Ollama läuft vollständig offline +- **DSGVO-konform** - Keine Schülerdaten verlassen das Schulnetzwerk +- **Deduplizierung** - SHA256-Hash verhindert doppelte Bilder + +## Dateien + +| Datei | Beschreibung | +|-------|--------------| +| `klausur-service/backend/ocr_labeling_api.py` | FastAPI Router mit OCR Model Dispatcher | +| `klausur-service/backend/training_export_service.py` | Export-Service für TrOCR/Llama | +| `klausur-service/backend/metrics_db.py` | PostgreSQL CRUD Funktionen | +| `klausur-service/backend/minio_storage.py` | MinIO OCR-Image Storage | +| `klausur-service/backend/hybrid_vocab_extractor.py` | PaddleOCR Integration | +| `klausur-service/backend/services/donut_ocr_service.py` | Donut OCR Service (NEU) | +| `klausur-service/backend/services/trocr_service.py` | TrOCR Service (NEU) | +| `website/app/admin/ocr-labeling/page.tsx` | Frontend UI mit Model-Auswahl | +| `website/app/admin/ocr-labeling/types.ts` | TypeScript Interfaces inkl. OCRModel Type | + +## Tests + +```bash +# Backend-Tests ausführen +cd klausur-service/backend +pytest tests/test_ocr_labeling.py -v + +# Mit Coverage +pytest tests/test_ocr_labeling.py --cov=. --cov-report=html +``` diff --git a/docs-src/services/klausur-service/RAG-Admin-Spec.md b/docs-src/services/klausur-service/RAG-Admin-Spec.md new file mode 100644 index 0000000..76e4eb7 --- /dev/null +++ b/docs-src/services/klausur-service/RAG-Admin-Spec.md @@ -0,0 +1,472 @@ +# RAG & Daten-Management Spezifikation + +## Übersicht + +Admin-Frontend für die Verwaltung von Trainingsdaten und RAG-Systemen in BreakPilot. + +**Location**: `/admin/docs` → Tab "Daten & RAG" +**Backend**: `klausur-service` (Port 8086) +**Storage**: MinIO (persistentes Docker Volume `minio_data`) +**Vector DB**: Qdrant (Port 6333) + +## Datenmodell + +### Zwei Datentypen mit unterschiedlichen Regeln + +| Typ | Quelle | Training erlaubt | Isolation | Collection | +|-----|--------|------------------|-----------|------------| +| **Landes-Daten** | NiBiS, andere Bundesländer | ✅ Ja | Pro Bundesland | `bp_{bundesland}_{usecase}` | +| **Lehrer-Daten** | Lehrer-Upload (BYOEH) | ❌ Nein | Pro Tenant (Schule/Lehrer) | `bp_eh` (verschlüsselt) | + +### Bundesland-Codes (ISO 3166-2:DE) + +``` +NI = Niedersachsen BY = Bayern BW = Baden-Württemberg +NW = Nordrhein-Westf. HE = Hessen SN = Sachsen +BE = Berlin HH = Hamburg SH = Schleswig-Holstein +BB = Brandenburg MV = Meckl.-Vorp. ST = Sachsen-Anhalt +TH = Thüringen RP = Rheinland-Pfalz SL = Saarland +HB = Bremen +``` + +### Use Cases (RAG-Sammlungen) + +| Use Case | Collection Pattern | Beschreibung | +|----------|-------------------|--------------| +| Klausurkorrektur | `bp_{bl}_klausur` | Erwartungshorizonte für Abitur | +| Zeugnisgenerator | `bp_{bl}_zeugnis` | Textbausteine für Zeugnisse | +| Lehrplan | `bp_{bl}_lehrplan` | Kerncurricula, Rahmenrichtlinien | + +Beispiel: `bp_ni_klausur` = Niedersachsen Klausurkorrektur + +## MinIO Bucket-Struktur + +``` +breakpilot-rag/ +├── landes-daten/ +│ ├── ni/ # Niedersachsen +│ │ ├── klausur/ +│ │ │ ├── 2016/ +│ │ │ │ ├── manifest.json +│ │ │ │ └── *.pdf +│ │ │ ├── 2017/ +│ │ │ ├── ... +│ │ │ └── 2025/ +│ │ └── zeugnis/ +│ ├── by/ # Bayern +│ └── .../ +│ +└── lehrer-daten/ # BYOEH - verschlüsselt + └── {tenant_id}/ + └── {lehrer_id}/ + └── *.pdf.enc +``` + +## Qdrant Schema + +### Landes-Daten Collection (z.B. `bp_ni_klausur`) + +```json +{ + "id": "uuid-v5-from-string", + "vector": [384 dimensions], + "payload": { + "original_id": "nibis_2024_deutsch_ea_1_abc123_chunk_0", + "doc_id": "nibis_2024_deutsch_ea_1_abc123", + "chunk_index": 0, + "text": "Der Erwartungshorizont...", + "year": 2024, + "subject": "Deutsch", + "niveau": "eA", + "task_number": 1, + "doc_type": "EWH", + "bundesland": "NI", + "source": "nibis", + "training_allowed": true, + "minio_path": "landes-daten/ni/klausur/2024/2024_Deutsch_eA_I_EWH.pdf" + } +} +``` + +### Lehrer-Daten Collection (`bp_eh`) + +```json +{ + "id": "uuid", + "vector": [384 dimensions], + "payload": { + "tenant_id": "schule_123", + "eh_id": "eh_abc", + "chunk_index": 0, + "subject": "deutsch", + "encrypted_content": "base64...", + "training_allowed": false + } +} +``` + +## Frontend-Komponenten + +### 1. Sammlungen-Übersicht (`/admin/rag/collections`) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Daten & RAG │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Sammlungen [+ Neu] │ +│ ───────────────────────────────────────────────────────────── │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 📚 Niedersachsen - Klausurkorrektur │ │ +│ │ bp_ni_klausur | 630 Docs | 4.521 Chunks | 2016-2025 │ │ +│ │ [Suchen] [Indexieren] [Details] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 📚 Niedersachsen - Zeugnisgenerator │ │ +│ │ bp_ni_zeugnis | 0 Docs | Leer │ │ +│ │ [Suchen] [Indexieren] [Details] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 2. Upload-Bereich (`/admin/rag/upload`) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Dokumente hochladen │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Ziel-Sammlung: [Niedersachsen - Klausurkorrektur ▼] │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ 📁 ZIP-Datei oder Ordner hierher ziehen │ │ +│ │ │ │ +│ │ oder [Dateien auswählen] │ │ +│ │ │ │ +│ │ Unterstützt: .zip, .pdf, Ordner │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ Upload-Queue: │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ ✅ 2018.zip - 45 PDFs erkannt │ │ +│ │ ⏳ 2019.zip - Wird analysiert... │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ [Hochladen & Indexieren] │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 3. Ingestion-Status (`/admin/rag/ingestion`) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Ingestion Status │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Aktueller Job: Niedersachsen Klausur 2024 │ +│ ████████████████████░░░░░░░░░░ 65% (412/630 Docs) │ +│ Chunks: 2.891 | Fehler: 3 | ETA: 4:32 │ +│ [Pausieren] [Abbrechen] │ +│ │ +│ ───────────────────────────────────────────────────────────── │ +│ │ +│ Letzte Jobs: │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ ✅ 09.01.2025 15:30 - NI Klausur 2024 - 128 Chunks │ │ +│ │ ✅ 09.01.2025 14:00 - NI Klausur 2017 - 890 Chunks │ │ +│ │ ❌ 08.01.2025 10:15 - BY Klausur - Fehler: Timeout │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 4. Suche & Qualitätstest (`/admin/rag/search`) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ RAG Suche & Qualitätstest │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Sammlung: [Niedersachsen - Klausurkorrektur ▼] │ +│ │ +│ Query: [Analyse eines Gedichts von Rilke ] │ +│ │ +│ Filter: │ +│ Jahr: [Alle ▼] Fach: [Deutsch ▼] Niveau: [eA ▼] │ +│ │ +│ [🔍 Suchen] │ +│ │ +│ ───────────────────────────────────────────────────────────── │ +│ │ +│ Ergebnisse (3): Latenz: 45ms │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ #1 | Score: 0.847 | 2024 Deutsch eA Aufgabe 2 │ │ +│ │ │ │ +│ │ "...Die Analyse des Rilke-Gedichts soll folgende │ │ +│ │ Aspekte berücksichtigen: Aufbau, Bildsprache..." │ │ +│ │ │ │ +│ │ Relevanz: [⭐⭐⭐⭐⭐] [⭐⭐⭐⭐] [⭐⭐⭐] [⭐⭐] [⭐] │ │ +│ │ Notizen: [Optional: Warum relevant/nicht relevant? ] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 5. Metriken-Dashboard (`/admin/rag/metrics`) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ RAG Qualitätsmetriken │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Zeitraum: [Letzte 7 Tage ▼] Sammlung: [Alle ▼] │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Precision@5 │ │ Recall@10 │ │ MRR │ │ +│ │ 0.78 │ │ 0.85 │ │ 0.72 │ │ +│ │ ↑ +5% │ │ ↑ +3% │ │ ↓ -2% │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Avg Latency │ │ Bewertungen │ │ Fehlerrate │ │ +│ │ 52ms │ │ 127 │ │ 0.3% │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +│ ───────────────────────────────────────────────────────────── │ +│ │ +│ Score-Verteilung: │ +│ 0.9+ ████████████████ 23% │ +│ 0.7+ ████████████████████████████ 41% │ +│ 0.5+ ████████████████████ 28% │ +│ <0.5 ██████ 8% │ +│ │ +│ [Export CSV] [Detailbericht] │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## API Endpoints + +### Collections API + +``` +GET /api/v1/admin/rag/collections +POST /api/v1/admin/rag/collections +GET /api/v1/admin/rag/collections/{id} +DELETE /api/v1/admin/rag/collections/{id} +GET /api/v1/admin/rag/collections/{id}/stats +``` + +### Upload API + +``` +POST /api/v1/admin/rag/upload + Content-Type: multipart/form-data + - file: ZIP oder PDF + - collection_id: string + - metadata: JSON (optional) + +POST /api/v1/admin/rag/upload/folder + - Für Ordner-Upload (WebKitDirectory) +``` + +### Ingestion API + +``` +POST /api/v1/admin/rag/ingest + - collection_id: string + - filters: {year?, subject?, doc_type?} + +GET /api/v1/admin/rag/ingest/status +GET /api/v1/admin/rag/ingest/history +POST /api/v1/admin/rag/ingest/cancel +``` + +### Search API + +``` +POST /api/v1/admin/rag/search + - query: string + - collection_id: string + - filters: {year?, subject?, niveau?} + - limit: int + +POST /api/v1/admin/rag/search/feedback + - result_id: string + - rating: 1-5 + - notes: string (optional) +``` + +### Metrics API + +``` +GET /api/v1/admin/rag/metrics + - collection_id?: string + - from_date?: date + - to_date?: date + +GET /api/v1/admin/rag/metrics/export + - format: csv|json +``` + +## Embedding-Konfiguration + +```python +# Default: Lokale Embeddings (kein API-Key nötig) +EMBEDDING_BACKEND = "local" +LOCAL_EMBEDDING_MODEL = "all-MiniLM-L6-v2" +VECTOR_DIMENSIONS = 384 + +# Optional: OpenAI (für Produktion) +EMBEDDING_BACKEND = "openai" +EMBEDDING_MODEL = "text-embedding-3-small" +VECTOR_DIMENSIONS = 1536 +``` + +## Datenpersistenz + +### Docker Volumes (WICHTIG - nicht löschen!) + +```yaml +volumes: + minio_data: # Alle hochgeladenen Dokumente + qdrant_data: # Alle Vektoren und Embeddings + postgres_data: # Metadaten, Bewertungen, History +``` + +### Backup-Strategie + +```bash +# MinIO Backup +docker exec breakpilot-pwa-minio mc mirror /data /backup + +# Qdrant Backup +curl -X POST http://localhost:6333/collections/bp_ni_klausur/snapshots + +# Postgres Backup (bereits implementiert) +# Läuft automatisch täglich um 2 Uhr +``` + +## Implementierungsreihenfolge + +1. ✅ Backend: Basis-Ingestion (nibis_ingestion.py) +2. ✅ Backend: Lokale Embeddings (sentence-transformers) +3. ✅ Backend: MinIO-Integration (minio_storage.py) +4. ✅ Backend: Collections API (admin_api.py) +5. ✅ Backend: Upload API mit ZIP-Support +6. ✅ Backend: Metrics API mit PostgreSQL (metrics_db.py) +7. ✅ Frontend: Sammlungen-Übersicht +8. ✅ Frontend: Upload-Bereich (Drag & Drop) +9. ✅ Frontend: Ingestion-Status +10. ✅ Frontend: Suche & Qualitätstest (mit Stern-Bewertungen) +11. ✅ Frontend: Metriken-Dashboard + +## Technologie-Stack + +- **Frontend**: Next.js 15 (`/website/app/admin/rag/page.tsx`) +- **Backend**: FastAPI (`klausur-service/backend/`) +- **Vector DB**: Qdrant v1.7.4 (384-dim Vektoren) +- **Object Storage**: MinIO (S3-kompatibel) +- **Embeddings**: sentence-transformers `all-MiniLM-L6-v2` +- **Metrics DB**: PostgreSQL 16 + +## Entwickler-Dokumentation + +### Projektstruktur + +``` +klausur-service/ +├── backend/ +│ ├── main.py # FastAPI App + BYOEH Endpoints +│ ├── admin_api.py # RAG Admin API (Upload, Search, Metrics) +│ ├── nibis_ingestion.py # NiBiS Dokument-Ingestion Pipeline +│ ├── eh_pipeline.py # Chunking, Embeddings, Encryption +│ ├── qdrant_service.py # Qdrant Client + Search +│ ├── minio_storage.py # MinIO S3 Storage +│ ├── metrics_db.py # PostgreSQL Metrics +│ ├── requirements.txt # Python Dependencies +│ └── tests/ +│ └── test_rag_admin.py +└── docs/ + └── RAG-Admin-Spec.md # Diese Datei +``` + +### Schnellstart für Entwickler + +```bash +# 1. Services starten +cd /path/to/breakpilot-pwa +docker-compose up -d qdrant minio postgres + +# 2. Dependencies installieren +cd klausur-service/backend +pip install -r requirements.txt + +# 3. Service starten +python -m uvicorn main:app --port 8086 --reload + +# 4. RAG-Services initialisieren (erstellt Bucket + Tabellen) +curl -X POST http://localhost:8086/api/v1/admin/rag/init +``` + +### API-Referenz (Implementiert) + +#### NiBiS Ingestion +``` +GET /api/v1/admin/nibis/discover # Dokumente finden +POST /api/v1/admin/nibis/ingest # Indexierung starten +GET /api/v1/admin/nibis/status # Status abfragen +GET /api/v1/admin/nibis/stats # Statistiken +POST /api/v1/admin/nibis/search # Semantische Suche +GET /api/v1/admin/nibis/collections # Qdrant Collections +``` + +#### RAG Upload & Storage +``` +POST /api/v1/admin/rag/upload # ZIP/PDF hochladen +GET /api/v1/admin/rag/upload/history # Upload-Verlauf +GET /api/v1/admin/rag/storage/stats # MinIO Statistiken +``` + +#### Metrics & Feedback +``` +GET /api/v1/admin/rag/metrics # Qualitätsmetriken +POST /api/v1/admin/rag/search/feedback # Bewertung abgeben +POST /api/v1/admin/rag/init # Services initialisieren +``` + +### Umgebungsvariablen + +```bash +# Qdrant +QDRANT_URL=http://localhost:6333 + +# MinIO +MINIO_ENDPOINT=localhost:9000 +MINIO_ACCESS_KEY=breakpilot +MINIO_SECRET_KEY=breakpilot123 +MINIO_BUCKET=breakpilot-rag + +# PostgreSQL +DATABASE_URL=postgres://breakpilot:breakpilot123@localhost:5432/breakpilot_db + +# Embeddings +EMBEDDING_BACKEND=local +LOCAL_EMBEDDING_MODEL=all-MiniLM-L6-v2 +``` + +### Aktuelle Indexierungs-Statistik + +- **Dokumente**: 579 Erwartungshorizonte (NiBiS) +- **Chunks**: 7.352 +- **Jahre**: 2016, 2017, 2024, 2025 +- **Fächer**: Deutsch, Englisch, Mathematik, Physik, Chemie, Biologie, Geschichte, Politik-Wirtschaft, Erdkunde, Sport, Kunst, Musik, Latein, Informatik, Ev. Religion, Kath. Religion, Werte und Normen, etc. +- **Collection**: `bp_nibis_eh` +- **Vektor-Dimensionen**: 384 diff --git a/docs-src/services/klausur-service/Worksheet-Editor-Architecture.md b/docs-src/services/klausur-service/Worksheet-Editor-Architecture.md new file mode 100644 index 0000000..6d7fed8 --- /dev/null +++ b/docs-src/services/klausur-service/Worksheet-Editor-Architecture.md @@ -0,0 +1,409 @@ +# Visual Worksheet Editor - Architecture Documentation + +**Version:** 1.0 +**Status:** Implementiert + +## 1. Übersicht + +Der Visual Worksheet Editor ist ein Canvas-basierter Editor für die Erstellung und Bearbeitung von Arbeitsblättern. Er ermöglicht Lehrern, eingescannte Arbeitsblätter originalgetreu zu rekonstruieren oder neue Arbeitsblätter visuell zu gestalten. + +### 1.1 Hauptfunktionen + +- **Canvas-basiertes Editieren** mit Fabric.js +- **Freie Positionierung** von Text, Bildern und Formen +- **Typografie-Steuerung** (Schriftarten, Größen, Stile) +- **Bilder & Grafiken** hochladen und einfügen +- **KI-generierte Bilder** via Ollama/Stable Diffusion +- **PDF/Bild-Export** für Druck und digitale Nutzung +- **Mehrseitige Dokumente** mit Seitennavigation + +### 1.2 Technologie-Stack + +| Komponente | Technologie | Lizenz | +|------------|-------------|--------| +| Canvas-Bibliothek | Fabric.js 6.x | MIT | +| PDF-Export | pdf-lib 1.17.x | MIT | +| Frontend | Next.js / React | MIT | +| Backend API | FastAPI | MIT | +| KI-Bilder | Ollama + Stable Diffusion | Apache 2.0 / MIT | + +## 2. Architektur + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ Frontend (studio-v2 / Next.js) │ +│ /studio-v2/app/worksheet-editor/page.tsx │ +│ │ +│ ┌─────────────┐ ┌────────────────────────────┐ ┌────────────────┐ │ +│ │ Toolbar │ │ Fabric.js Canvas │ │ Properties │ │ +│ │ (Links) │ │ (Mitte - 60%) │ │ Panel │ │ +│ │ │ │ │ │ (Rechts) │ │ +│ │ - Select │ │ ┌──────────────────────┐ │ │ │ │ +│ │ - Text │ │ │ │ │ │ - Schriftart │ │ +│ │ - Formen │ │ │ A4 Arbeitsfläche │ │ │ - Größe │ │ +│ │ - Bilder │ │ │ mit Grid │ │ │ - Farbe │ │ +│ │ - KI-Bild │ │ │ │ │ │ - Position │ │ +│ │ - Tabelle │ │ └──────────────────────┘ │ │ - Ebene │ │ +│ └─────────────┘ └────────────────────────────┘ └────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ Seiten-Navigation | Zoom | Grid | Export PDF │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ klausur-service (FastAPI - Port 8086) │ +│ POST /api/v1/worksheet/ai-image → Bild via Ollama generieren │ +│ POST /api/v1/worksheet/save → Worksheet speichern │ +│ GET /api/v1/worksheet/{id} → Worksheet laden │ +│ POST /api/v1/worksheet/export-pdf → PDF generieren │ +└──────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ Ollama (Port 11434) │ +│ Model: stable-diffusion oder kompatibles Text-to-Image Modell │ +│ Text-to-Image für KI-generierte Grafiken │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +## 3. Dateistruktur + +### 3.1 Frontend (studio-v2) + +``` +/studio-v2/ +├── app/ +│ └── worksheet-editor/ +│ ├── page.tsx # Haupt-Editor-Seite +│ └── types.ts # TypeScript Interfaces +│ +├── components/ +│ └── worksheet-editor/ +│ ├── index.ts # Exports +│ ├── FabricCanvas.tsx # Fabric.js Canvas Wrapper +│ ├── EditorToolbar.tsx # Werkzeugleiste (links) +│ ├── PropertiesPanel.tsx # Eigenschaften-Panel (rechts) +│ ├── AIImageGenerator.tsx # KI-Bild Generator Modal +│ ├── CanvasControls.tsx # Zoom, Grid, Seiten +│ ├── ExportPanel.tsx # PDF/Bild Export +│ └── PageNavigator.tsx # Mehrseitige Dokumente +│ +├── lib/ +│ └── worksheet-editor/ +│ ├── index.ts # Exports +│ └── WorksheetContext.tsx # State Management +``` + +### 3.2 Backend (klausur-service) + +``` +/klausur-service/backend/ +├── worksheet_editor_api.py # API Endpoints +└── main.py # Router-Registrierung +``` + +## 4. API Endpoints + +### 4.1 KI-Bild generieren + +```http +POST /api/v1/worksheet/ai-image +Content-Type: application/json + +{ + "prompt": "Ein freundlicher Cartoon-Hund der ein Buch liest", + "style": "cartoon", + "width": 512, + "height": 512 +} +``` + +**Response:** +```json +{ + "image_base64": "data:image/png;base64,...", + "prompt_used": "...", + "error": null +} +``` + +**Styles:** +- `realistic` - Fotorealistisch +- `cartoon` - Cartoon/Comic +- `sketch` - Handgezeichnete Skizze +- `clipart` - Einfache Clipart-Grafiken +- `educational` - Bildungs-Illustrationen + +### 4.2 Worksheet speichern + +```http +POST /api/v1/worksheet/save +Content-Type: application/json + +{ + "id": "optional-existing-id", + "title": "Englisch Vokabeln Unit 3", + "pages": [ + { "id": "page_1", "index": 0, "canvasJSON": "{...}" } + ], + "pageFormat": { + "width": 210, + "height": 297, + "orientation": "portrait" + } +} +``` + +### 4.3 Worksheet laden + +```http +GET /api/v1/worksheet/{id} +``` + +### 4.4 PDF exportieren + +```http +POST /api/v1/worksheet/{id}/export-pdf +``` + +**Response:** PDF-Datei als Download + +### 4.5 Worksheets auflisten + +```http +GET /api/v1/worksheet/list/all +``` + +## 5. Komponenten + +### 5.1 FabricCanvas + +Die Kernkomponente für den Canvas-Bereich: + +- **A4-Format**: 794 x 1123 Pixel (96 DPI) +- **Grid-Overlay**: Optionales Raster mit Snap-Funktion +- **Zoom/Pan**: Mausrad und Controls +- **Selection**: Einzel- und Mehrfachauswahl +- **Keyboard Shortcuts**: Del, Ctrl+C/V/Z/D + +### 5.2 EditorToolbar + +Werkzeuge für die Bearbeitung: + +| Icon | Tool | Beschreibung | +|------|------|--------------| +| 🖱️ | Select | Elemente auswählen/verschieben | +| T | Text | Text hinzufügen (IText) | +| ▭ | Rechteck | Rechteck zeichnen | +| ○ | Kreis | Kreis/Ellipse zeichnen | +| ― | Linie | Linie zeichnen | +| → | Pfeil | Pfeil zeichnen | +| 🖼️ | Bild | Bild hochladen | +| ✨ | KI-Bild | Bild mit KI generieren | +| ⊞ | Tabelle | Tabelle einfügen | + +### 5.3 PropertiesPanel + +Eigenschaften-Editor für ausgewählte Objekte: + +**Text-Eigenschaften:** +- Schriftart (Arial, Times, Georgia, OpenDyslexic, Schulschrift) +- Schriftgröße (8-120pt) +- Schriftstil (Normal, Fett, Kursiv) +- Zeilenhöhe, Zeichenabstand +- Textausrichtung +- Textfarbe + +**Form-Eigenschaften:** +- Füllfarbe +- Rahmenfarbe und -stärke +- Eckenradius + +**Allgemein:** +- Deckkraft +- Löschen-Button + +### 5.4 WorksheetContext + +React Context für globalen State: + +```typescript +interface WorksheetContextType { + canvas: Canvas | null + document: WorksheetDocument | null + activeTool: EditorTool + selectedObjects: FabricObject[] + zoom: number + showGrid: boolean + snapToGrid: boolean + currentPageIndex: number + canUndo: boolean + canRedo: boolean + isDirty: boolean + // ... Methoden +} +``` + +## 6. Datenmodelle + +### 6.1 WorksheetDocument + +```typescript +interface WorksheetDocument { + id: string + title: string + description?: string + pages: WorksheetPage[] + pageFormat: PageFormat + createdAt: string + updatedAt: string +} +``` + +### 6.2 WorksheetPage + +```typescript +interface WorksheetPage { + id: string + index: number + canvasJSON: string // Serialisierter Fabric.js Canvas + thumbnail?: string +} +``` + +### 6.3 PageFormat + +```typescript +interface PageFormat { + width: number // in mm (Standard: 210) + height: number // in mm (Standard: 297) + orientation: 'portrait' | 'landscape' + margins: { top, right, bottom, left: number } +} +``` + +## 7. Features + +### 7.1 Undo/Redo + +- History-Stack mit max. 50 Einträgen +- Automatische Speicherung bei jeder Änderung +- Keyboard: Ctrl+Z (Undo), Ctrl+Y (Redo) + +### 7.2 Grid & Snap + +- Konfigurierbares Raster (5mm, 10mm, 15mm, 20mm) +- Snap-to-Grid beim Verschieben +- Ein-/Ausblendbar + +### 7.3 Export + +- **PDF**: Mehrseitig, A4-Format +- **PNG**: Hochauflösend (2x Multiplier) +- **JPG**: Mit Qualitätseinstellung + +### 7.4 Speicherung + +- **Backend**: REST API mit JSON-Persistierung +- **Fallback**: localStorage bei Offline-Betrieb + +## 8. KI-Bildgenerierung + +### 8.1 Ollama Integration + +Der Editor nutzt Ollama für die KI-Bildgenerierung: + +```python +OLLAMA_URL = "http://host.docker.internal:11434" +``` + +### 8.2 Placeholder-System + +Falls Ollama nicht verfügbar ist, wird ein Placeholder-Bild generiert: +- Farbcodiert nach Stil +- Prompt-Text als Beschreibung +- "KI-Bild (Platzhalter)"-Badge + +### 8.3 Stil-Prompts + +Jeder Stil fügt automatisch Modifikatoren zum Prompt hinzu: + +```python +STYLE_PROMPTS = { + "realistic": "photorealistic, high detail", + "cartoon": "cartoon style, colorful, child-friendly", + "sketch": "pencil sketch, hand-drawn", + "clipart": "clipart style, flat design", + "educational": "educational illustration, textbook style" +} +``` + +## 9. Glassmorphism Design + +Der Editor folgt dem Glassmorphism-Design des Studio v2: + +```typescript +// Dark Theme +'backdrop-blur-xl bg-white/10 border border-white/20' + +// Light Theme +'backdrop-blur-xl bg-white/70 border border-black/10 shadow-xl' +``` + +## 10. Internationalisierung + +Unterstützte Sprachen: +- 🇩🇪 Deutsch +- 🇬🇧 English +- 🇹🇷 Türkçe +- 🇸🇦 العربية (RTL) +- 🇷🇺 Русский +- 🇺🇦 Українська +- 🇵🇱 Polski + +Translation Key: `nav_worksheet_editor` + +## 11. Sicherheit + +### 11.1 Bild-Upload + +- Nur Bildformate (image/*) +- Client-seitige Validierung +- Base64-Konvertierung + +### 11.2 CORS + +Aktiviert für lokale Entwicklung und Docker-Umgebung. + +## 12. Deployment + +### 12.1 Frontend + +```bash +cd studio-v2 +npm install +npm run dev # Port 3001 +``` + +### 12.2 Backend + +Der klausur-service läuft auf Port 8086: + +```bash +cd klausur-service/backend +python main.py +``` + +### 12.3 Docker + +Der Service ist Teil des docker-compose.yml. + +## 13. Zukünftige Erweiterungen + +- [ ] Tabellen-Tool mit Zellbearbeitung +- [ ] Vorlagen-Bibliothek +- [ ] Kollaboratives Editieren +- [ ] Drag & Drop aus Dokumentenbibliothek +- [ ] Integration mit Vocab-Worksheet diff --git a/docs-src/services/klausur-service/index.md b/docs-src/services/klausur-service/index.md new file mode 100644 index 0000000..22e50df --- /dev/null +++ b/docs-src/services/klausur-service/index.md @@ -0,0 +1,173 @@ +# Klausur-Service + +Der Klausur-Service ist ein FastAPI-basierter Microservice fuer KI-gestuetzte Abitur-Klausurkorrektur. + +## Uebersicht + +| Eigenschaft | Wert | +|-------------|------| +| **Port** | 8086 | +| **Framework** | FastAPI (Python) | +| **Datenbank** | PostgreSQL + Qdrant (Vektor-DB) | +| **Speicher** | MinIO (Datei-Storage) | + +## Features + +- **OCR-Erkennung**: Automatische Texterkennung aus gescannten Klausuren +- **KI-Bewertung**: Automatische Bewertungsvorschlaege basierend auf Erwartungshorizont +- **BYOEH**: Bring-Your-Own-Expectation-Horizon mit Client-seitiger Verschluesselung +- **Fairness-Analyse**: Statistische Analyse der Bewertungskonsistenz +- **PDF-Export**: Gutachten und Notenuebersichten als PDF +- **Zweitkorrektur**: Vollstaendiger Workflow fuer Erst-, Zweit- und Drittkorrektur + +## Architektur + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Frontend (Next.js) │ +│ /website/app/admin/klausur-korrektur/ │ +│ - Klausur-Liste │ +│ - Studenten-Liste │ +│ - Korrektur-Workspace (2/3-1/3 Layout) │ +│ - Fairness-Dashboard │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ klausur-service (FastAPI) │ +│ Port 8086 - /klausur-service/backend/main.py │ +│ - Klausur CRUD (/api/v1/klausuren) │ +│ - Student Work (/api/v1/students) │ +│ - Annotations (/api/v1/annotations) │ +│ - BYOEH (/api/v1/eh) │ +│ - PDF Export │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Infrastruktur │ +│ - Qdrant (Vektor-DB fuer RAG) │ +│ - MinIO (Datei-Storage) │ +│ - PostgreSQL (Metadaten) │ +│ - Embedding-Service (Port 8087) │ +└─────────────────────────────────────────────────────────────┘ +``` + +## API Endpoints + +### Klausur-Verwaltung + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| GET | `/api/v1/klausuren` | Liste aller Klausuren | +| POST | `/api/v1/klausuren` | Neue Klausur erstellen | +| GET | `/api/v1/klausuren/{id}` | Klausur-Details | +| DELETE | `/api/v1/klausuren/{id}` | Klausur loeschen | + +### Studenten-Arbeiten + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| POST | `/api/v1/klausuren/{id}/students` | Arbeit hochladen | +| GET | `/api/v1/klausuren/{id}/students` | Studenten-Liste | +| GET | `/api/v1/students/{id}` | Einzelne Arbeit | +| PUT | `/api/v1/students/{id}/criteria` | Kriterien bewerten | +| PUT | `/api/v1/students/{id}/gutachten` | Gutachten speichern | + +### KI-Funktionen + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| POST | `/api/v1/students/{id}/gutachten/generate` | Gutachten generieren | +| GET | `/api/v1/klausuren/{id}/fairness` | Fairness-Analyse | +| POST | `/api/v1/students/{id}/eh-suggestions` | EH-Vorschlaege via RAG | + +### PDF-Export + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| GET | `/api/v1/students/{id}/export/gutachten` | Einzelgutachten PDF | +| GET | `/api/v1/students/{id}/export/annotations` | Anmerkungen PDF | +| GET | `/api/v1/klausuren/{id}/export/overview` | Notenuebersicht PDF | +| GET | `/api/v1/klausuren/{id}/export/all-gutachten` | Alle Gutachten PDF | + +## Notensystem + +Das System verwendet das deutsche 15-Punkte-System fuer Abiturklausuren: + +| Punkte | Prozent | Note | +|--------|---------|------| +| 15 | >= 95% | 1+ | +| 14 | >= 90% | 1 | +| 13 | >= 85% | 1- | +| 12 | >= 80% | 2+ | +| 11 | >= 75% | 2 | +| 10 | >= 70% | 2- | +| 9 | >= 65% | 3+ | +| 8 | >= 60% | 3 | +| 7 | >= 55% | 3- | +| 6 | >= 50% | 4+ | +| 5 | >= 45% | 4 | +| 4 | >= 40% | 4- | +| 3 | >= 33% | 5+ | +| 2 | >= 27% | 5 | +| 1 | >= 20% | 5- | +| 0 | < 20% | 6 | + +## Bewertungskriterien + +| Kriterium | Gewicht | Beschreibung | +|-----------|---------|--------------| +| Rechtschreibung | 15% | Orthografie | +| Grammatik | 15% | Grammatik & Syntax | +| Inhalt | 40% | Inhaltliche Qualitaet | +| Struktur | 15% | Aufbau & Gliederung | +| Stil | 15% | Ausdruck & Stil | + +## Verzeichnisstruktur + +``` +klausur-service/ +├── backend/ +│ ├── main.py # API Endpoints + Datenmodelle +│ ├── qdrant_service.py # Vektor-Datenbank Operationen +│ ├── eh_pipeline.py # BYOEH Verarbeitung +│ ├── hybrid_search.py # Hybrid Search (BM25 + Semantic) +│ └── requirements.txt # Python Dependencies +├── frontend/ +│ └── src/ +│ ├── components/ # React Komponenten +│ ├── pages/ # Seiten +│ └── services/ # API Client +└── docs/ + ├── BYOEH-Architecture.md + └── BYOEH-Developer-Guide.md +``` + +## Konfiguration + +### Umgebungsvariablen + +```env +# Klausur-Service +KLAUSUR_SERVICE_PORT=8086 +QDRANT_URL=http://qdrant:6333 +MINIO_ENDPOINT=minio:9000 +MINIO_ACCESS_KEY=... +MINIO_SECRET_KEY=... + +# Embedding-Service +EMBEDDING_SERVICE_URL=http://embedding:8087 +OPENAI_API_KEY=sk-... + +# BYOEH +BYOEH_ENCRYPTION_ENABLED=true +EH_UPLOAD_DIR=/app/eh-uploads +``` + +## Weiterführende Dokumentation + +- [BYOEH Architektur](./BYOEH-Architecture.md) - Client-seitige Verschluesselung +- [OCR Compare](./OCR-Compare.md) - Block Review Feature fuer OCR-Vergleich +- [Zeugnis-System](../../architecture/zeugnis-system.md) - Zeugniserstellung +- [Backend API](../../api/backend-api.md) - Allgemeine API-Dokumentation diff --git a/docs-src/services/voice-service/index.md b/docs-src/services/voice-service/index.md new file mode 100644 index 0000000..7ffa502 --- /dev/null +++ b/docs-src/services/voice-service/index.md @@ -0,0 +1,160 @@ +# Voice Service + +Der Voice Service ist eine Voice-First Interface für die Breakpilot-Plattform mit DSGVO-konformem Design. + +## Übersicht + +| Eigenschaft | Wert | +|-------------|------| +| **Port** | 8082 | +| **Framework** | FastAPI (Python) | +| **Streaming** | WebSocket | +| **DSGVO** | Privacy-by-Design | + +## Architektur + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Voice Service (Port 8082) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Sessions │───>│ Task │───>│ BQAS │ │ +│ │ API │ │ Orchestrator │ │ (Quality) │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ │ +│ ┌────────────────────┼────────────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ WebSocket │ │ Encryption │ │ Logging │ │ +│ │ Streaming │ │ Service │ │ (structlog) │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Kernkomponenten + +### PersonaPlex + TaskOrchestrator + +- Voice-first Interface für Breakpilot +- Real-time Voice Processing +- Multi-Agent Integration + +### DSGVO-Compliance (Privacy-by-Design) + +| Feature | Beschreibung | +|---------|--------------| +| **Keine Audio-Persistenz** | Nur RAM-basiert, keine dauerhafte Speicherung | +| **Namespace-Verschlüsselung** | Schlüssel nur auf Lehrer-Gerät | +| **TTL-basierte Löschung** | Automatische Datenlöschung nach Zeitablauf | +| **Transcript-Verschlüsselung** | Verschlüsselte Transkripte | + +## API-Endpunkte + +### Sessions + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| POST | `/api/v1/sessions` | Session erstellen | +| GET | `/api/v1/sessions/:id` | Session abrufen | +| DELETE | `/api/v1/sessions/:id` | Session beenden | + +### Task Orchestration + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| POST | `/api/v1/tasks` | Task erstellen | +| GET | `/api/v1/tasks/:id` | Task-Status abrufen | + +### BQAS (Quality Assessment) + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| POST | `/api/v1/bqas/evaluate` | Qualitätsbewertung | +| GET | `/api/v1/bqas/metrics` | Metriken abrufen | + +### WebSocket + +| Endpoint | Beschreibung | +|----------|--------------| +| `/ws/voice` | Real-time Voice Streaming | + +### Health + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| GET | `/health` | Health Check | +| GET | `/ready` | Readiness Check | + +## Verzeichnisstruktur + +``` +voice-service/ +├── main.py # FastAPI Application +├── config.py # Konfiguration +├── pyproject.toml # Projekt-Metadaten +├── requirements.txt # Dependencies +├── api/ +│ ├── sessions.py # Session-Management +│ ├── streaming.py # WebSocket Voice Streaming +│ ├── tasks.py # Task Orchestration +│ └── bqas.py # Quality Assessment +├── services/ +│ ├── task_orchestrator.py # Task-Routing +│ └── encryption.py # Verschlüsselung +├── bqas/ +│ ├── judge.py # LLM Judge +│ └── quality_judge_agent.py # Agent-Integration +├── models/ # Datenmodelle +├── scripts/ # Utility-Scripts +└── tests/ # Test-Suite +``` + +## Konfiguration + +```env +# .env +VOICE_SERVICE_PORT=8082 +REDIS_URL=redis://localhost:6379 +DATABASE_URL=postgresql://... +ENCRYPTION_KEY=... +TTL_MINUTES=60 +``` + +## Entwicklung + +```bash +# Dependencies installieren +cd voice-service +pip install -r requirements.txt + +# Server starten +uvicorn main:app --reload --port 8082 + +# Tests ausführen +pytest -v +``` + +## Docker + +Der Service läuft als Teil von docker-compose.yml: + +```yaml +voice-service: + build: + context: ./voice-service + ports: + - "8082:8082" + environment: + - REDIS_URL=redis://valkey:6379 + depends_on: + - valkey + - postgres +``` + +## Weiterführende Dokumentation + +- [Multi-Agent Architektur](../../architecture/multi-agent.md) +- [BQAS Quality System](../../architecture/bqas.md) diff --git a/docs/ai-content-generator.md b/docs/ai-content-generator.md new file mode 100644 index 0000000..b977e6e --- /dev/null +++ b/docs/ai-content-generator.md @@ -0,0 +1,1010 @@ +# AI Content Generator - Dokumentation + +**Stand:** 2025-12-30 +**Version:** 1.0.0 +**Status:** Produktionsbereit + +--- + +## Übersicht + +Der **AI Content Generator** ist ein KI-gestützter Service, der automatisch H5P-Lerninhalte aus hochgeladenen Materialien generiert. Lehrer können Lernmaterialien (PDFs, Bilder, Word-Dokumente) zu einem Thema hochladen, und das System erstellt automatisch alle 8 H5P Content-Typen. + +### Hauptfunktionen + +1. **Material-Analyse**: Automatische Extraktion von Text aus PDFs, Word-Dokumenten und Bildern (OCR) +2. **KI-Generierung**: Claude AI erstellt altersgerechte Lerninhalte basierend auf den Materialien +3. **YouTube-Integration**: Findet passende Videos und generiert interaktive Elemente mit Zeitstempeln +4. **8 H5P Content-Typen**: Automatische Erstellung aller interaktiven Lernformate + +--- + +## Architektur + +### Service-Stack + +``` +┌─────────────────────────────────────────────────────────┐ +│ BreakPilot Studio │ +│ (Frontend/Teacher UI) │ +└─────────────────────┬───────────────────────────────────┘ + │ + │ Upload Materials (PDF, DOCX, Images) + ↓ +┌─────────────────────────────────────────────────────────┐ +│ AI Content Generator Service (Port 8004) │ +│ FastAPI │ +├─────────────────────────────────────────────────────────┤ +│ ┌─────────────────┐ ┌──────────────┐ ┌────────────┐ │ +│ │ Material │ │ Content │ │ Job Store │ │ +│ │ Analyzer │ │ Generator │ │ (In-Memory)│ │ +│ │ │ │ │ │ │ │ +│ │ • PDF Extract │ │ • Quiz │ │ • Tracking │ │ +│ │ • OCR (Images) │ │ • Flashcards │ │ • Status │ │ +│ │ • DOCX Parser │ │ • Timeline │ │ • Results │ │ +│ └─────────────────┘ └──────────────┘ └────────────┘ │ +│ │ +│ ┌─────────────────┐ ┌──────────────┐ │ +│ │ Claude Service │ │ YouTube │ │ +│ │ │ │ Service │ │ +│ │ • API Client │ │ │ │ +│ │ • Prompts │ │ • Transcript │ │ +│ │ • JSON Parse │ │ • Search │ │ +│ └─────────────────┘ └──────────────┘ │ +└───────────┬──────────────────────┬──────────────────────┘ + │ │ + │ │ Fetch Transcripts + ↓ ↓ + ┌──────────────┐ ┌──────────────────┐ + │ Anthropic │ │ YouTube │ + │ Claude API │ │ Transcript API │ + │ (External) │ │ (Public) │ + └──────────────┘ └──────────────────┘ + │ + ↓ Generated Content + ┌──────────────────────────┐ + │ H5P Service (Port 8003) │ + │ Interactive Content │ + │ Editors & Players │ + └──────────────────────────┘ +``` + +### Komponenten + +| Komponente | Technologie | Port | Beschreibung | +|------------|-------------|------|--------------| +| **API Server** | FastAPI | 8004 | REST API für Content-Generierung | +| **Material Analyzer** | PyPDF2, Tesseract, python-docx | - | Analyse von Lernmaterialien | +| **Claude Service** | Anthropic SDK | - | KI-Content-Generierung | +| **YouTube Service** | youtube-transcript-api | - | Video-Transkript-Analyse | +| **Content Generator** | Python | - | Orchestriert alle 8 Content-Typen | +| **Job Store** | In-Memory (Python) | - | Tracking von Generation-Jobs | + +--- + +## Installation & Setup + +### Voraussetzungen + +1. **Docker & Docker Compose** installiert +2. **Anthropic API Key** (für Claude AI) + - Registrierung: https://console.anthropic.com/ + - Kostenpflichtig (Pay-per-use) + +### Umgebungsvariablen + +Erstelle `.env` Datei in `/ai-content-generator/`: + +```bash +# Kopiere Example +cp ai-content-generator/.env.example ai-content-generator/.env + +# Editiere .env +nano ai-content-generator/.env +``` + +**Wichtige Variablen:** + +```env +# ERFORDERLICH +ANTHROPIC_API_KEY=sk-ant-api03-... + +# Optional +YOUTUBE_API_KEY= # Für erweiterte Video-Suche +SERVICE_HOST=0.0.0.0 +SERVICE_PORT=8004 +MAX_UPLOAD_SIZE=10485760 # 10MB +MAX_CONCURRENT_JOBS=5 +JOB_TIMEOUT=300 # 5 Minuten +``` + +### Docker Start + +```bash +# Content Services starten (inkl. AI Generator) +docker-compose -f docker-compose.content.yml up -d + +# Nur AI Generator neu bauen +docker-compose -f docker-compose.content.yml up -d --build ai-content-generator + +# Logs anzeigen +docker-compose -f docker-compose.content.yml logs -f ai-content-generator +``` + +### Lokale Entwicklung + +```bash +cd ai-content-generator + +# Virtual Environment +python3 -m venv venv +source venv/bin/activate + +# Dependencies +pip install -r requirements.txt + +# Tesseract OCR installieren +# macOS: +brew install tesseract tesseract-lang + +# Ubuntu/Debian: +sudo apt-get install tesseract-ocr tesseract-ocr-deu + +# Server starten +uvicorn app.main:app --reload --port 8004 +``` + +API-Dokumentation: http://localhost:8004/docs + +--- + +## API Endpoints + +### 1. Health Check + +```http +GET /health +``` + +**Response:** +```json +{ + "status": "healthy", + "claude_configured": true, + "youtube_configured": true +} +``` + +--- + +### 2. Content-Generierung starten + +```http +POST /api/generate-content +Content-Type: multipart/form-data +``` + +**Form Data:** +- `topic` (string, required): Thema (z.B. "Das Auge") +- `description` (string, optional): Beschreibung +- `target_grade` (string, required): Klassenstufe (z.B. "5-6", "7-8", "9-10") +- `materials` (File[], required): Lernmaterialien (PDF, DOCX, PNG, JPG) + +**Beispiel (cURL):** + +```bash +curl -X POST http://localhost:8004/api/generate-content \ + -F "topic=Das Auge" \ + -F "description=Biologie für Klasse 7" \ + -F "target_grade=7-8" \ + -F "materials=@auge_skizze.pdf" \ + -F "materials=@auge_aufbau.docx" \ + -F "materials=@diagramm.png" +``` + +**Response:** + +```json +{ + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "status": "pending", + "message": "Content generation started" +} +``` + +--- + +### 3. Generation-Status prüfen + +```http +GET /api/generation-status/{job_id} +``` + +**Response (Processing):** + +```json +{ + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "status": "processing", + "progress": 50, + "current_step": "Generating Quiz questions...", + "started_at": "2025-12-30T10:00:00Z" +} +``` + +**Response (Completed):** + +```json +{ + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "status": "completed", + "progress": 100, + "current_step": "All content generated", + "started_at": "2025-12-30T10:00:00Z", + "completed_at": "2025-12-30T10:05:00Z" +} +``` + +**Response (Failed):** + +```json +{ + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "status": "failed", + "error_message": "API Key invalid", + "started_at": "2025-12-30T10:00:00Z", + "completed_at": "2025-12-30T10:00:15Z" +} +``` + +--- + +### 4. Generierte Inhalte abrufen + +```http +GET /api/generated-content/{job_id} +``` + +**Response:** + +```json +{ + "topic": "Das Auge", + "description": "Biologie für Klasse 7", + "target_grade": "7-8", + "generated_at": "2025-12-30T10:05:00Z", + "content_types": { + "quiz": { + "type": "quiz", + "title": "Quiz: Das Auge", + "description": "Teste dein Wissen über das Auge", + "questions": [...] + }, + "interactive_video": {...}, + "flashcards": {...}, + "timeline": {...}, + "drag_drop": {...}, + "fill_blanks": {...}, + "memory": {...}, + "course_presentation": {...} + } +} +``` + +--- + +### 5. YouTube Video-Suche + +```http +POST /api/youtube-search +Content-Type: application/json +``` + +**Request:** + +```json +{ + "query": "Das Auge Biologie Schule", + "max_results": 5 +} +``` + +**Response:** + +```json +{ + "videos": [ + { + "video_id": "EXAMPLE_ID", + "title": "Video zum Thema: Das Auge", + "channel": "Educational Channel", + "url": "https://www.youtube.com/watch?v=...", + "has_transcript": false, + "note": "Use real YouTube Data API in production" + } + ] +} +``` + +--- + +## Content-Typen + +### 1. Quiz (Question Set) + +**Generierung:** Claude AI erstellt 10 Multiple-Choice-Fragen basierend auf den Materialien. + +**Struktur:** + +```json +{ + "type": "quiz", + "title": "Quiz: Das Auge", + "description": "Teste dein Wissen über das Auge", + "questions": [ + { + "question": "Was ist die Funktion der Pupille?", + "options": [ + "Lichteinfall regulieren", + "Farben erkennen", + "Scharfstellen", + "Bewegungen erkennen" + ], + "correct_answer": 0, + "explanation": "Die Pupille reguliert den Lichteinfall ins Auge..." + } + ] +} +``` + +--- + +### 2. Interactive Video + +**Generierung:** YouTube-Video wird mit Transkript analysiert, Claude AI generiert 5 zeitbasierte Interaktionen. + +**Struktur:** + +```json +{ + "type": "interactive-video", + "title": "Das Auge - Aufbau und Funktion", + "videoUrl": "https://youtube.com/watch?v=xyz", + "description": "Interaktives Video über das Auge", + "interactions": [ + { + "time": "01:30", + "seconds": 90, + "type": "question", + "title": "Verständnisfrage", + "content": "Was wurde über die Netzhaut erklärt?" + }, + { + "time": "03:15", + "seconds": 195, + "type": "info", + "title": "Wichtiger Hinweis", + "content": "Achte auf den Unterschied zwischen Stäbchen und Zapfen." + } + ] +} +``` + +**Interaktionstypen:** +- `question`: Verständnisfrage (pausiert Video) +- `info`: Information/Hinweis +- `link`: Externer Link zu Zusatzmaterial + +--- + +### 3. Course Presentation + +**Generierung:** 6 Folien mit Titel, Inhalt und Hintergrundfarbe. + +**Struktur:** + +```json +{ + "type": "course-presentation", + "title": "Präsentation: Das Auge", + "description": "Lerne alles über das Auge", + "slides": [ + { + "id": 1, + "title": "Das Auge - Einführung", + "content": "Das Auge ist unser wichtigstes Sinnesorgan...", + "backgroundColor": "#ffffff" + }, + { + "id": 2, + "title": "Aufbau des Auges", + "content": "Das Auge besteht aus mehreren Teilen: Hornhaut, Linse...", + "backgroundColor": "#f0f8ff" + } + ] +} +``` + +--- + +### 4. Flashcards + +**Generierung:** 15 Lernkarten mit Begriff/Frage und Definition/Antwort. + +**Struktur:** + +```json +{ + "type": "flashcards", + "title": "Lernkarten: Das Auge", + "description": "Wiederhole wichtige Begriffe", + "cards": [ + { + "id": 1, + "front": "Pupille", + "back": "Öffnung in der Iris, die den Lichteinfall reguliert" + }, + { + "id": 2, + "front": "Was sind Stäbchen?", + "back": "Rezeptoren in der Netzhaut für Hell-Dunkel-Sehen" + } + ] +} +``` + +--- + +### 5. Timeline + +**Generierung:** 5-8 chronologische Ereignisse (z.B. Geschichte, Entwicklung). + +**Struktur:** + +```json +{ + "type": "timeline", + "title": "Zeitleiste: Augenforschung", + "description": "Chronologie der Augenforschung", + "events": [ + { + "id": 1, + "year": "1604", + "title": "Johannes Kepler", + "description": "Erste korrekte Erklärung der Bildentstehung im Auge" + }, + { + "id": 2, + "year": "1851", + "title": "Erfindung des Augenspiegels", + "description": "Hermann von Helmholtz entwickelt den Augenspiegel" + } + ] +} +``` + +--- + +### 6. Drag and Drop + +**Generierung:** 3-4 Kategorien (Zonen) mit 8-12 zuordenbaren Elementen. + +**Struktur:** + +```json +{ + "type": "drag-drop", + "title": "Zuordnung: Teile des Auges", + "question": "Ordne die Begriffe den richtigen Kategorien zu", + "zones": [ + {"id": 1, "name": "Lichtbrechung"}, + {"id": 2, "name": "Lichtrezeption"}, + {"id": 3, "name": "Bildverarbeitung"} + ], + "draggables": [ + {"id": 1, "text": "Hornhaut", "correctZoneId": 1}, + {"id": 2, "text": "Linse", "correctZoneId": 1}, + {"id": 3, "text": "Netzhaut", "correctZoneId": 2}, + {"id": 4, "text": "Sehnerv", "correctZoneId": 3} + ] +} +``` + +--- + +### 7. Fill in the Blanks + +**Generierung:** Text mit 10-15 Lücken (markiert mit `*Wort*`). + +**Struktur:** + +```json +{ + "type": "fill-blanks", + "title": "Lückentext: Das Auge", + "text": "Das Licht tritt durch die *Hornhaut* ins Auge ein. Die *Pupille* reguliert den Lichteinfall. Die *Linse* bricht das Licht und projiziert es auf die *Netzhaut*.", + "hints": "Achte auf die richtige Reihenfolge des Lichteinfalls." +} +``` + +**Hinweis:** Lücken werden mit `*` markiert. Der H5P Player extrahiert die Wörter automatisch. + +--- + +### 8. Memory Game + +**Generierung:** 8 Paare von zusammengehörigen Begriffen/Konzepten. + +**Struktur:** + +```json +{ + "type": "memory", + "title": "Memory: Das Auge", + "description": "Finde die passenden Paare", + "pairs": [ + { + "id": 1, + "card1": "Pupille", + "card2": "Lichteinfall regulieren" + }, + { + "id": 2, + "card1": "Linse", + "card2": "Licht bündeln" + }, + { + "id": 3, + "card1": "Netzhaut", + "card2": "Lichtsignale empfangen" + } + ] +} +``` + +--- + +## Material-Analyse + +### Unterstützte Dateitypen + +| Dateityp | Library | Funktion | +|----------|---------|----------| +| **PDF** | PyPDF2 | Textextraktion aus mehrseitigen PDFs | +| **PNG/JPG** | Pillow + Tesseract OCR | Optische Zeichenerkennung (deutsch) | +| **DOCX** | python-docx / mammoth | Word-Dokument Parsing | +| **TXT** | Python stdlib | Plain-Text Dateien | + +### Beispiel: PDF-Analyse + +```python +# Automatisch ausgeführt bei Upload +pdf_result = { + "filename": "auge_skizze.pdf", + "type": "pdf", + "num_pages": 3, + "content": "--- Seite 1 ---\nDas Auge ist...\n--- Seite 2 ---\n...", + "success": True +} +``` + +### Beispiel: Image-Analyse (OCR) + +```python +# Tesseract OCR wird automatisch ausgeführt +image_result = { + "filename": "diagramm.png", + "type": "image", + "width": 1920, + "height": 1080, + "mode": "RGB", + "content": "Hornhaut\nLinse\nNetzhaut\nSehnerv", # OCR-Text + "success": True +} +``` + +**Voraussetzung:** Tesseract OCR muss installiert sein. + +--- + +## Workflow + +### Typischer Ablauf + +```mermaid +sequenceDiagram + participant Teacher as Lehrer (Browser) + participant API as AI Generator API + participant Analyzer as Material Analyzer + participant Claude as Claude AI + participant YouTube as YouTube API + participant H5P as H5P Service + + Teacher->>API: POST /api/generate-content (PDF, DOCX, Images) + API->>API: Create Job ID + API-->>Teacher: job_id + status: pending + + API->>Analyzer: Analyze materials + Analyzer->>Analyzer: Extract text from PDF + Analyzer->>Analyzer: OCR on images + Analyzer->>Analyzer: Parse DOCX + Analyzer-->>API: Analyzed content + + API->>Claude: Generate Quiz (materials + topic) + Claude-->>API: 10 questions + + API->>Claude: Generate Flashcards + Claude-->>API: 15 cards + + API->>YouTube: Search videos (topic) + YouTube-->>API: Video URLs + + API->>YouTube: Get transcript (video_id) + YouTube-->>API: Transcript with timestamps + + API->>Claude: Generate interactions (transcript + topic) + Claude-->>API: 5 interactions with times + + API->>Claude: Generate remaining types + Claude-->>API: Timeline, Drag&Drop, Fill-Blanks, Memory, Presentation + + API->>API: Update job status: completed + + Teacher->>API: GET /api/generation-status/{job_id} + API-->>Teacher: status: completed + + Teacher->>API: GET /api/generated-content/{job_id} + API-->>Teacher: All 8 content types + + Teacher->>H5P: Use generated content in editors + H5P-->>Teacher: Interactive content ready +``` + +### Schritte im Detail + +1. **Upload & Job Creation** (5s) + - Lehrer lädt Materialien hoch + - System erstellt Job-ID + - Status: `pending` + +2. **Material-Analyse** (10-30s) + - PDF: Textextraktion pro Seite + - Images: Tesseract OCR + - DOCX: Dokument-Parsing + - Status: `processing` (10% progress) + +3. **Quiz-Generierung** (15-30s) + - Claude API Call mit Materials + Thema + - 10 Multiple-Choice-Fragen + - Status: `processing` (25% progress) + +4. **Flashcards-Generierung** (10-20s) + - 15 Lernkarten + - Status: `processing` (40% progress) + +5. **YouTube-Integration** (20-40s) + - Video-Suche + - Transkript-Abruf + - Claude generiert Interaktionen + - Status: `processing` (60% progress) + +6. **Restliche Content-Typen** (30-60s) + - Timeline (5-8 Events) + - Drag & Drop (3-4 Kategorien) + - Fill in the Blanks (10-15 Lücken) + - Memory (8 Paare) + - Course Presentation (6 Folien) + - Status: `processing` (80-100% progress) + +7. **Fertigstellung** (<5s) + - Status: `completed` + - Content bereit zum Abruf + +**Gesamtdauer:** Ca. 2-5 Minuten (abhängig von Claude API Latenz) + +--- + +## Claude AI Prompts + +### Beispiel: Quiz-Generierung + +```python +prompt = f"""Erstelle {num_questions} Multiple-Choice-Fragen zum Thema "{topic}" für Klassenstufe {target_grade}. + +Materialien: +{material_text} + +Erstelle Fragen die: +1. Das Verständnis testen +2. Auf den Materialien basieren +3. Altersgerecht sind +4. 4 Antwortmöglichkeiten haben (1 richtig, 3 falsch) + +Formatiere die Ausgabe als JSON-Array: +[ + {{ + "question": "Frage text?", + "options": ["Option A", "Option B", "Option C", "Option D"], + "correct_answer": 0, + "explanation": "Erklärung warum die Antwort richtig ist" + }} +] + +Nur das JSON-Array zurückgeben, keine zusätzlichen Texte.""" + +system_prompt = "Du bist ein pädagogischer Experte der Quizfragen erstellt." +``` + +### Beispiel: Interactive Video Interactions + +```python +prompt = f"""Analysiere dieses Video-Transkript zum Thema "{topic}" und identifiziere {num_interactions} wichtige Momente für interaktive Elemente. + +Transkript: +{transcript_text[:8000]} + +Für jeden Moment, erstelle: +1. Einen Zeitstempel (in Sekunden) +2. Einen Interaktionstyp (question, info, oder link) +3. Einen Titel +4. Den Inhalt (Frage, Information, oder URL) + +Formatiere als JSON-Array: +[ + {{ + "seconds": 45, + "type": "question", + "title": "Verständnisfrage", + "content": "Was ist die Hauptfunktion...?" + }}, + {{ + "seconds": 120, + "type": "info", + "title": "Wichtiger Hinweis", + "content": "Beachte dass..." + }} +] + +Wähle Momente die: +- Wichtige Konzepte einführen +- Verständnis testen +- Zusatzinformationen bieten + +Nur JSON zurückgeben.""" + +system_prompt = "Du bist ein Experte für interaktive Video-Didaktik." +``` + +--- + +## Kosten & Limits + +### Anthropic Claude API + +| Modell | Input | Output | Verwendung | +|--------|-------|--------|------------| +| **Claude Sonnet 4.5** | $3/MTok | $15/MTok | Alle Content-Typen | + +**Geschätzte Kosten pro Generation:** +- Materials: ~500 Tokens Input +- 8 Content-Typen: ~8 API Calls +- Total Input: ~4,000 Tokens ($0.012) +- Total Output: ~8,000 Tokens ($0.120) +- **Pro Generation: ~$0.13** + +**Bei 100 Generierungen/Monat: ~$13/Monat** + +### YouTube API (Optional) + +| API | Kosten | Quota | +|-----|--------|-------| +| **YouTube Data API v3** | Kostenlos | 10,000 Einheiten/Tag | +| **YouTube Transcript API** | Kostenlos | Unbegrenzt (Public API) | + +**Hinweis:** Transcript API funktioniert ohne API Key. Data API nur für erweiterte Video-Suche. + +--- + +## Troubleshooting + +### Problem: "Claude API not configured" + +**Lösung:** +```bash +# Prüfe ob API Key gesetzt ist +docker-compose -f docker-compose.content.yml exec ai-content-generator env | grep ANTHROPIC + +# Setze API Key in .env +echo "ANTHROPIC_API_KEY=sk-ant-api03-..." >> ai-content-generator/.env + +# Service neu starten +docker-compose -f docker-compose.content.yml restart ai-content-generator +``` + +--- + +### Problem: OCR funktioniert nicht (Images) + +**Lösung:** + +Docker (automatisch installiert): +```bash +# Dockerfile enthält bereits: +RUN apt-get install -y tesseract-ocr tesseract-ocr-deu +``` + +Lokal: +```bash +# macOS +brew install tesseract tesseract-lang + +# Ubuntu/Debian +sudo apt-get install tesseract-ocr tesseract-ocr-deu + +# Testen +tesseract --version +``` + +--- + +### Problem: Job bleibt bei "processing" hängen + +**Lösung:** + +```bash +# Prüfe Logs +docker-compose -f docker-compose.content.yml logs -f ai-content-generator + +# Häufige Ursachen: +# 1. Claude API Rate Limit → Warte 1 Minute +# 2. Timeout (5 Min) → Materialien zu groß? Reduziere Upload-Größe +# 3. Network Error → Prüfe Internetverbindung + +# Job Store zurücksetzen (nur Development) +docker-compose -f docker-compose.content.yml restart ai-content-generator +``` + +--- + +### Problem: YouTube Video hat kein Transkript + +**Lösung:** + +Das System generiert dann generische Interaktionen: + +```json +{ + "type": "interactive-video", + "videoUrl": "https://youtube.com/watch?v=xyz", + "interactions": [ + { + "time": "01:00", + "type": "question", + "content": "Was ist das Hauptthema dieses Videos über {topic}?" + } + ], + "note": "Generische Interaktionen - Video hat kein Transkript" +} +``` + +**Alternative:** Lehrer kann manuell Video-URL mit Transkript eingeben. + +--- + +## Sicherheit & Datenschutz + +### Datenspeicherung + +- **Uploads:** Temporär in `/app/uploads/` (Docker Volume) +- **Jobs:** In-Memory (verloren bei Neustart) +- **TODO:** Redis Backend für Persistenz + +### Datenfluss + +``` +Teacher Upload → AI Generator (temp storage) → Claude API (external) → Results → Deleted +``` + +**Wichtig:** +- Hochgeladene Materialien werden NICHT dauerhaft gespeichert +- Claude API (Anthropic) verarbeitet Materialien gemäß ihrer [Commercial Terms](https://www.anthropic.com/legal/commercial-terms) +- Keine Speicherung in Cloud nach Generation (außer Lehrer speichert in H5P Service) + +### DSGVO-Compliance + +- ✅ Keine dauerhafte Speicherung von Schülerdaten +- ✅ Materialien werden nach Generation gelöscht +- ✅ Claude API: EU-Data Processing Agreement verfügbar +- ⚠️ YouTube Transcript API: Public API (keine Authentifizierung) + +**Empfehlung:** Keine personenbezogenen Daten in Materialien hochladen. + +--- + +## Deployment + +### Production Checklist + +- [ ] Anthropic API Key gesetzt (`ANTHROPIC_API_KEY`) +- [ ] Optional: YouTube API Key für erweiterte Suche +- [ ] Tesseract OCR installiert (im Dockerfile: ✅) +- [ ] H5P Service läuft (Port 8003) +- [ ] Content Service läuft (Port 8002) +- [ ] Docker Volumes erstellt +- [ ] Health Check: `curl http://localhost:8004/health` +- [ ] Logs monitoring: `docker-compose logs -f ai-content-generator` + +### Monitoring + +```bash +# Health Check +curl http://localhost:8004/health + +# API Docs +open http://localhost:8004/docs + +# Logs +docker-compose -f docker-compose.content.yml logs -f ai-content-generator + +# Container Status +docker-compose -f docker-compose.content.yml ps +``` + +--- + +## Roadmap / TODO + +### Phase 1 (Aktuell) ✅ +- [x] Material-Analyse (PDF, DOCX, Images) +- [x] Claude AI Integration +- [x] Alle 8 H5P Content-Typen +- [x] YouTube Transcript Integration +- [x] Docker Integration +- [x] API Endpoints + +### Phase 2 (Geplant) +- [ ] Redis Backend für Job Store (Persistenz) +- [ ] Celery für Background Tasks (async) +- [ ] Webhook Notifications (Lehrer wird benachrichtigt) +- [ ] Batch Processing (mehrere Themen gleichzeitig) +- [ ] Content Quality Validation (automatische Prüfung) + +### Phase 3 (Future) +- [ ] Multi-Language Support (nicht nur Deutsch) +- [ ] Custom Prompts (Lehrer kann Prompts anpassen) +- [ ] A/B Testing für Prompts +- [ ] Analytics (welche Content-Typen werden am meisten genutzt) +- [ ] Rate Limiting & Quota Management +- [ ] API Authentication (JWT) + +--- + +## Support & Kontakt + +### Dokumentation +- **Haupt-Docs:** `/docs/ai-content-generator.md` +- **Service README:** `/ai-content-generator/README.md` +- **API Spec:** http://localhost:8004/docs (Swagger UI) + +### Issues +- GitHub Issues: https://github.com/breakpilot/breakpilot-pwa/issues + +### Team +- E-Mail: dev@breakpilot.app +- Slack: #ai-content-generator + +--- + +## Änderungsprotokoll + +| Datum | Version | Änderung | +|-------|---------|----------| +| 2025-12-30 | 1.0.0 | Initiale Implementierung | +| 2025-12-30 | 1.0.0 | Alle 8 H5P Content-Typen implementiert | +| 2025-12-30 | 1.0.0 | Docker Integration abgeschlossen | +| 2025-12-30 | 1.0.0 | Dokumentation erstellt | + +--- + +**Status:** Produktionsbereit +**Nächste Schritte:** Redis Backend + Celery Background Tasks diff --git a/docs/api/backend-api.md b/docs/api/backend-api.md new file mode 100644 index 0000000..e916e15 --- /dev/null +++ b/docs/api/backend-api.md @@ -0,0 +1,1361 @@ +# BreakPilot Backend API Dokumentation + +## Übersicht + +Base URL: `http://localhost:8000/api` + +Alle Endpoints erfordern Authentifizierung via JWT im Authorization-Header: +``` +Authorization: Bearer +``` + +--- + +## Worksheets API + +Generiert Lernmaterialien (MC-Tests, Lückentexte, Mindmaps, Quiz). + +### POST /worksheets/generate/multiple-choice + +Generiert Multiple-Choice-Fragen aus Quelltext. + +**Request Body:** +```json +{ + "source_text": "Der Text, aus dem Fragen generiert werden sollen...", + "num_questions": 5, + "difficulty": "medium", + "topic": "Thema", + "subject": "Deutsch" +} +``` + +**Response (200):** +```json +{ + "success": true, + "content": { + "type": "multiple_choice", + "data": { + "questions": [ + { + "question": "Was ist...?", + "options": ["A", "B", "C", "D"], + "correct": 0, + "explanation": "Erklärung..." + } + ] + } + } +} +``` + +### POST /worksheets/generate/cloze + +Generiert Lückentexte. + +**Request Body:** +```json +{ + "source_text": "Quelltext...", + "num_gaps": 5, + "gap_type": "word", + "hint_type": "first_letter" +} +``` + +### POST /worksheets/generate/mindmap + +Generiert Mindmap als Mermaid-Diagramm. + +**Request Body:** +```json +{ + "source_text": "Quelltext...", + "max_branches": 5, + "depth": 2 +} +``` + +### POST /worksheets/generate/quiz + +Generiert Mix aus verschiedenen Fragetypen. + +**Request Body:** +```json +{ + "source_text": "Quelltext...", + "num_questions": 5, + "include_types": ["mc", "open", "truefalse"] +} +``` + +### POST /worksheets/generate/batch + +Generiert mehrere Inhaltstypen gleichzeitig. + +**Request Body:** +```json +{ + "source_text": "Quelltext...", + "types": ["multiple_choice", "cloze", "mindmap"] +} +``` + +--- + +## Corrections API + +OCR-basierte Klausurkorrektur mit automatischer Bewertung. + +### POST /corrections/ + +Erstellt neue Korrektur-Session. + +**Request Body:** +```json +{ + "student_id": "student-123", + "student_name": "Max Mustermann", + "class_name": "10a", + "exam_title": "Klassenarbeit Nr. 3", + "subject": "Mathematik", + "max_points": 100 +} +``` + +**Response (200):** +```json +{ + "success": true, + "correction": { + "id": "uuid", + "status": "uploaded", + "student_name": "Max Mustermann", + ... + } +} +``` + +### POST /corrections/{id}/upload + +Lädt gescannte Klausur hoch und startet OCR im Hintergrund. + +**Request (multipart/form-data):** +- `file`: PDF/PNG/JPG Datei + +**Response (200):** +```json +{ + "success": true, + "correction": { + "id": "uuid", + "status": "processing" + } +} +``` + +### GET /corrections/{id} + +Ruft Korrektur-Status ab. + +**Response (200):** +```json +{ + "success": true, + "correction": { + "id": "uuid", + "status": "ocr_complete", + "extracted_text": "Erkannter Text...", + "evaluations": [], + ... + } +} +``` + +**Status-Werte:** +- `uploaded` - Datei hochgeladen +- `processing` - OCR läuft +- `ocr_complete` - OCR fertig +- `analyzing` - Analyse läuft +- `analyzed` - Analyse abgeschlossen +- `reviewing` - In Review +- `completed` - Fertig +- `error` - Fehler + +### POST /corrections/{id}/analyze + +Analysiert extrahierten Text und bewertet Antworten. + +**Request Body (optional):** +```json +{ + "expected_answers": { + "1": "Musterlösung für Aufgabe 1", + "2": "Musterlösung für Aufgabe 2" + } +} +``` + +**Response (200):** +```json +{ + "success": true, + "evaluations": [ + { + "question_number": 1, + "extracted_text": "Schülerantwort...", + "points_possible": 10, + "points_awarded": 8, + "feedback": "Gut gelöst...", + "is_correct": true, + "confidence": 0.85 + } + ], + "total_points": 85, + "percentage": 85.0, + "suggested_grade": "2", + "ai_feedback": "Insgesamt gute Arbeit..." +} +``` + +### PUT /corrections/{id} + +Aktualisiert Korrektur (manuelle Anpassungen). + +**Request Body:** +```json +{ + "evaluations": [...], + "total_points": 90, + "grade": "1", + "teacher_notes": "Sehr gute Verbesserung", + "status": "reviewing" +} +``` + +### POST /corrections/{id}/complete + +Markiert Korrektur als abgeschlossen. + +### GET /corrections/{id}/export-pdf + +Exportiert korrigierte Arbeit als PDF. + +**Response:** `application/pdf` + +### GET /corrections/class/{class_name}/summary + +Klassenübersicht mit Statistiken. + +**Response (200):** +```json +{ + "class_name": "10a", + "total_students": 25, + "average_percentage": 72.5, + "average_points": 72.5, + "grade_distribution": {"1": 2, "2": 8, "3": 10, "4": 4, "5": 1}, + "corrections": [...] +} +``` + +--- + +## Letters API + +Elternbriefe mit GFK-Integration und PDF-Export. + +### POST /letters/ + +Erstellt neuen Elternbrief. + +**Request Body:** +```json +{ + "recipient_name": "Familie Müller", + "recipient_address": "Musterstr. 1, 12345 Stadt", + "student_name": "Max Müller", + "student_class": "7a", + "subject": "Information zum Leistungsstand", + "content": "Sehr geehrte Eltern...", + "letter_type": "halbjahr", + "tone": "professional", + "teacher_name": "Frau Schmidt", + "teacher_title": "Klassenlehrerin" +} +``` + +**letter_type Werte:** +- `general` - Allgemeine Information +- `halbjahr` - Halbjahresinformation +- `fehlzeiten` - Fehlzeiten-Mitteilung +- `elternabend` - Einladung Elternabend +- `lob` - Positives Feedback +- `custom` - Benutzerdefiniert + +**tone Werte:** +- `formal` - Sehr förmlich +- `professional` - Professionell-freundlich +- `warm` - Warmherzig +- `concerned` - Besorgt +- `appreciative` - Wertschätzend + +### GET /letters/{id} + +Lädt gespeicherten Brief. + +### GET /letters/ + +Listet alle Briefe (mit Filterung). + +**Query Parameter:** +- `class_name` - Filter nach Klasse +- `letter_type` - Filter nach Typ +- `status` - Filter nach Status (draft/sent/archived) +- `page` - Seitennummer +- `page_size` - Einträge pro Seite + +### PUT /letters/{id} + +Aktualisiert Brief. + +### DELETE /letters/{id} + +Löscht Brief. + +### POST /letters/export-pdf + +Exportiert Brief als PDF. + +**Request Body:** +```json +{ + "letter_id": "uuid" +} +``` +oder +```json +{ + "letter_data": { ... } +} +``` + +**Response:** `application/pdf` + +### POST /letters/improve + +Verbessert Text nach GFK-Prinzipien. + +**Request Body:** +```json +{ + "content": "Text zur Verbesserung...", + "communication_type": "general_info", + "tone": "professional" +} +``` + +**Response (200):** +```json +{ + "improved_content": "Verbesserter Text...", + "changes": ["Hinweis 1", "Hinweis 2"], + "gfk_score": 0.85, + "gfk_principles_applied": ["Ich-Botschaften", "Offene Fragen"] +} +``` + +### POST /letters/{id}/send + +Versendet Brief per Email. + +**Request Body:** +```json +{ + "letter_id": "uuid", + "recipient_email": "eltern@example.com", + "cc_emails": ["schulleitung@example.com"], + "include_pdf": true +} +``` + +--- + +## State Engine API + +Begleiter-Modus mit Phasen-Management und Antizipation. + +### GET /state/context + +Ruft Lehrer-Kontext ab. + +**Query Parameter:** +- `teacher_id` (required) + +**Response (200):** +```json +{ + "context": { + "teacher_id": "xxx", + "current_phase": "school_year_start", + "completed_milestones": ["consent_accept", "profile_complete"], + "classes": [...], + "stats": {...} + }, + "phase_info": { + "name": "Schuljahresbeginn", + "description": "..." + } +} +``` + +### GET /state/phase + +Ruft aktuelle Phase ab. + +### GET /state/phases + +Listet alle verfügbaren Phasen. + +**Response (200):** +```json +{ + "phases": [ + {"id": "onboarding", "name": "Onboarding", "description": "..."}, + {"id": "school_year_start", "name": "Schuljahresbeginn", "description": "..."}, + ... + ] +} +``` + +### GET /state/suggestions + +Ruft Vorschläge für Lehrer ab. + +**Query Parameter:** +- `teacher_id` (required) + +**Response (200):** +```json +{ + "suggestions": [ + { + "id": "create_first_class", + "title": "Erste Klasse anlegen", + "description": "...", + "priority": "urgent", + "category": "setup", + "action": {"type": "navigate", "target": "/school"} + } + ], + "current_phase": "school_year_start", + "priority_counts": {"urgent": 1, "high": 2, "medium": 3} +} +``` + +### GET /state/suggestions/top + +Ruft wichtigsten Vorschlag ab. + +### GET /state/dashboard + +Komplettes Dashboard für Begleiter-Modus. + +**Response (200):** +```json +{ + "context": {...}, + "suggestions": [...], + "stats": {...}, + "progress": { + "milestones_completed": 3, + "milestones_total": 5 + }, + "phases": [...] +} +``` + +### POST /state/milestone + +Schließt Meilenstein ab. + +**Request Body:** +```json +{ + "milestone": "consent_accept" +} +``` + +### POST /state/transition + +Manueller Phasenübergang. + +**Request Body:** +```json +{ + "target_phase": "teaching_setup" +} +``` + +### GET /state/next-phase + +Zeigt nächste Phase und Bedingungen. + +--- + +## Schuljahr-Phasen + +| Phase | ID | Beschreibung | +|-------|-----|--------------| +| Onboarding | `onboarding` | Ersteinrichtung | +| Schuljahresbeginn | `school_year_start` | Klassen anlegen | +| Unterrichtsvorbereitung | `teaching_setup` | Materialien erstellen | +| 1. Leistungsphase | `performance_1` | Tests & Klausuren | +| Halbjahr | `semester_end` | Zeugnisse | +| 2. Leistungsphase | `performance_2` | Tests & Klausuren | +| Prüfungsphase | `exam_phase` | Abschlussprüfungen | +| Schuljahresende | `year_end` | Abschluss | +| Archiviert | `archived` | Abgeschlossen | + +--- + +## Klausur-Korrektur API (Abitur) + +Abitur-Klausurkorrektur mit 15-Punkte-System, Erst-/Zweitprüfer-Workflow und KI-gestützter Bewertung. + +### Klausur-Modi + +| Modus | Beschreibung | +|-------|--------------| +| `landes_abitur` | NiBiS Niedersachsen - rechtlich geklärte Aufgaben | +| `vorabitur` | Lehrer-erstellte Klausuren mit Rights-Gate | + +### POST /klausur-korrektur/klausuren + +Erstellt neue Abitur-Klausur. + +**Request Body:** +```json +{ + "title": "Deutsch LK Q4", + "subject": "deutsch", + "modus": "landes_abitur", + "year": 2025, + "semester": "Q4" +} +``` + +**Response (200):** +```json +{ + "success": true, + "klausur": { + "id": "uuid", + "title": "Deutsch LK Q4", + "modus": "landes_abitur", + "status": "draft", + ... + } +} +``` + +### GET /klausur-korrektur/klausuren + +Listet alle Klausuren. + +**Query Parameter:** +- `subject` - Filter nach Fach +- `year` - Filter nach Jahr +- `status` - Filter nach Status + +### GET /klausur-korrektur/klausuren/{id} + +Ruft Klausur-Details ab. + +### PUT /klausur-korrektur/klausuren/{id} + +Aktualisiert Klausur. + +### DELETE /klausur-korrektur/klausuren/{id} + +Löscht Klausur. + +### POST /klausur-korrektur/klausuren/{id}/text-sources + +Fügt Text-Quelle hinzu (Vorabitur-Modus). + +**Request Body:** +```json +{ + "source_type": "eigentext", + "title": "Kafka - Die Verwandlung", + "author": "Franz Kafka", + "content": "Als Gregor Samsa eines Morgens..." +} +``` + +### POST /klausur-korrektur/text-sources/{id}/verify + +Prüft Text-Quelle mit Rights-Gate. + +**Response (200):** +```json +{ + "verified": true, + "license_status": "verified", + "license_info": { + "license": "PD", + "source": "Projekt Gutenberg" + } +} +``` + +### GET /klausur-korrektur/nibis/aufgaben + +Listet verfügbare NiBiS-Aufgaben. + +**Query Parameter:** +- `fach` - Filter nach Fach +- `jahr` - Filter nach Jahr +- `niveau` - Filter nach Niveau (eA/gA) + +### POST /klausur-korrektur/klausuren/{id}/erwartungshorizont/generate + +Generiert Erwartungshorizont mit KI. + +**Request Body:** +```json +{ + "aufgabenstellung": "Analysieren Sie den vorliegenden Text...", + "text_context": "Kafka - Die Verwandlung..." +} +``` + +**Response (200):** +```json +{ + "erwartungshorizont": { + "aufgaben": [ + { + "nummer": "1", + "operator": "analysieren", + "anforderungsbereich": 2, + "erwartete_leistungen": ["Erkennt den Erzähler", "..."], + "punkte": 30 + } + ], + "max_points": 100 + } +} +``` + +### PUT /klausur-korrektur/klausuren/{id}/erwartungshorizont + +Aktualisiert Erwartungshorizont manuell. + +### POST /klausur-korrektur/klausuren/{id}/students + +Lädt Schülerarbeit hoch. + +**Request (multipart/form-data):** +- `file`: PDF/PNG/JPG Datei +- `student_name`: Name des Schülers + +### POST /klausur-korrektur/students/{id}/ocr + +Startet OCR für Schülerarbeit. + +### POST /klausur-korrektur/students/{id}/evaluate + +Startet KI-Bewertung. + +**Response (200):** +```json +{ + "criteria_scores": { + "rechtschreibung": {"score": 85, "weight": 0.15, "annotations": ["..."]}, + "grammatik": {"score": 90, "weight": 0.15, "annotations": ["..."]}, + "inhalt": {"score": 75, "weight": 0.40, "annotations": ["..."]}, + "struktur": {"score": 80, "weight": 0.15, "annotations": ["..."]}, + "stil": {"score": 85, "weight": 0.15, "annotations": ["..."]} + }, + "raw_points": 80, + "grade_points": 11, + "grade_label": "2" +} +``` + +### PUT /klausur-korrektur/students/{id}/criteria + +Passt Bewertungskriterien manuell an. + +### POST /klausur-korrektur/students/{id}/annotations + +Fügt Annotation zu Dokument hinzu. + +### POST /klausur-korrektur/students/{id}/gutachten/generate + +Generiert Gutachten mit KI. + +**Response (200):** +```json +{ + "gutachten": { + "einleitung": "Die vorliegende Arbeit...", + "hauptteil": "Der Verfasser zeigt...", + "fazit": "Zusammenfassend lässt sich...", + "staerken": ["Gute Argumentation", "..."], + "schwaechen": ["Rechtschreibfehler", "..."] + } +} +``` + +### PUT /klausur-korrektur/students/{id}/gutachten + +Bearbeitet Gutachten manuell. + +### POST /klausur-korrektur/students/{id}/grade + +Berechnet 15-Punkte-Note. + +### PUT /klausur-korrektur/students/{id}/finalize + +Schließt Bewertung ab. + +### POST /klausur-korrektur/students/{id}/examiner + +Weist Erst-/Zweitprüfer zu. + +**Request Body:** +```json +{ + "examiner_type": "first", + "examiner_id": "teacher-uuid" +} +``` + +### GET /klausur-korrektur/examiner/queue + +Listet Prüfer-Warteschlange. + +### GET /klausur-korrektur/klausuren/{id}/fairness + +Vergleichsanalyse aller Schüler. + +**Response (200):** +```json +{ + "average_score": 75.5, + "median_score": 76, + "standard_deviation": 8.2, + "grade_distribution": {"15": 1, "14": 2, "13": 4, ...}, + "outliers": ["student-uuid-1", "student-uuid-2"] +} +``` + +### POST /klausur-korrektur/klausuren/{id}/export + +Exportiert Klausur als PDF. + +**Request Body:** +```json +{ + "include_gutachten": true, + "include_annotations": true +} +``` + +**Response:** `application/pdf` + +### 15-Punkte-Notenschlüssel + +| Punkte | Prozent | Note | +|--------|---------|------| +| 15 | ≥95% | 1+ | +| 14 | ≥90% | 1 | +| 13 | ≥85% | 1- | +| 12 | ≥80% | 2+ | +| 11 | ≥75% | 2 | +| 10 | ≥70% | 2- | +| 9 | ≥65% | 3+ | +| 8 | ≥60% | 3 | +| 7 | ≥55% | 3- | +| 6 | ≥50% | 4+ | +| 5 | ≥45% | 4 | +| 4 | ≥40% | 4- | +| 3 | ≥33% | 5+ | +| 2 | ≥27% | 5 | +| 1 | ≥20% | 5- | +| 0 | <20% | 6 | + +### Bewertungskriterien + +| Kriterium | Gewicht | Beschreibung | +|-----------|---------|--------------| +| `rechtschreibung` | 15% | Orthografie | +| `grammatik` | 15% | Grammatik & Syntax | +| `inhalt` | 40% | Inhaltliche Qualität | +| `struktur` | 15% | Aufbau & Gliederung | +| `stil` | 15% | Ausdruck & Stil | + +--- + +## Abitur-Docs API (Admin) + +Verwaltung von Abitur-Dokumenten (NiBiS Niedersachsen) für RAG-System. + +### GET /abitur-docs/documents + +Listet alle Dokumente. + +**Query Parameter:** +- `jahr` - Filter nach Jahr +- `fach` - Filter nach Fach +- `niveau` - Filter nach Niveau (eA/gA) +- `status` - Filter nach Status (pending/recognized/confirmed/indexed) +- `search` - Suche im Dateinamen + +**Response (200):** +```json +{ + "documents": [ + { + "id": "uuid", + "filename": "2025_Deutsch_eA_I_EWH.pdf", + "status": "confirmed", + "metadata": { + "jahr": 2025, + "bundesland": "niedersachsen", + "fach": "deutsch", + "niveau": "eA", + "dokument_typ": "erwartungshorizont", + "aufgaben_nummer": "I" + }, + "recognition_result": { + "confidence": 0.95, + "extracted": {...}, + "method": "filename_pattern" + } + } + ], + "total": 150 +} +``` + +### GET /abitur-docs/documents/{id} + +Ruft Dokument-Details ab. + +### POST /abitur-docs/documents + +Lädt einzelnes Dokument hoch. + +**Request (multipart/form-data):** +- `file`: PDF Datei + +**Response (200):** +```json +{ + "success": true, + "document": { + "id": "uuid", + "filename": "...", + "status": "recognized", + "recognition_result": {...} + } +} +``` + +### POST /abitur-docs/import-zip + +Importiert ZIP-Datei mit mehreren Dokumenten. + +**Request (multipart/form-data):** +- `file`: ZIP Datei + +**Response (200):** +```json +{ + "success": true, + "imported_count": 45, + "skipped_count": 2, + "errors": ["file.xyz: Unbekanntes Format"] +} +``` + +### PUT /abitur-docs/documents/{id}/metadata + +Aktualisiert Dokument-Metadaten. + +**Request Body:** +```json +{ + "jahr": 2025, + "bundesland": "niedersachsen", + "fach": "deutsch", + "niveau": "eA", + "dokument_typ": "erwartungshorizont", + "aufgaben_nummer": "I" +} +``` + +### POST /abitur-docs/documents/{id}/index + +Indexiert Dokument für RAG-System. + +### DELETE /abitur-docs/documents/{id} + +Löscht Dokument. + +### GET /abitur-docs/documents/{id}/preview + +Gibt PDF-Vorschau zurück. + +**Response:** `application/pdf` + +### GET /abitur-docs/enums + +Listet verfügbare Enum-Werte für Dropdowns. + +**Response (200):** +```json +{ + "bundeslaender": [ + {"value": "niedersachsen", "label": "Niedersachsen"}, + ... + ], + "faecher": [ + {"value": "deutsch", "label": "Deutsch"}, + ... + ], + "niveaus": [ + {"value": "eA", "label": "eA - erhöhtes Anforderungsniveau"}, + {"value": "gA", "label": "gA - grundlegendes Anforderungsniveau"} + ], + "dokumenttypen": [ + {"value": "aufgabe", "label": "Aufgabe"}, + {"value": "erwartungshorizont", "label": "Erwartungshorizont"}, + ... + ] +} +``` + +### GET /abitur-docs/search + +Sucht Dokumente für Klausur-Korrektur. + +**Query Parameter:** +- `fach` (required) - Fach +- `jahr` - Jahr +- `niveau` - Niveau + +**Response (200):** +```json +{ + "documents": [ + { + "id": "uuid", + "filename": "...", + "metadata": {...} + } + ] +} +``` + +### Dokument-Status + +| Status | Beschreibung | +|--------|--------------| +| `pending` | Hochgeladen, nicht erkannt | +| `recognized` | KI-Erkennung abgeschlossen | +| `confirmed` | Metadaten bestätigt | +| `indexed` | Im RAG-System indexiert | +| `error` | Fehler bei Verarbeitung | + +### NiBiS Dateinamen-Muster + +Automatische Erkennung von: +- `2025_Deutsch_eA_I.pdf` → Deutsch eA Aufgabe I +- `2025_Deutsch_eA_I_EWH.pdf` → Deutsch eA Erwartungshorizont I +- `2025_Englisch_gA_Hoerverstehen.pdf` → Englisch gA Hörverstehen + +--- + +## Security API (DevSecOps Dashboard) + +API fuer das Security Dashboard mit DevSecOps-Tools Integration. + +### GET /v1/security/tools + +Gibt Status aller DevSecOps-Tools zurueck. + +**Response (200):** +```json +[ + { + "name": "Gitleaks", + "installed": true, + "version": "v8.18.0", + "last_run": "09.01.2025 12:30", + "last_findings": 0 + } +] +``` + +### GET /v1/security/findings + +Gibt alle Security-Findings zurueck. + +**Query Parameter:** +- `tool` (optional): Filter nach Tool (gitleaks, semgrep, bandit, trivy, grype) +- `severity` (optional): Filter nach Severity (CRITICAL, HIGH, MEDIUM, LOW, INFO) +- `limit` (optional): Max. Anzahl (default: 100) + +**Response (200):** +```json +[ + { + "id": "CVE-2023-12345", + "tool": "trivy", + "severity": "HIGH", + "title": "Critical vulnerability in package", + "message": "requests 2.28.0", + "file": "requirements.txt", + "line": null, + "found_at": "2025-01-09T12:30:00" + } +] +``` + +### GET /v1/security/summary + +Gibt Severity-Zusammenfassung zurueck. + +**Response (200):** +```json +{ + "critical": 0, + "high": 2, + "medium": 5, + "low": 12, + "info": 3, + "total": 22 +} +``` + +### GET /v1/security/sbom + +Gibt SBOM (Software Bill of Materials) zurueck. + +**Response (200):** +```json +{ + "components": [ + { + "name": "fastapi", + "version": "0.109.0", + "type": "python", + "licenses": [{"license": {"id": "MIT"}}] + } + ], + "metadata": { + "timestamp": "2025-01-09T12:30:00" + } +} +``` + +### GET /v1/security/history + +Gibt Scan-Historie zurueck. + +**Query Parameter:** +- `limit` (optional): Max. Anzahl (default: 20) + +**Response (200):** +```json +[ + { + "timestamp": "2025-01-09T12:30:00", + "title": "Gitleaks Scan", + "description": "Keine Findings", + "status": "success" + } +] +``` + +### GET /v1/security/reports/{tool} + +Gibt vollstaendigen Report eines Tools zurueck. + +**Path Parameter:** +- `tool`: Tool-Name (gitleaks, semgrep, bandit, trivy, grype) + +**Response (200):** Rohes JSON des Tool-Reports + +### POST /v1/security/scan/{type} + +Startet einen Security-Scan. + +**Path Parameter:** +- `type`: Scan-Typ (secrets, sast, deps, containers, sbom, all) + +**Response (200):** +```json +{ + "status": "started", + "scan_type": "all", + "timestamp": "20250109_123000", + "message": "Scan 'all' wurde gestartet" +} +``` + +### GET /v1/security/health + +Health-Check fuer Security API. + +**Response (200):** +```json +{ + "status": "healthy", + "tools_installed": 5, + "tools_total": 6, + "reports_dir": "/app/security-reports", + "reports_exist": true +} +``` + +--- + +## Legal Templates API (Admin) + +Verwaltung von rechtlichen Textbausteinen für den Dokumentengenerator. Die Templates werden aus Open-Source-Repositories (CC0, MIT, CC BY 4.0) ingestiert und für RAG-basierte Dokumentengenerierung verwendet. + +### GET /api/v1/admin/templates/status + +Gibt den aktuellen Ingestion-Status zurück. + +**Response (200):** +```json +{ + "collection_exists": true, + "document_count": 1250, + "sources_count": 13, + "last_ingestion": "2026-02-08T10:30:00Z", + "ingestion_running": false +} +``` + +### GET /api/v1/admin/templates/sources + +Listet alle konfigurierten Template-Quellen. + +**Response (200):** +```json +{ + "sources": [ + { + "name": "github-site-policy", + "repo_url": "https://github.com/github/site-policy", + "license_type": "cc0", + "license_name": "CC0 1.0 Universal", + "template_types": ["terms_of_service", "privacy_policy"], + "languages": ["en"], + "jurisdiction": "US", + "attribution_required": false, + "document_count": 45 + } + ], + "total_sources": 13 +} +``` + +### GET /api/v1/admin/templates/licenses + +Gibt Lizenz-Statistiken zurück. + +**Response (200):** +```json +{ + "licenses": { + "cc0": { + "count": 450, + "attribution_required": false, + "sources": ["github-site-policy", "opr-vc"] + }, + "mit": { + "count": 380, + "attribution_required": true, + "sources": ["webflorist-privacy-policy"] + }, + "cc_by_4": { + "count": 220, + "attribution_required": true, + "sources": ["common-paper"] + } + }, + "total_documents": 1250 +} +``` + +### POST /api/v1/admin/templates/ingest + +Startet vollständige Ingestion aller Quellen im Hintergrund. + +**Response (202):** +```json +{ + "status": "started", + "message": "Ingestion für 13 Quellen gestartet", + "task_id": "uuid", + "sources_count": 13 +} +``` + +### POST /api/v1/admin/templates/ingest-source + +Ingestiert eine einzelne Quelle. + +**Request Body:** +```json +{ + "source_name": "github-site-policy" +} +``` + +**Response (202):** +```json +{ + "status": "started", + "message": "Ingestion für github-site-policy gestartet", + "source": "github-site-policy" +} +``` + +### POST /api/v1/admin/templates/search + +Semantische Suche in Templates mit Lizenz-Filtern. + +**Request Body:** +```json +{ + "query": "Datenschutzerklärung DSGVO konform", + "template_type": "privacy_policy", + "license_types": ["cc0", "mit"], + "language": "de", + "jurisdiction": "DE", + "attribution_required": false, + "limit": 10 +} +``` + +**Response (200):** +```json +{ + "results": [ + { + "id": "uuid", + "text": "Diese Datenschutzerklärung informiert Sie...", + "score": 0.92, + "template_type": "privacy_policy", + "license_id": "cc0", + "license_name": "CC0 1.0 Universal", + "source_name": "opr-vc", + "source_url": "https://opr.vc/docs/dsgvo", + "language": "de", + "jurisdiction": "DE", + "attribution_required": false, + "attribution_text": null, + "placeholders": ["[FIRMENNAME]", "[ADRESSE]"] + } + ], + "total": 45 +} +``` + +### DELETE /api/v1/admin/templates/reset + +Leert die gesamte Collection. **Vorsicht: Alle Templates werden gelöscht!** + +**Response (200):** +```json +{ + "success": true, + "message": "Collection bp_legal_templates geleert", + "deleted_count": 1250 +} +``` + +### DELETE /api/v1/admin/templates/source/{source_name} + +Löscht alle Dokumente einer bestimmten Quelle. + +**Path Parameter:** +- `source_name`: Name der Quelle (z.B. "github-site-policy") + +**Response (200):** +```json +{ + "success": true, + "message": "45 Dokumente von github-site-policy gelöscht", + "deleted_count": 45 +} +``` + +### Lizenz-Typen + +| Typ | Beschreibung | Attribution | +|-----|--------------|-------------| +| `public_domain` | Amtliche Werke (§5 UrhG) | Nein | +| `cc0` | CC0 1.0 Universal | Nein (empfohlen) | +| `unlicense` | Unlicense | Nein | +| `mit` | MIT License | Ja (im Footer) | +| `cc_by_4` | CC BY 4.0 | Ja + Änderungshinweis | +| `reuse_notice` | EDPB/EDPS Reuse Notice | Ja (keine Sinnentstellung) | + +### Template-Typen + +| Typ | Beschreibung | +|-----|--------------| +| `privacy_policy` | Datenschutzerklärung | +| `terms_of_service` | Nutzungsbedingungen | +| `agb` | Allgemeine Geschäftsbedingungen | +| `cookie_banner` | Cookie-Banner & Cookie-Policy | +| `impressum` | Impressum/Legal Notice | +| `widerruf` | Widerrufsbelehrung | +| `dpa` | Auftragsverarbeitungsvertrag | +| `sla` | Service Level Agreement | +| `nda` | Geheimhaltungsvereinbarung | +| `clause` | Einzelne Vertragsklausel | + +--- + +## Fehler-Responses + +### 400 Bad Request +```json +{ + "detail": "Beschreibung des Fehlers" +} +``` + +### 401 Unauthorized +```json +{ + "detail": "Not authenticated" +} +``` + +### 404 Not Found +```json +{ + "detail": "Ressource nicht gefunden" +} +``` + +### 500 Internal Server Error +```json +{ + "detail": "Interner Serverfehler" +} +``` diff --git a/docs/architecture/auth-system.md b/docs/architecture/auth-system.md new file mode 100644 index 0000000..aed865e --- /dev/null +++ b/docs/architecture/auth-system.md @@ -0,0 +1,294 @@ +# BreakPilot Authentifizierung & Autorisierung + +## Uebersicht + +BreakPilot verwendet einen **Hybrid-Ansatz** fuer Authentifizierung und Autorisierung: + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ AUTHENTIFIZIERUNG │ +│ "Wer bist du?" │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ HybridAuthenticator │ │ +│ │ ┌─────────────────────┐ ┌─────────────────────────────────┐ │ │ +│ │ │ Keycloak │ │ Lokales JWT │ │ │ +│ │ │ (Produktion) │ OR │ (Entwicklung) │ │ │ +│ │ │ RS256 + JWKS │ │ HS256 + Secret │ │ │ +│ │ └─────────────────────┘ └─────────────────────────────────┘ │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ AUTORISIERUNG │ +│ "Was darfst du?" │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ rbac.py (Eigenentwicklung) │ │ +│ │ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────────┐ │ │ +│ │ │ Rollen-Hierarchie│ │ PolicySet │ │ DEFAULT_PERMISSIONS│ │ │ +│ │ │ 15+ Rollen │ │ Bundesland- │ │ Matrix │ │ │ +│ │ │ - Erstkorrektor │ │ spezifisch │ │ Rolle→Ressource→ │ │ │ +│ │ │ - Klassenlehrer │ │ - Niedersachsen │ │ Aktion │ │ │ +│ │ │ - Schulleitung │ │ - Bayern │ │ │ │ │ +│ │ └─────────────────┘ └─────────────────┘ └───────────────────┘ │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +## Warum dieser Ansatz? + +### Alternative Loesungen (verworfen) + +| Tool | Problem fuer BreakPilot | +|------|-------------------------| +| **Casbin** | Zu generisch fuer Bundesland-spezifische Policies | +| **Cerbos** | Overhead: Externer PDP-Service fuer ~15 Rollen ueberdimensioniert | +| **OpenFGA** | Zanzibar-Modell optimiert fuer Graph-Beziehungen, nicht Hierarchien | +| **Keycloak RBAC** | Kann keine ressourcen-spezifischen Zuweisungen (User X ist Erstkorrektor fuer Package Y) | + +### Vorteile des Hybrid-Ansatzes + +1. **Keycloak fuer Authentifizierung:** + - Bewährtes IAM-System + - SSO, Federation, MFA + - Apache-2.0 Lizenz + +2. **Eigenes rbac.py fuer Autorisierung:** + - Domaenenspezifische Logik (Korrekturkette, Zeugnis-Workflow) + - Bundesland-spezifische Regeln + - Zeitlich begrenzte Zuweisungen + - Key-Sharing fuer verschluesselte Klausuren + +--- + +## Authentifizierung (auth/keycloak_auth.py) + +### Konfiguration + +```python +# Entwicklung: Lokales JWT (Standard) +JWT_SECRET=your-secret-key + +# Produktion: Keycloak +KEYCLOAK_SERVER_URL=https://keycloak.breakpilot.app +KEYCLOAK_REALM=breakpilot +KEYCLOAK_CLIENT_ID=breakpilot-backend +KEYCLOAK_CLIENT_SECRET=your-client-secret +``` + +### Token-Erkennung + +Der `HybridAuthenticator` erkennt automatisch den Token-Typ: + +```python +# Keycloak-Token (RS256) +{ + "iss": "https://keycloak.breakpilot.app/realms/breakpilot", + "sub": "user-uuid", + "realm_access": {"roles": ["teacher", "admin"]}, + ... +} + +# Lokales JWT (HS256) +{ + "iss": "breakpilot", + "user_id": "user-uuid", + "role": "admin", + ... +} +``` + +### FastAPI Integration + +```python +from auth import get_current_user + +@app.get("/api/protected") +async def protected_endpoint(user: dict = Depends(get_current_user)): + # user enthält: user_id, email, role, realm_roles, tenant_id + return {"user_id": user["user_id"]} +``` + +--- + +## Autorisierung (klausur-service/backend/rbac.py) + +### Rollen (15+) + +| Rolle | Beschreibung | Bereich | +|-------|--------------|---------| +| `erstkorrektor` | Erster Prüfer | Klausur | +| `zweitkorrektor` | Zweiter Prüfer | Klausur | +| `drittkorrektor` | Dritter Prüfer | Klausur | +| `klassenlehrer` | Klassenleitung | Zeugnis | +| `fachlehrer` | Fachlehrkraft | Noten | +| `fachvorsitz` | Fachkonferenz-Leitung | Fachschaft | +| `schulleitung` | Schulleiter/in | Schule | +| `zeugnisbeauftragter` | Zeugnis-Koordination | Zeugnis | +| `sekretariat` | Verwaltung | Schule | +| `data_protection_officer` | DSB | DSGVO | +| ... | | | + +### Ressourcentypen (25+) + +```python +class ResourceType(str, Enum): + EXAM_PACKAGE = "exam_package" # Klausurpaket + STUDENT_SUBMISSION = "student_submission" + CORRECTION = "correction" + ZEUGNIS = "zeugnis" + FACHNOTE = "fachnote" + KOPFNOTE = "kopfnote" + BEMERKUNG = "bemerkung" + ... +``` + +### Aktionen (17) + +```python +class Action(str, Enum): + CREATE = "create" + READ = "read" + UPDATE = "update" + DELETE = "delete" + SIGN_OFF = "sign_off" # Freigabe + BREAK_GLASS = "break_glass" # Notfall-Zugriff + SHARE_KEY = "share_key" # Schlüssel teilen + ... +``` + +### Permission-Pruefung + +```python +from klausur_service.backend.rbac import PolicyEngine + +engine = PolicyEngine() + +# Pruefe ob User X Klausur Y korrigieren darf +allowed = engine.check_permission( + user_id="user-uuid", + action=Action.UPDATE, + resource_type=ResourceType.CORRECTION, + resource_id="klausur-uuid" +) +``` + +--- + +## Bundesland-spezifische Policies + +```python +@dataclass +class PolicySet: + bundesland: str + abitur_type: str # "landesabitur" | "zentralabitur" + + # Korrekturkette + korrektoren_anzahl: int # 2 oder 3 + anonyme_erstkorrektur: bool + + # Sichtbarkeit + zk_visibility_mode: ZKVisibilityMode # BLIND | SEMI | FULL + eh_visibility_mode: EHVisibilityMode + + # Zeugnis + kopfnoten_enabled: bool + ... +``` + +### Beispiel: Niedersachsen + +```python +NIEDERSACHSEN_POLICY = PolicySet( + bundesland="niedersachsen", + abitur_type="landesabitur", + korrektoren_anzahl=2, + anonyme_erstkorrektur=True, + zk_visibility_mode=ZKVisibilityMode.BLIND, + eh_visibility_mode=EHVisibilityMode.SUMMARY_ONLY, + kopfnoten_enabled=True, +) +``` + +--- + +## Workflow-Beispiele + +### Klausurkorrektur-Workflow + +``` +1. Lehrer laedt Klausuren hoch + └── Rolle: "lehrer" + Action.CREATE auf EXAM_PACKAGE + +2. Erstkorrektor korrigiert + └── Rolle: "erstkorrektor" (ressourcen-spezifisch) + Action.UPDATE auf CORRECTION + +3. Zweitkorrektor ueberprueft + └── Rolle: "zweitkorrektor" + Action.READ auf CORRECTION + └── Policy: zk_visibility_mode bestimmt Sichtbarkeit + +4. Drittkorrektor (bei Abweichung) + └── Rolle: "drittkorrektor" + Action.SIGN_OFF +``` + +### Zeugnis-Workflow + +``` +1. Fachlehrer traegt Noten ein + └── Rolle: "fachlehrer" + Action.CREATE auf FACHNOTE + +2. Klassenlehrer prueft + └── Rolle: "klassenlehrer" + Action.READ auf ZEUGNIS + └── Action.SIGN_OFF freigeben + +3. Zeugnisbeauftragter final + └── Rolle: "zeugnisbeauftragter" + Action.SIGN_OFF + +4. Schulleitung unterzeichnet + └── Rolle: "schulleitung" + Action.SIGN_OFF +``` + +--- + +## Dateien + +| Datei | Beschreibung | +|-------|--------------| +| `backend/auth/__init__.py` | Auth-Modul Exports | +| `backend/auth/keycloak_auth.py` | Hybrid-Authentifizierung | +| `klausur-service/backend/rbac.py` | Autorisierungs-Engine | +| `backend/rbac_api.py` | REST API fuer Rollenverwaltung | + +--- + +## Konfiguration + +### Entwicklung (ohne Keycloak) + +```bash +# .env +ENVIRONMENT=development +JWT_SECRET=dev-secret-32-chars-minimum-here +``` + +### Produktion (mit Keycloak) + +```bash +# .env +ENVIRONMENT=production +JWT_SECRET= +KEYCLOAK_SERVER_URL=https://keycloak.breakpilot.app +KEYCLOAK_REALM=breakpilot +KEYCLOAK_CLIENT_ID=breakpilot-backend +KEYCLOAK_CLIENT_SECRET= +``` + +--- + +## Sicherheitshinweise + +1. **Secrets niemals im Code** - Immer Umgebungsvariablen verwenden +2. **JWT_SECRET in Produktion** - Mindestens 32 Bytes, generiert mit `openssl rand -hex 32` +3. **Keycloak HTTPS** - KEYCLOAK_VERIFY_SSL=true in Produktion +4. **Token-Expiration** - Keycloak-Tokens kurz halten (5-15 Minuten) +5. **Audit-Trail** - Alle Berechtigungspruefungen werden geloggt diff --git a/docs/architecture/devsecops.md b/docs/architecture/devsecops.md new file mode 100644 index 0000000..9a233d7 --- /dev/null +++ b/docs/architecture/devsecops.md @@ -0,0 +1,314 @@ +# BreakPilot DevSecOps Architecture + +## Uebersicht + +BreakPilot implementiert einen umfassenden DevSecOps-Ansatz mit Security-by-Design fuer die Entwicklung und den Betrieb der Bildungsplattform. + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ DEVSECOPS PIPELINE │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Pre-Commit │───►│ CI/CD │───►│ Build │───►│ Deploy │ │ +│ │ Hooks │ │ Pipeline │ │ & Scan │ │ & Monitor │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ │ │ │ │ +│ ▼ ▼ ▼ ▼ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Gitleaks │ │ Semgrep │ │ Trivy │ │ Falco │ │ +│ │ Bandit │ │ OWASP DC │ │ Grype │ │ (optional) │ │ +│ │ Secrets │ │ SAST/SCA │ │ SBOM │ │ Runtime │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +## Security Tools Stack + +### 1. Secrets Detection + +| Tool | Version | Lizenz | Verwendung | +|------|---------|--------|------------| +| **Gitleaks** | 8.18.x | MIT | Pre-commit Hook, CI/CD | +| **detect-secrets** | 1.4.x | Apache-2.0 | Zusaetzliche Baseline-Pruefung | + +**Konfiguration:** `.gitleaks.toml` + +```bash +# Lokal ausfuehren +gitleaks detect --source . -v + +# Pre-commit (automatisch) +gitleaks protect --staged -v +``` + +### 2. Static Application Security Testing (SAST) + +| Tool | Version | Lizenz | Sprachen | +|------|---------|--------|----------| +| **Semgrep** | 1.52.x | LGPL-2.1 | Python, Go, JavaScript, TypeScript | +| **Bandit** | 1.7.x | Apache-2.0 | Python (spezialisiert) | + +**Konfiguration:** `.semgrep.yml` + +```bash +# Semgrep ausfuehren +semgrep scan --config auto --config .semgrep.yml + +# Bandit ausfuehren +bandit -r backend/ -ll +``` + +### 3. Software Composition Analysis (SCA) + +| Tool | Version | Lizenz | Verwendung | +|------|---------|--------|------------| +| **Trivy** | 0.48.x | Apache-2.0 | Filesystem, Container, IaC | +| **Grype** | 0.74.x | Apache-2.0 | Vulnerability Scanning | +| **OWASP Dependency-Check** | 9.x | Apache-2.0 | CVE/NVD Abgleich | + +**Konfiguration:** `.trivy.yaml` + +```bash +# Filesystem-Scan +trivy fs . --severity HIGH,CRITICAL + +# Container-Scan +trivy image breakpilot-pwa-backend:latest +``` + +### 4. SBOM (Software Bill of Materials) + +| Tool | Version | Lizenz | Formate | +|------|---------|--------|---------| +| **Syft** | 0.100.x | Apache-2.0 | CycloneDX, SPDX | + +```bash +# SBOM generieren +syft dir:. -o cyclonedx-json=sbom.json +syft dir:. -o spdx-json=sbom-spdx.json +``` + +### 5. Dynamic Application Security Testing (DAST) + +| Tool | Version | Lizenz | Verwendung | +|------|---------|--------|------------| +| **OWASP ZAP** | 2.14.x | Apache-2.0 | Staging-Scans (nightly) | + +```bash +# ZAP Scan gegen Staging +docker run -t owasp/zap2docker-stable zap-baseline.py \ + -t http://staging.breakpilot.app -r zap-report.html +``` + +## Pre-Commit Hooks + +Die Pre-Commit-Konfiguration (`.pre-commit-config.yaml`) fuehrt automatisch bei jedem Commit aus: + +1. **Schnelle Checks** (< 10 Sekunden): + - Gitleaks (Secrets) + - Trailing Whitespace + - YAML/JSON Validierung + +2. **Code Quality** (< 30 Sekunden): + - Black/Ruff (Python Formatting) + - Go fmt/vet + - ESLint (JavaScript) + +3. **Security Checks** (< 60 Sekunden): + - Bandit (Python Security) + - Semgrep (Error-Severity) + +### Installation + +```bash +# Pre-commit installieren +pip install pre-commit + +# Hooks aktivieren +pre-commit install + +# Alle Checks manuell ausfuehren +pre-commit run --all-files +``` + +## CI/CD Integration + +### GitHub Actions Pipeline + +```yaml +# .github/workflows/security.yml +name: Security Scan + +on: [push, pull_request] + +jobs: + secrets: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: gitleaks/gitleaks-action@v2 + + sast: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: returntocorp/semgrep-action@v1 + with: + config: >- + auto + .semgrep.yml + + sca: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: aquasecurity/trivy-action@master + with: + scan-type: 'fs' + severity: 'HIGH,CRITICAL' + + sbom: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: anchore/sbom-action@v0 + with: + format: cyclonedx-json +``` + +## Security Reports + +Alle Security-Reports werden in `security-reports/` gespeichert: + +| Report | Format | Tool | +|--------|--------|------| +| `gitleaks-*.json` | JSON | Gitleaks | +| `semgrep-*.json` | SARIF/JSON | Semgrep | +| `bandit-*.json` | JSON | Bandit | +| `trivy-fs-*.json` | JSON | Trivy | +| `trivy-image-*.json` | JSON | Trivy | +| `grype-*.json` | JSON | Grype | +| `sbom-*.json` | CycloneDX | Syft | + +### Security-Scan Script + +```bash +# Alle Scans ausfuehren +./scripts/security-scan.sh --all + +# Nur Secrets-Scan +./scripts/security-scan.sh --secrets + +# CI-Modus (Exit bei Critical Findings) +./scripts/security-scan.sh --all --ci +``` + +## Severity-Gates + +| Phase | Severity | Aktion | +|-------|----------|--------| +| Pre-Commit | ERROR | Commit blockiert | +| PR/CI | CRITICAL, HIGH | Pipeline blockiert | +| Nightly Scan | MEDIUM+ | Report generiert | +| Production Deploy | CRITICAL | Deploy blockiert | + +## Compliance + +Die DevSecOps-Pipeline unterstuetzt folgende Compliance-Anforderungen: + +- **DSGVO/GDPR**: Automatische Erkennung von PII-Leaks +- **OWASP Top 10**: SAST/DAST-Scans gegen bekannte Schwachstellen +- **Supply Chain Security**: SBOM-Generierung fuer Audit-Trails +- **CVE Tracking**: Automatischer Abgleich mit NVD/CVE-Datenbanken + +## Dateien + +| Datei | Beschreibung | +|-------|--------------| +| `.gitleaks.toml` | Gitleaks Konfiguration | +| `.semgrep.yml` | Semgrep Custom Rules | +| `.trivy.yaml` | Trivy Konfiguration | +| `.trivyignore` | Trivy Ignore-Liste | +| `.pre-commit-config.yaml` | Pre-Commit Hooks | +| `scripts/security-scan.sh` | Security-Scan Script | + +## Tool-Installation + +### macOS (Homebrew) + +```bash +# Security Tools +brew install gitleaks +brew install trivy +brew install syft +brew install grype + +# Python Tools +pip install semgrep bandit pre-commit +``` + +### Linux (apt/snap) + +```bash +# Gitleaks +sudo snap install gitleaks + +# Trivy +sudo apt-get install trivy + +# Python Tools +pip install semgrep bandit pre-commit +``` + +## Security Dashboard + +Das BreakPilot Admin Panel enthaelt ein integriertes Security Dashboard unter **Verwaltung > Security**. + +### Features + +**Fuer Entwickler:** +- Scan-Ergebnisse auf einen Blick +- Pre-commit Hook Status +- Quick-Fix Suggestions +- SBOM Viewer mit Suchfunktion + +**Fuer Security-Experten:** +- Vulnerability Severity Distribution (Critical/High/Medium/Low) +- CVE-Tracking mit Fix-Verfuegbarkeit +- Compliance-Status (OWASP Top 10, DSGVO) +- Secrets Detection History + +**Fuer Ops:** +- Container Image Scan Results +- Dependency Update Status +- Security Scan Scheduling +- Auto-Refresh alle 30 Sekunden + +### API Endpoints + +``` +GET /api/v1/security/tools - Tool-Status +GET /api/v1/security/findings - Alle Findings +GET /api/v1/security/summary - Severity-Zusammenfassung +GET /api/v1/security/sbom - SBOM-Daten +GET /api/v1/security/history - Scan-Historie +GET /api/v1/security/reports/{tool} - Tool-spezifischer Report +POST /api/v1/security/scan/{type} - Scan starten (secrets/sast/deps/containers/sbom/all) +GET /api/v1/security/health - Health-Check +``` + +### Frontend-Integration + +Das Security-Modul ist unter `backend/frontend/modules/security.py` implementiert und folgt der modularen Studio-Architektur mit: +- `SecurityModule.get_css()` - Dashboard-Styles +- `SecurityModule.get_html()` - Panel-Struktur +- `SecurityModule.get_js()` - Dashboard-Logik + +## Weiterentwicklung + +Geplante Erweiterungen: + +1. **OPA/Conftest**: Policy-as-Code fuer Terraform/Kubernetes +2. **Falco**: Runtime-Security fuer Kubernetes +3. **OWASP ZAP**: Automatisierte DAST-Scans +4. **Dependency-Track**: SBOM-basiertes Vulnerability Management diff --git a/docs/architecture/environments.md b/docs/architecture/environments.md new file mode 100644 index 0000000..652f293 --- /dev/null +++ b/docs/architecture/environments.md @@ -0,0 +1,197 @@ +# Umgebungs-Architektur + +## Übersicht + +BreakPilot verwendet eine 3-Umgebungs-Strategie für sichere Entwicklung und Deployment: + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Development │────▶│ Staging │────▶│ Production │ +│ (develop) │ │ (staging) │ │ (main) │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + Tägliche Getesteter Code Produktionsreif + Entwicklung +``` + +## Umgebungen + +### Development (Dev) + +**Zweck:** Tägliche Entwicklungsarbeit + +| Eigenschaft | Wert | +|-------------|------| +| Git Branch | `develop` | +| Compose File | `docker-compose.yml` + `docker-compose.override.yml` (auto) | +| Env File | `.env.dev` | +| Database | `breakpilot_dev` | +| Debug | Aktiviert | +| Hot-Reload | Aktiviert | + +**Start:** +```bash +./scripts/start.sh dev +# oder einfach: +docker compose up -d +``` + +### Staging + +**Zweck:** Getesteter, freigegebener Code vor Produktion + +| Eigenschaft | Wert | +|-------------|------| +| Git Branch | `staging` | +| Compose File | `docker-compose.yml` + `docker-compose.staging.yml` | +| Env File | `.env.staging` | +| Database | `breakpilot_staging` (separates Volume) | +| Debug | Deaktiviert | +| Hot-Reload | Deaktiviert | + +**Start:** +```bash +./scripts/start.sh staging +# oder: +docker compose -f docker-compose.yml -f docker-compose.staging.yml up -d +``` + +### Production (Prod) + +**Zweck:** Live-System für Endbenutzer (ab Launch) + +| Eigenschaft | Wert | +|-------------|------| +| Git Branch | `main` | +| Compose File | `docker-compose.yml` + `docker-compose.prod.yml` | +| Env File | `.env.prod` (NICHT im Repository!) | +| Database | `breakpilot_prod` (separates Volume) | +| Debug | Deaktiviert | +| Vault | Pflicht (keine Env-Fallbacks) | + +## Datenbank-Trennung + +Jede Umgebung verwendet separate Docker Volumes für vollständige Datenisolierung: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ PostgreSQL Volumes │ +├─────────────────────────────────────────────────────────────┤ +│ breakpilot-dev_postgres_data │ Development Database │ +│ breakpilot_staging_postgres │ Staging Database │ +│ breakpilot_prod_postgres │ Production Database │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Port-Mapping + +Um mehrere Umgebungen gleichzeitig laufen zu lassen, verwenden sie unterschiedliche Ports: + +| Service | Dev Port | Staging Port | Prod Port | +|---------|----------|--------------|-----------| +| Backend | 8000 | 8001 | 8000 | +| PostgreSQL | 5432 | 5433 | - (intern) | +| MinIO | 9000/9001 | 9002/9003 | - (intern) | +| Qdrant | 6333/6334 | 6335/6336 | - (intern) | +| Mailpit | 8025/1025 | 8026/1026 | - (deaktiviert) | + +## Git Branching Strategie + +``` +main (Prod) ← Nur Release-Merges, geschützt + │ + ▼ +staging ← Getesteter Code, Review erforderlich + │ + ▼ +develop (Dev) ← Tägliche Arbeit, Default-Branch + │ + ▼ +feature/* ← Feature-Branches (optional) +``` + +### Workflow + +1. **Entwicklung:** Arbeite auf `develop` +2. **Code-Review:** Erstelle PR von Feature-Branch → `develop` +3. **Staging:** Promote `develop` → `staging` mit Tests +4. **Release:** Promote `staging` → `main` nach Freigabe + +### Promotion-Befehle + +```bash +# develop → staging +./scripts/promote.sh dev-to-staging + +# staging → main (Production) +./scripts/promote.sh staging-to-prod +``` + +## Secrets Management + +### Development +- `.env.dev` enthält Entwicklungs-Credentials +- Vault optional (Dev-Token) +- Mailpit für E-Mail-Tests + +### Staging +- `.env.staging` enthält Test-Credentials +- Vault empfohlen +- Mailpit für E-Mail-Sicherheit + +### Production +- `.env.prod` NICHT im Repository +- Vault PFLICHT +- Echte SMTP-Konfiguration + +Siehe auch: [secrets-management.md](./secrets-management.md) + +## Docker Compose Architektur + +``` +docker-compose.yml ← Basis-Konfiguration + │ + ├── docker-compose.override.yml ← Dev (auto-geladen) + │ + ├── docker-compose.staging.yml ← Staging (explizit) + │ + └── docker-compose.prod.yml ← Production (explizit) +``` + +### Automatisches Laden + +Docker Compose lädt automatisch: +1. `docker-compose.yml` +2. `docker-compose.override.yml` (falls vorhanden) + +Daher startet `docker compose up` automatisch die Dev-Umgebung. + +## Helper Scripts + +| Script | Beschreibung | +|--------|--------------| +| `scripts/env-switch.sh` | Wechselt zwischen Umgebungen | +| `scripts/start.sh` | Startet Services für Umgebung | +| `scripts/stop.sh` | Stoppt Services | +| `scripts/promote.sh` | Promotet Code zwischen Branches | +| `scripts/status.sh` | Zeigt aktuellen Status | + +## Verifikation + +Nach Setup prüfen: + +```bash +# Status anzeigen +./scripts/status.sh + +# Branches prüfen +git branch -v + +# Volumes prüfen +docker volume ls | grep breakpilot +``` + +## Verwandte Dokumentation + +- [secrets-management.md](./secrets-management.md) - Vault & Secrets +- [devsecops.md](./devsecops.md) - CI/CD & Security +- [system-architecture.md](./system-architecture.md) - Gesamtarchitektur diff --git a/docs/architecture/mail-rbac-architecture.md b/docs/architecture/mail-rbac-architecture.md new file mode 100644 index 0000000..58daf17 --- /dev/null +++ b/docs/architecture/mail-rbac-architecture.md @@ -0,0 +1,722 @@ +# Mail-RBAC Architektur mit Mitarbeiter-Anonymisierung + +**Version:** 1.0.0 +**Datum:** 2026-01-10 +**Status:** Architekturplanung + +--- + +## Executive Summary + +Dieses Dokument beschreibt eine neuartige Architektur, die E-Mail, Kalender und Videokonferenzen mit rollenbasierter Zugriffskontrolle (RBAC) verbindet. Das Kernkonzept ermöglicht die **vollständige Anonymisierung von Mitarbeiterdaten** bei Verlassen des Unternehmens, während geschäftliche Kommunikationshistorie erhalten bleibt. + +**Wichtig:** Dieses Konzept existiert in dieser Form noch nicht als fertige Lösung auf dem Markt. Es handelt sich um eine innovative Architektur, die entwickelt werden muss. + +--- + +## 1. Das Problem + +### Traditionelle E-Mail-Systeme +``` +max.mustermann@firma.de → Person gebunden + → DSGVO: Daten müssen gelöscht werden + → Geschäftshistorie geht verloren +``` + +### BreakPilot-Lösung: Rollenbasierte E-Mail +``` +klassenlehrer.5a@schule.breakpilot.app → Rolle gebunden + → Person kann anonymisiert werden + → Kommunikationshistorie bleibt erhalten +``` + +--- + +## 2. Architektur-Übersicht + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ BreakPilot Groupware │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Webmail │ │ Kalender │ │ Jitsi │ │ +│ │ (SOGo) │ │ (SOGo) │ │ Meeting │ │ +│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ +│ │ │ │ │ +│ └────────────────┼────────────────┘ │ +│ │ │ +│ ┌───────────┴───────────┐ │ +│ │ RBAC-Mail-Bridge │ ◄─── Neue Komponente │ +│ │ (Python/Go) │ │ +│ └───────────┬───────────┘ │ +│ │ │ +│ ┌─────────────────────┼─────────────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌──────────┐ ┌──────────────┐ ┌────────────┐ │ +│ │PostgreSQL│ │ Mail Server │ │ MinIO │ │ +│ │(RBAC DB) │ │ (Stalwart) │ │ (Backups) │ │ +│ └──────────┘ └──────────────┘ └────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. Komponenten-Auswahl + +### 3.1 E-Mail Server: Stalwart Mail Server + +**Empfehlung:** [Stalwart Mail Server](https://stalw.art/) + +| Kriterium | Bewertung | +|-----------|-----------| +| Lizenz | AGPL-3.0 (Open Source) | +| Sprache | Rust (performant, sicher) | +| Features | IMAP, SMTP, JMAP, WebSocket | +| Kalender | CalDAV integriert | +| Kontakte | CardDAV integriert | +| Spam/Virus | Integriert | +| API | REST API für Administration | + +**Lizenz-Implikation für kommerzielle Nutzung:** +- AGPL-3.0 erfordert Veröffentlichung von Änderungen am Stalwart-Code selbst +- Nutzung als Service ohne Code-Änderungen: keine Veröffentlichungspflicht +- Unsere RBAC-Bridge ist separater Code: kann proprietär bleiben + +### 3.2 Webmail-Client: SOGo oder Roundcube + +**Option A: SOGo** (empfohlen) +- Lizenz: GPL-2.0 / LGPL-2.1 +- Kalender, Kontakte, Mail in einem +- ActiveSync Support +- Outlook-ähnliche Oberfläche + +**Option B: Roundcube** +- Lizenz: GPL-3.0 +- Nur Webmail +- Benötigt separaten Kalender + +### 3.3 Kalender-Integration + +Stalwart bietet CalDAV, kann mit: +- SOGo Webinterface +- Thunderbird +- iOS/Android Kalender +- Outlook (via CalDAV Plugin) + +### 3.4 Jitsi-Integration + +Bereits vorhanden in BreakPilot: +- `backend/jitsi_api.py` - Meeting-Erstellung +- `email_service.py` - Jitsi-Einladungen per E-Mail +- Kalender-Events können Jitsi-Links enthalten + +--- + +## 4. Datenmodell für Rollen-E-Mail + +### 4.1 Neue Datenbank-Tabellen + +```sql +-- Funktionale E-Mail-Adressen (rollengebunden) +CREATE TABLE functional_mailboxes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Rolle und Mailbox + role_key VARCHAR(100) NOT NULL, -- z.B. "klassenlehrer_5a" + email_address VARCHAR(255) UNIQUE NOT NULL, -- z.B. "klassenlehrer.5a@schule.bp.app" + display_name VARCHAR(255) NOT NULL, -- z.B. "Klassenlehrer 5a" + + -- Zuordnung + tenant_id UUID NOT NULL REFERENCES tenants(id), + resource_type VARCHAR(50) DEFAULT 'class', -- class, department, function + resource_id VARCHAR(100), -- z.B. "5a" oder "mathematik" + + -- Status + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW(), + + CONSTRAINT fk_role FOREIGN KEY (role_key) + REFERENCES roles(role_key) ON DELETE RESTRICT +); + +-- Zuordnung: Welche Person hat welche funktionale Mailbox +CREATE TABLE mailbox_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + mailbox_id UUID NOT NULL REFERENCES functional_mailboxes(id), + user_id UUID NOT NULL REFERENCES users(id), + + -- Zeitraum + valid_from TIMESTAMP DEFAULT NOW(), + valid_to TIMESTAMP, + + -- Audit + assigned_by UUID REFERENCES users(id), + assigned_at TIMESTAMP DEFAULT NOW(), + revoked_by UUID, + revoked_at TIMESTAMP, + + -- Constraints + CONSTRAINT unique_active_assignment + UNIQUE (mailbox_id, user_id) + WHERE revoked_at IS NULL +); + +-- Persönliche E-Mail-Adressen (für Anonymisierung) +CREATE TABLE personal_email_accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + + -- E-Mail + email_address VARCHAR(255) UNIQUE NOT NULL, + + -- Anonymisierungsstatus + is_anonymized BOOLEAN DEFAULT false, + anonymized_at TIMESTAMP, + anonymized_by UUID, + + -- Original-Daten (verschlüsselt, für DSGVO-Auskunft) + original_name_encrypted BYTEA, + original_email_encrypted BYTEA, + encryption_key_id VARCHAR(100), + + -- Audit + created_at TIMESTAMP DEFAULT NOW() +); + +-- Audit-Trail für E-Mail-Kommunikation (ohne personenbezogene Daten) +CREATE TABLE email_audit_trail ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Kommunikation + mailbox_id UUID REFERENCES functional_mailboxes(id), + direction VARCHAR(10) NOT NULL, -- 'inbound', 'outbound' + + -- Metadaten (keine Inhalte!) + subject_hash VARCHAR(64), -- SHA-256 für Deduplizierung + timestamp TIMESTAMP NOT NULL, + external_party_domain VARCHAR(255), -- z.B. "eltern.de" (nicht volle Adresse) + + -- Rolle zum Zeitpunkt + role_key VARCHAR(100) NOT NULL, + + -- Person NICHT gespeichert - nur über Assignment nachvollziehbar + -- Bei Anonymisierung: Assignment-User wird anonymisiert + + created_at TIMESTAMP DEFAULT NOW() +); + +-- Anonymisierungsprotokoll (DSGVO-Nachweis) +CREATE TABLE anonymization_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Was wurde anonymisiert + entity_type VARCHAR(50) NOT NULL, -- 'user', 'email_account' + entity_id UUID NOT NULL, + + -- Wie + anonymization_type VARCHAR(50) NOT NULL, -- 'pseudonymization', 'deletion' + fields_affected JSONB NOT NULL, + + -- Warum + reason VARCHAR(100) NOT NULL, -- 'employee_departure', 'dsgvo_request' + + -- Audit + performed_by UUID NOT NULL, + performed_at TIMESTAMP DEFAULT NOW(), + + -- Bestätigung + legal_basis VARCHAR(255), -- z.B. "Art. 17 DSGVO" + retention_period_days INTEGER +); +``` + +### 4.2 Anonymisierungs-Workflow + +``` +Mitarbeiter kündigt + │ + ▼ +┌───────────────────────────┐ +│ 1. Functional Mailboxes │ +│ → Neu zuweisen oder │ +│ → Deaktivieren │ +└───────────┬───────────────┘ + │ + ▼ +┌───────────────────────────┐ +│ 2. Personal Email Account │ +│ → Anonymisieren: │ +│ max.mustermann@... │ +│ → mitarbeiter_a7x2@... │ +└───────────────────────────┘ + │ + ▼ +┌───────────────────────────┐ +│ 3. Users-Tabelle │ +│ → Pseudonymisieren: │ +│ name: "Max Mustermann" │ +│ → "Ehem. Mitarbeiter" │ +└───────────────────────────┘ + │ + ▼ +┌───────────────────────────┐ +│ 4. Mailbox Assignments │ +│ → Bleiben für Audit │ +│ → User-Referenz zeigt │ +│ auf anonymisierte │ +│ Daten │ +└───────────────────────────┘ + │ + ▼ +┌───────────────────────────┐ +│ 5. E-Mail-Archiv │ +│ → Header anonymisieren │ +│ → Inhalte optional │ +│ löschen │ +└───────────────────────────┘ +``` + +--- + +## 5. RBAC-Mail-Bridge Implementierung + +### 5.1 Python-Komponente + +```python +# rbac_mail_bridge/anonymizer.py + +class EmployeeAnonymizer: + """ + Anonymisiert Mitarbeiterdaten bei Ausscheiden. + Erhält Audit-Trail und funktionale Zuordnungen. + """ + + async def anonymize_employee( + self, + user_id: str, + reason: str, + performed_by: str + ) -> AnonymizationResult: + """ + Vollständige Anonymisierung eines Mitarbeiters. + + Schritte: + 1. Functional Mailboxes neu zuweisen + 2. Personal Email anonymisieren + 3. User-Daten pseudonymisieren + 4. E-Mail-Archive bereinigen + 5. Audit-Log erstellen + """ + + async with self.db.transaction(): + # 1. Functional Mailboxes + assignments = await self.get_active_assignments(user_id) + for assignment in assignments: + await self.revoke_assignment( + assignment.id, + revoked_by=performed_by, + reason="employee_departure" + ) + + # 2. Personal Email + personal_email = await self.get_personal_email(user_id) + if personal_email: + # Verschlüssele Original für DSGVO-Auskunft + encrypted = await self.encrypt_for_retention( + personal_email.email_address, + personal_email.display_name + ) + + # Anonymisiere + anon_email = f"user_{generate_random_id()}@anon.local" + await self.db.execute(""" + UPDATE personal_email_accounts + SET + email_address = $1, + is_anonymized = true, + anonymized_at = NOW(), + original_email_encrypted = $2 + WHERE user_id = $3 + """, anon_email, encrypted, user_id) + + # 3. User-Daten + await self.db.execute(""" + UPDATE users SET + name = 'Ehemaliger Mitarbeiter', + email = $1, + is_active = false, + anonymized_at = NOW() + WHERE id = $2 + """, f"anon_{user_id[:8]}@deleted.local", user_id) + + # 4. E-Mail-Archive + await self.mail_server.anonymize_mailbox(user_id) + + # 5. Audit-Log + await self.create_anonymization_log( + entity_type="user", + entity_id=user_id, + reason=reason, + performed_by=performed_by + ) + + return AnonymizationResult(success=True) +``` + +### 5.2 API-Endpunkte + +```python +# rbac_mail_bridge/api.py + +router = APIRouter(prefix="/api/v1/mail-rbac", tags=["mail-rbac"]) + +@router.get("/mailboxes") +async def list_functional_mailboxes( + user: Dict = Depends(get_current_admin) +) -> List[FunctionalMailbox]: + """Liste aller funktionalen Mailboxen""" + pass + +@router.post("/mailboxes") +async def create_functional_mailbox( + mailbox: FunctionalMailboxCreate, + user: Dict = Depends(get_current_admin) +) -> FunctionalMailbox: + """Erstellt eine rollengebundene Mailbox""" + pass + +@router.post("/mailboxes/{mailbox_id}/assign") +async def assign_mailbox_to_user( + mailbox_id: str, + assignment: MailboxAssignment, + user: Dict = Depends(get_current_admin) +) -> Assignment: + """Weist eine Mailbox einem Benutzer zu""" + pass + +@router.post("/users/{user_id}/anonymize") +async def anonymize_user( + user_id: str, + request: AnonymizationRequest, + user: Dict = Depends(get_current_admin) +) -> AnonymizationResult: + """Anonymisiert einen ausgeschiedenen Mitarbeiter""" + pass + +@router.get("/audit/anonymizations") +async def list_anonymizations( + user: Dict = Depends(get_current_admin) +) -> List[AnonymizationLog]: + """Liste aller Anonymisierungen (DSGVO-Nachweis)""" + pass +``` + +--- + +## 6. Docker-Compose Integration + +```yaml +# docker-compose.yml Erweiterung + +services: + # ... bestehende Services ... + + # Stalwart Mail Server + stalwart: + image: stalwartlabs/mail-server:latest + container_name: breakpilot-mail + hostname: mail.breakpilot.local + ports: + - "25:25" # SMTP + - "143:143" # IMAP + - "465:465" # SMTPS + - "993:993" # IMAPS + - "4190:4190" # ManageSieve + - "8080:8080" # Web Admin + volumes: + - stalwart-data:/opt/stalwart-mail/data + - ./config/stalwart:/opt/stalwart-mail/etc + environment: + - STALWART_HOSTNAME=mail.breakpilot.local + networks: + - breakpilot-pwa-network + depends_on: + - postgres + profiles: + - mail # Nur mit --profile mail starten + + # SOGo Groupware (Webmail + Kalender) + sogo: + image: sogo/sogo:latest + container_name: breakpilot-sogo + ports: + - "20000:20000" + volumes: + - ./config/sogo:/etc/sogo + environment: + - SOGO_HOSTNAME=groupware.breakpilot.local + depends_on: + - stalwart + - postgres + networks: + - breakpilot-pwa-network + profiles: + - mail + + # RBAC-Mail-Bridge + rbac-mail-bridge: + build: + context: ./rbac-mail-bridge + dockerfile: Dockerfile + container_name: breakpilot-rbac-mail + environment: + - DATABASE_URL=${DATABASE_URL} + - STALWART_API_URL=http://stalwart:8080 + - STALWART_API_KEY=${STALWART_API_KEY} + depends_on: + - postgres + - stalwart + networks: + - breakpilot-pwa-network + profiles: + - mail + +volumes: + stalwart-data: +``` + +--- + +## 7. Admin-UI im Frontend + +### 7.1 Neue Admin-Seite: `/admin/mail-management` + +```tsx +// website/app/admin/mail-management/page.tsx + +// Tabs: +// 1. Funktionale Mailboxen +// - Liste aller rollengebundenen Adressen +// - Zuweisungen verwalten +// +// 2. Mitarbeiter E-Mail +// - Persönliche Accounts +// - Anonymisierungs-Status +// +// 3. Anonymisierung +// - Mitarbeiter-Offboarding +// - Anonymisierungs-Wizard +// +// 4. Audit-Log +// - Alle Anonymisierungen +// - DSGVO-Export +``` + +--- + +## 8. Lizenz-Übersicht + +| Komponente | Lizenz | Kommerzielle Nutzung | Veröffentlichungspflicht | +|------------|--------|---------------------|-------------------------| +| Stalwart Mail | AGPL-3.0 | Ja | Nur bei Code-Änderungen | +| SOGo | GPL-2.0/LGPL | Ja | Nur bei Code-Änderungen | +| Roundcube | GPL-3.0 | Ja | Nur bei Code-Änderungen | +| RBAC-Mail-Bridge | Eigene | N/A | Kann proprietär bleiben | +| BreakPilot Backend | Eigene | N/A | Proprietär | + +**Empfehlung:** Bei reiner Nutzung der Open-Source-Komponenten ohne Code-Änderungen besteht keine Veröffentlichungspflicht. Die RBAC-Mail-Bridge ist unser eigener Code. + +--- + +## 9. Implementierungsreihenfolge + +### Phase 1: Grundlagen (2-3 Wochen Entwicklung) +1. Datenbank-Schema erstellen +2. RBAC-Mail-Bridge Backend +3. Basic API-Endpunkte + +### Phase 2: Mail-Server Integration +4. Stalwart konfigurieren +5. SOGo als Webclient +6. LDAP/OAuth Bridge + +### Phase 3: Anonymisierung +7. Anonymisierungs-Service +8. E-Mail-Archive-Bereinigung +9. DSGVO-Export-Funktion + +### Phase 4: Frontend +10. Admin-UI erstellen +11. Offboarding-Wizard +12. Audit-Dashboard + +### Phase 5: Kalender & Jitsi +13. CalDAV Integration +14. Jitsi-Meeting-Einladungen aus Kalender +15. Mobile Sync (ActiveSync/CardDAV) + +--- + +## 10. Existiert das schon? + +**Kurze Antwort: Nein, nicht in dieser Form.** + +### Ähnliche Konzepte: + +1. **Funktionale Mailboxen** (existiert) + - Shared Mailboxes in Exchange/Microsoft 365 + - Group Addresses in Google Workspace + - → Aber: Keine RBAC-Integration, keine Anonymisierung + +2. **DSGVO-Löschung** (existiert) + - Standard: Konto komplett löschen + - → Aber: Verlust der Geschäftshistorie + +3. **Pseudonymisierung** (existiert als Konzept) + - In Forschungsdatenbanken üblich + - → Aber: Nicht für E-Mail-Systeme implementiert + +### Was BreakPilot anders macht: + +``` +Traditionell: +max.mustermann@firma.de → Kündigung → Löschen → Historie weg + +BreakPilot: +klassenlehrer.5a@schule.bp.app ← Max Mustermann zugeordnet + ← Maria Müller übernimmt + ← Historie bleibt, Person anonymisiert +``` + +**Fazit:** Das Konzept ist innovativ und müsste entwickelt werden. Es gibt keine fertige Open-Source-Lösung, die all diese Features kombiniert. + +--- + +## 11. Alternative: Minimale Implementation + +Falls das vollständige System zu komplex ist, gibt es eine minimale Variante: + +### 11.1 Nur Functional Mailboxes + +```sql +-- Minimales Schema +CREATE TABLE role_email_aliases ( + id UUID PRIMARY KEY, + role_key VARCHAR(100) UNIQUE NOT NULL, + email_alias VARCHAR(255) UNIQUE NOT NULL, -- klassenlehrer.5a@... + forward_to UUID REFERENCES users(id), -- Aktuelle Person + is_active BOOLEAN DEFAULT true +); +``` + +- Keine eigene Mail-Infrastruktur +- Aliases werden an persönliche Adressen weitergeleitet +- Bei Kündigung: Alias umleiten +- Limitation: Keine echte Anonymisierung der Historie + +### 11.2 Mailpit als Development-Only + +Bestehendes Mailpit bleibt für Entwicklung: +- Kein Produktions-Mailserver +- Externe Mails über Gmail/Microsoft +- Functional Mailboxes als Konzept in DB + +--- + +## 12. Nächste Schritte + +1. **Entscheidung:** Vollständiges System oder minimale Variante? +2. **Proof of Concept:** Functional Mailboxes mit bestehendem RBAC +3. **Evaluierung:** Stalwart Mail Server in Testumgebung +4. **Architektur-Review:** Mit Datenschutzbeauftragtem abstimmen + +--- + +## Anhang A: Referenzen + +- [Stalwart Mail Server](https://stalw.art/) +- [SOGo Groupware](https://www.sogo.nu/) +- [Roundcube Webmail](https://roundcube.net/) +- [CalDAV Standard](https://tools.ietf.org/html/rfc4791) +- [DSGVO Art. 17 - Recht auf Löschung](https://dsgvo-gesetz.de/art-17-dsgvo/) + +## Anhang B: Bestehende BreakPilot-Integration + +- RBAC-System: `/backend/rbac_api.py` +- E-Mail-Service: `/backend/email_service.py` +- Jitsi-Integration: `/backend/jitsi_api.py` +- Mailpit-Config: `docker-compose.yml:193-202` + +--- + +## Anhang C: Unified Inbox Implementation (2026-01) + +### Implementierte Komponenten + +Die Unified Inbox wurde als Teil des klausur-service implementiert: + +| Komponente | Pfad | Beschreibung | +|------------|------|--------------| +| **Models** | `klausur-service/backend/mail/models.py` | Pydantic Models für Accounts, E-Mails, Tasks | +| **Database** | `klausur-service/backend/mail/mail_db.py` | PostgreSQL-Operationen mit asyncpg | +| **Credentials** | `klausur-service/backend/mail/credentials.py` | Vault-Integration für IMAP/SMTP-Passwörter | +| **Aggregator** | `klausur-service/backend/mail/aggregator.py` | Multi-Account IMAP Sync | +| **AI Service** | `klausur-service/backend/mail/ai_service.py` | KI-Analyse (Absender, Fristen, Kategorien) | +| **Task Service** | `klausur-service/backend/mail/task_service.py` | Arbeitsvorrat-Management | +| **API** | `klausur-service/backend/mail/api.py` | FastAPI Router mit 30+ Endpoints | + +### Frontend-Komponenten + +| Komponente | Pfad | Beschreibung | +|------------|------|--------------| +| **Admin UI** | `website/app/admin/mail/page.tsx` | Kontenverwaltung, KI-Einstellungen | +| **User Inbox** | `website/app/mail/page.tsx` | Unified Inbox mit KI-Panel | +| **Arbeitsvorrat** | `website/app/mail/tasks/page.tsx` | Task-Management Dashboard | + +### API-Endpoints (Port 8086) + +``` +# Account Management +POST /api/v1/mail/accounts - Neues Konto hinzufügen +GET /api/v1/mail/accounts - Alle Konten auflisten +DELETE /api/v1/mail/accounts/{id} - Konto entfernen +POST /api/v1/mail/accounts/{id}/test - Verbindung testen + +# Unified Inbox +GET /api/v1/mail/inbox - Aggregierte Inbox +GET /api/v1/mail/inbox/{id} - Einzelne E-Mail +POST /api/v1/mail/send - E-Mail senden + +# KI-Features +POST /api/v1/mail/analyze/{id} - E-Mail analysieren +GET /api/v1/mail/suggestions/{id} - Antwortvorschläge + +# Arbeitsvorrat +GET /api/v1/mail/tasks - Alle Tasks +POST /api/v1/mail/tasks - Manuelle Task erstellen +PATCH /api/v1/mail/tasks/{id} - Task aktualisieren +GET /api/v1/mail/tasks/dashboard - Dashboard-Statistiken +``` + +### Niedersachsen-spezifische Absendererkennung + +```python +KNOWN_AUTHORITIES_NI = { + "@mk.niedersachsen.de": "Kultusministerium Niedersachsen", + "@rlsb.de": "Regionales Landesamt für Schule und Bildung", + "@landesschulbehoerde-nds.de": "Landesschulbehörde", + "@nibis.de": "NiBiS", +} +``` + +### LLM-Playbook für E-Mail-Analyse + +Das Playbook `mail_analysis` in `/backend/llm_gateway/services/playbook_service.py` enthält: +- Absender-Klassifikation (12 Typen) +- Fristenerkennung mit Datumsextraktion +- Kategorisierung (11 Kategorien) +- Prioritätsvorschlag diff --git a/docs/architecture/secrets-management.md b/docs/architecture/secrets-management.md new file mode 100644 index 0000000..1ee644d --- /dev/null +++ b/docs/architecture/secrets-management.md @@ -0,0 +1,277 @@ +# BreakPilot Secrets Management + +## Uebersicht + +BreakPilot verwendet **HashiCorp Vault** als zentrales Secrets-Management-System. + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ SECRETS MANAGEMENT │ +│ │ +│ ┌────────────────────────────────────────────────────────────────────┐ │ +│ │ HashiCorp Vault │ │ +│ │ Port 8200 │ │ +│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │ │ +│ │ │ KV v2 Engine │ │ AppRole Auth │ │ Audit Logging │ │ │ +│ │ │ secret/ │ │ Token Auth │ │ Verschluesselung │ │ │ +│ │ └──────────────┘ └──────────────┘ └──────────────────────────┘ │ │ +│ └────────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌─────────────────────┼─────────────────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ Python Backend │ │ Go Services │ │ Frontend │ │ +│ │ (hvac client) │ │ (vault-client) │ │ (via Backend) │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +## Warum Vault? + +| Alternative | Nachteil | +|-------------|----------| +| Environment Variables | Keine Audit-Logs, keine Verschluesselung, keine Rotation | +| Docker Secrets | Nur fuer Docker Swarm, keine zentrale Verwaltung | +| AWS Secrets Manager | Cloud Lock-in, Kosten | +| Kubernetes Secrets | Keine Verschluesselung by default, nur K8s | +| **HashiCorp Vault** | Open Source (BSL 1.1), Self-Hosted, Enterprise Features | + +## Architektur + +### Secret-Hierarchie + +``` +secret/breakpilot/ +├── api_keys/ +│ ├── anthropic # Anthropic Claude API Key +│ ├── vast # vast.ai GPU API Key +│ ├── stripe # Stripe Payment Key +│ ├── stripe_webhook +│ └── tavily # Tavily Search API Key +├── database/ +│ ├── postgres # username, password, url +│ └── synapse # Matrix Synapse DB +├── auth/ +│ ├── jwt # secret, refresh_secret +│ └── keycloak # client_secret +├── communication/ +│ ├── matrix # access_token, db_password +│ └── jitsi # app_secret, jicofo, jvb passwords +├── storage/ +│ └── minio # access_key, secret_key +└── infra/ + └── vast # api_key, instance_id, control_key +``` + +### Python Integration + +```python +from secrets import get_secret + +# Einzelnes Secret abrufen +api_key = get_secret("ANTHROPIC_API_KEY") + +# Mit Default-Wert +debug = get_secret("DEBUG", default="false") + +# Als Pflicht-Secret +db_url = get_secret("DATABASE_URL", required=True) +``` + +### Fallback-Reihenfolge + +``` +1. HashiCorp Vault (wenn VAULT_ADDR gesetzt) + ↓ falls nicht verfuegbar +2. Environment Variables + ↓ falls nicht gesetzt +3. Docker Secrets (/run/secrets/) + ↓ falls nicht vorhanden +4. Default-Wert (wenn angegeben) + ↓ sonst +5. SecretNotFoundError (wenn required=True) +``` + +## Setup + +### Entwicklung (Dev Mode) + +```bash +# Vault starten (Dev Mode - NICHT fuer Produktion!) +docker-compose -f docker-compose.vault.yml up -d vault + +# Warten bis healthy +docker-compose -f docker-compose.vault.yml up vault-init + +# Environment setzen +export VAULT_ADDR=http://localhost:8200 +export VAULT_TOKEN=breakpilot-dev-token +``` + +### Secrets setzen + +```bash +# Anthropic API Key +vault kv put secret/breakpilot/api_keys/anthropic value='sk-ant-api03-...' + +# vast.ai Credentials +vault kv put secret/breakpilot/infra/vast \ + api_key='xxx' \ + instance_id='123' \ + control_key='yyy' + +# Database +vault kv put secret/breakpilot/database/postgres \ + username='breakpilot' \ + password='supersecret' \ + url='postgres://breakpilot:supersecret@localhost:5432/breakpilot_db' +``` + +### Secrets lesen + +```bash +# Liste aller Secrets +vault kv list secret/breakpilot/ + +# Secret anzeigen +vault kv get secret/breakpilot/api_keys/anthropic + +# Nur den Wert +vault kv get -field=value secret/breakpilot/api_keys/anthropic +``` + +## Produktion + +### AppRole Authentication + +In Produktion verwenden Services AppRole statt Token-Auth: + +```bash +# 1. AppRole aktivieren (einmalig) +vault auth enable approle + +# 2. Policy erstellen +vault policy write breakpilot-backend - < +VAULT_SECRET_ID= +VAULT_SECRETS_PATH=breakpilot +``` + +## Sicherheits-Checkliste + +### Muss erfuellt sein + +- [ ] Keine echten Secrets in `.env` Dateien +- [ ] `.env` in `.gitignore` +- [ ] Vault im Sealed-State wenn nicht in Verwendung +- [ ] TLS fuer Vault in Produktion +- [ ] AppRole statt Token-Auth in Produktion +- [ ] Audit-Logging aktiviert +- [ ] Minimale Policies (Least Privilege) + +### Sollte erfuellt sein + +- [ ] Automatische Secret-Rotation +- [ ] Separate Vault-Instanz fuer Produktion +- [ ] HSM-basiertes Auto-Unseal +- [ ] Disaster Recovery Plan + +## Dateien + +| Datei | Beschreibung | +|-------|--------------| +| `backend/secrets/__init__.py` | Secrets-Modul Exports | +| `backend/secrets/vault_client.py` | Vault Client Implementation | +| `docker-compose.vault.yml` | Vault Docker Configuration | +| `vault/init-secrets.sh` | Entwicklungs-Secrets Initialisierung | +| `vault/policies/` | Vault Policy Files | + +## Fehlerbehebung + +### Vault nicht erreichbar + +```bash +# Status pruefen +vault status + +# Falls sealed +vault operator unseal +``` + +### Secret nicht gefunden + +```bash +# Pfad pruefen +vault kv list secret/breakpilot/ + +# Cache leeren (Python) +from secrets import get_secrets_manager +get_secrets_manager().clear_cache() +``` + +### Token abgelaufen + +```bash +# Neuen Token holen (AppRole) +vault write auth/approle/login \ + role_id=$VAULT_ROLE_ID \ + secret_id=$VAULT_SECRET_ID +``` + +## Migration von .env + +Wenn Sie bestehende Secrets in .env haben: + +```bash +#!/bin/bash +# migrate-secrets.sh + +# Lese .env und schreibe nach Vault +while IFS='=' read -r key value; do + # Skip Kommentare und leere Zeilen + [[ $key =~ ^#.*$ ]] && continue + [[ -z "$key" ]] && continue + + # Sensitive Keys nach Vault + case $key in + *_API_KEY|*_SECRET|*_PASSWORD|*_TOKEN) + echo "Migrating $key to Vault..." + vault kv put secret/breakpilot/migrated/$key value="$value" + ;; + esac +done < .env + +echo "Migration complete. Remember to remove secrets from .env!" +``` + +--- + +## Referenzen + +- [HashiCorp Vault Documentation](https://developer.hashicorp.com/vault/docs) +- [hvac Python Client](https://hvac.readthedocs.io/) +- [Vault Best Practices](https://developer.hashicorp.com/vault/tutorials/recommended-patterns) diff --git a/docs/architecture/system-architecture.md b/docs/architecture/system-architecture.md new file mode 100644 index 0000000..84ffb8c --- /dev/null +++ b/docs/architecture/system-architecture.md @@ -0,0 +1,399 @@ +# BreakPilot PWA - System-Architektur + +## Übersicht + +BreakPilot ist eine modulare Bildungsplattform für Lehrkräfte mit folgenden Hauptkomponenten: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Browser │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ Frontend (Studio UI) │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │ +│ │ │Dashboard │ │Worksheets│ │Correction│ │Letters/Companion │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +└───────────────────────────┬─────────────────────────────────────────┘ + │ HTTP/REST + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Python Backend (FastAPI) │ +│ Port 8000 │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ API Layer │ │ +│ │ /api/worksheets /api/corrections /api/letters /api/state │ │ +│ │ /api/school /api/certificates /api/messenger /api/jitsi │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ Service Layer │ │ +│ │ FileProcessor │ PDFService │ ContentGenerators │ StateEngine │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +└───────────────────────────┬─────────────────────────────────────────┘ + │ + ┌─────────────┼─────────────┐ + ▼ ▼ ▼ +┌─────────────────┐ ┌───────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Go Consent │ │ PostgreSQL │ │ LLM Gateway │ │ HashiCorp │ +│ Service │ │ Database │ │ (optional) │ │ Vault │ +│ Port 8081 │ │ Port 5432 │ │ │ │ Port 8200 │ +└─────────────────┘ └───────────────┘ └──────────────┘ └──────────────┘ +``` + +## Komponenten + +### 1. Admin Frontend (Next.js Website) + +Das **Admin Frontend** ist eine vollständige Next.js 15 Anwendung für Developer und Administratoren: + +**Technologie:** Next.js 15, React 18, TypeScript, Tailwind CSS + +**Container:** `breakpilot-pwa-website` auf **Port 3000** + +**Verzeichnis:** `/website` + +| Modul | Route | Beschreibung | +|-------|-------|--------------| +| Dashboard | `/admin` | Übersicht & Statistiken | +| GPU Infrastruktur | `/admin/gpu` | vast.ai GPU Management | +| Consent Verwaltung | `/admin/consent` | Rechtliche Dokumente & Versionen | +| Datenschutzanfragen | `/admin/dsr` | DSGVO Art. 15-21 Anfragen | +| DSMS | `/admin/dsms` | Datenschutz-Management-System | +| Education Search | `/admin/edu-search` | Bildungsquellen & Crawler | +| Personensuche | `/admin/staff-search` | Uni-Mitarbeiter & Publikationen | +| Uni-Crawler | `/admin/uni-crawler` | Universitäts-Crawling Orchestrator | +| LLM Vergleich | `/admin/llm-compare` | KI-Provider Vergleich | +| PCA Platform | `/admin/pca-platform` | Bot-Erkennung & Monetarisierung | +| Production Backlog | `/admin/backlog` | Go-Live Checkliste | +| Developer Docs | `/admin/docs` | API & Architektur Dokumentation | +| Kommunikation | `/admin/communication` | Matrix & Jitsi Monitoring | +| **Security** | `/admin/security` | DevSecOps Dashboard, Scans, Findings | +| **SBOM** | `/admin/sbom` | Software Bill of Materials | + +### 2. Lehrer Frontend (Studio UI) + +Das **Lehrer Frontend** ist ein Single-Page-Application-ähnliches System für Lehrkräfte, das in Python-Modulen organisiert ist: + +| Modul | Datei | Beschreibung | +|-------|-------|--------------| +| Base | `frontend/modules/base.py` | TopBar, Sidebar, Theme, Login | +| Dashboard | `frontend/modules/dashboard.py` | Übersichtsseite | +| Worksheets | `frontend/modules/worksheets.py` | Lerneinheiten-Generator | +| Correction | `frontend/modules/correction.py` | OCR-Klausurkorrektur | +| Letters | `frontend/modules/letters.py` | Elternkommunikation | +| Companion | `frontend/modules/companion.py` | Begleiter-Modus mit State Engine | +| School | `frontend/modules/school.py` | Schulverwaltung | +| Gradebook | `frontend/modules/gradebook.py` | Notenbuch | +| ContentCreator | `frontend/modules/content_creator.py` | H5P Content Creator | +| ContentFeed | `frontend/modules/content_feed.py` | Content Discovery | +| Messenger | `frontend/modules/messenger.py` | Matrix Messenger | +| Jitsi | `frontend/modules/jitsi.py` | Videokonferenzen | +| **KlausurKorrektur** | `frontend/modules/klausur_korrektur.py` | **Abitur-Klausurkorrektur (15-Punkte-System)** | +| **AbiturDocsAdmin** | `frontend/modules/abitur_docs_admin.py` | **Admin für Abitur-Dokumente (NiBiS)** | + +Jedes Modul exportiert: +- `get_css()` - CSS-Styles +- `get_html()` - HTML-Template +- `get_js()` - JavaScript-Logik + +### 2. Python Backend (FastAPI) + +#### API-Router + +| Router | Präfix | Beschreibung | +|--------|--------|--------------| +| `worksheets_api` | `/api/worksheets` | Content-Generatoren (MC, Cloze, Mindmap, Quiz) | +| `correction_api` | `/api/corrections` | OCR-Pipeline für Klausurkorrektur | +| `letters_api` | `/api/letters` | Elternbriefe mit GFK-Integration | +| `state_engine_api` | `/api/state` | Begleiter-Modus Phasen & Vorschläge | +| `school_api` | `/api/school` | Schulverwaltung (Proxy zu school-service) | +| `certificates_api` | `/api/certificates` | Zeugniserstellung | +| `messenger_api` | `/api/messenger` | Matrix Messenger Integration | +| `jitsi_api` | `/api/jitsi` | Jitsi Meeting-Einladungen | +| `consent_api` | `/api/consent` | DSGVO Consent-Verwaltung | +| `gdpr_api` | `/api/gdpr` | GDPR-Export | +| **`klausur_korrektur_api`** | `/api/klausur-korrektur` | **Abitur-Klausuren (15-Punkte, Gutachten, Fairness)** | +| **`abitur_docs_api`** | `/api/abitur-docs` | **NiBiS-Dokumentenverwaltung für RAG** | + +#### Services + +| Service | Datei | Beschreibung | +|---------|-------|--------------| +| FileProcessor | `services/file_processor.py` | OCR mit PaddleOCR | +| PDFService | `services/pdf_service.py` | PDF-Generierung | +| ContentGenerators | `services/content_generators/` | MC, Cloze, Mindmap, Quiz | +| StateEngine | `state_engine/` | Phasen-Management & Antizipation | + +### 3. Klausur-Korrektur System (Abitur) + +Das Klausur-Korrektur-System implementiert die vollständige Abitur-Bewertungspipeline: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Klausur-Korrektur Modul │ +│ │ +│ ┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐ │ +│ │ Modus-Wahl │───►│ Text-Quellen & │───►│ Erwartungs- │ │ +│ │ LandesAbi/ │ │ Rights-Gate │ │ horizont │ │ +│ │ Vorabitur │ └──────────────────┘ └─────────────────┘ │ +│ └─────────────┘ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Schülerarbeiten-Pipeline │ │ +│ │ Upload → OCR → KI-Bewertung → Gutachten → 15-Punkte-Note │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌────────────────────┐ ┌──────────────────────────────────┐ │ +│ │ Erst-/Zweitprüfer │───►│ Fairness-Analyse & PDF-Export │ │ +│ └────────────────────┘ └──────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +#### 15-Punkte-Notensystem + +Das System verwendet den deutschen Abitur-Notenschlüssel: + +| Punkte | Prozent | Note | +|--------|---------|------| +| 15-13 | 95-85% | 1+/1/1- | +| 12-10 | 80-70% | 2+/2/2- | +| 9-7 | 65-55% | 3+/3/3- | +| 6-4 | 50-40% | 4+/4/4- | +| 3-1 | 33-20% | 5+/5/5- | +| 0 | <20% | 6 | + +#### Bewertungskriterien + +| Kriterium | Gewicht | Beschreibung | +|-----------|---------|--------------| +| Rechtschreibung | 15% | Orthografie | +| Grammatik | 15% | Grammatik & Syntax | +| **Inhalt** | **40%** | Inhaltliche Qualität (höchste Gewichtung) | +| Struktur | 15% | Aufbau & Gliederung | +| Stil | 15% | Ausdruck & Stil | + +#### Abitur-Docs RAG-System + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Abitur-Docs Admin │ +│ │ +│ ┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐ │ +│ │ ZIP-Import │───►│ KI-Datei- │───►│ Metadaten- │ │ +│ │ NiBiS PDFs │ │ erkennung │ │ Bestätigung │ │ +│ └─────────────┘ └──────────────────┘ └─────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ Vector Store (ChromaDB) │ │ +│ │ Indexierung für RAG-Suche nach Fach, Jahr, Niveau │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +NiBiS-Dateinamen werden automatisch erkannt: +- `2025_Deutsch_eA_I.pdf` → Deutsch, erhöhtes Niveau, Aufgabe I +- `2025_Deutsch_eA_I_EWH.pdf` → Erwartungshorizont + +### 4. State Engine (Begleiter-Modus) + +Das State Engine Modul implementiert proaktive Unterstützung für Lehrkräfte: + +``` +state_engine/ +├── __init__.py # Exports +├── models.py # SchoolYearPhase, TeacherContext, Suggestion +├── rules.py # 15+ Antizipations-Regeln +└── engine.py # AnticipationEngine, PhaseService +``` + +#### Schuljahr-Phasen + +1. `ONBOARDING` - Ersteinrichtung +2. `SCHOOL_YEAR_START` - Schuljahresbeginn +3. `TEACHING_SETUP` - Unterrichtsvorbereitung +4. `PERFORMANCE_1` - 1. Leistungsphase +5. `SEMESTER_END` - Halbjahreszeugnisse +6. `PERFORMANCE_2` - 2. Leistungsphase +7. `EXAM_PHASE` - Prüfungsphase +8. `YEAR_END` - Schuljahresende +9. `ARCHIVED` - Archiviert + +#### Antizipations-Regeln + +Regeln evaluieren den `TeacherContext` und generieren `Suggestion`-Objekte: + +```python +@dataclass +class Rule: + id: str + name: str + phases: List[SchoolYearPhase] + condition: Callable[[TeacherContext], bool] + suggestion_builder: Callable[[TeacherContext], Suggestion] +``` + +### 4. Go Consent Service + +Verwaltet DSGVO-Einwilligungen: + +``` +consent-service/ +├── cmd/server/ # Main entry point +├── internal/ +│ ├── handlers/ # HTTP Handler +│ ├── services/ # Business Logic +│ ├── models/ # Data Models +│ └── middleware/ # Auth Middleware +└── migrations/ # SQL Migrations +``` + +### 5. LLM Gateway (Optional) + +Wenn `LLM_GATEWAY_ENABLED=true`: + +``` +llm_gateway/ +├── routes/ +│ ├── chat.py # Chat-Completion API +│ ├── communication.py # GFK-Validierung +│ ├── edu_search_seeds.py # Bildungssuche +│ └── legal_crawler.py # Schulgesetz-Crawler +└── services/ + └── communication_service.py +``` + +## Datenfluss + +### Worksheet-Generierung + +``` +User Input → Frontend (worksheets.py) + ↓ +POST /api/worksheets/generate/multiple-choice + ↓ +worksheets_api.py → MCGenerator (services/content_generators/) + ↓ +Optional: LLM für erweiterte Generierung + ↓ +Response: WorksheetContent → Frontend rendert Ergebnis +``` + +### Klausurkorrektur + +``` +File Upload → Frontend (correction.py) + ↓ +POST /api/corrections/ (erstellen) +POST /api/corrections/{id}/upload (Datei) + ↓ +Background Task: OCR via FileProcessor + ↓ +Poll GET /api/corrections/{id} bis status="ocr_complete" + ↓ +POST /api/corrections/{id}/analyze + ↓ +Review Interface → PUT /api/corrections/{id} (Anpassungen) + ↓ +GET /api/corrections/{id}/export-pdf +``` + +### State Engine Flow + +``` +Frontend Companion-Mode aktiviert + ↓ +GET /api/state/dashboard?teacher_id=xxx + ↓ +AnticipationEngine.get_suggestions(context) + ↓ +Rules evaluieren TeacherContext + ↓ +Priorisierte Vorschläge (max 5) zurück + ↓ +User führt Aktion aus + ↓ +POST /api/state/milestone (Meilenstein abschließen) + ↓ +PhaseService.check_and_transition() prüft Phasenübergang +``` + +## Sicherheit + +### Authentifizierung & Autorisierung + +BreakPilot verwendet einen **Hybrid-Ansatz**: + +| Schicht | Komponente | Beschreibung | +|---------|------------|--------------| +| **Authentifizierung** | Keycloak (Prod) / Lokales JWT (Dev) | Token-Validierung via JWKS oder HS256 | +| **Autorisierung** | rbac.py (Eigenentwicklung) | Domaenenspezifische Berechtigungen | + +Siehe: [docs/architecture/auth-system.md](auth-system.md) + +### Basis-Rollen + +| Rolle | Beschreibung | +|-------|--------------| +| `user` | Normaler Benutzer | +| `teacher` / `lehrer` | Lehrkraft | +| `admin` | Administrator | +| `data_protection_officer` | Datenschutzbeauftragter | + +### Erweiterte Rollen (rbac.py) + +15+ domaenenspezifische Rollen fuer Klausurkorrektur und Zeugnisse: +- `erstkorrektor`, `zweitkorrektor`, `drittkorrektor` +- `klassenlehrer`, `fachlehrer`, `fachvorsitz` +- `schulleitung`, `zeugnisbeauftragter`, `sekretariat` + +### Sicherheitsfeatures + +- JWT-basierte Authentifizierung (RS256/HS256) +- CORS konfiguriert für Frontend-Zugriff +- DSGVO-konformes Consent-Management +- **HashiCorp Vault** fuer Secrets-Management (keine hardcodierten Secrets) +- Bundesland-spezifische Policy-Sets +- **DevSecOps Pipeline** mit automatisierten Security-Scans (SAST, SCA, Secrets Detection) + +Siehe: +- [docs/architecture/secrets-management.md](secrets-management.md) +- [docs/architecture/devsecops.md](devsecops.md) + +## Deployment + +```yaml +services: + backend: + build: ./backend + ports: ["8000:8000"] + environment: + - DATABASE_URL=postgresql://... + - LLM_GATEWAY_ENABLED=false + + consent-service: + build: ./consent-service + ports: ["8081:8081"] + + postgres: + image: postgres:15 + volumes: + - pgdata:/var/lib/postgresql/data +``` + +## Erweiterung + +Neues Frontend-Modul hinzufügen: + +1. Modul erstellen: `frontend/modules/new_module.py` +2. Klasse mit `get_css()`, `get_html()`, `get_js()` implementieren +3. In `frontend/modules/__init__.py` importieren und exportieren +4. Optional: Zugehörige API in `new_module_api.py` erstellen +5. In `main.py` Router registrieren + +Neue State Engine Regel: + +1. In `state_engine/rules.py` neue `Rule` definieren +2. `condition` und `suggestion_builder` Funktionen implementieren +3. Zur `RULES` Liste hinzufügen +4. Passende `phases` angeben diff --git a/docs/architecture/zeugnis-system.md b/docs/architecture/zeugnis-system.md new file mode 100644 index 0000000..12bd135 --- /dev/null +++ b/docs/architecture/zeugnis-system.md @@ -0,0 +1,411 @@ +# Zeugnis-System - Architecture Documentation + +## Overview + +The Zeugnis (Certificate) System enables schools to generate official school certificates with grades, attendance data, and remarks. It extends the existing School-Service with comprehensive grade management and certificate generation workflows. + +## Architecture Diagram + +``` + ┌─────────────────────────────────────┐ + │ Python Backend (Port 8000) │ + │ backend/frontend/modules/school.py │ + │ │ + │ ┌─────────────────────────────────┐ │ + │ │ panel-school-certificates │ │ + │ │ - Klassenauswahl │ │ + │ │ - Notenspiegel │ │ + │ │ - Zeugnis-Wizard (5 Steps) │ │ + │ │ - Workflow-Status │ │ + │ └─────────────────────────────────┘ │ + └──────────────────┬──────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────────────────┐ +│ School-Service (Go, Port 8084) │ +├─────────────────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────────────────┐ │ +│ │ Grade Handlers │ │ Statistics Handlers │ │ Certificate Handlers │ │ +│ │ │ │ │ │ │ │ +│ │ GetClassGrades │ │ GetClassStatistics │ │ GetCertificateTemplates │ │ +│ │ GetStudentGrades │ │ GetSubjectStatistics│ │ GetClassCertificates │ │ +│ │ UpdateOralGrade │ │ GetStudentStatistics│ │ GenerateCertificate │ │ +│ │ CalculateFinalGrades│ │ GetNotenspiegel │ │ BulkGenerateCertificates │ │ +│ │ TransferApprovedGrades│ │ │ │ FinalizeCertificate │ │ +│ │ LockFinalGrade │ │ │ │ GetCertificatePDF │ │ +│ │ UpdateGradeWeights │ │ │ │ │ │ +│ └─────────────────────┘ └─────────────────────┘ └─────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────────────────────┐│ +│ │ Grade Service Layer ││ +│ │ ││ +│ │ - ClassStatistics struct (average, pass_rate, at_risk_count, grade_distribution) ││ +│ │ - SubjectStatistics struct (class average, best/worst, exam results) ││ +│ │ - StudentStatistics struct (overall average, subject performance) ││ +│ │ - Notenspiegel struct (grade 1-6 distribution) ││ +│ └─────────────────────────────────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────────────────────┐ + │ PostgreSQL Database │ + │ │ + │ Tables: │ + │ - grade_overview │ + │ - exam_results │ + │ - students │ + │ - classes │ + │ - subjects │ + │ - certificates │ + │ - attendance │ + └─────────────────────────────────────┘ +``` + +## Zeugnis Workflow (Role Chain) + +The certificate workflow follows a strict approval chain from subject teachers to school principal: + +``` +┌──────────────────┐ ┌──────────────────┐ ┌────────────────────────┐ ┌────────────────────┐ ┌──────────────────┐ +│ FACHLEHRER │───▶│ KLASSENLEHRER │───▶│ ZEUGNISBEAUFTRAGTER │───▶│ SCHULLEITUNG │───▶│ SEKRETARIAT │ +│ (Subject │ │ (Class │ │ (Certificate │ │ (Principal) │ │ (Secretary) │ +│ Teacher) │ │ Teacher) │ │ Coordinator) │ │ │ │ │ +└──────────────────┘ └──────────────────┘ └────────────────────────┘ └────────────────────┘ └──────────────────┘ + │ │ │ │ │ + ▼ ▼ ▼ ▼ ▼ + Grades Entry Approve Quality Check Sign-off & Lock Print & Archive + (Oral/Written) Grades & Review +``` + +### Workflow States + +``` +┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ +│ DRAFT │────▶│ SUBMITTED │────▶│ REVIEWED │────▶│ SIGNED │────▶│ PRINTED │ +│ (Entwurf) │ │ (Eingereicht)│ │ (Geprueft) │ │(Unterzeichnet) │ (Gedruckt) │ +└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ + Fachlehrer Klassenlehrer Zeugnisbeauftragter Schulleitung +``` + +## RBAC Integration + +### Certificate-Related Roles + +| Role | German | Description | +|------|--------|-------------| +| `FACHLEHRER` | Fachlehrer | Subject teacher - enters grades | +| `KLASSENLEHRER` | Klassenlehrer | Class teacher - approves class grades | +| `ZEUGNISBEAUFTRAGTER` | Zeugnisbeauftragter | Certificate coordinator - quality control | +| `SCHULLEITUNG` | Schulleitung | Principal - final sign-off | +| `SEKRETARIAT` | Sekretariat | Secretary - printing & archiving | + +### Certificate Resource Types + +| ResourceType | Description | +|--------------|-------------| +| `ZEUGNIS` | Final certificate document | +| `ZEUGNIS_VORLAGE` | Certificate template (per Bundesland) | +| `ZEUGNIS_ENTWURF` | Draft certificate (before approval) | +| `FACHNOTE` | Subject grade | +| `KOPFNOTE` | Head grade (Arbeits-/Sozialverhalten) | +| `BEMERKUNG` | Certificate remarks | +| `STATISTIK` | Class/subject statistics | +| `NOTENSPIEGEL` | Grade distribution chart | + +### Permission Matrix + +| Role | ZEUGNIS | ZEUGNIS_ENTWURF | ZEUGNIS_VORLAGE | FACHNOTE | Actions | +|------|---------|-----------------|-----------------|----------|---------| +| FACHLEHRER | R | CRUD | R | CRUD | Create/Update grades | +| KLASSENLEHRER | CRU | CRUD | R | CRUD | Approve class grades | +| ZEUGNISBEAUFTRAGTER | RU | RU | RUU | RU | Quality review | +| SCHULLEITUNG | R/SIGN/LOCK | RU | RU | R | Final approval | +| SEKRETARIAT | RD | R | R | R | Print & archive | + +Legend: C=Create, R=Read, U=Update, D=Delete, SIGN=Sign-off, LOCK=Lock final + +### VerfahrenType for Certificates + +```python +class VerfahrenType(str, Enum): + # Exam types + ABITUR = "abitur" + KLAUSUR = "klausur" + ... + + # Certificate types + HALBJAHRESZEUGNIS = "halbjahreszeugnis" # Mid-year certificate + JAHRESZEUGNIS = "jahreszeugnis" # End-of-year certificate + ABSCHLUSSZEUGNIS = "abschlusszeugnis" # Graduation certificate + ABGANGSZEUGNIS = "abgangszeugnis" # Leaving certificate +``` + +## Statistics API + +### Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/school/statistics/:classId` | Class statistics | +| GET | `/api/v1/school/statistics/:classId/subject/:subjectId` | Subject statistics | +| GET | `/api/v1/school/statistics/student/:studentId` | Student statistics | +| GET | `/api/v1/school/statistics/:classId/notenspiegel` | Grade distribution | + +### Response Structures + +**ClassStatistics:** +```json +{ + "class_id": "uuid", + "class_name": "5a", + "semester": 1, + "average": 2.4, + "pass_rate": 92.8, + "at_risk_count": 2, + "student_count": 25, + "subjects": [ + { + "subject_id": "uuid", + "subject_name": "Mathematik", + "average": 2.8 + } + ], + "grade_distribution": { + "1": 3, + "2": 8, + "3": 10, + "4": 5, + "5": 2, + "6": 0 + } +} +``` + +**Notenspiegel:** +```json +{ + "class_id": "uuid", + "subject_id": "uuid", + "exam_id": "uuid", + "distribution": { + "1": 2, + "2": 5, + "3": 8, + "4": 6, + "5": 3, + "6": 1 + }, + "total_students": 25, + "average": 3.1, + "median": 3.0 +} +``` + +## Frontend Components + +### Certificate Panel (`panel-school-certificates`) + +Located in: `backend/frontend/modules/school.py` + +Features: +- **Statistik-Karten**: Overview cards showing class average, pass rate, at-risk students +- **Notenspiegel Chart**: Bar chart visualization of grade distribution (1-6) +- **Workflow-Status**: Visual display of certificate approval chain +- **Student Table**: List of students with grades, attendance, certificate status + +### Zeugnis-Wizard (5 Steps) + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ Zeugnis-Wizard Modal │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Step 1: Klasse auswaehlen │ +│ ├── Dropdown: Klasse (5a, 5b, 6a, ...) │ +│ ├── Dropdown: Halbjahr (1 oder 2) │ +│ └── Vorschau: Anzahl Schueler, Durchschnitt │ +│ │ +│ Step 2: Noten pruefen │ +│ ├── Tabelle: Schueler | Fach1 | Fach2 | ... | Status │ +│ ├── Fehlende Noten markiert │ +│ └── Edit-Moeglichkeit direkt in Tabelle │ +│ │ +│ Step 3: Vorlage waehlen │ +│ ├── Dropdown: Bundesland (Niedersachsen, Bayern, ...) │ +│ ├── Dropdown: Schulform (Gymnasium, Realschule, ...) │ +│ └── Vorschau: Template-Preview │ +│ │ +│ Step 4: Bemerkungen │ +│ ├── Textarea pro Schueler │ +│ ├── Textbausteine / Vorlagen │ +│ └── KI-generierte Vorschlaege (optional) │ +│ │ +│ Step 5: Zusammenfassung & Generieren │ +│ ├── Uebersicht aller Einstellungen │ +│ ├── Checkbox: Alle Zeugnisse generieren │ +│ └── Button: Zeugnisse erstellen (mit Fortschrittsbalken) │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +## Seed Data Generator + +Located in: `school-service/internal/seed/` + +### Usage + +```bash +# Run seed data generation +go run cmd/seed/main.go -teacher-id= + +# Or with default teacher ID +go run cmd/seed/main.go +``` + +### Generated Data + +| Entity | Count | Details | +|--------|-------|---------| +| School Years | 3 | 2022/23, 2023/24, 2024/25 | +| Classes per Year | 6 | 5a, 5b, 6a, 6b, 7a, 7b | +| Students per Class | 15-25 | Realistic German names | +| Subjects | 10 | Deutsch, Mathe, Englisch, ... | +| Exams per Class/Subject | 3-5 | With grade distribution | +| Attendance Records | Variable | 0-10 absence days | + +### German Name Generator + +```go +var firstNames = []string{ + "Anna", "Emma", "Mia", "Sophie", "Marie", "Lena", "Lea", "Emily", + "Ben", "Paul", "Leon", "Luis", "Max", "Felix", "Noah", "Elias", ... +} + +var lastNames = []string{ + "Mueller", "Schmidt", "Schneider", "Fischer", "Weber", "Meyer", + "Wagner", "Becker", "Schulz", "Hoffmann", "Koch", "Richter", ... +} +``` + +## File Structure + +``` +school-service/ +├── cmd/ +│ ├── server/ +│ │ └── main.go # Main server with all routes +│ └── seed/ +│ └── main.go # Seed data CLI +├── internal/ +│ ├── config/ +│ │ └── config.go +│ ├── database/ +│ │ └── database.go +│ ├── handlers/ +│ │ ├── handlers.go # Base handler +│ │ ├── grade_handlers.go # Grade + Statistics endpoints +│ │ └── certificate_handlers.go +│ ├── services/ +│ │ └── grade_service.go # Statistics calculations +│ ├── models/ +│ │ └── models.go +│ └── seed/ +│ └── seed_data.go # Fake data generator +└── go.mod + +backend/frontend/modules/ +└── school.py # Zeugnis-UI (panel-school-certificates) + +klausur-service/backend/ +├── rbac.py # RBAC with certificate roles +└── tests/ + └── test_rbac.py # RBAC unit tests +``` + +## API Routes (School-Service) + +### Grade Management + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/school/grades/:classId` | Get class grades | +| GET | `/api/v1/school/grades/student/:studentId` | Get student grades | +| PUT | `/api/v1/school/grades/:studentId/:subjectId/oral` | Update oral grade | +| POST | `/api/v1/school/grades/calculate` | Calculate final grades | +| POST | `/api/v1/school/grades/transfer` | Transfer approved grades | +| PUT | `/api/v1/school/grades/:studentId/:subjectId/lock` | Lock final grade | +| PUT | `/api/v1/school/grades/:studentId/:subjectId/weights` | Update grade weights | + +### Statistics + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/school/statistics/:classId` | Class statistics | +| GET | `/api/v1/school/statistics/:classId/subject/:subjectId` | Subject statistics | +| GET | `/api/v1/school/statistics/student/:studentId` | Student statistics | +| GET | `/api/v1/school/statistics/:classId/notenspiegel` | Grade distribution | + +### Certificates + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/school/certificates/templates` | List templates | +| GET | `/api/v1/school/certificates/class/:classId` | Class certificates | +| POST | `/api/v1/school/certificates/generate` | Generate single | +| POST | `/api/v1/school/certificates/generate-bulk` | Generate bulk | +| GET | `/api/v1/school/certificates/detail/:id` | Get certificate | +| PUT | `/api/v1/school/certificates/detail/:id` | Update certificate | +| PUT | `/api/v1/school/certificates/detail/:id/finalize` | Finalize | +| GET | `/api/v1/school/certificates/detail/:id/pdf` | Download PDF | +| DELETE | `/api/v1/school/certificates/detail/:id` | Delete | +| GET | `/api/v1/school/certificates/feedback/:studentId` | AI feedback | + +## German Grading System + +| Grade | Meaning | Points | +|-------|---------|--------| +| 1 | sehr gut (excellent) | 15-13 | +| 2 | gut (good) | 12-10 | +| 3 | befriedigend (satisfactory) | 9-7 | +| 4 | ausreichend (adequate) | 6-4 | +| 5 | mangelhaft (poor) | 3-1 | +| 6 | ungenuegend (inadequate) | 0 | + +### Grade Calculation + +``` +Final Grade = (Written Weight * Written Avg) + (Oral Weight * Oral Avg) + +Default weights: +- Written (Klassenarbeiten): 50% +- Oral (muendliche Note): 50% + +Customizable per subject/student via UpdateGradeWeights endpoint. +``` + +## Integration with BYOEH + +The Zeugnis system can optionally integrate with BYOEH for: +- AI-generated certificate remarks +- Template suggestion based on student performance +- Feedback text generation (`/certificates/feedback/:studentId`) + +## Security Considerations + +1. **RBAC Enforcement**: All certificate operations check user role permissions +2. **Tenant Isolation**: Teachers only see their own classes/students +3. **Audit Trail**: All grade changes and approvals logged +4. **Lock Mechanism**: Finalized certificates cannot be modified +5. **Workflow Enforcement**: Cannot skip approval steps + +## Future Enhancements + +- [ ] PDF template editor (LaTeX/HTML) +- [ ] Bundesland-specific templates (all 16 states) +- [ ] Kopfnoten (head grades) support +- [ ] Digital signatures for certificates +- [ ] Parent portal access to certificates +- [ ] Archive/retention policies diff --git a/docs/ci-cd/TEST-PIPELINE-DEVELOPER-GUIDE.md b/docs/ci-cd/TEST-PIPELINE-DEVELOPER-GUIDE.md new file mode 100644 index 0000000..05045d5 --- /dev/null +++ b/docs/ci-cd/TEST-PIPELINE-DEVELOPER-GUIDE.md @@ -0,0 +1,1081 @@ +# CI/CD Pipeline & Test-System - Entwicklerdokumentation + +> **Letzte Aktualisierung:** 2026-02-04 +> **Status:** Produktiv +> **Maintainer:** DevOps Team + +--- + +## Inhaltsverzeichnis + +1. [Architektur-Übersicht](#1-architektur-übersicht) +2. [Woodpecker CI Pipeline](#2-woodpecker-ci-pipeline) + - 2.5 [Integration Tests](#25-integration-tests) +3. [Test Registry Backend](#3-test-registry-backend) +4. [Datenbank-Schema](#4-datenbank-schema) +5. [Backlog-System](#5-backlog-system) +6. [Frontend Dashboard](#6-frontend-dashboard) +7. [Service-Übersicht](#7-service-übersicht) +8. [Fehlerbehebung](#8-fehlerbehebung) +9. [API-Referenz](#9-api-referenz) + +--- + +## 1. Architektur-Übersicht + +### Systemkomponenten + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ ENTWICKLER-WORKFLOW │ +│ │ +│ git push ──▶ Gitea (macmini:3003) ──▶ Webhook ──▶ Woodpecker CI │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ WOODPECKER CI PIPELINE │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ Go Tests │ │ Python Tests │ │ Node Tests │ │ Report Results │ │ +│ │ (consent, │ │ (backend, │ │ (h5p) │ │ (curl → Backend) │ │ +│ │ billing, │ │ voice, │ │ │ │ │ │ +│ │ school) │ │ klausur) │ │ │ │ │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────────┘ │ +│ │ │ +└────────────────────────────────────────────────────────────│─────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ BACKEND (FastAPI) │ +│ │ +│ POST /api/tests/ci-result │ +│ │ │ +│ ├──▶ TestRunDB (Test-Durchläufe) │ +│ ├──▶ TestResultDB (Einzelne Tests) │ +│ ├──▶ TestServiceStatsDB (Aggregierte Stats) │ +│ └──▶ FailedTestBacklogDB (Backlog bei Fehlern) │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ FRONTEND (Next.js) │ +│ │ +│ Test Dashboard: https://macmini:3002/infrastructure/tests │ +│ CI/CD Dashboard: https://macmini:3002/infrastructure/ci-cd │ +│ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### Technologie-Stack + +| Komponente | Technologie | Port | Beschreibung | +|------------|-------------|------|--------------| +| **CI/CD Server** | Woodpecker CI v3 | 4431 (HTTPS) | Pipeline-Orchestrierung | +| **Git Server** | Gitea | 3003 | Repository-Hosting | +| **Backend API** | FastAPI (Python) | 8000 | Test Registry, Backlog-Management | +| **Datenbank** | PostgreSQL | 5432 | Persistente Speicherung | +| **Frontend** | Next.js | 3002 | Admin Dashboard | +| **Plattform** | ARM64 (Apple Silicon) | - | Mac Mini M2 | + +--- + +## 2. Woodpecker CI Pipeline + +### Konfigurationsdatei + +**Pfad:** `.woodpecker/main.yml` + +### Pipeline-Stages + +```yaml +# Stage 1: Lint (nur bei PRs) +go-lint # golangci-lint für Go Services +python-lint # ruff/black für Python + +# Stage 2: Unit Tests +test-go-consent # consent-service (Go) +test-go-billing # billing-service (Go) +test-go-school # school-service (Go) +test-python-backend # backend (Python/pytest) +test-python-voice # voice-service inkl. BQAS (Python/pytest) +test-python-klausur # klausur-service (Python/pytest) +test-nodejs-h5p # h5p-service (Node.js/Jest) + +# Stage 3: Report +report-test-results # Sendet Ergebnisse an Backend API + +# Stage 4: Build (nur Tags/manuell) +build-consent-service +build-backend +build-voice-service + +# Stage 5: Deploy (nur manuell) +deploy-production +``` + +### Test-Step Struktur (Beispiel Go) + +```yaml +test-go-consent: + image: golang:1.23-alpine + environment: + CGO_ENABLED: "0" + commands: + - | + set -euo pipefail + apk add --no-cache jq bash + mkdir -p .ci-results + + # Directory-Check (falls Service nicht existiert) + if [ ! -d "consent-service" ]; then + echo '{"service":"consent-service","framework":"go","total":0,"passed":0,"failed":0,"skipped":0,"coverage":0}' > .ci-results/results-consent.json + echo "WARNUNG: consent-service Verzeichnis nicht gefunden" + exit 0 + fi + + cd consent-service + set +e + go test -v -json -coverprofile=coverage.out ./... 2>&1 | tee ../.ci-results/test-consent.json + TEST_EXIT=$? + set -e + + # jq für korrektes JSON-Parsing (nur Test-Zeilen zählen, nicht Package-Zeilen) + TOTAL=$(jq -s '[.[] | select(.Action=="run" and .Test != null)] | length' ../.ci-results/test-consent.json || echo 0) + PASSED=$(jq -s '[.[] | select(.Action=="pass" and .Test != null)] | length' ../.ci-results/test-consent.json || echo 0) + FAILED=$(jq -s '[.[] | select(.Action=="fail" and .Test != null)] | length' ../.ci-results/test-consent.json || echo 0) + SKIPPED=$(jq -s '[.[] | select(.Action=="skip" and .Test != null)] | length' ../.ci-results/test-consent.json || echo 0) + + COVERAGE=$(go tool cover -func=coverage.out 2>/dev/null | tail -1 | awk '{print $3}' | tr -d '%' || echo "0") + [ -z "$COVERAGE" ] && COVERAGE=0 + + echo "{\"service\":\"consent-service\",\"framework\":\"go\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":$COVERAGE}" > ../.ci-results/results-consent.json + cat ../.ci-results/results-consent.json + + if [ "$FAILED" -gt "0" ] || [ "$TEST_EXIT" -ne "0" ]; then exit 1; fi +``` + +**Wichtige Änderungen gegenüber früheren Versionen:** +- `set -euo pipefail` für strikte Fehlerbehandlung +- `jq` statt `grep -c` für korrektes JSON-Parsing (verhindert Überzählung bei mehreren Actions pro Test) +- Directory-Check vor dem `cd` Befehl +- Separate Prüfung von `TEST_EXIT` und `FAILED` Count + +### Test-Step Struktur (Beispiel Python) + +```yaml +test-python-backend: + image: python:3.12-slim + commands: + - | + mkdir -p .ci-results + cd backend + pip install --quiet -r requirements.txt + pip install --quiet pytest pytest-cov pytest-asyncio pytest-json-report + + # Tests mit JSON-Report ausführen + pytest tests/ -v --tb=short \ + --cov=. --cov-report=term-missing \ + --json-report --json-report-file=../.ci-results/test-backend.json || true + + # Statistiken aus JSON extrahieren + if [ -f ../.ci-results/test-backend.json ]; then + TOTAL=$(python3 -c "import json; d=json.load(open('../.ci-results/test-backend.json')); print(d.get('summary',{}).get('total',0))") + PASSED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-backend.json')); print(d.get('summary',{}).get('passed',0))") + FAILED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-backend.json')); print(d.get('summary',{}).get('failed',0))") + SKIPPED=$(python3 -c "import json; d=json.load(open('../.ci-results/test-backend.json')); print(d.get('summary',{}).get('skipped',0))") + else + TOTAL=0; PASSED=0; FAILED=0; SKIPPED=0 + fi + + # JSON-Ergebnis speichern + echo "{\"service\":\"backend\",\"framework\":\"pytest\",\"total\":$TOTAL,\"passed\":$PASSED,\"failed\":$FAILED,\"skipped\":$SKIPPED,\"coverage\":0}" \ + > ../.ci-results/results-backend.json +``` + +### Report-Step + +```yaml +report-test-results: + image: curlimages/curl:8.10.1 + commands: + - | + set -uo pipefail + echo "=== Sende Test-Ergebnisse an Dashboard ===" + echo "Pipeline Status: ${CI_PIPELINE_STATUS:-unknown}" + ls -la .ci-results/ || echo "Verzeichnis nicht gefunden" + + PIPELINE_STATUS="${CI_PIPELINE_STATUS:-unknown}" + + # Schleife über alle Ergebnis-Dateien + for f in .ci-results/results-*.json; do + [ -f "$f" ] || continue + echo "Sending: $f" + curl -f -sS -X POST "http://backend:8000/api/tests/ci-result" \ + -H "Content-Type: application/json" \ + -d "{ + \"pipeline_id\": \"${CI_PIPELINE_NUMBER}\", + \"commit\": \"${CI_COMMIT_SHA}\", + \"branch\": \"${CI_COMMIT_BRANCH}\", + \"status\": \"${PIPELINE_STATUS}\", + \"test_results\": $(cat "$f") + }" || echo "WARNUNG: Konnte $f nicht senden" + done + + echo "=== Test-Ergebnisse gesendet ===" + when: + status: [success, failure] # Läuft immer, auch bei Test-Fehlern + depends_on: + - test-go-consent + - test-go-billing + - test-go-school + - test-python-backend + - test-python-voice + - test-python-klausur + - test-nodejs-h5p +``` + +**Wichtige Verbesserungen:** +- **Vollständiges `depends_on`:** Alle Test-Steps inklusive integration-tests sind aufgelistet +- **Schleife statt if-Blöcke:** Reduziert Code-Duplikation +- **Dynamischer Status:** `${CI_PIPELINE_STATUS}` statt hardcodiertem `"success"` +- **curl mit `-f` Flag:** Zeigt HTTP-Fehler an +- **Pinned Image-Version:** `curl:8.10.1` statt `latest` + +--- + +### 2.5 Integration Tests + +Nach den Unit-Tests laeuft ein vollstaendiger Integration-Test-Schritt, der alle Services in einer Docker-Compose-Umgebung testet. + +#### Docker Compose Services + +| Service | Container-Name | Port (extern) | Port (intern) | +|---------|----------------|---------------|---------------| +| PostgreSQL | postgres-test | 55432 | 5432 | +| Valkey | valkey-test | 56379 | 6379 | +| Consent Service | consent-service-test | 58081 | 8081 | +| Backend | backend-test | 58000 | 8000 | +| Mailpit | mailpit-test | 58025/51025 | 8025/1025 | + +#### Pipeline-Step Konfiguration + +```yaml +integration-tests: + image: docker:27-cli + volumes: + - /var/run/docker.sock:/var/run/docker.sock + commands: + - | + # 1. Docker Compose Umgebung starten + docker compose -f docker-compose.test.yml up -d + + # 2. Auf healthy Services warten (Timeout: 120s pro Service) + for service in postgres-test valkey-test consent-service-test backend-test; do + echo "Waiting for $service..." + timeout=120 + while [ $elapsed -lt $timeout ]; do + status=$(docker compose -f docker-compose.test.yml ps $service --format json | jq -r '.[0].Health') + if [ "$status" = "healthy" ]; then break; fi + sleep 5 + done + done + + # 3. Integration Tests im Backend-Container ausfuehren + docker compose -f docker-compose.test.yml exec -T backend-test \ + pytest tests/test_integration/ -v --tb=short \ + --json-report --json-report-file=/tmp/integration-results.json + + # 4. Cleanup + docker compose -f docker-compose.test.yml down -v + when: + - event: [push, pull_request] + branch: [main, develop] + depends_on: + - test-python-backend +``` + +#### Environment Variables im Integration-Modus + +| Variable | Wert | +|----------|------| +| `SKIP_INTEGRATION_TESTS` | `false` | +| `DATABASE_URL` | `postgresql://breakpilot:breakpilot_test@postgres-test:5432/breakpilot_test` | +| `CONSENT_SERVICE_URL` | `http://consent-service-test:8081` | +| `VALKEY_URL` / `REDIS_URL` | `redis://valkey-test:6379` | +| `SMTP_HOST` / `SMTP_PORT` | `mailpit-test` / `1025` | + +#### Lokales Testen der Integration-Tests + +```bash +# 1. Test-Umgebung starten +docker compose -f docker-compose.test.yml up -d + +# 2. Warten bis healthy +docker compose -f docker-compose.test.yml ps + +# 3. Tests ausfuehren +cd backend +export SKIP_INTEGRATION_TESTS=false +pytest tests/test_integration/ -v + +# 4. Aufraeumen +docker compose -f docker-compose.test.yml down -v +``` + +#### Troubleshooting Integration Tests + +| Problem | Loesung | +|---------|---------| +| Service nicht healthy | `docker compose -f docker-compose.test.yml logs --tail=100` | +| Port bereits belegt | `lsof -i :` und bestehende Container stoppen | +| Tests finden keine Services | Sicherstellen dass `SKIP_INTEGRATION_TESTS=false` gesetzt ist | +| Timeout beim Warten | Health-Check-Intervalle in docker-compose.test.yml anpassen | + +**Weitere Details:** Siehe [Integration Test Environment Dokumentation](../testing/integration-test-environment.md) + +--- + +### CI-Result JSON-Format + +```json +{ + "pipeline_id": "27", + "commit": "abc12345", + "branch": "main", + "status": "success", + "test_results": { + "service": "consent-service", + "framework": "go", + "total": 57, + "passed": 57, + "failed": 0, + "skipped": 0, + "coverage": 75.5 + } +} +``` + +--- + +## 3. Test Registry Backend + +### Dateien + +| Datei | Beschreibung | +|-------|--------------| +| `backend/api/tests/registry.py` | Haupt-API Router (~2200 Zeilen) | +| `backend/api/tests/models.py` | Pydantic/Dataclass Models | +| `backend/api/tests/db_models.py` | SQLAlchemy DB Models | +| `backend/api/tests/repository.py` | Datenbank-Repository | +| `backend/api/tests/database.py` | DB-Session Management | + +### Datenfluss bei CI-Result + +```python +@router.post("/ci-result") +async def receive_ci_result(result: CIResultRequest, background_tasks: BackgroundTasks): + """ + 1. Extrahiere Service-Daten aus test_results + 2. Erstelle TestRunDB Eintrag + 3. Aktualisiere TestServiceStatsDB + 4. Aktualisiere In-Memory Cache (_persisted_results) + 5. Bei Fehlern: Erstelle FailedTestBacklogDB Eintrag (Background Task) + 6. Bei 0 Fehlern: Schließe offene Backlog-Einträge (Background Task) + """ +``` + +### In-Memory Cache + +```python +# Wird beim Start aus PostgreSQL geladen +_persisted_results: Dict[str, Dict] = {} + +# Struktur pro Service: +{ + "consent-service": { + "total": 57, + "passed": 57, + "failed": 0, + "last_run": "2026-02-02T18:46:50", + "status": "passed", + "failed_test_ids": [] + } +} + +# Wird bei jedem CI-Result sofort aktualisiert für Echtzeit-Updates +``` + +--- + +## 4. Datenbank-Schema + +### TestRunDB (test_runs) + +Speichert jeden Test-Durchlauf. + +```sql +CREATE TABLE test_runs ( + id SERIAL PRIMARY KEY, + run_id VARCHAR(50) UNIQUE NOT NULL, -- z.B. "ci-27-consent-service" + service VARCHAR(100) NOT NULL, + framework VARCHAR(50) NOT NULL, -- go, pytest, jest + started_at TIMESTAMP NOT NULL, + completed_at TIMESTAMP, + status VARCHAR(20) NOT NULL, -- queued, running, completed, failed + total_tests INTEGER DEFAULT 0, + passed_tests INTEGER DEFAULT 0, + failed_tests INTEGER DEFAULT 0, + skipped_tests INTEGER DEFAULT 0, + duration_seconds FLOAT DEFAULT 0, + git_commit VARCHAR(40), + git_branch VARCHAR(100), + triggered_by VARCHAR(50), -- manual, ci, schedule + output TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_test_runs_service ON test_runs(service); +CREATE INDEX idx_test_runs_started_at ON test_runs(started_at); +``` + +### TestResultDB (test_results) + +Speichert einzelne Test-Ergebnisse. + +```sql +CREATE TABLE test_results ( + id SERIAL PRIMARY KEY, + run_id VARCHAR(50) REFERENCES test_runs(run_id) ON DELETE CASCADE, + test_name VARCHAR(500) NOT NULL, + test_file VARCHAR(500), + line_number INTEGER, + status VARCHAR(20) NOT NULL, -- passed, failed, skipped, error + duration_ms FLOAT, + error_message TEXT, + error_type VARCHAR(100), + output TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_test_results_run_id ON test_results(run_id); +CREATE INDEX idx_test_results_status ON test_results(status); +``` + +### FailedTestBacklogDB (failed_tests_backlog) + +Persistenter Backlog für fehlgeschlagene Tests. + +```sql +CREATE TABLE failed_tests_backlog ( + id SERIAL PRIMARY KEY, + test_name VARCHAR(500) NOT NULL, + test_file VARCHAR(500), + service VARCHAR(100) NOT NULL, + framework VARCHAR(50), + error_message TEXT, + error_type VARCHAR(100), + first_failed_at TIMESTAMP NOT NULL, + last_failed_at TIMESTAMP NOT NULL, + failure_count INTEGER DEFAULT 1, + status VARCHAR(30) DEFAULT 'open', -- open, in_progress, fixed, wont_fix, flaky + priority VARCHAR(20) DEFAULT 'medium', -- critical, high, medium, low + assigned_to VARCHAR(100), + fix_suggestion TEXT, + notes TEXT, + -- Auto-Close Felder + resolved_at TIMESTAMP, + resolution_commit VARCHAR(50), + resolution_notes TEXT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + + CONSTRAINT uq_backlog_test_service UNIQUE (test_name, service) +); + +CREATE INDEX idx_backlog_service ON failed_tests_backlog(service); +CREATE INDEX idx_backlog_status ON failed_tests_backlog(status); +CREATE INDEX idx_backlog_priority ON failed_tests_backlog(priority); +``` + +### TestServiceStatsDB (test_service_stats) + +Aggregierte Statistiken pro Service für schnelle Abfragen. + +```sql +CREATE TABLE test_service_stats ( + id SERIAL PRIMARY KEY, + service VARCHAR(100) UNIQUE NOT NULL, + total_tests INTEGER DEFAULT 0, + passed_tests INTEGER DEFAULT 0, + failed_tests INTEGER DEFAULT 0, + skipped_tests INTEGER DEFAULT 0, + pass_rate FLOAT DEFAULT 0.0, + last_run_id VARCHAR(50), + last_run_at TIMESTAMP, + last_status VARCHAR(20), + updated_at TIMESTAMP DEFAULT NOW() +); +``` + +### TestFixHistoryDB (test_fixes_history) + +Historie aller Fix-Versuche. + +```sql +CREATE TABLE test_fixes_history ( + id SERIAL PRIMARY KEY, + backlog_id INTEGER REFERENCES failed_tests_backlog(id) ON DELETE CASCADE, + fix_type VARCHAR(50), -- manual, auto_claude, auto_script + fix_description TEXT, + commit_hash VARCHAR(40), + success BOOLEAN, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_fixes_backlog_id ON test_fixes_history(backlog_id); +``` + +--- + +## 5. Backlog-System + +### Status-Workflow + +``` + ┌─────────────┐ + │ │ + Test schlägt ──▶│ open │◀── Test schlägt erneut fehl + fehl │ │ + └──────┬──────┘ + │ + │ Entwickler beginnt Fix + ▼ + ┌─────────────┐ + │ │ + │ in_progress │ + │ │ + └──────┬──────┘ + │ + ┌───────────────┼───────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ + │ │ │ │ │ │ + │ fixed │ │ wont_fix │ │ flaky │ + │ │ │ │ │ │ + └─────────────┘ └─────────────┘ └─────────────┘ + ▲ + │ + │ Automatisch wenn alle + │ Tests bestehen + ┌──────┴──────┐ + │ resolved │ + │ (auto-close)│ + └─────────────┘ +``` + +### Automatische Backlog-Erstellung + +Bei `failed > 0` in CI-Result: + +```python +async def _create_backlog_entry(service_name, framework, failed_count, pipeline_id, commit, branch): + """ + 1. Prüfe ob Eintrag für Service bereits existiert + 2. Falls ja: Erhöhe failure_count, aktualisiere last_failed_at + 3. Falls nein: Erstelle neuen Eintrag mit status='open' + """ + with get_db_session() as db: + existing = db.query(FailedTestBacklogDB).filter( + FailedTestBacklogDB.service == service_name, + FailedTestBacklogDB.status == "open" + ).first() + + if existing: + existing.failure_count += failed_count + existing.last_failed_at = datetime.utcnow() + else: + entry = FailedTestBacklogDB( + test_name=f"Pipeline {pipeline_id} - {failed_count} Tests", + service=service_name, + framework=framework, + status="open", + priority="medium", + first_failed_at=datetime.utcnow(), + last_failed_at=datetime.utcnow(), + failure_count=failed_count, + fix_suggestion="Analysiere den Stack-Trace für Details." + ) + db.add(entry) + db.commit() +``` + +### Automatisches Schließen (Auto-Close) + +Bei `failed == 0` in CI-Result: + +```python +async def _close_backlog_entry(service_name, pipeline_id, commit): + """ + Schließt alle offenen Backlog-Einträge für einen Service, + wenn alle Tests bestanden haben. + """ + with get_db_session() as db: + open_entries = db.query(FailedTestBacklogDB).filter( + FailedTestBacklogDB.service == service_name, + FailedTestBacklogDB.status == "open" + ).all() + + for entry in open_entries: + entry.status = "resolved" + entry.resolved_at = datetime.utcnow() + entry.resolution_commit = commit[:8] if commit else None + entry.resolution_notes = f"Automatisch geschlossen - alle Tests in Pipeline {pipeline_id} bestanden" + + db.commit() +``` + +### Prioritäts-Regeln + +| Priorität | Kriterium | +|-----------|-----------| +| `critical` | > 10 Fehler oder Service-kritisch | +| `high` | 5-10 Fehler oder häufige Regression | +| `medium` | 1-4 Fehler (Standard) | +| `low` | Flaky Tests oder Edge Cases | + +--- + +## 6. Frontend Dashboard + +### URLs + +| Dashboard | URL | Beschreibung | +|-----------|-----|--------------| +| Test Dashboard | `https://macmini:3002/infrastructure/tests` | Übersicht aller Services | +| CI/CD Dashboard | `https://macmini:3002/infrastructure/ci-cd` | Pipeline-Status | +| Backlog | `https://macmini:3002/infrastructure/tests` (Tab) | Fehlgeschlagene Tests | + +### Komponenten + +**Pfad:** `admin-v2/app/(admin)/infrastructure/tests/page.tsx` + +```typescript +// Haupt-Komponenten + + // Kacheln für jeden Service + // Letzte Test-Durchläufe + // Offene Backlog-Einträge + // Coverage-Visualisierung + +``` + +### API-Aufrufe + +```typescript +// Test Registry laden +const registry = await fetch('/api/tests/registry').then(r => r.json()); + +// Backlog laden +const backlog = await fetch('/api/tests/backlog').then(r => r.json()); + +// Test-Runs laden +const runs = await fetch('/api/tests/runs').then(r => r.json()); + +// Coverage laden +const coverage = await fetch('/api/tests/coverage').then(r => r.json()); +``` + +--- + +## 7. Service-Übersicht + +### Registrierte Services + +| Service | Sprache | Framework | Port | Tests | Status | +|---------|---------|-----------|------|-------|--------| +| consent-service | Go | go_test | 8081 | ~60 | Aktiv | +| billing-service | Go | go_test | 8082 | ~20 | Aktiv | +| school-service | Go | go_test | 8084 | ~15 | Aktiv | +| backend | Python | pytest | 8000 | ~200 | Aktiv | +| voice-service | Python | pytest | 8091 | ~30 | Aktiv (inkl. BQAS) | +| klausur-service | Python | pytest | 8086 | ~150 | Aktiv | +| h5p-service | Node.js | jest | - | ~25 | Aktiv | +| edu-search-service | Go | go_test | 8088 | 0 | Keine Tests | +| ai-compliance-sdk | Go | go_test | - | ~150 | Nicht in Pipeline | +| website | TypeScript | jest | 3000 | ~0 | Nicht in Pipeline | +| bqas-golden | Python | pytest | 8091 | ~10 | In voice-service | +| bqas-rag | Python | pytest | 8091 | ~10 | In voice-service | + +### Service-Definition (models.py) + +```python +SERVICE_DEFINITIONS = [ + { + "service": "consent-service", + "display_name": "Consent Service", + "port": 8081, + "language": "go", + "base_path": "/consent-service", + "test_pattern": "*_test.go", + "framework": TestFramework.GO_TEST, + }, + # ... weitere Services +] +``` + +### Tests pro Service + +#### consent-service (Go) + +``` +consent-service/ +├── internal/ +│ ├── handlers/ +│ │ └── handlers_test.go # HTTP Handler Tests +│ ├── services/ +│ │ ├── auth_service_test.go # Auth Tests +│ │ └── consent_service_test.go +│ └── middleware/ +│ └── middleware_test.go +└── cmd/ + └── server_test.go +``` + +#### backend (Python) + +``` +backend/tests/ +├── test_consent_client.py +├── test_gdpr_api.py +├── test_documents.py +├── test_worksheets_api.py +├── test_auth.py +└── ... +``` + +#### voice-service (Python) + +``` +voice-service/tests/ +├── test_encryption.py +├── test_intent_router.py +├── test_sessions.py +├── test_tasks.py +└── bqas/ + ├── test_golden.py # BQAS Golden Suite + └── test_rag.py # BQAS RAG Tests +``` + +#### klausur-service (Python) + +``` +klausur-service/backend/tests/ +├── test_advanced_rag.py +├── test_byoeh.py +├── test_mail_service.py +├── test_ocr_labeling.py +├── test_rag_admin.py +├── test_rbac.py +├── test_vocab_worksheet.py +└── test_worksheet_editor.py +``` + +#### h5p-service (Node.js) + +``` +h5p-service/tests/ +├── server.test.js +├── setup.js +└── README.md +``` + +--- + +## 8. Fehlerbehebung + +### Problem: Tests zeigen 0/0 an + +**Ursache:** Pipeline-Tests produzieren keine Ergebnisse + +**Lösung:** +1. Prüfe Woodpecker Agent Logs: + ```bash + docker logs breakpilot-pwa-woodpecker-agent --tail=100 + ``` +2. Prüfe ob Service kompiliert: + ```bash + cd billing-service && go build ./... + ``` +3. Prüfe ob Tests lokal laufen: + ```bash + cd billing-service && go test -v ./... + ``` + +### Problem: Backlog zeigt 500 Error + +**Ursache:** Fehlende DB-Spalten + +**Lösung:** +```sql +ALTER TABLE failed_tests_backlog ADD COLUMN resolved_at TIMESTAMP; +ALTER TABLE failed_tests_backlog ADD COLUMN resolution_commit VARCHAR(50); +ALTER TABLE failed_tests_backlog ADD COLUMN resolution_notes TEXT; +``` + +### Problem: Frontend zeigt alte Daten + +**Ursache:** In-Memory Cache nicht aktualisiert + +**Lösung:** +1. Backend neustarten: + ```bash + docker compose restart backend + ``` +2. Oder: Manuell Cache-Refresh via API (wenn implementiert) + +### Problem: Pipeline startet nicht + +**Ursache:** Webhook von Gitea nicht empfangen + +**Lösung:** +1. Prüfe Gitea Webhook-Konfiguration +2. Prüfe Woodpecker Server Logs: + ```bash + docker logs breakpilot-pwa-woodpecker-server --tail=50 + ``` +3. Manuell Pipeline triggern via Woodpecker UI + +### Problem: OOM Kill in Pipeline + +**Ursache:** Zu viele parallele Tests + +**Lösung:** +1. Tests sequentiell statt parallel ausführen +2. Memory-Limits in docker-compose erhöhen +3. Große Test-Suites aufteilen + +--- + +## 9. API-Referenz + +### Test Registry Endpoints + +#### GET /api/tests/registry + +Gibt alle registrierten Services mit Test-Statistiken zurück. + +**Response:** +```json +{ + "services": [ + { + "service": "consent-service", + "display_name": "Consent Service", + "port": 8081, + "language": "go", + "total_tests": 57, + "passed_tests": 57, + "failed_tests": 0, + "skipped_tests": 0, + "pass_rate": 100.0, + "coverage_percent": 75.5, + "last_run": "2026-02-02T18:46:50", + "status": "passed" + } + ], + "stats": { + "total_tests": 500, + "total_passed": 480, + "total_failed": 20, + "services_count": 12, + "overall_pass_rate": 96.0 + } +} +``` + +#### POST /api/tests/ci-result + +Empfängt Test-Ergebnisse von der CI/CD-Pipeline. + +**Request:** +```json +{ + "pipeline_id": "27", + "commit": "abc12345def67890", + "branch": "main", + "status": "success", + "test_results": { + "service": "consent-service", + "framework": "go", + "total": 57, + "passed": 57, + "failed": 0, + "skipped": 0, + "coverage": 75.5 + } +} +``` + +**Response:** +```json +{ + "received": true, + "run_id": "ci-27-consent-service", + "service": "consent-service", + "pipeline_id": "27", + "status": "passed", + "tests": {"total": 57, "passed": 57, "failed": 0}, + "stored_in": "postgres" +} +``` + +#### GET /api/tests/backlog + +Gibt alle Backlog-Einträge zurück. + +**Query-Parameter:** +- `status`: Filter nach Status (open, in_progress, fixed, etc.) +- `service`: Filter nach Service +- `limit`: Anzahl der Ergebnisse (default: 50) + +**Response:** +```json +{ + "total": 15, + "items": [ + { + "id": 1, + "test_name": "Pipeline 27 - 3 Tests", + "service": "backend", + "status": "open", + "priority": "medium", + "failure_count": 3, + "first_failed_at": "2026-02-02T10:00:00", + "last_failed_at": "2026-02-02T18:00:00", + "resolved_at": null + } + ] +} +``` + +#### GET /api/tests/runs + +Gibt die letzten Test-Durchläufe zurück. + +**Query-Parameter:** +- `service`: Filter nach Service +- `limit`: Anzahl (default: 20) + +#### GET /api/tests/coverage + +Gibt Coverage-Informationen zurück. + +#### POST /api/tests/run/{suite} + +Startet einen Test-Run manuell. + +#### PUT /api/tests/backlog/{id} + +Aktualisiert einen Backlog-Eintrag (Status, Priorität, Assignee). + +--- + +## Anhang + +### Umgebungsvariablen + +```bash +# Woodpecker +WOODPECKER_URL=https://macmini:4431 +WOODPECKER_TOKEN= + +# Backend +DATABASE_URL=postgresql://user:pass@postgres:5432/breakpilot +TEST_REGISTRY_ENABLED=true +``` + +### Lokale CI-Simulation + +Für lokales Testen ohne Woodpecker CI stehen zwei Hilfsdateien zur Verfügung: + +**Makefile** (Projektroot) +```bash +# Alle Tests lokal ausführen +make ci + +# Nur Go-Tests +make test-go + +# Nur Python-Tests +make test-python + +# Woodpecker Agent Logs +make logs-agent + +# Backend Logs (ci-result Filter) +make logs-backend + +# Test-Ergebnisse löschen +make clean +``` + +**docker-compose.test.yml** (Projektroot) +```bash +# Test-Datenbanken starten +docker compose -f docker-compose.test.yml up -d + +# Test-Datenbanken stoppen und Daten löschen +docker compose -f docker-compose.test.yml down -v +``` + +| Service | Port | Credentials | +|---------|------|-------------| +| PostgreSQL | 55432 | breakpilot_test/breakpilot/breakpilot | +| Redis | 56379 | (keine) | + +--- + +### Nützliche Befehle + +```bash +# Woodpecker Logs +docker logs breakpilot-pwa-woodpecker-agent --tail=100 +docker logs breakpilot-pwa-woodpecker-server --tail=100 + +# Backend Logs +docker compose logs backend --tail=100 | grep -E "(CI-RESULT|test|error)" + +# Datenbank prüfen +docker compose exec backend python3 -c " +from sqlalchemy.orm import Session +from classroom_engine.database import engine +from api.tests.db_models import TestRunDB, TestServiceStatsDB + +with Session(engine) as db: + runs = db.query(TestRunDB).order_by(TestRunDB.started_at.desc()).limit(5).all() + for r in runs: + print(f'{r.run_id}: {r.status} - {r.passed_tests}/{r.total_tests}') +" + +# Pipeline manuell triggern +curl -X POST "https://macmini:4431/api/repos/pilotadmin/breakpilot-pwa/pipelines" \ + -H "Authorization: Bearer $WOODPECKER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"branch":"main"}' +``` + +### Weiterführende Dokumentation + +- [Woodpecker CI Docs](https://woodpecker-ci.org/docs/intro) +- [BQAS Quality System](../architecture/bqas-quality-system.md) +- [Voice Service Guide](../guides/voice-service-developer-guide.md) +- [E2E Testing Guide](../guides/E2E-Testing-Playwright.md) + +--- + +*Generiert am 2026-02-02 von Claude Code* +*Aktualisiert am 2026-02-02: jq-Parsing für Go-Tests, vollständiges depends_on, Makefile & docker-compose.test.yml* +*Aktualisiert am 2026-02-04: Integration Test Environment mit Docker Compose hinzugefuegt (Sektion 2.5)* diff --git a/docs/consent-banner/SPECIFICATION.md b/docs/consent-banner/SPECIFICATION.md new file mode 100644 index 0000000..77a5d5b --- /dev/null +++ b/docs/consent-banner/SPECIFICATION.md @@ -0,0 +1,1737 @@ +# Cookie & Consent Banner - Technische Spezifikation + +**Version:** 1.0.0 +**Status:** Draft +**Lizenz:** Apache 2.0 (Open Source, kommerziell nutzbar) +**Letzte Aktualisierung:** 2026-02-04 + +--- + +## Inhaltsverzeichnis + +1. [Executive Summary](#1-executive-summary) +2. [Rechtliche Anforderungen](#2-rechtliche-anforderungen) +3. [Architektur-Übersicht](#3-architektur-übersicht) +4. [Consent-Kategorien](#4-consent-kategorien) +5. [SDK-Spezifikation](#5-sdk-spezifikation) +6. [API-Spezifikation](#6-api-spezifikation) +7. [UI/UX-Anforderungen](#7-uiux-anforderungen) +8. [Plattform-Integrationen](#8-plattform-integrationen) +9. [Datenspeicherung & Audit](#9-datenspeicherung--audit) +10. [Sicherheit & Privacy](#10-sicherheit--privacy) +11. [Implementierungs-Roadmap](#11-implementierungs-roadmap) +12. [Anhang](#12-anhang) + +--- + +## 1. Executive Summary + +### 1.1 Projektziel + +Entwicklung eines **plattformübergreifenden Consent-Management-Systems** (CMP), das: + +- DSGVO, TTDSG und ePrivacy-Richtlinie vollständig erfüllt +- In PWAs, Websites, Mobile Apps und Desktop-Anwendungen integrierbar ist +- Als Open-Source-Lösung kommerziell nutzbar ist (Apache 2.0) +- Über SDKs für alle gängigen Plattformen verfügbar ist +- Mit dem bestehenden BreakPilot Consent-Service integriert + +### 1.2 Kernfunktionen + +| Funktion | Beschreibung | +|----------|--------------| +| **Consent Collection** | Granulare Einwilligung pro Kategorie und Anbieter | +| **Consent Storage** | Revisionssichere Speicherung aller Einwilligungen | +| **Consent Withdrawal** | Jederzeitiger Widerruf mit gleicher Einfachheit | +| **Cross-Platform Sync** | Consent-Status über alle Plattformen synchronisiert | +| **TCF 2.2 Support** | IAB Transparency & Consent Framework Integration | +| **A/B Testing** | DSGVO-konforme Banner-Optimierung | +| **Analytics** | Anonymisierte Consent-Statistiken | + +### 1.3 Zielplattformen + +- **Web:** JavaScript SDK (Vanilla, React, Vue, Angular, Svelte) +- **PWA:** Service Worker Integration mit Offline-Support +- **iOS:** Swift SDK (SwiftUI & UIKit) +- **Android:** Kotlin SDK (Jetpack Compose & XML Views) +- **Flutter:** Cross-Platform SDK +- **React Native:** Cross-Platform SDK + +--- + +## 2. Rechtliche Anforderungen + +### 2.1 DSGVO (EU 2016/679) + +| Artikel | Anforderung | Umsetzung | +|---------|-------------|-----------| +| **Art. 4 Nr. 11** | Einwilligung muss freiwillig, bestimmt, informiert und unmissverständlich sein | Granulare Opt-in-Buttons, keine vorausgewählten Checkboxen | +| **Art. 6 Abs. 1 lit. a** | Einwilligung als Rechtsgrundlage | Dokumentierte Consent-Erfassung mit Timestamp | +| **Art. 7 Abs. 1** | Nachweis der Einwilligung | Revisionssichere Speicherung, Audit-Log | +| **Art. 7 Abs. 2** | Abgrenzung von anderen Sachverhalten | Separate Consent-Abfrage, kein Bundling | +| **Art. 7 Abs. 3** | Widerruf jederzeit möglich | Persistenter "Einstellungen"-Button, gleichwertige Widerrufsmöglichkeit | +| **Art. 7 Abs. 4** | Koppelungsverbot | Keine Dienstverweigerung bei Ablehnung | +| **Art. 12** | Transparente Information | Klare, verständliche Sprache | +| **Art. 13/14** | Informationspflichten | Verlinkung zur Datenschutzerklärung | +| **Art. 17** | Recht auf Löschung | Löschung aller Consent-Daten auf Anfrage | +| **Art. 20** | Datenportabilität | Export aller Consent-Daten als JSON | + +### 2.2 TTDSG (Deutschland) + +| § | Anforderung | Umsetzung | +|---|-------------|-----------| +| **§ 25 Abs. 1** | Einwilligung für nicht-notwendige Cookies | Opt-in für alle nicht-essentiellen Technologien | +| **§ 25 Abs. 2** | Ausnahmen für technisch notwendige Speicherung | Klare Kategorisierung "Essentiell" ohne Einwilligung | +| **§ 25 Abs. 2 Nr. 2** | Ausnahme für vom Nutzer gewünschte Dienste | Dokumentation der Notwendigkeit | + +### 2.3 ePrivacy-Richtlinie (2002/58/EG) + +| Artikel | Anforderung | Umsetzung | +|---------|-------------|-----------| +| **Art. 5 Abs. 3** | Einwilligung vor Cookie-Setzung | Blockierung aller Skripte bis Consent | +| **Art. 5 Abs. 3** | Klare Information über Zweck | Transparente Kategoriebeschreibungen | + +### 2.4 Planet49-Urteil (EuGH C-673/17) + +**Kernaussagen:** +- Vorausgewählte Checkboxen sind KEINE gültige Einwilligung +- Cookie-Laufzeit muss angegeben werden +- Drittanbieter müssen benannt werden + +**Umsetzung:** +- Alle Checkboxen standardmäßig NICHT ausgewählt +- Anzeige der Cookie-Laufzeit pro Anbieter +- Auflistung aller Drittanbieter mit Links zu deren Datenschutzerklärung + +### 2.5 DSK-Orientierungshilfe Telemedien + +**Anforderungen:** +1. "Ablehnen" muss genauso prominent sein wie "Akzeptieren" +2. Keine Dark Patterns (versteckte Ablehnungsoptionen) +3. Keine Nudging-Techniken (farbliche Hervorhebung von "Akzeptieren") +4. Direkter Zugang zu granularen Einstellungen + +### 2.6 BGH-Urteil Cookie-Einwilligung II (2023) + +**Kernaussagen:** +- "Alles akzeptieren" nur zulässig wenn gleichwertige "Alles ablehnen"-Option +- Keine versteckten Ablehungs-Flows +- Consent-Banner darf Seiteninhalt nicht vollständig verdecken + +--- + +## 3. Architektur-Übersicht + +### 3.1 System-Architektur + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Client-Plattformen │ +├──────────┬──────────┬──────────┬──────────┬──────────┬─────────────────┤ +│ Web │ PWA │ iOS │ Android │ Flutter │ React Native │ +│ SDK │ SDK │ SDK │ SDK │ SDK │ SDK │ +└────┬─────┴────┬─────┴────┬─────┴────┬─────┴────┬─────┴────────┬────────┘ + │ │ │ │ │ │ + └──────────┴──────────┴────┬─────┴──────────┴──────────────┘ + │ + ┌───────────▼───────────┐ + │ Consent Gateway │ + │ (REST + WebSocket) │ + └───────────┬───────────┘ + │ + ┌──────────────────────────┼──────────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌─────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Consent │ │ Config │ │ Analytics │ +│ Service │ │ Service │ │ Service │ +│ (Go) │ │ (Go) │ │ (Go) │ +└──────┬──────┘ └────────┬────────┘ └────────┬────────┘ + │ │ │ + └──────────────────────┼────────────────────────┘ + │ + ┌─────────▼─────────┐ + │ PostgreSQL │ + │ (Consent Store) │ + └───────────────────┘ +``` + +### 3.2 Komponenten + +| Komponente | Technologie | Beschreibung | +|------------|-------------|--------------| +| **Web SDK** | TypeScript | Vanilla JS + Framework-Wrapper | +| **Mobile SDKs** | Swift/Kotlin | Native Implementierungen | +| **Consent Gateway** | Go + Gin | API-Gateway mit Rate-Limiting | +| **Consent Service** | Go | Consent-Logik, bereits vorhanden | +| **Config Service** | Go | Banner-Konfiguration, TCF-Vendors | +| **Analytics Service** | Go | Anonymisierte Statistiken | +| **PostgreSQL** | PostgreSQL 16 | Revisionssichere Speicherung | + +### 3.3 Datenfluss + +``` +1. Nutzer öffnet Website/App + │ + ▼ +2. SDK prüft lokalen Consent-Status + │ + ┌────┴────┐ + │ Consent │ + │ exists? │ + └────┬────┘ + No │ Yes + │ │ │ + ▼ │ ▼ +3. Banner│ 5. Apply + anzeigen Consent + │ + ▼ +4. Nutzer wählt + Kategorien + │ + ▼ +6. SDK speichert lokal + + sendet an Backend + │ + ▼ +7. Backend speichert + revisionssicher + │ + ▼ +8. SDK blockiert/erlaubt + entsprechende Skripte +``` + +--- + +## 4. Consent-Kategorien + +### 4.1 Standard-Kategorien (IAB TCF 2.2 kompatibel) + +| ID | Kategorie | Beschreibung | Rechtsgrundlage | +|----|-----------|--------------|-----------------| +| `essential` | **Essentiell** | Technisch notwendig für Grundfunktionen | TTDSG § 25 Abs. 2 (keine Einwilligung nötig) | +| `functional` | **Funktional** | Personalisierung, Spracheinstellungen | DSGVO Art. 6 Abs. 1 lit. a | +| `analytics` | **Statistik** | Anonyme Nutzungsanalyse | DSGVO Art. 6 Abs. 1 lit. a | +| `marketing` | **Marketing** | Werbung, Retargeting | DSGVO Art. 6 Abs. 1 lit. a | +| `social` | **Soziale Medien** | Social Plugins, Sharing | DSGVO Art. 6 Abs. 1 lit. a | + +### 4.2 Kategorie-Details + +#### 4.2.1 Essentiell (`essential`) + +**Beschreibung:** Cookies und Technologien, die für den Betrieb der Website unbedingt erforderlich sind. + +**Beispiele:** +- Session-Cookies +- Warenkorb-Cookies +- Authentifizierung +- Load-Balancing +- CSRF-Token +- Consent-Cookie selbst + +**Rechtsgrundlage:** TTDSG § 25 Abs. 2 Nr. 2 - keine Einwilligung erforderlich + +**UI:** Immer aktiv, nicht deaktivierbar, mit Erklärung warum + +#### 4.2.2 Funktional (`functional`) + +**Beschreibung:** Cookies, die die Website-Funktionalität verbessern, aber nicht essentiell sind. + +**Beispiele:** +- Sprachpräferenzen +- Theme-Einstellungen (Dark Mode) +- Zuletzt angesehene Produkte +- Chat-Widget-Status +- Video-Player-Einstellungen + +**Rechtsgrundlage:** DSGVO Art. 6 Abs. 1 lit. a - Einwilligung erforderlich + +#### 4.2.3 Statistik (`analytics`) + +**Beschreibung:** Cookies zur anonymen Analyse des Nutzerverhaltens. + +**Beispiele:** +- Google Analytics +- Matomo/Piwik +- Hotjar (Heatmaps) +- Plausible Analytics + +**Rechtsgrundlage:** DSGVO Art. 6 Abs. 1 lit. a - Einwilligung erforderlich + +**Hinweis:** Auch bei Anonymisierung ist Einwilligung erforderlich (EuGH-Rechtsprechung) + +#### 4.2.4 Marketing (`marketing`) + +**Beschreibung:** Cookies für personalisierte Werbung und Retargeting. + +**Beispiele:** +- Google Ads +- Facebook Pixel +- LinkedIn Insight Tag +- Criteo +- Affiliate-Tracking + +**Rechtsgrundlage:** DSGVO Art. 6 Abs. 1 lit. a - Einwilligung erforderlich + +#### 4.2.5 Soziale Medien (`social`) + +**Beschreibung:** Cookies von Social-Media-Plattformen. + +**Beispiele:** +- Facebook Like-Button +- Twitter Share +- Instagram Embed +- YouTube Embed +- LinkedIn Share + +**Rechtsgrundlage:** DSGVO Art. 6 Abs. 1 lit. a - Einwilligung erforderlich + +### 4.3 Anbieter-Granularität (TCF 2.2) + +Zusätzlich zur Kategorie kann der Nutzer einzelne Anbieter aktivieren/deaktivieren: + +```typescript +interface ConsentVendor { + id: string; // TCF Vendor ID oder custom ID + name: string; // "Google LLC" + category: ConsentCategory; + purposes: TCFPurpose[]; // IAB TCF Purposes + legitimateInterests?: TCFPurpose[]; + cookies: CookieInfo[]; + privacyPolicyUrl: string; + dataRetention: string; // "2 Jahre" / "Session" + dataTransfer?: string; // "USA (EU-US DPF)" / "EU" +} + +interface CookieInfo { + name: string; // "_ga" + domain: string; // ".example.com" + expiration: string; // "2 Jahre" + type: "http" | "localStorage" | "sessionStorage" | "indexedDB"; + description: string; +} +``` + +--- + +## 5. SDK-Spezifikation + +### 5.1 Web SDK (JavaScript/TypeScript) + +#### 5.1.1 Installation + +```bash +# npm +npm install @breakpilot/consent-sdk + +# yarn +yarn add @breakpilot/consent-sdk + +# pnpm +pnpm add @breakpilot/consent-sdk + +# CDN (für Vanilla JS) + +``` + +#### 5.1.2 Basis-Konfiguration + +```typescript +import { ConsentManager, ConsentConfig } from '@breakpilot/consent-sdk'; + +const config: ConsentConfig = { + // Pflichtfelder + apiEndpoint: 'https://consent.example.com/api/v1', + siteId: 'site_abc123', + + // Sprache + language: 'de', + fallbackLanguage: 'en', + + // UI-Optionen + ui: { + position: 'bottom', // 'bottom' | 'top' | 'center' + layout: 'bar', // 'bar' | 'modal' | 'floating' + theme: 'auto', // 'light' | 'dark' | 'auto' + customCss: '/css/consent.css', + zIndex: 999999, + blockScrollOnModal: true, + }, + + // Consent-Optionen + consent: { + required: true, // Muss Nutzer interagieren? + rejectAllVisible: true, // "Alle ablehnen" Button sichtbar + acceptAllVisible: true, // "Alle akzeptieren" Button sichtbar + granularControl: true, // Einzelne Kategorien wählbar + vendorControl: true, // Einzelne Anbieter wählbar + rememberChoice: true, // Auswahl speichern + rememberDays: 365, // Speicherdauer in Tagen + geoTargeting: true, // Nur in EU anzeigen + recheckAfterDays: 180, // Erneut nachfragen nach X Tagen + }, + + // Kategorien aktivieren + categories: ['essential', 'functional', 'analytics', 'marketing', 'social'], + + // Callbacks + onConsentChange: (consent) => { + console.log('Consent changed:', consent); + }, + onBannerShow: () => { + console.log('Banner shown'); + }, + onBannerHide: () => { + console.log('Banner hidden'); + }, + + // Debug-Modus + debug: process.env.NODE_ENV === 'development', +}; + +// Manager initialisieren +const consent = new ConsentManager(config); +consent.init(); +``` + +#### 5.1.3 Skript-Blocking + +```html + + + + + + + + +``` + +#### 5.1.4 API-Methoden + +```typescript +// Consent-Status prüfen +const hasAnalytics = consent.hasConsent('analytics'); +const hasVendor = consent.hasVendorConsent('google-analytics'); + +// Consent programmatisch setzen +await consent.setConsent({ + essential: true, + functional: true, + analytics: false, + marketing: false, + social: false, +}); + +// Alle Einwilligungen widerrufen +await consent.revokeAll(); + +// Banner erneut anzeigen +consent.showBanner(); + +// Einstellungs-Modal öffnen +consent.showSettings(); + +// Consent-Objekt abrufen +const currentConsent = consent.getConsent(); +// { +// categories: { essential: true, analytics: false, ... }, +// vendors: { 'google-analytics': false, ... }, +// timestamp: '2026-02-04T12:00:00Z', +// version: '1.0.0', +// consentId: 'cns_abc123', +// } + +// Consent exportieren (für Nutzeranfragen) +const exportData = consent.exportConsent(); +// JSON mit allen historischen Consents + +// Event-Listener +consent.on('change', (newConsent) => { ... }); +consent.on('vendor:enable', (vendorId) => { ... }); +consent.on('vendor:disable', (vendorId) => { ... }); +``` + +#### 5.1.5 Framework-Integrationen + +**React:** + +```tsx +import { ConsentProvider, useConsent, ConsentBanner } from '@breakpilot/consent-sdk/react'; + +function App() { + return ( + + + + + ); +} + +function AnalyticsComponent() { + const { hasConsent, isLoading } = useConsent('analytics'); + + if (isLoading) return ; + if (!hasConsent) return ; + + return ; +} +``` + +**Vue 3:** + +```vue + + + +``` + +**Angular:** + +```typescript +import { ConsentModule } from '@breakpilot/consent-sdk/angular'; + +@NgModule({ + imports: [ + ConsentModule.forRoot(config), + ], +}) +export class AppModule {} + +// In Component: +@Component({...}) +export class MyComponent { + constructor(private consent: ConsentService) {} + + get hasAnalytics(): boolean { + return this.consent.hasConsent('analytics'); + } +} +``` + +### 5.2 PWA-spezifische Features + +#### 5.2.1 Offline-Support + +```typescript +const pwaConfig: ConsentConfig = { + ...baseConfig, + pwa: { + offlineSupport: true, + syncOnReconnect: true, + cacheStrategy: 'stale-while-revalidate', + }, +}; + +// Service Worker Integration +// consent-sw.js wird automatisch registriert +``` + +#### 5.2.2 App-Banner Integration + +```typescript +// Consent vor PWA-Install-Banner abfragen +consent.on('beforeInstallPrompt', (event) => { + if (!consent.hasConsent('functional')) { + event.preventDefault(); + consent.showBanner({ + message: 'Für die App-Installation benötigen wir Ihre Einwilligung.', + highlightCategory: 'functional', + }); + } +}); +``` + +### 5.3 iOS SDK (Swift) + +#### 5.3.1 Installation + +```swift +// Swift Package Manager +dependencies: [ + .package(url: "https://github.com/breakpilot/consent-sdk-ios", from: "1.0.0") +] + +// CocoaPods +pod 'BreakPilotConsent', '~> 1.0' +``` + +#### 5.3.2 Konfiguration + +```swift +import BreakPilotConsent + +let config = ConsentConfig( + apiEndpoint: "https://consent.example.com/api/v1", + siteId: "site_abc123", + language: .german, + categories: [.essential, .functional, .analytics, .marketing] +) + +// AppDelegate oder SceneDelegate +ConsentManager.shared.configure(with: config) +``` + +#### 5.3.3 SwiftUI Integration + +```swift +import SwiftUI +import BreakPilotConsent + +struct ContentView: View { + @StateObject private var consent = ConsentManager.shared + + var body: some View { + VStack { + if consent.needsConsent { + ConsentBannerView() + } + + MainContent() + } + .consentGate(category: .analytics) { + AnalyticsView() + } placeholder: { + ConsentPlaceholder(category: .analytics) + } + } +} +``` + +#### 5.3.4 UIKit Integration + +```swift +import BreakPilotConsent + +class ViewController: UIViewController { + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + if ConsentManager.shared.needsConsent { + let bannerVC = ConsentBannerViewController() + bannerVC.modalPresentationStyle = .overFullScreen + present(bannerVC, animated: true) + } + } + + func loadAnalytics() { + guard ConsentManager.shared.hasConsent(.analytics) else { + showConsentRequired(for: .analytics) + return + } + // Analytics laden + } +} +``` + +#### 5.3.5 App Tracking Transparency (ATT) Integration + +```swift +import AppTrackingTransparency + +// Consent-SDK koordiniert mit ATT +ConsentManager.shared.configure(with: config) { manager in + // Nach Consent-Banner ATT-Dialog anzeigen + if manager.hasConsent(.marketing) { + ATTrackingManager.requestTrackingAuthorization { status in + switch status { + case .authorized: + manager.updateATTStatus(.authorized) + case .denied, .restricted: + manager.revokeConsent(.marketing) + case .notDetermined: + break + @unknown default: + break + } + } + } +} +``` + +### 5.4 Android SDK (Kotlin) + +#### 5.4.1 Installation + +```kotlin +// build.gradle.kts +dependencies { + implementation("eu.breakpilot:consent-sdk:1.0.0") +} +``` + +#### 5.4.2 Konfiguration + +```kotlin +import eu.breakpilot.consent.ConsentManager +import eu.breakpilot.consent.ConsentConfig + +class MyApplication : Application() { + override fun onCreate() { + super.onCreate() + + val config = ConsentConfig.Builder() + .apiEndpoint("https://consent.example.com/api/v1") + .siteId("site_abc123") + .language(Language.GERMAN) + .categories( + Category.ESSENTIAL, + Category.FUNCTIONAL, + Category.ANALYTICS, + Category.MARKETING + ) + .build() + + ConsentManager.initialize(this, config) + } +} +``` + +#### 5.4.3 Jetpack Compose Integration + +```kotlin +import eu.breakpilot.consent.compose.* + +@Composable +fun MainScreen() { + val consent = rememberConsentState() + + if (consent.needsConsent) { + ConsentBanner( + onAcceptAll = { consent.acceptAll() }, + onRejectAll = { consent.rejectAll() }, + onCustomize = { /* Öffne Einstellungen */ } + ) + } + + ConsentGate( + category = Category.ANALYTICS, + placeholder = { ConsentPlaceholder(Category.ANALYTICS) } + ) { + AnalyticsContent() + } +} +``` + +#### 5.4.4 XML Views Integration + +```kotlin +class MainActivity : AppCompatActivity() { + private val consent = ConsentManager.instance + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + consent.observe(this) { state -> + when { + state.needsConsent -> showConsentBanner() + state.hasConsent(Category.ANALYTICS) -> loadAnalytics() + } + } + } + + private fun showConsentBanner() { + ConsentBannerFragment.show(supportFragmentManager) + } +} +``` + +### 5.5 Flutter SDK + +```dart +import 'package:breakpilot_consent/breakpilot_consent.dart'; + +void main() async { + WidgetsFlutterBinding.ensureInitialized(); + + await ConsentManager.initialize( + config: ConsentConfig( + apiEndpoint: 'https://consent.example.com/api/v1', + siteId: 'site_abc123', + language: Language.german, + categories: [ + Category.essential, + Category.functional, + Category.analytics, + Category.marketing, + ], + ), + ); + + runApp(MyApp()); +} + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return ConsentProvider( + child: MaterialApp( + home: ConsentAwareHome(), + ), + ); + } +} + +class ConsentAwareHome extends StatelessWidget { + @override + Widget build(BuildContext context) { + return ConsentBuilder( + builder: (context, consent) { + if (consent.needsConsent) { + return ConsentBanner(); + } + + return Scaffold( + body: Column( + children: [ + ConsentGate( + category: Category.analytics, + child: AnalyticsWidget(), + placeholder: ConsentPlaceholder(category: Category.analytics), + ), + ], + ), + ); + }, + ); + } +} +``` + +### 5.6 React Native SDK + +```tsx +import { + ConsentProvider, + useConsent, + ConsentBanner, + ConsentGate, +} from '@breakpilot/consent-sdk-react-native'; + +const config = { + apiEndpoint: 'https://consent.example.com/api/v1', + siteId: 'site_abc123', + language: 'de', + categories: ['essential', 'functional', 'analytics', 'marketing'], +}; + +function App() { + return ( + + + + + + + ); +} + +function AnalyticsScreen() { + const { hasConsent } = useConsent(); + + if (!hasConsent('analytics')) { + return ; + } + + return ; +} +``` + +--- + +## 6. API-Spezifikation + +### 6.1 Base URL + +``` +Production: https://consent.breakpilot.eu/api/v1 +Staging: https://consent-staging.breakpilot.eu/api/v1 +``` + +### 6.2 Authentifizierung + +```http +# API-Key für Server-to-Server +Authorization: Bearer + +# HMAC für Client-SDKs (verhindert Manipulation) +X-Consent-Signature: sha256= +X-Consent-Timestamp: 1707000000 +``` + +### 6.3 Endpoints + +#### 6.3.1 Consent erstellen/aktualisieren + +```http +POST /consent +Content-Type: application/json + +{ + "siteId": "site_abc123", + "userId": "user_xyz789", // Optional: Für eingeloggte Nutzer + "deviceFingerprint": "fp_...", // Anonymisierter Fingerprint + "consent": { + "categories": { + "essential": true, + "functional": true, + "analytics": false, + "marketing": false, + "social": false + }, + "vendors": { + "google-analytics": false, + "facebook-pixel": false + } + }, + "metadata": { + "userAgent": "Mozilla/5.0...", + "language": "de-DE", + "screenResolution": "1920x1080", + "platform": "web", + "appVersion": "1.0.0" + } +} + +Response 201: +{ + "consentId": "cns_abc123def456", + "timestamp": "2026-02-04T12:00:00Z", + "expiresAt": "2027-02-04T12:00:00Z", + "version": "1.0.0" +} +``` + +#### 6.3.2 Consent abrufen + +```http +GET /consent?siteId=site_abc123&deviceFingerprint=fp_... + +Response 200: +{ + "consentId": "cns_abc123def456", + "consent": { + "categories": { + "essential": true, + "functional": true, + "analytics": false, + "marketing": false, + "social": false + }, + "vendors": {} + }, + "createdAt": "2026-02-04T12:00:00Z", + "updatedAt": "2026-02-04T12:00:00Z", + "expiresAt": "2027-02-04T12:00:00Z", + "version": "1.0.0" +} + +Response 404: +{ + "error": "consent_not_found", + "message": "No consent record found" +} +``` + +#### 6.3.3 Consent widerrufen + +```http +DELETE /consent/{consentId} + +Response 200: +{ + "status": "revoked", + "revokedAt": "2026-02-04T13:00:00Z" +} +``` + +#### 6.3.4 Consent-Historie exportieren (DSGVO Art. 20) + +```http +GET /consent/export?userId=user_xyz789 + +Response 200: +{ + "userId": "user_xyz789", + "exportedAt": "2026-02-04T12:00:00Z", + "consents": [ + { + "consentId": "cns_abc123", + "siteId": "site_abc123", + "consent": { ... }, + "createdAt": "2026-01-01T12:00:00Z", + "revokedAt": null + }, + { + "consentId": "cns_def456", + "siteId": "site_abc123", + "consent": { ... }, + "createdAt": "2025-06-01T12:00:00Z", + "revokedAt": "2026-01-01T11:59:00Z" + } + ] +} +``` + +#### 6.3.5 Site-Konfiguration abrufen + +```http +GET /config/{siteId} + +Response 200: +{ + "siteId": "site_abc123", + "siteName": "Example Website", + "categories": [ + { + "id": "essential", + "name": { "de": "Essentiell", "en": "Essential" }, + "description": { "de": "...", "en": "..." }, + "required": true, + "vendors": [] + }, + { + "id": "analytics", + "name": { "de": "Statistik", "en": "Analytics" }, + "description": { "de": "...", "en": "..." }, + "required": false, + "vendors": [ + { + "id": "google-analytics", + "name": "Google Analytics", + "privacyPolicyUrl": "https://policies.google.com/privacy", + "cookies": [ + { + "name": "_ga", + "expiration": "2 Jahre", + "description": "Unterscheidet Nutzer" + } + ] + } + ] + } + ], + "ui": { + "theme": "light", + "position": "bottom", + "customCss": "..." + }, + "legal": { + "privacyPolicyUrl": "https://example.com/datenschutz", + "imprintUrl": "https://example.com/impressum", + "dpo": { + "name": "Max Mustermann", + "email": "datenschutz@example.com" + } + }, + "tcf": { + "enabled": true, + "cmpId": 123, + "cmpVersion": 1 + } +} +``` + +#### 6.3.6 Consent-Statistiken (Admin) + +```http +GET /admin/stats/{siteId} +Authorization: Bearer + +Response 200: +{ + "siteId": "site_abc123", + "period": { + "from": "2026-01-01", + "to": "2026-02-04" + }, + "totalBannerViews": 150000, + "totalInteractions": 120000, + "interactionRate": 0.80, + "consentByCategory": { + "essential": { "accepted": 120000, "rate": 1.0 }, + "functional": { "accepted": 95000, "rate": 0.79 }, + "analytics": { "accepted": 45000, "rate": 0.38 }, + "marketing": { "accepted": 25000, "rate": 0.21 }, + "social": { "accepted": 30000, "rate": 0.25 } + }, + "actions": { + "acceptAll": 35000, + "rejectAll": 40000, + "customize": 45000 + }, + "avgTimeToDecision": "4.2s", + "bounceAfterBanner": 0.05 +} +``` + +### 6.4 Webhooks + +```http +POST https://your-server.com/webhooks/consent + +{ + "event": "consent.updated", + "timestamp": "2026-02-04T12:00:00Z", + "data": { + "consentId": "cns_abc123", + "siteId": "site_abc123", + "userId": "user_xyz789", + "changes": { + "analytics": { "from": true, "to": false }, + "marketing": { "from": false, "to": false } + } + } +} + +// Event-Typen: +// - consent.created +// - consent.updated +// - consent.revoked +// - consent.expired +``` + +--- + +## 7. UI/UX-Anforderungen + +### 7.1 Design-Prinzipien + +1. **Gleichwertigkeit:** "Ablehnen" und "Akzeptieren" gleich prominent +2. **Transparenz:** Alle Informationen auf einen Blick +3. **Barrierefreiheit:** WCAG 2.1 AA konform +4. **Mobile-First:** Responsive Design +5. **Keine Dark Patterns:** Keine manipulativen Design-Elemente + +### 7.2 Banner-Layouts + +#### 7.2.1 Bottom Bar (Standard) + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ 🍪 Diese Website verwendet Cookies │ +│ │ +│ Wir nutzen Cookies und ähnliche Technologien, um Ihnen ein │ +│ optimales Nutzererlebnis zu bieten. [Mehr erfahren] │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ +│ │ Alle ablehnen│ │ Einstellungen │ │ Alle akzeptieren │ │ +│ └──────────────┘ └──────────────┘ └──────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +#### 7.2.2 Modal (DSGVO-empfohlen) + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ [X] │ +│ 🍪 Datenschutzeinstellungen │ +│ │ +│ ───────────────────────────────────────────────────────────────────── │ +│ │ +│ Wir respektieren Ihre Privatsphäre. Bitte wählen Sie, welche │ +│ Cookies Sie akzeptieren möchten. │ +│ │ +│ ☑ Essentiell (immer aktiv) ⓘ │ +│ Notwendig für die Grundfunktionen der Website. │ +│ │ +│ ☐ Funktional ⓘ │ +│ Personalisierung und Komfortfunktionen. │ +│ │ +│ ☐ Statistik ⓘ │ +│ Hilft uns, die Website zu verbessern. │ +│ │ +│ ☐ Marketing ⓘ │ +│ Personalisierte Werbung. │ +│ │ +│ ☐ Soziale Medien ⓘ │ +│ Inhalte von sozialen Netzwerken. │ +│ │ +│ ───────────────────────────────────────────────────────────────────── │ +│ │ +│ [Datenschutzerklärung] [Impressum] [Cookie-Details] │ +│ │ +│ ┌──────────────────┐ ┌──────────────────────────┐ │ +│ │ Auswahl speichern │ │ Alle akzeptieren │ │ +│ └──────────────────┘ └──────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Alle ablehnen │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 7.3 Farbschema + +```scss +// Light Theme +$consent-bg: #ffffff; +$consent-text: #1a1a1a; +$consent-secondary: #666666; +$consent-border: #e0e0e0; + +$button-accept-bg: #2563eb; // Blau (neutral, nicht grün) +$button-accept-text: #ffffff; + +$button-reject-bg: #ffffff; // Gleiche Prominenz! +$button-reject-text: #1a1a1a; +$button-reject-border: #2563eb; + +$button-settings-bg: #f3f4f6; +$button-settings-text: #1a1a1a; + +// Dark Theme +$consent-bg-dark: #1f2937; +$consent-text-dark: #f9fafb; +// ... +``` + +### 7.4 Barrierefreiheit (WCAG 2.1 AA) + +| Anforderung | Umsetzung | +|-------------|-----------| +| **Kontrast** | Mindestens 4.5:1 für Text | +| **Fokus** | Sichtbare Fokus-Indikatoren | +| **Tastatur** | Vollständig per Tastatur bedienbar | +| **Screen Reader** | ARIA-Labels für alle Elemente | +| **Zoom** | Funktioniert bei 200% Zoom | +| **Motion** | Respektiert `prefers-reduced-motion` | + +```html + + +``` + +### 7.5 Animationen + +```css +/* Respektiert Nutzereinstellung */ +@media (prefers-reduced-motion: reduce) { + .consent-banner { + animation: none; + transition: none; + } +} + +/* Dezente Einblendung */ +@media (prefers-reduced-motion: no-preference) { + .consent-banner { + animation: slideUp 0.3s ease-out; + } + + @keyframes slideUp { + from { + transform: translateY(100%); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } + } +} +``` + +### 7.6 Mehrsprachigkeit + +```typescript +const translations = { + de: { + title: 'Datenschutzeinstellungen', + description: 'Wir nutzen Cookies und ähnliche Technologien...', + acceptAll: 'Alle akzeptieren', + rejectAll: 'Alle ablehnen', + settings: 'Einstellungen', + saveSelection: 'Auswahl speichern', + categories: { + essential: { + name: 'Essentiell', + description: 'Notwendig für die Grundfunktionen.', + }, + analytics: { + name: 'Statistik', + description: 'Hilft uns, die Website zu verbessern.', + }, + // ... + }, + footer: { + privacyPolicy: 'Datenschutzerklärung', + imprint: 'Impressum', + cookieDetails: 'Cookie-Details', + }, + }, + en: { + title: 'Privacy Settings', + // ... + }, + fr: { ... }, + es: { ... }, + it: { ... }, + nl: { ... }, + pl: { ... }, + // Alle EU-Sprachen +}; +``` + +--- + +## 8. Plattform-Integrationen + +### 8.1 Google Tag Manager + +```javascript +// GTM-Template für Consent Mode v2 +consent.on('change', (consentState) => { + gtag('consent', 'update', { + 'ad_storage': consentState.marketing ? 'granted' : 'denied', + 'ad_user_data': consentState.marketing ? 'granted' : 'denied', + 'ad_personalization': consentState.marketing ? 'granted' : 'denied', + 'analytics_storage': consentState.analytics ? 'granted' : 'denied', + 'functionality_storage': consentState.functional ? 'granted' : 'denied', + 'personalization_storage': consentState.functional ? 'granted' : 'denied', + 'security_storage': 'granted', // Immer erlaubt + }); +}); +``` + +### 8.2 Meta (Facebook) Pixel + +```javascript +consent.on('change', (consentState) => { + if (typeof fbq !== 'undefined') { + if (consentState.marketing) { + fbq('consent', 'grant'); + } else { + fbq('consent', 'revoke'); + } + } +}); +``` + +### 8.3 Google Analytics 4 + +```javascript +// GA4 mit Consent Mode +consent.on('change', (consentState) => { + gtag('consent', 'update', { + 'analytics_storage': consentState.analytics ? 'granted' : 'denied', + }); +}); +``` + +### 8.4 IAB TCF 2.2 + +```javascript +// TCF API Integration +window.__tcfapi = window.__tcfapi || function() { + (window.__tcfapi.a = window.__tcfapi.a || []).push(arguments); +}; + +consent.on('change', (consentState, tcfString) => { + // TC String an CMP-API übergeben + __tcfapi('setConsentInfo', 2, (success) => { + if (success) { + console.log('TCF Consent updated'); + } + }, { tcString: tcfString }); +}); +``` + +### 8.5 WordPress Plugin + +```php + 192.168.1.0 + parsed[15] = 0 + } else { + // IPv6: Letzte 80 Bit auf 0 + for i := 6; i < 16; i++ { + parsed[i] = 0 + } + } + + // Hash für zusätzliche Sicherheit + return sha256(parsed.String()) +} +``` + +### 10.4 Verschlüsselung + +| Daten | Verschlüsselung | +|-------|-----------------| +| **In Transit** | TLS 1.3 | +| **At Rest** | AES-256-GCM | +| **Consent-Cookie** | HMAC-SHA256 signiert | +| **API-Keys** | bcrypt (Faktor 12) | + +### 10.5 Rate Limiting + +```go +// Pro IP-Adresse +var limiter = rate.NewLimiter( + rate.Every(time.Second), // 1 Request/Sekunde + 10, // Burst von 10 +) + +// Pro Site +var siteLimiter = rate.NewLimiter( + rate.Every(time.Millisecond * 100), // 10 Requests/Sekunde + 100, // Burst von 100 +) +``` + +### 10.6 CORS-Konfiguration + +```go +func corsMiddleware() gin.HandlerFunc { + return cors.New(cors.Config{ + AllowOrigins: []string{"https://*.example.com"}, + AllowMethods: []string{"GET", "POST", "DELETE"}, + AllowHeaders: []string{"Content-Type", "X-Consent-Signature"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + }) +} +``` + +--- + +## 11. Implementierungs-Roadmap + +### Phase 1: Core SDK (8 Wochen) + +| Woche | Aufgabe | +|-------|---------| +| 1-2 | Web SDK Basis (TypeScript) | +| 3-4 | Backend API (Go) | +| 5-6 | React/Vue Integration | +| 7-8 | Testing & Dokumentation | + +**Deliverables:** +- Web SDK v1.0.0 +- REST API v1.0.0 +- React/Vue Wrapper +- Dokumentation + +### Phase 2: Mobile SDKs (6 Wochen) + +| Woche | Aufgabe | +|-------|---------| +| 1-2 | iOS SDK (Swift) | +| 3-4 | Android SDK (Kotlin) | +| 5-6 | Testing & Dokumentation | + +**Deliverables:** +- iOS SDK v1.0.0 +- Android SDK v1.0.0 +- Beispiel-Apps + +### Phase 3: Cross-Platform (4 Wochen) + +| Woche | Aufgabe | +|-------|---------| +| 1-2 | Flutter SDK | +| 3-4 | React Native SDK | + +**Deliverables:** +- Flutter SDK v1.0.0 +- React Native SDK v1.0.0 + +### Phase 4: Enterprise Features (4 Wochen) + +| Woche | Aufgabe | +|-------|---------| +| 1 | TCF 2.2 Integration | +| 2 | A/B Testing | +| 3 | Analytics Dashboard | +| 4 | White-Label UI | + +**Deliverables:** +- TCF 2.2 Unterstützung +- Admin Dashboard +- A/B Testing Engine + +### Phase 5: Integrationen (4 Wochen) + +| Woche | Aufgabe | +|-------|---------| +| 1 | WordPress Plugin | +| 2 | Shopify App | +| 3 | GTM Template | +| 4 | Weitere CMS | + +--- + +## 12. Anhang + +### 12.1 Glossar + +| Begriff | Beschreibung | +|---------|--------------| +| **CMP** | Consent Management Platform | +| **TCF** | IAB Transparency & Consent Framework | +| **DSGVO** | Datenschutz-Grundverordnung (EU 2016/679) | +| **TTDSG** | Telekommunikation-Telemedien-Datenschutz-Gesetz | +| **DSK** | Datenschutzkonferenz (Deutschland) | +| **PWA** | Progressive Web App | +| **ATT** | App Tracking Transparency (Apple) | + +### 12.2 Referenzen + +- [DSGVO Volltext](https://eur-lex.europa.eu/legal-content/DE/TXT/?uri=CELEX:32016R0679) +- [TTDSG Volltext](https://www.gesetze-im-internet.de/ttdsg/) +- [DSK Orientierungshilfe Telemedien](https://www.datenschutzkonferenz-online.de/media/oh/20211220_oh_telemedien.pdf) +- [IAB TCF 2.2 Specification](https://iabeurope.eu/tcf-2-0/) +- [EuGH Planet49 Urteil](https://curia.europa.eu/juris/liste.jsf?num=C-673/17) +- [BGH Cookie-Einwilligung II](https://www.bundesgerichtshof.de/SharedDocs/Pressemitteilungen/DE/2023/2023103.html) +- [WCAG 2.1](https://www.w3.org/TR/WCAG21/) + +### 12.3 Lizenz + +``` +Copyright 2026 BreakPilot GmbH + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +``` + +### 12.4 Changelog + +| Version | Datum | Änderungen | +|---------|-------|------------| +| 1.0.0 | 2026-02-04 | Initiale Spezifikation | + +--- + +**Autor:** BreakPilot Engineering Team +**Review:** Datenschutzbeauftragter +**Freigabe:** Pending diff --git a/docs/guides/environment-setup.md b/docs/guides/environment-setup.md new file mode 100644 index 0000000..a1d6379 --- /dev/null +++ b/docs/guides/environment-setup.md @@ -0,0 +1,258 @@ +# Entwickler-Guide: Umgebungs-Setup + +Dieser Guide erklärt das tägliche Arbeiten mit den Dev/Staging/Prod-Umgebungen. + +## Schnellstart + +```bash +# 1. Wechsle in das Projektverzeichnis +cd /Users/benjaminadmin/Projekte/breakpilot-pwa + +# 2. Starte die Entwicklungsumgebung +./scripts/start.sh dev + +# 3. Prüfe den Status +./scripts/status.sh +``` + +## Täglicher Workflow + +### Morgens: Entwicklung starten + +```bash +# Auf develop-Branch wechseln +git checkout develop + +# Neueste Änderungen holen (falls Remote konfiguriert) +git pull origin develop + +# Umgebung starten +./scripts/start.sh dev +``` + +### Während der Arbeit + +```bash +# Logs eines Services anzeigen +docker compose logs -f backend + +# Service neustarten +docker compose restart backend + +# Status prüfen +./scripts/status.sh +``` + +### Änderungen committen + +```bash +# Änderungen anzeigen +git status + +# Dateien hinzufügen +git add . + +# Commit erstellen +git commit -m "Feature: Beschreibung der Änderung" +``` + +### Abends: Umgebung stoppen + +```bash +./scripts/stop.sh dev +``` + +## Umgebung wechseln + +### Von Dev zu Staging + +```bash +# Stoppe Dev +./scripts/stop.sh dev + +# Starte Staging +./scripts/start.sh staging +``` + +### Zurück zu Dev + +```bash +./scripts/stop.sh staging +./scripts/start.sh dev +``` + +## Code promoten + +### Dev → Staging (nach erfolgreichem Test) + +```bash +# Stelle sicher, dass alle Änderungen committet sind +git status + +# Promote zu Staging +./scripts/promote.sh dev-to-staging + +# Push zu Remote (falls konfiguriert) +git push origin staging +``` + +### Staging → Production (Release) + +```bash +# Nur nach vollständigem Test auf Staging! +./scripts/promote.sh staging-to-prod + +# Push zu Remote +git push origin main +``` + +## Nützliche Befehle + +### Docker + +```bash +# Alle Container anzeigen +docker compose ps + +# Logs folgen +docker compose logs -f [service] + +# In Container einsteigen +docker compose exec backend bash +docker compose exec postgres psql -U breakpilot -d breakpilot_dev + +# Container neustarten +docker compose restart [service] + +# Alle Container stoppen und entfernen +docker compose down + +# Mit Volumes löschen (VORSICHT!) +docker compose down -v +``` + +### Git + +```bash +# Aktuellen Branch anzeigen +git branch --show-current + +# Alle Branches anzeigen +git branch -v + +# Änderungen zwischen Branches anzeigen +git diff develop..staging +``` + +### Datenbank + +```bash +# Direkt mit PostgreSQL verbinden (Dev) +docker compose exec postgres psql -U breakpilot -d breakpilot_dev + +# Backup erstellen +./scripts/backup.sh + +# Backup wiederherstellen +./scripts/restore.sh backup-file.sql.gz +``` + +## Häufige Probleme + +### "Port already in use" + +Ein anderer Prozess oder Container verwendet den Port. + +```bash +# Laufende Container prüfen +docker ps + +# Alte Container stoppen +docker compose down + +# Prozess auf Port finden (z.B. 8000) +lsof -i :8000 +``` + +### Container startet nicht + +```bash +# Logs prüfen +docker compose logs backend + +# Container neu bauen +docker compose build backend +docker compose up -d backend +``` + +### Datenbank-Verbindungsfehler + +```bash +# Prüfen ob PostgreSQL läuft +docker compose ps postgres + +# PostgreSQL-Logs prüfen +docker compose logs postgres + +# Neustart +docker compose restart postgres +``` + +### Falsche Umgebung aktiv + +```bash +# Status prüfen +./scripts/status.sh + +# Auf richtige Umgebung wechseln +./scripts/env-switch.sh dev +``` + +## Umgebungs-Dateien + +| Datei | Beschreibung | Im Git? | +|-------|--------------|---------| +| `.env` | Aktive Umgebung | Nein | +| `.env.dev` | Development Werte | Ja | +| `.env.staging` | Staging Werte | Ja | +| `.env.prod` | Production Werte | **NEIN** | +| `.env.example` | Template | Ja | + +## Ports Übersicht + +### Development + +| Service | Port | URL | +|---------|------|-----| +| Backend | 8000 | http://localhost:8000 | +| Website | 3000 | http://localhost:3000 | +| Consent Service | 8081 | http://localhost:8081 | +| PostgreSQL | 5432 | localhost:5432 | +| Mailpit UI | 8025 | http://localhost:8025 | +| MinIO Console | 9001 | http://localhost:9001 | + +### Staging + +| Service | Port | URL | +|---------|------|-----| +| Backend | 8001 | http://localhost:8001 | +| PostgreSQL | 5433 | localhost:5433 | +| Mailpit UI | 8026 | http://localhost:8026 | +| MinIO Console | 9003 | http://localhost:9003 | + +## Hilfe + +```bash +# Status und Übersicht +./scripts/status.sh + +# Script-Hilfe +./scripts/env-switch.sh --help +./scripts/promote.sh --help +``` + +## Verwandte Dokumentation + +- [Architektur: Umgebungen](../architecture/environments.md) +- [Secrets Management](../architecture/secrets-management.md) +- [System-Architektur](../architecture/system-architecture.md) diff --git a/docs/klausur-modul/DEVELOPER_SPECIFICATION.md b/docs/klausur-modul/DEVELOPER_SPECIFICATION.md new file mode 100644 index 0000000..9d2921f --- /dev/null +++ b/docs/klausur-modul/DEVELOPER_SPECIFICATION.md @@ -0,0 +1,1177 @@ +# Klausur-Modul - Vollständige Entwicklerspezifikation + +**Version:** 2.0 +**Stand:** Januar 2025 +**Autor:** BreakPilot Development Team + +--- + +## Inhaltsverzeichnis + +1. [Übersicht](#1-übersicht) +2. [Systemarchitektur](#2-systemarchitektur) +3. [Datenmodelle](#3-datenmodelle) +4. [API-Spezifikation](#4-api-spezifikation) +5. [Frontend-Architektur](#5-frontend-architektur) +6. [Sicherheit & Compliance](#6-sicherheit--compliance) +7. [Testing-Strategie](#7-testing-strategie) +8. [Deployment & Operations](#8-deployment--operations) +9. [Entwicklungsrichtlinien](#9-entwicklungsrichtlinien) + +--- + +## 1. Übersicht + +### 1.1 Modulbeschreibung + +Das Klausur-Modul ist ein umfassendes System für die digitale Korrektur, Bewertung und Verwaltung von Abitur- und Vorabiturklausuren. Es besteht aus folgenden Kernkomponenten: + +| Komponente | Beschreibung | Technologie | +|------------|--------------|-------------| +| Klausur-Service Backend | Hauptservice für alle Klausur-Operationen | Python FastAPI | +| BYOEH (Bring Your Own EH) | Erwartungshorizont-Management mit RAG | Qdrant, MinIO | +| Zeugnisse-Modul | Verordnungen und KI-Assistent | Crawler, Embeddings | +| Training-Modul | KI-Modell Training & Monitoring | Background Tasks | +| Frontend | Admin & Lehrer Oberflächen | Next.js, React | + +### 1.2 Technologie-Stack + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Frontend Layer │ +│ Next.js 15 │ React 18 │ TypeScript │ Tailwind CSS │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ API Gateway Layer │ +│ Next.js API Routes │ Server-Side Proxy │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Backend Services │ +│ Klausur-Service (FastAPI) │ Port 8086 │ +│ ├── main.py (Klausur CRUD, BYOEH) │ +│ ├── admin_api.py (NiBiS Ingestion) │ +│ ├── zeugnis_api.py (Zeugnisse Crawler) │ +│ ├── training_api.py (Training Management) │ +│ └── metrics_db.py (PostgreSQL Operations) │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ +┌──────────────────┐ ┌──────────────┐ ┌────────────────┐ +│ PostgreSQL │ │ Qdrant │ │ MinIO │ +│ (Metadata) │ │ (Vectors) │ │ (Documents) │ +│ Port 5432 │ │ Port 6333 │ │ Port 9000 │ +└──────────────────┘ └──────────────┘ └────────────────┘ +``` + +### 1.3 Kernfunktionen + +1. **Klausurverwaltung** + - Erstellen/Bearbeiten von Klausuren + - Upload von Schülerarbeiten + - Kriterien-basierte Bewertung + - Gutachten-Generierung + +2. **BYOEH - Erwartungshorizont** + - Upload & Verschlüsselung von EH-Dokumenten + - Chunking & Embedding-Generierung + - RAG-basierte Suche + - Tenant-Isolation + +3. **Zeugnisse** + - Rights-Aware Crawler für Verordnungen + - KI-Assistent für Lehrer + - Bundesland-spezifische Suche + +4. **Training** + - Modell-Training mit Monitoring + - Hyperparameter-Konfiguration + - Versions-Management + +--- + +## 2. Systemarchitektur + +### 2.1 Microservice-Architektur + +``` + ┌───────────────────┐ + │ Load Balancer │ + │ (Nginx) │ + └─────────┬─────────┘ + │ + ┌─────────────────────┼─────────────────────┐ + ▼ ▼ ▼ +┌───────────────┐ ┌───────────────┐ ┌───────────────┐ +│ Website │ │ Backend │ │ Klausur-Svc │ +│ (Next.js) │ │ (FastAPI) │ │ (FastAPI) │ +│ Port 3000 │ │ Port 8000 │ │ Port 8086 │ +└───────────────┘ └───────────────┘ └───────────────┘ + │ │ │ + └─────────────────────┴─────────────────────┘ + │ + ┌─────────┴─────────┐ + │ Service Mesh │ + │ (Docker Net) │ + └─────────┬─────────┘ + │ + ┌─────────────┬───────┴───────┬─────────────┐ + ▼ ▼ ▼ ▼ + PostgreSQL Qdrant MinIO Mailpit +``` + +### 2.2 Datenfluss + +#### 2.2.1 Klausur-Korrektur Flow + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Upload │────▶│ OCR │────▶│ Analyse │────▶│ Bewertung│ +│ Arbeit │ │ (extern) │ │ (LLM) │ │ Kriterien│ +└──────────┘ └──────────┘ └──────────┘ └──────────┘ + │ + ▼ +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Export │◀────│ Finalize │◀────│Gutachten │◀────│ RAG-EH │ +│ PDF │ │ │ │ Generate │ │ Query │ +└──────────┘ └──────────┘ └──────────┘ └──────────┘ +``` + +#### 2.2.2 Zeugnis-Crawler Flow + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Seed URL │────▶│ Fetch │────▶│ Extract │────▶│ Check │ +│ (Config) │ │ HTTP │ │ PDF/HTML │ │ Rights │ +└──────────┘ └──────────┘ └──────────┘ └──────────┘ + │ + ┌───────────────┤ + ▼ ▼ + ┌──────────┐ ┌──────────┐ + │ MinIO │ │ Qdrant │ + │ (Store) │ │ (Index) │ + └──────────┘ └──────────┘ +``` + +### 2.3 Komponenten-Details + +#### 2.3.1 Klausur-Service (main.py) + +| Endpunkt | Methode | Beschreibung | +|----------|---------|--------------| +| `/api/v1/klausuren` | GET | Liste aller Klausuren | +| `/api/v1/klausuren` | POST | Neue Klausur erstellen | +| `/api/v1/klausuren/{id}` | GET | Klausur-Details | +| `/api/v1/klausuren/{id}` | PUT | Klausur aktualisieren | +| `/api/v1/klausuren/{id}` | DELETE | Klausur löschen | +| `/api/v1/klausuren/{id}/students` | POST | Schülerarbeit hinzufügen | +| `/api/v1/students/{id}/criteria` | PUT | Kriterien bewerten | +| `/api/v1/students/{id}/gutachten` | PUT | Gutachten speichern | +| `/api/v1/students/{id}/gutachten/generate` | POST | Gutachten generieren | + +#### 2.3.2 BYOEH (eh_pipeline.py, qdrant_service.py) + +| Endpunkt | Methode | Beschreibung | +|----------|---------|--------------| +| `/api/v1/eh/upload` | POST | EH hochladen | +| `/api/v1/eh/{id}/index` | POST | EH indexieren | +| `/api/v1/eh/rag-query` | POST | RAG-Suche | +| `/api/v1/eh/{id}/share` | POST | EH teilen | +| `/api/v1/eh/{id}/link-klausur` | POST | EH mit Klausur verknüpfen | + +#### 2.3.3 Zeugnis-Modul (zeugnis_api.py) + +| Endpunkt | Methode | Beschreibung | +|----------|---------|--------------| +| `/api/v1/admin/zeugnis/sources` | GET | Bundesländer-Quellen | +| `/api/v1/admin/zeugnis/crawler/start` | POST | Crawler starten | +| `/api/v1/admin/zeugnis/crawler/stop` | POST | Crawler stoppen | +| `/api/v1/admin/zeugnis/documents` | GET | Dokumente abrufen | +| `/api/v1/admin/zeugnis/stats` | GET | Statistiken | + +#### 2.3.4 Training-Modul (training_api.py) + +| Endpunkt | Methode | Beschreibung | +|----------|---------|--------------| +| `/api/v1/admin/training/jobs` | GET | Training-Jobs | +| `/api/v1/admin/training/jobs` | POST | Training starten | +| `/api/v1/admin/training/jobs/{id}/pause` | POST | Pausieren | +| `/api/v1/admin/training/jobs/{id}/resume` | POST | Fortsetzen | +| `/api/v1/admin/training/models` | GET | Modell-Versionen | + +--- + +## 3. Datenmodelle + +### 3.1 PostgreSQL Schema + +#### 3.1.1 Kern-Tabellen (metrics_db.py) + +```sql +-- RAG Feedback +CREATE TABLE rag_search_feedback ( + id SERIAL PRIMARY KEY, + result_id VARCHAR(255) NOT NULL, + query_text TEXT, + collection_name VARCHAR(100), + score FLOAT, + rating INTEGER CHECK (rating >= 1 AND rating <= 5), + notes TEXT, + user_id VARCHAR(100), + created_at TIMESTAMP DEFAULT NOW() +); + +-- RAG Search Logs +CREATE TABLE rag_search_logs ( + id SERIAL PRIMARY KEY, + query_text TEXT NOT NULL, + collection_name VARCHAR(100), + result_count INTEGER, + latency_ms INTEGER, + top_score FLOAT, + filters JSONB, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Relevanz-Judgments für Precision/Recall +CREATE TABLE rag_relevance_judgments ( + id SERIAL PRIMARY KEY, + query_id VARCHAR(255) NOT NULL, + query_text TEXT NOT NULL, + result_id VARCHAR(255) NOT NULL, + result_rank INTEGER, + is_relevant BOOLEAN NOT NULL, + collection_name VARCHAR(100), + user_id VARCHAR(100), + created_at TIMESTAMP DEFAULT NOW() +); +``` + +#### 3.1.2 Zeugnis-Tabellen + +```sql +-- Bundesland-Quellen +CREATE TABLE zeugnis_sources ( + id VARCHAR(36) PRIMARY KEY, + bundesland VARCHAR(10) NOT NULL, + name VARCHAR(255) NOT NULL, + base_url TEXT, + license_type VARCHAR(50) NOT NULL, + training_allowed BOOLEAN DEFAULT FALSE, + verified_by VARCHAR(100), + verified_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Seed URLs +CREATE TABLE zeugnis_seed_urls ( + id VARCHAR(36) PRIMARY KEY, + source_id VARCHAR(36) REFERENCES zeugnis_sources(id), + url TEXT NOT NULL, + doc_type VARCHAR(50), + status VARCHAR(20) DEFAULT 'pending', + last_crawled TIMESTAMP, + error_message TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Dokumente +CREATE TABLE zeugnis_documents ( + id VARCHAR(36) PRIMARY KEY, + seed_url_id VARCHAR(36) REFERENCES zeugnis_seed_urls(id), + title VARCHAR(500), + url TEXT NOT NULL, + content_hash VARCHAR(64), + minio_path TEXT, + training_allowed BOOLEAN DEFAULT FALSE, + indexed_in_qdrant BOOLEAN DEFAULT FALSE, + file_size INTEGER, + content_type VARCHAR(100), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- Dokument-Versionen +CREATE TABLE zeugnis_document_versions ( + id VARCHAR(36) PRIMARY KEY, + document_id VARCHAR(36) REFERENCES zeugnis_documents(id), + version INTEGER NOT NULL, + content_hash VARCHAR(64), + minio_path TEXT, + change_summary TEXT, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Usage Events (Audit Trail) +CREATE TABLE zeugnis_usage_events ( + id VARCHAR(36) PRIMARY KEY, + document_id VARCHAR(36) REFERENCES zeugnis_documents(id), + event_type VARCHAR(50) NOT NULL, + user_id VARCHAR(100), + details JSONB, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Crawler Queue +CREATE TABLE zeugnis_crawler_queue ( + id VARCHAR(36) PRIMARY KEY, + source_id VARCHAR(36) REFERENCES zeugnis_sources(id), + priority INTEGER DEFAULT 5, + status VARCHAR(20) DEFAULT 'pending', + started_at TIMESTAMP, + completed_at TIMESTAMP, + documents_found INTEGER DEFAULT 0, + documents_indexed INTEGER DEFAULT 0, + error_count INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW() +); +``` + +### 3.2 In-Memory Modelle (Python Dataclasses) + +#### 3.2.1 Klausur-Modelle + +```python +@dataclass +class StudentKlausur: + id: str + klausur_id: str + student_name: str + status: StudentKlausurStatus # Enum + criteria_scores: Dict[str, Dict] + gutachten: Optional[Dict] + file_path: Optional[str] + ocr_text: Optional[str] + created_at: datetime + updated_at: datetime + +@dataclass +class Klausur: + id: str + title: str + subject: str + modus: KlausurModus # LANDES_ABITUR, VORABITUR + year: int + semester: str + erwartungshorizont: Optional[Dict] + students: List[StudentKlausur] + created_at: datetime + tenant_id: str +``` + +#### 3.2.2 Zeugnis-Modelle (zeugnis_models.py) + +```python +class LicenseType(str, Enum): + PUBLIC_DOMAIN = "public_domain" + CC_BY = "cc_by" + CC_BY_SA = "cc_by_sa" + CC_BY_NC = "cc_by_nc" + GOV_STATUTE_FREE_USE = "gov_statute" + ALL_RIGHTS_RESERVED = "all_rights" + UNKNOWN_REQUIRES_REVIEW = "unknown" + +class CrawlStatus(str, Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + PAUSED = "paused" + +class ZeugnisSource(BaseModel): + id: str + bundesland: str + name: str + base_url: Optional[str] + license_type: LicenseType + training_allowed: bool + verified_by: Optional[str] + verified_at: Optional[datetime] +``` + +### 3.3 Qdrant Collections + +#### 3.3.1 BYOEH Collection (bp_eh) + +```python +# Collection Config +collection_name = "bp_eh" +vector_size = 384 # all-MiniLM-L6-v2 +distance = Distance.COSINE + +# Payload Schema +{ + "tenant_id": str, # Tenant-Isolation + "eh_id": str, # Erwartungshorizont ID + "chunk_index": int, # Position im Dokument + "subject": str, # Fach + "encrypted_content": str, # AES-256-GCM encrypted + "training_allowed": bool, # IMMER False für EH +} +``` + +#### 3.3.2 Zeugnis Collection (bp_zeugnis) + +```python +# Collection Config +collection_name = "bp_zeugnis" +vector_size = 384 # all-MiniLM-L6-v2 +distance = Distance.COSINE + +# Payload Schema +{ + "document_id": str, + "chunk_index": int, + "chunk_text": str, # Preview (max 500 chars) + "bundesland": str, + "doc_type": str, + "title": str, + "source_url": str, + "training_allowed": bool, # Von Source geerbt + "indexed_at": str, +} +``` + +### 3.4 MinIO Bucket-Struktur + +``` +breakpilot-rag/ +├── landes-daten/ +│ ├── {bundesland}/ +│ │ └── zeugnis/ +│ │ └── {year}/ +│ │ └── {filename}.pdf +│ └── klausur/ +│ └── {year}/ +│ └── {subject}/ +│ └── {filename}.pdf +│ +└── lehrer-daten/ + └── {tenant_id}/ + └── {teacher_id}/ + └── {filename}.pdf.enc +``` + +--- + +## 4. API-Spezifikation + +### 4.1 Authentifizierung + +Alle API-Endpunkte erfordern JWT-Authentifizierung: + +```http +Authorization: Bearer +``` + +JWT-Payload: +```json +{ + "sub": "user-id", + "tenant_id": "school-id", + "roles": ["teacher", "admin"], + "exp": 1704067200 +} +``` + +### 4.2 Fehlerbehandlung + +#### Standard-Fehlerformat + +```json +{ + "detail": "Beschreibung des Fehlers", + "code": "ERROR_CODE", + "timestamp": "2024-01-01T10:00:00Z" +} +``` + +#### HTTP Status Codes + +| Code | Bedeutung | Verwendung | +|------|-----------|------------| +| 200 | OK | Erfolgreiche Anfrage | +| 201 | Created | Ressource erstellt | +| 400 | Bad Request | Ungültige Eingabe | +| 401 | Unauthorized | Authentifizierung fehlt | +| 403 | Forbidden | Keine Berechtigung | +| 404 | Not Found | Ressource nicht gefunden | +| 409 | Conflict | Ressourcenkonflikt | +| 422 | Unprocessable | Validierungsfehler | +| 500 | Internal Error | Serverfehler | +| 503 | Unavailable | Service nicht verfügbar | + +### 4.3 Pagination + +```http +GET /api/v1/resource?limit=20&offset=0 +``` + +Response: +```json +{ + "items": [...], + "total": 100, + "limit": 20, + "offset": 0, + "has_more": true +} +``` + +### 4.4 Rate Limiting + +| Endpunkt-Typ | Limit | +|--------------|-------| +| Standard API | 100/min | +| RAG Query | 30/min | +| Upload | 10/min | +| Training Start | 5/hour | + +--- + +## 5. Frontend-Architektur + +### 5.1 Verzeichnisstruktur + +``` +website/ +├── app/ +│ ├── admin/ +│ │ ├── training/ +│ │ │ └── page.tsx # Training Dashboard +│ │ ├── zeugnisse-crawler/ +│ │ │ └── page.tsx # Crawler Admin +│ │ ├── rag/ +│ │ │ └── page.tsx # RAG Admin +│ │ └── uni-crawler/ +│ │ └── page.tsx # Uni Crawler +│ │ +│ ├── zeugnisse/ +│ │ └── page.tsx # Lehrer-Frontend +│ │ +│ └── api/ +│ └── admin/ +│ ├── zeugnisse-crawler/ +│ │ └── route.ts # API Proxy +│ └── training/ +│ └── route.ts # API Proxy +│ +├── components/ +│ ├── ui/ # Basis-Komponenten +│ └── shared/ # Geteilte Komponenten +│ +└── lib/ + ├── api.ts # API Client + └── utils.ts # Hilfsfunktionen +``` + +### 5.2 Komponenten-Hierarchie + +``` +App +├── Layout +│ ├── Header +│ │ ├── Navigation +│ │ └── UserMenu +│ └── Sidebar +│ +├── Training Dashboard Page +│ ├── StatsCards +│ ├── TrainingJobCard +│ │ ├── ProgressRing +│ │ ├── MetricCards +│ │ └── LossChart +│ ├── DatasetOverview +│ └── NewTrainingModal (Wizard) +│ +├── Zeugnisse Crawler Page +│ ├── StatsCards +│ ├── BundeslandTable +│ ├── DocumentList +│ └── CrawlerControls +│ +└── Lehrer Zeugnisse Page + ├── OnboardingWizard + ├── ChatInterface + │ └── MessageList + ├── SearchInterface + │ └── SearchResults + └── DocumentBrowser +``` + +### 5.3 State Management + +#### Local State (useState) +- UI-Zustand (Modals, Tabs) +- Formular-Eingaben +- Lokale Filter + +#### Server State (SWR/Fetch) +- API-Daten mit Polling +- Caching +- Revalidierung + +#### Persisted State (localStorage) +- Benutzereinstellungen +- Letzte Suchen +- Wizard-Status + +### 5.4 Styling-Konventionen + +```typescript +// Tailwind CSS Klassennamen +const buttonPrimary = "px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition" +const buttonSecondary = "px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition" +const card = "bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700" +const input = "px-3 py-2 bg-gray-100 dark:bg-gray-900 border-0 rounded-lg focus:ring-2 focus:ring-blue-500" +``` + +--- + +## 6. Sicherheit & Compliance + +### 6.1 Authentifizierung & Autorisierung + +#### JWT-Validierung + +```python +def verify_jwt(token: str) -> dict: + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + return payload + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="Token expired") + except jwt.InvalidTokenError: + raise HTTPException(status_code=401, detail="Invalid token") +``` + +#### RBAC Rollen + +| Rolle | Berechtigungen | +|-------|----------------| +| teacher | Klausuren erstellen, EH hochladen, Zeugnis-Assistent | +| admin | + Crawler steuern, Training starten | +| superadmin | + System-Konfiguration | + +### 6.2 Datenverschlüsselung + +#### AES-256-GCM für EH-Dokumente + +```python +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +def encrypt_text(plaintext: str, passphrase: str) -> tuple: + salt = os.urandom(16) + iv = os.urandom(12) + key = derive_key(passphrase, salt) + cipher = AESGCM(key) + ciphertext = cipher.encrypt(iv, plaintext.encode(), None) + return base64.b64encode(salt + iv + ciphertext).decode(), hash_key(key) +``` + +### 6.3 Tenant-Isolation + +- Alle Datenbankabfragen filtern nach `tenant_id` +- Qdrant-Suchen mit `tenant_id` Filter +- MinIO-Pfade enthalten `tenant_id` + +### 6.4 Audit-Trail + +```python +async def log_event(event_type: str, resource_id: str, user_id: str, details: dict): + await log_zeugnis_event( + document_id=resource_id, + event_type=event_type, + user_id=user_id, + details=details, + ) +``` + +### 6.5 DSGVO-Compliance + +- Datenexport-Funktion +- Lösch-Anfragen +- Einwilligungs-Tracking +- Protokollierung aller Zugriffe + +--- + +## 7. Testing-Strategie + +### 7.1 Test-Pyramide + +``` + /\ + / \ + / E2E \ <- 5% (Critical Paths) + /------\ + / Integ \ <- 25% (API, DB) + /----------\ + / Unit \ <- 70% (Functions) + /--------------\ +``` + +### 7.2 Unit Tests (Python) + +**Speicherort:** `klausur-service/backend/tests/` + +```python +# tests/test_zeugnis_models.py +import pytest +from zeugnis_models import ( + LicenseType, get_training_allowed, get_bundesland_name +) + +class TestTrainingPermissions: + def test_niedersachsen_allows_training(self): + assert get_training_allowed("ni") == True + + def test_berlin_disallows_training(self): + assert get_training_allowed("be") == False + + def test_unknown_bundesland_disallows(self): + assert get_training_allowed("xx") == False + + +class TestBundeslandNames: + def test_valid_code_returns_name(self): + assert get_bundesland_name("ni") == "Niedersachsen" + + def test_invalid_code_returns_code(self): + assert get_bundesland_name("xx") == "xx" +``` + +```python +# tests/test_zeugnis_crawler.py +import pytest +from zeugnis_crawler import chunk_text, compute_hash, extract_text_from_pdf + +class TestChunking: + def test_short_text_single_chunk(self): + text = "Dies ist ein kurzer Text." + chunks = chunk_text(text, chunk_size=100) + assert len(chunks) == 1 + + def test_long_text_multiple_chunks(self): + text = "A" * 2000 + chunks = chunk_text(text, chunk_size=500, overlap=50) + assert len(chunks) > 1 + + def test_overlap_preserved(self): + text = "ABCDE" * 200 + chunks = chunk_text(text, chunk_size=100, overlap=20) + for i in range(1, len(chunks)): + assert chunks[i][:20] == chunks[i-1][-20:] + + +class TestHashing: + def test_same_content_same_hash(self): + content = b"Hello World" + assert compute_hash(content) == compute_hash(content) + + def test_different_content_different_hash(self): + assert compute_hash(b"Hello") != compute_hash(b"World") +``` + +### 7.3 Integration Tests + +```python +# tests/test_zeugnis_api_integration.py +import pytest +from httpx import AsyncClient +from main import app + +@pytest.fixture +async def client(): + async with AsyncClient(app=app, base_url="http://test") as ac: + yield ac + +@pytest.mark.asyncio +class TestZeugnisAPI: + async def test_get_sources_returns_list(self, client): + response = await client.get("/api/v1/admin/zeugnis/sources") + assert response.status_code == 200 + assert isinstance(response.json(), list) + + async def test_start_crawler_without_running(self, client): + response = await client.post( + "/api/v1/admin/zeugnis/crawler/start", + json={"bundesland": "ni"} + ) + assert response.status_code == 200 + + async def test_start_crawler_while_running_fails(self, client): + # First start + await client.post("/api/v1/admin/zeugnis/crawler/start") + # Second start should fail + response = await client.post("/api/v1/admin/zeugnis/crawler/start") + assert response.status_code == 409 +``` + +### 7.4 E2E Tests (Playwright) + +```typescript +// tests/e2e/zeugnisse.spec.ts +import { test, expect } from '@playwright/test' + +test.describe('Zeugnis-Assistent', () => { + test('onboarding wizard completes successfully', async ({ page }) => { + await page.goto('/zeugnisse') + + // Step 1: Welcome + await expect(page.locator('h2')).toContainText('Willkommen') + await page.click('button:has-text("Weiter")') + + // Step 2: Select Bundesland + await page.click('button:has-text("Niedersachsen")') + await page.click('button:has-text("Weiter")') + + // Step 3: Select Schulform + await page.click('button:has-text("Gymnasium")') + await page.click('button:has-text("Weiter")') + + // Step 4: Complete + await page.click('button:has-text("Loslegen")') + + // Verify main interface + await expect(page.locator('h1')).toContainText('Zeugnis-Assistent') + }) + + test('chat interface responds to questions', async ({ page }) => { + // Skip wizard (set localStorage) + await page.goto('/zeugnisse') + await page.evaluate(() => { + localStorage.setItem('zeugnis-preferences', JSON.stringify({ + bundesland: 'ni', + schulform: 'gymnasium', + hasSeenWizard: true, + })) + }) + await page.reload() + + // Send message + await page.fill('textarea', 'Wie schreibe ich Bemerkungen?') + await page.click('button[type="submit"]') + + // Wait for response + await expect(page.locator('.bg-white.rounded-2xl').last()) + .toContainText('Bemerkung', { timeout: 10000 }) + }) +}) +``` + +### 7.5 Test-Ausführung + +```bash +# Unit Tests (Python) +cd klausur-service/backend +pytest -v tests/ + +# Mit Coverage +pytest --cov=. --cov-report=html tests/ + +# E2E Tests (Playwright) +cd website +npx playwright test + +# Alle Tests +./run-tests.sh +``` + +--- + +## 8. Deployment & Operations + +### 8.1 Docker Compose + +```yaml +# docker-compose.yml (Auszug) +services: + klausur-service: + build: + context: ./klausur-service + dockerfile: Dockerfile + ports: + - "8086:8086" + environment: + - JWT_SECRET=${JWT_SECRET} + - QDRANT_URL=http://qdrant:6333 + - MINIO_ENDPOINT=minio:9000 + - DATABASE_URL=postgres://breakpilot:breakpilot123@postgres:5432/breakpilot_db + depends_on: + - qdrant + - minio + - postgres + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8086/health"] + interval: 30s + timeout: 10s + retries: 3 +``` + +### 8.2 Dockerfile (Klausur-Service) + +```dockerfile +FROM python:3.11-slim + +WORKDIR /app + +# System dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + libpq-dev \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Application code +COPY backend/ . + +# Create directories +RUN mkdir -p /app/uploads /app/eh-uploads + +EXPOSE 8086 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8086"] +``` + +### 8.3 Monitoring + +#### Health Checks + +```python +@app.get("/health") +async def health(): + return { + "status": "healthy", + "service": "klausur-service", + "version": "2.0", + "timestamp": datetime.now().isoformat(), + } + +@app.get("/health/detailed") +async def health_detailed(): + # Check dependencies + qdrant_ok = await check_qdrant() + postgres_ok = await check_postgres() + minio_ok = await check_minio() + + return { + "status": "healthy" if all([qdrant_ok, postgres_ok, minio_ok]) else "degraded", + "dependencies": { + "qdrant": "ok" if qdrant_ok else "error", + "postgres": "ok" if postgres_ok else "error", + "minio": "ok" if minio_ok else "error", + } + } +``` + +#### Prometheus Metrics + +```python +from prometheus_client import Counter, Histogram + +# Metriken +request_count = Counter('klausur_requests_total', 'Total requests', ['endpoint', 'method']) +request_latency = Histogram('klausur_request_latency_seconds', 'Request latency', ['endpoint']) +training_jobs = Counter('klausur_training_jobs_total', 'Training jobs', ['status']) +``` + +### 8.4 Logging + +```python +import logging + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) + +logger = logging.getLogger("klausur-service") + +# Strukturiertes Logging +logger.info("Training started", extra={ + "job_id": job_id, + "bundeslaender": config.bundeslaender, + "epochs": config.epochs, +}) +``` + +--- + +## 9. Entwicklungsrichtlinien + +### 9.1 Code-Style + +#### Python + +```python +# Imports: Standard → Third-Party → Local +import os +from datetime import datetime + +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel + +from zeugnis_models import LicenseType + +# Docstrings: Google Style +def process_document(content: bytes, doc_type: str) -> dict: + """Process a document for indexing. + + Args: + content: Raw document bytes. + doc_type: Type of document (pdf, html). + + Returns: + Dict with extracted text and metadata. + + Raises: + ValueError: If doc_type is not supported. + """ + pass + +# Type Hints: Immer verwenden +async def get_sources( + bundesland: Optional[str] = None, + limit: int = 100, +) -> List[Dict[str, Any]]: + pass +``` + +#### TypeScript + +```typescript +// Interfaces über Types bevorzugen +interface TrainingJob { + id: string + name: string + status: TrainingStatus +} + +// Props-Interface für Komponenten +interface TrainingCardProps { + job: TrainingJob + onPause: () => void + onResume: () => void +} + +// Funktionskomponenten mit expliziten Typen +export function TrainingCard({ job, onPause, onResume }: TrainingCardProps) { + return (...) +} +``` + +### 9.2 Git-Workflow + +``` +main + │ + ├── develop + │ │ + │ ├── feature/zeugnis-crawler + │ ├── feature/training-dashboard + │ └── fix/crawler-retry + │ + └── release/v2.0 +``` + +#### Commit-Messages + +``` +feat(zeugnis): add rights-aware crawler + +- Implement PDF/HTML text extraction +- Add training_allowed flag per bundesland +- Create audit trail for document access + +Closes #123 +``` + +### 9.3 Review-Checkliste + +- [ ] Tests vorhanden und bestanden +- [ ] Dokumentation aktualisiert +- [ ] Type-Hints/Interfaces vollständig +- [ ] Keine Hardcoded Credentials +- [ ] Error Handling implementiert +- [ ] Logging vorhanden +- [ ] Performance akzeptabel + +### 9.4 Versionierung + +Semantic Versioning: `MAJOR.MINOR.PATCH` + +- MAJOR: Breaking Changes +- MINOR: Neue Features (rückwärtskompatibel) +- PATCH: Bug Fixes + +--- + +## Anhang A: Umgebungsvariablen + +```env +# Authentifizierung +JWT_SECRET=your-super-secret-key + +# Datenbanken +DATABASE_URL=postgres://user:pass@host:5432/db +QDRANT_URL=http://qdrant:6333 +MINIO_ENDPOINT=minio:9000 +MINIO_ACCESS_KEY=breakpilot +MINIO_SECRET_KEY=breakpilot123 +MINIO_BUCKET=breakpilot-rag + +# Embeddings +EMBEDDING_BACKEND=local # oder "openai" +OPENAI_API_KEY=sk-... # Falls openai + +# Services +BACKEND_URL=http://backend:8000 +SCHOOL_SERVICE_URL=http://school-service:8084 + +# Feature Flags +BYOEH_ENCRYPTION_ENABLED=true +BYOEH_CHUNK_SIZE=1000 +BYOEH_CHUNK_OVERLAP=200 +``` + +--- + +## Anhang B: Schnellreferenz + +### API-Basis-URLs + +| Umgebung | URL | +|----------|-----| +| Lokal | http://localhost:8086 | +| Entwicklung | https://dev.breakpilot.app | +| Produktion | https://api.breakpilot.app | + +### Wichtige Befehle + +```bash +# Service starten +docker-compose up -d klausur-service + +# Logs anzeigen +docker logs -f breakpilot-pwa-klausur-service + +# Tests ausführen +docker exec klausur-service pytest tests/ + +# DB-Migration +docker exec postgres psql -U breakpilot -d breakpilot_db -f /migration.sql +``` + +--- + +*Letzte Aktualisierung: Januar 2025* diff --git a/docs/klausur-modul/MAIL-DEVELOPER-GUIDE.md b/docs/klausur-modul/MAIL-DEVELOPER-GUIDE.md new file mode 100644 index 0000000..0d4cf24 --- /dev/null +++ b/docs/klausur-modul/MAIL-DEVELOPER-GUIDE.md @@ -0,0 +1,463 @@ +# Unified Inbox - Developer Guide + +**Version:** 1.0.0 +**Datum:** 2026-01-10 +**Status:** Implementiert + +--- + +## 1. Architektur-Übersicht + +Das Unified Inbox System besteht aus folgenden Komponenten: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ FRONTEND │ +├─────────────────────────────────────────────────────────────────┤ +│ website/app/admin/mail/page.tsx - Admin UI │ +│ website/app/mail/page.tsx - User Inbox │ +│ website/app/mail/tasks/page.tsx - Arbeitsvorrat │ +└───────────────────────────┬─────────────────────────────────────┘ + │ HTTP (Port 8086) + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ KLAUSUR-SERVICE │ +├─────────────────────────────────────────────────────────────────┤ +│ klausur-service/backend/mail/ │ +│ ├── __init__.py - Module exports │ +│ ├── models.py - Pydantic Models & Enums │ +│ ├── mail_db.py - PostgreSQL operations (asyncpg) │ +│ ├── credentials.py - Vault/encrypted credential storage │ +│ ├── aggregator.py - IMAP/SMTP multi-account sync │ +│ ├── ai_service.py - LLM-powered email analysis │ +│ ├── task_service.py - Arbeitsvorrat (task management) │ +│ └── api.py - FastAPI router (30+ endpoints) │ +└───────────────────────────┬─────────────────────────────────────┘ + │ + ┌───────────────────┼───────────────────┐ + ▼ ▼ ▼ +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ PostgreSQL │ │ Vault │ │ LLM Gateway │ +│ (Port 5432) │ │ (Port 8200) │ │ (Port 8000) │ +└──────────────┘ └──────────────┘ └──────────────┘ +``` + +--- + +## 2. Schnellstart + +### 2.1 Service starten + +```bash +# Klausur-Service starten +cd klausur-service/backend +uvicorn main:app --host 0.0.0.0 --port 8086 --reload + +# Frontend starten +cd website +npm run dev +``` + +### 2.2 API testen + +```bash +# Health Check +curl http://localhost:8086/api/v1/mail/health + +# Konto hinzufügen (mit Auth) +curl -X POST http://localhost:8086/api/v1/mail/accounts \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "email": "schulleitung@example.de", + "imap_host": "imap.example.de", + "imap_port": 993, + "smtp_host": "smtp.example.de", + "smtp_port": 587, + "username": "schulleitung", + "password": "secret" + }' + +# Inbox abrufen +curl http://localhost:8086/api/v1/mail/inbox \ + -H "Authorization: Bearer $TOKEN" + +# E-Mail analysieren +curl -X POST http://localhost:8086/api/v1/mail/analyze/{email_id} \ + -H "Authorization: Bearer $TOKEN" +``` + +--- + +## 3. Module im Detail + +### 3.1 Models (`models.py`) + +#### Enums + +```python +class SenderType(str, Enum): + """Klassifizierung des Absender-Typs.""" + KULTUSMINISTERIUM = "kultusministerium" + LANDESSCHULBEHOERDE = "landesschulbehoerde" + RLSB = "rlsb" + SCHULAMT = "schulamt" + NIBIS = "nibis" + SCHULTRAEGER = "schultraeger" + ELTERNVERTRETER = "elternvertreter" + GEWERKSCHAFT = "gewerkschaft" + FORTBILDUNGSINSTITUT = "fortbildungsinstitut" + PRIVATPERSON = "privatperson" + UNTERNEHMEN = "unternehmen" + UNBEKANNT = "unbekannt" + +class TaskPriority(str, Enum): + """Aufgaben-Priorität.""" + URGENT = "urgent" # Sofort bearbeiten + HIGH = "high" # Zeitnah bearbeiten + MEDIUM = "medium" # Normale Bearbeitung + LOW = "low" # Kann warten +``` + +#### Bekannte Behörden (Niedersachsen) + +```python +KNOWN_AUTHORITIES_NI = { + "@mk.niedersachsen.de": { + "type": SenderType.KULTUSMINISTERIUM, + "name": "Kultusministerium Niedersachsen", + "priority_boost": 2, + }, + "@rlsb.de": { + "type": SenderType.RLSB, + "name": "Regionales Landesamt für Schule und Bildung", + "priority_boost": 2, + }, + "@landesschulbehoerde-nds.de": { + "type": SenderType.LANDESSCHULBEHOERDE, + "name": "Landesschulbehörde Niedersachsen", + "priority_boost": 2, + }, + "@nibis.de": { + "type": SenderType.NIBIS, + "name": "Niedersächsischer Bildungsserver", + "priority_boost": 1, + }, +} +``` + +### 3.2 Database (`mail_db.py`) + +#### Tabellen + +| Tabelle | Beschreibung | +|---------|--------------| +| `external_email_accounts` | IMAP/SMTP-Konfiguration | +| `aggregated_emails` | Gecachte E-Mails | +| `inbox_tasks` | Extrahierte Aufgaben | +| `email_templates` | Antwortvorlagen | +| `mail_audit_log` | Audit-Trail | +| `mail_sync_status` | Sync-Status pro Konto | + +#### Beispiel: E-Mail speichern + +```python +from mail.mail_db import store_email + +email_id = await store_email( + account_id="acc_123", + message_id="", + subject="Einladung zur Fortbildung", + sender_email="info@mk.niedersachsen.de", + sender_name="Kultusministerium", + recipients=["schulleitung@example.de"], + date=datetime.now(), + body_preview="Sehr geehrte Schulleitung...", + body_text="Vollständiger Text...", + has_attachments=True, + attachment_count=2, + user_id="user_456", + tenant_id="tenant_789", +) +``` + +### 3.3 Credentials (`credentials.py`) + +Sichere Speicherung von IMAP/SMTP-Passwörtern: + +```python +from mail.credentials import get_credentials_service + +creds = get_credentials_service() + +# Passwort speichern (verschlüsselt) +await creds.store_credentials( + account_id="acc_123", + username="user@example.de", + password="secret123" +) + +# Passwort abrufen +username, password = await creds.get_credentials("acc_123") +``` + +**Verschlüsselung:** +- Produktion: HashiCorp Vault (KV v2) +- Entwicklung: Fernet-Verschlüsselung mit lokalem Key + +### 3.4 Aggregator (`aggregator.py`) + +IMAP-Synchronisation und SMTP-Versand: + +```python +from mail.aggregator import get_mail_aggregator + +aggregator = get_mail_aggregator() + +# Verbindung testen +result = await aggregator.test_account_connection(account_id) +if result.success: + print(f"Verbunden: {result.folder_count} Ordner") + +# Konto synchronisieren +synced = await aggregator.sync_account( + account_id="acc_123", + user_id="user_456", + tenant_id="tenant_789", + full_sync=False # Nur neue E-Mails +) +print(f"{synced} neue E-Mails synchronisiert") + +# E-Mail senden +await aggregator.send_email( + account_id="acc_123", + to_addresses=["empfaenger@example.de"], + subject="Antwort", + body_html="

              Vielen Dank für Ihre Anfrage...

              " +) +``` + +### 3.5 AI Service (`ai_service.py`) + +KI-gestützte E-Mail-Analyse: + +```python +from mail.ai_service import get_ai_email_service + +ai = get_ai_email_service() + +# Vollständige Analyse +result = await ai.analyze_email(email_id, user_id) + +print(f"Absender: {result.sender.sender_type}") +print(f"Kategorie: {result.category}") +print(f"Priorität: {result.suggested_priority}") +print(f"Fristen: {len(result.deadlines)}") +for deadline in result.deadlines: + print(f" - {deadline.deadline_date}: {deadline.description}") +``` + +#### Absender-Klassifikation + +1. **Domain-Matching:** Bekannte Behörden-Domains +2. **LLM-Fallback:** Bei unbekannten Absendern + +#### Fristen-Erkennung + +1. **Regex-Patterns:** Deutsche Datumsformate +2. **LLM-Verstärkung:** Kontextuelle Deadline-Erkennung + +### 3.6 Task Service (`task_service.py`) + +Arbeitsvorrat-Management: + +```python +from mail.task_service import get_task_service + +tasks = get_task_service() + +# Task aus E-Mail erstellen +task_id = await tasks.create_task_from_email( + user_id="user_456", + tenant_id="tenant_789", + email_id="email_123", + deadlines=[...], # Aus AI-Analyse + sender_type=SenderType.KULTUSMINISTERIUM, + auto_created=True +) + +# Dashboard-Statistiken +stats = await tasks.get_dashboard_stats(user_id) +print(f"Gesamt: {stats.total_tasks}") +print(f"Überfällig: {stats.overdue_tasks}") +print(f"Heute fällig: {stats.due_today}") + +# Überfällige Tasks +overdue = await tasks.get_overdue_tasks(user_id) +for task in overdue: + print(f"ÜBERFÄLLIG: {task['title']}") +``` + +#### Prioritäts-Berechnung + +```python +# Absender-basierte Priorität +KULTUSMINISTERIUM → HIGH +NIBIS → MEDIUM +PRIVATPERSON → LOW + +# Deadline-Anpassung +≤ 1 Tag → URGENT +≤ 3 Tage → min. HIGH +≤ 7 Tage → min. MEDIUM +``` + +--- + +## 4. API-Endpoints + +### 4.1 Account Management + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| `POST` | `/api/v1/mail/accounts` | Konto hinzufügen | +| `GET` | `/api/v1/mail/accounts` | Alle Konten | +| `GET` | `/api/v1/mail/accounts/{id}` | Einzelnes Konto | +| `DELETE` | `/api/v1/mail/accounts/{id}` | Konto entfernen | +| `POST` | `/api/v1/mail/accounts/{id}/test` | Verbindung testen | +| `POST` | `/api/v1/mail/accounts/{id}/sync` | Konto synchronisieren | + +### 4.2 Unified Inbox + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| `GET` | `/api/v1/mail/inbox` | Aggregierte Inbox | +| `GET` | `/api/v1/mail/inbox/{id}` | Einzelne E-Mail | +| `POST` | `/api/v1/mail/inbox/{id}/read` | Als gelesen markieren | +| `POST` | `/api/v1/mail/send` | E-Mail senden | + +### 4.3 AI Analysis + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| `POST` | `/api/v1/mail/analyze/{id}` | E-Mail analysieren | +| `GET` | `/api/v1/mail/suggestions/{id}` | Antwortvorschläge | + +### 4.4 Tasks (Arbeitsvorrat) + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| `GET` | `/api/v1/mail/tasks` | Alle Tasks | +| `POST` | `/api/v1/mail/tasks` | Manuelle Task | +| `GET` | `/api/v1/mail/tasks/{id}` | Einzelne Task | +| `PATCH` | `/api/v1/mail/tasks/{id}` | Task aktualisieren | +| `POST` | `/api/v1/mail/tasks/{id}/complete` | Als erledigt markieren | +| `GET` | `/api/v1/mail/tasks/dashboard` | Dashboard-Stats | + +--- + +## 5. Tests + +### 5.1 Unit Tests ausführen + +```bash +cd klausur-service/backend +pytest tests/test_mail_service.py -v +``` + +### 5.2 Testabdeckung + +```bash +pytest tests/test_mail_service.py --cov=mail --cov-report=html +``` + +### 5.3 Testfälle + +- **Absender-Erkennung:** Behörden-Domains +- **Prioritäts-Berechnung:** Sender-basiert, Deadline-basiert +- **Fristen-Extraktion:** Deutsche Datumsformate +- **Kategorie-Regeln:** Fortbildung, Personal, Finanzen + +--- + +## 6. Konfiguration + +### 6.1 Umgebungsvariablen + +```env +# Datenbank +DATABASE_URL=postgresql://user:pass@localhost:5432/breakpilot + +# Vault (Produktion) +VAULT_ADDR=http://vault:8200 +VAULT_TOKEN=hvs.xxxxx + +# Entwicklung (ohne Vault) +MAIL_ENCRYPTION_KEY=base64-encoded-fernet-key + +# LLM Gateway +LLM_GATEWAY_URL=http://localhost:8000 +DEFAULT_MODEL=breakpilot-teacher-8b +``` + +### 6.2 Playbook-Konfiguration + +Das Mail-Analyse-Playbook ist in `/backend/llm_gateway/services/playbook_service.py` definiert: + +```python +"mail_analysis": Playbook( + id="mail_analysis", + name="E-Mail-Analyse", + system_prompt="...", # Niedersachsen-spezifisch + recommended_models=["breakpilot-teacher-8b"], +) +``` + +--- + +## 7. Erweiterung + +### 7.1 Neue Bundesländer hinzufügen + +1. `KNOWN_AUTHORITIES_{KÜRZEL}` in `models.py` definieren +2. Domain-Patterns für Behörden hinzufügen +3. Playbook-Prompt anpassen + +### 7.2 Neue Kategorien hinzufügen + +1. `EmailCategory` Enum erweitern +2. Regeln in `ai_service._apply_category_rules()` hinzufügen +3. Frontend-Labels in `page.tsx` ergänzen + +--- + +## 8. Troubleshooting + +### IMAP-Verbindungsfehler + +``` +IMAPConnectionError: Connection refused +``` +→ Port/SSL-Einstellungen prüfen, Firewall-Regeln + +### Vault-Authentifizierungsfehler + +``` +VaultError: permission denied +``` +→ Token prüfen, Policy für `secret/mail/*` erforderlich + +### LLM-Timeout + +``` +HTTPException: 504 Gateway Timeout +``` +→ LLM Gateway Status prüfen, Modell-Verfügbarkeit + +--- + +## 9. Referenzen + +- [UNIFIED-INBOX-SPECIFICATION.md](./UNIFIED-INBOX-SPECIFICATION.md) - Vollständige Spezifikation +- [MAIL-RBAC-DEVELOPER-SPECIFICATION.md](./MAIL-RBAC-DEVELOPER-SPECIFICATION.md) - DSGVO & RBAC Details +- [mail-rbac-architecture.md](../architecture/mail-rbac-architecture.md) - Architektur-Dokument diff --git a/docs/klausur-modul/MAIL-RBAC-DEVELOPER-SPECIFICATION.md b/docs/klausur-modul/MAIL-RBAC-DEVELOPER-SPECIFICATION.md new file mode 100644 index 0000000..20db75f --- /dev/null +++ b/docs/klausur-modul/MAIL-RBAC-DEVELOPER-SPECIFICATION.md @@ -0,0 +1,1834 @@ +# Mail-RBAC Developer Specification + +**Version:** 1.0.0 +**Datum:** 2026-01-10 +**Status:** Entwicklungsspezifikation +**Autor:** BreakPilot Development Team + +--- + +## 1. Executive Summary + +Dieses Dokument spezifiziert die technische Implementierung eines DSGVO-konformen Mail-Systems mit rollenbasierter Zugriffskontrolle (RBAC) und Mitarbeiter-Anonymisierungsfunktion. + +### 1.1 Projektziele + +| Ziel | Beschreibung | Priorität | +|------|--------------|-----------| +| **Rollenbasierte E-Mail** | Funktionale Mailboxen statt personengebundener Adressen | P0 | +| **DSGVO-Anonymisierung** | Vollständige Anonymisierung bei Mitarbeiter-Ausscheiden | P0 | +| **Audit-Trail** | Lückenlose Nachverfolgbarkeit aller E-Mail-Aktionen | P0 | +| **Kalender-Integration** | CalDAV mit Jitsi-Meeting-Links | P1 | +| **Groupware-UI** | Webmail-Interface für Mitarbeiter | P1 | + +### 1.2 Nicht-Ziele (Out of Scope) + +- Exchange/Outlook-Protokoll-Kompatibilität +- Mobile Push-Notifications (Phase 2) +- Externe E-Mail-Domain-Routing (Phase 2) + +--- + +## 2. DSGVO-Konformität + +### 2.1 Rechtliche Grundlagen + +| Artikel | Anforderung | Umsetzung | +|---------|-------------|-----------| +| **Art. 5 DSGVO** | Datenminimierung | Nur notwendige Metadaten speichern | +| **Art. 17 DSGVO** | Recht auf Löschung | Anonymisierungs-Workflow | +| **Art. 20 DSGVO** | Datenportabilität | MBOX/EML Export | +| **Art. 30 DSGVO** | Verarbeitungsverzeichnis | Automatische Protokollierung | +| **Art. 32 DSGVO** | Sicherheit | Verschlüsselung at-rest & in-transit | + +### 2.2 Datenschutz-Prinzipien + +``` +┌─────────────────────────────────────────────────────────────┐ +│ DATENSCHUTZ-BY-DESIGN │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ 1. TRENNUNG: Person ≠ Rolle ≠ Mailbox │ +│ ├── Person: Max Mustermann (anonymisierbar) │ +│ ├── Rolle: klassenlehrer (persistent) │ +│ └── Mailbox: klassenlehrer.5a@... (rollengebunden) │ +│ │ +│ 2. MINIMIERUNG: Nur speichern was nötig │ +│ ├── E-Mail-Inhalte: Verschlüsselt │ +│ ├── Metadaten: Nur Subject-Hash, keine Namen │ +│ └── Audit: Rollenbasiert, nicht personenbasiert │ +│ │ +│ 3. LÖSCHBARKEIT: Jederzeit anonymisierbar │ +│ ├── Personendaten: Pseudonymisierung │ +│ ├── E-Mail-Archive: Header-Anonymisierung │ +│ └── Audit-Trail: Bleibt für Nachvollziehbarkeit │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.3 Aufbewahrungsfristen + +| Datentyp | Frist | Rechtsgrundlage | +|----------|-------|-----------------| +| E-Mail-Inhalte | 10 Jahre | § 147 AO (Geschäftskorrespondenz) | +| Audit-Logs | 10 Jahre | § 257 HGB | +| Personenbezogene Daten | Bis Löschungsantrag | Art. 17 DSGVO | +| Anonymisierte Daten | Unbegrenzt | Keine personenbezogenen Daten | + +--- + +## 3. Systemarchitektur + +### 3.1 Komponenten-Übersicht + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ BREAKPILOT MAIL-RBAC │ +├──────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ PRESENTATION LAYER │ │ +│ ├────────────────────────────────────────────────────────────┤ │ +│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ +│ │ │ Admin UI │ │ Webmail UI │ │ Calendar UI │ │ │ +│ │ │ (Next.js) │ │ (SOGo) │ │ (SOGo) │ │ │ +│ │ │ :3000 │ │ :20000 │ │ :20000 │ │ │ +│ │ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ │ +│ └─────────┼─────────────────┼─────────────────┼──────────────┘ │ +│ │ │ │ │ +│ ┌─────────┴─────────────────┴─────────────────┴──────────────┐ │ +│ │ APPLICATION LAYER │ │ +│ ├────────────────────────────────────────────────────────────┤ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────────┐ │ │ +│ │ │ RBAC-MAIL-BRIDGE (Python) │ │ │ +│ │ │ Port: 8087 │ │ │ +│ │ ├─────────────────────────────────────────────────────┤ │ │ +│ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ +│ │ │ │ Mailbox │ │ Anonymizer │ │ Audit │ │ │ │ +│ │ │ │ Manager │ │ Service │ │ Logger │ │ │ │ +│ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │ +│ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ +│ │ │ │ RBAC │ │ Calendar │ │ Jitsi │ │ │ │ +│ │ │ │ Sync │ │ Bridge │ │ Integrator │ │ │ │ +│ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │ +│ │ └─────────────────────────────────────────────────────┘ │ │ +│ │ │ │ │ +│ │ ┌────────────────────────┼────────────────────────────┐ │ │ +│ │ │ │ │ │ │ +│ │ │ ┌──────────────┐ ┌───┴────────┐ ┌─────────────┐ │ │ │ +│ │ │ │ Stalwart │ │ Existing │ │ MinIO │ │ │ │ +│ │ │ │ Mail Server │ │ RBAC API │ │ Storage │ │ │ │ +│ │ │ │ :25/:143/:993│ │ :8000 │ │ :9000 │ │ │ │ +│ │ │ └──────────────┘ └────────────┘ └─────────────┘ │ │ │ +│ │ └────────────────────────────────────────────────────┘ │ │ +│ └────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ DATA LAYER │ │ +│ ├────────────────────────────────────────────────────────────┤ │ +│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ +│ │ │ PostgreSQL │ │ Stalwart │ │ MinIO │ │ │ +│ │ │ (RBAC Data) │ │ (Mail Data) │ │ (Attachments)│ │ │ +│ │ │ :5432 │ │ Internal │ │ :9000 │ │ │ +│ │ └──────────────┘ └──────────────┘ └──────────────┘ │ │ +│ └────────────────────────────────────────────────────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +### 3.2 Datenfluss + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ E-MAIL SENDEN (OUTBOUND) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. Lehrer klickt "Senden" in Webmail │ +│ │ │ +│ ▼ │ +│ 2. SOGo → Stalwart SMTP (Port 25) │ +│ │ ┌──────────────────────────────────────┐ │ +│ │ │ From: klassenlehrer.5a@schule.bp.app │ │ +│ │ │ (Rollenbasiert, nicht personenbasiert)│ │ +│ │ └──────────────────────────────────────┘ │ +│ ▼ │ +│ 3. Stalwart → RBAC-Mail-Bridge (Milter Hook) │ +│ │ ┌──────────────────────────────────────┐ │ +│ │ │ - Validiere Absender-Berechtigung │ │ +│ │ │ - Logge Audit-Event │ │ +│ │ │ - Füge X-BP-Role Header hinzu │ │ +│ │ └──────────────────────────────────────┘ │ +│ ▼ │ +│ 4. Stalwart → Internet (MX Lookup) │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ E-MAIL EMPFANGEN (INBOUND) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. Internet → Stalwart SMTP (Port 25) │ +│ │ │ +│ ▼ │ +│ 2. Stalwart → RBAC-Mail-Bridge (Milter Hook) │ +│ │ ┌──────────────────────────────────────┐ │ +│ │ │ - Identifiziere Ziel-Mailbox │ │ +│ │ │ - Lookup aktuelle Rollenzuweisung │ │ +│ │ │ - Logge Audit-Event │ │ +│ │ └──────────────────────────────────────┘ │ +│ ▼ │ +│ 3. Stalwart → Mailbox Store (IMAP) │ +│ │ │ +│ ▼ │ +│ 4. SOGo zeigt E-Mail an (für zugewiesene Person) │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 4. Datenmodell + +### 4.1 Entity-Relationship-Diagramm + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ MAIL-RBAC DATENMODELL │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌───────────────┐ ┌───────────────────┐ │ +│ │ users │ │ functional_ │ │ +│ │ (bestehend) │ │ mailboxes │ │ +│ ├───────────────┤ ├───────────────────┤ │ +│ │ id (PK) │ │ id (PK) │ │ +│ │ email │ │ role_key (FK) │──────┐ │ +│ │ name │ │ email_address │ │ │ +│ │ is_active │ │ display_name │ │ │ +│ │ anonymized_at │ │ tenant_id (FK) │ │ │ +│ └───────┬───────┘ │ is_active │ │ │ +│ │ └─────────┬─────────┘ │ │ +│ │ │ │ │ +│ │ ┌────────────────────┘ │ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌───────────────────┐ ┌───────────────────┐ │ +│ │ mailbox_ │ │ roles │ │ +│ │ assignments │ │ (bestehend) │ │ +│ ├───────────────────┤ ├───────────────────┤ │ +│ │ id (PK) │ │ role_key (PK) │ │ +│ │ mailbox_id (FK) │───────────────│ display_name │ │ +│ │ user_id (FK) │ │ category │ │ +│ │ valid_from │ └───────────────────┘ │ +│ │ valid_to │ │ +│ │ assigned_by (FK) │ │ +│ │ revoked_at │ │ +│ └─────────┬─────────┘ │ +│ │ │ +│ │ ┌────────────────────────────────────────────┐ │ +│ │ │ │ │ +│ ▼ ▼ │ │ +│ ┌───────────────────┐ ┌───────────────────┐ │ │ +│ │ email_audit_ │ │ anonymization_ │ │ │ +│ │ trail │ │ log │ │ │ +│ ├───────────────────┤ ├───────────────────┤ │ │ +│ │ id (PK) │ │ id (PK) │ │ │ +│ │ mailbox_id (FK) │ │ entity_type │ │ │ +│ │ direction │ │ entity_id │ │ │ +│ │ subject_hash │ │ anonymization_type│ │ │ +│ │ timestamp │ │ fields_affected │ │ │ +│ │ external_domain │ │ reason │ │ │ +│ │ role_key │ │ performed_by (FK) │◄────────┘ │ +│ └───────────────────┘ │ performed_at │ │ +│ │ legal_basis │ │ +│ └───────────────────┘ │ +│ │ +└───────────────────────────────────────────────────────────────────┘ +``` + +### 4.2 SQL Schema + +```sql +-- ============================================================ +-- MAIL-RBAC SCHEMA +-- Version: 1.0.0 +-- ============================================================ + +-- Erweiterung der bestehenden users-Tabelle +ALTER TABLE users ADD COLUMN IF NOT EXISTS + anonymized_at TIMESTAMP; +ALTER TABLE users ADD COLUMN IF NOT EXISTS + anonymization_token VARCHAR(64); + +-- Funktionale Mailboxen (rollengebunden) +CREATE TABLE functional_mailboxes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Rollen-Verknüpfung + role_key VARCHAR(100) NOT NULL, + email_address VARCHAR(255) UNIQUE NOT NULL, + display_name VARCHAR(255) NOT NULL, + + -- Tenant/Schule + tenant_id UUID NOT NULL REFERENCES tenants(id), + resource_type VARCHAR(50) DEFAULT 'class', + resource_id VARCHAR(100), + + -- Stalwart Mailbox ID (nach Erstellung) + stalwart_mailbox_id VARCHAR(255), + + -- Status + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW(), + created_by UUID REFERENCES users(id), + + -- Indizes + CONSTRAINT fk_tenant FOREIGN KEY (tenant_id) + REFERENCES tenants(id) ON DELETE CASCADE +); + +CREATE INDEX idx_fm_role ON functional_mailboxes(role_key); +CREATE INDEX idx_fm_tenant ON functional_mailboxes(tenant_id); +CREATE INDEX idx_fm_email ON functional_mailboxes(email_address); + +-- Mailbox-Zuweisungen (Person ↔ Funktionale Mailbox) +CREATE TABLE mailbox_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Verknüpfungen + mailbox_id UUID NOT NULL REFERENCES functional_mailboxes(id), + user_id UUID NOT NULL REFERENCES users(id), + + -- Gültigkeitszeitraum + valid_from TIMESTAMP DEFAULT NOW(), + valid_to TIMESTAMP, + + -- Audit + assigned_by UUID REFERENCES users(id), + assigned_at TIMESTAMP DEFAULT NOW(), + revoked_by UUID REFERENCES users(id), + revoked_at TIMESTAMP, + revocation_reason VARCHAR(255), + + -- Constraints + CONSTRAINT unique_active_mailbox_assignment + EXCLUDE USING gist ( + mailbox_id WITH =, + tsrange(valid_from, COALESCE(valid_to, 'infinity'::timestamp)) WITH && + ) WHERE (revoked_at IS NULL) +); + +CREATE INDEX idx_ma_mailbox ON mailbox_assignments(mailbox_id); +CREATE INDEX idx_ma_user ON mailbox_assignments(user_id); +CREATE INDEX idx_ma_active ON mailbox_assignments(revoked_at) WHERE revoked_at IS NULL; + +-- E-Mail Audit Trail (DSGVO-konform, ohne personenbezogene Daten) +CREATE TABLE email_audit_trail ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Mailbox-Referenz (nicht Person!) + mailbox_id UUID REFERENCES functional_mailboxes(id), + + -- E-Mail-Metadaten (anonymisiert) + direction VARCHAR(10) NOT NULL CHECK (direction IN ('inbound', 'outbound')), + message_id_hash VARCHAR(64), -- SHA-256 der Message-ID + subject_hash VARCHAR(64), -- SHA-256 des Betreffs + timestamp TIMESTAMP NOT NULL, + + -- Externe Partei (nur Domain, nicht volle Adresse) + external_party_domain VARCHAR(255), + + -- Rolle zum Zeitpunkt (für Nachvollziehbarkeit) + role_key VARCHAR(100) NOT NULL, + + -- Keine personenbezogenen Daten! + -- Die Person ist nur über mailbox_assignments nachvollziehbar + + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_eat_mailbox ON email_audit_trail(mailbox_id); +CREATE INDEX idx_eat_timestamp ON email_audit_trail(timestamp); +CREATE INDEX idx_eat_role ON email_audit_trail(role_key); + +-- Anonymisierungsprotokoll +CREATE TABLE anonymization_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Was wurde anonymisiert + entity_type VARCHAR(50) NOT NULL CHECK (entity_type IN ( + 'user', 'email_account', 'email_content', 'calendar_event' + )), + entity_id UUID NOT NULL, + + -- Wie + anonymization_type VARCHAR(50) NOT NULL CHECK (anonymization_type IN ( + 'pseudonymization', 'deletion', 'header_anonymization' + )), + fields_affected JSONB NOT NULL, + + -- Warum + reason VARCHAR(100) NOT NULL CHECK (reason IN ( + 'employee_departure', 'dsgvo_request', 'retention_expiry', 'manual' + )), + + -- Audit + performed_by UUID NOT NULL REFERENCES users(id), + performed_at TIMESTAMP DEFAULT NOW(), + + -- Rechtliche Dokumentation + legal_basis VARCHAR(255), + retention_period_days INTEGER, + confirmation_token VARCHAR(64) +); + +CREATE INDEX idx_al_entity ON anonymization_log(entity_type, entity_id); +CREATE INDEX idx_al_performed ON anonymization_log(performed_at); + +-- Kalender-Events mit Jitsi-Integration +CREATE TABLE calendar_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Event-Daten + title VARCHAR(255) NOT NULL, + description TEXT, + start_time TIMESTAMP NOT NULL, + end_time TIMESTAMP NOT NULL, + location VARCHAR(255), + + -- Jitsi-Integration + jitsi_room_id VARCHAR(100), + jitsi_url TEXT, + + -- Organisator (Mailbox, nicht Person) + organizer_mailbox_id UUID REFERENCES functional_mailboxes(id), + + -- CalDAV-Synchronisation + caldav_uid VARCHAR(255) UNIQUE, + caldav_etag VARCHAR(100), + + -- Status + is_cancelled BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_ce_organizer ON calendar_events(organizer_mailbox_id); +CREATE INDEX idx_ce_time ON calendar_events(start_time, end_time); + +-- Kalender-Teilnehmer +CREATE TABLE calendar_attendees ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES calendar_events(id) ON DELETE CASCADE, + mailbox_id UUID REFERENCES functional_mailboxes(id), + external_email VARCHAR(255), + status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ( + 'pending', 'accepted', 'declined', 'tentative' + )), + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_ca_event ON calendar_attendees(event_id); +``` + +--- + +## 5. API-Spezifikation + +### 5.1 REST API Endpoints + +```yaml +# ============================================================ +# MAIL-RBAC API SPECIFICATION +# OpenAPI 3.0 +# ============================================================ + +openapi: 3.0.3 +info: + title: BreakPilot Mail-RBAC API + version: 1.0.0 + description: DSGVO-konformes Mail-System mit Rollen-Integration + +servers: + - url: http://localhost:8087/api/v1 + description: Development + +paths: + # ==================== MAILBOXEN ==================== + + /mailboxes: + get: + summary: Liste aller funktionalen Mailboxen + tags: [Mailboxes] + parameters: + - name: tenant_id + in: query + schema: + type: string + format: uuid + - name: role_key + in: query + schema: + type: string + responses: + 200: + description: Mailbox-Liste + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/FunctionalMailbox' + + post: + summary: Erstellt eine neue funktionale Mailbox + tags: [Mailboxes] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateMailboxRequest' + responses: + 201: + description: Mailbox erstellt + content: + application/json: + schema: + $ref: '#/components/schemas/FunctionalMailbox' + + /mailboxes/{mailbox_id}: + get: + summary: Details einer Mailbox + tags: [Mailboxes] + parameters: + - name: mailbox_id + in: path + required: true + schema: + type: string + format: uuid + responses: + 200: + description: Mailbox-Details + content: + application/json: + schema: + $ref: '#/components/schemas/FunctionalMailboxDetail' + + /mailboxes/{mailbox_id}/assign: + post: + summary: Weist Mailbox einem Benutzer zu + tags: [Mailboxes] + parameters: + - name: mailbox_id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AssignMailboxRequest' + responses: + 201: + description: Zuweisung erstellt + content: + application/json: + schema: + $ref: '#/components/schemas/MailboxAssignment' + + /mailboxes/{mailbox_id}/revoke: + post: + summary: Widerruft Mailbox-Zuweisung + tags: [Mailboxes] + parameters: + - name: mailbox_id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + user_id: + type: string + format: uuid + reason: + type: string + responses: + 200: + description: Zuweisung widerrufen + + # ==================== ANONYMISIERUNG ==================== + + /users/{user_id}/anonymize: + post: + summary: Anonymisiert einen Benutzer vollständig + tags: [Anonymization] + description: | + Führt folgende Schritte aus: + 1. Widerruft alle Mailbox-Zuweisungen + 2. Anonymisiert Benutzerdaten + 3. Anonymisiert E-Mail-Header + 4. Erstellt Audit-Log + parameters: + - name: user_id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AnonymizationRequest' + responses: + 200: + description: Anonymisierung erfolgreich + content: + application/json: + schema: + $ref: '#/components/schemas/AnonymizationResult' + + /users/{user_id}/anonymize/preview: + get: + summary: Vorschau der Anonymisierung + tags: [Anonymization] + description: Zeigt was anonymisiert werden würde + parameters: + - name: user_id + in: path + required: true + schema: + type: string + format: uuid + responses: + 200: + description: Anonymisierungs-Vorschau + content: + application/json: + schema: + $ref: '#/components/schemas/AnonymizationPreview' + + # ==================== AUDIT ==================== + + /audit/logs: + get: + summary: Audit-Log abrufen + tags: [Audit] + parameters: + - name: mailbox_id + in: query + schema: + type: string + format: uuid + - name: from + in: query + schema: + type: string + format: date-time + - name: to + in: query + schema: + type: string + format: date-time + responses: + 200: + description: Audit-Einträge + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AuditLogEntry' + + /audit/anonymizations: + get: + summary: Anonymisierungsprotokoll + tags: [Audit] + responses: + 200: + description: Anonymisierungen + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AnonymizationLogEntry' + + /audit/export: + get: + summary: DSGVO-Export + tags: [Audit] + parameters: + - name: user_id + in: query + required: true + schema: + type: string + format: uuid + responses: + 200: + description: DSGVO-Datenexport + content: + application/json: + schema: + $ref: '#/components/schemas/GDPRExport' + + # ==================== KALENDER ==================== + + /calendar/events: + post: + summary: Erstellt einen Kalender-Eintrag mit optionalem Jitsi-Meeting + tags: [Calendar] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateCalendarEventRequest' + responses: + 201: + description: Event erstellt + content: + application/json: + schema: + $ref: '#/components/schemas/CalendarEvent' + +components: + schemas: + FunctionalMailbox: + type: object + properties: + id: + type: string + format: uuid + role_key: + type: string + example: "klassenlehrer" + email_address: + type: string + format: email + example: "klassenlehrer.5a@schule.breakpilot.app" + display_name: + type: string + example: "Klassenlehrer 5a" + is_active: + type: boolean + current_assignee: + $ref: '#/components/schemas/UserSummary' + + CreateMailboxRequest: + type: object + required: + - role_key + - email_address + - display_name + - tenant_id + properties: + role_key: + type: string + email_address: + type: string + format: email + display_name: + type: string + tenant_id: + type: string + format: uuid + resource_type: + type: string + enum: [class, department, function] + resource_id: + type: string + + AnonymizationRequest: + type: object + required: + - reason + - confirmed + properties: + reason: + type: string + enum: [employee_departure, dsgvo_request, manual] + confirmed: + type: boolean + description: Muss true sein für Durchführung + preserve_audit_trail: + type: boolean + default: true + delete_email_content: + type: boolean + default: false + + AnonymizationResult: + type: object + properties: + success: + type: boolean + anonymization_id: + type: string + format: uuid + affected_mailboxes: + type: integer + affected_emails: + type: integer + audit_log_entry_id: + type: string + format: uuid +``` + +### 5.2 Internal Events (Message Queue) + +```python +# Für zukünftige Skalierung: Event-basierte Kommunikation + +class MailRBACEvent: + """Base Event für Mail-RBAC System""" + event_type: str + timestamp: datetime + correlation_id: str + +class MailboxAssignedEvent(MailRBACEvent): + """Wird ausgelöst wenn eine Mailbox zugewiesen wird""" + event_type = "mailbox.assigned" + mailbox_id: str + user_id: str + role_key: str + +class MailboxRevokedEvent(MailRBACEvent): + """Wird ausgelöst wenn eine Mailbox-Zuweisung widerrufen wird""" + event_type = "mailbox.revoked" + mailbox_id: str + user_id: str + reason: str + +class UserAnonymizedEvent(MailRBACEvent): + """Wird ausgelöst wenn ein Benutzer anonymisiert wird""" + event_type = "user.anonymized" + user_id: str + anonymization_id: str + affected_mailboxes: List[str] + +class EmailSentEvent(MailRBACEvent): + """Wird ausgelöst wenn eine E-Mail gesendet wird""" + event_type = "email.sent" + mailbox_id: str + message_id_hash: str + external_domain: str + +class CalendarEventCreatedEvent(MailRBACEvent): + """Wird ausgelöst wenn ein Kalender-Event erstellt wird""" + event_type = "calendar.created" + event_id: str + organizer_mailbox_id: str + has_jitsi: bool +``` + +--- + +## 6. Implementierungsdetails + +### 6.1 Anonymisierungs-Service + +```python +# rbac_mail_bridge/services/anonymizer.py + +from dataclasses import dataclass +from datetime import datetime +from typing import List, Optional +import hashlib +import secrets + +@dataclass +class AnonymizationResult: + success: bool + anonymization_id: str + affected_mailboxes: int + affected_emails: int + audit_log_id: str + errors: List[str] + +class EmployeeAnonymizer: + """ + DSGVO-konforme Anonymisierung von Mitarbeiterdaten. + + Prinzipien: + 1. Keine personenbezogenen Daten in Audit-Logs + 2. Rollenbasierte Historie bleibt erhalten + 3. Verschlüsselung der Original-Daten für Auskunftsrechte + """ + + def __init__( + self, + db: AsyncSession, + mail_server: StalwartClient, + encryption_service: EncryptionService + ): + self.db = db + self.mail_server = mail_server + self.encryption = encryption_service + + async def anonymize( + self, + user_id: str, + reason: str, + performed_by: str, + options: AnonymizationOptions + ) -> AnonymizationResult: + """ + Führt vollständige Anonymisierung durch. + + Steps: + 1. Validation + 2. Mailbox-Zuweisungen widerrufen + 3. Benutzerdaten pseudonymisieren + 4. E-Mail-Header anonymisieren (optional) + 5. Audit-Log erstellen + 6. Event publizieren + """ + anonymization_id = str(uuid.uuid4()) + errors = [] + + async with self.db.begin(): + # 1. Validierung + user = await self._get_user(user_id) + if not user: + raise UserNotFoundError(user_id) + + if user.anonymized_at: + raise AlreadyAnonymizedError(user_id) + + # 2. Mailbox-Zuweisungen widerrufen + assignments = await self._get_active_assignments(user_id) + for assignment in assignments: + await self._revoke_assignment( + assignment.id, + performed_by, + reason="employee_anonymization" + ) + + # 3. Benutzerdaten pseudonymisieren + pseudonym = self._generate_pseudonym() + + # Original-Daten verschlüsseln (für DSGVO Art. 15 Auskunftsrecht) + encrypted_original = await self.encryption.encrypt({ + "name": user.name, + "email": user.email, + "encrypted_at": datetime.utcnow().isoformat() + }) + + await self.db.execute(""" + UPDATE users SET + name = :pseudonym, + email = :anon_email, + anonymized_at = NOW(), + anonymization_token = :token, + original_data_encrypted = :encrypted + WHERE id = :user_id + """, { + "pseudonym": f"Ehemaliger Mitarbeiter ({pseudonym})", + "anon_email": f"anon_{pseudonym}@deleted.local", + "token": secrets.token_hex(32), + "encrypted": encrypted_original, + "user_id": user_id + }) + + # 4. E-Mail-Header anonymisieren (falls gewünscht) + affected_emails = 0 + if options.anonymize_email_headers: + affected_emails = await self._anonymize_email_headers( + user_id, pseudonym + ) + + # 5. Audit-Log + audit_log_id = await self._create_audit_log( + entity_type="user", + entity_id=user_id, + anonymization_type="pseudonymization", + fields_affected={ + "name": True, + "email": True, + "mailbox_assignments": len(assignments), + "email_headers": affected_emails + }, + reason=reason, + performed_by=performed_by, + legal_basis="Art. 17 DSGVO" + ) + + # 6. Event publizieren (für andere Services) + await self._publish_event(UserAnonymizedEvent( + user_id=user_id, + anonymization_id=anonymization_id, + affected_mailboxes=[a.mailbox_id for a in assignments] + )) + + return AnonymizationResult( + success=True, + anonymization_id=anonymization_id, + affected_mailboxes=len(assignments), + affected_emails=affected_emails, + audit_log_id=audit_log_id, + errors=errors + ) + + def _generate_pseudonym(self) -> str: + """Generiert ein eindeutiges Pseudonym.""" + return hashlib.sha256( + secrets.token_bytes(32) + ).hexdigest()[:12] + + async def _anonymize_email_headers( + self, + user_id: str, + pseudonym: str + ) -> int: + """ + Anonymisiert E-Mail-Header in Stalwart. + + Ersetzt: + - From: Max Mustermann → Ehemaliger Mitarbeiter + - Reply-To: max.mustermann@... → anon_xxx@deleted.local + + Behält: + - Funktionale Absender-Adressen (klassenlehrer.5a@...) + """ + # Stalwart API Call für Header-Manipulation + return await self.mail_server.anonymize_headers( + user_id=user_id, + replacement_name=f"Ehemaliger Mitarbeiter ({pseudonym})" + ) +``` + +### 6.2 Stalwart Integration + +```python +# rbac_mail_bridge/integrations/stalwart.py + +class StalwartClient: + """ + Client für Stalwart Mail Server API. + + Dokumentation: https://stalw.art/docs/api/ + """ + + def __init__(self, base_url: str, api_key: str): + self.base_url = base_url + self.api_key = api_key + self.session = httpx.AsyncClient( + base_url=base_url, + headers={"Authorization": f"Bearer {api_key}"} + ) + + async def create_mailbox( + self, + email: str, + display_name: str, + quota_mb: int = 1024 + ) -> str: + """Erstellt eine neue Mailbox in Stalwart.""" + response = await self.session.post( + "/api/v1/accounts", + json={ + "email": email, + "name": display_name, + "quota": quota_mb * 1024 * 1024, + "type": "individual" + } + ) + response.raise_for_status() + return response.json()["id"] + + async def update_mailbox_access( + self, + mailbox_id: str, + user_email: str, + access_type: str # "full", "send_as", "read_only" + ): + """ + Aktualisiert Zugriffsrechte auf eine Mailbox. + + Wird verwendet wenn: + - Neue Zuweisung erstellt wird + - Zuweisung widerrufen wird + """ + response = await self.session.put( + f"/api/v1/accounts/{mailbox_id}/access", + json={ + "user": user_email, + "access": access_type + } + ) + response.raise_for_status() + + async def anonymize_headers( + self, + user_id: str, + replacement_name: str + ) -> int: + """ + Anonymisiert E-Mail-Header für einen Benutzer. + + Returns: Anzahl der betroffenen E-Mails + """ + # Stalwart unterstützt dies möglicherweise nicht nativ + # Alternative: Sieve-Filter oder Post-Processing + pass +``` + +### 6.3 SOGo Integration + +```python +# rbac_mail_bridge/integrations/sogo.py + +class SOGoClient: + """ + Client für SOGo Groupware API. + + SOGo nutzt CalDAV/CardDAV und hat eine eigene REST API. + """ + + def __init__(self, base_url: str, admin_user: str, admin_pass: str): + self.base_url = base_url + self.auth = (admin_user, admin_pass) + + async def create_calendar_event( + self, + calendar_id: str, + event: CalendarEvent + ) -> str: + """Erstellt einen Kalender-Eintrag.""" + ical_data = self._to_ical(event) + + response = await httpx.put( + f"{self.base_url}/SOGo/dav/{calendar_id}/Calendar/{event.uid}.ics", + content=ical_data, + headers={"Content-Type": "text/calendar"}, + auth=self.auth + ) + response.raise_for_status() + return event.uid + + def _to_ical(self, event: CalendarEvent) -> str: + """Konvertiert Event zu iCalendar Format.""" + lines = [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//BreakPilot//Mail-RBAC//DE", + "BEGIN:VEVENT", + f"UID:{event.uid}", + f"DTSTART:{event.start_time.strftime('%Y%m%dT%H%M%SZ')}", + f"DTEND:{event.end_time.strftime('%Y%m%dT%H%M%SZ')}", + f"SUMMARY:{event.title}", + ] + + if event.description: + lines.append(f"DESCRIPTION:{event.description}") + + if event.jitsi_url: + lines.append(f"LOCATION:{event.jitsi_url}") + lines.append(f"X-JITSI-URL:{event.jitsi_url}") + + lines.extend([ + "END:VEVENT", + "END:VCALENDAR" + ]) + + return "\r\n".join(lines) +``` + +--- + +## 7. Frontend-Integration + +### 7.1 Admin-Seite: Mail-Management + +```tsx +// website/app/admin/mail-management/page.tsx + +'use client' + +import { useState, useEffect } from 'react' +import AdminLayout from '@/components/admin/AdminLayout' + +type TabType = 'mailboxes' | 'assignments' | 'anonymization' | 'audit' + +interface FunctionalMailbox { + id: string + role_key: string + email_address: string + display_name: string + is_active: boolean + current_assignee?: { + id: string + name: string + email: string + } +} + +export default function MailManagementPage() { + const [activeTab, setActiveTab] = useState('mailboxes') + const [mailboxes, setMailboxes] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetchMailboxes() + }, []) + + const fetchMailboxes = async () => { + try { + const res = await fetch('/api/admin/mail-rbac/mailboxes') + const data = await res.json() + setMailboxes(data) + } finally { + setLoading(false) + } + } + + return ( + + {/* Tabs */} +
              + +
              + + {/* Mailboxes Tab */} + {activeTab === 'mailboxes' && ( +
              + {/* Stats */} +
              +
              +
              + {mailboxes.length} +
              +
              Funktionale Mailboxen
              +
              +
              +
              + {mailboxes.filter(m => m.current_assignee).length} +
              +
              Zugewiesen
              +
              +
              +
              + {mailboxes.filter(m => !m.current_assignee).length} +
              +
              Nicht zugewiesen
              +
              +
              +
              + {new Set(mailboxes.map(m => m.role_key)).size} +
              +
              Verschiedene Rollen
              +
              +
              + + {/* Mailbox Liste */} +
              +
              +

              Funktionale Mailboxen

              + +
              +
              + + + + + + + + + + + + {mailboxes.map((mailbox) => ( + + + + + + + + ))} + +
              + E-Mail-Adresse + + Rolle + + Aktuell zugewiesen + + Status + + Aktionen +
              + + {mailbox.email_address} + + + + {mailbox.role_key} + + + {mailbox.current_assignee ? ( +
              +
              + {mailbox.current_assignee.name} +
              +
              + {mailbox.current_assignee.email} +
              +
              + ) : ( + + Nicht zugewiesen + + )} +
              + + {mailbox.is_active ? 'Aktiv' : 'Inaktiv'} + + +
              + + +
              +
              +
              +
              +
              + )} + + {/* Anonymization Tab */} + {activeTab === 'anonymization' && ( +
              + {/* Warning Banner */} +
              +
              + ⚠️ +
              +

              + Anonymisierung ist irreversibel +

              +

              + Die Anonymisierung kann nicht rückgängig gemacht werden. + Stellen Sie sicher, dass alle Aufbewahrungsfristen eingehalten werden. +

              +
              +
              +
              + + {/* Anonymization Form */} +
              +

              + Mitarbeiter anonymisieren +

              + +
              +
              + + +
              + +
              + + +
              + +
              + + +
              + +
              + +
              +
              +
              +
              + )} +
              + ) +} +``` + +--- + +## 8. Docker-Konfiguration + +### 8.1 Docker Compose Erweiterung + +```yaml +# docker-compose.mail.yml +# Verwendet mit: docker compose -f docker-compose.yml -f docker-compose.mail.yml up + +version: '3.8' + +services: + # Stalwart Mail Server + stalwart: + image: stalwartlabs/mail-server:v0.9 + container_name: breakpilot-mail-stalwart + hostname: mail.breakpilot.local + ports: + - "25:25" # SMTP + - "143:143" # IMAP + - "465:465" # SMTPS + - "993:993" # IMAPS + - "4190:4190" # ManageSieve + - "8787:8080" # Admin API + volumes: + - stalwart-data:/opt/stalwart-mail/data + - ./config/stalwart/config.toml:/opt/stalwart-mail/etc/config.toml:ro + environment: + - STALWART_HOSTNAME=mail.breakpilot.local + networks: + - breakpilot-pwa-network + depends_on: + postgres: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8080/healthz"] + interval: 30s + timeout: 10s + retries: 3 + + # SOGo Groupware + sogo: + image: sogo/sogo:5.10 + container_name: breakpilot-mail-sogo + ports: + - "20000:20000" + volumes: + - ./config/sogo/sogo.conf:/etc/sogo/sogo.conf:ro + environment: + - MYSQL_HOST=postgres # SOGo unterstützt auch PostgreSQL + - SOGO_HOSTNAME=groupware.breakpilot.local + depends_on: + - stalwart + - postgres + networks: + - breakpilot-pwa-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:20000/SOGo/"] + interval: 30s + timeout: 10s + retries: 3 + + # RBAC-Mail-Bridge + rbac-mail-bridge: + build: + context: ./rbac-mail-bridge + dockerfile: Dockerfile + container_name: breakpilot-mail-rbac + ports: + - "8087:8087" + environment: + - DATABASE_URL=${DATABASE_URL} + - STALWART_API_URL=http://stalwart:8080 + - STALWART_API_KEY=${STALWART_API_KEY} + - SOGO_URL=http://sogo:20000 + - JITSI_URL=${JITSI_URL:-http://localhost:8443} + - ENCRYPTION_KEY=${MAIL_ENCRYPTION_KEY} + depends_on: + - postgres + - stalwart + - sogo + networks: + - breakpilot-pwa-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8087/health"] + interval: 30s + timeout: 10s + retries: 3 + +volumes: + stalwart-data: + driver: local +``` + +### 8.2 Stalwart Konfiguration + +```toml +# config/stalwart/config.toml + +[server] +hostname = "mail.breakpilot.local" + +[store] +db.type = "postgresql" +db.url = "postgresql://breakpilot:breakpilot123@postgres:5432/breakpilot_mail" + +[authentication] +mechanisms = ["PLAIN", "LOGIN"] +directory.type = "internal" + +[imap] +bind = ["0.0.0.0:143"] + +[smtp] +bind = ["0.0.0.0:25"] + +[api] +bind = ["0.0.0.0:8080"] +key = "${STALWART_API_KEY}" +``` + +--- + +## 9. Sicherheit + +### 9.1 Verschlüsselung + +```python +# rbac_mail_bridge/services/encryption.py + +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +import base64 +import os + +class EncryptionService: + """ + AES-256 Verschlüsselung für Original-Daten. + + Verwendet für: + - Verschlüsselung der Original-Benutzerdaten bei Anonymisierung + - DSGVO Art. 15 Auskunftsrecht (Entschlüsselung nur bei Berechtigung) + """ + + def __init__(self, master_key: str): + self.master_key = master_key.encode() + + def _derive_key(self, salt: bytes) -> bytes: + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=100000, + ) + return base64.urlsafe_b64encode(kdf.derive(self.master_key)) + + async def encrypt(self, data: dict) -> bytes: + """Verschlüsselt Daten mit AES-256.""" + salt = os.urandom(16) + key = self._derive_key(salt) + f = Fernet(key) + + plaintext = json.dumps(data).encode() + ciphertext = f.encrypt(plaintext) + + # Salt + Ciphertext kombinieren + return salt + ciphertext + + async def decrypt(self, encrypted: bytes, audit_reason: str) -> dict: + """ + Entschlüsselt Daten. + + WICHTIG: Jede Entschlüsselung wird geloggt! + """ + salt = encrypted[:16] + ciphertext = encrypted[16:] + + key = self._derive_key(salt) + f = Fernet(key) + + plaintext = f.decrypt(ciphertext) + + # Audit-Log + await self._log_decryption(audit_reason) + + return json.loads(plaintext.decode()) +``` + +### 9.2 Zugriffskontrolle + +```python +# Berechtigungen für Mail-RBAC + +MAIL_RBAC_PERMISSIONS = { + "mail:mailbox:create": "Kann funktionale Mailboxen erstellen", + "mail:mailbox:assign": "Kann Mailboxen Benutzern zuweisen", + "mail:mailbox:revoke": "Kann Mailbox-Zuweisungen widerrufen", + "mail:user:anonymize": "Kann Benutzer anonymisieren", + "mail:audit:view": "Kann Audit-Logs einsehen", + "mail:audit:export": "Kann DSGVO-Export durchführen", +} + +# Rollen-Mapping +ROLE_PERMISSIONS = { + "schul_admin": [ + "mail:mailbox:create", + "mail:mailbox:assign", + "mail:mailbox:revoke", + ], + "data_protection_officer": [ + "mail:user:anonymize", + "mail:audit:view", + "mail:audit:export", + ], + "schulleitung": [ + "mail:mailbox:assign", + "mail:mailbox:revoke", + "mail:audit:view", + ], +} +``` + +--- + +## 10. Testing + +### 10.1 Unit Tests + +```python +# rbac_mail_bridge/tests/test_anonymizer.py + +import pytest +from datetime import datetime +from unittest.mock import AsyncMock, MagicMock + +from services.anonymizer import EmployeeAnonymizer, AnonymizationResult + +@pytest.fixture +def anonymizer(): + db = AsyncMock() + mail_server = AsyncMock() + encryption = AsyncMock() + return EmployeeAnonymizer(db, mail_server, encryption) + +class TestAnonymizer: + @pytest.mark.asyncio + async def test_anonymize_user_success(self, anonymizer): + # Arrange + anonymizer._get_user = AsyncMock(return_value=MagicMock( + id="user-123", + name="Max Mustermann", + email="max@test.de", + anonymized_at=None + )) + anonymizer._get_active_assignments = AsyncMock(return_value=[ + MagicMock(id="assign-1", mailbox_id="mb-1"), + MagicMock(id="assign-2", mailbox_id="mb-2"), + ]) + anonymizer._revoke_assignment = AsyncMock() + anonymizer._create_audit_log = AsyncMock(return_value="audit-123") + anonymizer._publish_event = AsyncMock() + + # Act + result = await anonymizer.anonymize( + user_id="user-123", + reason="employee_departure", + performed_by="admin-1", + options=MagicMock(anonymize_email_headers=False) + ) + + # Assert + assert result.success is True + assert result.affected_mailboxes == 2 + assert anonymizer._revoke_assignment.call_count == 2 + assert anonymizer._create_audit_log.called + + @pytest.mark.asyncio + async def test_anonymize_already_anonymized_raises(self, anonymizer): + # Arrange + anonymizer._get_user = AsyncMock(return_value=MagicMock( + anonymized_at=datetime.utcnow() + )) + + # Act & Assert + with pytest.raises(AlreadyAnonymizedError): + await anonymizer.anonymize( + user_id="user-123", + reason="employee_departure", + performed_by="admin-1", + options=MagicMock() + ) + + @pytest.mark.asyncio + async def test_anonymize_user_not_found_raises(self, anonymizer): + # Arrange + anonymizer._get_user = AsyncMock(return_value=None) + + # Act & Assert + with pytest.raises(UserNotFoundError): + await anonymizer.anonymize( + user_id="nonexistent", + reason="employee_departure", + performed_by="admin-1", + options=MagicMock() + ) +``` + +### 10.2 Integration Tests + +```python +# rbac_mail_bridge/tests/test_integration.py + +import pytest +from httpx import AsyncClient + +@pytest.mark.integration +class TestMailRBACIntegration: + @pytest.mark.asyncio + async def test_create_and_assign_mailbox(self, client: AsyncClient): + # 1. Mailbox erstellen + create_res = await client.post( + "/api/v1/mailboxes", + json={ + "role_key": "klassenlehrer", + "email_address": "klassenlehrer.test@school.bp.app", + "display_name": "Klassenlehrer Test", + "tenant_id": "tenant-123" + } + ) + assert create_res.status_code == 201 + mailbox_id = create_res.json()["id"] + + # 2. Mailbox zuweisen + assign_res = await client.post( + f"/api/v1/mailboxes/{mailbox_id}/assign", + json={ + "user_id": "user-456", + "valid_from": "2026-01-10T00:00:00Z" + } + ) + assert assign_res.status_code == 201 + + # 3. Verifizieren + get_res = await client.get(f"/api/v1/mailboxes/{mailbox_id}") + assert get_res.status_code == 200 + assert get_res.json()["current_assignee"]["id"] == "user-456" +``` + +--- + +## 11. Deployment Checklist + +### 11.1 Vor dem Deployment + +- [ ] PostgreSQL Schema migriert +- [ ] Stalwart API Key generiert und in .env +- [ ] MAIL_ENCRYPTION_KEY generiert (32 Bytes, Base64) +- [ ] DNS Records konfiguriert (MX, SPF, DKIM, DMARC) +- [ ] SSL-Zertifikate für Mail-Domain +- [ ] Firewall-Regeln für Ports 25, 143, 465, 993 + +### 11.2 Nach dem Deployment + +- [ ] Health-Checks für alle Services grün +- [ ] Test-E-Mail senden/empfangen +- [ ] Admin-UI erreichbar +- [ ] Audit-Logging funktioniert +- [ ] Backup konfiguriert + +--- + +## 12. Roadmap + +### Phase 1: MVP (4-6 Wochen) +- [x] Architektur-Dokumentation +- [ ] Datenbank-Schema +- [ ] RBAC-Mail-Bridge Backend +- [ ] Stalwart Integration +- [ ] Admin-UI (Basis) + +### Phase 2: Groupware (2-3 Wochen) +- [ ] SOGo Integration +- [ ] CalDAV/CardDAV +- [ ] Jitsi-Meeting aus Kalender + +### Phase 3: Anonymisierung (2-3 Wochen) +- [ ] Anonymisierungs-Service +- [ ] E-Mail-Header-Anonymisierung +- [ ] DSGVO-Export + +### Phase 4: Polish (1-2 Wochen) +- [ ] Admin-UI vollständig +- [ ] Dokumentation +- [ ] Produktions-Hardening + +--- + +## Anhang A: Glossar + +| Begriff | Beschreibung | +|---------|--------------| +| **Funktionale Mailbox** | Rollengebundene E-Mail-Adresse (z.B. klassenlehrer.5a@...) | +| **Personenbezogene Mailbox** | An eine Person gebundene E-Mail-Adresse | +| **Anonymisierung** | Unwiderrufliche Entfernung personenbezogener Daten | +| **Pseudonymisierung** | Ersetzung durch Pseudonym (reversibel mit Schlüssel) | +| **Audit-Trail** | Lückenlose Protokollierung aller Aktionen | +| **RBAC** | Role-Based Access Control | +| **CalDAV** | Calendar Distributed Authoring and Versioning | + +## Anhang B: Referenzen + +- [Stalwart Mail Server Docs](https://stalw.art/docs/) +- [SOGo Installation Guide](https://www.sogo.nu/support/faq.html) +- [DSGVO Art. 17 - Recht auf Löschung](https://dsgvo-gesetz.de/art-17-dsgvo/) +- [RFC 5545 - iCalendar](https://tools.ietf.org/html/rfc5545) +- [RFC 4791 - CalDAV](https://tools.ietf.org/html/rfc4791) diff --git a/docs/klausur-modul/UNIFIED-INBOX-SPECIFICATION.md b/docs/klausur-modul/UNIFIED-INBOX-SPECIFICATION.md new file mode 100644 index 0000000..4d3598a --- /dev/null +++ b/docs/klausur-modul/UNIFIED-INBOX-SPECIFICATION.md @@ -0,0 +1,1555 @@ +# BreakPilot Unified Inbox - Erweiterte Spezifikation + +**Version:** 2.0.0 +**Datum:** 2026-01-10 +**Status:** Entwicklungsspezifikation +**Priorität:** P0 - Kernprodukt + +--- + +## 1. Executive Summary + +### 1.1 Das Problem + +Eine Schulleiterin in Niedersachsen muss **4 verschiedene dienstliche E-Mail-Adressen** verwalten: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ AKTUELLE SITUATION │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 📧 schulleitung@grundschule-xy.de │ +│ └── Landesschulbehörde, Schulträger │ +│ │ +│ 📧 vorname.nachname@schule.niedersachsen.de │ +│ └── Bildungsportal Niedersachsen │ +│ │ +│ 📧 personal@grundschule-xy.de │ +│ └── Personalverwaltung, Vertretungsplanung │ +│ │ +│ 📧 verwaltung@grundschule-xy.de │ +│ └── Schulträger (Kommune), Haushalt │ +│ │ +│ PROBLEME: │ +│ ❌ 4 verschiedene Logins │ +│ ❌ Fristen werden übersehen │ +│ ❌ Keine einheitliche Übersicht │ +│ ❌ Keine KI-Unterstützung │ +│ ❌ Kein gemeinsamer Kalender │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 1.2 Die Lösung: BreakPilot Unified Inbox + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ BREAKPILOT UNIFIED INBOX │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ EINHEITLICHES FRONTEND │ │ +│ │ │ │ +│ │ 📥 Posteingang (alle Konten) │ │ +│ │ 📋 Arbeitsvorrat (KI-extrahierte Aufgaben) │ │ +│ │ 📅 Kalender (Jitsi-integriert) │ │ +│ │ 💬 Chat (Matrix E2EE) │ │ +│ │ 📹 Meetings (Jitsi) │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ KI-ASSISTENZ-LAYER │ │ +│ │ │ │ +│ │ 🤖 Absender-Erkennung (welche Behörde?) │ │ +│ │ 📆 Fristen-Extraktion (Deadline-Tracking) │ │ +│ │ ✍️ Antwort-Vorschläge (vorformuliert) │ │ +│ │ 🏷️ Automatische Kategorisierung │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ MULTI-ACCOUNT AGGREGATOR │ │ +│ │ │ │ +│ │ IMAP/SMTP ──► schulleitung@grundschule-xy.de │ │ +│ │ IMAP/SMTP ──► vorname.nachname@schule.nds.de │ │ +│ │ IMAP/SMTP ──► personal@grundschule-xy.de │ │ +│ │ IMAP/SMTP ──► verwaltung@grundschule-xy.de │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 1.3 Zielgruppen + +| Zielgruppe | Nutzung | Priorität | +|------------|---------|-----------| +| **BreakPilot GmbH intern** | Interne Kommunikation, Proof-of-Concept | P0 | +| **Schulleitungen** | Multi-Account-Aggregation, KI-Assistenz | P0 | +| **Lehrkräfte** | Optionales Angebot, funktionale Mailboxen | P1 | +| **Schulträger** | White-Label-Lösung für ihre Schulen | P2 | + +--- + +## 2. Kern-Features + +### 2.1 Multi-Account Aggregation + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ACCOUNT-AGGREGATION │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ UNTERSTÜTZTE PROTOKOLLE: │ +│ ├── IMAP/IMAPS (Lesen) │ +│ ├── SMTP/SMTPS (Senden) │ +│ ├── OAuth2 (Microsoft 365, Google) │ +│ └── Exchange Web Services (EWS) │ +│ │ +│ PRO KONTO GESPEICHERT: │ +│ ├── Server-Konfiguration (verschlüsselt) │ +│ ├── Credentials (Vault-verschlüsselt) │ +│ ├── Display-Name und Farbe │ +│ ├── Absender-Identität │ +│ └── Signatur (HTML/Text) │ +│ │ +│ SYNCHRONISATION: │ +│ ├── Polling-Intervall: 1-5 Minuten (konfigurierbar) │ +│ ├── Push: IMAP IDLE (wenn unterstützt) │ +│ └── Full-Sync: 1x täglich (Konsistenzprüfung) │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 KI-Assistenz-Features + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ KI-ASSISTENZ │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. ABSENDER-ERKENNUNG │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Von: info@landesschulbehoerde.niedersachsen.de │ │ +│ │ │ │ +│ │ 🏛️ Erkannt als: Landesschulbehörde Niedersachsen │ │ +│ │ 📁 Kategorie: Behördliche Mitteilung │ │ +│ │ ⚠️ Priorität: Hoch (enthält Fristsetzung) │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ 2. FRISTEN-EXTRAKTION │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ "...bitten wir um Rückmeldung bis zum 15.02.2026" │ │ +│ │ │ │ +│ │ 📆 Erkannte Frist: 15. Februar 2026 │ │ +│ │ ⏰ Erinnerung: 3 Tage vorher │ │ +│ │ ✅ Zum Arbeitsvorrat hinzufügen │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ 3. ANTWORT-VORSCHLÄGE │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ Betreff: Bestätigung Terminvorschlag │ │ +│ │ │ │ +│ │ 💡 Vorschlag 1: "Hiermit bestätige ich den..." │ │ +│ │ 💡 Vorschlag 2: "Ich bitte um einen Alternativ..." │ │ +│ │ 💡 Vorschlag 3: "Aufgrund von ... ist mir ..." │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ │ +│ 4. KATEGORISIERUNG │ +│ ├── 🏛️ Behörde (Landesschulbehörde, Kultusministerium) │ +│ ├── 🏢 Schulträger (Kommune, Kreis) │ +│ ├── 👥 Personal (Vertretung, Krankheit, Fortbildung) │ +│ ├── 📚 Pädagogik (Curricula, Prüfungen) │ +│ ├── 💰 Haushalt (Budget, Beschaffung) │ +│ └── 📋 Sonstiges │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 2.3 Arbeitsvorrat (Task-Management) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ARBEITSVORRAT │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 🔴 ÜBERFÄLLIG (2) │ │ +│ ├─────────────────────────────────────────────────────────┤ │ +│ │ ☐ Statistik-Meldung Schülerzahlen Frist: 05.01. │ │ +│ │ └── Von: Landesschulbehörde ⚠️ 5 Tage über │ │ +│ │ ☐ Haushaltsentwurf 2026 Frist: 08.01. │ │ +│ │ └── Von: Stadt Musterstadt ⚠️ 2 Tage über │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 🟡 DIESE WOCHE (3) │ │ +│ ├─────────────────────────────────────────────────────────┤ │ +│ │ ☐ Fortbildungsanträge genehmigen Frist: 12.01. │ │ +│ │ ☐ Elternbrief Halbjahreszeugnis Frist: 14.01. │ │ +│ │ ☐ Rückmeldung Prüfungstermine Frist: 15.01. │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 🟢 SPÄTER (5) │ │ +│ ├─────────────────────────────────────────────────────────┤ │ +│ │ ☐ Jahresbericht erstellen Frist: 31.01. │ │ +│ │ ☐ Medienkonzept aktualisieren Frist: 28.02. │ │ +│ │ ... │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ FEATURES: │ +│ ├── Automatische Frist-Erkennung aus E-Mails │ +│ ├── Manuelle Aufgaben hinzufügen │ +│ ├── Erinnerungen (E-Mail, Push, Matrix) │ +│ ├── Delegation an Kollegium │ +│ ├── Verknüpfung mit Original-E-Mail │ +│ └── Export nach iCal/Outlook │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 2.4 Integrationen + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ INTEGRATIONEN │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 📅 KALENDER (CalDAV) │ +│ ├── Termine aus E-Mails extrahieren │ +│ ├── Meeting-Einladungen erstellen │ +│ ├── Jitsi-Link automatisch hinzufügen │ +│ ├── Sync mit externen Kalendern │ +│ └── Fristübersicht im Kalender │ +│ │ +│ 📹 JITSI (Video-Meetings) │ +│ ├── Ein-Klick-Meeting aus E-Mail │ +│ ├── Einladung an alle Beteiligten │ +│ ├── Warteraum für externe Teilnehmer │ +│ └── Aufzeichnung (optional, mit Consent) │ +│ │ +│ 💬 MATRIX (E2EE Chat) │ +│ ├── Schnelle Rückfragen an Kollegium │ +│ ├── Kanal pro Thema/Fachbereich │ +│ ├── E-Mail zu Chat: "Dazu besprechen wir uns kurz" │ +│ └── Verschlüsselung für sensible Themen │ +│ │ +│ 🤖 KI-SERVICES │ +│ ├── Fristen-Extraktion (NLP) │ +│ ├── Absender-Klassifikation │ +│ ├── Antwort-Generierung (optional) │ +│ └── Zusammenfassung langer E-Mails │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 3. Architektur + +### 3.1 System-Übersicht + +``` +┌────────────────────────────────────────────────────────────────────────┐ +│ BREAKPILOT UNIFIED INBOX │ +├────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────────────────────────────────────────────────────────┐ │ +│ │ FRONTEND LAYER │ │ +│ ├──────────────────────────────────────────────────────────────────┤ │ +│ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │ │ +│ │ │ Web App │ │ Mobile PWA │ │ Desktop App │ │ │ +│ │ │ (Next.js) │ │ (React) │ │ (Electron) │ │ │ +│ │ │ :3000 │ │ PWA │ │ Optional │ │ │ +│ │ └────────┬───────┘ └────────┬───────┘ └────────┬───────┘ │ │ +│ └───────────┼───────────────────┼───────────────────┼──────────────┘ │ +│ │ │ │ │ +│ └───────────────────┼───────────────────┘ │ +│ │ │ +│ ┌──────────────────────────────┴───────────────────────────────────┐ │ +│ │ API GATEWAY (Go) │ │ +│ │ :8088 │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ +│ │ │ Auth │ │ Rate Limit │ │ Logging │ │ │ +│ │ │ (JWT/OIDC) │ │ │ │ (Audit) │ │ │ +│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ +│ └──────────────────────────────┬───────────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────────────┴───────────────────────────────────┐ │ +│ │ APPLICATION LAYER │ │ +│ ├───────────────────┬───────────────────┬──────────────────────────┤ │ +│ │ │ │ │ │ +│ │ ┌─────────────┐ │ ┌─────────────┐ │ ┌─────────────┐ │ │ +│ │ │ Mail │ │ │ AI │ │ │ Task │ │ │ +│ │ │ Aggregator │ │ │ Service │ │ │ Service │ │ │ +│ │ │ (Python) │ │ │ (Python) │ │ │ (Python) │ │ │ +│ │ │ :8089 │ │ │ :8090 │ │ │ :8091 │ │ │ +│ │ └──────┬──────┘ │ └──────┬──────┘ │ └──────┬──────┘ │ │ +│ │ │ │ │ │ │ │ │ +│ │ ┌──────┴──────┐ │ ┌──────┴──────┐ │ ┌──────┴──────┐ │ │ +│ │ │ IMAP/SMTP │ │ │ LLM │ │ │ PostgreSQL │ │ │ +│ │ │ Connectors │ │ │ Gateway │ │ │ Tasks DB │ │ │ +│ │ └─────────────┘ │ └─────────────┘ │ └─────────────┘ │ │ +│ │ │ │ │ │ +│ └───────────────────┴───────────────────┴──────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────────────┴───────────────────────────────────┐ │ +│ │ DATA LAYER │ │ +│ ├───────────────────┬───────────────────┬──────────────────────────┤ │ +│ │ ┌─────────────┐ │ ┌─────────────┐ │ ┌─────────────┐ │ │ +│ │ │ PostgreSQL │ │ │ Redis │ │ │ MinIO │ │ │ +│ │ │ (Main DB) │ │ │ (Cache) │ │ │ (Attachm.) │ │ │ +│ │ │ :5432 │ │ │ :6379 │ │ │ :9000 │ │ │ +│ │ └─────────────┘ │ └─────────────┘ │ └─────────────┘ │ │ +│ │ │ │ │ │ +│ │ ┌─────────────┐ │ ┌─────────────┐ │ ┌─────────────┐ │ │ +│ │ │ Vault │ │ │ Qdrant │ │ │ Stalwart │ │ │ +│ │ │ (Secrets) │ │ │ (Vectors) │ │ │ (Own Mail) │ │ │ +│ │ │ :8200 │ │ │ :6333 │ │ │ :25/143 │ │ │ +│ │ └─────────────┘ │ └─────────────┘ │ └─────────────┘ │ │ +│ └───────────────────┴───────────────────┴──────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────────────────────────────────────────┐ │ +│ │ EXTERNAL CONNECTIONS │ │ +│ ├──────────────────────────────────────────────────────────────────┤ │ +│ │ │ │ +│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ +│ │ │ Externe │ │ Jitsi │ │ Matrix │ │ │ +│ │ │ IMAP/SMTP │ │ :8443 │ │ :8008 │ │ │ +│ │ │ Server │ │ │ │ │ │ │ +│ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ +│ │ │ │ +│ └──────────────────────────────────────────────────────────────────┘ │ +│ │ +└────────────────────────────────────────────────────────────────────────┘ +``` + +### 3.2 Mail Aggregator Service + +```python +# unified_inbox/services/mail_aggregator.py + +""" +Mail Aggregator Service + +Aggregiert E-Mails aus mehreren externen IMAP-Konten und +stellt sie in einer einheitlichen Inbox bereit. + +WICHTIG: Credentials werden niemals im Klartext gespeichert! + Alle Zugangsdaten liegen verschlüsselt in Vault. +""" + +from dataclasses import dataclass +from datetime import datetime +from typing import List, Optional, Dict, Any +from enum import Enum +import asyncio +import aioimaplib +import aiosmtplib + +class AccountType(str, Enum): + IMAP = "imap" + OAUTH_MICROSOFT = "oauth_microsoft" + OAUTH_GOOGLE = "oauth_google" + EWS = "ews" + +@dataclass +class ExternalAccount: + """Externes E-Mail-Konto""" + id: str + user_id: str # BreakPilot User + + # Identität + email_address: str + display_name: str + color: str # Für UI-Unterscheidung + + # Server-Konfiguration + account_type: AccountType + imap_host: str + imap_port: int + smtp_host: str + smtp_port: int + use_ssl: bool + + # Credentials (Vault-Referenz, nicht Klartext!) + vault_secret_path: str + + # Sync-Status + last_sync: Optional[datetime] + sync_state: str # UIDVALIDITY, HIGHESTMODSEQ + + # Features + supports_idle: bool + signature_html: Optional[str] + signature_text: Optional[str] + +@dataclass +class AggregatedEmail: + """E-Mail aus einem externen Konto""" + id: str + account_id: str # Welches Konto + + # E-Mail-Daten + message_id: str + subject: str + from_address: str + from_name: str + to_addresses: List[str] + cc_addresses: List[str] + date: datetime + body_text: str + body_html: Optional[str] + has_attachments: bool + + # Aggregator-Metadaten + fetched_at: datetime + is_read: bool + is_flagged: bool + folder: str + + # KI-Analyse (später befüllt) + ai_category: Optional[str] + ai_priority: Optional[str] + ai_sender_type: Optional[str] + ai_extracted_deadline: Optional[datetime] + ai_summary: Optional[str] + +class MailAggregator: + """ + Aggregiert E-Mails aus mehreren externen Konten. + + Prinzipien: + 1. Credentials niemals im Speicher halten (nur kurz während Verbindung) + 2. E-Mail-Inhalte werden verschlüsselt gecacht + 3. Vollständiger Audit-Trail + """ + + def __init__( + self, + db: AsyncSession, + vault_client: VaultClient, + cache: RedisClient, + ai_service: AIService + ): + self.db = db + self.vault = vault_client + self.cache = cache + self.ai = ai_service + self._connections: Dict[str, aioimaplib.IMAP4_SSL] = {} + + async def add_account( + self, + user_id: str, + account_config: ExternalAccountConfig + ) -> ExternalAccount: + """ + Fügt ein externes E-Mail-Konto hinzu. + + 1. Validiert Credentials (Test-Verbindung) + 2. Speichert Credentials in Vault + 3. Erstellt Account-Eintrag in DB + """ + # 1. Test-Verbindung + await self._test_connection(account_config) + + # 2. Credentials in Vault speichern + vault_path = f"secret/mail-accounts/{user_id}/{account_config.email_address}" + await self.vault.write(vault_path, { + "username": account_config.username, + "password": account_config.password, + "oauth_token": account_config.oauth_token, + }) + + # 3. Account in DB (ohne Credentials!) + account = ExternalAccount( + id=str(uuid.uuid4()), + user_id=user_id, + email_address=account_config.email_address, + display_name=account_config.display_name, + color=account_config.color or self._generate_color(), + account_type=account_config.account_type, + imap_host=account_config.imap_host, + imap_port=account_config.imap_port, + smtp_host=account_config.smtp_host, + smtp_port=account_config.smtp_port, + use_ssl=account_config.use_ssl, + vault_secret_path=vault_path, + last_sync=None, + sync_state="", + supports_idle=False, # Wird beim ersten Sync ermittelt + signature_html=account_config.signature_html, + signature_text=account_config.signature_text, + ) + + await self._save_account(account) + + # Initial-Sync anstoßen + asyncio.create_task(self.sync_account(account.id)) + + return account + + async def sync_account(self, account_id: str) -> SyncResult: + """ + Synchronisiert ein E-Mail-Konto. + + Verwendet IMAP IDLE wenn verfügbar, sonst Polling. + """ + account = await self._get_account(account_id) + + # Credentials aus Vault holen (nur für diese Operation) + credentials = await self.vault.read(account.vault_secret_path) + + try: + async with self._get_imap_connection(account, credentials) as imap: + # Neue E-Mails abrufen + new_emails = await self._fetch_new_emails(imap, account) + + # KI-Analyse für jede neue E-Mail + for email in new_emails: + email.ai_analysis = await self.ai.analyze_email(email) + + # In lokale DB speichern + await self._save_emails(new_emails) + + # Sync-Status aktualisieren + account.last_sync = datetime.utcnow() + await self._save_account(account) + + return SyncResult( + success=True, + new_count=len(new_emails), + account_id=account_id + ) + finally: + # Credentials sofort aus Speicher löschen + credentials = None + + async def send_email( + self, + account_id: str, + to: List[str], + subject: str, + body_html: str, + body_text: str, + cc: Optional[List[str]] = None, + attachments: Optional[List[Attachment]] = None + ) -> SendResult: + """ + Sendet eine E-Mail über das angegebene Konto. + + Die E-Mail wird im Namen des externen Kontos gesendet, + nicht über den BreakPilot Mail-Server. + """ + account = await self._get_account(account_id) + credentials = await self.vault.read(account.vault_secret_path) + + try: + message = self._build_message( + account=account, + to=to, + subject=subject, + body_html=body_html, + body_text=body_text, + cc=cc, + attachments=attachments + ) + + async with aiosmtplib.SMTP( + hostname=account.smtp_host, + port=account.smtp_port, + use_tls=account.use_ssl + ) as smtp: + await smtp.login(credentials["username"], credentials["password"]) + await smtp.send_message(message) + + # Audit-Log + await self._log_sent_email(account_id, to, subject) + + return SendResult(success=True, message_id=message["Message-ID"]) + finally: + credentials = None + + async def get_unified_inbox( + self, + user_id: str, + filters: Optional[InboxFilters] = None, + page: int = 1, + per_page: int = 50 + ) -> UnifiedInboxResponse: + """ + Gibt die vereinheitlichte Inbox zurück. + + Alle Konten werden zusammengeführt, aber + das Quell-Konto ist immer erkennbar (Farbe, Icon). + """ + accounts = await self._get_user_accounts(user_id) + account_ids = [a.id for a in accounts] + + # E-Mails aus allen Konten laden + query = """ + SELECT * FROM aggregated_emails + WHERE account_id = ANY($1) + """ + + if filters: + if filters.unread_only: + query += " AND is_read = false" + if filters.account_id: + query += f" AND account_id = '{filters.account_id}'" + if filters.category: + query += f" AND ai_category = '{filters.category}'" + if filters.has_deadline: + query += " AND ai_extracted_deadline IS NOT NULL" + + query += " ORDER BY date DESC" + query += f" LIMIT {per_page} OFFSET {(page - 1) * per_page}" + + emails = await self.db.fetch_all(query, account_ids) + + # Account-Info hinzufügen + account_map = {a.id: a for a in accounts} + for email in emails: + email.account = account_map[email.account_id] + + return UnifiedInboxResponse( + emails=emails, + total=await self._count_emails(user_id, filters), + page=page, + per_page=per_page + ) +``` + +### 3.3 KI-Analyse-Service + +```python +# unified_inbox/services/ai_service.py + +""" +AI Service für E-Mail-Analyse + +Funktionen: +1. Absender-Erkennung (Behörde, Schulträger, etc.) +2. Fristen-Extraktion +3. Antwort-Vorschläge +4. Kategorisierung +""" + +from dataclasses import dataclass +from datetime import datetime +from typing import List, Optional, Dict +import re + +@dataclass +class EmailAnalysis: + """Ergebnis der KI-Analyse""" + # Absender-Klassifikation + sender_type: str # "behoerde", "schultraeger", "eltern", "sonstige" + sender_organization: Optional[str] # z.B. "Landesschulbehörde Niedersachsen" + + # Kategorisierung + category: str # "personal", "haushalt", "paedagogik", "verwaltung" + priority: str # "hoch", "mittel", "niedrig" + + # Fristen + has_deadline: bool + deadline_date: Optional[datetime] + deadline_text: Optional[str] # Original-Text mit Frist + + # Zusammenfassung + summary: str # 1-2 Sätze + action_required: bool + suggested_action: Optional[str] + + # Antwort-Vorschläge + response_suggestions: List[str] + + # Confidence + confidence: float # 0.0 - 1.0 + +class AIEmailService: + """ + KI-gestützte E-Mail-Analyse. + + Verwendet lokales LLM oder API (konfigurierbar). + """ + + # Bekannte Absender-Muster für Niedersachsen + SENDER_PATTERNS = { + "landesschulbehoerde": { + "domains": ["nlschb.niedersachsen.de", "landesschulbehoerde.niedersachsen.de"], + "type": "behoerde", + "organization": "Landesschulbehörde Niedersachsen" + }, + "kultusministerium": { + "domains": ["mk.niedersachsen.de"], + "type": "behoerde", + "organization": "Kultusministerium Niedersachsen" + }, + "bildungsportal": { + "domains": ["schule.niedersachsen.de", "nibis.de"], + "type": "behoerde", + "organization": "Bildungsportal Niedersachsen" + }, + # Schulträger werden dynamisch aus Domain erkannt + } + + # Frist-Erkennungsmuster + DEADLINE_PATTERNS = [ + r"bis zum (\d{1,2}\.\d{1,2}\.\d{4})", + r"bis spätestens (\d{1,2}\.\d{1,2}\.\d{4})", + r"Frist[:\s]+(\d{1,2}\.\d{1,2}\.\d{4})", + r"Rückmeldung bis (\d{1,2}\.\d{1,2}\.\d{4})", + r"Abgabetermin[:\s]+(\d{1,2}\.\d{1,2}\.\d{4})", + r"innerhalb von (\d+) (Tagen|Wochen)", + ] + + def __init__(self, llm_client: LLMClient): + self.llm = llm_client + + async def analyze_email(self, email: AggregatedEmail) -> EmailAnalysis: + """ + Analysiert eine E-Mail vollständig. + """ + # 1. Absender-Erkennung (regelbasiert + LLM) + sender_info = await self._classify_sender(email) + + # 2. Fristen-Extraktion + deadline_info = await self._extract_deadline(email) + + # 3. Kategorisierung und Priorität + category_info = await self._categorize(email, sender_info) + + # 4. Zusammenfassung und Aktions-Vorschlag + summary_info = await self._summarize(email) + + # 5. Antwort-Vorschläge generieren + responses = await self._generate_responses(email, sender_info, summary_info) + + return EmailAnalysis( + sender_type=sender_info["type"], + sender_organization=sender_info.get("organization"), + category=category_info["category"], + priority=category_info["priority"], + has_deadline=deadline_info["has_deadline"], + deadline_date=deadline_info.get("date"), + deadline_text=deadline_info.get("text"), + summary=summary_info["summary"], + action_required=summary_info["action_required"], + suggested_action=summary_info.get("action"), + response_suggestions=responses, + confidence=self._calculate_confidence(sender_info, deadline_info) + ) + + async def _classify_sender(self, email: AggregatedEmail) -> Dict: + """Klassifiziert den Absender.""" + from_domain = email.from_address.split("@")[1].lower() + + # Regelbasierte Erkennung + for key, pattern in self.SENDER_PATTERNS.items(): + if from_domain in pattern["domains"]: + return { + "type": pattern["type"], + "organization": pattern["organization"], + "confidence": 0.95 + } + + # Fallback: LLM-basierte Erkennung + prompt = f""" + Klassifiziere den Absender dieser E-Mail: + + Von: {email.from_name} <{email.from_address}> + Betreff: {email.subject} + + Mögliche Kategorien: + - behoerde (Schulbehörde, Ministerium, etc.) + - schultraeger (Kommune, Kreis, Stadt) + - eltern (Eltern, Elternvertreter) + - kollegium (andere Lehrkräfte) + - sonstige + + Antworte nur mit der Kategorie und optional dem Organisationsnamen. + Format: kategorie|organisationsname + """ + + response = await self.llm.complete(prompt, max_tokens=50) + parts = response.strip().split("|") + + return { + "type": parts[0] if parts else "sonstige", + "organization": parts[1] if len(parts) > 1 else None, + "confidence": 0.7 + } + + async def _extract_deadline(self, email: AggregatedEmail) -> Dict: + """Extrahiert Fristen aus der E-Mail.""" + text = email.body_text or "" + + for pattern in self.DEADLINE_PATTERNS: + match = re.search(pattern, text, re.IGNORECASE) + if match: + deadline_str = match.group(1) + + # Versuche Datum zu parsen + try: + if "Tagen" in deadline_str or "Wochen" in deadline_str: + # Relative Frist + num = int(re.search(r"\d+", deadline_str).group()) + unit = "weeks" if "Wochen" in deadline_str else "days" + deadline_date = email.date + timedelta(**{unit: num}) + else: + # Absolutes Datum + deadline_date = datetime.strptime(deadline_str, "%d.%m.%Y") + + return { + "has_deadline": True, + "date": deadline_date, + "text": match.group(0), + "confidence": 0.9 + } + except: + pass + + # LLM-Fallback für komplexere Fristen + prompt = f""" + Enthält diese E-Mail eine Frist oder einen Abgabetermin? + + Betreff: {email.subject} + Text: {email.body_text[:1000]} + + Wenn ja, antworte mit: JA|DATUM|ORIGINALTEXT + Wenn nein, antworte mit: NEIN + """ + + response = await self.llm.complete(prompt, max_tokens=100) + + if response.startswith("JA"): + parts = response.split("|") + if len(parts) >= 2: + try: + return { + "has_deadline": True, + "date": datetime.strptime(parts[1].strip(), "%d.%m.%Y"), + "text": parts[2] if len(parts) > 2 else None, + "confidence": 0.7 + } + except: + pass + + return {"has_deadline": False} + + async def _generate_responses( + self, + email: AggregatedEmail, + sender_info: Dict, + summary_info: Dict + ) -> List[str]: + """Generiert Antwort-Vorschläge.""" + + prompt = f""" + Generiere 3 kurze, professionelle Antwort-Vorschläge für diese E-Mail. + + Kontext: + - Absender: {sender_info.get('organization', email.from_name)} ({sender_info['type']}) + - Betreff: {email.subject} + - Zusammenfassung: {summary_info['summary']} + + Antworte im folgenden Format: + 1. [Erste Antwort-Option] + 2. [Zweite Antwort-Option] + 3. [Dritte Antwort-Option] + + Die Antworten sollten: + - Formell und respektvoll sein + - Zur Situation passen + - Unterschiedliche Optionen bieten (zustimmen, nachfragen, ablehnen) + """ + + response = await self.llm.complete(prompt, max_tokens=500) + + # Parse die drei Vorschläge + suggestions = [] + for line in response.split("\n"): + if line.strip().startswith(("1.", "2.", "3.")): + suggestion = line.split(".", 1)[1].strip() + if suggestion: + suggestions.append(suggestion) + + return suggestions[:3] +``` + +--- + +## 4. Datenmodell + +### 4.1 SQL Schema + +```sql +-- ============================================================ +-- UNIFIED INBOX SCHEMA +-- Version: 2.0.0 +-- ============================================================ + +-- Externe E-Mail-Konten +CREATE TABLE external_email_accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Identität + email_address VARCHAR(255) NOT NULL, + display_name VARCHAR(255) NOT NULL, + color VARCHAR(7) NOT NULL DEFAULT '#3B82F6', -- Hex-Farbe + + -- Server-Konfiguration + account_type VARCHAR(50) NOT NULL DEFAULT 'imap', + imap_host VARCHAR(255) NOT NULL, + imap_port INTEGER NOT NULL DEFAULT 993, + smtp_host VARCHAR(255) NOT NULL, + smtp_port INTEGER NOT NULL DEFAULT 587, + use_ssl BOOLEAN NOT NULL DEFAULT true, + + -- Credentials (Vault-Referenz!) + vault_secret_path VARCHAR(500) NOT NULL, + + -- Sync-Status + last_sync TIMESTAMP, + sync_state JSONB DEFAULT '{}', + sync_error VARCHAR(500), + supports_idle BOOLEAN DEFAULT false, + + -- Signaturen + signature_html TEXT, + signature_text TEXT, + + -- Status + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + + -- Constraints + CONSTRAINT unique_user_email UNIQUE (user_id, email_address) +); + +CREATE INDEX idx_eea_user ON external_email_accounts(user_id); + +-- Aggregierte E-Mails (Cache) +CREATE TABLE aggregated_emails ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + account_id UUID NOT NULL REFERENCES external_email_accounts(id) ON DELETE CASCADE, + + -- E-Mail-Identifikation + message_id VARCHAR(500) NOT NULL, + uid INTEGER, -- IMAP UID + + -- E-Mail-Daten + subject VARCHAR(1000), + from_address VARCHAR(255) NOT NULL, + from_name VARCHAR(255), + to_addresses JSONB NOT NULL DEFAULT '[]', + cc_addresses JSONB DEFAULT '[]', + date TIMESTAMP NOT NULL, + + -- Inhalt (verschlüsselt gespeichert) + body_text_encrypted BYTEA, + body_html_encrypted BYTEA, + has_attachments BOOLEAN DEFAULT false, + attachment_info JSONB DEFAULT '[]', + + -- Status + is_read BOOLEAN DEFAULT false, + is_flagged BOOLEAN DEFAULT false, + is_deleted BOOLEAN DEFAULT false, + folder VARCHAR(255) DEFAULT 'INBOX', + + -- KI-Analyse + ai_sender_type VARCHAR(50), + ai_sender_organization VARCHAR(255), + ai_category VARCHAR(50), + ai_priority VARCHAR(20), + ai_has_deadline BOOLEAN DEFAULT false, + ai_deadline_date TIMESTAMP, + ai_deadline_text VARCHAR(500), + ai_summary TEXT, + ai_action_required BOOLEAN DEFAULT false, + ai_suggested_action TEXT, + ai_response_suggestions JSONB DEFAULT '[]', + ai_analyzed_at TIMESTAMP, + ai_confidence FLOAT, + + -- Metadaten + fetched_at TIMESTAMP DEFAULT NOW(), + raw_headers JSONB, + + -- Constraints + CONSTRAINT unique_message UNIQUE (account_id, message_id) +); + +CREATE INDEX idx_ae_account ON aggregated_emails(account_id); +CREATE INDEX idx_ae_date ON aggregated_emails(date DESC); +CREATE INDEX idx_ae_unread ON aggregated_emails(is_read) WHERE is_read = false; +CREATE INDEX idx_ae_deadline ON aggregated_emails(ai_deadline_date) WHERE ai_has_deadline = true; +CREATE INDEX idx_ae_category ON aggregated_emails(ai_category); + +-- Arbeitsvorrat (Tasks) +CREATE TABLE inbox_tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- Quelle + source_type VARCHAR(50) NOT NULL DEFAULT 'email', -- email, manual, calendar + source_email_id UUID REFERENCES aggregated_emails(id), + + -- Task-Daten + title VARCHAR(500) NOT NULL, + description TEXT, + category VARCHAR(50), + priority VARCHAR(20) DEFAULT 'mittel', + + -- Frist + deadline TIMESTAMP, + reminder_at TIMESTAMP, + reminder_sent BOOLEAN DEFAULT false, + + -- Status + status VARCHAR(20) DEFAULT 'offen', -- offen, in_bearbeitung, erledigt, delegiert + completed_at TIMESTAMP, + + -- Delegation + delegated_to UUID REFERENCES users(id), + delegated_at TIMESTAMP, + delegation_note TEXT, + + -- Metadaten + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + created_by VARCHAR(50) DEFAULT 'ai', -- ai, manual + + -- Ursprungs-Account (für Kontext) + account_id UUID REFERENCES external_email_accounts(id) +); + +CREATE INDEX idx_it_user ON inbox_tasks(user_id); +CREATE INDEX idx_it_deadline ON inbox_tasks(deadline); +CREATE INDEX idx_it_status ON inbox_tasks(status); +CREATE INDEX idx_it_source ON inbox_tasks(source_email_id); + +-- Absender-Klassifikation (für schnellere Erkennung) +CREATE TABLE sender_classifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Identifikation + email_domain VARCHAR(255) NOT NULL, + email_pattern VARCHAR(255), -- z.B. "*@landesschulbehoerde.niedersachsen.de" + + -- Klassifikation + sender_type VARCHAR(50) NOT NULL, + organization_name VARCHAR(255), + + -- Region (für länderspezifische Behörden) + bundesland VARCHAR(50), + + -- Vertrauen + confidence FLOAT DEFAULT 1.0, + verified BOOLEAN DEFAULT false, + verified_by UUID, + + created_at TIMESTAMP DEFAULT NOW(), + + CONSTRAINT unique_domain UNIQUE (email_domain) +); + +-- Seed-Daten für Niedersachsen +INSERT INTO sender_classifications (email_domain, sender_type, organization_name, bundesland, verified) VALUES +('nlschb.niedersachsen.de', 'behoerde', 'Landesschulbehörde Niedersachsen', 'niedersachsen', true), +('landesschulbehoerde.niedersachsen.de', 'behoerde', 'Landesschulbehörde Niedersachsen', 'niedersachsen', true), +('mk.niedersachsen.de', 'behoerde', 'Kultusministerium Niedersachsen', 'niedersachsen', true), +('schule.niedersachsen.de', 'behoerde', 'Bildungsportal Niedersachsen', 'niedersachsen', true), +('nibis.de', 'behoerde', 'NiBiS - Niedersächsischer Bildungsserver', 'niedersachsen', true), +('rlsb.de', 'behoerde', 'Regionales Landesamt für Schule und Bildung', 'niedersachsen', true); + +-- Antwort-Vorlagen +CREATE TABLE response_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Zuordnung + user_id UUID REFERENCES users(id), -- NULL = global + sender_type VARCHAR(50), -- Für welchen Absender-Typ + category VARCHAR(50), -- Für welche Kategorie + + -- Vorlage + name VARCHAR(255) NOT NULL, + subject_template VARCHAR(500), + body_template TEXT NOT NULL, + + -- Platzhalter-Info + placeholders JSONB DEFAULT '[]', -- ["{{anrede}}", "{{datum}}"] + + -- Status + is_active BOOLEAN DEFAULT true, + usage_count INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Audit-Log für E-Mail-Aktionen +CREATE TABLE email_audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + user_id UUID NOT NULL REFERENCES users(id), + account_id UUID REFERENCES external_email_accounts(id), + email_id UUID REFERENCES aggregated_emails(id), + + action VARCHAR(50) NOT NULL, -- read, sent, deleted, delegated + details JSONB, + + ip_address INET, + user_agent VARCHAR(500), + + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_eal_user ON email_audit_log(user_id); +CREATE INDEX idx_eal_created ON email_audit_log(created_at); +``` + +--- + +## 5. API-Endpunkte + +### 5.1 Accounts API + +```yaml +/api/v1/unified-inbox/accounts: + get: + summary: Liste aller verbundenen E-Mail-Konten + responses: + 200: + description: Account-Liste mit Sync-Status + + post: + summary: Neues E-Mail-Konto verbinden + requestBody: + content: + application/json: + schema: + type: object + required: [email_address, imap_host, smtp_host, username, password] + properties: + email_address: + type: string + format: email + display_name: + type: string + color: + type: string + imap_host: + type: string + imap_port: + type: integer + default: 993 + smtp_host: + type: string + smtp_port: + type: integer + default: 587 + username: + type: string + password: + type: string + format: password + responses: + 201: + description: Account erfolgreich verbunden + +/api/v1/unified-inbox/accounts/{account_id}: + delete: + summary: E-Mail-Konto trennen + description: Entfernt das Konto und alle gecachten E-Mails + responses: + 200: + description: Account getrennt + + post: + summary: Konto synchronisieren + parameters: + - name: action + in: query + schema: + type: string + enum: [sync, test] + responses: + 200: + description: Sync gestartet oder Test erfolgreich +``` + +### 5.2 Inbox API + +```yaml +/api/v1/unified-inbox/emails: + get: + summary: Vereinheitlichte Inbox + parameters: + - name: account_id + in: query + description: Filter nach Konto (optional) + schema: + type: string + format: uuid + - name: category + in: query + schema: + type: string + enum: [behoerde, schultraeger, personal, haushalt, paedagogik, sonstige] + - name: priority + in: query + schema: + type: string + enum: [hoch, mittel, niedrig] + - name: has_deadline + in: query + schema: + type: boolean + - name: unread_only + in: query + schema: + type: boolean + - name: page + in: query + schema: + type: integer + default: 1 + responses: + 200: + description: E-Mail-Liste mit KI-Analyse + +/api/v1/unified-inbox/emails/{email_id}: + get: + summary: E-Mail-Details + responses: + 200: + description: Vollständige E-Mail mit Analyse + + patch: + summary: E-Mail aktualisieren (lesen, markieren) + requestBody: + content: + application/json: + schema: + type: object + properties: + is_read: + type: boolean + is_flagged: + type: boolean + +/api/v1/unified-inbox/emails/{email_id}/reply: + post: + summary: Auf E-Mail antworten + requestBody: + content: + application/json: + schema: + type: object + required: [body_text] + properties: + body_text: + type: string + body_html: + type: string + use_suggestion: + type: integer + description: Index des Antwort-Vorschlags (0-2) +``` + +### 5.3 Tasks API + +```yaml +/api/v1/unified-inbox/tasks: + get: + summary: Arbeitsvorrat + parameters: + - name: status + in: query + schema: + type: string + enum: [offen, in_bearbeitung, erledigt, delegiert, ueberfaellig] + - name: priority + in: query + schema: + type: string + - name: deadline_before + in: query + schema: + type: string + format: date + responses: + 200: + description: Task-Liste + + post: + summary: Manuelle Aufgabe erstellen + requestBody: + content: + application/json: + schema: + type: object + required: [title] + properties: + title: + type: string + description: + type: string + deadline: + type: string + format: date-time + priority: + type: string + link_to_email: + type: string + format: uuid + +/api/v1/unified-inbox/tasks/{task_id}: + patch: + summary: Task aktualisieren + requestBody: + content: + application/json: + schema: + type: object + properties: + status: + type: string + enum: [offen, in_bearbeitung, erledigt] + priority: + type: string + deadline: + type: string + format: date-time + +/api/v1/unified-inbox/tasks/{task_id}/delegate: + post: + summary: Task delegieren + requestBody: + content: + application/json: + schema: + type: object + required: [delegate_to] + properties: + delegate_to: + type: string + format: uuid + note: + type: string +``` + +--- + +## 6. Sicherheit + +### 6.1 Credential-Management + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ CREDENTIAL SECURITY │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ NIEMALS IM KLARTEXT: │ +│ ├── Passwörter werden direkt nach Eingabe an Vault gesendet │ +│ ├── Im Backend nur als Vault-Referenz gespeichert │ +│ ├── Credentials werden bei jeder Verbindung neu aus Vault │ +│ │ gelesen und sofort nach Nutzung verworfen │ +│ └── Audit-Log für jeden Vault-Zugriff │ +│ │ +│ VAULT-STRUKTUR: │ +│ secret/ │ +│ └── mail-accounts/ │ +│ └── {user_id}/ │ +│ └── {email_address}/ │ +│ ├── username │ +│ ├── password (verschlüsselt) │ +│ └── oauth_token (wenn OAuth) │ +│ │ +│ ROTATION: │ +│ ├── OAuth-Tokens: Automatisch via Refresh-Token │ +│ └── Passwörter: Bei Änderung durch Nutzer │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 6.2 E-Mail-Inhalt-Verschlüsselung + +```python +# E-Mail-Inhalte werden at-rest verschlüsselt + +class EmailEncryption: + """ + Verschlüsselt E-Mail-Inhalte vor der Speicherung. + + - Jeder User hat einen eigenen Encryption Key + - Keys liegen in Vault, nicht in der DB + - Bei User-Löschung: Key vernichten = Daten unlesbar + """ + + async def encrypt_email_content( + self, + user_id: str, + body_text: str, + body_html: Optional[str] + ) -> Tuple[bytes, Optional[bytes]]: + # User-spezifischen Key aus Vault holen + key = await self.vault.read(f"secret/user-keys/{user_id}/email-key") + + # Verschlüsseln + encrypted_text = self._encrypt(body_text, key) + encrypted_html = self._encrypt(body_html, key) if body_html else None + + return encrypted_text, encrypted_html + + async def decrypt_email_content( + self, + user_id: str, + encrypted_text: bytes, + encrypted_html: Optional[bytes] + ) -> Tuple[str, Optional[str]]: + key = await self.vault.read(f"secret/user-keys/{user_id}/email-key") + + text = self._decrypt(encrypted_text, key) + html = self._decrypt(encrypted_html, key) if encrypted_html else None + + return text, html +``` + +--- + +## 7. BreakPilot GmbH Interne Nutzung + +### 7.1 Dual-Use-Konzept + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ DUAL-USE ARCHITEKTUR │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ BREAKPILOT GMBH (Intern) │ +│ ├── Eigene Domain: @breakpilot.de │ +│ ├── Eigener Mail-Server (Stalwart) │ +│ ├── Funktionale Mailboxen für Rollen │ +│ │ ├── support@breakpilot.de │ +│ │ ├── sales@breakpilot.de │ +│ │ ├── datenschutz@breakpilot.de │ +│ │ └── buchhaltung@breakpilot.de │ +│ ├── Mitarbeiter-Anonymisierung bei Ausscheiden │ +│ └── Volle Matrix/Jitsi/Kalender-Integration │ +│ │ +│ SCHULEN (Kunden) │ +│ ├── Option A: Unified Inbox │ +│ │ └── Aggregiert externe Konten (IMAP) │ +│ │ ├── schulleitung@grundschule-xy.de │ +│ │ ├── vorname.nachname@schule.nds.de │ +│ │ └── weitere... │ +│ │ │ +│ ├── Option B: Gehostete Mailboxen │ +│ │ └── @schule.breakpilot.app │ +│ │ ├── schulleitung@grundschule.breakpilot.app │ +│ │ ├── sekretariat@grundschule.breakpilot.app │ +│ │ └── weitere... │ +│ │ │ +│ └── Option C: Hybrid │ +│ ├── Gehostete BreakPilot-Mailbox für Alltag │ +│ └── + Aggregation externer Pflicht-Konten │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 7.2 Internes Dogfooding + +``` +BreakPilot GmbH nutzt das System selbst: + +1. Alle Mitarbeiter haben @breakpilot.de Mailboxen +2. Funktionale Adressen für Abteilungen +3. Vollständige KI-Assistenz im Alltag +4. Beweis der Funktionalität für Kunden +5. Kontinuierliche Verbesserung durch eigene Nutzung +``` + +--- + +## 8. Roadmap + +### Phase 1: Foundation (6-8 Wochen) +- [ ] Mail Aggregator Service (IMAP/SMTP) +- [ ] Account-Management (hinzufügen, entfernen, sync) +- [ ] Unified Inbox UI (Basic) +- [ ] Credential-Management (Vault) +- [ ] BreakPilot interne Nutzung + +### Phase 2: KI-Integration (4-6 Wochen) +- [ ] Absender-Erkennung (regelbasiert + LLM) +- [ ] Fristen-Extraktion +- [ ] Kategorisierung +- [ ] Prioritäts-Scoring +- [ ] Niedersachsen-spezifische Behörden-DB + +### Phase 3: Arbeitsvorrat (3-4 Wochen) +- [ ] Task-Extraktion aus E-Mails +- [ ] Deadline-Tracking +- [ ] Erinnerungen (E-Mail, Push) +- [ ] Kalender-Integration +- [ ] Delegation + +### Phase 4: Antwort-Assistenz (3-4 Wochen) +- [ ] Antwort-Vorschläge (LLM) +- [ ] Vorlagen-Management +- [ ] Ein-Klick-Antworten +- [ ] Signatur-Management + +### Phase 5: Integrationen (4-6 Wochen) +- [ ] Jitsi-Integration (Meetings aus E-Mail) +- [ ] Matrix-Integration (Chat-Verknüpfung) +- [ ] CalDAV-Sync +- [ ] Mobile PWA + +### Phase 6: Erweiterung (Ongoing) +- [ ] Weitere Bundesländer +- [ ] OAuth-Support (Microsoft 365, Google) +- [ ] Exchange Web Services +- [ ] Desktop-App (Electron) + +--- + +## 9. Referenzdokumente + +Diese Spezifikation ergänzt: + +1. **Mail-RBAC Architektur:** `/docs/architecture/mail-rbac-architecture.md` +2. **Mail-RBAC Developer Spec:** `/docs/klausur-modul/MAIL-RBAC-DEVELOPER-SPECIFICATION.md` +3. **DSGVO-Konzept:** `/docs/architecture/dsgvo-compliance.md` + +--- + +## Anhang: Niedersachsen-spezifische Informationen + +### Behörden und ihre Domains + +| Behörde | Domain(s) | Typische Inhalte | +|---------|-----------|------------------| +| Landesschulbehörde | nlschb.niedersachsen.de | Statistiken, Personal, Genehmigungen | +| Kultusministerium | mk.niedersachsen.de | Erlasse, Verordnungen | +| Bildungsportal | schule.niedersachsen.de | Account-Info, Fortbildungen | +| NiBiS | nibis.de | Curricula, Materialien | +| RLSB | rlsb.de | Regionale Verwaltung | + +### Typische Fristen + +| Anlass | Übliche Frist | Häufigkeit | +|--------|---------------|------------| +| Schülerzahlen-Statistik | September/März | 2x jährlich | +| Haushaltsentwurf | November | 1x jährlich | +| Prüfungstermine | Dezember/Januar | 1x jährlich | +| Personalplanung | Februar | 1x jährlich | +| Fortbildungsanträge | Laufend | Variabel | diff --git a/docs/testing/h5p-service-tests.md b/docs/testing/h5p-service-tests.md new file mode 100644 index 0000000..f9c2a99 --- /dev/null +++ b/docs/testing/h5p-service-tests.md @@ -0,0 +1,184 @@ +# H5P Service Tests + +## Übersicht + +Das H5P Service Modul verfügt über umfassende Integration Tests, die alle Endpoints und Content-Typen validieren. + +## Test-Coverage + +### Module +- **Server Endpoints**: Health, Info, Editor Selection +- **8 Content Type Editors**: Quiz, Video, Presentation, Flashcards, Timeline, Drag & Drop, Fill Blanks, Memory +- **8 Content Type Players**: Interaktive Player für alle Content-Typen +- **Static Files**: Korrekte Bereitstellung von Editoren, Players und Assets +- **Error Handling**: 404-Behandlung für ungültige Routen + +## Tests ausführen + +### Lokal + +```bash +cd h5p-service +npm install +npm test +``` + +### Im Docker Container + +```bash +# Service starten +docker compose -f docker-compose.content.yml up -d h5p-service + +# Tests ausführen +docker compose -f docker-compose.content.yml exec h5p-service npm test +``` + +## Test-Dateien + +| Datei | Beschreibung | +|-------|--------------| +| `h5p-service/tests/server.test.js` | Integration Tests für alle Endpoints | +| `h5p-service/tests/setup.js` | Jest Test Setup & Configuration | +| `h5p-service/jest.config.js` | Jest Configuration | + +## Coverage Reports + +Nach dem Ausführen der Tests: + +```bash +# HTML Report öffnen +open h5p-service/coverage/lcov-report/index.html +``` + +Coverage-Ziel: **>80%** + +## Test-Kategorien + +### 1. Health & Info Tests +- Service-Verfügbarkeit +- Health Check Endpoint +- Service Info Page + +### 2. Editor Selection Tests +- Alle 8 Content-Typen sichtbar +- Korrekte Links zu Editoren +- UI-Elemente vorhanden + +### 3. Content Type Tests + +Für jeden der 8 Content-Typen: +- Editor lädt korrekt +- Player lädt korrekt +- Erforderliche UI-Elemente vorhanden + +**Content-Typen:** +1. Quiz (Question Set) +2. Interactive Video +3. Course Presentation +4. Flashcards +5. Timeline +6. Drag and Drop +7. Fill in the Blanks +8. Memory Game + +### 4. Static File Tests +- Core Files erreichbar +- Editor Files erreichbar +- Player Files erreichbar + +### 5. Error Handling Tests +- 404 für ungültige Routes +- Fehlerbehandlung für fehlende Editoren/Players + +## CI/CD Integration + +Die Tests sind in die CI/CD Pipeline integriert: + +```yaml +# .github/workflows/tests.yml (Beispiel) +name: H5P Service Tests + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Start H5P Service + run: docker compose -f docker-compose.content.yml up -d h5p-service + + - name: Wait for Service + run: sleep 10 + + - name: Run Tests + run: docker compose -f docker-compose.content.yml exec h5p-service npm run test:ci + + - name: Upload Coverage + uses: codecov/codecov-action@v3 + with: + files: ./h5p-service/coverage/lcov.info +``` + +## Test-Qualität + +### Best Practices + +1. **Isolation**: Jeder Test ist unabhängig +2. **Cleanup**: Keine persistenten Änderungen +3. **Assertions**: Klare Expectations +4. **Speed**: Tests laufen schnell (<10s gesamt) +5. **Reliability**: Tests sind deterministisch + +### Code Review Checklist + +Bei neuen Features: +- [ ] Tests für neue Endpoints hinzugefügt +- [ ] Coverage bleibt >80% +- [ ] Tests sind dokumentiert +- [ ] CI/CD Tests bestehen + +## Troubleshooting + +### Tests schlagen fehl + +**Service nicht erreichbar:** +```bash +# Service Status prüfen +docker compose -f docker-compose.content.yml ps + +# Logs ansehen +docker compose -f docker-compose.content.yml logs h5p-service +``` + +**Port-Konflikte:** +```bash +# Prüfe, ob Port 8003 belegt ist +lsof -i :8003 + +# Stoppe andere Services +docker compose -f docker-compose.content.yml down +``` + +**Veraltete Dependencies:** +```bash +cd h5p-service +rm -rf node_modules package-lock.json +npm install +``` + +## Zukünftige Erweiterungen + +- [ ] E2E Tests mit Playwright +- [ ] Content Validation Tests +- [ ] Performance Tests +- [ ] Security Tests (XSS, CSRF) +- [ ] Load Tests +- [ ] Visual Regression Tests + +## Verwandte Dokumentation + +- [H5P Service README](../../h5p-service/tests/README.md) +- [Content Service Tests](./content-service-tests.md) +- [Integration Testing Guide](./integration-testing.md) diff --git a/docs/testing/integration-test-environment.md b/docs/testing/integration-test-environment.md new file mode 100644 index 0000000..f9ff2fc --- /dev/null +++ b/docs/testing/integration-test-environment.md @@ -0,0 +1,333 @@ +# Integration Test Environment + +> **Letzte Aktualisierung:** 2026-02-04 +> **Status:** Produktiv +> **Maintainer:** DevOps Team + +--- + +## Inhaltsverzeichnis + +1. [Uebersicht](#1-uebersicht) +2. [Quick Start (Lokal)](#2-quick-start-lokal) +3. [Services](#3-services) +4. [CI/CD Integration](#4-cicd-integration) +5. [Konfiguration](#5-konfiguration) +6. [Troubleshooting](#6-troubleshooting) + +--- + +## 1. Uebersicht + +Die Integration-Test-Umgebung ermoeglicht vollstaendige End-to-End-Tests aller Services in einer isolierten Docker-Compose-Umgebung. Sie wird sowohl lokal als auch in der CI/CD-Pipeline verwendet. + +### Architektur + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ docker-compose.test.yml │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ +│ │ postgres-test│ │ valkey-test │ │ mailpit-test │ │ +│ │ Port 55432 │ │ Port 56379 │ │ Web: 58025 │ │ +│ │ PostgreSQL │ │ Redis/Valkey│ │ SMTP: 51025 │ │ +│ └──────────────┘ └──────────────┘ └──────────────────────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ consent-service-test │ │ +│ │ Port 58081 │ │ +│ │ Go Authentication Service │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐ │ +│ │ backend-test │ │ +│ │ Port 58000 │ │ +│ │ Python FastAPI Backend │ │ +│ └─────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Vorteile + +- **Isolation:** Keine Konflikte mit Produktions- oder Entwicklungs-Datenbanken +- **Reproduzierbarkeit:** Identische Umgebung lokal und in CI/CD +- **Health Checks:** Automatisches Warten auf Service-Verfuegbarkeit +- **Cleanup:** Automatisches Entfernen aller Volumes nach Tests + +--- + +## 2. Quick Start (Lokal) + +### Test-Umgebung starten + +```bash +# 1. Test-Umgebung starten +docker compose -f docker-compose.test.yml up -d + +# 2. Warten bis alle Services healthy sind +docker compose -f docker-compose.test.yml ps + +# 3. Health-Status pruefen (alle sollten "healthy" zeigen) +watch docker compose -f docker-compose.test.yml ps +``` + +### Integration Tests ausfuehren + +```bash +# In das Backend-Verzeichnis wechseln +cd backend + +# Virtual Environment aktivieren (falls vorhanden) +source venv/bin/activate + +# Integration Tests ausfuehren +export SKIP_INTEGRATION_TESTS=false +pytest tests/test_integration/ -v +``` + +### Services einzeln testen + +```bash +# Datenbank-Verbindung testen +psql -h localhost -p 55432 -U breakpilot -d breakpilot_test -c "SELECT 1" + +# Consent Service Health Check +curl -f http://localhost:58081/health + +# Backend Health Check +curl -f http://localhost:58000/health + +# Mailpit Web UI +open http://localhost:58025 +``` + +### Aufraeumen + +```bash +# Alle Container stoppen und Volumes loeschen +docker compose -f docker-compose.test.yml down -v +``` + +--- + +## 3. Services + +### Port-Strategie + +| Service | Test-Port (extern) | Container-Port | Prod-Port | +|---------|-------------------|----------------|-----------| +| PostgreSQL | 55432 | 5432 | 5432 | +| Valkey/Redis | 56379 | 6379 | 6379 | +| Consent Service | 58081 | 8081 | 8081 | +| Backend | 58000 | 8000 | 8000 | +| Mailpit Web | 58025 | 8025 | - | +| Mailpit SMTP | 51025 | 1025 | - | + +### Service-Details + +#### postgres-test + +- **Image:** `postgres:16-alpine` +- **Credentials:** `breakpilot:breakpilot_test@breakpilot_test` +- **Health Check:** `pg_isready -U breakpilot -d breakpilot_test` + +#### valkey-test + +- **Image:** `valkey/valkey:7-alpine` +- **Health Check:** `valkey-cli ping` +- **Hinweis:** Ersetzt Redis fuer bessere ARM64-Kompatibilitaet + +#### consent-service-test + +- **Build:** `./consent-service/Dockerfile` +- **Health Check:** `GET /health` +- **Abhaengigkeiten:** postgres-test, valkey-test + +#### backend-test + +- **Build:** `./backend/Dockerfile` +- **Health Check:** `GET /health` +- **Abhaengigkeiten:** postgres-test, valkey-test, consent-service-test + +#### mailpit-test + +- **Image:** `axllent/mailpit:latest` +- **Web UI:** http://localhost:58025 +- **SMTP:** localhost:51025 +- **Zweck:** E-Mail-Testing ohne echten SMTP-Server + +--- + +## 4. CI/CD Integration + +### Woodpecker Pipeline + +Die Integration-Tests laufen automatisch bei jedem Push/PR zu `main` oder `develop`: + +```yaml +# .woodpecker/main.yml +integration-tests: + image: docker:27-cli + volumes: + - /var/run/docker.sock:/var/run/docker.sock + commands: + - docker compose -f docker-compose.test.yml up -d + - # Wait for services... + - docker compose -f docker-compose.test.yml exec -T backend-test \ + pytest tests/test_integration/ -v + - docker compose -f docker-compose.test.yml down -v + when: + - event: [push, pull_request] + branch: [main, develop] +``` + +### Pipeline-Ablauf + +1. **Unit Tests** (test-go-*, test-python-*) laufen zuerst +2. **Integration Tests** starten Docker Compose Umgebung +3. **Report** sendet alle Ergebnisse ans Test Dashboard + +### Manueller Pipeline-Trigger + +```bash +# Via Gitea/Woodpecker UI oder: +curl -X POST "https://macmini:4431/api/repos/pilotadmin/breakpilot-pwa/pipelines" \ + -H "Authorization: Bearer $WOODPECKER_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"branch":"main"}' +``` + +--- + +## 5. Konfiguration + +### Environment Variables + +Die Test-Umgebung konfiguriert automatisch alle noetigen Variablen: + +| Variable | Wert in Test-Umgebung | +|----------|----------------------| +| `DATABASE_URL` | `postgresql://breakpilot:breakpilot_test@postgres-test:5432/breakpilot_test` | +| `CONSENT_SERVICE_URL` | `http://consent-service-test:8081` | +| `VALKEY_URL` | `redis://valkey-test:6379` | +| `REDIS_URL` | `redis://valkey-test:6379` | +| `JWT_SECRET` | `test-jwt-secret-for-integration-tests` | +| `ENVIRONMENT` | `test` | +| `SMTP_HOST` | `mailpit-test` | +| `SMTP_PORT` | `1025` | + +### conftest.py Integration + +Das `backend/tests/conftest.py` erkennt automatisch die Integration-Umgebung: + +```python +# Wird aktiviert wenn SKIP_INTEGRATION_TESTS=false +IS_INTEGRATION_ENV = os.environ.get("SKIP_INTEGRATION_TESTS", "").lower() == "false" + +if IS_INTEGRATION_ENV: + os.environ.setdefault("DATABASE_URL", + "postgresql://breakpilot:breakpilot_test@postgres-test:5432/breakpilot_test") + # ... weitere Container-URLs +``` + +--- + +## 6. Troubleshooting + +### Service startet nicht + +```bash +# Logs eines spezifischen Services anzeigen +docker compose -f docker-compose.test.yml logs consent-service-test --tail=100 + +# Alle Logs anzeigen +docker compose -f docker-compose.test.yml logs +``` + +### Health Check schlaegt fehl + +```bash +# Container-Status pruefen +docker compose -f docker-compose.test.yml ps + +# In Container einloggen +docker compose -f docker-compose.test.yml exec backend-test bash + +# Health-Endpoint manuell pruefen +docker compose -f docker-compose.test.yml exec backend-test curl -v http://localhost:8000/health +``` + +### Datenbank-Verbindungsprobleme + +```bash +# Datenbank-Container pruefen +docker compose -f docker-compose.test.yml exec postgres-test pg_isready -U breakpilot + +# Datenbank-Logs +docker compose -f docker-compose.test.yml logs postgres-test +``` + +### Port-Konflikte + +Falls Ports bereits belegt sind: + +```bash +# Pruefen welcher Prozess den Port belegt +lsof -i :55432 + +# Container mit anderen Ports starten (manuell .yml anpassen) +# Oder: Bestehende Container stoppen +docker compose -f docker-compose.test.yml down +``` + +### Tests finden keine Services + +Stellen Sie sicher, dass: + +1. `SKIP_INTEGRATION_TESTS=false` gesetzt ist +2. Tests innerhalb des Docker-Netzwerks laufen +3. Container-Namen (nicht localhost) verwendet werden + +```bash +# Innerhalb des Backend-Containers: +export SKIP_INTEGRATION_TESTS=false +pytest tests/test_integration/ -v +``` + +### Cleanup bei Problemen + +```bash +# Komplettes Aufräumen +docker compose -f docker-compose.test.yml down -v --remove-orphans + +# Auch Netzwerke entfernen +docker network prune -f + +# Alle Test-Container entfernen +docker rm -f $(docker ps -a -q --filter "name=breakpilot-*-test") 2>/dev/null || true +``` + +--- + +## Anhang: Test-Verzeichnisstruktur + +``` +backend/tests/ +├── conftest.py # Pytest Konfiguration mit Integration-Detection +├── test_consent_client.py # Unit Tests (Mock-basiert) +├── test_gdpr_api.py # Unit Tests +└── test_integration/ # Integration Tests (benoetigen Docker Compose) + ├── __init__.py + ├── conftest.py # Integration-spezifische Fixtures + ├── test_consent_flow.py # E2E Consent Workflow + ├── test_auth_flow.py # E2E Auth Workflow + └── test_email_flow.py # E2E E-Mail Tests (Mailpit) +``` + +--- + +*Generiert am 2026-02-04 von Claude Code* diff --git a/docs/za-download-2/2016/2016MatheBG/2016MatheTech/2016MatheTechCAS/2016MatheTechCASEA/Anlagen/Baumdigramm.jpg b/docs/za-download-2/2016/2016MatheBG/2016MatheTech/2016MatheTechCAS/2016MatheTechCASEA/Anlagen/Baumdigramm.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8d0c49f56465d1392005583d9684d934b906f888 GIT binary patch literal 32682 zcmeFYWmH^UmoB;q8Z@|DAc5fS8iIuoAOyD%2v)cjZowS_1oz+&+#xuG;7*~1yF+2c zt@rDDzSG^~j&u6$d;5%Y#<*2uj`~rnR_(d>T5~<~na_Njdt3#cy_Hjx1CWrAfKP}w z@CX5901PxVbTm{9baeEmPcbmDiEyy7u&~Jq2=R!hDQIb^DX6IE7&(~ep0hDfQ85d? zV&mfG{$h?u2lUGoDtNu|#Q%hS%*TnRTnYo3fm6NlJs~gzeBk*fba7buacznXQ#H8f! zDXF=6`2{};i;7FCYijH28ycIMySjUN`}zk4e@{$KP0!5E%`dD$*Ecq|ws&^-PS4IS zF0Za(H@AQJg#;k~i(CKn?BDr?kMQdW3JNj``d@w_J#j-AGCm3_JvSPGv>Ljx1L1QX ze+;76aXD3;PZ@YWoDhF@9LFSK0}@a#Y4SipbEvwu4F-~Czuu#u4v4-Xk1 zkOZ!8nRERx|F%_1!XS?TV~D{c(BS((OAUCS5})zzW?|EvSQ$dVSz0tNNR*i7lX!pX zAENy5$~g-j7=dSu`g)%9wUq5jK6SQ{QJ*}%^ww2K)WkC7QF^&0q2qMVU^ek*N%UNb z`jU8?>t1=P%uA(UC-c=w6>*v&{9=v^9X3An_gaJ<93Cg-N3or1tj>J;9aBl45SVan>(X>uj7>5 zrHrhX0^78L`kvFCl~JS=t||HZx*>tbsQZJ@5%RE>)2*Spv1`V1*ge^JKytPL^uDUwrtyp8E;iGe9qw2H2c;^^-u zldv{lDI5_HM@&6g7*`wdG-$3KtYPJ zG>9%PE^sWyQ1hM_)XRZUr^lF^l!+zB!&F`Cf`4J zo{;W;cI!E-%oK4iW0xnx2S@LK&onk#eEu1*_C+-txn0q3o=mu3{1Ko6T%EO$bG+nR zT29k_h)(f>x5xPv*LWet%}QV6q?nGGk#id=m-t65vncu5&AYW0rbGOX3d1|UFD16C zQ8@&myF#<8_7gGf0pTq_ai8$BFgV}PgFkKP!98t}N_Humn_#7V+>NaQ3X7xu6NeW6 zOja3P*w0kA@LB_k?$?b|lUhHOAeP48Su^ggIJ&s+kr{vfV!YZw9^a{CuZ1-gE$(Y& zQrn@d;1Nj4K~P$vVk-nmEZ-B$VQiyNcQs2Q!Ol1-dW-sQots_revEDyEGVp93DOz+ zk#A}{Q!^u1=7uu^|GU}0-<}?*L`dhFDW_O#UkwSWCvER>XFa~Ts&(>}7D9@XRQ-6} z{Tt5aMXNHMv<}dtTG{W!Zjuz;Q2!)v_DxIJwB_L{P-}7UW_F$*8_B3i(Q;Vvf^Aw2 zRx`BTw9IgpL#MJuf=i22FgQ%*o;JZyP-D{_M%BajTiKzMoFN^Atxk4OzUF$7^5X^T z6!uJMp5sJ6gY1nsx~l;O{-at|^r0u4m0mASgLe-%8!Rue6e-B%Xn1TF4~^#{S7%jD zRp83|okmydk3fEmk(8PgjpV5>{4HrW?Hy*;1Et+ZKIP9VYgxUVWb#m02F(v1QE3$xBIrY#oOcMGq2^q9LUs+HXQ{WmWbltY34jqxi+~a>R zQLUQvpgdURmhFI~!`SjSB zw#b62T9#nOjYj~F_yTpQv85(+K0k?`&xSmnlzBfcvJ_XEI&|02Nw8amNae?H-q0z7 zTC!p~k0SP!CF4lc^t;{$r*sdKNLBR2dF&luRB0+|&Gnc&@8~wk_4noFmYNita}||k zweA&Hdksl+H&yb9eHa_2r1w(P`(u361f$)l#F;Ul%IfRmLMI)DXg0DO6`A&R`H;?i z{1&h!sav@{bLw+%-;FB~tnAU4*>0WXqZB8gJiunNw;T9#PHGSjq_5-Z0r5b>{(2y^ zl8?Y}&Lfa@^9U4-cWhL8u|EP;ns^WR;+_vgNb7K#M<5~R)cheJ;SrFo%UeUg|0Wr> zaz#vgD*$Z^2nU#R|9|_hx(8=jVNSY_K$mfW9;^a-yV6-{0ZCnK?tig-kNUlk-u2@8 z-trbbxK`BLTzRUUVn3-AMD@$cm39G-&+(T>Mt&6ma-=!(qT9smy+#pOruSL}ieby) z^}_p!J7Kpqqns5yZzn3R?5neJj#_PlK>SOr=S6U6%m5iwY9m#xFSv!88(ov~|ZosQr=4G(` zJBBqcA3pfVx!Z!C{zJ?tBt|G3(qXyVQL*f>(Aa@Dk}N<=Bt{n?U14Cj=cWqbvli@q z&ccA!C(P)OexAB5ro3Lxs|tJ8PFWXxS|_R`s^qzox-_PHqe8}ZASe6<0G!S-ftgb( zk>m&;0X>V`l_19ENt<#(d7Fn4{5O&o^H(d$RO>OU$M&o((_&7=QJ>G>pUei?x~%-< ziav8`RsyekAy+ARUjdQ+ll)q6TT=L@%0o+~WXG5naW1&OC+pY1n~ z9_|pxtbww3CPzT$aLR;6*O!ngQw5m@S_3Dao12HdYfoF-b!Jsc3&|AKsi4sz2dgpI zZ7QMCatf_yf~vA{(N{}bv!3!PGMc%W*#G(Cs3=HantEJ13V-T5NI%wbhzPCngNFW6 zs6=Z2VWX-HZS^hydwx*nJXnY>EaCI9o2wl%5i7}Bru&3+$@d80z>XD9*)JBH?N_v| z>Q95Kpmr$B7Pnaoe_R(bqW-kz|Ew4zo)y)8!c!iz z8JgP>(PQ|kZn9Nl4YGmaJ4{5$H!E4Xi%^=jH+Y+(we-C|U9i9HV&YR_5`1;^a+$kBeKUZc=e6OjNmjrfjojDnBwJ)GV~Gb2@yZ zc*C5sgQCUNISNp6j1W9Gn1VYCv2fy({u#C824g=VpS6B!jgM$ z6Te-%aw}3tWoA&aJ+mWo9rF64u%3EUl#RzR(M}06S*s{rH9$mF6#*0z<;Hp~9=zq+ z{NK3f%aE?CQSnzC_mt}o9{b&spoRAE!F;QvYdKbpxHkhKYzGQk@BM%$_rt>;i9|?4 zJ3dsclVWbrHb+Z3kb%*GulbwL23|$s0;_Upit8wF;t`qEQ3|jnCtt@?knTqh_CKR+ zAaAUcvWE0Hd_ywxxaKyFa3CkwvNGSIKGiF^L2yEvOslEM@$Bu=T)g7O)hIP<$f;X! zt3xTci(3A@FYai8PDz@Qxbim3i+-N!=c_-3vErqu{BS(PiI7HN=5Rv1i%QtrN8slp z5O}aMZ|K&32sgh+C%di)_}>ud{;vSumEzMCN9yZ^A%`T;FZJFWu3(wiNNH<=wx^Y^ zD+OmHH#%T;@J`-GKp-1Wp-l7xe9`S z^)~s{@QU`HL7gLqZ~X)5%(>X1z!mNbdcQ?chhM#b$8u?zWixfw!lmAwC#!d-*Wd=m zQ}JMrEUQ9uC?cxvkK%*(X;m?MN`3h%9wVv~gq= zysvqYYxjlWw~Dw7e&dFp!^l&9x3`lnHj-V-f-~#~jFT1ogyPFtrW$Xm@NtPl4Tw zIX7g3Gx~Ya8#Lr>hYgF+g&7$a&W)~_PHfjHnq+)R8&>4POVx8l$!TJumcG?otmj(x z@~IW9>gP*>lM}wwqEoyIXz{z{^>Id8+SMINtI&CL#3iJ=k#9QUM-jeE)F!J?P8$1hnybL%!0#`dnX4pTn-o6WUu*SdCH zrAn?E&a=AIz8Rr3=V5CKXcy2^$22ADVHZ?cY_!l;3 zGSjQHF;)=EPi3-HWK>vEG#US}suwJfxSp>dQ0_3uA49!mE@L$!(ldaKs-8@AvMkQ) zz3Be!P!;Hm+-`yF8s0hWrEXIQN)lltIXALOHg}s}?LYc_%=Y!9M3M&#jF*BYcADX7 zs2hq8$weLWd5!u<;Tszz9d{f;D9*-#Q=gDM!kM$5{KJY%tdev&k{ybzc*{tV1`Ks5 zvuUTU02lti0`U3!sX8TZ5l3cbO@F3p9YoEl zVZ%X*qw=vmoSW@rw|?2uA8(1n5f~LBAH2xMO$zU?JJpPETdRT+b%Z%rx1_pDsHPv% z`)@o0cOJhxIga3qA@;7T4O<46GvbT=;0gbuCCOp0(77K|0N9LcQ>;Xk)#|}&H*4Fc zTwXDp2W5PfFpZ#bXsede7$s*kuhSm$k~fA@x^L8-=cX>Guxu@C@luUR@P?>QMoVwd z^!f4cRJ_Nd)m||StsUmnp0W{JVca80N-OV14h&`_0WMiEDysiGgScJy2q44Udah55 ztY$d*r|`e$#c_NIElEiCiWCRslnGzh80v+Y#`Kbo^&RPa)&n^{BkT$=Vf3F&NCg6A zPnbel#f!XK-qmKZMg<9qADl(fbg(wj7RVQSNt|pI7Y+OJ=btLuJ2_nG6@Sus1XezN zBD=dRMx0l5mA902c=xFFkHEUKuQABzLePp9hK1k(`*x5c5oaJfdf3IxG#~5T3f8>k zBVb+~p{T`f!*QJE*!L#rZ4eL{Elj+@7Pk2?<#x4BS4vrgQZOPzG}LT`oqG?m#usgK}S22Eq73z5EC+T zG;m$v3n%vd%IjLFWMb#iI0(8*c?101OyYukynQBg7(Vtto*YZdkbJzdRgxsg<#YV* z&%U^RtQu;604sQ8Rf^gv%cDFO%qu&_se3g+zR}8Tawj|eC!skG*Q#^=zny#P z%Hw^pof}mH4MS^ln94AA(g{lk_3v!SxNMx%zs)xPO@^8!GpA(t_T3Yk*H>8DgIy{5 z9P2ycDnHBeDy?+=`oePm?06Vhp)B-XVoJv%RuCPnF0ca{gfn?*b)5J#+2vcz%eWMLg0i` zEe4=W^EhG^GXaxC}n;1-pZM@aYRg?F=BoTZrU((iruP9K<4wmVmJ^2RK?A8$& zw!X%MK$(uJtK{x^3TdVrR2Ovh=5%E(Eiq;6Fuz9gk!c|REG~V@w;U)m8X~L>8)?d~5$rL6t7d zzO@y2SvY?%nihq+8^yd?XD)_H)0QMyBZegmB-}>!EluT3JLybwSR92{uPfBoZ2gO2 zPF=Gk9(4`#M>KHvM6q74Jp8~!MlBmDsy`(5R&buF&5kfzv{d@=31PI2&}ut#;I#D! zS=_#n`u&&F;0znikmEYyFor2MoBYPQW{oEb1|NUYUG~Xg?8r&4PDgP}fW2fY78S|0 zD;U4_^uzo@k#DEq^Z|O6*n9P3^zoL~ThV;fI^oaIx1~sJ3V0V}xXE~)S0UHSf%-4m zL8=pQ!Tp&9e*d=V%G!Ofjap+kZzqp}=ksV4r(e{d*|I!~GWKh)IYW&rG1^9G&i~-J1Q!fbNp_!?N14OYLQ$w|58GojUz|AuPOa`OCq8b`|=mntZS38D9q4p-}3IK@AwvC>kpHol&zPMoiKTN{e%& z>-2n2Ga27-J_VO(h|(6woF^y0E~8v4hwXnDJLK>ZbvLq^|WpykhdYUf_=#C6+v$HJsPMmb6>}ZP*+c;@bxY4uR{m0 z8Pa+zZQ^Sey4y#9&FQy1P33A9A*e>r!mYVIvFttP^E)hhFcS;$^z@upzMmWJHvkbz z*lRRT%X9tcBMcJ9J)i6qRKd<+Q<*4_KwN3R(G`i&15u*j*Nbw!?sn>Fe#&9VLy?(6 zPmUb^Zv@#3BU0LdRaj?5R0)yWCm@dSIiKS$NV z?6LiTXEIaNzuG3#tGJFMqg^kew?8w!u&~e)0@ma{6H8MhXAi8U0eS&xCtv6&3QSOW zmiNaaV4@?7xoB|;iDOiyE{zj7PL+;T`HgPa_8=!rpdGIOaTLPNcSCFK{%Ax5tL$=s ziL7@w2nc>Cv$vx7RxM|f#7|e#BV+3b0qbMiZWnksb2C1Z$2NIN@m2p8DID615YlgD`p9gq7a~3R*{n!$ci0+D@_E{swrDC=-`u2iEdjZ z44G6gBTfC6;C<~`_^SzP4vNW9Q0e&0#E3wq@#RN!i)(8<4`BYs;By&DMb;9zJhngf zz~#%Pm0ED2#v>5psboX2_y`Osb#ZWDTt(sbN^C-QRU$eUoa4)GYa1wryV66Zs6<{} zYWv|=PH}ke0CuFh2HY`k>Y8Ru(J2p!+=074Jxr_lSyreqVf?&lhi|DlkRJh#TgRJt zZ|n(d-xX%OHfPM7k1T#f*Z}%lU0`VPUbMh6A?-W6%u-x%b4Fwh*9DI-dyF--?k{uz zm3Rdbi4IzeP%7uyE&t&yw@l&!#7FisWa%F65BmtYcbo)3+)>}GEV#mTd|O(W$ghS0 zX97W`{J^azkSRFFZeoXnNM#&LnG_jhkN&e*45j1UcMBh>$zO}58 z^fXq?k$&I{16|;bETAMyr26C;MkhIXQJE301^e0nhc#Uz-VgU5Nu{b}5}9JJma_I%nIqO`L<$v#i8hZ_6p z#vRGOGaDBoeXJM8DWAZfdu1>MW%1H5loyd zz$ZjYMMEoWHp!#4^+kj(QS3pZ5`wvdtEG%C*ij`AG);08(^Dymp;h0I_j_TvJMaG3 z6R{8|TkRWiOxCha{fu8xYi{9w+8ROmis?&aJTi5*A5UTN;rqZ&38K2jaOg_6u9$ky z+IqYw*>s%$0G)p)dYg%r1p8FsscX*KA$@Hr)8tyX&XJ3}Ne7tG-J#gv7t)X|853DH z77g7@?IZBai)Hqboo7`A#F6M$=SLUt{F5|#tn?m^2}}iw7lHscYf%zgN2cc0_4TB* zW(flXH&z1fEv`qS=$Ux>T9Rv)sq1ShJ%hSXtf<+A;&Yq6=U!d78{|Uj>Cr0a#l@{! zME3AM^@h4Akiy*&E)j09qNIMp%@}&#z7NZItEhtwZaKKV!!lZj!42JZ?iYIkSEW6;Q*hegb|*YqXWV(p(0#t7*PjL}ugJ&OTTwA#bDe0~DP>@$x;oT` zqfZ&1)cco$J>XOF#XE{G{y>RuDxsam_D!1+Cg=&lWb6L=F+H?|R=3w(mUY6%&R8JI z=>P?`9J8eDhtFI_ypDd|Okr}B3s9=WF@NXSh@HS>@qpRxM0QOtp> zWr}lSHr^`u*ihVYurX{p!*&ZxB~pUMGbJyGR;fk9l8l+jk;?QAS&I5)0n~;`VGKIM zSwTUm7~rm#Ysd3?oh4VycG0&b@^Vy0ZO4#h(o34Xx9w+hLu&)lkJ{D-@Jm^7N)TZ0 zFr`I#O6iP10|A%XE&1xZiv4$()VSXD~ z>-=z0V{GoK&}ssOn0O@Q%5uQ<;{VkNqBbffFxEQxJMK*&+ZmUJ+L6jzAU=HCca1Ih zdUMR$w$5q6;^T|P>+>wk?uX(2g}@)+kdL&dJkts9zh2f)%(7qb3JoIC-x696U~=lR zoL4T)bgDADUQU>+DJGo1 zOHe!Gs=#4%yg2YKLGo^bo6VO{_Z!lPZx_Ey(Rn3EP%zwMVPR`#WjE->oF{MGp`NCQ zOHzt;{R~O0?yok60IrA|%$k1}QX`FpDw`mu24RruSbB5NBoWHl(UQsfgukVNEM8Cy=wyTGb^aXlu63oK_TKJO;%URCezl>|($X@;__Tg=IEadC0?q3Y-Qu@0G7 ziZ8V-&iqt#vS}GeSx1E~azV7uny9E;3gSm*Kt&t{^+8AM-56#To#1P1fypUTq;!{F z69f_^-3UC3!7|}79#Gi8n;NjfQj9yK;iZC`f;1l)3>s{ zowP^AXJe1RudH2_kS-7N^kj;VjFKh##TqNTtwV{D?-(Hu)5I;Z05uvaaVKZEm{!GL z(K5_LWnyFEz&w6QVxA$)&QOAOi1jRcUgT5k z!bL8IlWZJa*6*i+p~nX<%7U-Q4$F`Dqol;bZM8!`g>g5_Lk2hefISt%SD7@YG=-d@ z9QT4yN|3gTHv7!cE9?14L9T?_>fp_{4JZU2;lN%|1+T_waAhH^MsY%~dkahSZqmA8 zQlG&NN$HE4B7@N0+^uFvgqZTnfFzP6fl{|pE2oFd-{{_9>0dHNzo-rNi!&qVnu0)e)Kiz_`3!{{~r_W`q3FsrMt>-k zN1l9H_jhD&T}`L1$yCQSW|LOQ7WOl@&OQ$6JAWkJJOch)**FWqdq)eu=Z`p!o;UcT z8S3aiFS=&(*Oolh*lQfD9V;Kz|Gc*mGr=c$Jy(3#%1|Kdn4E0MQQ(nYK9WP9^6j=r zlEwGelQ9&T-p}`kW6qFrRoWXc@?bp;+~i>mzG|g%koC+eeXn_-`YR%>sgG}YFHT{O z8Z-TxVrM5CdziC(;eF)D6$y7gqG(r^QGGTDzlch*>q(vbS2Cp4c5Fmq7o=BE7IHnr zK9D@?w!k>zy#teMgrTa4;#`i$Jgf*e`v?~b2DZu+3>WKx%?)a+CoW%Qx+z6y#_P%Z zwj0y?ZJZ%vBkR3dGdehaQW;z;{@RAG7YL^8RXqw3=8Wc1926Hx|Ln)j$R;7t*$O{w z<$;P_+tmg*97n2oH{Lr>yf%-*bcDf=QyWLubv?F&I63nt_{Bv(YV$9#W6HaTiLN(l zs#2Ru$>aEe#b~}A-`Ds{6z~y$ux6w`q-Fj@a6NHlaMV;G+{c^25QzBQ1aM4wd4;FY#5`*h40900R|C)%FXM4~mSLW$!(mEB+=G{6C|bPcz>3>rMLz?k||dxw3t3 znk=8Wx>Py-91@9u&k$R`tBuQEPkyH)L*hH87#B$*?*?B;xk>$i=kC>XNYe1e-B*YEwqOixGSNyL!Z=Qml$PwbcBt+P3^g3y?!kAR(lWolH$@s&^c z50x#wrO?*VZzreK8@8HMG_ua$kTP)NHuh&l8m@xwZcf>ZOxI?lzx( z+12ZZV52mx50N2L4-W3I&U=wUEn3yqc8*%JY7C|NmaoPlDcuu--+8#v^n3ubc6FSD z?9^DUAOzAWF6^Tzo9^Icrv`Fyam(i?S#zyUf}t08D>d@&c*#XN?s`dU$wvyfax>Jt zfpNPte!}IX$}v!CcvCS*%dKx_QZ1?L_wVH+1%&Py`Hv{Zi$TWobO-(8P?tm9?zPdV zi-cnRVV7(}h^&p*$eT*SK}u#S!jDR6vObGH3JWEZvIzd`z?AA(qd-n zorw}(So#|`C@S4gMkuq8tTL%6@_(Fbq}bZ=3+LjV{btl+|M?8K;R1w^XB*M9LTg)M zF!l`zr)^v^K6nl-8RAI5R9ug#yM9wTa3|E+Yx!j(!9MJ_Q`EP>It%hv_69hd?^3?^ z7X1mrFkQp2i*w(ULxQ%@5`jT*0=aRO1^DPgN{LL-BT%}s<;2f>-shNtqxJWC?T;rY4O5< zg8|NHka^oFdeCJ=hWekZH_A+XKrzBQNMMMtoTbJR{X~R;cXO( z!3oKYEH5oYZS6-DB31;sq`H0Oe|0ZySq{t6Relfc)z;DLwAqls;_4Qr3^QYhOz>aG zuw3=6hI#g;YF$Mi!xT6wImA{NXyh|n9HLePR`et9RME+c_ep=kWY(4gty5DPrY+YtMfCCtVJ68AFG(8-915t5b@!|KLTS+>}(S#8n+3(fQqbB$H_NE zAJ-jS<>hP)=$7=Ar(MN0x$#l>)bWA0`3GQcVMv;MA5;+dtUvFqM4twinh=aSj)`1)ow(ul}80981e)jnO{$#6OvA>UoRh|1yT(cq}#42aGJS zAAX9)P&H-4s{IC0jns!1gwdeDE zT4>_p`vnY7sr=*piiSU~%Kg(=#F~;HGo4NF(txEo6BWc4sfO^C5OysY|S?F{;e z!-!+e7$qk~_RggEqTJ$RkVsYf`k#bpH$vrdJmC}Hm{u7mCtiMY-A1$5EBdg?L+3Da z%hQf9n@)me&6EqDn3A{ai$K)9Hfa!HSzupjF%9<&O;O zS;1LVh+g4xlI0WTdlllCO>mm}BcLZ4JmY(3K?A&#duqlVPEZQQ%L|4p#vdM>S?U_vG z7(Hb+GSZ!ijRBt$^<4vEBBZQ>9S_6s$Yz&FlFggAaUXZQQtr)|rkAz<=VuKrZ9Fejz(j}{wB3W?*vn6ZI$(S5ZR|LkTd5nSL%EWx&- z_>wXA2o#2rsBE!ZB2P8c@bEGZLNJl!L6eJ~H)PR&(vv3{MSBvrSKs|0+2xTMeQe1nh;@UG z_XtGK%F6;yStl7_AKr+e;@T@}-&*!B^~iA4=l(Yob5+yMhsuI;Mhi#Ewt3uMS9t``(q>J7TNQ7?2`h4t| zmv~+;xzqlVFuGgqr=p~GbyRFh^Qki`-wBkOSqBowoKC&{M4)5YW|`p=RL@3c?+xYYyD(D zpLt&2n*aU3R0;k*#qgh@CI0nY$wQkH% zw#BSgiHCuK+04p*Q}7kd-dE4@hrrT3uYHe=Qv7|(fYl(2ItEce979L+h+(9YW(l--x|9imE683nJ_7Zxf*2lF`Fj*3uu zT@NDUu)ucjb+}E(`u3Zu7NcVwqi6kaL|3fjakC2SZ4VRaEpC%}bpHH9w(kb=3uiu6 zG%5-1lZ~^`;5u`n{(Rm6`UE)4i*}+uZsMa>6k`9$yc*z}FJEg4?G@u5BHycK{^X0S zUAfwU%Uc^~$)!8c|5Aw!!Y$E~$KoVKIi-c@xxwfe^X(4_tt{n zgcGQ zWc$+A-Wa_+gWs%ZYRwY!0rZ46v>iM1OSmNA?w7?i=_a)VSSql)laKNYgD!C3dCkSJ z?WA_*C0oNFb^5Z*Xv|V=;4w?)od@Bhn5z&lp4$`xi&`DnuvqC?_}m;%Mj15lyzU~( zNon@ba9eoYPbEH{;OjCTkL2LmY6z6?;y~R zD&qQ5^E{)$sMpZNL8?;HWa4hl6$~tH6qf}>A@TQ55q#M{6H!K_gkQvR{W=quiZ5bR zrpk_%8Nx`GU1AHTo;XO67UdROQM9MzhmI5!E$D>dK3g2AI+6%oBNK$)>1@b!h z24p;3zpK*V64eu$0BKbJepZB3ag@MDi|q~5*wpX>N_bhE0)%t-RtPyB?B8G3aqZ|~9!a1jM-)FM(+6yNss&sm?!FG~ezh$h%Jm1W_23R8lbz^pnq3=n;5@9wNgDj7r|e`4waOw(DB(>=oHVG)%EVDmM2 zRjH1H3ySn-Yjcn&3tE5u0h|a?*f&%|R&-U3@%6K^XN@O0uJy0@aj=m=8Sve*3$L;k~EJOAkLF^2UUBKEvV=k1T$ zqKd1ZBvE=fPF;bAjp2(G4A9H;h80W~m@!IqS9~n`Nq+d@?W=5T1FL?+6QiKX-WaIz zhdb5UvmaA7wl8q(M0k;&GlF}-OPw?s`;+4{+B0v2HIcsE2I@2QJiNy0_$rdvgG+5! z4%M?4<{>9{_1}9Bs;;*vvtcVYPv!fT+vETJ$pLAsxCaHWW{r9sgTM*T#8O8J3N1a9 z_bq3}b|-Jx&a%-Isr`l=<KHr)52NrjFL*3kGS|Ni~aw_2Lv zAz`ATFhopg?U>N&x}7>%G2z{G0jau-&jhR9Xe4jNm>Y6rtio+{UEdC(&2z0X*hlD8 zqpZU=bNfT8o?b}I=674|Kb;;ngP+lJp?(5&-}&*d!LRX1v|*JWr(EzBg@Qf(uCDv! zR86$~y0#r44RLx|gp8*M6`KpSy8JAuO8Crp#=4#=qH9Y*Z<9>?Tb9S>z3dteZ6^(; z5y{R()}gnUynQl{1nh`>4vj|VhDcyN%3ooiv!Vt1CW^Bw!&NJ?Z}gLP&R)KMFW|hO zSDPxE&_d$skHt$B?;35jq>f0+u@JHoLqxC9&hv_Y7A{adTd+i7_bo#T9O`#9a#TKY ztyxgK&g5AaQP2vuubJgumAks0$oIUpFzgnh^Yc%UxB+be)t8(EJ0kpDkiFF9G!xO6 zm(hE>&p$UY&~c-aqi31utyS^cPiyGDpLlJls1pm8>3#%GB4C=`a)LOHS9_36hGSlC z*L$9RglrLkpYEA_5D+e>60!d~BPH)O5M@5;+ zy~R|n6pV&R23uS^M;y{8kRvDfl~uYr06Tt4e=!0X?3MX_39o@%HwfCnHRZP$YpooZ zk$g-yoOF_}XqMKj;f>u@7P*bKFpviu8rD%8_u zg7{SZEa);>2r4Sqjw6rBSc<36Wa}UuiF>_G=%8M$&D5?>m}`8$bNC9 z>m4BX;*Qrk$(@R%)2AfI^r_K_{xcJhrB9 zHLvKXh#Alkk_e4qTDV*mf$yxI?*l=dNw3ivGsx(2O%fhq1x$s@rV=Ge>=zkl$rvg- zL0R#zy-ZJKAMUtm=aa^}u}z#41{5F^oUTlD9+?PWM>nK$3(dO4s1%;8-52dxAk}Mb zY)~|-oumVk_j%O|p~QGOWO)Eht7KZeBbg%8=FwYMuTOOrD&BcmAqV);-dH%CbJKK3 zH`5a3#);uOlt3K3^Ob0FtPz;eV$Sv+lLUQVM>qLiuae>sur3^2%W(aa8W7WB=?vCs zOS~2AkO-)UGmTPrbkjZ?q({;nj-_?Ik>8UAoUf9(m*=HjNN=0+bVzsPHC|&N!9NZwUKYel9u)E25Yh@ar;KgS zv7`cdkHFLZV96ap1l8a@6P;4Nn>z4qx8dg!Mpeg<_2Gy^)&N{1V++;+_qve0$4o{j zvuC2eFSI=MtuwBQjCA+?lDH!7l<}Jzf*?Wd!nFu|yF?3j6-og5e6TyGzG>(?}yxZqNAeo$QZ;i$~nVHvM-^0T_C?sY15B?Pm&b(unrSEo$VJ_KB?LWCSpTXoFL`3965_C^aZNc)wXIctYamLVkVg_ zNTCYfTOh|L_kMtZe$hN1FXzm}IP`jbE1U5?EyXVBQV*PWO&U%f@+U?5iCl}04(y3gP~yE= zV_*%Hy8<+5Gkh&=uQBW@4$=nvAvjDzs~=ZR*$@cxDW464k*qp4xBie#2s78y*Ft+p z=ny`nKJp5eM53v$A zlGGqm-KRn5gMIZrfQ%D^em35TtJd2A)OPwGLAs!Vv z+52HrN?(FK-~FS*3s0vRQR(f`nA@H8&ks)oXUeo;CU2{n^g8G(D!)0nfw$a4D#)up zdj5mSkYH@-ARDU%rTz5KU)q|%tI)PHJ}Q#F%oo8eEj}*?C9s)pELi$MFDjVr`sl|}U?=?`O! z;&`MRN9)%lMV=z>s{oJ>+y=SVL$e`w$Q^s^b-A4n@yRElQN|OD{mdRujR#tpi3=>w zs$Bt+E6tn54@6jvw^`~(R%!*idkt3gB-w@pwthjrzHxyaF=^4lhf*}s>ru#BMriXe z!|h&kt;}16@(YMUc@on%G<2^r@`zkICI)0W!qlBhSaudvCFEYbEOwbz1k~0_NV{ZU zP>NL7FW==zw4E@~yhPhYLw)k_Cz(Pn%G_Km5Qk9>n7cs#c}1l$tS2|g4`DDbnS(6r zCA17>AOqfV9<&ZpRPW}c*Lv6NKzS7HM9JERG$qOADhUqLw z1m?qh#uj!dyxYDp9(@UVawL&|$+S2V_4g{?^Y|~K^5TfcJ2esdI-2Zo7b2v=xqG3L zqbzSb2P!Q`SxcrgOH2O6cs+>ZbzWpf_IZDywdQhO_HJt3QI@;6MUB4YL`u1S6^&k8 zLEL1+#m)sI+J>*@bTGu z>RfPk@$}(#I?CDAi;eMD`rD{#za&G;7nqS*WlpwI*5(*P-bI7gV*|SMip_SoxVLys zPRe!vTNfANvk8%aGIpGFgp^m-u90TExEDCws#vQV!Skt`)k?j$w

              kKZ@;RT3QVG z_HNx_@YgWlVu(J4YGiQUAbWdAbC^7!8>drf;={H^bL8??yDFPuNh)D0fs(^ri zAUzamQX;+ApeP`{2q-8Z9U=4(dJ9dE7JBc!NRSd~zF&W{ZQkA4IXiRqoU?yrCS*uv zzE8Wa>vLaE>;TaGx${M;k{N`cpcXYed{2awZJ2t=m?ze)FM1cJdtDVjPs^;qqf{t) z+IXM;ZE*l>Nxk6v(#VY+zap-HI;XX7&)G(lDQO!04jz@7=#zhb_bSKwnvVzo5G#Qc zeeDS^k~%(pvsCvFpSt(t(8l7GJey$@J0OdrL+GLoFZd;J^tgu&%65^;u7z zy2TN@_o$nUfAF}lAoI%SSD2~E9GJgPmObUVV5Uyj$TC9XA3;@mq7<5F)oO96H!HMd zA<;h`-Mq0OJaYDgu#P)T?oQK4kSYFy+S;d>y)&`0K;^|X@rCztRq!_1LWgIcyrCbK zovKq^#LBG(%Q%z)RVNrQUA%R$V)40}yd`|-If0%jXkn_?sd8|&EH)zd_!sDgLxbUF znZ4NhC3H(;O+8kd+!k^&9I|VC>J*=EH%Kos&FF9VwU;CtSRxWfGTaU9@YBsnrc4DA zp>@B}XKB6(0DZond3UKL!*4!DBvdqpT{l{pM)w)s2BFmdY@`YIM{NEZ_#*#M=J4h8 zu@=`JcZP7!58<+PCn2fR--AHExePffau!K8p$~(Ge{<`cz4fS-=EI^a={DM!z*wU* zZ|b$zj{clMq*bwbTlCi%U7#7bi>CG;J*fZMdo+Ex%QOAq0J0)8JG<;61DR2FYV6jDk1C}zsP#=jv9Ng}Y7cp61X z;gO0S{Aqk_)YranLij$}{KN-;&_LbDI5za~la8yqY^Hjd0Ln%B zfzSo}eMaWV4p;<&;mAfuP+Kc&l9a2b6G1Z&7}oA*d+xI(uvJF&)1n}j&%DiU9CCuW z?w{(0s~DSn?Kg_(k7t#b_XutaO5}S2ZwZ$h4emR=SKghdT=8%VTlR;VqL}}e^6P&` z-2Iz<^j~Gz9`)Q(D6EO!_RgK zNMZX0>gxN{ROuwYn)~6JCUJw4m-vX(8R=en}uG0tQu7+ zYVa|^Qfl03m-C}lt$-Z~zt;i_>5#VJSP&;>uS5r9WY%+pgF+|12?_oJS&LI0=_tJz z{Er*|e=}bQ=)Cmn-c&-(rXRTDUu@(>No5qs=!>7T=}eqY(H6NsBUbl?V}MAj@1fG~ z{Mk7FkpAJ-o(S=1b0zyFu+w41bBKQUlH#H1FOc!@uxO*Ok%3g;p*W#LJWZ3&K=!?# z@6N7qT|D0kf?$xLs{AjqeTOeh+g3SZFDB{8n_u3<%T;oRPs*`keqxYGO|LzWqXu_3 zDp_Xaic9Q($x_P`WPZ&a<);X?bzPf=z(s{_!kexGQ-zBgR^&hJQG-msx9FObaF@JT zHhf{^zbl(XuW_VUWa7Mcdm5&@UFWMuyk&P~l;*cf0?TZsO>x^IMjZ!Mp%g9!i>St8 zagRJ1V{zF1&b;NS6yBR;KZU;otA-t5BGg`7^4Fe~ZPfYfn5jAqEPwp9|Fyu6kz$xBkS`Wa%H@@eT}K{HSePiyM~a@sg14A&4SDp5J+N^Eqx<|l+5JO~@wAIj zr5tBo`BvEBcHTUDVx9P^UY89%eQ-#O+pIm_=%f!y8;J#%ge*C7ag(Jjto5voE%XuW zWb=IBDn`V(sEAs%Gt5qm9}ZA#QWnB}7?yl{LeE4isB8Cqc-mi^U=vg~jnuP!LOPha zF-S+ho_V$5!!Ucq&T`F-UYYi1!ew}R3L8Y4_Dx{t>Hte@4#sUvY)q`&pDp{5%9`Dk zjCR(XO~1})CNuIuq@%iVt)0yD#5HgWIjv)q7BF(kfjK-V>szKWdH z)STJ@C8Yp@p7^8pW-4f-=<2yu7Eet1jETt?L4yQLKP-GCCbPk=M{v(x=J_|7y8&Nz z(w({u3tOl53MOQ0te`Gvnkwp60{feS1-cJSxLyu|q)0)b=qHt^XV)M?dN~LP-aX!!$Bppe@cSn0DhwAqC z(b-xZv6=7TTy%|OH9re*$bXCU$RtQ^!)B7Z9Y2lQ4$X%mF zUJW?Cxsp2_^QN+Y2Amn?rdsM6^Dvb?`L03qh)~7HP~y+Tm|%f`JEa;3!j5JsTh;Abw=u?8!j*f_Iv*sxsl0_b&f?F?V zR@`Gk*f=ULxyxf84BgRWk2S5Z8*rK43}O&gbze5JhBV6Esk^Ub`lb90(Af-VNI@S8 zab}KzQOVU~ZCc1Ksm0wXneY1eHC+eA5<#qUDm2O2UvQS&T7#w54?{2Zs3*lgvZ>Rr ziApl9EVGHW;$GQ*-lyyEcpaB*M2~+_9WK9V7J@aC7+;jDi^x>2?yaH>p{%)lhc~h{ zm7DiA$JD_a^P;*s>MQH_mlJ-8gOn?yR9AgcA+#IRTZ6`Z4yEkz@AEoWIXg66_qZcR zJg2Fsaj3^b{72l-Kyj5A#$A=&8(e`|D+Pi|UvH6}Oc(SII;^)&pGh__jSZQ$-f5dN zm3~-prS);&D-{!_7(4u-e+iTMlA2d&leOv1ken^mYX9QRYJ45uh1N6Tr-o7nu_C+b zx~Sp&vQdxTNaXXX%OT{!n{NEO7GKwy78Zc&tUiOpUdKdsG*vy%OtRO+{=k=~YxG?} zDUby6R2$x*mkS>#sdth~MzI8y0}b8HmUU7qp8Vmd`WbLvM{rD0AUMRasji|*2<@Qo zm2z^6n4J?yAz~1_K=^%o=eZ_44ndoSlXY3}>rOzQ+6p!ZjwcObGs+MYj&4{81U7bsou8++pAPm>JH zXW;i&+-qJ&tBjQfiW!NpTrfBWU$`rP34d?0cDVHQOW8v*sGfXBV30T!j@)4ZNUOLX zTjrCsei2k_W0#M6v2)ep7wAg-1mtWDfVCo)*5)v5yx*`7=(i3ep2*)Y%-E0MnFo&L zh;8rm+6Do9oB|sSZ*$1Gq$B4C5h%xdk5t3g3GjKx#M40n$7OTMy?aE3G|MrhhTNtC za__?h`|h5zqiz8EcvzTdTCkr?{GGZ>IT&}48Z1yx`+n9N?H158Qe%LC9$xCrIP!B! zvdquc^77(^#Fl?K3^*(Ra0h0=IzSE9EKZrdQ^j$RC60L|S5tUV2d{*_+TNzl{NBxqIIifoue@*m~^FBhTk8Cl{UZ%_d2~O<*`IX)1-YP&#{h= zvWhj{w#&RyqfQ_t-|1+?&S*Jawc&KzYA7d!vq!eAOkQnoo8Nd4ag>9!O?bptymn)1 zrXw0Fv9K(2O_3#}l_*%S0lMfv>)&ohJHl&&dgPHdR`bc`IeCu)y$OC|SAYRj=_X#% zlB`_d(+8=TdFRxFhiKQ9w(erM0!%-G6aSvb&+(4REfwd;+m{*H!B4Lbo+g?|^he)yAk^ycHD|HVD6hq9pVClB~~w;=aofdTVM<<$jS6 z$ml9qLB`sDkeFsM>2d`3X{pbV$PtlD!F7vR*@JKu5+Xn|Q+Jik`o-=u#a5O}t6ZHS z511^zYKTUevf*vwWXEqinnv$WELGW0Q=1ilGUc4r4|0Cc062_qx_*J^)MRdDT_K71 z!FHKN2RS!&9#SB5!Pw2Q?a#mUbW}kn>l;TWmu!oSjni=up*~XNwobJs1rx;7Zkhtx zVlNJg_P%7^Up;j8B$%2#|6w+xRrS-{E-{bTG=x+x1gTi$W2J|M1b-eDg>Bo6TOO=K z>pkR*XV{$dK`*=LWxQn!%#}bcTG_b#4ylru043Y}&8eUyH&jeAidWdQKX`|YON@p6 zBU5^Yae@Y8dk`a*4^e<~>*>|57@d8dI<>UYGWO$TRgJWvP#GwGT$Z~wD_8;{+yciw z*qY)icdImm>1epaT}jWg?4>hhQ^=xzfxJ;_y>uddP-}#<``-SUu?2hD_b40>(imX69Wf%*366K zHO3nO!)kr96-UZ$J-HDfEnQ%VlDdCQy;``u*R(5-)_)bve#?)L)uLPL@FBN6;n@_ zwtT;?`+u0Z4hL};{PPfw4tx3iINbH&Cxbla;@)4{oNUt6YETJ+*No`PRMiFUA>V#& z?E|UqSGSEn#RcZ0y-AWpX4Slq1Q~HTrDbQ!u3d|pPkrX(ajG&}i+#kJjNe5r)WDht zo}2chws^76-CXFJgrnTmowI!sTbs!c9*8TWq7*KWd5QRI+}#~3an3AaS6T3PyBuQC z-e&AWS)pz6VJK}Sp3f!D9HYA{@sr`RJg=GRz0U=tNkNBF10#pL{6<$VK3kE0hn&`u zw8v-Yx&F9!LZJWV4`|IACgm)pSvK4*WxN&KBNxeujxBNqs72fIpPsXP>snpV*AP3@ z>CbSX?AcKmFvVG_8j&Ns$w2wR_#U#?OBVL@kS!v;yyToWcV-4Tqb(b~9k^X|kNkIm z4|AqEe5RyW*?wv}1E|(0aPF141*(&GzD{t0)4|^eBzkFActG9pp;peUWE;95KdI)F zd!=*zVobDg#QIMHnNHJ~NFT4a14DFf=26Wjt4nKr+jEXo_6+R0Xn&@E;42LDjr=~l zPBvELPoUZ{0yvj5o6$D4Zr)khZwcEc@0X{^X6CQ`o@L*1rtYP38Iei(>Nf&iCaNBb zyTB@Ep&Pu)A6yCYCj$t#5qKNTXH{VDNgj4!2Apf1nGhhID?a>!^UCVUTMt>m>UkKg z*!PbF35ySIY|u$^6vonT{(yHy=M@ELJs`^9LmztSX^kOYR5eOJ)<~wMt&WVcB+(Z< zB}M&Sg^s(z`IH@H`1Vi1%EuDTj~a$m;+Li~j#5sQBI3J4r&K2(&uz7T7XBd>0F*XE zUB;#SxoWWq{^;!lX-|mOJ(^}Qk)nlC%47=HJQcr%711-~5b?NBGUAl;3L{J%Cecs? z^~sEa6Fj;ZbU*J8th!eIZEE`|6*}5o8yTPdiREC~Q-ZU<6_0t@%MRa<0;X2ioX|G^ zv@-dC|0o&TkGLJE%M{UQX1)x+x@ks-d+scf#>tBWQ(M+l*T)F3&lT543ap~Y*Ja(! zQXB`-O&F|I~atg#KKSxt!cv zaO2qPs|CTp5_VRzK^%C)&<)wrngYLHo;%Aza#Q35cB7Xto4Ct`ZiX#n5i$zdbzUkc?d7lZs zrHu+is9Ghn7}zU;EOA9>uO{yOqy8ihHQ!(nVg9*L?Vh@`kP8(*+t)VZ8R)^TC3S70 zLOg+tMHHLhwBcCS4LtuF`CVWCF zybJ=wF&_rBv<)f?|d4h{+&pC|E&ri2AaIB5M6c=^d zevAoFu_ZtylL=j8W|N^{DqpXe!S9@pr}2sCZ#2?NP;HvN%FRs(I0LeRC_}qr+JV_6 zl#KHcn8&|NGYx)1gk`QT1_ zne$s>1M6ptDz)6Popq_4_&qY_%V6P72o;?&hHt5MTr{4hVuCH`n2sk;OHeBF2_rkU z!y8pMm$A4V&8Su*?3;2Bm^^K56S@lYX68fILyX_r1M<@hKn`fJpt}q3#Yc`rMM1u$ zVaP!7Ryj`W^Q4lw*8DqbnNx_IaXvOxjGpP4;l5hMLJZP-aiBL37uJsA8au8l$rarp z(yqFA52ERoiF$K)Gh%m-m$zh8e{JsGt4}@b3KMeKzyC2`(sBl{y0%S^Im1yrO?~e9 z(+|j-YarpjrI?p~z{2t5{pZkwf#VP_10EuePpypUH^~N3PSnB`&yKU-!d2hSa>3)(#w3 zUGxH z@IN8KU$$LL1FREI8YpQo1`h4&h(kxtSA zv?nZqDtj}R)Dat;ndItl8v6v3JBVrZygA9X>G}MGZ-F$iJwWY6%!w{WndnhdM{udE2Sa%a{z!I)uC0<85EWXDB}ieYyiw%K!SMLjSCy{x|-u9jo$Y zrvaMxzyi+O?yBUH!u8CCqE-q&&S$N9<4;Mi03-0{)YnR+EV23+sp!Q!onDCGH=&Bo zK)rGD{0$|$AqA%OW6kC~_gvVO18U5;9$}0utILI zcj@4geX*Z3G^9WcooV%TN};XnlHn6dc(|ApR?ftbT*fB@$(2RV8eXj`>XL*-OKGu} zAK9G60_FZwZD*{0;t6Klv>LqJf$WCd4?xLBdEjBR4ZX~GF-|4P{r1*nO-_Wl=g+Uw z18`J**lFQy)L+Z!KbO=0L9bUDccb%i9mdAT%1oina}jpv!qborK>TRq70dYH12?D} zZrOwhii>qkTYc)0jZdyq&#HO+uD_z zs6mq*2~U-uai>B@z^E-cO8VmCt~$nURLV0rliBXAeS-;5PwH>Sq4!1bihi3kbL^Pf zVWc5af>_;A+v{trq7c=^Rr)hbY2@xMy-KN(solUw-xMx}nF|h+Od#%n1|YG9h8{=Q z<-fHFeV8DA%RrfcdPhm_uaUWbryl(q$NGQAciqRoLPEeyB2x8)h@k1(aDsk@x_>nD zV$9O1fB0x@k+NcRY^_eS9-Y3t1~feKBef1x6&nI(HZFCKc;fdHtq+P zVlH=m5t55obILpyzqXB&`0W`3ZHkw(BOaHc2nL%d&${8SwUJx8c((z$jGfAOWcbKb zUO%`mf3DS&Uc%}|=u{xp=VK8(w?EJ5|A<#^fF@m$W85FhF78jm^b@5#lbG}8^S53I z`aLjxb1!q3wa7LEe2NT45v9B@-yTu$@{H1wd<+iACw8@-kvnz=&TE|4dPrDa36XqP zAX9SD-U?#DhBg;ZZ=}}Q0mO^zv9JKrXr<;O$XvQc&*bIHuSYkHe2&-TL9o=O&vvn2 z>~scT-sF;3*@yN?Pe>=9?j~+3fOl!uP61CY@|<_Eb*X6AZ(2K)l_|y8VKT>c$Lq;M zP0C%gH{siUDgEYlUfcF5s<7{#ve#vWcaF#Ucq(RvKj2gL1V1}oK`q$fmlq>c(5d=` z0yXL5HWT#F8u|{YKQSC8!@0hPy{b8m7*$eXCM)&=j^aUL>0|!un*oy!*Bl0aQyf%W zCjJ`qt-zdPl|!gC@mE$`Fr%E^vma@Sn-v_W9)D1c-RGe~dWb}Q+0uI_6#oja$U*Vl z37r+nTN`Mu9ygLBqE9A==~U&R%4y2!>q;L#wNFSGzW)V6UL_Jfn$GaEtH&xQb*x4= zl>;CI^5wzvt_Dr7i~@KTaLZgwME7umR?e0;#3o|9rxBl{f0bM~7Kb1F=mZq&H~!cC ziA0} z=6^AJ@Za%ymuC9K1o-QzgNIiwn4DZMMw93-gU3Ce%uYe)C$(%nulG93I5L%^lyw9Y z&pGjr(j=qe6T2BOeOYNQ?aF>-uFAgIt<18>Y^_dsQ$03gUOQK@>DN~pE z@NC0J6;t+=KmBi(&QLBskScwqWieJKYoLG{J)+<{hNX39XnY)CP~>Zmi;t4HWc?YG z7c2?*gVZ?r_S3D+I=bWP#G+Y7+)B)zMAQw5D@rcw?AP&nLb|L>P+>=r8C19%OLXB! z-2VQ*K*D+-0&)q^!r<~a;W631s1s7ykY_B}mhq><11PRkudK2b?>X|NB@0OK3}p?( zMJ^l_VR5!9k!Z+%%$;}eNi)^p=^wB1OJ&`(B)8N328q*P={IZly+a(2ej+0gy7nB> zZ-M+qbtCE;it5RDYX95z3+n4N*Q4mtk$+&A3hIF797X{HP=aN4bQNu@C)LW^^ zZ(sBb*$;=eRSzc_fptY91SEhk7a&IUzC6veD5Age%jA?Qbp+I3JX+_87S>5w@|0SE z;Z)`P)Tq3NMPU-At|);rEw2z48k-je|;^ zj{}u^$O>L|8)Z{eokktXEj36lorIh}OgRnk6JhAg&f+n8M61fz@S(!Jwb#mN=0=<( zlR?vvDZR-9w4R=hMoF|OsoGYT>;lx(Y@k)jw5jgfRK&x8*}mF$PIq+ESNH}4J3K_R z4OM4nEX=%@{2TSZFXcrpZaX@hX_nUxucn)q|Fnp_C$T!(oaA@M*E|yOy`)Lp#|s5fSZ;c5e~3KTU*<8+RtU`hs&uk>{>I3)h&4qlXOGA&V^Q zQ&s2ofv&z^?3%7ud{_)Ln%!HATFgd9Jy)Qz{7FLfB2{-^sqYwJ8@Qwqv}^#qMKD&~ zbYGEFO-h>U;G48HRyU0wdnp>EFHcVU_}I$*mC9lS$hkunxOfHKL|!3vXV_4(>D8p! zou}2p@xdf)W#?bk|G(-X9^0x8(B$|Pw4;u}Hd~T1v3?tf05*R|Z}Qz`$hH3R?h=cn zg-I4-X(%;*$lyW*X!C%^tt(iPqjtjc!ay(FWW$f=>7=-z$-HTrs}Ru9PNO|hxMRrY zci1Y$%*O6xuFtX%d9MtTgHcg0<3AjIT}AF+=DL!-OXfB*rT2RFI^^e=S9Vv7$c_0ORLK6t0FJ7s z1ebWJ&JS{6dx{c!#^PgbaE|PSi&?pGZRv%g$C8okP`MKLz3}xxTDD_~zT^ z^FTNwF>AbSB&qvg&|dZ>W>9|PGjIg~HP!Fve#*-l1XOqIsPvY`B7*oy?BkfuQ9l0C zp#Dm^^}D^%dQI9bJgNf@+r=N4J7=?M#Hp*)^q;eQAd;{OCYJS~X~)3?&Yxj1k6gyD zZ635!c^FOt>Sj{`zIvO~TCRH`Ob#-(66&a~njnApKGAaK!RB$AMmowb3WTXI6Uwuh^?D=VVAcRGxM&kKKn{=wQiNaXon zIbc)=v22^&89bK-CWd`(8VshNtXg6^r2wB$5K9;Q>fACSPvc~nxX6K$DiiE|kw#Ur zZrYr#B)>%9Ow~qVd5;JzGFvR$0^Bwdof%DV zk`2zx*^W%+w&Ft@88(CrGZfR0o8i8iZW03yyO=g!gVybQ%}1eo=x#TuU7zEO!SPR8 z@{#BA`=}>_&WAo08uD5R(`Tk7gKhg?uHCXC4mzm8dxS6Xbyoo_hFF#?K~#~+Q~JtJ zF&xUI%r1tpa();p@PilEXtP2K1gNvDnQj7?tm;I# zn^ZsD8PU6%^kOAGYCS_6>hT26L_RX>vJ;yU)?n;Y85#7cK;CXCg&CTmbR948a@3m! zkU%}1?=-fn@(2~GvUw63X%Sol(h0l&i8!zshXwBs|~L zNSX#3ZpL9TLPTJDls~P&i?Dd9%6*;divNNofZN{F%Ik71nsngVVa7;W>4F)9W)cr4 zPe;b9p!AvyIsMrR7Q|6X9XS9A3V|9&Te)`td zo|WwEEE=EvfLX`|1fm4tgPwYnl1|wB(Tqi5Oy-SdWe@_5Dc^Y5&wA74%-~-UJ)HPq z)i_s%G;Z}>_|xX0Vs;}(k;(JM2!lOhm(QOJ%I=nJ8#e(rLI?4nV9-ytdX-fPY&WZq z-INPC@8VEwOcx|2F~CzB^!VF@iQiujCdDfIH&GZBO5~EpQ+>>0+@oZ=D|ZZ7R(y-U zFep$`QZbRQ)pK#;leGuYhqObiJ~@@}h%#O!Pxh{!n5SkV7ibC9kzk2&kf*}w$TFO6 z>()2dj*9vt4c8iLNl6rksC5aI1|+~*9CZx;H{_NQcozeB{A~c;7rQZ?2jGqwD4YFT z63PW%mK)QUI#N!2sbTdfAGdkcrWna(+DJz!PZh>1W$Wc@6*4z6x0>jO_dNXT3!JK^ oeYK9Q(B6x5}a3g**V(V{YvHyr7`oHh@QvI6ze@mQ;&Hw-a literal 0 HcmV?d00001 diff --git a/docs/za-download-2/2016/2016MatheBG/2016MatheTech/2016MatheTechCAS/2016MatheTechCASEA/Anlagen/Bild 1 Plattform.jpg b/docs/za-download-2/2016/2016MatheBG/2016MatheTech/2016MatheTechCAS/2016MatheTechCASEA/Anlagen/Bild 1 Plattform.jpg new file mode 100644 index 0000000000000000000000000000000000000000..eae00d47a3ce498f3dcf57f2585353d8ea9f26ca GIT binary patch literal 10039 zcmcI~1yEewy5(saXxxHput1O`I3z$f1PxAbPjL6(G>`-WfyUk4HMj-{4#6d8@Bo3} zPWSM?H*@cOw`T6V_on7_eN|o6r}x>b_V?|*zO@c&4)qftdL<(-1Assvz~ue|pca6a z02U@D1QP=b0)b#-W8vVD;^X1s;!%-2AS9)uW}v5|rlozz@`UXn6BjcrExXWTu4g>_ z{QL}TB4Wb4qEGnvdH)&&gpG}jhl@vvk59?VNXy9kU%pUZ0b(p*8I%DAF#%}AATTiq z^$lPE01(FgZ2y|@|GYqGU~~*j2o^RD?)?pQL;xBH3`Ro-V_=}8-{0+b|2u$Aj6uT4 zBZ2uq?G1#Fw(u7@U}#nx2`R`!T<|w!X2swf$>n_vrZK^z8iN^6L68 zTp$4a@38)c?4NKE-{V3pvbQedIOHt+tE@+VLHjpk|2bem|1D&H1NL`Z^8g+gbia6DVgL$UT(jo{;QY5E zD@_$7{m9x1$O&Wr{}8-l_aoPwJtf+qmUFDPA(C20>K-hjfQ$ZtJI;*e%Yd?w8zH5X z*YgUw8`4d^yZyQj+XN|zame%QFn3MEAWU!4Ol-!_!Bdbm=9N^~6i zbh*B}{;6)d4Xt<^2Aq!Yee+f>|5(=^kqL?<jo{`7fFW>fp8leK$`tse-Z0B87RNoVVnfRa4Uij4#Ri-dES9amO_C_7gjJ;#W=>!2Vx}yhTn5)gz10LI+*P33=@@ zRWRrFByO5NKd3=Mn5@gl^LST-S!wc}WnN-s4HazVu^Qt<$?C; zRfisM>L+V3aT?DJhhud3RjfN-KYeYmzY@70_Ml+rf+12LEX2imOw4x`Jr^XD?}GPFkJ(nk}o2=_F;M{uWk2BQ-r#sSEV zAt{BbfmzpT^(>vV(uK0m7=%`WP6FL~0*^jzR_DBGB$HRb?iwL&sg%Pw5GL8)8^FVv zdx*_&7;k_;0dGc;Qe+Y0>8|z{xm<+qTlKzZjYpb8L=ray(Bp?$gbF!fd@u}BwSN`S zdoT<#WfY)UHL-T`w9F^pPXvgNA{ZmkrQ-z(dZ_F|CQ&mnv2010!djwYK|Z$vfQH_0ca(TDh2XMgfQ!mMYvrLeFEvfwVtOu8gDW zUvDu0Z;U?(H(MwFA+Cl4;RTD-UigZz@k~|4tz(&?jC6i^+b3ub&qK*9JD)vqC1vYMF20E4YD*2>xImTDhY4IYnO!{~4c2Y=B51x{DM1A+Hsv zR`XN2k*Y4>MDTn&vn%cV3zIW3`o=c20CG8oVe+xX-D~otco+8owwUU2 zc-VAKHsyWGHrdsy&MW{Q0^0$*V8ES=H&9w9-9*E!NdQ^xMwU0j9`oW_!dWgWfwE}yj=er1IG#2XQ zC0vZS+t;xi*OLQgCus~e-H7u8vg2>&b8eOg;yIP`wnU+_DV?NQY+xt+Of?vtqTWXJ zLm@S^=j1PgpkSmxzGKn+A_nd=^i%CCCD*|rmN#+1>bzJR#PTc(OdrJ{5->UkX>OP5 zB}4s>$1HXPMar@pwvu8_jG9fKD`j5Z;RE(+n%Qj)jlu|X?z_p(+mc?{a)A3kWV04q zDC-frR&_es@`SaE?`SO#I>CZ0kXcL$YS5*Q4CA+FuUWDo3Wd;XSo+)vRw#MZ=fjH? zeIvXNkZg`u=FbF=8YUeF;e2Y{c&akwloR~FiA<=0Jx5XhV;Py`{FVn33cbf6vsU-# zcxTJ3z=#Bob8#zhroeg}vOVNqryK@^a%amdGvl*)z>_1V5%R@W|HO12!`|}Bta!)U z&s7|}yFSgrvWBtM>By}gCojc6b55YVQ@PZ<@sS&5OioZdHQ$LW2Q0XFiKB!sPyl^A z%e$MLC*mdq!eo`S#8Qo#qas7JX-NJ=2G^r3yd<;tZx-_d>>MJuzFv?OqpM=o7xQwu z2xabQQ{&}tG z#Wfwp#l^JHzb=4{SeyRvx!vyWCm};TV3ZZ$eOhAc&NNixt_g|$PPqBH+nIFZ(?axI zI5i+&z8+V+K|J%O4ZG6q--?<-CkhceEb97?5!sE! zjiUnKAKwm)x6sdkLpTnQ^z2P^|9O51+%kiAh!`g%trA@|(c$xE7578YaMLiLNxD_d zt0EAvFo+Q@O4t(1YMNJp^LX^)($1=9qV;kU;@2ygBN^dG&w1#G9nblfs#xFY~h*`5SGK6(u(KHm*dXK;tf} z`W1=UR5}W>=5w%Rev-+!eMi@>c#}Pd)Tt;415OlHu=ncGlX64UKHrQrB(mKd_dyN? z!cNNgt|)L>Yf-?-;3}T|rDf%+(NI{>!Qc!MjImVv`Wrk*0}rYP9P4p@X+6YvzgfHY z`7`G4uw^s&#gN-AR>ixM?&s&}F*BilL?|F3A>zcJeGw@&hUrfB-Qco-lV)J8F{AUF zvS!ZBt4>ifIIb)sn7LzEJZ}1EJ7mLn$(UJwiSk1y3h?^G5X}#o82@814t69NCFw4(*wFgL^Q#^6g>WyNx;2OqM8vT#y5Uiw zmAFsx^3YGABD9!G&ni{aDHu(!*S!xWx) z-WCOvY?b^V`QHG{v|H{u1#~85!r-fxd=ffaJXrWv+BheGlXX{~-I)8U7(<uW&HjEv3rZBXhA&T3|&epBX~ zC3ZB+2v$lrE&6o_Ma$o~9U)?CVq=v3r86vxdY&p|hU z;KkER&3V&K(uhQ9wkd=a5at#y^tk`~eIxX$V1~*xRR7IxNWM(D2d1gc5W#olU0^Kt zu*7+#*Yv9W@}|?PM^BzyiXe~3TNMLdCDj?wzqBB#;WTg5r6-m2h5~r%d@A4>?&9q| zNxm!0c7JdSx*2(m06gv%nyNDgK0=hthXU?TvZI5_J~xI3X4rg~PZ=)YfA5_k?%;aI zNvO$}pTnijg(5WFQt|99~T!`<bMWc#ea+550OzH(Q+cw$q>AoG5h*Ut1B&Eah!W-pf-c1@O=}CWv*WQ|=F zFYD5G=%&oWQGT1@CXv4Mw#wDFvL*UEdY%b#JM==76+9dP$6ni%t5A@o4qh# zT(qan*p{+XpEt)G^q$RcrATWJ1=EZ zcjT|N0B$wiF3wc##Si^?E_{=R#mw#IE{Pl^&e8X+!$-idh_yCMo1pW(cW}>hnxsOA zsTm>*A^l1u3rA)~r{EEOSj22KY0$15Rc>4|!==B0T+_R-gL5<1#$ASs`siSLmow6v zYn_(-)gkupT@LPvEg6xClXmf`NJ(?z?Ry7#EqFd3lDj&}2OiPXiSZQQ#K2}t!jo0pLJxiQXT{O*hzX};UBl~^{R%x%U z>F`<5)P1FsR z+b1!{860lF-3(tZfW4U zS>gRS-N-H;_@fGvmX1*4W3(z1P!(=GQ2HSYEsXu&BdfWlt__x`b)*Pk_J03OWU@TXTIWi(P0p`1URyCM0Yw8W`B?6TN3Bm<(R{@iZyhA}*N~{Dpo6 z_AGY}CsK91Oh87aCE!JYV22F&;ZOS9LL1Aur}j-XQ?y0*G(p>RgyFr@N-{s_ovjX= zNX#=Gw>O;(pGrmx>rbtgj2#&*sQSi3m~xR5O$}=|D)A66`wrjCC{V}IN^kjs(fkUd zt?j2Rwa&S(>vPwt7$Vf38~7YGY)3%L>Z z5`BC7ipaSy&DF_T<|nQ7!#W(@kq2qC{-hwWVEo@e0DK(bI_h3rDGDuI7GEbu7>!bO z6K3y1@id;>avCm>J=|KVj=aRT%cup7DiqiXI)AKf(_J%?a(P0t(+D@5*1FNN`fV0; z{@s(syjz4Ob{C6b7-y%TF?i9!I)>L9(}WKx;4=AYTEHA_RJJjJZ$f@{Fmf+(0||^_ zVn#cYEltHrFby!oQFzl0rJeS#wR8ZLx0*uS-<1&e@<_=!3cyyH>hjt9Rc5vh{Sl`1 z^0%4r=%fRutsjpHNG_18To-XA!nbZE1YkGY;*S;0-B3v!f`xuJt`^FEh zFEfo%A6;-2t6%FX^8=wu79~0?i5JNi4vzbiA!c|`J~3V~Z`ISB9W~TwkAo>cTELEG zr>1+}T+3@UZsQSX#~3X-dK!XTt+K?W{7kNsx!!rcGKdll>)|n07RLWY?*@lo#-o6T zoP;-6J+~>1uMUv^Sk(V}!M)hMKzCU<#>br(5mTYrHy(_2ySqdR8cCPbm-HWUEqe7H zsk^lErD0}dNzj|U8Ac<*MtT9!+;@OJq)JTj}Dft07Coj$ld~kWWQOoZylZdo5%`3 zjuW68g6?xsQlqmFWTyC4R_kTpB_a+KYbIg)Xq4Wo4=H8{c{MUwTbH1zPued&A+B2^}gL{g|AP6fK_`8-i#M>Q+vYLI=ba8h z_w#u33H!Xss6*wP)wswDuP8rW4ExSiI^`}GBvV6cOFblHT!$2VSNl-m^3m>@9pNiI zU`teh@WVybILXldy&hbq4xM;#cZdR>XI60LrAP%+Bxq|$sSodu!!}~~byLpM^9_Gc zor&=qd4V5qel<&0xuKEy>DS8{*j|4mSnS_S5OH2=%NF7Aflsk*wDpS3XZeN{!HjEea=|dMePFqu&6j1xu0qXUAg#x0; zLhe4iMJgGvdca9KHZb1Y>#>v_vD(=GLT%MhH3Jz6}sv z8(Kw{I|p~P2aOQkeyLXxxc^JRSBB_{;O?(___^4R>V0+6)1|C+P#pQIxHMoH{q zSmcakHPmhuogSY@E!#-p3#l}>jvn>V&)oy5??ToBaqmXlw8X(P6^FBlS~J8KI(_ME z+g*_}%{0)EW>QMqqOBKoO*FwEnY)A>eYSe;ye)x)1snV#mH@At6 z3B@vOv?9%Bl?s2&JH#?1zA<&QYrAdy%C_sB()N!Y(f`|b=gj|*aE2@2Dz88~@Hv^M z8`E9)KAhAO<`_pNy4eRPV3eJx9|gp0)tB9}t#RIxRU-!ZMw7kRpch>mNHbMTO=*E) z@=KDzPb$)Ev8QxO9$Ll(4=L?c)SH^c)1Qso&2C*q{^ZR5a_R2z7{A!2>pr_OwB+C4`!w|`8yMaM zp!_U6{1=@m1Sxwg-oCEN(9OC5-j1yweXC*1_m(~wE#dE}GdwMDgHP}Iu=z$`>G_vF zy7IVLJWVIcXc8X4!APb2@$BhpLAun?!&)NI{l`IyOohw%$D2BEx79{bC+E@2DkX|g z{?cssvL9Xbi9B_^i6#!I&Q@|-^cq><+O(g3%XObU3T)J^5EykEzpuqhivHNw$m7NI zj{ngFlL!wi1jvsL62KazU(F&LPyn-BrwGe+MyQ96qp1U1Q>>Zv#IptYtMs6v^{Fa` z2-zZ=dTRBB)V-pgH;OxsSgyk{B3&hjJQTnfJ4U^Bw*MS5ZDD=-xuslPVNZnHA*asc zI7|Dp(}|NNDcQTn(HY7jBMz6b@+9;6->Ya^rrPTlcs?qIQq1SRQ`MBf0&Wvo(k$ZC z-*spsrKAn%h##VmJ3 z{gUd;Lc*0=_!!Gr^vVP;rsG+sk;a)SUnkkfrFM6ssueZYd~k8`@XUF1jm{#AT$KFg zelNLziw4QFVZURTfEW$388O(WY3YTTZHlp3dX;cNTvjcPwOGbWK*;=lxfBK1Td$q` z&a}Ei0rTQR4W{t&_orOxWt(1|Er|l0W>?U7YOxf4!WXxOF{%|mSq`{Ks z$x(m&So6i)MhpOo!B~3JX!X%pt{(@<%+3K-hzZFNMdl0^0?P&J5=ylav zt9;6dGNQrf2sTSGplRA#9&cA$I?rG z-P5_+HP$bmRjfaFa529~Aw>^H0lFt-KVxI~1`U5Do>({MCD!we7Im-AnKnen4ep8m z=>A|L;!B2J$;Lbrg0H9IxOaU_@^Pu7{I|E5w7@r2eg)>$e7{f6l3Z)xEUUSyvV7bHYVM+d6$uypH#*c zs~ErD@ZLra1z0pGoC!#K_>3 zl5NjtM2Zz;!c8p;UlK&pmzCKo$6cs;VJ@kVAJrrp^Kav3rm`Ri1nletFb?gD z!<9Jo2e$H~>dABDW=wWVX}<8jp};EZrUC__0KA3MTlr1vT)5(_ox4Xgcl$()44B_T zD`ugcP?B6)UAFN)2|Vi0iOL#y?PQ{vAn2Qa)z@?q&U%HznZ2>x1*zc4OkRZedm(qeoo#d4y9-5@|FJ)H zw%PL#)sBe}Hx^hw&Yv4``^K3|7XCq1GCh|$P&4cBWPT@wCsIC!M?5B?^wa*Fyp!7g zfV}fi{R+Rm2n#u-2veNq1bo)`_;I)K-Zip8j0tzaC;7l6SZPA9pE=n{W`={3!27n0 zkiL#(lC_mYiII_6eONL4wUhAAR?~zKmT@#lIMg{%ecNRreU`*bN2OQ(ck5a|0QdrvN(@Ckmlg*UAN`L m3lhV+caVvdY#m*AM2TMs#&>5q5UP!us*nH2LmH0y@jn1aZC=s< literal 0 HcmV?d00001 diff --git a/docs/za-download-2/2016/2016MatheBG/2016MatheTech/2016MatheTechCAS/2016MatheTechCASEA/Anlagen/Bild 2 statisches System ga.png b/docs/za-download-2/2016/2016MatheBG/2016MatheTech/2016MatheTechCAS/2016MatheTechCASEA/Anlagen/Bild 2 statisches System ga.png new file mode 100644 index 0000000000000000000000000000000000000000..a3a4e2c845488b9e1d4b24aae6b1f874b28ce315 GIT binary patch literal 5764 zcmeHLX*iVa-=0!UC8kog81<;oLb60-Y>DvLQdttIY*`})!;G4yq9|oIOm-qX$i76O z?EBK#W$ZgM!tmbr43+oG`y9u6{J;G_%rVDx&vjqF>$jc1^Biw&O%;~C+m^n6lxXzs{Ekz z_)-R|HD(Ni{6t14%%3EQ-2HB+1agGAR)<@tw%Rrpx-IKn zE|l%Ef72TRVEl0vyoE>TY|W?w1X=Y+eT zYY7FHwUhqfyUXwez>cVnyI>9r;1%;JbOT2i+nkb>vK?vRs26+7!aEUh6{}crksSK| z0r=mCE~}|KGWKda#b%vL0PhnIl~6h+9>yP6Ee)C}M@k8$HCQS;#Xgk20G?wrR;q0Q zE+ukQz<0;!b}E!cqq-!4NS;lcP%yF7{K^)`m1o9`rv;xe3WNUyC(||22It3`J%{v|e_X|#yD(S>#9j7~) z0hLfuV|D`XbKhP2(jmnd>8tq!`gOZ-Z|C;0ylB|hbwK4O=HOf2k6)RIs*nk(`bY!5!%~>{x zZZnQMdOtVlHUa+SeSR8>n1(G_OR+tw{s8u82|PdC>REwO4EWxHi>iAG)X9CjR|`jj z3|QK-Lk}M_*`pJ^<)RaTr{90N9wE_VY@2sDQ(}Z2ruXcUMv$=Vk&x@1xGDY#{Dub| zai(v7mbqt}7#4T47dPb%S1zvUi}_wKV&b{hjH7T0`Ua4PB%5%&VHje#eMT$%#R;zB zy>;0`3uivP|5a4!a_F5GR%+{?o$ej>sFhZ?X61!&CT*sBq7E}&iE-bhK2euN@raqs=`{2$_cQu%5ja3{ z@!e@Lt&Orwm;3y9j+E*AQU7kg(5X87LFW;LLrK#L)B}{V5J6-BX&161ZbA2ha|kKY zI-}EvS&Jt-T-}^hNA2^tynO{|9nYghuLab^97*v|G?lp6G4SY-^p?&=3RCw zq9vDs-uI5aVx_7Ha~UF&$t%=pZo;C7CnYd7yQ=Ha&nV8Fr_dV>wlcd$ThU81w)%S4 z_J+Ze8&NGs)qu`?D)@xnVF^&g>`-;^a6QplP`lltjhJ<`>VI_tGM$JQ5f;YKmgDlS zxFrOxa%1Hs7jGwufh8P9;zzntQr046J>5~LggnJ>!DTGigy*TPRG|9>UV1lO%Cl|2 z4NtDEt&wg!XzoDy1II01zjsYza_K7!&6gJ0|NtqgJF+!wYTqICHBf6`IRKC?gJR#?G0XkLQ3I?CkApr(iK=FfhdPH+%qTg{DJu|=qzn6OVjz3_nzKQ zb3F1930lN4mojq+Wa6>CQS=$1R}3@0b&GeEDEK2qG_<s8?Amry6j4F`OGa^xrZ6P&gkzojo*<1gXETj%cBK3{)hzWT zOEbLV%z~^poIkT?1MRtkG}3MESkbQ+?%q%#zkw$W={Aqe*wpDplcyC(tPB*X2wm)> z(P-;yR5=pDdbFLbBbC2+Z;%FImVSExMkF6I>Jd)4t?7M_T?50%OY^etExvxyMPlhg zTqRPd`Y~&8r%T5t7XV$>r5JOv;v2P;aPXVK-SfergK&pYHiB$H=>x5*4B6n$#IrUx zHsD5f6x?+t3`o=nv}=8bmh1pISHR4GSZ&k7E}6^#TaPB($pod%bTW3)&+>)`8AJnz z{hl`-B~T}|-e6fCanNmjrN8Db zoh96Vu4qZmKZBiYX>66bpN81&jf)nOp&e&EC{xeQ8+7g@glQl-r6hCzC*d zEQWGyP_(`n2Z^BTxR~n&x6L@<4KsFS%0Njh*XV9gMLxWzCz?_drcTDjJ9kU=ye(e* zFj2;oA%_5J{x_D1<~DItLXyjAryeF#s_O&*TV9lDp=+DvPy)rSttOq7;cUNy?j?_u zk|%p_?4jx;{eg9F;j4?|?d9c{>(-rE{H!Qx)TO*!HvlWFCjk5h!S;0my%v~j=}=QU zqfMn!gM)*6i#?lPLNj}<&C_^LrB%W*M+oiOI>yT0~i^ zPgskREOJS0p7UoHo)kkMd~8!O#|m(5*r5|aE!218Vj_&O6?>^uu5^NU*YwExM^)R- zT&it@)A&FnBjj)F;`+wh)9Q(GImBe~7$q}UzU;hWq+sn=TLe!P{PQmO%kfl8ZSUr3 z9}H`}MotDZ>~oL1Ep25QfSiqS%7q8;2D{A5031ZiGZ^0z5i0Rz%$Mk(rIa59j4ojj zk)IqdZyuuFjuyOX_-6g*Fp;8REM_?KGt3^9@K={Eg||WNb#H6Fjv4H}UjRE9U#+pEr39S>iyCDZv&PEHQx3T(SG0Ofa4uXowp=*6Yh7XqRqcpi`-PXKm*%42nD zw)_PLkh2N^XMrkWc8h)}og-Hk(14Q{TH$`+?Cgxi+M{0o1p+{7y$s;@N?MmISrpf-L{F`&0MaJk__%w4;|CxLT- zmL97CmJI8b3Z9^yO|l5+ARvlBH@iiP^XJ$yWWygrnlAKC`ur7lg07k90Wl2OP(Q!v zJYWXMZ-Abq_@?Xf%v-1%U}p{p$j+Bj-R2=LIiyRf_{Kb{%c$V&gs}9 z8vuE@g&+cf+`^{lx)6{(YW#6-dA<3Ou*T?TEp0c#{gUBeDyFzi2kPT)UD2n6xjZ#n@uJ;C$8S zLFnc{BEI(Y+!m(PzZm9a(Pf5e)X%QObmw>DrA2#9WTaOuOBdddwF3Rp5kph1=L^HW z>FCvuXb6jbFS2%Gc0qG!itdGK8RuUME-)K)o0TpQs4+&)r2Dnu23PJ&`BGvLijlWW zV8opJuHk;qkmKk|y#t!EMd#|LznjQ-To$V*MW9dx0=P_kyX@;(rA+sVAD$%a~=3X{RKwkBSVCAHi$6r1rHo63wTP)2YKX-5hSqVU z+BmH3w`@D{<&xE`om)o<`uvQP-}1ETNfb_Y4L50eX6**2iqrXEjS<Rvog|5p!Pfw%FjcpD_N_zOoS?wlAj);|@hhB=Y7Bh{?*u_0<)tyK^&iW#lyH2)) zmsLh;tZV)e6jfNlw+}@a=pRO>BXc`r2nf@MBZmD)3v@M1@Wh0HvDtw}iSb<3i={O$ zG$MFjcn}ouAdfoId9=fd8AB7pT?UH`mWNZM$s}-Z8a+pna6{ReIvSZs~)iI z1S($op}-PNCI7ne@Z6>Rpl%jkeqxPCPXXD%AAuPHPxU+Zw@dqZQP;8wE?77Lbs^u$ zFV$l9kgQ(8L$Al;5v3wTWNUu;UDoO9#$-+wV=0u(SJx6E zduk`KOeDe}Fq-Cx`(p1WY~DKc5@9gym&|;@E|t!quA7sLMBbuxy;!EH?%}$Z{?;JeROYJlP^Fy&lzQM0L78G9(j)%%7-bg zBszD$YaGB{5Hkq=BKEiaKi+W(({%yH_*d-4x<;~@`UT|35Y6*Whx^|=Q`uedt!t<< zBH5x#dX;sDVGdT54V|Eu?vjMzLp~?-{sRq`?Vabt)Z6*v5vHTUb(0Df?h(zIUz`N_ zBAYH_H0!y9BaP;lWZqhHPZ4CyE`$}`n@mi$;NeUQCx~*F)L9N4dP}u6`(Pp+LMtoc zkVaqs#01bx!ePA|*AC<>!(9CA??I3qX-k8X-(hX?93!Q6Nzk9dyMh0y+*M%PM|c5b qMgb_<)A-*9|A}}kQvf9h#Vjisahw~T1^zjPs9x4oN>#l5_<7x z!vpS~y#U-8a19_KCI%4`k$^xTQc@B!3i@*t?~|-oZKROoEL?- z*x2}GFA0f>OG!zA`Q#PlB(8`^N=bab2_7jaDFrzN^SN`(5*OGmNc^8K+(&?h1o(`n zO@Mb1z^B0@puxkn0AK*XBRZq~4*b^(51)XLh!{jdN=ANmpqv`O$0H!XCnO*uA|yOJ z{rK#AfRKiW_JX(yF`b?@=%OdRL~v{-370COkwL$2i(As>{u5F%MkeO-EIhn?{Felz zq-A8~luBNVW1EP7`z|hFp#MI2z&iZRro*4lRA<+eKVp zMTuY%dezuWL?bDer2ZCz&HX+yMsBGYp6&0_epmKu!k+wH%Kj$ozjTcP6a;u@&Lf}! zlz_t{{;VLfe}KvNQae{i6(JmK?E~^nR1b`e6Pb)rXn~7OU!wap%53x@d>CllR>O;0 z>)9Bzu%=)gWpA@g%`=wr$z&;*)7as-x*PG=R@Oz~ub!!mZjV&9CSt-*3^UADhj+|Y zWDblgd^=10p;vAkwAmun2PewIae!?kn^-drh|9zQx-p?<#-}wnz{%e#G0`pgakY>2 zamG0+a!gGt2S2udFstUv`iUg8QsuqHN~y*{YQy7}d(*Cw*7Y_MVdux0G-leO-A`=a zL1TM3<5~wgpsekM^T=a;@mSL)g@&$bUSJ8)4&wBh+A&aCrP=Dz!~qpBw5Z_D4tl`7 z;?`%S2UT0gEeTiowv8Gd^#``W~3wR5YF*+-KTvt8HrF@_k0@44EtmBZBVn% zcl2QBd#jG`=#~>x0v+Gc$JnDs&d^bOr_o{_%~V*;uv68m=bfw$HcxXG0xd17T@O}9 z!Zw1az6pT$Nig=gu(5`N#Q1OiJ}VVt`S-xp@T3|ZNd#mbx7w+f|~om*fu$Ny)YcWa}ApbrYI{u_Qm$^G>D2!$Q*mGHy@g(Q`JIjXJHJ` zLo@AX3Dw$l@$tDYqyo))X%*WiUj&!Sy6Gx?T>=RJinI~@8TfyaQ7K@a&eVKIS1una zp8t+s?6w(i_Z=M_-R2TiPy?mIwlT|D{|KG>k?ePLN5<;%li$(N6FY@z-_a3Y(Xr*S zt~Ri*b07*a95DY5Hpn%T5RU`gaKNag@xsDb-jrMFo4{q@2Qd!s4V&ct?*H;*IB(PM z{_pJnl%(|C|MTu@A4iib_`&O5Pvd{&|35i&3t@CqcT60!v`nuQs3C3`Qc+S@Q*ZdH z%XC%)slZ+$lZP(UIcB>%6AC_wr#gD}NTR<1J$0BJ%%1ArJDGY@*Ke*J(m?% z!G3XVEm(o!jk07?VejMsK|w7>$CL6j?ts`jCIV--@eCNEhtj|r2XyG-fOi_r!h+Zd z98gwU+|^Z~T|c29c8XeR-lR3bq<=CyMZL1-DSG8@Z|`K&4NfR+iMcPEHSMZB_L}NI zzOgfi>UvfLKk#E~8Fb?v617rC#Ah174AH5j-mBB`i{A>VDCpKQU5X z-IddJj7;}5@*fKR>_Wn24SvKHMnK3QO%+xv$rw_wChIIKy^bJR@J}rY9 zIdC_w&B?Hb!O-2p`Lbx}x@-TzQQOUOK#e5LE<}z^V1v^et-n&;lUUfa>+~=_b??TQ z1=_(>HLa2d(wIStQW9CzAXrEwoQ6>|cbtfSGc@RvT3$!H>QB*(cJe(7b6w+wh&Cv^ zkXamu@Em?{33XZ4Q>HEpwC@yWQ;U;OdLHSCao8<*_v0e_uM#;!y(Fuieq*1^hET!2 z1&$B3Q{z)}lg(Ag;Nv$AIWHF?WCqVw8BjbPNCC$yE9ytgIeZwJ-Ik7ieZ>m?x~jCY zN_4^0jya8i0IPWXt!;-P7YC3W6yShsX;nBN(52xUN6=+5!KkhM&|$JW)w?(`An*y;j2$a9Lb>U{KufC zH5;4m=I!7Z%>t7W)9obp(4~y_NAS?Z- z)$GW-SXJ?t*1wX6pATQBYtRH%_gz)J6BK1{jXq!v(k!Kb*FL5wQUob0J>MSSHXkU% zNOkcbpLzlbzFrBj9bF0>>$f1%Jx*AvK{z8MXFK|XyPHh;XGF6@m`8>O3%+V}R|-Y? zZ9Tt-&Bmx&$z10WzXtQDO_!M}JCnla`peA0*qAXXyq*(-E$PYfV#h!}WSv^f%_N?r zQGE~NZqWU^;AB2RHV@DhWBVD=Z_zm5t0n9-p=y(6g=N0tV3eDK7bQ|ZZ-mJsE-RDBi>TSoMhB9hU=^T1#F;f7pxt zl0+F_CZ3b`LQmb&ij)TYTHit4;GGJYp6nkL$GYP z);qE-o=~LNTA8P$K_Uc3?+_}u1VBSVzId(+k9q`EQ=ShM_LU&K2sljBi(@D+Zc#jU_%kxZ`(NvkFcrK37(det5t z#Fv9Rq=U0D_`5eT6%S@%nCsHF^YGn-U9Tep;(XT9r_iI6vtswJS`y0x`@OV&71y^y za%3N(=gd?jzd7;6at9$LV`6W~fr>MW0%m+)>?!&-B^kZSo0K}N_DObS9}m19aq({O zjd6oZ^Bm8Xh_kl~W$VSZ;;}DepFYTSdL`E0KN7g1*C~7Q=;8iBx9y2Y-@X#QpRQ^5 z^r@wV>f$x&K}45BSz1K4_w+Qy1A;h5d<&h~iYMqt0ro;i&FV-aUt}U{;S#aDnfZPC zsJA#EL1!Q)aopH;R+MGAo}nN9K+();rmX5NqFs2Ripzu0R=_ijg#DbqQQb6ox9x0Q zdN^!`ddOJYMaSR8zI1}SU;x}O_H<{7n=W)MO(C5*cj-m#C0JJP^n`0*A9y22;*3}2 zJFgFC;CXsrDkM;{?*R^=a7wzGDpfMEQtevntK8K2u94x!xH=W*9J>cp@-_xOU1P;D zqILB{A3Q!4o!h!5-xzI_Fdo8n54P}XZ*Jj`0o;TJ-`?U2QA@#64J{0ca7$Wf(W3$& z%fCs=$q@`vLW*FPTSSY9ZOh)4hOOt$@AHs+wMdDPjW~mqm??DQH?KI-<_En12cp=o zOJ%F|E%e=*Y@FGs1vTOZsvikq4Z2_uFSo9|{8*6upUF{9KgI3X-vlh7V21S*t+R$E z^tWoLO+c5eaM}vbtv9FGad4tMXNv5d?J*3Bb&A>VY}BBZ+*sXT*73&9$)&-Ue}NTy zRq^5Ry&!Ik#q24S#a3Q?&Pqj;;znxTn1(Mn_Tuw5ocgj1Wd5K``NqO1o!R0i=-U5^ zu8O}#*P~0Uk~lypJ)bQg;>CaDQL3+U0X4+BCoZ|T&t(=FH4o# z&aWFN8*|*4+u_!uBP3Iw&D7!CUVux9)vt(j8U`{t_e%|E}BH-P&c`$ua)j>E@y%asw`e72&++=(tGN|PIpYO6y&he8W%6MCH#8Pr!AKQga^xKYHvk_iAsTg&Z)r!!F! z!+I!p?((wg3pTOvT$eamK(#=?^K0;zBO|6?fYz8ZaerhzBY4W_PQ0c$z-t|wTXH&{ z{n{Oa*Rs8t0Qc2{#ikw^3T*Z$wHUQbG`E^rEQT4XfD+FAyJ{Rur;fCtklh3p=>7yeM*@O7lu}or|oTuuR~f~ zYz-Y6N13PT#`r+sc&~aUMEyY0?BtTp&aP0g@W9O+&F%~KJ6~v3Pd?v5=Ww*i(+Ff4zL!C zIVD4`6nLp=Ra?c%ltR0))@t;(ng81T|L4De-`|Z~j$X(5rL}3&6htrfEY76t6UzoG zbF_6Uy@U%aNKdG6}ToEt^ah>4x{7L6yP=K{vg9Odrr?&3X0V%dr6nH+7M$u%%Bvz?S{iqr%EaE&rM&mR@%zvpv? z&zK!>nV(h1u@sY~Jv!?f6NsiO`8n2D!F)lH3KZR1wLJ5f(Y#BoudGf`_A>N^g(3?2 zNV=#0_$CB-Ez-e#s{q6m5$Rul9B8#@EE1j;qvkQHHK|Q6BXbm)^hoz5Kv2Tosz+LnJJ?Dk+P_k$?S`YCm~`(VJVtlX;fYzw4Ryn=|* z#{p)^+I{zu>3OWnWf3^*H7TQwk~g^q z#L5h>Uxf(KqdOx#Zb&oBLqyl0IN+o3?HaABHE(0?qlA3-+2uIX4W-gW7%x%y-2fkJ z8k?wogG7NgB?;G$tA3qzaR+P7Ne>3G7mlwdMWQ5gt0Fir@Gi2wK?Z3SO;gU|facyi zpKrU0%<}|rgs6EWnza@?xL-|V@5v1CsLm+SdS^7`rkU(1i?950W4?G|`7&L%hCh5Q zTcx~qw`hhjYkE@ZoCEhNmkq;$_9wC#bHDKT3XcqLq6VEd-38e2{@~_vk#WmIm+J0M zIj-(mQ-uNZG1`N7-fX-gp?LFk>^vzady&}03r|z&aH&*Alor$vZG90+vi|7fa-#Es ztpRL<(MVTJOeX4mR9PYV6lQ6a8Vpb0KYgHZSuiqf%{Z(@(8067k{r@l9_kl2GV4HH z%HE8BJ^6mAx8rtjWSTr$$r$C01J125qV%0_3nxUm`}M247_xe}2u(sS&;-5dygMgv zJ8%u6#t(+bo)4oA~U4rPaRSBu@*iwdB230)L7BjcVEF!E^8UO@?5!+R-N z>3QeyMXf>sCW%>iX%IW&nz0u9IMba5G@4}>UVryF{fAw$;4*yDUd7wmg5{@9SVwuw zrNTawK$*_}H?(cC22hrM4Y1@9++d0ub#=B%>}rPosfe#MOI5T%1~ zj(#kP{JoQS{rNe{sHd^AAc4-pP>mCFIza*TL&yUd72O_@~=d938pgt&+SyI>UBI3%~7Eg$3 zU*RAo9^xX1mV>kKlfPcygDdDnGq0B&n>$V$nHVt}5?VhOqR0Eljqf=a5xu6;S6t5g zfVGadY4(V{-taTBeTR1-w)}Z}*rBo5T%Jezg%kd`y6+J^H5h=4W~xsjCX`ElLyEs-=J3$Dx`*n&yDN14)|S}_)x=2C zI|Lhri28U`b_OApu7`MF1n^D@F6SI}n~^bF_tHXgG0&s^hKZ@jxiM*r6NSu9;b%!Q zDYHYL6K3GL-$cwk?dBGEs@gZZo%)oirK7U_li_!-+P#om6yLo(2L&6k8iU@ADhen> z$&AjDX6>{DK6=3oXU0oSbR(6UpE2GrNh%;i&S4}8l5r3PbpRy@2$FNo!w>}|38E+x zhn$n-9A;+6d#`x8uioz4SG84JTia8Ab$505Ip^#DJKz7E9?TGC5}>@Ls;&y);NSq} z*dKry0h9p{At4bV0f>l*h?p2eLPkSQMoLP?NPV7yhLwq(jg^Uog@ap&kAw3v7Yhr& ziHeE*Xat9tn3#-|jDeh-LF59<1(E;uhG_z*KsZ7;xA1T{ z0bD8^JSrSaE5Hr_I0V?y{uuC|8xAfWJ^>*Sh?s;Fdjp&jz{SDC!^OuVAi&4R-W`Cw z4&YM}P+t(fPIz9|f{4?NM&xm18i-4&teN)q*9~q_%LhTkBy{u)j7&VdeEb&$#Ka{e zrKDw)Z>XrM-BiDI=dPZqnoxOvjyT?OMFK-{;Cr^Vzo;`m7i;8|76C3v? zJ|R6LGb=kMH!r`uq7q((sIIAf-_qLF-tnQctM^-9|G?nTFmh^oW_E6VVR31Bb8CBN zcW?jT@aP9F902bRSf|MThKmY|3l|?B51;47`q`t08WZ`z6Q{*v- zMkz9_teKcg^!5g=<%6#zblhT7JexnD{f_Kk0~YjeAv*>33l|a~!^6QQ5045^0QQgg z(*sHVow#()g?8!73M4y4{N0^-69M$R#0QE`UEB_AiA?Jp0=@7c)`k z)%XRr>YW?|ayJV|-pa;GoZA}AFRnC1#j=QrqPnYvz?~DpRlI3JF$_d;P+-X6jiSGp9H>Sw0Oho?eq})89N{mSMk#W=tkTG2Iz??L7`mGytU!l z=<5b4yUiG2=e()ABJ;$V%dZYJ)-k|B*B}~=zyRm(iRlpTpYZHsfbBU527nr2fET%I z*9`MIodi^zAS_`|xemiT6J6b4ZRvqRngwGLK?%kZRbQV+;@|cOyH!r9SL+)H$^PDy4MTi4XAnDHCV4@pb4 zjoQ$l*w<1%!H9s7Nhs_osq^-UTUTI<_J4N#a{y=dxWzdbfTN)2Xc7Y)5tm4psPY+z*Cxn-s%ZzJ@qHttEUV z%&RHXU537L;rhvD!q@ab5uy;Wrx?J%c@sK#aaMb_uW{qm$g5qighZ&ulJ?@g0XPj# zBr3?y@hBAoAi^+!u-YC?$_{fsbbT4RNn4@eo^+F2pQJiQNiyx^giYjVaHboFm{bcg)$>i49&xH$H@<{aRw5Fl1^aGvJytGW z!rhG3O-u~Kffe~o9UpigG9IXN~&JE@%> zDY9nKwqbzxCVAL-4NsZxgZ?_iLH=9M=ooen(n)Mf;mn4c1nuC5ZQl9vQ(*{9&M1ay zIJVah0^8_n1#}SP^Dj=FVS)G)qj5?M5CNY+`Q@OW+T)e~;{6%gol>$`5({aIwh{vr zQ(##o=&9F!(1=#5`a`OCQqLJO(bo*4k}mfC7C)9!sa|30(-Sv~W=K7k`IpzE zr-GkhaEGe^&(Rfk@>RThbTaf-gCYTq?wMc<&xU>w=s{QWp@Rha^}Z?0(%NAq;5=3W zXdO>$zQ8hci=_UKRHY_tC+s+#;mEHRUA&diN`uhdwUi^OLZ>~JZv z#{8}CGh^WrsEx59%gN>9=>4ilC0I!k7D{Gew9c>lWVcwYZMhLy1?8V6>R^?(DgInN zL5~Hh|Lq4SpH||0OgSXYLHS9dL*S12e>pxK{!i;7$NU?Xqk1VQS8Wu&UVgz}p`Vs} zy}#+8_#$>f29+qRJi!2qy!G?HT0H~ro_NC)#UZH)1{kb`E~*B~3j9}oX~y3S16(lD z{#}8o2W^a9La_>b$>9$LR>EpZ&q?q%bi-Q=pbUoVY5(Qq*9-simHw|uqlispYNUm0 zRLlj|IM+KbiCNLFkNEMrKm)t%WaM(1o4<2N4N8)0K~dyEW}-f=K8Q05dU8CJi|%zg4P zG0g3#e>s0*i(dO^&jm&9*ngV&pN7v@BK}8dR5+SUIRX{v8Jr=_sa-8Nql=KQaD_tx zQ|KTO2Kb`s^Xarv+B38$h4{T^-=eF-03{yS8^Zz5yZ?#H{4pTfGqPCR^?!i-*}qv+ z-n7tjP)dSz098`>do;k&*~u{Q8{36V+O@Y1mPcXw$e8&?_Z;!jtCue8w(__{8a-+C zCa4(Xt!Xf71y|){8<})Ic;RJbB^F)&USgCs`Q{N6B(LR--US@ke+ffwX11n@`}tJ5 z%n+#-o46c>&n5q0s);fjQnpAcusTPK0a9UbJ&&G40z>SK#>eJL1)Pct3m)2+_~S(l??O`p3y>mHb&P@xE4`fu3Lc*9v?3;{M+&{CBfbRX>D8t*(CaV^?NQzo60j?damSh{1y_!;b)@xDwe@32!bM`alz z!6%}M$8h8cduh|PqrnVFt;&3E)w)IDn1dIkFy~FhhYu}+7@40eQ&<@jJwKth$Z?dQ z9M%-m#{iZg#8y-FeUINMYCG4@6amWfT#!fLD6p(2`BvnK#zfe6$ePD=_qH02ne3A>JBQmEQVIU+HHPAWme0ki*mS4-qKA0cv}?{!^r z0x|=NrK)5Ir1ULGg4_ac;!oFIS#7{N*I2Uy%9I&X^=W<{DC78{CjLO2SLt|GpBG3^ zk6PS@OPWX+Et`v{c*Y**JUcd|NMe)!+#=htA6pdgGdZ}o1QdRyf!+QI+7G)_34j4n zuvb5`sfOgh0Lh!s3Clq4^o*bB)cu+DRQ#G6*k{6I&r8%Z?&gWsGknPB&X(08hnm%@ z^Aa3x)enLF+$))e-)%#PSL-|JZLQrZ@tUR9+r9H+8NTk6-RBAMb{e6IajS0|f~I^#m}vSd0lhGnkWK)gS2r7^1*oz^Chi;#ImdGhj7zhGK;X3UVa08W(lB)MDU zK}$=p(5`OwGU-G1;Z@|P@38J^RTutzwzL<{0WWHXqZeka}+JTLjdk~&?@gdjO`pRi=gP1cYk z_92fIALNQw!#8B#Sg;XIvdLy(tN(K7)}*hSX?!*^;E6_M#aP|DYCfU*NiewZW9X$f+|}<8wRJVGxhg%i1q!uB zwCLAn$@V`1?mFI$Ew=^73-(AC*DP;dIKEtEV-sU-HAka8J?dqC%rZmEGZB6wN*53D z-J=&Rd~a)iyR|;zvfBM3&KUuk4QP?2Q0R2%u|%JX$JK;c$vwoUcY7iTC6SiSS1w0z zoet*ODD~L{m>H>eGIFe>sB*+-U7SIjLC8sSNY~Lhi~5ptJB!d&w*wubj24@0$Xo!Ad58^#FwgOk|Su)_q&HGll zK`f!)bS|^CamqM;@PyL|PVS5RGM1IOl3j|ZBD0Bz;ZSXC>wc23OqkfBBdLnsjEF|R z?0#p!#n3*AhIfn|fDgZ4Q!2>>I}*DUW__oN?$)(^VfDflq;ScG8K-*E+WZT_m~!h8 zVO-Aen8{+C{r*Mn^dX0-Y?u`Kxb{_T2FJpvff_+}+(Q@IR=TPZnp{?v8`U;k96LKD z(ro89dE>2IBvP`Cy0(nWkO6^X1V;R8tfp5P!bGx1DdoINRKFIUi%D}ha&w*UMM{xU zOe53Wk96=;hO5}O=bSLWqqq}(8K*!-3H>3gCDTCDI_8E;H&&7lH`WViJ^F0uU;JhX zA&;`hd&9K;lsi;sVDhmuUAt}$BKkh_r$|^tS+TsOMlSxtBX@O$F#jur@h*O-IM;do zcra2{f=dQ`1R;2yQGk1F1d#@^in`%~`H{~s+l1U3<+j#nJ%>T{i9t4P&XZ z@YHO-DbGF?1Rfv0TcJz|SSJP5Fq_dVO2&#Fgzq#cjSmb^Ms~_&e|0Ut+7w2%&Yk}l zy|G*T-uq<4hihg*sZql1!#ZrM0E)YE(Zc)wj*;NSY1kK@M0%Bs`UK(uGf_UC>v~8@ zo0^3z!1vsN{Z}7j(qfsPU#sDa^ewo!=)<1mfQp?!d#hjof0E{nkfdD0 zk!3I6q+0NpqrtkTQ>-rEYm#(=%TjzG;*KH@4;=c_;pZccEKR|0IBlxN6}?^x*SWan zl*x%AOR%3un(_6kHjSD>S%f)ms4V6a0_?%+e}1ezHJHY{@Azsq!)MveHwjL3?

              ^ z#pN^Wy@;Rhv{+~-mQ}MoZm4TT_RkLNVSpu(^{b0p*P80@&uc^#lh`@Vl2HPD+|-H; z)h%zewOfl#oKV>Hs^}N~VtGwva9#h-+b43B;QI-grKH|1nOV1`;}$H3TyKRSX{=rv zG3Gdy8DB40N`PU2`)cC--M88`()J*Cr3m?g*&%cvfe|yZ;md&WEdxZ#E(#hivafei za)<#U2X*fXHCAe7ZYXx{<#D}kc|Zx8b~&_VpyN(f<1k;96CrqT^l<-#Yt!_jk45o_ zaho0YD*4#4X^Y$3jN6M)Ss`KuW}S!$*O@tG`_;u_rYqFTqQf$y?OP(IhQTSv66@Nx zxs!CG??0vEDpodM1Byk+^d)6h2QnO3L(-^kYUNdryTpSu;CB!4@}$`Lr4O&4kUv<0 zu0X<7mlpz5T#pvQ)(R_)H1+#eb(qEL6MDW^m~?fiy1jW$b%hMSkR*EKr8QGkkzJu< z9H|>wg0JJ=UXjaX=U&Tule%U5fq|lH?E^h>q6suco@j)|?m2zW*9LqRCCe3#5-zUy z(Jfv9?v@c9q{O=EB2ypZn(=QuGRY`0?Wfq=%iZ4wBb904ZSA>&YRG+!*;i$S!vf{z z5vo@UX;A9ix)vXHw`2t_MfUEZY>kq{OA?w>YwM~@?I$1iKaKQhY#=iZESgP;#t(0q zms;RoNeKVkn7b55^otv@UeQhd zrrURUu3X9BW}jwe=*8I&ijjC3WaciiZcVZ;KzRQ96GHGYV>=UNe9!RdXOU>OVJkZ$nX4{pO+w2reH^L0l14}vXTISQyU!eq8fI{{C z#Rr$PU=HxoweF0mhRe&Hd2hHSg}ZEj8&#jnh!HTf8Y`t{ELU8RmKK|tLWwO;ARqmf z;xN|8=AW$VZv~C1vpsREa}HS1f+_i~89*-i2=c;X$i8SccB{Ynz#uHmE`05Q_aYG< zEX{5zT%gldm$j`amWz~hrKb3u^8Ra|!jbDU9Of)!3%KybR4-d`L~v#3y=3tqAIVOd z;5DJF_-$sw>kUJtdKM8i9egh}c{+l{{IYa5w1Uanz@#;n7W36n+VKo5qQK1_gw`cT zn3sj8mD#}^mA+CTCuX~0boW=4Ymus(=7ELJo9~f#FcLXG5Y|so`8In#1ksb?#@d%6 z>)+c^VfAo^4ea*qrEfx`3NgIYv< z@}gD;6AxcC&m_x`xI55Yp*_)dX1p@;uq&4Prl)}K=2reo;cs$|t27ehQ=V4i-e&5J zQIgdmp4MSXZO(Jr(nKn85DIahYw}aulKU@Bpi>Q!y2;XWM!7aOsGe2SC-l8$rLE64 zX|kSVnc-vTEOO+p8#Psx&r4N-I8b literal 0 HcmV?d00001 diff --git a/docs/za-download-2/2016/2016MatheBG/2016MatheTech/2016MatheTechCAS/2016MatheTechCASEA/Anlagen/Boxplot GPS Lsg.jpg b/docs/za-download-2/2016/2016MatheBG/2016MatheTech/2016MatheTechCAS/2016MatheTechCASEA/Anlagen/Boxplot GPS Lsg.jpg new file mode 100644 index 0000000000000000000000000000000000000000..0eeed8174f51ef28aa90529cc9ac704bf71e7cb3 GIT binary patch literal 72297 zcmeFZ2|Sd2+c!QUOOdRFP-H1vcG)JCJ;qXmP}vE|Hr8n)`-Bi;WS6b%yUJG9?Ac>N zmcdxYFxz{&uKT*KuKRts|4+~JywCf2|Ig(7Xg=ed^Y=T><2b&@@;y${SJF4oaV<4X zH4qsY8R$0f4@4RRT>%{;BisA=$BXTbhO9z{=fGnd#CT61^m!bQ&I2z#lP=GdJkftCNrdnAt&Pj9bzCOXCNar zgSbH;vcte?_ZIw*7ug|lio=w^l8zh&{-FFguzYgzL%?DWQ&0eZ9RPd|qF^}8ct%`> zl1cv#6^|RU#G~lf)V!D9ePA)@T{|mz_rYVDBdjM*va$2=3kaS&FC{G_D|b=;%2ic0 zbq!4|!|OMUjBlElT3OrJ+Sxlex_fwfc|(1CpF9l;eijlM78Cm-E$7i zZr+=c(z5c3%Bt#`k4?=jt!QcXs#o zMFt}OeOrI-?4S0<0PO1!a1SV`_Vz_~$QwAw87K~)5vOES(WkoO#>6A>h?@Cw^y_yY zXm}+J)>!U7=sm)ER%)6Ly|=aBcJ}{mV~>AnXMb+&uYHYzXvxWdn@7$70)q$(Yj*h1 zT?oOai3CbJVi(ddOn8?@NbX(_oSbqTdF5kii{PlQ>k&hbnKaO%QxF)@Sex&QlinlK z;ocbYp4gO!u!41r6`}C!c18S)9udn(RQ_olQi8#;2i)hAtcTu54 zxG5YX+lf2=VyXAx>cS=V>qhs8=PN3EGaBs6c`0%n-z;p?U051AoqBs=AQ!bL_@u>X zm|ZHNgEjMX2+GD;dD>P<{;FZ8l5da_fez%LZ&G6!R#@(57MdPzaJ_=#6(nc;QrqLe z8y<&uJ@ov1`|MMN%h89M)G17pBt&OiJeI<3+H~2k>OtOKOruzXkAX|FaNddY0Trt* z#0KM0>-9!@Fiy6K`4~Kmr zfu45v;4GIdbPMNwQjJ!ZYY_q$+je`zY{8=wa9_|+dWk2PZDc|IQAIqbN#@BpPSH90 z9TLcyS7;Xv8N!=5P2+;1pyjf5=I_TGhEE7Zy(+VR&wbbZ61#_yOWsVrGtbbZ!`*>I zQMK>K9k!X?e&bHmr_rrHd5ri1GL!EVz^*VCZg4@%>rD~je*dxhyEJphQnaEb(;spa z6H0N$S8=k5-wEXn!<9L47zcFiSm!zkbjm2pUA_%YaW3=L*pClql<`Zgbi?6ogN6+@$o)&mri2Fh#{*vBsuzc3I0*w8r$4Mc}F1`7>k+N zkKQRM)9A;BjDh0VRHdFRdOx&``IPFsBdpRYMYkQY>{caVYrX7YGCoiD# zYz@#tZb8a^QZ?M#-+X~}u_V8Nz8TXfuS~jG&aWL^O~D?}yevmGcm1+qu^M?}78CZ( z=#*O2PWnIuW6GQSoN=k9;+v0sp5z#0ROY79ofnd!XED2F-)AXo`us6(d0ZJT=;h`D z1LVTiBJ=j9ltbY8ib0eMf0&4-!B-bIWrQ0!7tPL%FDDjyRZvTp=ngW`ZhtU34}GI- zN!S=YuI~PTEE$qIL94A$0Fz8J7&~Nbcd`4@>6w*8q=J%}9&7KFeBoz`U0LHz+}x;p!&l`|cd^y9+rEqS%_E0klpRn@mVW;4`4IHn|XLqHi+ z3+wt?@+59WemdR1B5>zDSR$P<#rFayL6EG51R4XEdq7HkQXykzC_mr7d~=JV@x>W# z_7r(K#+8%BT`99ms`oP>DLD)~Ocw}4Js13cAe#EW&XwIp-zaa5i+@AJnUe*kFmp|oB?*L!_^aIS z{lJ5dV zX9Kw`I{oe@=V7~hGzaL)-Y#~XZo9DHEvepvb6~R^4(sm2`-=!$;?y{Ga&YG34j5@! z1HIASOzt_i%tYny_whUqc+_RngPabS5#j1?=hRW{8f$wn%x>OH&C9wWOV4wD-Z$q; zY!}F5(+Gm+_kxX*KzG+YT@^alD`7U{I~TjbLq2}u#Gz%s-hHJ9c-H?$D3<*QzDI0N zKuy}EV8-fecXuouV%zbG^)E4iIxBp0W#c~nBO!sQ#UJkE{(#z#CY_%zNrh|B<}f@y z|9SPrZx5mQyZ?3iIAElk1GoVJJ(<3ye5og=nzF{bxWTAQ8<@qp-6_Bf!1v$vux2kW zRdG1W&&;~@$48zdU)HrJlO};GV2(c!wi}9wipo*U=6XYyvaFv^swFaPpvb$WK|-E( zmnqY@#})SAqZ`Z9evNklsYYx!4BEXzu)JWusAEzBfSEQ#a&D;Ro#a!vVGOBqcxLgA zVuMOIOWjrfw+M*qd_`4nX2Z?$GZgAhLJr$U#TWW`QtvDcoLl{2--o~9oIg6Q!)zLp z^>%zFKzW8zk!d|YO{_ot$=J{y?sPG=#5bI-vi}xtsHQru)1BgWuWF%5Ba8+^CDpYb za(p=uOtVR#k9Ky1VvLx8(MDa5&~L%vD zuE7Nd6H~_dj|WPj4g_i^SsipEW;Ig8S>(&CBjDQ4XpiiJ}n-pab}@U2vfPFQ&@*Ctt^s`X-^WNDu#xwEi* zsfKu`ym@Eu260r)cxwhxh%Fgz+CF*;?dZ~7`>3M7NYt3~qJ8f}<>tAEQvX5UC;Nf> z9)Nga8pPfWaG7!o{b*fJ{oJzo&h|tSpv@mrCFFCO#yAZyLet=pbXg5yenvK}B0@B3 zyvglbF7Fq#Df|D0VIg@D5r89_tl%C=9C+DS*-LpfxnZiKr(sH#J8}<8A?%1_*w&9j z8pYpBliGc3WOn_x<(@0@C|jv5+w|b6I#ZPbN1g=gFKDC)C?y`R=f$aa62IV>!Hyfs+sW^| z=cl|JYV0DGx12ATsU9mRIkh2^;7;ogt3U@4e1MbuRdwtx_Z|MuUa}}_^d+$eqto}1 z&)Q%LobZ-5wtLy{3)PMPE>(0eGM^d)PfNWWnST zxT-7QjJd0B&W9U%d5+Unq_{n(TIf)VAj1eq)#QW~mW7-7WQ7}8S93g9$r;yedKhSP zl}~>2w!*F+xjzG@2YY!Fj^dWYU0K#67||!7BdIdiVbbn%+C*GPd|ZFr91=S z+e5TGf4ukJOg}KS+cWPE2c>tB2O@?&xmF2&-+PzUt7zghb2K=Lx?IZIym@?Kn6)G7 zl~hO?;p-v@?c^%;&hc6T?*nQnT{Mrr*{9 zc?|qFCMmxh6e{j}uK_!ig0LsUD1Ic=WE!KqVK&Sq;m*oFm`_$l_X_M=e3a0;MiB>A zy!Jw$1I_rq41zuPcgTfWb1weS6ElZXUp`;7w1Wzmyj)1&Q2e$Qg68k4z29f^XsrLN zwcQm%z(2(1ayZ->Oj3!u)3;&^M_~FS8jG39W&$Nipy(ze5~%odi^he{XI$Q-PdYqM+BnXA+5T@8PILF`VNHg;Gcgmk zeq*hV1>^`#M}0CU<%o+CXm|r)mtlfwhyHQUP90f_oS~C2nP8RVoE@xS-eS0=c^n*a z3C7-6KmyS@_Bxq*#9pC)9_sB*W^vJeK1Rj`T-Odknlgj*Rew9kl8c-oGZ%doj{sM# zbFw{4crP-Sz>5x{Zyt)XTaqs`^~%U>I;(6oHBrVO%UyGaqQr#+YOuP~ww-_Z;Ss^~ z-;b`fqN>T${d_6CR^pT9W_KX^EX~p6*r!zJ!t*_ALxrs6Ihs-zcBm7)FR&ovqkMR6 zk+{xGq7>O{Rc1Z1y~Cttu4+^_C*yr?fwV$v#a+nwAwWRjlMnGa4n~2c`KmdN^4BD; z%qbDZRG8dw*~hi{S8;PPr(nJhr3BN%Kz|&+xDx5tmku-Q|8}tb@=D4CLFr+?9|ga9 zYc8=f>#KtH4whfPHM!)M|6f&;N>ij6wx&(zYnJHpim3K{oVelXx+;v zE;ZA4m*s;$`j(k)2bGQeID=D^t%!!4x)r!!LVg$-Mb={fyQ%`}O6fdQHAEDB*Y9;s zyH+7QW@q*qi`3@i$HDS@wo9|v>om#Q@0#{(d2Mo3Rng52mt{?RB1(>m z`6rwCA7T7IBn+~R)d&*k`~AVjrFB;VZLj*@-d*CF`qs0_h&jWuTP)w7Hm4%nXmZR5c}yAQTnwBk-f6CqCeB@ z#Ksv}Z_&1k&4r4=`dE!yfh<4y&)u$;SMpIYm6|mUE+ZnxBugxL3?P*&%}>yUM|y^w zm*)mN;%fp*RlR!bB_r2&7#}N^TQQVasvM2JVUmww^{M?C65nz8_&`6)QdtG%c7LVS|H+2?X;9nrUsg_rC>S?s z%3TohNY0K&#e6|3@U8Qm#RV>FfZ4wk5Ns#Aoz*p+a>k^L?_r+EO^HPd@=k5rUcnj# z{RRQQ!N9Mr;U^sV#Wm2Iz+FFlt5*EuLrN&fIL-J4@uTIsH8&-2rdls}^YM{q=&Yq(u0rJy*Ym#JD#M)Jf;}ymn%AlIKt1#2;6Ys_30? z#CVmmm>^?#TF+&YKe#|K&&IOkIZXns>2vQ$?*I$5Uuz>~vU}PYYdvs!w{pKl?t1tA z7mKP|qJ>iZn%S=g`ypzG>zj7rKn+qKeT5iq90i#oJdHkEyetk6y~M&PZ{tvS=+kVcwiVNu0!OD5=>07(Og zh#87?>GfizR^sV6!d4kw+=m_USB&&~xA)7`x^W-{3G{Wtodn7qsx^hU)@~>Yr@j&h zd0|^reZJoBjiCcRsb)jTv~{8S{&Z^~%~Bse|H?(aeV_ z75u9J$F!DGpOO<)_O)z&Lkf%H(Khb(Zaz+Nadmy>udt3>d2Xi5-_VXJ+mos+<(q%N z*C!v(vTfUjuVa93sIDw^Wgcl?jZ_g4&|m`l@~kBuQ%byxCoKIGDFfGONtp^N8}azQ zzPTg9<9EGP=Bgg^NfdA$A@D~pj8`ga|J{B>XFswmRK)V))bUHl=Nmi1Kwth0Epf@= z(!hpw69>5ZydR;r@6uC0x0^^3>liH)Ql6Xasx!0`YLKQVOF4aw@FV=AwH(GKd}BL} z?tf(Qf1fOr1JDS36niBhP=^G1oh118cjRMuHWG*@5UAf-+$Vv49LgNr6wh~LzcqIm zF_NJ3;Dn2RXOr+v3<+e0_?YoG$nXC%OFRU)xFgEky(gM5jF+PNXhdB^A2gkm=vXd~1qu;u|BSMy!xB#0=FU< z+bW4W(QxjG!XsV7QfhuJ9fy>$G8?Lou$A8d!Qcns%+kFzkF+ee%epz@h`4~CF}R}*ax!AM*{JythoZnz(fLRoGvyhK1X=k z`2CYI9pK6%(_sq~Ec8eV_@+`Ac>R1K31oQ=wjd2aw8zVGPIb*B&AQ6BwGJ~DVBc06 zt1%MD|5F=$WhlsYDT&u{G!-siliM%GKKqEG_DO*+d6I=v?b3vLqVFarq` zYl#5Bj~2G5l4D$D3w9O0TVZZ&iy@- zXX?BA#a|@*>j&AS*bwXSqA|{wb^ZA6fia;3EF_$%0!M+e1{RGb1+glR#g( zn4y1rM}{KO0yR~zCQwtAgKd&oO2R(aHb~R9+TG?73gaj5yo%f&6RU_W;SCvt=#@k$GpTCBlnF-U(~|a(WTctJGG*>``pq(wRUn$#^YMO zWOGkF=RkpEe+TRx9F+K;1fumsgio#Y6FCT(9*tz9pYxq84h;pKTxei5x6}hr>tdUxLS7Ghp`gSYhz;Dvlq25*vU-gpxoE!u}jtc%6RdEgi~HGlgUv zE|>q2Ej`9hW9|h{KB$jnz3F&u$$RdXB#;9HD@XMWhVouJ+5%i2X6tO+Y{^cPfNp42 zeenR(FDNq%rm>D}qhL?t$qv_#_*%vGpWJW(!R43~k_wm}MW|SEnK0HQy=gdZz z0GoYsSh(ZxwxgEsg5QIt+PWmoMi1uOqc2=W4IXG)lnrOt zj0G8Sx50dkc90NL<0e-uA0$lCA-4xDQYX1KkE!qTH!hyUj*gn(4Q;YS<`5U^1{to` zj+Z@9>tivj{*Js@REqh4oy{k90y&xiB8>ZF56U=?*l9t5MP*=N#%spmX5Z4Wi%qTa zjtgJlr7d1R`b{;N9IFdI<6-;4CY`OE>y3)IAOW+ z`TK+_b0nLhPgs^@t9wh}UA!FjbR-ExF^}bNoe?p#KyL>tpAH<9SFW`KhX!(+VP!-t z+ULCZ?LBgf_^*~!M_Z>VbsXD#6?^DOJT66wLrH*mj8S%I2yHguNCJ`3k7Y(U%i4Cr@Pm4a!-Fv4Sv~YVxSdA@tYZ zM|RVXe7<<3-5)k1@E#85UegATg-?5!{GT9$2EI?o5Czph5C(3M#_-&pHiqOUV$F5Ti+hT{BIJvHHl zh{^HEcM=d>$Zjlraa|U3F3;>_rsVmU!k1al9c~HTnVM086e|o%pRX|tU3Gkl)9a7|1G+&owlPMr@VJ2E4Z$#4<;C8!KcCUv-)_v{F>U;?B#MG9;2KxM^UDHRqXeAtv#S)N8_>H$n zZXu0j<24?j9aAvD^s!=+J8qK3^BnCH2b}I}w+B>TG?u9b3_Yf(O&3g~Yr@R+vo7w~ z4iwqKQyY1!AvveK(&)5=#E1+>Em4R{pPHPM#=t3*5JCXF)d5R@J6B~GtOXV2=N-+n z8D!Om$8&1Uoy0Dz^FG~0_xOSa3L2>wD&>vhgpJiDq*3GT-Y$gk1POU}B2tXA#<0Bc z$QbnTb)VUR4DrZ}<)w!g?b>TBi{+ip8pxYU|X*i6|Y|OsrZiyo$jbyyv z&g310rgkFLw*jCsZMUGzUfJtx8_Xt6rAb7m5`Dh9!Q(Q$gs==cy^2E4(>vzh5)7Vy zl0lR$o;YzjfFkOB06^z7vN+)W=x1Xu4{HrX4j3#(p6lt5$#R$S-`e4950FEa+b*TD zK=CFSlcOE^{@csLVbV06hr~)d)vUzc(qB-0Z&9@+Mb@ux;+ybP#M4~ETw64^B*Opg zqA|c_heV$e7>@Gm-&a(7yuQG~>HGl4p_8_;dRL#UycC;%1V6cxBx^7gDs>C$I61GT z%^ItBTs=aZQll(o}X^^27WT(zjR$2_6cLcs%Q z-V!;XjyjC3Guolj>Q#QVFAl8`{T`k= zaD!gutH{1*8`J=6er7&uFvk_pLua;SBb`eh_#gDZ6gnjr&bgn)v+tQ5L7>TKSES%e z6MxzQcv5nzN8y@a)`fX>(3E~K)+jyo*xmerJbGfbKn1nRz4s9-6o1qPn-Tt2o}h>?->qx4Kc|hQ;LA zr?OP6;fvO^^E9kEAfui~!=EcZR==&BUQ(>cKdK&D=9f(4>1AB+1}$-vWoqV;*N2)l<)iTnXA=T+(Y|4IKFE zYHp2Bc1eajT^BYp`5H$U>O8LU1EM$-915Toefug!&5K}_GD41CRWljYZUd4q)pqoh7hGu*fi%rYJ- ziQ5^h1%KHUt~u#o<4G*NsOqO>G#i#MQaGtkfMj?QWae-;difsCC@2gieRFBGlIc%F zHg+$xAxF!FTBUowt~T{9`pT6L5`}i?p&dT((1x>e;maEgucpwr4<~q2Q$h>5u~x3s zK>lMnQ%pPsROm^d^?)1mBLP6fdCbx(4%V}Xz@7)=tF|10{Z2>L@sdC)u&_p~J6@Xv z`n(Bi;()3XACo{Vu(f$?0#Km%QHK+2B!c`-LFI@A+XIYKXY$9?;6Y=U;#F4$hwSSM(XNFpz2y+IxTEk83YYH3IxMb-RqV@aAxR} zkX74AxuIfM&`q(0t_J~G1TV0D4sqHLe;@ybtMY&EPc)pyy05kAU~_O*Slc$kYBXJe zI8@U7l?=Ri>cS0@=vWs8qUY;IMC` zVPfhX%-u98QzjGRSaMU_>b|IQKTN5Q=IV1Fe=XFL%C3c|okDo5ys6Ny!$5k;mKe3^ zV$JiKcyoQ5lRI|?itfOp5Ik~_;#1o>PJt{HXo}p8jyyFh4)u}pqv5v$(`EGnv-ha} z1Tz4$1`959@u!7SOP*+Z!I|srfJ^E9Wt4VkRi`?kHU5Mn ztoL+Db+CSIK@?_IZnfF9=N=B=lLqpw4J463ky5C2$IzU@xSkY@*$yh9Yl<5SsrSio-use50eyiu{TU)6uF9~MrMvX-4UvZy$ z?vrtV3_I4;pVDLaqXTj~3Sdrz_!BiI(n67`-tn~?tR#@ws$FDN+OhPoj)A%}PHz@3 zFPeC&zOb5oW&n&2%KL{*E7!SCrW9h0#+n&3dPK9>BAmHx2Si04?S@RK>?VQw(o45o z>CBdl-Zn`kJwD6r@d2ht3vKJ{t%=8LrX!n-w&f*mmL)xWtavAe=?K}~@w5xN5QrPF*lV`h4R_A?!1NcO( zxt|!B=LBc=`Wrgal_g~?#E#q|WCHTTCInQ$>3B=e0km)j&L8BN>7^bJAUz2*hVl($ zO3>w?LMKLY4 zDg7Wf2j6#yT&v&C{GC>1qv)^2-&6q|wR|i!mcsGDS2b&T!rRG)Rx)L*lqWrSzVY;i?!Nxa z#{c44vO^7Jf{&kQ(P;iiv&+!DepQ@*-;+QqJHcbio2R^5_D1+D@jR@JV_=sJPl!Sw zp>fn$Sr@hx4_GAEn48-E0oW#4o~rHAe(padAL=+|S{}@nIlyolmwj~Sn^mi_ZR9}Z zjLyHbIZ;JvSN9CQaLrt5-c~lTdCe##n`39e;YMayM{Q@9?$&eCS28tiJTbveI}^>> z+Ay(gpm>4oYULPuD6b_1-aid+`L79F1qs;l>c;910GSZ zUpAS&&~UuGubkJDxh6x)eZ4F1Ru{KYp!x%Zt{m#p;e)*B9v{2zJaR@Ua#TA%AiA)K zF9lX6unrKisT;i;?ItAfRUhVgADNo&U%U8174q+CE*Oh$3Ep!NNskMw|DJtPt{ zn*V*d9sMZCDXTmd$Opu_zyf${o}TznaM-h2s?bpN5eu8A&``zm${YHwQq^2LA@r}M z$a4N@(E`6IWMt8c7ZQ1dZ&*X4z6)*ezHp)D9N74>#YQD9ZHqO&<$By9ru)acm#dOk9s!X~CZ#@lW`m1FPGO~Q?DU@QY6>s)BlohkW!r?_+QAlI}dXsL( zsRSp3Qh%W$*$}DEb{;o@ix_=198h&Tdx?044F6O6ykv`@HP?2dm*=6aLdQ_>H&iz- zYk$>zWp`-L^klh^?5$xA&4EoQ3rlbgbf{Ak_t30MR2_MoI}{jI{DTz^{FcD;=G|JX zxn43st5I2bKXRVr3MdO*s-m=gNWmQ@^#5f0vQI1Jk8IWxCEay z8>_nRJcg@UvA&8Vv{?9U6>9Cz8vkiK9m88f$(+Vl!^mE)ikWgesg#Q_(?zCE$qRs= z`3?Z=h+EDAhP~ge8?mP#&BO&($N(kbj<^)a|MwPr$68dD`jb2 z?mPnAE9W<+3LTAHgFIW&l!?MC;cQXsi(^N8LyFFZa5tX@M>_nz9ScJo4eaxCe-5DI z&2g0k>Z2AK%>U=K_zHG7gFt^5jt|R+4YynF>R5d#|HT_xc)qmlH|o$D34|4dwDcmTX?kESQ47CY4y(PMyOQVc8?_O*A0o1kPdOh0NlY=v=zt+0UG=-Xc}OqC|! zflmH2*s|I%3AEXGxwtt0JNHNj5dv#!#PB+iK)aT&jfV$^pN;Gy63}h9F8bYV5~#8h z$eZn5cZ3=b!Ipihfkio)|3*}J-T1u-PXh>U4&V#u7kZWDRdxm11x1rUbsfa|A%GQN zsAO8QvL*Dg&f$)I27%E}C^1XHR|D?Clsc|~_iih91lr#FQNQh?9DZh*Cwz<4nm1Xq zi1vocOiY+ukwUtRYkbP8LVZ>=%Cf~O+;U}ts%s6TunG(aPgh$TJ`egLPQFoi{?A#4 zj@p5uyXdT-boi#`a*^!|)B=xEm9C!~FM5h=Fb`;b?@NXMvxCp_Ym4|<>wj?(|I~jE zqz3E{ad_c`_Y>!d!55OcGN|T=Fte!f*cL$N_oc%B$zr0dt=F0pW4~!C2;{%1?Q;&g zlQFzwPvX*Z!)o)bqO~ z?3;)t9T=Zb0mRPSS`X)+DPuW>F zT+KwdPJ~kZGynxMf(4h`mlw)wzJB)UYHjty;!|brpE+c?Cv61w`08teEh*h8yfZd}lQIOq| zEbIM6d94Zlg{)fZGP&;0{-K)6Q?-ort$!b0I$%`lH}d_yocOLL2`b>T{}5L8 zmjCMN3y=K&3@l4mJndhjby7l~N!~Jr$ZNNKdRKS8W9f4=1W&m~N8XPwov-Nrr?LP_ z1Nf2sN1j-K!4syc(prE;Ci*JX0>PIFaeUeODeTY_&W2k*jY=Fe0A_C#(AAOzjAaR2 zTcmeTc#p-sqk5_#xzcrF7WLP>2H*{zZrPWw9(Abvas>l*b>>Nxt0MH3P#$eNMIQF0 z09fU?$pqF+CqWk?28rntD8FW9txBd6H6^-NIQ++G=C8`+pH*KVU$q!&rql44yR^yA zff?P>wi5UJf`#0-Q-Dvw>dthpejL45^T5Z~KR#ONjTHxv;1OD{9k;)>LxynASEUcT zw|wnl(oCOEpM|n&coM zhiNcZfHT;aQ~%R$da4K0pS8$%@t^8)b5L9m zcAd4RHk9u3GN!BOCO-XIQ`x@jzYLX z<57o_h0n7K>!w#Rc@g(Q(gelFW`CaB3+OInRz6qy?!uf?xaPbjbca1Q$MJjfpIT_^x5wWV!F8d6k zsW!&v5fyxA&RVI4{xyUW_6Y%0REJ-IcR^XhQdPxUN?axaH!a%HIibhEfvuDFm}9V- z<#D?e_J77rBaGLmNBCW$HUG-&*Lk_PoagOenxpWK!2Cdy=`~Z)l(QGQ<})V7yUn85 zHNQtEz*kBcex4|@Tav-JJIZUDE03ht+Lf`j?)ZnQaFIZ@65M;=VbXVZz>|~Tfq#AS zBPu5pN?1C{2X`fG@N$7`|5B|z+$>y1l`XKg;6Le1biC_!6M~$15y*IgJ7~`_m%S`A zm)E$H+@oGw{i^bmRP1M7G42Ul-nF0A;-FzLU~nh1C3jK&TA$8reb5+$5se$Vpv^Uf zn^RMSdHu6y4A!Sqr0{%gOr)u;_PW^3=kQ$u{_!$$Z4bc@;9mcPn_cyhO23C*hC^dK%PI4jQ*2ge+N!iJLepY3bDD%E-U6y z?xChWv>9v9tAMd50Kqd`(ii%!&`$a)O?1vyO=qTRvO$SU;=sN89XL%aDA$Y<*M>jH zgP(b5TfVa&JsiRA9P~Ci6Q@)sUf`BJ$Cpg~Mxr@3N)rHRE}J*8dLI3~BV$>E%S2DB)n351j;>usE{5BANYpMnDbo4|UJ=p}* zskx$b(tbPUgJSdtAEj668Uj%>Crr{j;@E7$WDegvRh;GVLa)!Ub12C7g`-+In_Rny zRDY?CW0KSL{*hFGH-2mdYe@nrDtZICBvmsvKKZ($EWm}E!>TytXw-OCMzW4i+^P-n z_bcYv=~5_b4yq&fPO@}cG&>ukDKNEkkX;3~BG!wmfYlF;uMh2_%wWTxNub0J{``dJ zid1XbZB)%khvVxB0aT^h}1-@n3=4-fRT>0yvPue-4;k^0#ds!of7vjUpghYFAUnH3*2CG{IZFNoVKv-aP+os2p@Vya5<0F4{GeuN|HBKgmc8~A0J z{L@q)w*qQ8N3W=k3n zwY+=mT6V?8ftjJBZS{tppo`phT7V?-$CRht_}EBg*u7cr*0lK8Od3Qz1N7Q6uFiLX zfm=YVAb-sen%#}<3N1tlJmNvln{ct(ZhdRbARhi})O2rN6$nfwyZeE_#MFH!$=@rQ zFMnQPhWgN{Z6GiKe4rA)2-U|?=Ln{NbPgenv?yQ@_Xba5VLxoc6#xDfwADjt zzGV4zS1aJjHQ)g-!o1x$znwAvcRdN;k;=e43cGjM=7f_0nk!l)tLP;r$!2OayvrB; zng4b?Pwmxfl#1g|YE!C^7ul8nmik^pvnIO13hX65N^-{GbiiE^XDU*~w*X2dFoyz) zHLDp!%|e#PIOS2)J6&BHV{ytK?)nq&rsUsRd_MZ|-n~s+D13Jy6+L|&iM@GswtDMO z84wiX!c+7;tf2*)+XKgBKW<08EiO8{9tz%mpFTN%-I;CDOi4+aEaiC?vP@(lQ87)O zu;z3*4Aj%-;;C6^D?8*HP}f8-P=Rg;q4C%5Uas3E*Xb24_Y1YeX|?H=T~fZ{?7w{& zUS%K4omhBYkUV)!ExFL%!n1Iq=%onP%Hj_lx}`cqeX8c=WpP1dAkD;RneuQU?A|ht zGLe92KZ$Gg@;#}pA3IL{&v@Y z9A{WwTWB$Po!IrRzvv76)%cWBwc#?WK+l|z7OH= z=fU{yBh{Af+!_f*BVWHoDvoKtwwuPoj6PQpxNk;5%w*k*Nw&oWSF+6OE0S4=3D9k@ zomm^yhLR?oymS50sdoBtB_VCi>OubUms2JSm<{?Z&BMsf;x@E+=#h;dbn3+mm9nEn zp~6%QBa>yrR>L<`>?Q<|@oz2+g;0HRE>8XSZkvwLYC~#5 zX4N#oK*;+}wu(bHUoc_z`L2I33%6x5+;;$|hZFagV5HWnX}G~rhdbF2hgO>;!pw7D z{9{&bNHW3^I8)MdKPiE=`=w#DKX^E_on_W%%sTQDuMWcxsqv+1UNU01c+a)%v`3f6 zjf81VnR%_gtgs(*YW1~=x~Qqzm^pP_*tiX{QVSF_Gk@KjjY~*LX^T8^tsYq*v=pkC z4k4@wR#p#ksCmctKy)tIS)YTVj{4d{1Q~$Z2KyIaQJcvr22LiCTd4@w3nb9>=N5|X zco@^k=f%no`#rP^EoO{~eee1UkJCRNvsC(EwZeY3ZO#d}tsVU?TX~*5m0s-DtgmKn zx=xzV=X|=OzqM1fT|ltY87g;WagYc|xCV#xt(wkv;hYk)3@P4Ob*#UoUv=N^nb<~V z9>~^9BR8lWN*{PUdeb3x)Gg7r5t_->csl4=|J?j&N+B@Oy`F<{j!N-*>OVKPgK9_I zey0o*H@&V$8CdB4=86-|yVCv@e(;ikUt5qNeEJSk{QSTXQBhCT-sqXq{wY1VsiTwf z9~-J-+s^`F%kQ@8m!e;0=W}m!bAZz8azfOX%53ZPN8H;VGWORpd;LqNR+|Wt#=ZAe zM7D&S<W<4sU84*dN!PC7i)0O=VQ`r;Ai6WDzW?@>UtCwC}g>cz}-7Dk@IrbA)?$$y4 zI-qK}zc6L5)(o!j!B9e+^1YxPy7G;2R^{pRYk{_9xoNcZ`wQ>@pH>d|;QIZovgcjD zXYtXm$$m%8B?#8FH8tK<^?r97oSS2UKv#F(->3QX-B=UoJnc_pPNR`_N-^NLsS}xs za1$qiOk*b%&%c5lrG8bZoTl&Q{zfzc&XStC`=}LbfgWPnYEIP`AYxfw128)_z8IOI z&|#O8ohDhrT;~;3FDdW>AQd*yvFmi_Nf)&4_CP9M!ro|zaoGfdLnAbd5tsJRp=&<~ zDCme(FdM>aW_EZwWQ~q2c)Pkc2rcl(>)lQ|EpzfZ{Z<;{C6aR~>tEO%%6Ayk-SgG~ zF@i(S2h`(H4GosD5ja=8{Xo%HZu#kocy?>dD_fJ|$#Yc2pU&Lb+^NPCY6+6d{vXB2 zc|1+lViH%VC*3ByZ|AVFtUk49s;aBJ1K((KYv%2S@rUjaxEm|xJ!urYU0f8<`KX4Z1gwoPw zb~{WU-e<}crr=uqNAR(S5L-g^jv^W&S;2`)Ic)Jx+hHn8>(n;`lwAVyMkj`fUC+a8 zzb*{ht9QG&rjF-fjxx;y1DC2GccNI|md@n>#<^BHWzDj&?3(zCE2(_eLQp4TMd6b$D*-9(t$I$66V2CP;mF4mr7Jca7ysmSYOaAF^R~t8FQm?Tw1voj_el zR3o}zf*UfdC{^s_YL{g``q{_*fo%*?!}^d@C|~P`qsPdRtL{;Q{XiG|03Nd-*?zKQ zMw!lE4<2H&vdbC21{=!Uh9Ak{{`gTqq$l?`yBoi?7@on~Vwcm9=GJhc&NC>$P2hzl zV0VXEx8*bJyVpk+7IJBqgTy#ryI%V|dkDPxWKRx(vF>kloWvVS`%!b;i!n#r5-s5~ zR*Wq3U)VeQJtpn~5tPhmREBf*NQ<4FQb}Psa$G7>=>3R7IvnJqyfc9^=d_tPfUpFT z`ai93c8kOEnKEyaIh2%Vtcc|Tb`$NjIg>2Muq zHg{;xD>uq=ZRM$gYzW1s#E}sU9q`26K*iO{#+1t8j&(PLGr+oJkDDxK&1CIsx|Z4> z(IJg%IlZ+*A*_s$RUQP+bZ`a@Ni>;q3)THzB%-_v+ZjYYF`mKz{4Bh ze(qWONBSAH{Vp~@n|BnksUci+=Y`AvV(-19n%vfXVGtD?q6kP)qJo0bq)Uwr5D^em zdQlK*LPUCvjb0)uAT>%yno6%xX-e-%FOhB_p@aZQ_-0nqrHkynJ?GqezdP<942F!? zWWLXA&z#S%%y5?%tIOgz7y1;IK(4vGUnXJlBTjE>F6{u!XYSw)6GN3TCivWa#qy@K zUa_(QcXw+#*~6-}mIm#jZl@7z!(AW&Ms6Izm;@v1WhN9pJT%Voo~Li`%Ir?aoz`Pe zF-iht9OIIun$A-Cy8Tez1XHE={6{Z^b2|_@jkV>VW(_8uSkjdKm7XcB9xHrHZ+_j- z$D?=rnO_Hes?}_Vqm3lYHwG+;(`=k_{Er+0kT8c=lEHI=@$u33nm%gbdRZ46;xBWP z2@)|DDb~w_JcS)|wt0OPxw*v!p=`ojH;#wDxUS0f)J`rz22yUsLVdfQ6Mn=Z&*gkfj6M1;dnks*g9hgDVEzw`Gl)$Ws=)cT1Nsm`7Be?tyEIRX zwdjMR^`Bj)m&%$dX(!CqUe3#JKbn`9AKkIiox>b5UM65*8Q@p{1uNxHS|HulHJk6@ zc4bYnR=xDBf`fx6eoW)FE)x1Yh1x3G@nRQg!{wjZaKoh^B=YR|jh26i>wh0RF zo+&lpyAeA(5XHhQCBH%+O+zKQ{l(+v@!tHVbcW1hHO(4D&(~?PROYx?)S*6))D4iA zDa(6vQE}&fCFQ_o4X@K^Phr=Y9jl`nd|(mu+|g(&rc=_H<)kN=_j%4%VAeLG2X-*w z@kgvEQmSsV{odw}?tzmTLR6seEt{h^Om3|z#fYh9T zU;CGnX{*G3kJb&3ND`Ed;TJD1Ch}~>w6T#79BpNqhSV#HUgrh&no0EarD#bQrtg)O z<}PAM+%Uoa7gKJdsVZ(IC6m;Z%Q+*jFnEKUeyGeXoIpM%a@g35Hfsk+u{Sy)#iR_( zXiY2HeHH&GC|&h>sO&97lWgSy`@u&!4g$ zT3V4J`&uS`|Bn}pu)paVQF8#(_~FTX*&qh9GlBd3=U~0;WW%v7dGm~^0KiB9RuO+* zdV=5V-h0<9@K;fG3vzP>1DK)iIzU6z83EQW1^ilXsR9_E{iPEWN!6Z;I;`{elFRP^ zrC)6dAPG{`lXSve&jgSPD-Q1-!aj60M3aYzipCiig{CKN$!=e=4vd>~{a`{kz|HTO z13L*$avS=-aqj@RGyH>ooOTLFPEA6rijXuc1{p0NVwmdVwsQg0}hj->Ps-eO2m&7iOkC(T(_0Lb#k zXMg^FM=w(&6of!aPP`7C6oM0u!{^a+A=F0ZLX0*kihMwSXewZTC1MkEQL*LtI!&N^ z%ZO)`0oe_=Z~{;wb>Jfr73}aZ1WWLlDr`~U>xK_sZjK+o|DZ)`8*$@o*;W`YPi*QAKDU2=?G zfzA!Rwwy1g8T!*(5=FZ!g;#kMHUskMmeh3`&cqoe z3}Kz-9pe%wEnu%YfWND*(?mjEhDW*pPY5*uNjGE<@PsN}c(d#|fF~P!;a7@?HpB2A zKl*Am^cLK2`>3j_Js8tSi-=yU1As^^Z&k^Eem_#UHHH|d@J46|L8JkQ-q(=D{ZJw^ zYQ=t4Owrp7FkA&aMFDBcVOWE>M<0$Wf&SRn{|5~U1p-zX7>Mj_gXVcQV;J9@!{><2 zxs~qs_FBG`&p%&_*7>?T%&F0%QC*U0Op7&&NZaQoZr&C@VQ>8uht%x>-`U; zH|tD0SzXMCu}XYDs?M1hp_!;3Vxf7}LI1r{|0~9%36F&p{Q)~rtGn&_7%Wz_R69hA zIvV=>t!o=FC_3z}j7LO~UgG(@JIB4|q)g|UE53FmgF~TKahK=*B%?uwLKp4Jji>qw znM9A<$==mq1{|S3MebbSw}F{YTcri4FX8!-vJjWXA=uc&5G)2!lg}?Sr&SzO6Tun? zZRLGi9DSuQ$sa!=jj2M^dU{;m`uF|l*V@98{tXcF>umwmE@n;RYink2Odh!_fdXLQ z#0NUW8oOhimUEEBEu%-PT|yD8_=#s`UKr=_=4%Oo$762*X!8FH-)~(B!uKCRlsRS# zkA=Uoe0?NQDA~QMjdl7J#lc`mgm&>5;(L@;)^=NT9NOZ%vrl1--d)YtI@~S8VT-(7 z_aJ|cKMKf!3jN}WX~1>EBd<28-cXX}L zpxobqw~WWJeiXJu6?AQHneaMIGWiIF^zaXVOiL|nBwM@z&z9Aa%fXJ`ei?{rO4PJD z$#ZQ1c<(`pTqf^f)&7n)xHs3^Y#he=L^Wrp=?`!H> zyM)}XLKbZ)Yy09YLQ9Bl1Jyz8AoUK%=QsTtJk|;x978W(IuxNcwA^s}6nW`;++qbw zH)j>c&D;_Jz+lR_eSG>z(UU{)J@?2G$v|GbGodSQ>X_F)3O;4EKo~~CGRy+2pS^T{F zv+&2rs&r_GLAJx}ra$1PJM`mIy7reio;kO(@8QmU#6yZaQIJ4d%70Fq-frP`gR{0g zJ5}T`kcz*l$UQNC7Rpih*Rr?eCw=MCy5XOMGc_nQoz_3q7!HmxYhHDGPnktmQ#*Qv z_ZaA5I4ugJO$Xj9`O^|s{N^+AwQSFDciUnvxjnHw8g8=olH;T@x(q%fPHXokXcqit zlp?)Yk_0TAn^4X{(ML8Y`k{uaj#^z>2fXuFMNHx#c?vJ0bG{hOiN#nSVj8la z!Tc~Cw$VGN>oYunh`KUXG(oD|PL@H1?@0*cPb0Qaj6oVpx;cibyJYODkGojd-!+j- z4;a1-g@}{S=4B{MXa_==dpQA2YDZKYj6Fu8kcC-QPlRh61mNvc7(9Qa^=?QLL0Jh`p>io?sKx*v= zV4vr~Y^i`U?qqBApncPop8Gs9 zw8Ak0pZNk^OmYXuZJ^?+SFsyR$-%vC`Min&OFK{5=`nvf?6~VRapoex5pM`~{%$he z8n6k00qs*M(`Ti*Ma9DLSF5mm5JWWmFzAJivCiKCzkXZeH>4X;fK(egZ+!1@K!tln z6@`A8zPUo|-?t6YcBw`Ld+#MM3Mw8&?RfoK z*t5r1A8g~{h;-pQD_f_DAa6Me>2T+(yW1=B425MYT;*EAM&j29`)~_2uU&24p*Sj- z2<=ra7v?LIEfs{@mpFUEm~-D&?rQjQ;(-d?JyDyknGq8LUnFtwGTx~ZWya{QkMQx{ zN0cF$i~>BAWuY+-IGF^N9e$zj3cuPa!O3Sl+Ur=HAHuWaty1Z;*u9fyUoU9%YLItS z<6=f4zvf^$tGGDINpW&?QlS+GdI-%1IExx2^Ff8WdHCB83sYSj=Fc8pE-G!vIBq#A zvW1^*fIE$`_iWqbDB62)K1Q9Su-ypCFFAJ8LwZoMQ#Gc0;ES3;)=FpAgyWM^l0*WE zQ9%KmP*l7n;P2I-!*S5f;3#6L!0X>_xYx2Y-LlTYvz?Kko2mAn;a0-b_>?S&5RZi=X7|b3AY0d}_jy zrc06sUR}bM$iKP}sj=%uA$!utN}t1ecb9HGtsr(4I(^!{;ASP^h%eKgfBjS~+o;%% zh?IK?lQ&B)?EIST{Zi8;Mgd5tKInHHs$Qpg&3n(AhbV^CxKF%-Nl z7si<5X%B6(8J#1Xf*NDze1%Zv;7)^;DBe|ZlHlAo`qE}@>l7DPP}TmDdARBHX+@Uc zB%UuKjQI%K?HChzPifr52a^C4_A0c;p3F|Ji#i4Wu-rdw#)N zR2vXmy@b{bN%09W;`Ok7vTqX(2{hEwo}O08B*AKAlzq+5cjOz%YPczVF^GBQU$jK) z-$?OoUM&m~FW=h5K9?`3P{)Z_($csSmRV*o#&gegF#%m2ZIlum9b6KDN-tc7Mq*=T8%y)F0uL6S2uqp1Yu&|QYvVj;dpGjpjfL|}8Jpf=MkOE5duaPU z9c>gg&X4vusH#!!J4Cwu*pMe^Kl_S5&u)22G6@uL%#vKAb=0J;wdd3O+c^~!t6XH% zVvYp@j+~RdO-Z*g5?-Ry7>DPFOOzj*<<#wV{Y|FKgm`RK3g3)(!B>DoAgX>%xc6z( zz*Meua{*sR87$GV_i=7%=VHA z&0^N^u_HqAr%lDK-j-7tncF$}zyxEn(}q8$G*ZQ)PKci^T z@0zQc*ZNd5QmY-^{-vfqwdl;wi4=&e#)Kj&NW8S1eTfYOUrj-7u0-|G$rPEfg*Bg2d*fjd??i8s%z7o3$e(&+De2Uq#!3`0>@azxOwvAG}m@)zw=vb8BErz z{ydk}mB)u&XafVd`{~tY;uA_?1iPUJkVQQO7Wjz4jdhw@8#eXhdKEbf3KMA@bjb5-?YQB#kZV{{H0>&$mP~ zg5k}#MPg?Pt0PYBHVxbTRkhuw(3mh6h_cx|xd89{+D*Ry3O>vP0J5Lca*l(mI1B-# zfr?K@bjU0P5TY~U$HrRXU(Y@y88^ZwIa0~A!t?U_f%C|p9{Pt50$&LI{`)$A@%uO{ zby!_w979aw4Uf!@v6-=lTRgr9XQxBf6X%3@ES8Z4)pqOVl%qo4+_q zGM_4r;rd+5;y~ss9QGrzYWSECv_Hdfz4Do|FV<;va!ppZrh_|ARU$$=p)B>sr=TOH z-rL}~XGY}RXVjh?S@g;YS^Y%(}CUynnK*gvToZrnP=syi$lcwY96uv+$j z2fBjq$N3cazj!|BSMcJLiE;EbIcppOs3l12%%t#-^Vw$n0-2lOQHwgDAVTmv*PUl; zbGjHOpYC_%b$Q@;)8**hPeKZ=n)=yf9DhHYeo*m-vAwg(5{Vt}xj)?OPN&E<5pnY^ zU$fj4li@b1YMNOIjWCIrm}`9yr0DA!1$%yaXpk>BvSv2#w${Jd>shN&R#@~vI{Ar` z%DG3koSi>?6;4U@-MkaSlwh7`2tsER)2q`aD~v zlMa;bj5W)lnFxfA9BYT?dDoI&n&Zp+Jaeee*Ino{1mE7?h5}amoE>i zX$%t5t>U@cmx|eM&t=Z`Y}Gv{`thpAaGM?>My&(JP+sxUT>f2?X{eJ0+sr%Z(R;!= zKgnujgDcb+HFE131n(2syL@|IZfQYms!+P|4w6PrEoEwOC#tZhL*PY`0v~Sckta?% z(g4c-UV1zgBRunFvVEQAPCEIN_ccb9gmi0f?)F6$7MO4*hP~#hnrMx#$IbSfrL}`6 zwvCmP=M{vSEWrfo0!aj(g{yAWB`1u*zN!i zOMF0FduE-Pp;NsDGp*e#HHo5b+tx^u;EV8t+m5GO=h#=D5!3!TFjwDnIfl3x1z=zp zq1oXFiaR&Y9P7A+us^hKKns{uzmoJm*6zev34^8SY1|Qeo*1WBBZJ-Tnn-4{m0DB& z3h!k#u^GtyxX|6uoYFrbPU{|+=mv(7dKv37htIS9^lX7DorV*sn`{Irp227l73-gUVkz>?4> zdnrM(`I4ml=>rf9qeK~yHHU^8H#+M>9|`wvsgZp7_NirUJIkuDNh~~%p$iGBJOSu8 zH|&|aE)%X?e!#0ow`->g^CmC%T~3W&K&$${qDT)jQR zTer~Wm3Dd9bcfC}Q15Uig2W}^6BZ=LJNg*OvdTZLQ|*`=0}xtr&Mnj;!xd1~id^B! zQgqY!@wq(s&!3g==Lng`+W`N;PAGOeJZ!RiV+OHKX!Dvaaa*KGK^ZIVlP?}DEMrjY0BSRI@~tN*R(>d` zT&D>IN2ecpoU7|#3p}GIheUT3+(sP*0;HP8pv!)E3i9(O{rH9M`BbRQ6F)bl6TB!$ zE5YFI4gs5)OYilS%vj6m(+z=4-SLQ4X=RUD^M}ElX3%ljGASZwExm(h!GX*0&Psa7j8B@|S z*Wj#HF79H}BG&qz(VlKYj*;7LTym`=W@~9Z?C{}bK@P)NT<_Y7R$gq)50Sy@ z_wn4RJlG4Y(E@4eCR}KCeYs4p_?xCTFmwTBOAENo6tb6-hp_9GMNVCdijKTBK09cqiu?zqw>adpC$#9cd*W17HK^PfG=p1#rP-IZ84UXGi9rUB zLLE!4Yzy+_vJ%)m$aY=^S2}o4W(N>P#NCR_(QlLwO->!Ggpms_2r(%J1rw{7=(+L+ z_JOO6fMmyZCnMR*uD%lnHqV~DSURjyO}WZ&hlMQKT4+f+M^{8SITttV8Q}gX(mh5m z=aNX?=f$~Zu_n7_atXLTj*%iUDid~#J+Tvgl;W@Xw6Rocp6vy_f$3HAtUj|nYK{Si zn>C)rE&!(11`&Ee@4B)41Cnxnyo`W{qSjJ?n3 z<`QVbh~Mlm@5m!8u86#yn%#4DWy-IqeFe~AS&*>NMxi++FC{@$ji(w2+1V!}yN-tz zdtxAQ92(%h#=XnxtCOGto)fC#i*5$_<)Xj2iM`^ zuHLbueDW8x?17RiEYU_LEH@9_eq`h~UH6^hIunZGlSY@*s5sc)syLn-&@S_^R~@UM z@b%0H)2_8##T5^2ABP3P)5kq zm5gMP!0eKjXcy*<|FM$uk0(;=)ZH+rxw!wP?nGVd$#We*D7IWDg_pUlnUZU}74{M= z+(tMd9K8g;^FWeLIAE+ONL4s>T6;O>mL%?zP}jV@k6|pDzr&06Z}-kG`7dzE2L>m< zIehE=wfA3k9f%z^3*9b_-Xh_QyxMM@o`PZ)U$jY7^Nh|Vvaht__Tev)D&TgIt%F2S zLLa)hCujut5$)r9SXGGJCE!$_v)5W%kIVLv5-EaB%dtO%ul|J`|E8-0q&EPm1Td&< zi^x9J_e1P(3t4DS<+Wn$+(QB4L7PNR{nk0V8`$X2jRFzf?&tA=ZI%2y^Gd_aG>4?U z-YKtgD#}{NI$90Rc=Xi5kMi1e+v*kc4oC=I(JoXqf;Jtmz^e@KAnM^e!PW2qxFuo- zxr$2)n_pX{;mdWDPz$Y@(FpF{it?~^vP!Zp)NHh}KVtHJm+XrH0f)GPk!~*CEq9%J zNUuO65hnZPEO7XBn$bBhF4s6b&*KnPar~D+s*XNKKn<_OnW?r(7#N&?pc=w~1g|UVPE|`5h-|~IjYo! z)4tII!(xb0QFF8I(ygVy_w$ekjoq5N1YOVkJ%B}FV1LPh^{zQ$2 z?>xDk>o_TPQP>Z@t4zZRZ7B{eWV%Y~r}bx(Z>i)zaD#o^)0ZLbEHV~j_u*t;v!h>Q5UD7ACpF=L`Y5=RR8piMvOYq%bN`hg!<-d2IHubp2PtN9GU0b| zH#+LdCoD19mLVC|?sC)9iI>W3A~t(rZO4P?aL*v`zSHjn4EGIHJ^&HllRrVqZ}HQ0 z7rkAPkvh_vEvoiBFT82r0R@nYlmh5K73=@)3jV*6DWT%A_=9rcFTMo-H_J=0f1&XK zR6G593sMuCvR{eU*A!n@9@;u+`5MZ8hRE=}ULTpG&9v2V>aZ_No@;jeZvUM&4W8gK zxYKir+KtqQ5^h)cr3iYb$DiDs9KsK7E@Zk$KYW1k!#Bare<5c>rBhqJ`3L@5313X{ zwVGrjI#feK-z}N9r7ZJ{_ZENRi2<#ayqY$F_+!_{vUy_;IX}^P%=k_cx}(%^qJPUD zm|7MI&dp_Iufx84?#hk23~xSb8YZ$L4fQd2r62#id%48%<%1yphhm!)up%m4^rORM z7U#btHg^16uKx$0OJp7@yEgGpn@ia`zJ#=e2b5C$E4AxSYzmf@E~)Hdssih)tE`d= zQIc_OI;->eVohWfYj=$AXTkAD+0sXQj{u`ZWYayN*6)-JCm?z953EeU!PnOGX=7;L zhy6Gnf1@|B>qLyf=Z&s*eX)6 z7COtOf$Jou`7mo5DUaW$)NHBglu+};{H7yI(e_$=o(I`!Rd7-b?|(b%$OCCt()6n> z47CUDN4I1S(OZU$Ah%8F+mzI;dc{gMOrJcEu!YC_>US=zR8%m4wXIWvL~T%}li)N{ zThr+06lpX5ckjqtnnX%|^j(OkieT_dz3=6AwfG>T9XoTe(m(jBJ^W15QAaN-ZHU~gZyeP zdL|mVzCCeDbk9yFA?m)Hk_r zQ)qJeS$(~J1Lj%*b5}M**7u$`xblSg;6j*59 z8{2c7d;U8KO{zj7nSEJ%v*nD;(qWe)B3xLjMe5m&x6)(h5kQpU-hXJ?95pH^3d-KP zE#{AI$@1f);W2FIY~{>mfUOXjd7b8aj9Q9o5C-1I!Prwa$BV(>v!ZIl@m9FfzJ_^F zg2j|D~@e!W86U`BhIS9`RZQpGCIlwUeBk$!+pN`ZgBfGdi2R-uipuspMr=3|ByecsKr zEbjkZTVPthUIUpYuZW(+#c!2pUz#CyUI&p-xFX@6*6#&wlvUMA$)E66W&K_QYY9C_ zPZ-H^)6#cr7%p2Jxye=*e;Sux{=Tt_Yj;KHl~QM2y$g);D><#v#BqR&$0G8d9nF;&zoWHN`s>lE zJM*EFkM#^I`3Aa_J?_T3j7m)uc+CK`Hn|62n38>)0gw5sj)JqoTujAKeRIW-6eQ%^ zAf+fF162yxmMPXqKc|7LXD6Fn>>2_CIpc{B8?AwtgaY`KZN_1w#BsQ#S0E(c4e8PN zxpZN!RQ!9mw!3XZh4|~zE98JJ;SHi812T|#SqKZFbnp8fAs@e``6kc@jYDoSKHE+Oam@s3p9vN1i8tz$Zhax)f63y(bEsx zq^8cWx8Ive)V#z8Alvv6K6PQqnlB*}_kkM!68KVX_R1yiDd%2OZXma;F+;5v6qaM& zxJ(Q=TLKrBKLkVH&Mx*vd0O3YP9-tz05DI1_m-fh4>=a41ben2fszBP(i6R1@Pwo`iNg{O4h~yjK{YjJFlI{ zzD{$*`zi2Izq3v=xhcfO;OWIqaoxQD!%=QywbSVu1(`he{NC!m(;SX}EZu)B-&)0; zj_KwlY+*3F5zRu_EV?*sA7YusZLSM=7JMi{yFR=W;i4E^>*i$ZBY$3p={)1;9!O2z zHyk%L*4vC$E)`_*UsC0psB;FXm>&G@dhVw4h#Ar6F}k-FLgDaP{LbN1WKPJ;bT)ef z*Gqe)-xUlb_wSt*K{a*H@Aze3f~cCm<)uI&>f*$;w&aLOQ^oR9AEg0QY3TTQb!h*! z36y?E=FpCrkWmIvy#ZFSgJYcq%u9O>RrWyrPs2Id{(ALY<#4UyH=T|bUI6ogrI$sr z4*u17v(lJ_1P6i(uvwsjG5>1>?)YVcgl}m!*!j{8KTntNT;5gd=HAkOu(-;!*X00y zrBxbmsFJ}+7;PmJkJyy(x?_%`Cjfe9r(5zzqA4AY;@k5_j%7mYsZVGIrzt;^{L~s7 z;XST7mHI4SD!AXo2`(K}->GLT_QLAK0shxVRkYcs!WF`oF)ZQwc2;^wf=N6D>Nf{Lfjr3#qI#M_7$7SKT z!(m7|1BvZG0Rmd0>%MgwdOVVNw_+8WT{L*8{b*dPnaH4#!3_;fmZ~s1!XtWI)`|M* z`8fjoy+R_%4e;-H0RPS!@b7phkVFY#z{%npq;RrGfRl9~k<1YZxO~hoz~v*igNl>R zq&c&fG2jF;5)PBghM|*8aDoJU{wZMn{qUqFs5+?v)?c6$VEw_Z0FH@&Q8Z8?M+k(6 zQgJlC16kY-C32uvR92eP$(=*HUzE2;K>WS8SdJGRm*&8v_r_lh-mYK)Idul<>GDsn zNM&pwGU`zyfQ$$2j-@AU0)7%(bI4l1(Yv&W6na+(pm#xf;W$u=NcRSuKPABVW5hrS zV}PWm+&MOc1%OG&OcNdXKJPlsL!EV+<4`%U@BaN+$d6G+o#RXVJ+_QX6SSb{309={ z8S6C9kbw91Lk|_9?xFG!mF19k%dJL)<@e5SQT|IkoyWQ`VbRhO9-p}H>?Cz5X%Lj&HTBlnlY1`?=# z!L2~6a20eDDI7EI#<}L)Ok3|FeFfx>Y&uIoIeaUVEW*jOrfnR zast{aG!Niy!dKZ9A-}!lHh9h{#?{S8GJ^>*iMa6dqtD=#Ka3MV{QSqS{;!QG0kV{L zy8<0f<7>|7j1#W=p`C=6@`uQ8PW^C`dk`&QQ=y zA8_hPsD)Aphq#f1TH}la{{o-TW1p-~6CR7NH5FOj%%7p$j2I0jJM0e;5M+iYXqY>Q zr@~bBVm>(Z1wcUX!IHZ%_2I8gj|OBZ);coH4;F(;-xthI*RiK^I*%$T3f9`GjDmgh zeJ|QI2HZ#_O}P0b|wXM~o1>=v4T0p4O8x zeOb}3CY|<6IY6* z&<1>w-#2+mzQPjel5SWHa(P9_B(Mi;Jl@xK-s4UzepDG+E<#vcPrMmFMyr7@L(}-AlSC)Z9+z2 z;9BcxLe;8Itdv0D-905+4|zK!|3&&?cX1DIFcyEEr%4Ww4{vCTE9X%z+}>=*<1I@u zwWY#a`PX`@pyu)Y1xIqX#Dz==d9LGvT^8kKmPZ^A{U-~f)zFC9L}9kI@wY_w7e;Wi zDJhda_td9&U4yUjMyRI<0ng(3vIh9Og*oM*JbQ;xBUU{pFa4f)Y*x}9Wjj-q^ZS@o zT%(GEFDNYI>_cDBr2vQS^4$~K;T#NRTJ4*HSqfC08D~8|#*)up$+zV|Yv%i&aiUJ9s+xmU%;{FE z-q5GJ9#e(R^YR|eAYHM&A<)-j8H;Fa@P4F!0@R(Pi%MGnQ`z!vryqHVa|26GZvMX?|=EW0^EGDRvgtXUZ$gAtqW z<(0wrM{zQG(r=-EPsfCNUc(*6&ri%BiQ70vFX;3-6Wa3+9%Dbez(vNq2q+@8Z#(+bg|xuA8(OZ<(oFDE zM|@qCh+U0%5t-$z)wRU^sBzr?nCS(P=QUs!P&f1whk+pnL^(5d6 z)0+h|2hf9?cU#gW059N;cLKu=b^%H(6gJukI5yq|Xn^w)HQw@ullE3~5`%D+yJ2B( zO}clxQHkDrac#2U-4&*SyJ7shlz8X_hhkgkRVX;5>qhX~q>MG#)p?iMHQq?r7xdbn zyQWg|3nLTjv#$B&T1_IseU%4`gV8y{`=Ih-TUZ@keF)# zee62TWiye5uMdK}I7Sb$V+^!Ajz;AM)n1VMvP#Q_KsbuixY}q3>9;3Evw1#JEq%TN zSFbD=mTONM*Mf7jWW(P@J36l(ZR?xX_vweZ+>O79`w+sMV8yESl3Nq>+##~zHOxkv z064mvPnB%x3{A3#H0TuOmaZ;q?enj32a&?{jjxU){%7_}*?%?jQ0k$xUc1>P1kW~o z8F*`;w)sx3ILhMLlRmULq?E~#F3a-2f4BitX|>%GD&f6eW?CJUyS5aoI&GhoD|$@6 z3B*P>P1kCP)n`pdhQ*E!T4Jiqh4- zj~wsaIP0KTk^jO*$xn^kO=XAZvWrSiL5!~5EbVj>)FK;5_YPr1PH)!auN=Q#%fY3p z*ex%g29}Vwi&SE%jNj6YZ|+4EENSq1&%JeXCaXhBk^Bv{L`H?h-*9jGTRQPcyg>$? z0%*3zGLgRLO)~R1v4x@WRnxev4fX(VV9F#JC>3?s-UvNtaQU?>dGR&RmSi-c?Biz- z?3Vo;5>>sT`q^1&ilS&K|Hp34OIyVt3Lp7su++?xnhC<{p(cI>dv&e#{d!XKLohdQ^1cf2h<4To7_b8q`%|)%*$6B9sg0sT$NcOVx51Zg=BF|UwXA~~{y_vofri8hWbHNnCfmXo8h%OE@S4buo?ut)3q(oo{=V^kwaB)8nl{h>>};=G;f| z4HH;eph>Z@&(;ZfD*0^}fHmBANwq3mb$;_oIY|Y^Tu`8Jt;pvvpCs>U&Pj$MvfSEl zTg3GL%JD{bmYNINM%#k~(mYi;c?R2OIIZvdGQHXA4!(O|?KaCJ4BH+HM{?u{v_hUk zN-?JoZefW@uym2Rl-)kV5s|`nU9ip|O~Zby%5ULVOv>bbZJDrmBiPll;0e|RWjk*6 zsk-wywXm3(Xqt|Mw}3V+gevgvBo)4LlU??DT30b$xCiJ)2zpR8Q{5BTom2adq_El=!tUdI?o=Y!E_-6+qc4AYHs-S&;P0RV4e ziKG@OD3u_+*e*oP^)IGt@xB|nl$E0<-O9P^shr3;mSRMmj$b3YYgEN85sPt5jflSf z2HOAy3D>-F+M&5&%0W37t!>jH-}SZYh#A>{yE-( z2*-QkhH%D64utoal*#CWO;7prR%?>eec5*2jhNoo?`D6P_z7HC@e8Z92xU5P%ashK zyRkP3j)(RuiM2lqFbN%!>K2Dn$zFl9rM5AZ4EA{MMnj2qaN{+V|7>G$@aJvnHfPX7=?orcDVtuH6d6#J5;* zj?Ew4>_Pte5e`0sWT6tI+2I(K6jSf9vmpK`dO7oYggk1wA=s6S`CdPY%u$$I(4J(l zf8nr{i%_j}RhFEuCgFmiH^uF2V_fQYf@VO{99H|$KuRk28!Ea!d{QoQ;|8KT@{8dvt zu$+7c{tzSb!Yr%+FjG*P*r*_T;YsI09Bwo1ZVzG+#RZcJYLl7fPuvyK0d{Nfcy9O* z#{+%!$H%-E6-Pu5c!*Z7tWnHSr$p37V`R~i#|rb}HwPi&Zo2BlbU)-k5Lo^;Z+_7d z@4L78RZF~YbB|I@YR1Nu$EEt@ea@R$5GP=csOmJiRPEU;JvToJY)(J8U(#pv$?Ib~ zwsu}pJM7GXGVi5pn|#rsryIY$%~r3*@)%%yr{ih8-Ad+k)LQHQM<4CK*6*K~17>y( zN!3z;x95UCM-R5SG6^X-g^k>T8Sk=&4qtouS8-*_FB{^0dmF&``ZYtmAJ&l%&Uoi| z)^^i&WJ5bTFF?>ldT7t-Jrk(<->r=|G)|QG_B!mpYMcmsn*T#t$ADuQuPk#Dk7UQV zcZ+VFuA|T`hD0AOZvC6ABY@0aDnfOMv)^D3ts|u%$7}ZVX|SylXJg?$G&bMku7SAX z-ZE&4DqYFPn`I@%b)gRo0*=yQchisPQ+G$}_=ay8a5!&kaHVxZ=NgAO+VC~^+(VeS zE+qH`d23#Vut~ceEZ>MpcQMgmUx3|-Zy+pBR1QA5Ym41{=iL9 zVc97FIfenpuNVd|dc68u29%x8)o*Ve&z?}-66~*->e~0X}D^8Hi zN}pWFcoiaw%huhy{Jt<+fU)K>oDRGT6+CRmH~1cJu#blDh4N+}=yflZ_&WgAf=u7QA1!I563 z{S&Kh+vVTQCG@7FFilleHpuk(Sa1#<9l=c*I9KVpP(yC=eHV@m_!-n2@!g#H)kfTt z_;Z+2euKKc{~$ZiflMjQ%t`I3yxo*YTX#rHc??8!I7D(n32Pi?4VDEc%<#~(dN=|zCg#~^#ONTm%66@4y+a1(xvsGEsjWG>g|tc%nU zjNhgOHF`yOWX`mXq)c`IGo8qwah?>tojqGYT&;YCqdI&yu!Z`QD@I`o{9;SKn;XB} z63R{X-$*ImJ*05qzOrUnOreL%(tJfbZ#cotLj(h$D5!1IIx^Ye0)^+81Tp<5Br&9Z z7ttg3@XPBuY z@EO($x(Y#<1wu$HSI4%YRH6MA4>cX5>*7|=T>j<)N+@G-dv z^GWXVw`5__}c` z^}#3HeUByznV<)8m>k3xSNkwpP)wZ^Llz@s*VS!H^wVj}hi+H4@UGA&&mS%68jckv zIRUMYkn85mk~?R}zVIwg>uqu%K#|4WsB?9dR)5oAkCfT8_J2wJC6io;PvA-0mYt<#MArVye4qkmJ2H`}y(azB{?hff_AW*DqM z<`CvI@^hb_X}J}saRS}MaJTS@^5**yR<@Yrz;A2dDjE@DKZk5}I)(9}>le9?(UTgaeAbD2!+jAU-8b>jmdE2sab!io_ zsKbFq{$v9T&HS7%600f9>Zy&zG^M}vZa^Mbis3iX(P|wlD(LWpFey9T@RASd*sPIV zGd5CG7|nY$N?3N$(SH*sY*FajGg>BPVrexVqJ699NmF2-GpZ2kJ4@c8h8D*z2Zz4gTHtwd zkV#U6aS#0V_A=z_ZDm6|pPoP(7RnI3%Y(4+g_f;4L`mf;lU4CK#5xT@3ah+MBQ6fF zyy>a9PLrq($ZG&P4&Mx4IA>J*b!AOO-_zr1l_V3vqFP}aPU_KYLVlMVbd)bEVzs`} zUv<}gj79vQZqixjPrI5*pD6N(iXtum*eHPC|69Gfx+#!m1zux9iKpxSD&u`^OVFOD z=y;?DT3%ZXa-lFr6YEit?ieBE(zsbk(5dx+K~Mih{ac~(k7fKs#Whi9{;6|qU3t-S zm5jZS_<+u`~$7tWpqot&G(PSsdD8#KzIF{Q@P?^!aW5ro&IT>7lS|l^7wT}V4T*BU3u|G35T;? z7i#ynpG%o6kka{NX2FKk>{Mu`>sMjPWZ$Z@S83L5hkxkoCl0Y!u6;G$T%@O-ytXL> zmk%;Eg=qwYHmbZGFv*KvNcCo6I;*;u+m9DTvvD(sx2y8xsvWqIOn1jZ@%nD)Kdc1* zuwZ2AuG82d0jt2_GVWB1oEa%U*(PAbCWqcDbl)gje&l=<5Ec^M*@3bnPs0Jz5dA6<%SsxJkNXk zal=e8kJdSEJ{qtt{|CbxEIL0gXA?w#>Dt1Cv$*f6I3h26g%qVAJ+)jR)_JpaF~}_+ zLSH%^sbc2h*>c{r+h}ifd0bU;iY2;-F3o{kk&|@BSZo-HK<$EBoAmN(V^f}GjLO4@ zV{{3j@|vatH!PgIqidqbL8or33RuC?-V3#8?#kjqANly^o+%I!{CTP?|GXqfbP?4^ zImDyb9DFFj0sD5z$)K6VQ?A<#5%DEmI|eA)WKOJvF=? zP8zxImYEc7ww?*E(kzWXo4{TARw;Ozc5H|NI-FoEK*$>Di@S1`(nB+n+9lb^iS_oj zX#@qH(CH7W9-L|0kVYZ(mG&@k_4k)<?tKSjY@M)9aw`Zq-B{fmJx$P_c%Nmq2gj^m&sH}@)`|JT&LM63`~vSux827UJTr@En*MnOK>W0f z*c6eFP%C~7#9de44<@?^sqeXtVdFZqOKSayiY#x3^xWQTzwuZ;bD^)E*#N@Q*X^1ePp1kAeX!9mQ&he12eHoVl+9S|5^rAAt2EU!Q}t5X?)<>t+;W+nRS_XvX@b4Xu&)Zf$1zU2 zY#(l?IyTjRDP2xJ!L@g~t@;0J?>nQKT)QYklis9*5P<+HO{q#%L5c_{NN*yY2oY&P zO6a`^TtGo7(uL4_??q5bC=zOd(o3R(Q9_*8J2Pv3e15a;z2D5NS!;$r{7c@P_dI9s zv!7E=D9k%GE=8s|joB1EQIV?Emv4Cdc2PlR1)wZ~f7Pt+Ffc%AuFDz5tX!Q%isB3h z#DTtRg11S8<{S~`!+-F&~|%pi2HEn-bNfNHB52+GyXKmD4>UR0=iWC6~uhJBmqki^~&o#v{{9OV2$E z!;Mt1!lz4NpbNj_9={7niqYL``MhGWr1RTK-f%H2=PQgM>#h+zP!r!cTby@%ruJ#H z39Z0bX1mTuxXr_Cs4FI;=}~IO$hW{*5YS!sAI6q>^>t}r=|zGHJyx1_`)S@UOB#0K zb1Otsimcw15mZ<2x9RbZ(5ymygbU3VcY7EW$bMl(S$U$`kXVKW1w^_C0&FNPC9>*4fY1a}z z50wkWezuvisz1#msk$K=w8Na|eD>8Xr#vO(>666nPs=CxM%5m-=o0SWeJ8hXMl7mf!YlJlA z3i|Cm&wQa^KSgOqpc+`2>Pr2DTwyOSPw_v_rqhqt6Skk zC1K}iZ$E5$za;qPT;Nvm^RAEu3G|GjsEBc73)3KFDs=&-nsDJv99BJ6$>8M*Eg`BD zZanaGCRboe1(*)!avD}?Taa;6yY5rf^fdu4^O9`zRVR-e2uwz`2k4&ti-7x2Z1lf6 zn&zQ3up4oh?pOhy+6c^Wf|@vCzAp{T_F+4hPY>MaN-x3XNB;d|XIo>qy3ECD`&%+E zL4TU#zdGHHd|3@%^lOlt)#k8^7b1(aW-@@ZOA+eSJ+$)Pbj`=!4qcvlm_b z#6m%o>09DG2&Y4IULQIyPOh59JV=@lhU*&}+J6Jj>(5d;jVj^{5e+!7Bptg(DjMri zPV4%Mj7A?Q2j~RQquL#YHX7Z~IkKL^4Lm7HW)Jfd!Wp}67m8vK>*Rv+RQUa83jN#N!aRg`~m zt@$8zZ0DtU^XVgTeeGV{;*LTyOVQ*6uQjyRAa-k~*C`1?7V2pFHaX%qIm;|QYe<#t zSgQty=vSu*lE=p+I-%q=^_BZ`S>gu}_S*^x{2eez*1YLtk;A8Cxi5F>0${b^u_s+I zI6Lfd68;cl8?(;L+&Q+*dsNdJ=7~hFU@6k7*=rSeuQ@y(*;=X)rkD;@$@|TB|1JOg zz3bO@2?FYd9>HQQ$#6hvx!=m|=^aLugH*XX79}CA(2le#f+p;oeoiKo&j2!&r1y)b zzLUo0Carz{334_}eoTn4nXW!ckdz0~vz&x>t80}7EwA`}QRGF22=~f5ow_S6i9^X1 z;@XqHqcp|@`r~+9Rtib(w0cN@0==luXCTd^{DJ?O59IS<2@WW$+N+O z3v)QY983Q?lC;`|0~T^+k_4o=GmM;C+N0fyKU;`=_Hf9(o2KO#^7-=8(ZoI%Mt$L1 zuR3!FAFjQ}_5vZMR2New)xa;@OWs?6^0^=!FCH*H-)uT5A;JJX`@KZhPhlW9`93-G zzobL|=IC}htaT6W^R2J;buteLfCICp<#+A9$(OAfI|rymY%e~1B==+plRDLEszqJw z`?m?t@2zi5D*lteJ7?hywQvB91d*EA|E~0GF4Zoxk zz$fXDd%y}Azgcy3uT48yvHvtbmYQ<>IoSg$R3_2Vm!MeGD z3T**Soo!8F!kJJjoAM1FC#b`x!T?w(L-k)ZXk#O6F})uZB#hIJSCPg{@W6MzU#j);rzDFNjwLXV*&CV3ac(3Mla@?_vtpNWcirxb-VlorC8eSv#wb*zEU=vYp_aS zyt%GBE0^?e`N51_!UwQp)0a!iC791OuFF%v-wX4zWWWybf6-q5jWm4gZ=?%dHp_$d zP{^0jbC>&~bs?$YmF{PBAvh5K?%NQ?ki|S^H6^$)Rx!3g-?}F5!K_$(Kea1tJE+5qa*aYEV+a4^wj z>CG0D){8Z*`A3ouP>q35agQ;mr(kO~G~n!Wlncf_gbM1$=XqK7fYIwd90jA`NmToQ>y>( zF!?0M2Drg+KVU6yA^uC>oGqm5#Sx5qx6L`C#4Ynm+#w_l*cIM{T5o}WREx1TEgVxF9lUsw_)Sey{!LQR$+fOlv33|)i|Cugi@XW$?tAJqm*o<`E-AkqeH?i6 z*c6}an6`nP8$ByeRg@}Z@^KB%LRwK0=sZyklE8n9CJL{oa8BUUb?fJXWHCuH^zw5q z%`J6S%cy{FS1fykqUOf~q$uqdSd?Y+AJF|plRnkn`w7DD>Yct;p;xFcb^9@w;ALNC z?WRf}Y%ncBZ=Ds@$ zNxN*4bbS<(3!>-*!NLEEJO3#(9EKvAq{9$ys^Y`hdC~GbB9kP6T^G$6d1xfdhvmzt z%0_U*;)nsH@v_}bQ%?@ucupZO5)@otx-XBB~``)2q(X za7YJFu}tqrizQVdC4iPB`GgsnGM!`VMzpqY-!41KN}B;$@0hfq-TuE&*+2acILnUu zKRZ&e9Q-iWHfv2iQAy+=9XCH{$JQn2W~n+(%N)IEpx4QEyEB(Ud`#r@Q{_eWFK)Q~ zyBoTUIgf4%KLK6-4XOJbb=y*LI&t3077JC8tk@FZ$HS5vVLUeaPQ`F$Lji@f=UF!cFXa{`qTsrEloBbUa11L_ zk9Yq-hA1A`Nw?Kd&6pjh5^{;vNfH_Zzhd>dyQ4!*i_7~{U`<@5SCL<5X8IN4j#ilo z)wdhxcP}EBrSRu7dP7uTeb;AR>`q z%up7t8%y1{%538*s6u<%Y41iAjV!g$I9p_j<*9(7k$H7#YX?QX4|A5R2 zBuT$lKmQGn>k6&rC5)XoEfOX0Qpi#_b!L(f5xD(~v^i^d)|y~C{T|+-#4GZ`)TE^~ zT6g2KjmT%ovjO3{eB8q?1lE|?{)6QA>%U%R%;c(_77_Wqu&dOB=H4!9y!4Pdr#0N< zh*6{i(q!jWHz}{!{Kt|?^So&dL{ZwFOj9PHE=TJ_(x=fTU>58CBBuQ?a)kx27}cS? zuFh`<-`NP16zhL_>m>`6QMEP$Cj3fTv^n|%dXyn`+$B?9mt8yUAijtD>Hc1Ja%+H> zeDPpp{rk_Vm7f@z#?90Do-aJB%VnB#8d*zy$6@R71jcp;oiX&H%r$+7!P5yNyd@Z-m!1=KgG zYeEo}qZQ98oXP?xp*8K7D#mK43)lT>PU}=mid>&+?gp=^pCQDfEaw`{4R(X|j8Rh4 z8*_aNKXgCZ_OjQH6uptJiP-SV898RAy?MOCff7#Ty(ZicXJP)JoJNM`s9F#=Ok`6( zevAXg2>k@HxUSZJ>*W`=ot74!k&qXcCg1k^=4W=LCG8qE<2+NEaG8^@Up5IZEK_ zR2N;4%jp|w6_-l4A5MRSoUg;D2iHA*y{e(cJuN|+)bNR2c54gYEZJJ^f)?Oo{(Lk- z4ECL#?L4qg<}h4oqqTBrG_cHFDJIOPJ*R#9-8tNA4V6U|2obm%o0L99Dm9ubYG zQSA8%VuFWX6P)i*Ut^@!pD9dNp5EYBTDhj-@-oKseL%GxQ11HqCnyP6YCKf{f3qST zV@pP`$0Avg9GmTUv6Sit`mzJX7S}l%Ip~aQAI-?Kw?&!uHB=09Dh~MR!yXdam^}V58-IrwWln_kq32Z%|^63I)$fMN0l~h{?eEqQ)LH^`6 zA#>4!kc3Oeq@)}rDBC;15LiQc^PT3)*LhxQrWvx;<-7QynPm37(p)JI)Ia zZwPE|z7>#T#SlLHyQoiJS6lS$2HLvH3xTn?+G!jm3h0h<0p0ry`Oj#p)gr5ja*lZ{ z4l@7=!cYJzP}Yg@4mskh1{YBrk3kl99TH=_sI3FK_r2^)y7y9WuMNX56J=r1>dc1y zfzHy*QOq#_Y}X1oh~K%r@IB4)|OuAY|zEtK%a1)c3w% z4)$=`027sLxKLx-^jP1c?6k}>E?09M-yYjkiy}dWpN|p?#r`>9nPOJKcFp$Of*R?Zj=MAYOur&7&nwu#L4wE-0JnRjaAPV6_(Kp zeCVqy8<Rr7Pv5Z5i1T$t zMY{g-qbf8oa_*lTu9X5H`>Y4(q!!Lv4bU&cZG&KV!#LtK%r1i8O`WR4!ei5N^cY{* zIG4O10E7BNuIS(zo@vrQe=S4fAjzBehuD4`u83%G+7@!gYA075uN|c#RtAR-w#m;Q z3sid*86sEpq+)zM9!Tvkh(#qTDAq?%rjFbyEb~YbYMv%Z;BvFpSLt>vIonHstRW6* zsb^p2_U(Av8;XEkV$bAx_}EBinI|J7jDePt#XV+VHOiKYFuOL^%>*Coe@dMYq>2dh zE#9>ZGCMZAdW{d0VZld9E~69=A}s3SIg1_EyQvaZSytVFt?VozlWeX{yrk-K)sM}V zzQYj*`@+eAt+Z}vet%O1Azh1?jcwG<&&~#SW5^6y5WN~*^1BLLmuhndaobM?a+qGZ&|ewjh~~IYnX5~Y z4cgjOAIwrh=0*8l^buE8zc+_%$oP7qcQRbp*w#XG=iZpoE!janwaIA-BM!=E$yzAj zMFt>x4LO5c=YveVxCZP%#LGZ$P*Wn#h5#F$N1x7Q@l8s2Q2vlllW<~)91(d)SN1`J z(+-T2is0%oTr^t`j_IGELfc43l4#(9L8=)2i}@(~HdkLTFSmrfi#P+>g6ZXP6D~jV zP}=HBaka(1r#5-<)$$)2-l<8#?lpw>9p2URv8t$m3%G>yh)@J-^DQmOT!dWAH3CU3 zfQUFdF2ZKN9T?#RR2JPPQ3hE}ci3R<5>Pw25IkpjFvbB^wmi1>6U4eGb2GDTcA$3A z^7^MVnO4p@1G4N-vtoN8LL30nhMR_$Ne)WME59RWPwA-R zWTbcb19>#;BJfiR;Zk6rK(JR@O_5MKKstvz>P~LR3ty3F`hs*kflr}#wf#Dp8{5;k zi{O?zLR91Tcqp%ACKplJqtxxl^d-98#oaM(*Uqr?s)WIfG7* zq4w%Uh-c#4w>{C6;WTn&33#h@&IBAnuOZ7g)1vywjWk^3W*RTjJy+FKpAhI;r`R>? z40)FNx+6_SD{=RWe@Y08Z4eJ0(IEnpikZYv32>Na}Lc;(d3%A&#&Vsrn)0EpKiaRQ)lwfFA>1Sz4 zB6gB2Jr@)AtiN(_h5ZCM!lJ+oAsjm-D%@&6IHpSgryUDP^Dyp5yTnNB%OQ0?Y9?#t zqOT4Z$wIu!C5keVZmiLPup$3%0^TT*+w_n`>vVFNz;$Bq9>(YP0}#`J`9U8Hb7E$` zN;>H0S;m0jqYReLuN^5JY4ZHeK=*fHv_K@g)G_}MB}ZuN)S9NJ@4O*7Do&{M-JX?R9hm)KubpaBy1QiVK527{Na4O&m+gRbG#2gjIWd9y-Q*vjBvZYSPy!s@+ZnzONecjj^JvJB5N z#&>-+JHgPVeDeCgM?#c<+O6?!9yKANQ^K(`q1zWPwl>_Cvrlb+6R7v+OeugL`~(en z!_#~kpU5qEluT9rp`jEI`(DI()P(ibr>rDgOn4(KDzAZS@0(OP-ob>h3&plq0EH$q z)4lt|%WStgUjQ2&^>hj|FntMZB70d+_Gi|~*JQ^wgI`#y_k$O*Fx|cCY*60$2sJQV zxKC*!HYcP4@p{X&iAyQR#m^-TV*1DC%T|?1>QOZYaettmsP6Q65dCSXi(*p4DFSm4 z=KDzOUH{Hg&f1a*q((}IW3;5~JsENtO2c*v`Cn8)`WIE`M{vPN=iOF7IQ^Iu-}_f= z0otq4m|)g%6C^G&o&REpok3Or^z|200Q$UDy4URz)Z|=kFEuA%=0iic;T4+DTLA|| z-Sypl@*U-*177)XM&2Xd%i%H*nR1cmkH|C>BM1p=uMUUo2p4RNx>-8(u&^^XDv6@F zJ^YpcD?YiPcyV>d)H1UU9Whw8NUvA^y5S@^d;OcqQ1nXME zzum-(4WOj5yeJOiO&V~dxRPZm;gqx=LwG*$FyFU#Ph$oQ!$@?5ZXAxFJ($aa>yB5s zU@EU9Rb6}|u#{CmrS{`(qyQHMY;5R^xK(d1-Bi~(xasgs+;Mt|tCDCIO>-UU>+b!= z#`;7b9qe^hc0W2$KBE#~mM}hUu*7%c6zQFP-ryZng`tEbH;K; zr34csnF>p`O;_&U9hL9*?9f8g$7Y?e+5YOmPKFcfVf44al@i&#*AUUA1W7!!_l?>) zIKKKQg~MHquQku4wPl7V>Fr&%TbTnN>rx{@KVh{4j!U89*Y5Zf7w0 zPmtTfyLAtBJ3?T-jTP;e$3JqTD&6XL@2(&62qxT_xf7Zg#&*#2F1XVhqbl`f(DdnE zn{%F2bE&1Fy<3@ToBS-Y8=;APK{OtCyliMV)O z_={sQC%&2{h%4pz!RebWO0Py;b zCKMMSgx`HRwx;&CV9ujpiP=3=58VcFiG+nIUeTIX@ z8$&k9GWu4C1u#Z4kJ6styvh(FkQaSCZVRRZGXa+z4>gBOpJ&v6CWhXr`g~4o)!DNs z8-ztXoX#E|K%l_`3TBsWw`?JVY^Uji^TGJrO}XGHxyAe-plg_hOgmP1M32%zPH~ZDoWPEU5*$OH?A@8gz0uO0uyS>hBv14_{o{ZrT(OOQ z)sZXIjsFAT_`+^tN;_IUQ1)?w_w%Ut#iiw{iaHThRKdd5hW+Xb6_|-$7(4XoYD5Rw zS|07Lt;n;niXC2s7^uDPo7RVeRggH2V&%ANPXZv5)E@u6;}@1dv@as6;W|mpRXcvX z3Q5J+=M|gh>nk~rQ!k9Eq+OwVLv3Ur{t1Rgt*yoaWy+y;ydAKT5ALRxgTO*;*ja)Y z<|uNqvvXIpz`ai2U&4`AC@?3%iuK$b#i7hM;~X2VuYdSs{re^uf*#=jbv;_e3G4vxautMpZO8M+ekSC&j0SI~XUHsl-@x9n*;$|qK7&1_nJfGB&K-F1%0iPvgI5|n!c zXmPoXSPO=>u7W~Wza}oricAShtWK(>aZ7rZSem}y%%9zhW*f~!_KBf9)<))q-DBju ziVcn$o3z}R*T2dMY=+BzS5<+DlT&5W_E5u!%+fVgH5VWFNlats-|0MzW{+(faX!8H z52z+UF})a=H}Kl z$c(&j5?t`Bod#&};~vAk_S1rMQ%l<r9T3V|C)pef0*Gp1HeLV^DheEOZyo>jv(GM;|G+*(X;B=PG1XNY^ z^)X%=;-g7Be%c-&< zSa0Ww;+l|w$VH)!Y>}waSYYq&;*wGwD^;cJTa5})VdXIE_#eD4ykPvF+6ON|YHt_w z^ZSdJuZ1(;b)~>aqth`4%@|dB>}Ky_bgmG-IUY$2#)%KMMGZDJV-pTpmbNWN^#X0A zbX?jq-3qwEcYrjgf+h~to5UV7QBAbP55?fC*ZucO{W1J@1~dB~@f<%viG?SSx1^AG zl`9nsIZ^a42c&!@Rdu)9H2EKke+Xd-naCwO&HcK#D-9xm`vj=#XAT&bKMH`SkY7g* zHdGx`?Qbywitf4W7);(T2C>#0FW(941sH^3|IbSwk z_H>A{-JX!uMM;sxlB-|FAR+qQA6y1ft%%N(@~^ZtYY72P>v^HJG&vf$UNz09*J`rN zv+g#;@o`(-pZU7=f$92{O>T~_W&?-6=+w|Eo9mDHL=r%!2GPSYRv{S(q^oK3Hzfxb zzXxgeontp&7Mz;_4wSk}9w=n$seH*RJax|sGkztnbcw#*%8q4g4qc>xA`26OZ0%Hs zYpuz?0|m?z*`mo2POJMg8_Ao7cyMBn4OUVVtFX-fKBo+xb-$}NgV`t0DwfAdz1T*0av2FbN|otNA|^#$e!L~DN@~c3Le03 zD?NcHpI{aHC8gHyTlsQ%TWYL#3Yq7-U1=FgGnBowNmlHgm~MrZd<$&grPE&zOfo&D z*MqSWWpO`TrzS2KHYntj?^c)9Q4Al<8=(?XCv{%z+fHHafE-s2RaieK_fFlkA^HGK zx+*O>4WGuM;Rli%bUk4Z<3aYa<$LQ|Mm#speH$`d*z5r}*e1D^2Hn6ZMiBi=x4)4e z%*_ud$v2fVHuOBuiBnRlSW9hT_;MXAL>jJ9h`^_>4aa*n(gW=ST-}33lLri|Ei+aY zc;Bk}BAPf%Vzv!(CN5>obTKvioAif<7|SK&_q$5hAkS?1q?QO8aajaWPRuV~iGS`IK+)CHj4pSp26HArn`U zHDk9lH#gGmuVGrK+}5x03izyuuhp?3EVDk0fd+MRa;i_>d#K;5>4T7=He^24uBhVG(q<7Q~><&RQMTaq%l%K65^KS8OHUVN@>n ztXwzSf;GYWMoS&O)(_9o(H3OL*d>k%Q$jI+4!G<^I(|-uA>Kgt~LH-s~D7?jocuCjJM&6w(c@FFy(s-D&-K3 z&4q2VZ>$&XFT`S|6~71@PFk+3+&j5ORK*YXiQC2em=9Cot02(ztPE>~LFF#3x~5~Qqu0ZP z=|_~`Qqk|n?{rYZV7Q8Pu^ta?z;#%OE_fzzqO47GY@Qija<1I1d>dM5V_Q`VbAu#N z#afN*2`;*vyS#q)!`(b-*sP^uAb$QWQicTJumbNQC#P>xz7lNvsayWySY z2+atq+8)FF^6bD?cfDDE9wX7Fn-EqCr|4A>P@TOy7>Kp1CJPoKa>vp&c_ub3m zaEj@6_dn+ceJ>@^>8?qhoyUZvVBue1T~pBx#`9q^v%KC&wk5P81@SAX5_jdEFFELn zy~ywmoo*e{1t?Nt7u{@^1ZkzMB&LAy#fES#xRgMl-|2zq$8Fc`gh=R0AKg0P6D@@K zdg9Cjw2VwCE-6&DWQzD(gk<8S0qgO)O)c#5I_FhbYWRB~&T(aKtGIc2sQ7b!p?YvV z?IJe*zGoS}@FGKgav(r?4uQ2!#|hJq_SDW3xO!KVYf+mg`pD*3A1e%efk@pUqEP_AHIf2`MKUgy^>$^vm=Ed2DyuYzCOSE}EkHe~9MzuqUhO?eYyg zLY#xG%I|<#NYp@|_+fuHK~u#7pn&x7ygnxfjn_Nq`&?~a=0GH`A4w8m`zN_{kli|? z80K5l;Zdi*am`eN$E7o^on4);9yB+^X<2+sjWxNel;&k89x|7AhIr$|h)7PDT@P01 zH>#VHCb7Y@fJ)6u(Ww1H3we5zG-#{Cd0n=pCCd-7Sopnma7-BP(5hI zd)Qwc30!fJA(zqekw0RhiQ*2$Hu|3LlP4Q$G?N{q&po>Tl8yJJ6QCA<8$3Mcir@tn zJiYY~VV(WDZ?Iy>YDajhUU#Q@bE~kQxAR5I-PZA&n?|Ua+?=8t^W+@~{{RtM2rj?0 zHG(8I31)@!2edVeDs-TZ$2V9*YjXdQRHK=MdE-E;P(!Jgn3N$TTkap=KtR?IBK{j! z);HjW%H*RA@#c&NN5Y%$wFFzV&4@f-?bLPj2KV#mmNc1)NQ>eG6m%ry60ly5L{9X@ zzuSx>!f@Ic=ENYqGMc$v(|HyCQg^O-AvyC&71Yi5i}y^lbo#w7&W5&6WGC2g(oc2z^{*m|G-` z`~=ZK2*S+$3Iqq-SE6FmLmc5Q;mO3wSy7pW0c2j*M{+0sXBf2;SzjQt%_US}q=?4l zdTd3hy1wFt1#jE3AS{9Lm)uC4>X944;x}>YZrP&GhfoIfH7H_^l^&dAUKq!E6XG6r e^GzrS!~|TEBL4euumAu5e@yoJjV&mCF8mkwTK5|O literal 0 HcmV?d00001 diff --git a/docs/za-download-2/2016/2016MatheBG/2016MatheTech/2016MatheTechCAS/2016MatheTechCASEA/Anlagen/Boxplot GPS.jpg b/docs/za-download-2/2016/2016MatheBG/2016MatheTech/2016MatheTechCAS/2016MatheTechCASEA/Anlagen/Boxplot GPS.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cd549ab332fe1446da60355a57bbed1aa5910543 GIT binary patch literal 42214 zcmd?S2|QH$|37{tDHScWS*EUvQn{@noR*6uTO~zIQXxr_sF))yN-}A4TQrrVC@M)I zV+lzmdr=0XWXp_om@{YouQS%@_xaw>clrOn|Hse0bKRzybKdXwYk$3-uh$WN5>_DN z*6ADSBVuA=$aeS#5mq99=y^HqMi66TWG;drBjN8RAmZ?UV(^dXM_7ieL57Kmk^lDm zh)al*KO=?@7nhJ6At_0|MvfXiYUD_%k&=>9W2K}n@-y&qu;GJd3(+VHdDVt*jR#*2xM7ZbilD6m!u*b%v@o*%Jc;=?6Iz>=g! z!w2HV!Scn$hrwbch7X5N`@r8L!^cZZn6Yrxh>7NQl7G0#E;<%`d!+K}C#iB9@;EaW z?>OK$N@~*NDO0D-nysQbXYP`v%QTj2YOVQ8Pv2mz;ku2REH+zi*=l9KbJuPMN2fjR z2R#mXdL2G;{KUyq{-@6bT)24Y@|BRkuU@+ob~pT9#Qg`6Ph+3OJ&%9!G9fMfbw*~^ zo9wsw1%*Y$C7(W*R#vgAzkRRyQCr7tZfR|6@8ETIk^2%u#JjiEx3fX}8V~y#1~eEh zN$yK*m?yl%#}Aj7v2etMRpyd*ZWI4lbZn&T>fqZ?Qb#E--oTODaUf4>(#$25v$*8e zL_7Ou8}s{5?W}KO{rf6K#)yjn@x;d?C?de`1gIj>>J?1|f+ZJ#XV zxwB_6GIhniM6$TLAuPf!M2J+NbCHJocLEUAo)?=KjlE%b={Dh}aW}Av{JKObHX)_J z>%PkxA!2S6*`SJ$>w=d!ofou_t&XsXbl2gco^SP>`x7MsX|3a``gOXxG7nThioi zhLJDwnrx*-9BK52j(VjKnUL7kpho8fxU*5DMTjh{Jt{;B7YGrYI4JhXTu&AuUmJHS zU~LZyKTAs>2e9Enq}t>aoJM=B5Q#8~td9!jsbn2@MH_S;3X$6Ux`es`N1#mSJxm&g z*6FBYT_;&3c5s^ZLPRda7i-W_H|0yfTPr1G%|)-V7cUEu`VwW%CWq8`f7o=frVx1n z8_M@#WxiDR^Cwj6S$$hd{$)#qnmR)7q8qWg2}I#e5h5A8DkyvzqXHt;v$cOS8_=)e zw_nPNW#Plv6;wa0K^zuzmrE`!MTsWirB0+QQ^e}Osy*&LF}RI1y0wNKQsBE6YrUz3)b}#X$T!ibr4YH$RGG^c+r#WKzgM#Th$SUT0r%uo=He+HK;TyH zy^oT1Bu|nrV;WPi$79)=H%@b=db+qGh_F0cPzY^Rq!|LJc_3dfQz zS$d$;*Wczw?~3iv8tdXk!X|Q8$IQ4$CQAG%TCWP6pRD7gEh@1&6@`%aQjm!3) z0P1U5he&dg#5{-u!x_sF(pVQl%om~D-Yp^$ zrTUbEJ`wlEdt#^e)WZeDn$iy_$H~LyC{5?6HrOe>^18Q3y=1c&O%JO2sR zqwP@wiW77*XcuaacB#KFGA_51YwsM-_gyZa5=L34NJfyfz|s6}sn5l!*jP~fe=7E9 zf0cIe3}b#sMn0imhiuJdC1l|i&#zuKkq8h z4y-+vPF&2g@i~AZruY_20)%h`m^(vubVA>`_n!Yh`VF>Mh8O0l6W8dVC`7)ic!+pF z00swPUxO0U zrL<+Wx5C!Hkk+2NQt@1m6_!}~Qi!x|d=S%d5;;H}&a5_m)lm}1z&g)-wWMzIPgU=n zs1C;aVH;?)i7O)Oe+=+o=%CTMO?!{E_k3;6n1Z7q#}jLhVucHc#A*HVU&{lQHDjlS zFhHGQt+gID&aJp}FmLt?&aNi%%vfy`!FTBs=N_%4=>=LVAGF_n8^IyDEN{K3T+pH- zqQrxf^h5kfukyQ{V7~TC4ERXCnld1otCQrw6ZLBQ8vfm9+ zpn)ZrRhI6>26G?dDl&%A=oSjs2iJh_D_X`2w1KGysO3ioFvQ?jCVfJ5&_X2+P!X*9 zxR*K;U)y8vfAlzqGV6Mk4Aw4IG!;ddSUY`<4jnqe4*F-AR&sP)mRsKa`me_3bPoZ>9FCPs|G(jGS7nIP| z$3wlg`ak|1_z^t$)Wz|s@g%pIiW7H2z&l`p<&MFht(4Yp;Xs&Y0>YNoiFIq43BspikY&tNa1Zz5}6JmJSzhbPysY zAr{4J_cH}YstGCVO(^*8cHM7p5-aKTia?P~CJa!K7h}t68{HFF)aZ5x0Si!$B{qP^ zmYL{jU7wK#hw%fLY&KSPE(vYx5l@c;D5)<8I{)1!rhD(?Rw4#C_TcMd*C9%31#LED zD_^5cD{h0su!sWzM{nd3)8TxcHy?rtQo(SAh?~)`{@8zXhXKkW_`{?t!3k!PGdQD( zwU(rnw<{rDft6sCAh#g$CwlKNP;u?7Q@h9m%Q};iN(IkIbQlla--FhA$&;yI@{vyvx*QZM z$?J&&Njv`Q)wi2SxOIWOINKk$4D?qn-jCS0t!1SGBSpP)Iw1viw%e+E9H0!mg-6Lp zQApDp+Wdpx;)dBVfe>^@gHj+C@I1|Uqbr!lnG>0n8=M)kNk_bO4p+(W4Cb{p{qe~u zq~tH3Yqjf^*?!gBtS>~Ir%Kiq@zlc@o!;C-Xv3Z&wDXT-Ss~*05jW&aZrI2bZ@1)~ zW6wXjB@b7rWCe^}_5kzoxnDopd1{`=%rP_GeK2V^|NG-jpT%3>mISyOWwq$(w_gmZ zTjXq1|F*#awT!c zw^8B4XH668mUWrLJKW)^8}Qd3_)4|Ce!udhY=->ZJ-4o$_x-bMjaOFV6TZn00_4N* z0)F5lO$Fb_m+iy>}P(D>TZswxrDm!OP(M%gx)Onb%f?IHy z4fXu7LL^m__J>y&-}a;TWa1TLA3Yo()X`l`#Jl} zi4jvzZwcK=r+5`vbj(dKvN*i8BYyscP94wIS^C@!&4$q*niKKKF2$fioiV2v-kixP ztWPaz#MBbqncjMN-b?TBKIDHFB5Mj2cPGRZ?TC-J_Wv_SN^7Cx)%AO%YtKezD*inu zPjHUuN+gWCs;~|aRZ|l~&)WqG)H(UIC%L3&f$7an9Z^WfH1ye&& za1M5RaIQoMk>R-<%fQ=Qle?vJxF-beJ$E>E`kG*iy z%1wUk+18kbr4?f}(kUDZ)3Pc9i|*#lo5v@e+P!(W+{t4x;NSWkA_|jd3c_fUc-DG6f1j}(88Iga zv=AT9cCtUU!s8V)_Hm(w`Uce^3*`jgbgh#X=HD0HPy3#v)?Uk-!IdYZz~hrx@R2qR z|ATkchZC9G?oeRQr9G&MdNjrBfjxJ`-Ba>vt6UB*r@u3zJX^0I$kkTZ8KpjzW7+P+ z1M@UI6&4ZZW$PNqm=dkK*vr2Cf&Cqp*VTjSJpFx3SmU=?y)3xs@+e*6a3lWXuls9W zj98X-F>%_2QK6y=jwN;A8tj{6nrcjOLiciA;e!nLpxH1Slw7cGZ;~@ACPsy4 zz`3}tP<#HtT+gbLc8~VtRiP91ER3FL{&H3G!-|Q%i@t8DzOd!O5qHi;191Y?w!(fo z6=o4%%p~pg+}IV#LD;vJ3>ME~4+9VGDlkZOp?KmnuSBq~p%BzuP!)fA2fTLPhHEHJ zcqLASy=jqqyT|Y=)otS8u<-1~< z8Xc8b;&WFJo56(jVR-OLIHE?$#+%@erA5K&sskX`6TKXl%}ark&z;2JU-MWfMB-(` z2JP#kr0tQCS)7f;U-i8iRmcid8aWDRWmT2*u4{2X!f5+SO4@vqOd%T#J*qs^;4pWf z6Y3T&fWtJ~AM0x(yX(mn&D}FEIEoiLMlZo9<#k|LHD{Te2c-SEK^y&pO;EK-?YQ{? zs6omjh>$H6bfp}t$&VQb-fLYIB4q&^ffA*rW!l!{2jS?lWyL;ODg&z<$UqL{h%K{y zH1i&uhwew(=PDLHHx7lhQL|VhpCy{g@n$bcTmNvG=9D1d6$bGL3wbcn+t*7-?&sG{#+2}7#+oLg8deZ(;GxV*e+FzM@!PrB& z;vW4O<+)cpu-+q65$l2Yum`d22D)FDrVt6qos3mlL}JYb_2Bu+WisHn-T|Y0!3ql^ z4+)XyL}4X|YJkESfPj+J6*!8~lqtL@9Q3GN?GXst7Z3>?cUx0-(+Mk!n6ugwr`dYl z5JXsAS*BiPqxY8HP+!t%c;nLE`<<8kRw*yvEeRZRa?V|8-Lk5NgCK?7vP?4If0`2{ zQ*9_HF#S(AIxmtW^DXJu*Q#k!H@wISLl;@ccyPQTnob8f+b=|3Ms4iEax>g1BZ+af zR{ke`-gVBM_`t?_^XZXEhW9h?&JH_p>wN673$hcAJjt10INMieM*uqubco{s9kPMq zcWpT-X$2xlLwh8RBGKwTn7lM8NB-jW+{r-qjV2Guh%&yJJc#P1=MO4u!u6L*-+4IxjmYV!3l5m zO&2WAAfsuK@-Rguo?)Fh_s|LkZym`Ix5O(b_<1H|+kWh zO`dLZymCM*qsmxcweZIc5 zJDkNhks^^eKU6u1eahSB)#JSOn}EEbb^^I*g>rqoyHggTEI+8!HtLCKo3N<|u9 z1|iGNJXmYUoqcDgghN3}DM9;2(R}MCHp6guB>Nuh*M|j!?y*p}q~8Xxb5ng3upCln zMS{*!Zo5m7SaJYmw`7D7-$U06k;V+<&=N}}lJbTFc5#eUSu($N7^q$~?PEZDf+s{q$)U zhfR!|v_Dd_LKTrlhWYC34qy*4KXX`|@FgOuEv9!)14E1Ui2JX`XCr;kzHUjUv_>0j zh2=F?LKGy51q&oW6iCjmWD)uz4WkxP1dQs2@BZj=r9NYX=0W3i`YNvz&YYKHm?Dxb z9jfU!?oVG>x^{&nq7gY?rsH>;#lB{fPISra__x&ugCBhJQ3rH}{SQ#j`eLu^&Is^skrx0`%s z>6EbHDJM?q-IyEXB{l3=3L;f!kZPDZ@rN?Og| z@|Mn-!jN~Ky>hdUHbQB%zGU}d)3_;(GZmwP?$X}tDrMm(Xw#*SdNpkWGn0U{YN>t5 zZ78Yy%4#0ZI|fHqEV@xdyEEqpWTQhw>&zvL1die#f5fbyz&h88)(M&()`_+MyhM;& zG99?3)jG}>M1k{wV=Xqt7n}M6nP;e!a8DbflL)bWE)P49eu zo2D-B{AcFfloO452~uGEA{pPSoy!V2;#Ki(B0j`+%4YNrxO?r`z|4;>eTae>aGU%7$| z{SY1OZy4Yb?LAEqZJ8hH0$WIaT>28E^yM;f z!!r(Cy07Qlc-%Bxi3t@iAtGjT8F*m4i8fVK@fH=|`U{z4a&##205X$<%awg8ZAl7O zwkDG%Rzui#C)&Y3!|NaazL?mbpPZ2bNj-+rSg0!Wp#vztsagI~ozT=JJ;DewC)w)m~x!Rowjlp3UA5a8buZf zMgYIyh%E!0@opSP7}R?F1!%R-pt><25jOn!_8L;Hi?ndmQlS{|7_?z;fierWzMD}qBU$h>ZI+^1k04T>tW*y%o4 zwRj{!fvo6&YTjp=>6n$rQnLP`2XGODP8LS6UMDHxC0U6XSS0PsUas*eSgA1dekXrZ zQ0!w=@txYo$a3VbXb1Z5E0-T7slBz=-#{x$w*RgmJ1{d&LM=j#MKxG5rKt=h+Gq@{i{ENh)=k>6F>=L9%AuvLH@)YPkmA=NZnGBPGS@GBG3M0 z|B1ur*Evw9@FaIcVXwQWRatntIq~Q8y8>)1?Vvnj>#caWF-C+L8c=tDkDmSoEdWVn>@_GHIXYzfQ3%@JLM5(0FJ8i? zSoM@CdkYet7RA3SUiMdxM8Kr~@FxA6mL+fEAu3gxNzNhx17+RNAoqmmkW4dtQ{Y0?J4b|mqJ#Y$ShnmFoRKWLEmYPfK)|+$UuIhjB2Z{j zmjKD7L6ysXnUR2V8P5X+jW(dFHT2F?RMBZ+By5C?9mz{9AbeN!)vTxNa=Z<73JOPz zS@l|k*wF!U0sJT$CHW}^WM^0w6uy52C=3B3yby>?JU0tp+nf3C+s0U0TsAI-a_#WR zkXz}lp=H%?W2AsW02T(N^MrK3e~3r-R!(&Q5HtiD@SB?DfP%|4fm2~gJt-Iyf4ORX zbhJUmNRj3tD+(gbQ_?NKt7`Qm!;#(sLNfNf$2)<_d{>I7s6cw($*g9wj`6>KH~)_L zCqE1Fw$GaamJe!nF@lo|q8@_=iO3lU+v}~Xilifn$4n6?p@7|H3hH|DbSM-;ExcR2 z`&p_-ycqxs!~hit2E}egBw6wvT%GI#dx-xYdH`r7bv%d$uJGg5S4=R@1M9=T!yR}$ zJpd)8U#f;H6+L?E*HBM|Bp(kep_Ju=YqLKQ5DXcImS*94AYi#EXuDEKcLl!>R&|eA zE?c+vMgZVl2H161kzL@7zwUa9aK=mDD`5gfIBsK9}?o*Kc1 z3ogw%E&?~S!!-e0mG4sul~wS@)MW$)ab!};tPj5<1~`f#;D*6>Xzj0ZZ_rQ^BE{i% z-A~?uyMl#9WAGVzu=&^|ExI^o)zd#a5sKdqwPd#MCvb}--2O%p! zrA@}q5At(U@E#cofsO-caX<0{V1GAwIVs?q z+-iLRwLKCG^ah9OZg6@E1r$k)z^|!3x*t3B=65g&i()blB|6sLs>uC*BM910GdD0v zoZcm4M91Yw=oFoVPH6)_^d^CQwd#7Es=fZBg-A)n2Bl}fGNNmC<1s@QM3E*i0_a3S zPef6KhsZNrXdSRBL#`SU&JG!&?iCR!qZ^N=W1k;9TJ|8^>3P$AYeP2|N@<&#v;LuT zGtV>bd1iR7<<*HAXEwI*8Uwj-@E0Uko+98-Sj z{Eg}E=xN&2ccsSsZOSpnT~}^!SukTBo4@Is+ngy=Ig;K zq+__UI@;-E@caz5j;4`BgTjyG=jF27Ei+l1BtjGW_>BV~*O587gw=-MeCss!fLG4t zLs6fyGj^vNXk*uq)Oa%RG9WA#M18-G(isXav%Zwi8VAHu+&MKs=-l{Tf`m6F6)85StTD9XHCmG=!G`#n+kw&j%k&US+89K%9nAn0%MXi9$;x` zc&$}vc;>}$Qz-CdeMva6FX-vE+(e4~>XB+4Wz)G9(}J&@{+7WhUM#bEOTd+9F0mz> zL%!#RIo=6~tw{s)^N5$<0gSv2mlR=vMJ)kz2+07eG?HU#+S!Zi>4k_5fgzEQ-8c1U zvrQ6E7Z8sk52L?XVNUd6k(vVoUsKTyZnhIS7}RZaa4%*kje^|Bba@#PtJl*EqB1N% zW*x$Aa~A=t8STT0u+}i+=(Hi3NtHQrAAU9=KvoW?P4^LxDAS!;Q-s#0$|Zm2y}km2qjS+;bA zPswb>N$#`rjq;1;v?b5HSisLVq>VYeT&^3t4E~QqckYzL zXa~iLAfgaCiS}`;s^1Ja-*hVUe}o)Be?>S!!1(L3|5G^T_p%6J7A=Xt_#gJ0yn1-k zK%m#>1&Dx+UU2dYMX0&3CA+~V30s0dpfJTAl6eE1cyPp%U8nLSlb3d4tt9~eBf-Cv zabQL>v1Wgnv#cgZR@c$TF|Kf$4h&w2N{S4BHtCfW?0V(pk1IF`5!L;?S48_ca0nBn zz%As>_Qb5CG!VCaGDOcyW^Po`AQqA zzJ*=?_F;1NWv0wkOZ?iaO&&VN57iI;@H;g*e#9gd-pT;OMX0PeH>xO&ma&mdb_?w$ zWl7E;y`zBoX>Cj3@|Q0Hy0cCY~5vNEd3Xmw$k<Pw!Z7+N*WHEM8WCT%joD`EqHy8di*&i++%i~x(uxPqU*6hMzUYK=qSyDi zT615(UkL7<-f2nYkHfNy@eQzg>vTq!#iPxaXtM+dwUhXS z-qKgf@ZGRFaN-hWvP7!$|L*2392A@181Tgnv5H$lq}|+~2whqySZ7DQ!sM*FiB;TX zam-x#s-SsIuC>v^7e27m4GRSJa8l<$9-?5sg1(+8VT(y)pGE_VW8)mKum5 zrO6aehbV}=-|0Re%7=zIKpj`hK>jEsMM-x!83u0_RdvWHlFVGm^<}R5+YA3=)OUag zDZWq3@*S14fm#-yNqM4snNwbtf{x0{RG*9+b2V^ftKBD|0Qr}alAz+*iv;!dGj}fN zPWpOEetS)m1H$(z!aaq^>Y5Ya^4x_;qk38=g^jxJhhGt*AvtUxY|9U zfciA+v@XIk%IvzKC4I4~jH=*NzM#ORZR+3i{$%+Cn?Gcq4~<&9PvXsR3F+E)%hW?z z$72=>kyVX5!8tXlOD17*UEZr4vF)#%E6dj0&90)K)k+Sjj2W>di$>j0=%G!2cOI3} zNTxrf+VkJ8e*V1un){--mn$c+-(TIj>rl(G=8VXPhvyzui88T_-&vHrucNkZQbLVQ zNck70jAvJ^V8o>Cw($0t4CZ+6#atC!7JA7zrj)Vxj>6qVOJ&BX1kS_tW}jm_?wkDj z`Jv+Zl#tnv!n0?(&w4lhj}IS{cgoBS4a{=QXK>b{$Ck04cs4rmExadlv=@KEQO-Hm zsW!T_*P$Bggygt9irM2UMt@h`+@8Ppu(Gt_h69J^2VUDGsMTwIl~Dd(y}174P1i$< zY&N*QZH&2E$B3`NWzj0nhAjSST!*)d69TXlb@fSJErx|{jHeK#xjI!c9$SBU6{*Q` zADuPZIG_7!ZI;+mx3|Ox2FIL9@!l?o(U#}X*`6sj_U#z&*!~6H8*i0mSDi&CI+ag< zeCk78xtrp*$jJHcT($Rp9JfV3HCybG#x*Q;Eo~CMo%b0+nQc7ryWWd=x9vG-=9-l9 zc{g=vW2IObbKC2eT}~bmpRhLA^8#~uwoQ`9(zm!GZx=VJ9nueeDc)eEPu6p-)6r-C zLAtZNjI8stDF=&3S02`$cIBAszVKyNo@ndZyxX(G`8efo+vWCO;{$R>H|a0%In0z) zvnoG}EsC^%}E=%6td2PD;v5rhJl^fgfO8y_T0*tfak$Afa&(6LA z{oGWk6jyOneUy`#*HN=1=E;4ot<|w-Ty~#V+_6Zpa^$-)PTA<(*3l0-ycC{pDOU=# z+b}-P$@_r0>6cUQf6}sCgvjzRN<+!Fczh`Xm$N3$MO4HPW443rf1_}UF^+|6*D{75 zleh3V;My+bY6vzx0_7l!aSo#I1XIao3XyNs2Hsz2(V-~-17^;qQD{X}&ZZT{BMySj zc91#lx$OOeJO90ASM)^*=%NAT)NW7<6W9eH#tynOBl+pm|Uy&(8*g`x#)&Up;gy}}y0pWj^4j8gZ=wQFP;T+5Dd_>ub>LJzWl zD-SK9kW1a4BT6cFZ`6A$e-oB!_H${|n7EFHO3ps{`yLuTIup(P_qJSLaaonc)mpA( zwEnB*C584C%%QiJ?0R5L_<`S3@dLZsb&=lgn8jb(2{if5rVNG>NQ{Uq{%<>{`whBw z&4BM473j67<=a>2sQc=`ppu#J8Z}xmm1D6{h%77MSPGF5W>|CcV?v22LOGj^j7L-+ zjQN~i@%4Sw9?PFiv%{bm05k^Ue&%E4?nSEXJ3*!!`ZxLsHv0bm(~XK40_5~`I$1Nv zHE{oMHc=TOL}~)???U7h>7hhndCy3fAT^MJCp}c3L@S~IKc73AKHrDG+*|%souV1O@bsZ6lhp{lhip)sSEF6pWB2({WA3Pzad_dT~R&!KpxY713x&+ z{Ox^@xd5$%{ANWYtJMXSz&Q4jfTf%x-wxW_FOt#uoD}axBJu7g6UmQDIqU7Ad2&G$ z?`FL=v`)PKP?tCc1nD9wgY9AP=FYlze1C!^$@bri;?z1kFPi)Q85%R&=_B^~$Nt!< zvTk_>Di45>hMwpxw8)CpI^Fz-3pq3Z}Hy5XZ4)P4Tqk) zN9)=zEm*y(eb}7pi=jQbKvOkyF%IRC4wC+qa_^YS&qVY1zBlZ9cWoipzKF96Ick`@AXhY|c4D^@XlZyJj(`9$rwYp~O@@=TGQ#nn~2n zy%M2&TlQ@G3Tw?0%ED{4x2N;PJZEt^MKa&vvP^6aaxVLZgw90=GfS1)pRazY0k*3Xo{T*(HW!5i<{r#RbOeZ^Xd+; zVVaTq^I0$Lr-EWy79J=%n2pMb;<)KWoWA}}(VHv%@g@{e5_hmI*q z$bOu371xL`yjyCey=ayE%mZJR7)_ItSUF?L&4qWBf_@zU5B|Fhcq09T; zQVOr5W~Iz^v$btyM>o?K84rI7LVHkIeD0qOUj&tn5lWiAYxUsLXIB8>*Lk6QXs*>Jl8iS=T#usx;Ur#(* z89LMJv~!2y*;)k6(+*@oY^sa^j-dY=qNjeE8jrC7We5Itg?DIT_5HnxHCBtTo zPsCo=9#-?CnU~zE1+V9FpV#y7(|^V5sWbU6n62~ab7>p5^)!QnS(&bANjGghr^VEy zlg+~-U(K=4SK9&(MVtXHH#T`@?P5dHxZ2q2ENh#8Yg(Z0w)0D#pK?&)J4Ys@NWNQs zsG<$|U$RFq%}zm@+JCYi(YF1LaW+s&a|o$OQ~Y|rS1aD2 zM<;?CUY+~z(cJpCPUU~I6QMtKWAimU{s{Gh=l*0`S0TAxq*Kp9bDNjH9FN=`M0lT6Qy<0kHuP~6<_G+ zH~rT6{ofKK)GZD&9kaZCTZ9Pkq@3z)--A)0xiNl|O_WkRQhAS^reb#={w_2@=sxoaG;swLuuu{P@THJP>(vSy;+?X8K zp@GwH{5<&j=KilQWN=o2wUJi_9}7hDvp%TdFB@%R3h&yLGN*x$SWcaYZ(<4{#@n0I zXq`rrHC5cBq!!D_u=4IIyt1s+_D)Gcj_H^Ys{3A_j-9%R#_=7$zv)Iv-Bqr7#yops z91A&04#x7#A~yW6mdpIO|9y>8-o0lL=l_Xmu{}(u;2Y2>&!)W;Mz&x@NCG@~l&LeQzB!y2p`rVbzH zJ4WG@b|8`p@U7AWoysHTwr(zPt%9_v_~eQypa4gEWj2h5m^*E*DQ_(?*Mazp*c+MYew&!m`rJbMxx75b({NxI^KIzmhM=Nq&VTP#w0eIDSxcf z)ZfxpTbzV@U9Y<5232NPUySoKu?9vc`9wZq1t!l+Uon>_pYk&*FC?SmFD0~L8hTkm ze}T!H@*#qrmZV_MQK*?M%hFJtka^zzS26>s%f>K z@t$1Mg0|UTYgX+#(s5qp-Rj26RcEfN`MFQ4xS*N2)-e3vz>*F7GiH+DIp9Fhk-#5! zXh?q2d@enW%K6(-Yr(ChMk}a|MqknFm*bPHJ4)YAjL%j+_i$ayjh(}E!V@-#Uo=-` z>7Tl8@D_V&ijSwXZxx)@R-``))s$ZxNISw(})ik~j=L3fEDG-*DE zVa9XbM~HDu;?++-Q(T}(HH#Ka(@}n{w4j-N%4OZy)qLYCt+R*8A9K<++Ml}m{^8@#Gb}!QM7kc2G1Cl- z(Xb(o(9THW8ueYV@2dqDbrt9n@#V$yag}?uCDf63UdD60j~qTKZzHXC*3D&}n{`^w zs5K);$_4u!Yd)!69C4h6@C;I@lkr{bS1N{l^|RXESLVd9d?KB6%b&X~*_@u^urTY1 zqW{k#Plx#zkK4pe{eCt+ib%Y0=fYE64?#Ryafyil1;Y{|{F`(a^JIvLYIG?BZ7YcD zUFx(vKW}!QdTB>~)pUNKVa`uw*D2Y#lljZA)LEEA3{S-}gCe~L!Zl)AEls9adsXDC zb3gBNgjj{MS1#dy*1rEe>59`I=dO->n)x=`eDp92x3&uH5@qf{QB2u1zWvyjekZ{$1_)f&L>N-M5&8& zap!+k4$P3Xpv(g~mCy&sah7q<0FXmdpd8o>lsN(rdJ`C$h={r$#+Zu+@l`D+hl~MK zka7u9U@%W;jHRNe`PEiW;kX{fi{bU z#&!ibv?lP=01r3#j!d~8D6t3^WXs|%2CTBLXk#O8GXGU|kipBSMH`$rB?M#d++7^z zn&LND^{zFz8=l854cy#pyX9)x-rx^bUmTjy%hQz!-U3W2-oitEcv9ly;ObAsu~*Ho z+|@gR7j|l*C#|90an=ZkEcmN80%pf7ot5fDU_2lNH<7J6|n{Uj?(HfDk7s?Tf(As=dB3W>pNz0}{;A zs6+*UA=Iwjg|%pX(~3XQ*ED*w&aeR0dv3k+jO3P#eAL*gHZ#_x{mig=V{iOi8RnE* zAO}(JB(-qI&6}SXe$R3l_SXf>d#WX(eHmB32OZ=BB0nvz{&tn)Sr%?KJ4SBXk-}>E>G< z)w`|1TK6v;NL}D=6&aZc}T`#s1@eqj#T{* z^f&y)!16D@0aey)tF2K&tkjBO6T$b2C<_M6i>o&QTriwGIihe7NlHp)3?5GfGS-L5 z?8WN+liy^^6qNDlopfg%X5AceUL13H_*U;3M}1Ac9^FzmvOd!0$VbUty|x;9BFI#9 z_b?}z4%t1e)r~o!W(#g#p#)x z;w?l}HpSjFEnixpTa%t&5PE`P;qT{(`)*}HoV zCGRnAx4u8mF2J!P^WC|)yi?zHL`)qXq27bppolOVFW@a!vr0MT738J6o1z8*Ihf6wWGhO3d+*#f+(45X;tM8V zRW1C-59Z(XBYlUW$Zd9t(3Q}{M4AT@6iDJI`_apywhOG1*}}}R!9%r5ER{v$9{MpIzEU#l09Un7@YTxzmxZ&=TwnwV zE&~xBcx~qpPc*<6B1PKR{nUAb#VzddKkg-h11e_e3i+@+kpRA*^S)4|%~5|cDW>>ln@ zvEzcaOhzg`xA8!^avblf(?iGc?>P=xX4&qvyw{Afj@I3J2y^SgpKLKui{uEA6gIK@vfOpSS?(I*bO;^gINipY z)94MZ0hHFik`6<4#dtKpJ7ukG7+(yEA2VCJC_D&27Ox9oXT;|vRoVA@no`%=^C4d@f)I4-rvW> zB-}qXzIl@xkptJaYYS8d!SN>~DJ@z7Y8r#DMft>%MSq;}g0Nx4waG$c-W+l(6svSB z_Xk$tjK81z55A-NED?R78wZAYqEA;lDVVu5KIgFiJIZGHcs#7;ox8trT1 zZLo!+Vd_ot1g2HdX7N^3U5SFR!66h*g!e920VBqExuJ*7M32maIlT56)-5gG-;!OT z;^afiEP}RLJ9I3L85NY8>GC#dLM&CmGycJk!p*Mj>Y}41FWh~!=#yksEN=q0h8Vj< zo!DYd9);#FE8GO%8cdME-*P&H?e}p80;535V=*mD?muJ=Aw6dAZ3Zkfi_Pq2p*#5v zL`7HeWCB|wM8v7zjViw`d}b>1>2s{4vyoTjJjpQ@D|2ftokKWdBVNBZO1U;agP9tm zXlwmt%c_~~(Gj=ln(Ozh!ApJjc`cu=+}3La4_SV32+ zj7Gnl#NwN-5!5}rlETDKEW;BE4;=91-cUG_)u6{|$t+dRWSmx-$u&6cfk}RMqOfHlLsG@b}zf?vu;s8mfo~uj9Xgw|X7WQ@Ic)52l3nS!Dc!?T)4Q>ey)8uKXvRT(9ErpB_q2KS zV|51fcI*w+)4Emzt*L`%+(n%qbdz2K3mQ^o7|fX++`%#ACId$8!c7if2($Wl|E%hu z!{AX(M#Z|D)kqL2bS}cRwF3|lI%%vzH#&*c3r<@5LBbpC=eEpB$a`mrYQVzWxpX_ZvH^qlzDnz1B_^3>_ zjIdsOsn&3v_OmvV`gLxm7N39M*^4v&$=p85vVQ*r<^p;w&0QjvJ*58yMjMZ2_!7W* zkH`9p#@qhqIz-=)rMH2ToFHrh|29Ck+Q7qJ zNB1-&j_E-Ji@gywFbo(K1veN1-Rwi@NgI$urj;Oig*Gv)0Xj`Qz@(4Q)ZSI0fPFkd zO($jz?5iH8G`Q&^>fw3#LNVDr{(I|w039+OYA|MVKr7<;w(xszK!Mbg*vx@rn?%@5 z?AK}eg`(++qUdtX;BEN)b{nGJeew(95YYV-LiG6NqJXqoR~dU#2?iK^p*A=$8-Yg~ zH0n(0^@T7Q-eqzewH}bsr2`>5@U5P}6+BW&3RIM{ zJvmLigERnWyH%4{U-`wG$(>OI_C21QV&6ANbq(qK|2j{BULr&u!8pr-aJ>G`eaY|x zTDf?L>sW}=B`riazl5qq-7!WYuXozyfR-xgi+NT88nyVAv?=J{?hAm>32 zoR;4+4-)!Ahwn6#R3^7Fa3;u*QThGNp(BUl!=s;c!8Fq-7-XH5LWgczsR5c%oW#cd zzp<6xAmN|>-iD=`Xe$DlpgELDg1kwfbVToH1waY|n&+2Rum8*#1&ID(WUZ=ug=nss zj!f@;z$Xakeo6(EKkDni(a6~AjSRqzGdjDUv;dfl7MO3)beh$?97ln;GitrykqwZ) z?W`Iw>3OgJFc@4r5EK272Gi2XCT&|_ALvkJ!GkzpW^G>2pizLV|I_&sqJh2Ln&|qB zl!%Sg`y_u28bSdkYZl~%|aFNS@l4TJY>lr=MbDS$jovFEdLK9vmK;R^RD$-( zLQ*?J_&(ry81+CXahO&#aNK;){0_rk%Sc|6U_aB7l`4}%zUjqWB@ROtd zMSUt;L}?9R!Cz-l4AgFJT~BheFlq4m-bq>jO-y0*@9(DD572NM$zc@G1TPwAPg?&$ zxMi5}0gCLGnf?Fv+*{EA8Bh*u$j;{);3n64vH81v^3Gy)*e?*8j8+ zNKU{Ag6>qNL3b(>T!3G5sROtbJWTk@|4Ro%40`?sC?f?dL)2dR7HbX}*aHtOwz4Q! zzuY~yz31^EWS6}vDjJ$iDK?FP=@SDdb%3EKgnh7Or!oa#XR7VA~3yvjS{J# z;aLkAqP}oV2tf~uNKNMHL_@9Z8Mx%S{>+Iuy?8kcu>JrNIYH5q!W)0DqgVF*muD{N zeye>zYG&GyhdFrAi$Pv~9l}1~sTJhp6b4x`7CkBkMpla+?=T?6$;WashYYQUN7M{{ z5(W%*r;F;b{R1d^`*mSr{eWgMZ;=R!JPbImo=0<#ombsc^ZVzc|F7IjZ?60w{q6*} zP$`2y52Du`Z%`KL{&%rvJqo8?{j&oRWL+UiDCj7I8Bi7f0h}Y{-aRWqn?hRY5cv2f z<}OB;rmHW7x4)H6&i$;E!R3tF?Fy3Scq(Q!DsrZk>lep4`5CbVv!;aAX)HK*+%50& zo;O)RYx3@S?&8+_$kGPhJ?7|4^F z!IvKa-kxbLL}Yddu3$gDB_mj6dOnOc_0{$59vt;c*(Byq$lW}l<)EzxSP06gtfk<= z+hXWgMI;(KmUY&9;!O0JXNr@3TygakPg6?myvMf-Vm7*ucWZv&pqKqK#RIMzc_$eg zdKCj)=WP2{032W8iNW8>tc9$10Ma!q;NLmjf8!*~`p1^`Jmn+Vk_Y-(!c6e@l3Xd9 zEH}P^w?uq!+#{>)7rY=U*H`U2mlL-A%fVf{wjUX{N?}?ZI^-P9BtObp=0eNK5Yi9b z@Jh-`b1FKN{9(W0CFBV9o2P%+ob!%ivv_mq4e6d$tl`-*B&%#`^msWdv>>a?XrAi9 z`ob@-w|Pfy%{y@PvEth3sNY@MM@$c7btnVSf5%c{trBuAP(At$t$ueJOtf6C`zc3@ z`m1L#^Dv&8Ov6)4!o;#qHq@?)4u6B-?}MzK!`*tkQwV3zg zrOqemyJnI_oBl`m!$-^*OHxb0hH&IYDj}85pLbb^T)aF`u-ec8eta4D@fIZ|ZvlV- zl1z=2zFukc;wS3Z7gL~zJ0v|qaz^?#C^BDx1f z3?j;>lDEk^;Pu=m-f7V0bJm|H@>pWwK_%7~wtm&d?mkWPH+g0JPA}_ic<5x6M6tqv zI_8j5`X7^9A97xhiMRY=QAqgzwD;xVP`7X2L{Vt$YfNRUY~8YyZ4^S1Hd!VlyNOB2 zs4*k5Z{dzmB>OVC<|NncgWeZ^kb?AN{Dc9c++WE}|KGHHvcX1f zHHcC`fCk2YFiY>o|A3ru83BCb?iJWiPkE+YvEXfn4ZEZ$+VZPcX-n{O9}e$6+W6lQ zApb_XkjX8_f~BFko7h`RExTKPfN^IhrKLH#HFPr4DM6BNc-QbVNh(5DV-{p!!|2yD zpGV#wN7mgs#d(kGsxU40|Gme8@&9+Nc1Q3==r=%glmzrRh3aF! z-D3rQ?N{zqM|%#cri1wBAgSt0<8!N%3|8u=Ji=D6iOT1BQSOfi^N)8K8A7iQ{Vq!X z_PYNrl=!E3>0T7%ciY5yE=CP-7tm+b)K}mi|7!6$XAmc8tP2L zzmh;rR(vEU|JjcM4uA96^1!3jiVS%Nt1F67)f|X2I8BdAs)NJpt8y;W7ixV<$?bQG z69?<6bYFD~noZ=M*<*6Loj3IUpMR-3?AE>i?;l4SM$zVLdgVKq2>i>|LMj7xneZA9 zC#JSz`Oqd*3+wYihLW^P)vqJu`h1>Ewuf5BMg+=n4C+}?L)6}3q((JgxLmFp`sSlR zw&-2nUVhwAUlXROCRfw_@x;+$hd1 zYfBPF+-QcrX-K~q$y%_K{#5t6r7lzBjgbAH%1>gA+k#<}m}N1(uFTY}SW+qUOmLI(s-9lDIx8n8I5iEg+Z;3=n(DI)lzM!ubXED5 z%zKOmN)scBZ3{p!VZ0+?a(w^;ADwV9jSm)4bf!2;cV{Y8ur^EN%H%ZjUm z3gH0C+A->M=Gst4$(IMubrPP(w*=p?wR@7B`~)IV8IRU1nE~3n93k}e7>(B|>v$wt z)D!JTiSBM1`o>eNCmmF7Z{(#g_7yk#FysW&QqHd~UF@E&2~WeH*{gKy#a-p9Ruh)C z?Sx&IN5Rr8gdP>`i!R>8eJIi#h*nX|h@S8EsoT*vU#`~}@ZNnFD=Bq2$~VwGBEGBK z-q1gXuv8$yduUZv*;|Xl8metdD1n8{bL>-x%szZRP}*mBSQ5X06~ah;!AcmYF=5Gs zv%ES#+VtLeEq&_SLPcqAx7B zVdTcpvSqymizKl@RO+R1&A48(TcoUOVrS)35X%#@)Txzjy{rscu)DQedI6A;rwVoe zIP-Uk2@ZkzQ~k7KftAtrt8d%k3k#M;Mq}TP1(<(L=rp@M5SyGuJuq3)!+RJ$?Mg_hFUxm$Agb(70>8PRNe%t1Eh$nLq% zLt}B@Lj1vVFLu0XkE5DXTEKH>$d~5nzU1>OvYkGc;;z+qhw|=3$GOH-a8&cZ>i4--TUf1+C1GIiyA&EWt@-*;kJP8gu7symXGP{=4cJvY z{VSz~r~;jzi)mItDN-yy$7-yvCOotr*Rw?APTPHnw0T^R)h~Bv+~%sO@-fEOJ*Ik1 zX~q$*blAjPNA^A-j?Oh76~W|rNP!5Vb+a~~GG<={BVG;cUCTlskU=y)gvCW;drd?7 zqSzlCt>!8(Pyx5ux7g`9G_l7R9h89@KMLC6D8)#R`g}i8#mZ=uGJ5Bu+xGjKw!X*l z@u8^xz}+fG>&_p$5^sCOfEgEO59#dmG8C*$=E$3kJaU81^Kpa2b=mH+;YaF6zI|7$ zm>AJ7`vP`m%PU1tX>+aFVx#!D?(h3?8d-Ko#l*I@hs#A~gH7DHdDBcUZmv6ivbxVt zl>Na*hwg1bBpqO)8P=Uixr;ENZ`-{OZxDj@%<1DqM=89kjqwp*O(`jqpX+=jUMoP zCxoxgEo2S191N_Cw{e2pVTQ~%Rm#|Qy1${1>U=8CBy#Vg4tPR8TxREz_X~IA+LmDo z)B+&waW5axk=I^|aFiXbXqG^UZrh9p#t_MgZ&2jQc#$;^H*1HED*{(UToO;y5<~<< zOwTe#=AluQ^MD<_q4j|SF0F);BjRQ7hnTlLB4N+_&K*_Xih>ut7e<_Z`rC;GsZWR0 ze42Qjc>glw=HGMYruTp>)jb|GPNrb$<|{@ZDHW+5pG#^!0&<7>kXad;d=Q(_+#$se z+7d_NEJq%j77G+k^V-brDwhX4b>@(1Z7pm}Yh>PXrWJAL+y-?FDQLyV)MSXos8~zP zuptt3I;p~`f7H8p9Av+7LS*y_#E=1t`PRY?ATN^w6VA1p4%KCOsi{@2R|w9M-*b-= zQhH^d6vw`-J;e{wS{ywnx=;>QE0puQ7r~n5Bi24EnbjfrIoek`{+Qk2XNu{{5&22|k*A(xcr{tc|G}squG;BDWV#gR~%Fl{{q8su)mJ9}DGaov6 zZaOe)6;7yB+rdrzC^7gO2@$7ZUA>d9NEckx0^c8D)0h!?CoAyRFPJL;TF<1#SIcEP zV1I3V@Z73RBNI9z){doL!6uNjMExcgA2ftBk5Hv@PUx5Um-PEndCV7XpH`RhfmvOB zu$pj+3>K$%Iq;PNI~DlKNEQ!6tqm)NHUknU*r@x>91K_TY`Q{I8F{gO3qRVZRaCH( z71I7v4jjc+5Kil}=-iL1On~9LA~5Vg>741fMo!FD#HWtZS-ET}06|p~v@bo?$Tlt+ zm_R(P;Bu$)!=XLp*Of{POlVHAGHML2)q9$d$ITMMOdp&BT4xx~XKKdi|33G4m8t6- z7+kEGUwx+fO;hCBD&@8LsvF)>V?wVhg1Y}0*C18^a~h>K|05JjQDy|i>#0$kc-SRa zlVjh_lmnRNtjZr7HCTc9d<12ke*y@%9GU&Vc^*Zx zI1*B3_fBL=l)+ZfF1IbVVp*sbX&{do$`pC}%4vj~CvtK|{4 z)kHZD{|IaM!_a47ns%C^Su)gCkc^Sb^n))L*Df%e9%H1ISw}`HG(q4vD@PyY(Zt6m zh^6}#+fETTenzUxnyT|lgi*vpAtpOd0+S+H2ctT$2bod~sW#TB@j9$9f^~%SZCk~W z^!f5o;TXPb=8~E3;h0IPKrf1+O5e|Y>I0%`2>jdFNY+uHAq0d7mO%J?88{X0RqFV) z{XTiMyzy=6qt_WK)_vVt7;U=qeSspM)2^;3H(4`y_}#DALlPzEhYjrngyx)zc8yS&NFI zjK}!;3eJfMct0Rj7aHL%UDDn!A9r=sIRAOF(%QNq6#a|7^`DDLd}{G?+~3xtF)DyR zHNoP#k18y};K*ynMdMZeX|W-^zDn8+Ql?~>&HtBcd;dXCood5d`R*4ZRQ{iJkW|R5Mq-UMaTE{1ML*| zB|KDAJj018oYy6=ly-6!4tP*84h4f0n>Bm) zA|%eM{zsaBSD;s*WIreUHEQ8JTUpt`15dz)=-INT^Dw$4IM0in^w^fs4Xhg>Xw%fh zzDQa&RuJn)?x3wo?QMzSHS*>BCuj+!uc>dheodNCFei2)5{3)$KsAeogrd}1x00gW zHv-BWkT>xo3!`&~eI6+UdR;OTH@sw4u|&Yqx+3(%=GC}SQ#T0og7jGMr8cCHr0_J5 zm8ux)ep1}$+n8Y5_0ZB<^<%a!eEzx-eZ%|*47f=KQlK}0$kMGc(twMRPDJtmPDXT0 zL?K{waSVN>%zhC@j{GpJ8_@c6jJLcx@HN*}FEzFUy`+ zRV_G?d@e?~rh9VcQyWF(W~g zZR~xBLD~Z03&uR2cd7;Vw5(TM`k>h?i5Y?Mu{41!+d(i9aI_jIlL$Hs`ZwB5#(j`$ zb)9^5F|?S;S4UpAKN=AEIRJKwl3)<#@k$n1GQABNm(2&fpnckT@oi3Upu$sfwJ*N;e>-K)Xeus@D~ktf*7fk!YFzaEoUVM`ZxeCtv+YPP{^em z>wnSsvVI8OM(!ADkTMmJdI7V#6EGtg)WH^V>MyDwmk`5fGm;KzRWUtZ%P30JgU)Xc z2n=jGDDWZe>PJ3~Da;4oRjw$s`5E;-Lodmm!W1aqeQbmwS-noYXb4R$dRC0y95uhg zV@Z$9Cyt<9JG991Zqo2>4pTOJ65gJEun!)(+Kpx9ex=nSGm}fP$e|Y?Hubn;}~JLjAwK< zwiK%i46u)`GtiDC=g_*MSjs3as&)kPPV?Tuz!vWAZ0RvvpoL+e(hG$JBjqPecajgL zit}U}_gS5_1W)$S(p}Ggu+@AhAGzshs?v#jWp4+Eze`QHz3JolLCQhPb|zV7c7sC2 zXV{I3`?~e2%|f*?qfVmDiYT2f>L#Ar&tXD`5OHerusFQh{s{o)kmXP{K0M^}Vok9z zW%vWOZgSefv#R~0kI86}TbhxqvLV!&sj*XtkzyhkTGhFLHkB(3ZHGez_C3Hw##NB# zX1wC1yo9=hYK@KjqByd_K%CL6j@B4t@iYB*Qfc(WIF~~BB1>*mPhO(7;kqbU2?e)p z)S`J(W&Mj5R}-`HE&-K=zu1lCZ^b%vkkM-2Egte+=-m2j(kQAEuk|&*#_PvE@gn}d zQu9P3-oYMx)qM*o7vBAqw7G*bsP=c6ov9w>-i}g)F=fTtaO)AG`})Ncvq@2^zzIE! zt80|Qm)@rmSJjm$Z(f$ zNrA_x?oa-B+u`H=+vaCpLh`WAmBMHhI=Cr-hbaYM8M%GK#&z3zvR_8R`6-3 zaT4C;DYGOl9j`ijx%<{hV(jWSEZK?D65XsahlY({4g)7Z5ui-(bOL>*z6qgP>HX4I z!NS^Ko$X-om5yMsPOHdFXd5&UTZ6_34BZLn4S^9eqaNpJ-CqiuGC}en#LiFq95VI| z*wL>Z3GkW|`6weWM6v?pqgI+|Rx4R30zx|)BaKR1pr|nXM2m0;NmrfGkF>ifws;le z3+6q7&1>J-e{}pR<-~PJA|Go2Yj~dvT^9A3OS7G4Dn6TCwTO#J;XcAkKjit= zZsz(PGm#P}S!}D70k_@qR!}%Puo%P!v@cizgF};xIvS~u3KrA(XbEzbnB={CEAk_+ za6Ma@I(oylEx0x~6x10vT*^6Uz{9*pcj{P=#tP`2niogIUM?F5)Uf8pZ~?taw9=c4 z+N#F2jp0Y76;75GB`bY?S@DAk>9XQPD*%C@L@G9x0M=%B0n0RP$yr<(eA27KwJQxX zBwLO`C$wmvp%$Z!hX`UIKE4jF?M9HQLmIoxC9gSNkb4tYuU^Z8K;3Ka%eE7_K+JY) z=jQ(zan$-}&SnsBycm59__S>RK#4k*xaJ%e%6hbccHMA&c@+xuDdYtN&#~w}=kgWJ ztX_@do!xWJQ-)ASlo~i(LrmhaU2c{pY0Nw{8apa9xuoMW8g!$xmhz z^yl`b0{AL~3buImA9z2cu>&~|WWy~QV)dWAngJdfjrzv2cMXIDqgFu?%=OHzeP9+Z zhsgl38QxPy3Z&LaWuN$yilIB11Sv|+ zWDv!w9UCUvtWZ0uiR1*-doNYt-@K01x4Kd_l3QPcn5zwMy5+(hbfwzS_2#EYj6blS z0eOd|UqT*WO~@Q3sYzMVv0)hb35^D^AM^bW{rxDA{jV}ADh}<-URVp9(b!;feNISZ zcRvJOX=Qw-SP>z>iJ-O}xE0Vt&!H+32c*zv7|$Op10Ee|_OIXro1iSmw7r+;t1~>o zTAQTte!EwL$0YU*s2{<5}^ zeCeAEHZHwlDp*#5w(01ARCUg(opiJps0=Qa;Ld=wR|-x~%QveY9sQ1Txfo^t{JF27 zg=j%>jQN?PHZ!x$z(2{{ye$H#JTPD7NRx0SwxDXe1AL-Ob9(WBxu2i{KNWeh+Ye59gx>gBSGV13BLYam)vO)Lv6xLNQ1 zVl=ok;PHW%8}it*jv!5JuO4v3qjGVUk3q^UUA9TeWf}wi9{FE}FYQY&ys4e2k*v98 zEG*iXB4I4!QhO)H&Sn2X|AHFvJCI8Q>;1>IqXV2c8$tt`oDMLx2G>{d1HxZ^=)7O7 zS(40d5E*;>HeXvSAOGHKv)Q#7wb4<2D?j_z1p7A|A}oQmAKbtGgC)2w0<^cW#OQ?+ z6A1fy+}^YoF@Jo@F)+2IPfB5Y{X4w>gQi}Yh;`@Lfp)?qF?+t;HxRBJM8haA6seYd znHR722D#*;QZFzLX65&7CLNc*%@n0o)o zmRHNvd$sOwqzm-=d_R`T36(L}Gs(O}2VczM6S)4>s7R@{%+{8WtDmzY>VnOOoeQkQKfB&4~@`(S&^@x-~|e zRbZGV>X}dx#HKOAS*nCeChJ?5WQXuWpIc$G$MQ_tx_JM1sl0MF=swcy)vrevH2@j* zz%hccPMi~PemyE>&#;O5mcuvmZY`3e8>033+UrNO%eln(E!?|%H^T&#Pdw>p@;=7V zhSuHY@pP17WM`=ZwVmSzJcEH&tNpU?YWrQ$u!6bZN*zm?DQ8sgp%1RH626Yy&rCmc zW?y#}>YS@eOQvC?GZTwvHGxM(fQ`f0VG_QQb8^N`^-=k(u?te&fz*FLPS!{wP2ATd z^0s)7)Xx;;j|abQuR?J%3{Way4a3+8e)MIB*P|*LPEb!Vmei!}!&9n< z&Dq1|;w7%PxFxQht{}&i^4_p#%$LiNtoka>?c|~!F6dx45f4HehJO8h4v7e51tG?Pf;0F~MT{Z2^heMUM zZ&^jI%?BAFq?@z+OQsDO#h0n)LvGBUQ9Zj~RK)D|tSKF_$v)nE09Arlf|5_s(n|Oi zYBX^-2CVDoxVGh_g?*mBzWZVu+>eHy+jA-5(AldB@kf2OF2u6YlcX?h_NED}qux4s zD6q}Q$_c#gw$5)7C8AyAkPGH5Ghcog#pxU} z&wNGtwi$CDqpKZvpLV%!2p#g$39P$kcMPfKHW2DlW^H0#IB-6ayIqhRdf3g3TFcUB z4bYi^HAAxyAyihY%n|zPl%tp9Vft5c+_or+eEgcvsL)iea5PcF-{`1d@x;OC-Y?)V zOT6NWHRT(?I}3I@9*1{AeSNM2kAbRLBM>=pZ8qe9+-lF@W}DQ7wV&t~#qNz3|4+fN zCLFjO=s$NQzvNJYtvX?oSe7Ua?R(iMLKY=q|4n19p@FaithNWH5zX>mTMbNwIetwD&lh_Fc`ac+tE^0xvLmzU zbn<61XFa^Z*el;NH*>+v`F7#yk=`T^0$7yZ#pSyn)J+da-4!~2U85t)FmU7 zHNW=MEe}8tw_Hhd~w z`@(c&ski;1t%T>KlHx^{4f)%TfD?*Q{pPY?`Cs(Dl@2=gzN*{c__ijCXg|Kv6O`ZK z!|DlkV!U=fhb~Hq726KNsWsCL+u}&l1^r z{4I$GePQG~60mGO>@6cUh!4ikNVRI)+%Rq5(qCzI{(5x(od)0N5wjs9&AumX9T)wI z(q#rvpxV*y{spD?7+p*smj0SxQvigUGsY7Qq<~aQe7cp7P20NjXUEC+?^k{Fg6bPi zHbxnCvusn;om>ik%q)Q(qef4sM5cnf=$qpx4t%Pz!{2n>@S)QeM+Q{Sc*!T%3}0BW zo0&*+cfWnl^52(8qPH1Vn=)g~M=*mya{_fOi81r@p5mxtiSw}Vo9Hl9veh5W5=`|m4?x%TALvcf@Bo!7zA%J8kM zBp@j`wYJO`2|bAxLLSZrC3^5{?yDaH5l>V7)t}miTDObRFx;$;8`ynJ|AJ+0j2@6c zi)Nm3QO%&>V8n(re|x}p)-twte;8T9J+(JMR%oWj@1*5>xt)($Z`*!d%kI@Ma^gUa z!b}cvPRiSUA)S^4MeRch+IP56&CsX(J%k-)!{h@J+_gnV^wc?%QwQo$ib_=y+==>8 z#Fh!L%VH0>TXCamJE8m639<#?{tdesl;MPuK}PiEq`Al{u8&VL*wY26Y%VZUJ@RmE z6AUQnfCatX95_y&Goe>RrVA`Skgg%W^HuQroPMY-rF+s#{=V|sip8l@f1FWh{L9`C zf-8&L`W0!g&~<_?4%cowt9ME{ZA3#%G__UT3hBoi_ui6ssO!Y(PmY@UWs!ix_E9@P zm#9td0-vKaNGy4kl&%#n4N5be&$jQi%otjz11T*Qwptg4h_3Dl`Kdkex>Y6|6P_<2 z5I}ZY#11U@eMm2#0lreT6J_cB@r@plbs(mkwd+Svnu4{Ilofb9-7>9IHR7#;}vbN;iu%i#i= z%`f(!_|y~h=Vvc#$1O0KfpsHTwRKKw%b~>w{c}kMvM4HyccQltg|K3%e0ol5mTE}7 zUDI}}KGH6KgJwqjaqs3up?fi~V1=dsikJQcjxeXu!ow^BltGsfNmY{v4u|r4X3Hk5 z%m^C!6?oTY7BmXJZ}0V5q1uS|oWEOMZgdcY8uS*j;-*G3@RNaTZ&s}Z-ZCuS*%*k` zJ$xt)GmJBOGh+OqzmeWv;?EKUwWU7xvT|yk-0$ZS&4-5_X5I~&OYVDul!fS=0QbDQL6*k290MO zK{SSR=4pN==J1{G|mh8yXYhk;*}YsQbgEg|&1mi*bxT;6YU$ zmke8W6rr&V+ZBDRm z2hI{gMoUITd{c4s2|7==92-lfvyvXFJwtbg!BwVSX$)&z?)R5FS$*x0*Qc)9S#Yv) zSHI(QP9k)#MoUsQqdn^wUoDE6c_QW`2#BX5jp(LN6{v!ZL6mM-x1;uE%jE)c$&ha pk1@E_TWP@;A#&&7ZxsKOt6`({-JATcd&ysS^B++QgZ*Rje*wcfY&rk{ literal 0 HcmV?d00001 diff --git a/docs/za-download/2024/2024Biologie/2024_Biologie/Hinweis_Biologie_Leerfassung.txt b/docs/za-download/2024/2024Biologie/2024_Biologie/Hinweis_Biologie_Leerfassung.txt new file mode 100644 index 0000000..b3ae6cb --- /dev/null +++ b/docs/za-download/2024/2024Biologie/2024_Biologie/Hinweis_Biologie_Leerfassung.txt @@ -0,0 +1,3 @@ +Die EWH enthalten keine urheberrechtlich relevanten Elemente. + +EWHs wurden ergänzt (298.05.2024) \ No newline at end of file diff --git a/docs/za-download/2024/2024Informatik/2024_Informatik/Hinweis_Informatik_Leerfassung.txt b/docs/za-download/2024/2024Informatik/2024_Informatik/Hinweis_Informatik_Leerfassung.txt new file mode 100644 index 0000000..e7579b2 --- /dev/null +++ b/docs/za-download/2024/2024Informatik/2024_Informatik/Hinweis_Informatik_Leerfassung.txt @@ -0,0 +1,2 @@ +in den Informatik-ZA-Aufgaben 2024 sind keine Fremdmaterialien enthalten. Also ist bzgl des Urheberrechts alles geklärt. + diff --git a/docs/zeugnis-system/README.md b/docs/zeugnis-system/README.md new file mode 100644 index 0000000..99eed6a --- /dev/null +++ b/docs/zeugnis-system/README.md @@ -0,0 +1,400 @@ +# Zeugnis-System Dokumentation + +## Übersicht + +Das Zeugnis-System ist ein umfassendes Modul für die Verwaltung und Nutzung von Zeugnisverordnungen aller 16 deutschen Bundesländer. Es besteht aus drei Hauptkomponenten: + +1. **Rights-Aware Crawler** - Automatische Sammlung von Verordnungen +2. **Training Dashboard** - Steuerung und Überwachung von KI-Trainingsprozessen +3. **Lehrer-Frontend** - Intuitive Benutzeroberfläche für Lehrkräfte + +--- + +## Inhaltsverzeichnis + +1. [Architektur](#architektur) +2. [Rights-Aware Crawler](#rights-aware-crawler) +3. [Training Dashboard](#training-dashboard) +4. [Lehrer-Frontend](#lehrer-frontend) +5. [API-Referenz](#api-referenz) +6. [Deployment](#deployment) + +--- + +## Architektur + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Frontend (Next.js) │ +├─────────────────┬─────────────────┬─────────────────────────────┤ +│ /admin/training │ /admin/zeugnis │ /zeugnisse (Lehrer) │ +│ Dashboard │ Crawler Admin │ Assistent │ +└────────┬────────┴────────┬────────┴────────┬────────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ API Proxy (/api/admin/...) │ +└────────────────────────────┬────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ Klausur-Service (FastAPI, Port 8086) │ +├─────────────────┬─────────────────┬─────────────────────────────┤ +│ zeugnis_api.py │ zeugnis_ │ zeugnis_models.py │ +│ (Endpoints) │ crawler.py │ (Datenmodelle) │ +└────────┬────────┴────────┬────────┴────────┬────────────────────┘ + │ │ │ + ▼ ▼ ▼ +┌────────────────┐ ┌───────────────┐ ┌───────────────────────────┐ +│ PostgreSQL │ │ MinIO │ │ Qdrant │ +│ (Metadata) │ │ (Dokumente) │ │ (Vektoren) │ +└────────────────┘ └───────────────┘ └───────────────────────────┘ +``` + +--- + +## Rights-Aware Crawler + +### Konzept + +Der Rights-Aware Crawler sammelt automatisch Zeugnisverordnungen und respektiert dabei die Urheberrechte: + +- **Training erlaubt**: Amtliche Werke nach §5 UrhG +- **Training nicht erlaubt**: Dokumente ohne explizite Lizenzierung + +### Bundesländer-Berechtigungen + +| Bundesland | Training | Begründung | +|------------|----------|------------| +| Baden-Württemberg | ✅ Ja | Amtliches Werk | +| Bayern | ✅ Ja | Amtliches Werk | +| Berlin | ❌ Nein | Keine Lizenz | +| Brandenburg | ❌ Nein | Keine Lizenz | +| Bremen | ❌ Nein | Eingeschränkt | +| Hamburg | ❌ Nein | Keine Lizenz | +| Hessen | ✅ Ja | Amtliches Werk | +| Mecklenburg-Vorpommern | ❌ Nein | Eingeschränkt | +| Niedersachsen | ✅ Ja | Amtliches Werk | +| Nordrhein-Westfalen | ✅ Ja | Amtliches Werk | +| Rheinland-Pfalz | ✅ Ja | Amtliches Werk | +| Saarland | ❌ Nein | Keine Lizenz | +| Sachsen | ✅ Ja | Amtliches Werk | +| Sachsen-Anhalt | ❌ Nein | Eingeschränkt | +| Schleswig-Holstein | ✅ Ja | Amtliches Werk | +| Thüringen | ✅ Ja | Amtliches Werk | + +### Admin-Oberfläche + +**URL**: `/admin/zeugnisse-crawler` + +#### Features: +- Übersicht aller 16 Bundesländer +- Training-Status pro Bundesland +- Crawler starten/stoppen +- Dokument-Browser +- Audit-Trail + +#### Screenshot-Bereiche: +1. **Stats-Karten**: Gesamtdokumente, indexierte Dokumente, Training-erlaubt +2. **Bundesland-Tabelle**: Status, Dokumentanzahl, letzter Crawl +3. **Dokument-Liste**: Filterbar, mit Statusanzeige + +### API-Endpunkte + +``` +GET /api/v1/admin/zeugnis/sources # Alle Bundesländer +POST /api/v1/admin/zeugnis/sources # Neue Quelle +PUT /api/v1/admin/zeugnis/sources/{id}/verify # Lizenz verifizieren + +GET /api/v1/admin/zeugnis/crawler/status # Crawler-Status +POST /api/v1/admin/zeugnis/crawler/start # Crawler starten +POST /api/v1/admin/zeugnis/crawler/stop # Crawler stoppen + +GET /api/v1/admin/zeugnis/documents # Dokumente abrufen +GET /api/v1/admin/zeugnis/stats # Statistiken +GET /api/v1/admin/zeugnis/audit/events # Audit-Trail +``` + +--- + +## Training Dashboard + +### Konzept + +Das Training Dashboard ermöglicht die vollständige Kontrolle über KI-Trainingsprozesse: + +- Echtzeit-Monitoring von Trainingsjobs +- Konfiguration von Hyperparametern +- Visualisierung von Metriken (Loss, Precision, Recall, F1) +- Multi-Bundesland Training + +**URL**: `/admin/training` + +### Features + +#### 1. Übersichtskarten +- Dokumente gesamt / indexiert +- Training-erlaubte Dokumente +- Heute gecrawlt / Fehler + +#### 2. Training-Job-Karten +Jeder laufende Job zeigt: +- **Progress Ring**: Visueller Fortschritt (0-100%) +- **Epochen-Fortschritt**: Aktuelle / Gesamt +- **Dokument-Fortschritt**: Verarbeitet / Gesamt +- **Metriken**: Loss, Val Loss, Precision, F1 +- **Loss-Chart**: Verlaufsgrafik + +#### 3. Datensatz-Übersicht +- Verteilung nach Bundesland (Balkendiagramm) +- Verteilung nach Dokumenttyp +- Training-erlaubte Quote + +#### 4. Neues Training starten (Wizard) + +**Schritt 1 - Datenauswahl:** +- Checkbox-Grid für Bundesländer +- Nur Training-erlaubte wählbar +- Deaktivierte Anzeige für nicht-erlaubte + +**Schritt 2 - Parameter:** +- Batch Size (Standard: 16) +- Learning Rate (Standard: 0.00005) +- Epochen (Standard: 10) +- Warmup Steps (Standard: 500) +- Mixed Precision (FP16) Toggle + +**Schritt 3 - Bestätigung:** +- Zusammenfassung aller Einstellungen +- Geschätzte Trainingszeit +- Start-Button + +### Training-Kontrolle + +| Aktion | Beschreibung | +|--------|--------------| +| Pausieren | Training temporär unterbrechen | +| Fortsetzen | Pausiertes Training fortsetzen | +| Abbrechen | Training beenden (Daten bleiben) | +| Details | Erweiterte Metriken anzeigen | + +--- + +## Lehrer-Frontend + +### Konzept + +Das Lehrer-Frontend bietet eine intuitive Oberfläche für Lehrkräfte zur Recherche in Zeugnisverordnungen. + +**URL**: `/zeugnisse` + +### Onboarding-Wizard + +Beim ersten Besuch führt ein 4-stufiger Wizard durch die Einrichtung: + +#### Schritt 1: Willkommen +- Vorstellung des Assistenten +- Feature-Übersicht (Suche, KI-Antworten, 16 Bundesländer) + +#### Schritt 2: Bundesland +- Grid mit allen 16 Bundesländern +- Emoji-Icons für visuelle Zuordnung +- Auswahl speichert bevorzugtes Bundesland + +#### Schritt 3: Schulform +- Auswahl der Schulform: + - Grundschule + - Hauptschule + - Realschule + - Gymnasium + - Gesamtschule + - Förderschule + - Berufsschule + +#### Schritt 4: Fertig +- Zusammenfassung der Einstellungen +- Option zum Ändern + +### Hauptfunktionen + +#### Tab 1: Assistent (Chat) +- KI-gestützter Dialog +- Fragen in natürlicher Sprache +- Quellenangaben zu Antworten +- Vorgeschlagene Fragen + +#### Tab 2: Suche +- Volltextsuche in Verordnungen +- Letzte Suchen (gespeichert) +- Häufige Fragen als Schnellauswahl +- Relevanz-Score pro Ergebnis + +#### Tab 3: Dokumente +- Dokumenten-Browser +- Filterbar nach Bundesland +- Download-Möglichkeit + +### Häufige Fragen (vordefiniert) + +1. "Wie formuliere ich eine Bemerkung zur Arbeits- und Sozialverhalten?" +2. "Welche Noten dürfen im Zeugnis stehen?" +3. "Wann sind Zeugniskonferenzen durchzuführen?" +4. "Wie gehe ich mit Fehlzeiten um?" +5. "Welche Unterschriften sind erforderlich?" +6. "Wie werden Versetzungsentscheidungen dokumentiert?" + +### Benutzereinstellungen + +Gespeichert im localStorage: +- Bundesland +- Schulform +- Wizard-Status +- Favoriten +- Letzte Suchen + +--- + +## API-Referenz + +### Zeugnis-Crawler API + +#### GET /api/v1/admin/zeugnis/sources +Listet alle Bundesland-Quellen. + +**Response:** +```json +[ + { + "id": "uuid", + "bundesland": "ni", + "name": "Niedersachsen", + "license_type": "gov_statute", + "training_allowed": true, + "verified_by": "admin@example.com", + "verified_at": "2024-01-15T10:00:00Z" + } +] +``` + +#### POST /api/v1/admin/zeugnis/crawler/start +Startet den Crawler. + +**Request:** +```json +{ + "bundesland": "ni", // Optional: Nur dieses Bundesland + "priority": 5 // 1-10, Standard: 5 +} +``` + +**Response:** +```json +{ + "success": true, + "message": "Crawler started" +} +``` + +#### GET /api/v1/admin/zeugnis/stats +Holt Statistiken. + +**Response:** +```json +{ + "total_sources": 16, + "total_documents": 632, + "indexed_documents": 489, + "training_allowed_documents": 423, + "active_crawls": 1, + "per_bundesland": [...] +} +``` + +--- + +## Deployment + +### Voraussetzungen + +- Docker & Docker Compose +- PostgreSQL 16+ +- Qdrant 1.12+ +- MinIO + +### Umgebungsvariablen + +```env +# Klausur-Service +QDRANT_URL=http://qdrant:6333 +MINIO_ENDPOINT=minio:9000 +MINIO_ACCESS_KEY=breakpilot +MINIO_SECRET_KEY=breakpilot123 +MINIO_BUCKET=breakpilot-rag +EMBEDDING_BACKEND=local # oder "openai" + +# Website +KLAUSUR_SERVICE_URL=http://klausur-service:8086 +``` + +### Docker Compose + +```yaml +services: + qdrant: + image: qdrant/qdrant:v1.12.1 + + minio: + image: minio/minio:latest + + klausur-service: + build: ./klausur-service + depends_on: + - qdrant + - minio +``` + +### Initialisierung + +1. Container starten: + ```bash + docker-compose up -d + ``` + +2. Datenbank initialisieren: + ```bash + curl -X POST http://localhost:8086/api/v1/admin/zeugnis/init + ``` + +3. Crawler starten: + ```bash + curl -X POST http://localhost:8086/api/v1/admin/zeugnis/crawler/start + ``` + +4. Frontend öffnen: + - Admin: http://localhost:3000/admin/zeugnisse-crawler + - Training: http://localhost:3000/admin/training + - Lehrer: http://localhost:3000/zeugnisse + +--- + +## Sicherheit + +### Datenschutz + +- **Audit-Trail**: Alle Zugriffe werden protokolliert +- **DSGVO-Export**: `/api/v1/admin/zeugnis/audit/export` +- **Verschlüsselung**: Sensible Daten in MinIO verschlüsselt + +### Rights Management + +- Training nur mit expliziter Erlaubnis +- Lizenz-Verifizierung durch Admin +- Quellen-Nachverfolgung für alle Dokumente + +--- + +## Support + +Bei Fragen oder Problemen: +- GitHub Issues: https://github.com/... +- E-Mail: support@breakpilot.app diff --git a/dsms-gateway/Dockerfile b/dsms-gateway/Dockerfile new file mode 100644 index 0000000..5ac37dc --- /dev/null +++ b/dsms-gateway/Dockerfile @@ -0,0 +1,32 @@ +# DSMS Gateway - REST API für dezentrales Speichersystem +FROM python:3.11-slim + +LABEL maintainer="BreakPilot " +LABEL description="DSMS Gateway - REST API wrapper for IPFS" + +WORKDIR /app + +# Install curl for healthcheck and dependencies +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application +COPY main.py . + +# Environment variables +ENV IPFS_API_URL=http://dsms-node:5001 +ENV IPFS_GATEWAY_URL=http://dsms-node:8080 +ENV PORT=8082 + +# Expose port +EXPOSE 8082 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8082/health || exit 1 + +# Run application +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8082"] diff --git a/dsms-gateway/main.py b/dsms-gateway/main.py new file mode 100644 index 0000000..0a2a390 --- /dev/null +++ b/dsms-gateway/main.py @@ -0,0 +1,467 @@ +""" +DSMS Gateway - REST API für dezentrales Speichersystem +Bietet eine vereinfachte API über IPFS für BreakPilot +""" + +import os +import json +import httpx +import hashlib +from datetime import datetime +from typing import Optional +from fastapi import FastAPI, HTTPException, UploadFile, File, Header, Depends +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +import io + +app = FastAPI( + title="DSMS Gateway", + description="Dezentrales Daten Speicher System Gateway für BreakPilot", + version="1.0.0" +) + +# CORS Configuration +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:8000", "http://backend:8000", "*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Configuration +IPFS_API_URL = os.getenv("IPFS_API_URL", "http://dsms-node:5001") +IPFS_GATEWAY_URL = os.getenv("IPFS_GATEWAY_URL", "http://dsms-node:8080") +JWT_SECRET = os.getenv("JWT_SECRET", "your-super-secret-jwt-key-change-in-production") + + +# Models +class DocumentMetadata(BaseModel): + """Metadaten für gespeicherte Dokumente""" + document_type: str # 'legal_document', 'consent_record', 'audit_log' + document_id: Optional[str] = None + version: Optional[str] = None + language: Optional[str] = "de" + created_at: Optional[str] = None + checksum: Optional[str] = None + encrypted: bool = False + + +class StoredDocument(BaseModel): + """Antwort nach erfolgreichem Speichern""" + cid: str # Content Identifier (IPFS Hash) + size: int + metadata: DocumentMetadata + gateway_url: str + timestamp: str + + +class DocumentList(BaseModel): + """Liste der gespeicherten Dokumente""" + documents: list + total: int + + +# Helper Functions +async def verify_token(authorization: Optional[str] = Header(None)) -> dict: + """Verifiziert JWT Token (vereinfacht für MVP)""" + if not authorization: + raise HTTPException(status_code=401, detail="Authorization header fehlt") + + # In Produktion: JWT validieren + # Für MVP: Einfache Token-Prüfung + if not authorization.startswith("Bearer "): + raise HTTPException(status_code=401, detail="Ungültiges Token-Format") + + return {"valid": True} + + +async def ipfs_add(content: bytes, pin: bool = True) -> dict: + """Fügt Inhalt zu IPFS hinzu""" + async with httpx.AsyncClient(timeout=60.0) as client: + files = {"file": ("document", content)} + params = {"pin": str(pin).lower()} + + response = await client.post( + f"{IPFS_API_URL}/api/v0/add", + files=files, + params=params + ) + + if response.status_code != 200: + raise HTTPException( + status_code=502, + detail=f"IPFS Fehler: {response.text}" + ) + + return response.json() + + +async def ipfs_cat(cid: str) -> bytes: + """Liest Inhalt von IPFS""" + async with httpx.AsyncClient(timeout=60.0) as client: + response = await client.post( + f"{IPFS_API_URL}/api/v0/cat", + params={"arg": cid} + ) + + if response.status_code != 200: + raise HTTPException( + status_code=404, + detail=f"Dokument nicht gefunden: {cid}" + ) + + return response.content + + +async def ipfs_pin_ls() -> list: + """Listet alle gepinnten Objekte""" + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{IPFS_API_URL}/api/v0/pin/ls", + params={"type": "recursive"} + ) + + if response.status_code != 200: + return [] + + data = response.json() + return list(data.get("Keys", {}).keys()) + + +# API Endpoints +@app.get("/health") +async def health_check(): + """Health Check für DSMS Gateway""" + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.post(f"{IPFS_API_URL}/api/v0/id") + ipfs_status = response.status_code == 200 + except Exception: + ipfs_status = False + + return { + "status": "healthy" if ipfs_status else "degraded", + "ipfs_connected": ipfs_status, + "timestamp": datetime.utcnow().isoformat() + } + + +@app.post("/api/v1/documents", response_model=StoredDocument) +async def store_document( + file: UploadFile = File(...), + document_type: str = "legal_document", + document_id: Optional[str] = None, + version: Optional[str] = None, + language: str = "de", + _auth: dict = Depends(verify_token) +): + """ + Speichert ein Dokument im DSMS. + + - **file**: Das zu speichernde Dokument + - **document_type**: Typ des Dokuments (legal_document, consent_record, audit_log) + - **document_id**: Optionale ID des Dokuments + - **version**: Optionale Versionsnummer + - **language**: Sprache (default: de) + """ + content = await file.read() + + # Checksum berechnen + checksum = hashlib.sha256(content).hexdigest() + + # Metadaten erstellen + metadata = DocumentMetadata( + document_type=document_type, + document_id=document_id, + version=version, + language=language, + created_at=datetime.utcnow().isoformat(), + checksum=checksum, + encrypted=False + ) + + # Dokument mit Metadaten als JSON verpacken + package = { + "metadata": metadata.model_dump(), + "content_base64": content.hex(), # Hex-encodiert für JSON + "filename": file.filename + } + + package_bytes = json.dumps(package).encode() + + # Zu IPFS hinzufügen + result = await ipfs_add(package_bytes) + + cid = result.get("Hash") + size = int(result.get("Size", 0)) + + return StoredDocument( + cid=cid, + size=size, + metadata=metadata, + gateway_url=f"{IPFS_GATEWAY_URL}/ipfs/{cid}", + timestamp=datetime.utcnow().isoformat() + ) + + +@app.get("/api/v1/documents/{cid}") +async def get_document( + cid: str, + _auth: dict = Depends(verify_token) +): + """ + Ruft ein Dokument aus dem DSMS ab. + + - **cid**: Content Identifier (IPFS Hash) + """ + content = await ipfs_cat(cid) + + try: + package = json.loads(content) + metadata = package.get("metadata", {}) + original_content = bytes.fromhex(package.get("content_base64", "")) + filename = package.get("filename", "document") + + return StreamingResponse( + io.BytesIO(original_content), + media_type="application/octet-stream", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"', + "X-DSMS-Document-Type": metadata.get("document_type", "unknown"), + "X-DSMS-Checksum": metadata.get("checksum", ""), + "X-DSMS-Created-At": metadata.get("created_at", "") + } + ) + except json.JSONDecodeError: + # Wenn es kein DSMS-Paket ist, gib rohen Inhalt zurück + return StreamingResponse( + io.BytesIO(content), + media_type="application/octet-stream" + ) + + +@app.get("/api/v1/documents/{cid}/metadata") +async def get_document_metadata( + cid: str, + _auth: dict = Depends(verify_token) +): + """ + Ruft nur die Metadaten eines Dokuments ab. + + - **cid**: Content Identifier (IPFS Hash) + """ + content = await ipfs_cat(cid) + + try: + package = json.loads(content) + return { + "cid": cid, + "metadata": package.get("metadata", {}), + "filename": package.get("filename"), + "size": len(bytes.fromhex(package.get("content_base64", ""))) + } + except json.JSONDecodeError: + return { + "cid": cid, + "metadata": {}, + "raw_size": len(content) + } + + +@app.get("/api/v1/documents", response_model=DocumentList) +async def list_documents( + _auth: dict = Depends(verify_token) +): + """ + Listet alle gespeicherten Dokumente auf. + """ + cids = await ipfs_pin_ls() + + documents = [] + for cid in cids[:100]: # Limit auf 100 für Performance + try: + content = await ipfs_cat(cid) + package = json.loads(content) + documents.append({ + "cid": cid, + "metadata": package.get("metadata", {}), + "filename": package.get("filename") + }) + except Exception: + # Überspringe nicht-DSMS Objekte + continue + + return DocumentList( + documents=documents, + total=len(documents) + ) + + +@app.delete("/api/v1/documents/{cid}") +async def unpin_document( + cid: str, + _auth: dict = Depends(verify_token) +): + """ + Entfernt ein Dokument aus dem lokalen Pin-Set. + Das Dokument bleibt im Netzwerk, wird aber bei GC entfernt. + + - **cid**: Content Identifier (IPFS Hash) + """ + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + f"{IPFS_API_URL}/api/v0/pin/rm", + params={"arg": cid} + ) + + if response.status_code != 200: + raise HTTPException( + status_code=404, + detail=f"Konnte Pin nicht entfernen: {cid}" + ) + + return { + "status": "unpinned", + "cid": cid, + "message": "Dokument wird bei nächster Garbage Collection entfernt" + } + + +@app.post("/api/v1/legal-documents/archive") +async def archive_legal_document( + document_id: str, + version: str, + content: str, + language: str = "de", + _auth: dict = Depends(verify_token) +): + """ + Archiviert eine rechtliche Dokumentversion dauerhaft. + Speziell für AGB, Datenschutzerklärung, etc. + + - **document_id**: ID des Legal Documents + - **version**: Versionsnummer + - **content**: HTML/Markdown Inhalt + - **language**: Sprache + """ + # Checksum berechnen + content_bytes = content.encode('utf-8') + checksum = hashlib.sha256(content_bytes).hexdigest() + + # Metadaten + metadata = { + "document_type": "legal_document", + "document_id": document_id, + "version": version, + "language": language, + "created_at": datetime.utcnow().isoformat(), + "checksum": checksum, + "content_type": "text/html" + } + + # Paket erstellen + package = { + "metadata": metadata, + "content": content, + "archived_at": datetime.utcnow().isoformat() + } + + package_bytes = json.dumps(package, ensure_ascii=False).encode('utf-8') + + # Zu IPFS hinzufügen + result = await ipfs_add(package_bytes) + + cid = result.get("Hash") + + return { + "cid": cid, + "document_id": document_id, + "version": version, + "checksum": checksum, + "archived_at": datetime.utcnow().isoformat(), + "verification_url": f"{IPFS_GATEWAY_URL}/ipfs/{cid}" + } + + +@app.get("/api/v1/verify/{cid}") +async def verify_document(cid: str): + """ + Verifiziert die Integrität eines Dokuments. + Öffentlich zugänglich für Audit-Zwecke. + + - **cid**: Content Identifier (IPFS Hash) + """ + try: + content = await ipfs_cat(cid) + package = json.loads(content) + + # Checksum verifizieren + stored_checksum = package.get("metadata", {}).get("checksum") + + if "content_base64" in package: + original_content = bytes.fromhex(package["content_base64"]) + calculated_checksum = hashlib.sha256(original_content).hexdigest() + elif "content" in package: + calculated_checksum = hashlib.sha256( + package["content"].encode('utf-8') + ).hexdigest() + else: + calculated_checksum = None + + integrity_valid = ( + stored_checksum == calculated_checksum + if stored_checksum and calculated_checksum + else None + ) + + return { + "cid": cid, + "exists": True, + "integrity_valid": integrity_valid, + "metadata": package.get("metadata", {}), + "stored_checksum": stored_checksum, + "calculated_checksum": calculated_checksum, + "verified_at": datetime.utcnow().isoformat() + } + except Exception as e: + return { + "cid": cid, + "exists": False, + "error": str(e), + "verified_at": datetime.utcnow().isoformat() + } + + +@app.get("/api/v1/node/info") +async def get_node_info(): + """ + Gibt Informationen über den DSMS Node zurück. + """ + try: + async with httpx.AsyncClient(timeout=10.0) as client: + # Node ID + id_response = await client.post(f"{IPFS_API_URL}/api/v0/id") + node_info = id_response.json() if id_response.status_code == 200 else {} + + # Repo Stats + stat_response = await client.post(f"{IPFS_API_URL}/api/v0/repo/stat") + repo_stats = stat_response.json() if stat_response.status_code == 200 else {} + + return { + "node_id": node_info.get("ID"), + "protocol_version": node_info.get("ProtocolVersion"), + "agent_version": node_info.get("AgentVersion"), + "repo_size": repo_stats.get("RepoSize"), + "storage_max": repo_stats.get("StorageMax"), + "num_objects": repo_stats.get("NumObjects"), + "addresses": node_info.get("Addresses", [])[:5] # Erste 5 + } + except Exception as e: + return {"error": str(e)} + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8082) diff --git a/dsms-gateway/requirements.txt b/dsms-gateway/requirements.txt new file mode 100644 index 0000000..0c13cca --- /dev/null +++ b/dsms-gateway/requirements.txt @@ -0,0 +1,9 @@ +fastapi>=0.104.0 +uvicorn>=0.24.0 +httpx>=0.25.0 +pydantic>=2.5.0 +python-multipart>=0.0.6 + +# Testing +pytest>=7.4.0 +pytest-asyncio>=0.21.0 diff --git a/dsms-gateway/test_main.py b/dsms-gateway/test_main.py new file mode 100644 index 0000000..8a40705 --- /dev/null +++ b/dsms-gateway/test_main.py @@ -0,0 +1,612 @@ +""" +Unit Tests für DSMS Gateway +Tests für alle API-Endpoints und Hilfsfunktionen +""" + +import pytest +import hashlib +import json +from unittest.mock import AsyncMock, patch, MagicMock +from fastapi.testclient import TestClient +from httpx import Response + +# Import der App +from main import app, DocumentMetadata, StoredDocument, DocumentList + + +# Test Client +client = TestClient(app) + + +# ==================== Fixtures ==================== + +@pytest.fixture +def valid_auth_header(): + """Gültiger Authorization Header für Tests""" + return {"Authorization": "Bearer test-token-12345"} + + +@pytest.fixture +def sample_document_metadata(): + """Beispiel-Metadaten für Tests""" + return DocumentMetadata( + document_type="legal_document", + document_id="doc-123", + version="1.0", + language="de", + created_at="2024-01-01T00:00:00", + checksum="abc123", + encrypted=False + ) + + +@pytest.fixture +def mock_ipfs_response(): + """Mock-Antwort von IPFS add""" + return { + "Hash": "QmTest1234567890abcdef", + "Size": "1024" + } + + +# ==================== Health Check Tests ==================== + +class TestHealthCheck: + """Tests für den Health Check Endpoint""" + + def test_health_check_ipfs_connected(self): + """Test: Health Check wenn IPFS verbunden ist""" + with patch("main.httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.post.return_value = MagicMock(status_code=200) + mock_client.return_value.__aenter__.return_value = mock_instance + + response = client.get("/health") + + assert response.status_code == 200 + data = response.json() + assert "status" in data + assert "ipfs_connected" in data + assert "timestamp" in data + + def test_health_check_ipfs_disconnected(self): + """Test: Health Check wenn IPFS nicht erreichbar""" + with patch("main.httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.post.side_effect = Exception("Connection failed") + mock_client.return_value.__aenter__.return_value = mock_instance + + response = client.get("/health") + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "degraded" + assert data["ipfs_connected"] is False + + +# ==================== Authorization Tests ==================== + +class TestAuthorization: + """Tests für die Autorisierung""" + + def test_documents_endpoint_without_auth_returns_401(self): + """Test: Dokument-Endpoint ohne Auth gibt 401 zurück""" + response = client.get("/api/v1/documents") + assert response.status_code == 401 + + def test_documents_endpoint_with_invalid_token_returns_401(self): + """Test: Ungültiges Token-Format gibt 401 zurück""" + response = client.get( + "/api/v1/documents", + headers={"Authorization": "InvalidFormat"} + ) + assert response.status_code == 401 + + def test_documents_endpoint_with_valid_token_format(self, valid_auth_header): + """Test: Gültiges Token-Format wird akzeptiert""" + with patch("main.ipfs_pin_ls", new_callable=AsyncMock) as mock_pin_ls: + mock_pin_ls.return_value = [] + + response = client.get( + "/api/v1/documents", + headers=valid_auth_header + ) + + assert response.status_code == 200 + + +# ==================== Document Storage Tests ==================== + +class TestDocumentStorage: + """Tests für das Speichern von Dokumenten""" + + def test_store_document_success(self, valid_auth_header, mock_ipfs_response): + """Test: Dokument erfolgreich speichern""" + with patch("main.ipfs_add", new_callable=AsyncMock) as mock_add: + mock_add.return_value = mock_ipfs_response + + test_content = b"Test document content" + + response = client.post( + "/api/v1/documents", + headers=valid_auth_header, + files={"file": ("test.txt", test_content, "text/plain")}, + data={ + "document_type": "legal_document", + "document_id": "doc-123", + "version": "1.0", + "language": "de" + } + ) + + assert response.status_code == 200 + data = response.json() + assert "cid" in data + assert data["cid"] == "QmTest1234567890abcdef" + assert "metadata" in data + assert "gateway_url" in data + + def test_store_document_calculates_checksum(self, valid_auth_header, mock_ipfs_response): + """Test: Checksum wird korrekt berechnet""" + with patch("main.ipfs_add", new_callable=AsyncMock) as mock_add: + mock_add.return_value = mock_ipfs_response + + test_content = b"Test content for checksum" + expected_checksum = hashlib.sha256(test_content).hexdigest() + + response = client.post( + "/api/v1/documents", + headers=valid_auth_header, + files={"file": ("test.txt", test_content, "text/plain")} + ) + + assert response.status_code == 200 + data = response.json() + assert data["metadata"]["checksum"] == expected_checksum + + def test_store_document_without_file_returns_422(self, valid_auth_header): + """Test: Fehlende Datei gibt 422 zurück""" + response = client.post( + "/api/v1/documents", + headers=valid_auth_header + ) + + assert response.status_code == 422 + + +# ==================== Document Retrieval Tests ==================== + +class TestDocumentRetrieval: + """Tests für das Abrufen von Dokumenten""" + + def test_get_document_success(self, valid_auth_header): + """Test: Dokument erfolgreich abrufen""" + test_content = b"Original content" + package = { + "metadata": { + "document_type": "legal_document", + "checksum": hashlib.sha256(test_content).hexdigest() + }, + "content_base64": test_content.hex(), + "filename": "test.txt" + } + + with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + mock_cat.return_value = json.dumps(package).encode() + + response = client.get( + "/api/v1/documents/QmTestCid123", + headers=valid_auth_header + ) + + assert response.status_code == 200 + assert response.content == test_content + + def test_get_document_not_found(self, valid_auth_header): + """Test: Nicht existierendes Dokument gibt 404 zurück""" + with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + from fastapi import HTTPException + mock_cat.side_effect = HTTPException(status_code=404, detail="Not found") + + response = client.get( + "/api/v1/documents/QmNonExistent", + headers=valid_auth_header + ) + + assert response.status_code == 404 + + def test_get_document_metadata_success(self, valid_auth_header): + """Test: Dokument-Metadaten abrufen""" + test_content = b"Content" + package = { + "metadata": { + "document_type": "legal_document", + "document_id": "doc-123", + "version": "1.0" + }, + "content_base64": test_content.hex(), + "filename": "test.txt" + } + + with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + mock_cat.return_value = json.dumps(package).encode() + + response = client.get( + "/api/v1/documents/QmTestCid123/metadata", + headers=valid_auth_header + ) + + assert response.status_code == 200 + data = response.json() + assert data["cid"] == "QmTestCid123" + assert data["metadata"]["document_type"] == "legal_document" + + +# ==================== Document List Tests ==================== + +class TestDocumentList: + """Tests für das Auflisten von Dokumenten""" + + def test_list_documents_empty(self, valid_auth_header): + """Test: Leere Dokumentenliste""" + with patch("main.ipfs_pin_ls", new_callable=AsyncMock) as mock_pin_ls: + mock_pin_ls.return_value = [] + + response = client.get( + "/api/v1/documents", + headers=valid_auth_header + ) + + assert response.status_code == 200 + data = response.json() + assert data["documents"] == [] + assert data["total"] == 0 + + def test_list_documents_with_items(self, valid_auth_header): + """Test: Dokumentenliste mit Einträgen""" + package = { + "metadata": {"document_type": "legal_document"}, + "content_base64": "68656c6c6f", + "filename": "test.txt" + } + + with patch("main.ipfs_pin_ls", new_callable=AsyncMock) as mock_pin_ls: + mock_pin_ls.return_value = ["QmCid1", "QmCid2"] + + with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + mock_cat.return_value = json.dumps(package).encode() + + response = client.get( + "/api/v1/documents", + headers=valid_auth_header + ) + + assert response.status_code == 200 + data = response.json() + assert data["total"] == 2 + + +# ==================== Document Deletion Tests ==================== + +class TestDocumentDeletion: + """Tests für das Löschen von Dokumenten""" + + def test_unpin_document_success(self, valid_auth_header): + """Test: Dokument erfolgreich unpinnen""" + with patch("main.httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.post.return_value = MagicMock(status_code=200) + mock_client.return_value.__aenter__.return_value = mock_instance + + response = client.delete( + "/api/v1/documents/QmTestCid123", + headers=valid_auth_header + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "unpinned" + assert data["cid"] == "QmTestCid123" + + def test_unpin_document_not_found(self, valid_auth_header): + """Test: Nicht existierendes Dokument unpinnen""" + with patch("main.httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.post.return_value = MagicMock(status_code=404) + mock_client.return_value.__aenter__.return_value = mock_instance + + response = client.delete( + "/api/v1/documents/QmNonExistent", + headers=valid_auth_header + ) + + assert response.status_code == 404 + + +# ==================== Legal Document Archive Tests ==================== + +class TestLegalDocumentArchive: + """Tests für die Legal Document Archivierung""" + + def test_archive_legal_document_success(self, valid_auth_header, mock_ipfs_response): + """Test: Legal Document erfolgreich archivieren""" + with patch("main.ipfs_add", new_callable=AsyncMock) as mock_add: + mock_add.return_value = mock_ipfs_response + + response = client.post( + "/api/v1/legal-documents/archive", + headers=valid_auth_header, + params={ + "document_id": "privacy-policy", + "version": "2.0", + "content": "

              Datenschutzerklärung

              ", + "language": "de" + } + ) + + assert response.status_code == 200 + data = response.json() + assert "cid" in data + assert data["document_id"] == "privacy-policy" + assert data["version"] == "2.0" + assert "checksum" in data + assert "archived_at" in data + + def test_archive_legal_document_calculates_checksum(self, valid_auth_header, mock_ipfs_response): + """Test: Checksum für HTML-Inhalt korrekt berechnet""" + content = "

              Test Content

              " + expected_checksum = hashlib.sha256(content.encode('utf-8')).hexdigest() + + with patch("main.ipfs_add", new_callable=AsyncMock) as mock_add: + mock_add.return_value = mock_ipfs_response + + response = client.post( + "/api/v1/legal-documents/archive", + headers=valid_auth_header, + params={ + "document_id": "terms", + "version": "1.0", + "content": content + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["checksum"] == expected_checksum + + +# ==================== Document Verification Tests ==================== + +class TestDocumentVerification: + """Tests für die Dokumenten-Verifizierung""" + + def test_verify_document_integrity_valid(self): + """Test: Dokument mit gültiger Integrität""" + content = "Test content" + checksum = hashlib.sha256(content.encode('utf-8')).hexdigest() + + package = { + "metadata": { + "document_type": "legal_document", + "checksum": checksum + }, + "content": content + } + + with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + mock_cat.return_value = json.dumps(package).encode() + + response = client.get("/api/v1/verify/QmTestCid123") + + assert response.status_code == 200 + data = response.json() + assert data["exists"] is True + assert data["integrity_valid"] is True + assert data["stored_checksum"] == checksum + assert data["calculated_checksum"] == checksum + + def test_verify_document_integrity_invalid(self): + """Test: Dokument mit ungültiger Integrität (manipuliert)""" + package = { + "metadata": { + "document_type": "legal_document", + "checksum": "fake_checksum_12345" + }, + "content": "Actual content" + } + + with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + mock_cat.return_value = json.dumps(package).encode() + + response = client.get("/api/v1/verify/QmTestCid123") + + assert response.status_code == 200 + data = response.json() + assert data["exists"] is True + assert data["integrity_valid"] is False + + def test_verify_document_not_found(self): + """Test: Nicht existierendes Dokument verifizieren""" + with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + mock_cat.side_effect = Exception("Not found") + + response = client.get("/api/v1/verify/QmNonExistent") + + assert response.status_code == 200 + data = response.json() + assert data["exists"] is False + assert "error" in data + + def test_verify_document_public_access(self): + """Test: Verifizierung ist öffentlich zugänglich (keine Auth)""" + package = { + "metadata": {"checksum": "abc"}, + "content": "test" + } + + with patch("main.ipfs_cat", new_callable=AsyncMock) as mock_cat: + mock_cat.return_value = json.dumps(package).encode() + + # Kein Authorization Header! + response = client.get("/api/v1/verify/QmTestCid123") + + assert response.status_code == 200 + + +# ==================== Node Info Tests ==================== + +class TestNodeInfo: + """Tests für Node-Informationen""" + + def test_get_node_info_success(self): + """Test: Node-Informationen abrufen""" + id_response = { + "ID": "QmNodeId12345", + "ProtocolVersion": "ipfs/0.1.0", + "AgentVersion": "kubo/0.24.0", + "Addresses": ["/ip4/127.0.0.1/tcp/4001"] + } + stat_response = { + "RepoSize": 1048576, + "StorageMax": 10737418240, + "NumObjects": 42 + } + + with patch("main.httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + + async def mock_post(url, **kwargs): + mock_resp = MagicMock() + if "id" in url: + mock_resp.status_code = 200 + mock_resp.json.return_value = id_response + elif "stat" in url: + mock_resp.status_code = 200 + mock_resp.json.return_value = stat_response + return mock_resp + + mock_instance.post = mock_post + mock_client.return_value.__aenter__.return_value = mock_instance + + response = client.get("/api/v1/node/info") + + assert response.status_code == 200 + data = response.json() + assert data["node_id"] == "QmNodeId12345" + assert data["num_objects"] == 42 + + def test_get_node_info_public_access(self): + """Test: Node-Info ist öffentlich zugänglich""" + with patch("main.httpx.AsyncClient") as mock_client: + mock_instance = AsyncMock() + mock_instance.post.return_value = MagicMock( + status_code=200, + json=lambda: {} + ) + mock_client.return_value.__aenter__.return_value = mock_instance + + # Kein Authorization Header! + response = client.get("/api/v1/node/info") + + assert response.status_code == 200 + + +# ==================== Model Tests ==================== + +class TestModels: + """Tests für Pydantic Models""" + + def test_document_metadata_defaults(self): + """Test: DocumentMetadata Default-Werte""" + metadata = DocumentMetadata(document_type="test") + + assert metadata.document_type == "test" + assert metadata.document_id is None + assert metadata.version is None + assert metadata.language == "de" + assert metadata.encrypted is False + + def test_document_metadata_all_fields(self): + """Test: DocumentMetadata mit allen Feldern""" + metadata = DocumentMetadata( + document_type="legal_document", + document_id="doc-123", + version="1.0", + language="en", + created_at="2024-01-01T00:00:00", + checksum="abc123", + encrypted=True + ) + + assert metadata.document_type == "legal_document" + assert metadata.document_id == "doc-123" + assert metadata.version == "1.0" + assert metadata.language == "en" + assert metadata.encrypted is True + + def test_stored_document_model(self, sample_document_metadata): + """Test: StoredDocument Model""" + stored = StoredDocument( + cid="QmTest123", + size=1024, + metadata=sample_document_metadata, + gateway_url="http://localhost:8080/ipfs/QmTest123", + timestamp="2024-01-01T00:00:00" + ) + + assert stored.cid == "QmTest123" + assert stored.size == 1024 + assert stored.metadata.document_type == "legal_document" + + def test_document_list_model(self): + """Test: DocumentList Model""" + doc_list = DocumentList( + documents=[{"cid": "Qm1"}, {"cid": "Qm2"}], + total=2 + ) + + assert doc_list.total == 2 + assert len(doc_list.documents) == 2 + + +# ==================== Integration Tests ==================== + +class TestIntegration: + """Integration Tests (erfordern laufenden IPFS Node)""" + + @pytest.mark.skip(reason="Erfordert laufenden IPFS Node") + def test_full_document_lifecycle(self, valid_auth_header): + """Integration Test: Vollständiger Dokument-Lebenszyklus""" + # 1. Dokument speichern + response = client.post( + "/api/v1/documents", + headers=valid_auth_header, + files={"file": ("test.txt", b"Test content", "text/plain")} + ) + assert response.status_code == 200 + cid = response.json()["cid"] + + # 2. Dokument abrufen + response = client.get( + f"/api/v1/documents/{cid}", + headers=valid_auth_header + ) + assert response.status_code == 200 + + # 3. Verifizieren + response = client.get(f"/api/v1/verify/{cid}") + assert response.status_code == 200 + assert response.json()["integrity_valid"] is True + + # 4. Unpinnen + response = client.delete( + f"/api/v1/documents/{cid}", + headers=valid_auth_header + ) + assert response.status_code == 200 + + +# ==================== Run Tests ==================== + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/dsms-node/Dockerfile b/dsms-node/Dockerfile new file mode 100644 index 0000000..310076d --- /dev/null +++ b/dsms-node/Dockerfile @@ -0,0 +1,32 @@ +# DSMS Node - Dezentrales Daten Speicher System +# Basiert auf IPFS für BreakPilot PWA + +FROM ipfs/kubo:v0.24.0 + +LABEL maintainer="BreakPilot " +LABEL description="DSMS Node for BreakPilot - Decentralized Storage System" + +# Environment variables +ENV IPFS_PATH=/data/ipfs +ENV IPFS_PROFILE=server + +# Expose ports +# 4001 - Swarm (P2P) +# 5001 - API +# 8080 - Gateway +EXPOSE 4001 +EXPOSE 5001 +EXPOSE 8080 + +# Copy initialization script with correct permissions for ipfs user +USER root +COPY init-dsms.sh /container-init.d/001-init-dsms.sh +RUN chmod 755 /container-init.d/001-init-dsms.sh && chown 1000:users /container-init.d/001-init-dsms.sh +USER ipfs + +# Health check - use ipfs id which works for standalone node +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD ipfs id > /dev/null 2>&1 || exit 1 + +# Default command +CMD ["daemon", "--migrate=true", "--enable-gc"] diff --git a/dsms-node/init-dsms.sh b/dsms-node/init-dsms.sh new file mode 100644 index 0000000..5f85875 --- /dev/null +++ b/dsms-node/init-dsms.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# DSMS Node Initialization Script +# Creates a private IPFS network for BreakPilot + +set -e + +echo "=== DSMS Node Initialization ===" + +# Generate swarm key for private network if not exists +if [ ! -f "$IPFS_PATH/swarm.key" ]; then + echo "Generating private network swarm key..." + + # Use predefined swarm key for BreakPilot private network + # In production, this should be securely generated and shared between nodes + cat > "$IPFS_PATH/swarm.key" << 'EOF' +/key/swarm/psk/1.0.0/ +/base16/ +b3c7e8f4a9d2e1c5f8b7a6d4c3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4 +EOF + + echo "Swarm key created for private network" +fi + +# Configure IPFS for private network +echo "Configuring IPFS for DSMS private network..." + +# Remove default bootstrap nodes (we want a private network) +ipfs bootstrap rm --all 2>/dev/null || true + +# Configure API to listen on all interfaces (for Docker) +ipfs config Addresses.API /ip4/0.0.0.0/tcp/5001 + +# Configure Gateway +ipfs config Addresses.Gateway /ip4/0.0.0.0/tcp/8080 + +# Enable CORS for BreakPilot +ipfs config --json API.HTTPHeaders.Access-Control-Allow-Origin '["http://localhost:8000", "http://backend:8000", "*"]' +ipfs config --json API.HTTPHeaders.Access-Control-Allow-Methods '["GET", "POST", "PUT", "DELETE"]' +ipfs config --json API.HTTPHeaders.Access-Control-Allow-Headers '["Authorization", "Content-Type", "X-Requested-With"]' + +# Configure for server profile (less aggressive DHT) +ipfs config Routing.Type dht +ipfs config --json Swarm.ConnMgr.LowWater 50 +ipfs config --json Swarm.ConnMgr.HighWater 200 +ipfs config --json Swarm.ConnMgr.GracePeriod '"60s"' + +# Enable garbage collection +ipfs config --json Datastore.GCPeriod '"1h"' +ipfs config --json Datastore.StorageMax '"10GB"' + +# Configure for BreakPilot metadata tagging +ipfs config --json Experimental.FilestoreEnabled true + +echo "=== DSMS Node Configuration Complete ===" +echo "Private Network Key: $(cat $IPFS_PATH/swarm.key | tail -1 | head -c 16)..." +echo "API: http://0.0.0.0:5001" +echo "Gateway: http://0.0.0.0:8080" diff --git a/edu-search-service/Dockerfile b/edu-search-service/Dockerfile new file mode 100644 index 0000000..b048d4b --- /dev/null +++ b/edu-search-service/Dockerfile @@ -0,0 +1,48 @@ +# Build stage +FROM golang:1.23-alpine AS builder + +WORKDIR /app + +# Copy go mod files and vendor +COPY go.mod go.sum ./ +COPY vendor/ vendor/ + +# Copy source code +COPY . . + +# Build binary with vendor mode +RUN CGO_ENABLED=0 GOOS=linux go build -mod=vendor -a -installsuffix cgo -o edu-search-service ./cmd/server + +# Runtime stage +FROM alpine:3.19 + +WORKDIR /app + +# Install CA certificates for HTTPS +RUN apk --no-cache add ca-certificates tzdata + +# Create non-root user +RUN adduser -D -g '' appuser + +# Copy binary from builder +COPY --from=builder /app/edu-search-service . + +# Copy seeds, rules and migrations +COPY seeds/ ./seeds/ +COPY rules/ ./rules/ +COPY migrations/ ./migrations/ + +# Set ownership +RUN chown -R appuser:appuser /app + +USER appuser + +# Expose port +EXPOSE 8086 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8086/v1/health || exit 1 + +# Run +CMD ["./edu-search-service"] diff --git a/edu-search-service/README.md b/edu-search-service/README.md new file mode 100644 index 0000000..3c33229 --- /dev/null +++ b/edu-search-service/README.md @@ -0,0 +1,409 @@ +# edu-search-service + +Spezialisierter Suchdienst für deutsche Bildungsinhalte - eine Alternative zu Tavily, optimiert für den deutschen Bildungssektor. + +## Übersicht + +Der edu-search-service crawlt, extrahiert und indiziert Bildungsinhalte von deutschen Bildungsquellen (Kultusministerien, Bildungsserver, wissenschaftliche Studien, etc.) und stellt eine Such-API bereit. + +### Features + +- **BM25 Keyword-Suche** mit German Analyzer (OpenSearch) +- **Semantic Search** mit Embeddings (OpenAI oder Ollama) +- **Hybrid Search** kombiniert BM25 + Vektor-Ähnlichkeit +- **Automatisches Tagging** für Dokumenttyp, Fächer, Schulstufe, Bundesland +- **Trust-Score** basierend auf Domain-Reputation und Content-Qualität +- **Rate-Limited Crawler** mit robots.txt Respekt +- **Admin API** für Seed-Verwaltung und Crawl-Steuerung + +## Architektur + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ edu-search-service │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────┐ ┌───────────┐ ┌────────┐ ┌─────────┐ │ +│ │ Crawler │───▶│ Extractor │───▶│ Tagger │───▶│ Indexer │ │ +│ └─────────┘ └───────────┘ └────────┘ └─────────┘ │ +│ │ │ │ +│ ▼ ▼ │ +│ ┌─────────┐ ┌────────────┐ │ +│ │ Seeds │ │ OpenSearch │ │ +│ └─────────┘ └────────────┘ │ +│ │ │ +│ ┌────────────┐ │ │ +│ │ Search API │◀──────────────────┘ │ +│ └────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +## Komponenten + +### Crawler (`internal/crawler/`) +- Rate-Limited HTTP Client (Standard: 0.2 req/sec pro Domain) +- Denylist-Support für ungewünschte Domains +- **Seeds aus Backend-API** (primär) oder lokale Seed-Files (Fallback) +- URL-Normalisierung und Deduplication +- Seed-Metadaten: Trust-Boost, Crawl-Tiefe, Kategorie, Bundesland +- **Crawl-Status-Feedback** an Backend (Dokumentenzahl, Dauer, Fehler) + +### Robots (`internal/robots/`) +- **robots.txt Parser** mit Caching (24h TTL) +- Unterstützt `Disallow`, `Allow`, `Crawl-delay` +- Wildcard-Patterns (`*`) und End-Anchors (`$`) +- User-Agent-spezifische Regeln +- Leniente Behandlung bei fehlenden robots.txt + +### Extractor (`internal/extractor/`) +- HTML-Extraktion mit goquery +- **PDF-Textextraktion** mit ledongthuc/pdf Bibliothek + - `ExtractPDF()` - Standard-Extraktion mit GetPlainText + - `ExtractPDFWithMetadata()` - Seiten-weise Extraktion für mehr Kontrolle + - Fallback-Extraktion bei beschädigten PDFs + - Automatische Titel-Erkennung (erste signifikante Zeile) + - Heading-Erkennung (All-Caps, nummerierte Zeilen) +- Metadaten-Extraktion (og:title, description, etc.) +- Content-Feature-Berechnung (Ad-Density, Link-Density) +- Sprach-Erkennung (Deutsch/Englisch) + +### Tagger (`internal/tagger/`) +- Regelbasiertes Tagging via YAML-Konfiguration +- DocType-Erkennung (Lehrplan, Arbeitsblatt, Studie, etc.) +- Fächer-Erkennung (Mathematik, Deutsch, etc.) +- Schulstufen-Erkennung (Grundschule, Sek I/II, etc.) +- Bundesland-Erkennung aus URL-Patterns +- Trust-Score-Berechnung + +### Quality (`internal/quality/`) +- **Multi-Faktor Quality-Score** (0-1) + - Content Length (20%) + - Heading Structure (15%) + - Link/Ad Quality (15%) + - Text-to-HTML Ratio (15%) + - Metadata Presence (10%) + - Language Clarity (10%) + - Content Freshness (10%) + - PDF-Specific Signals (5%) +- Konfigurierbare Gewichtungen +- Date-Indicator-Extraktion für Frische-Bewertung + +### Indexer (`internal/indexer/`) +- OpenSearch 2.11 Client +- German Analyzer für BM25 +- Bulk-Indexierung +- Custom Mapping für Bildungsdokumente + +### Search (`internal/search/`) +- Multi-Match Query mit Boosting +- Filter für alle Taxonomie-Felder +- Function-Score mit Trust/Quality-Boosting +- Highlighting-Support +- **Drei Suchmodi:** + - `keyword` - Klassische BM25-Suche (Default) + - `semantic` - Reine Vektor-Ähnlichkeitssuche (k-NN) + - `hybrid` - Kombination aus BM25 und Vektor-Score + +### Embedding (`internal/embedding/`) +- **OpenAI Provider** - `text-embedding-3-small` (1536 Dimensionen) +- **Ollama Provider** - Lokale Modelle (z.B. `nomic-embed-text`, 384-768 Dim.) +- Batch-Embedding für effiziente Indexierung +- Automatische Text-Kürzung (max. 30.000 Zeichen) + +### Scheduler (`internal/scheduler/`) +- **Automatisches Crawling** in konfigurierbaren Intervallen +- Default: täglich um 2:00 Uhr (minimale Auswirkung) +- Manuelles Triggern via Admin-API +- Status-Tracking (letzter Lauf, nächster Lauf, Ergebnis) + +## API Endpoints + +### Public Endpoints + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| GET | `/v1/health` | Health Check (kein Auth) | +| POST | `/v1/search` | Suche ausführen | +| GET | `/v1/document` | Einzeldokument abrufen | + +### Admin Endpoints (Auth erforderlich) + +| Method | Endpoint | Beschreibung | +|--------|----------|--------------| +| GET | `/v1/admin/seeds` | Alle Seeds abrufen | +| POST | `/v1/admin/seeds` | Neuen Seed erstellen | +| PUT | `/v1/admin/seeds/:id` | Seed aktualisieren | +| DELETE | `/v1/admin/seeds/:id` | Seed löschen | +| GET | `/v1/admin/stats` | Crawl-Statistiken | +| POST | `/v1/admin/crawl/start` | Crawl starten | + +## API Dokumentation + +### POST /v1/search + +**Request Body:** +```json +{ + "q": "Lehrplan Mathematik Gymnasium", + "mode": "keyword", + "limit": 10, + "offset": 0, + "filters": { + "language": ["de"], + "doc_type": ["Lehrplan"], + "school_level": ["Gymnasium"], + "state": ["BY", "NW"], + "subjects": ["Mathematik"], + "min_trust_score": 0.5 + }, + "include": { + "snippets": true, + "highlights": true + } +} +``` + +**Such-Modi (`mode`):** +| Mode | Beschreibung | +|------|--------------| +| `keyword` | BM25-Textsuche (Default) | +| `semantic` | Vektor-Ähnlichkeitssuche via Embeddings | +| `hybrid` | Kombination: 70% BM25 + 30% Vektor-Score | + +> **Hinweis:** `semantic` und `hybrid` Modi erfordern `SEMANTIC_SEARCH_ENABLED=true` und konfigurierte Embedding-Provider. + +**Response:** +```json +{ + "query_id": "q-12345", + "results": [ + { + "doc_id": "uuid-...", + "title": "Lehrplan Mathematik Gymnasium Bayern", + "url": "https://www.isb.bayern.de/...", + "domain": "isb.bayern.de", + "language": "de", + "doc_type": "Lehrplan", + "school_level": "Gymnasium", + "subjects": ["Mathematik"], + "scores": { + "bm25": 12.5, + "trust": 0.85, + "quality": 0.9, + "final": 10.6 + }, + "snippet": "Der Lehrplan für das Fach Mathematik...", + "highlights": ["Lehrplan für das Fach Mathematik..."] + } + ], + "pagination": { + "limit": 10, + "offset": 0, + "total_estimate": 156 + } +} +``` + +### Filter-Optionen + +| Filter | Werte | +|--------|-------| +| `language` | `de`, `en` | +| `doc_type` | `Lehrplan`, `Arbeitsblatt`, `Unterrichtsentwurf`, `Erlass_Verordnung`, `Pruefung_Abitur`, `Studie_Bericht`, `Sonstiges` | +| `school_level` | `Grundschule`, `Sek_I`, `Gymnasium`, `Berufsschule`, `Hochschule`, `Alle`, `NA` | +| `state` | `BW`, `BY`, `BE`, `BB`, `HB`, `HH`, `HE`, `MV`, `NI`, `NW`, `RP`, `SL`, `SN`, `ST`, `SH`, `TH` | +| `subjects` | `Mathematik`, `Deutsch`, `Englisch`, `Geschichte`, `Physik`, `Biologie`, `Chemie`, etc. | + +## Konfiguration + +### Umgebungsvariablen + +| Variable | Beschreibung | Default | +|----------|--------------|---------| +| `PORT` | Server Port | `8084` | +| `OPENSEARCH_URL` | OpenSearch URL | `http://opensearch:9200` | +| `OPENSEARCH_USERNAME` | OpenSearch User | `admin` | +| `OPENSEARCH_PASSWORD` | OpenSearch Passwort | `admin` | +| `INDEX_NAME` | Index Name | `bp_documents_v1` | +| `USER_AGENT` | Crawler User Agent | `BreakpilotEduCrawler/1.0` | +| `RATE_LIMIT_PER_SEC` | Requests pro Sekunde/Domain | `0.2` | +| `MAX_DEPTH` | Max Crawl-Tiefe | `4` | +| `MAX_PAGES_PER_RUN` | Max Seiten pro Crawl | `500` | +| `SEEDS_DIR` | Seed-Dateien Verzeichnis | `./seeds` | +| `RULES_DIR` | Tagging-Regeln Verzeichnis | `./rules` | +| `EDU_SEARCH_API_KEY` | API Key für Auth | `` | +| `BACKEND_URL` | URL zum Python Backend | `http://backend:8000` | +| `SEEDS_FROM_API` | Seeds aus API laden | `true` | +| **Semantic Search** | | | +| `SEMANTIC_SEARCH_ENABLED` | Semantic Search aktivieren | `false` | +| `EMBEDDING_PROVIDER` | Provider: `openai`, `ollama`, `none` | `none` | +| `OPENAI_API_KEY` | API Key für OpenAI Embeddings | `` | +| `EMBEDDING_MODEL` | Embedding-Modell | `text-embedding-3-small` | +| `EMBEDDING_DIMENSION` | Vektor-Dimension | `1536` | +| `OLLAMA_URL` | Ollama Server URL | `http://ollama:11434` | +| **Scheduler** | | | +| `SCHEDULER_ENABLED` | Automatisches Crawling aktivieren | `false` | +| `SCHEDULER_INTERVAL` | Crawl-Intervall | `24h` (täglich) | + +## Installation & Start + +### Docker (empfohlen) + +```bash +# Im edu-search-service Verzeichnis +docker compose up -d + +# Logs anzeigen +docker compose logs -f edu-search + +# Nur der Service (OpenSearch extern) +docker build -t edu-search-service . +docker run -p 8084:8084 \ + -e OPENSEARCH_URL=http://host.docker.internal:9200 \ + edu-search-service +``` + +### Lokal (Entwicklung) + +```bash +# Dependencies installieren +go mod download + +# Service starten +go run cmd/server/main.go + +# Tests ausführen +go test -v ./... +``` + +## Seed-Kategorien + +| Kategorie | Beschreibung | Beispiele | +|-----------|--------------|-----------| +| `federal` | Bundesweite Institutionen | KMK, BMBF, IQB | +| `states` | Landeskultusbehörden | Kultusministerien, Landesinstitute | +| `science` | Wissenschaftliche Studien | PISA, IGLU, TIMSS | +| `universities` | Hochschulen | Pädagogische Hochschulen | +| `schools` | Schulen direkt | Schulhomepages | +| `portals` | Bildungsportale | Lehrer-Online, 4teachers | +| `eu` | EU-Bildungsprogramme | Erasmus+, Eurydice | +| `authorities` | Schulbehörden | Regierungspräsidien | + +## Tagging-Regeln + +Die YAML-Regeldateien im `rules/` Verzeichnis definieren das Tagging: + +- `doc_type_rules.yaml` - Dokumenttyp-Erkennung +- `subject_rules.yaml` - Fächer-Erkennung +- `level_rules.yaml` - Schulstufen-Erkennung +- `trust_rules.yaml` - Trust-Score-Berechnung + +### Beispiel: doc_type_rules.yaml + +```yaml +doc_types: + Lehrplan: + strong_terms: + - Lehrplan + - Kernlehrplan + - Bildungsplan + medium_terms: + - Curriculum + - Kompetenzerwartungen + url_patterns: + - /lehrplan + - /kernlehrplan + +priority_order: + - Pruefung_Abitur + - Lehrplan + - Arbeitsblatt +``` + +## Projektstruktur + +``` +edu-search-service/ +├── cmd/ +│ └── server/ +│ └── main.go # Entry Point +├── internal/ +│ ├── api/ +│ │ └── handlers/ +│ │ ├── handlers.go # Search & Health Handler +│ │ └── admin_handlers.go # Admin API Handler +│ ├── config/ +│ │ └── config.go # Konfiguration +│ ├── crawler/ +│ │ ├── crawler.go # URL Fetcher +│ │ └── api_client.go # Backend API Client (Seeds) +│ ├── robots/ +│ │ └── robots.go # robots.txt Parser & Checker +│ ├── embedding/ +│ │ └── embedding.go # Embedding Provider (OpenAI/Ollama) +│ ├── extractor/ +│ │ └── extractor.go # HTML/PDF Extraktion +│ ├── indexer/ +│ │ └── mapping.go # OpenSearch Indexer +│ ├── pipeline/ +│ │ └── pipeline.go # Crawl Orchestrierung +│ ├── quality/ +│ │ └── quality.go # Multi-Faktor Quality Scoring +│ ├── scheduler/ +│ │ └── scheduler.go # Automatisches Crawl-Scheduling +│ ├── search/ +│ │ └── search.go # Search Service (Keyword/Semantic/Hybrid) +│ └── tagger/ +│ └── tagger.go # Regelbasiertes Tagging +├── rules/ +│ ├── doc_type_rules.yaml +│ ├── subject_rules.yaml +│ ├── level_rules.yaml +│ └── trust_rules.yaml +├── seeds/ +│ ├── federal.txt +│ ├── states.txt +│ └── denylist.txt +├── Dockerfile +├── docker-compose.yml +├── go.mod +└── README.md +``` + +## Abhängigkeiten + +| Package | Version | Beschreibung | Lizenz | +|---------|---------|--------------|--------| +| `github.com/gin-gonic/gin` | v1.9+ | HTTP Framework | MIT | +| `github.com/opensearch-project/opensearch-go/v2` | v2.3+ | OpenSearch Client | Apache-2.0 | +| `github.com/PuerkitoBio/goquery` | v1.8+ | HTML Parser | BSD-3-Clause | +| `github.com/ledongthuc/pdf` | v0.0.0-20240201 | PDF Text Extraktion | MIT | +| `gopkg.in/yaml.v3` | v3.0+ | YAML Parser | MIT | +| `github.com/google/uuid` | v1.4+ | UUID Generation | BSD-3-Clause | +| `golang.org/x/net` | v0.19+ | HTML Utilities | BSD-3-Clause | + +## Tests ausführen + +```bash +# Alle Tests +go test -v ./... + +# Mit Coverage +go test -cover ./... + +# Nur Tagger Tests +go test -v ./internal/tagger/... + +# Nur Crawler Tests +go test -v ./internal/crawler/... +``` + +## Lizenz + +Proprietär - BreakPilot GmbH + +## Kontakt + +- Security Issues: security@breakpilot.com +- Bugs: https://github.com/breakpilot/edu-search-service/issues diff --git a/edu-search-service/cmd/server/main.go b/edu-search-service/cmd/server/main.go new file mode 100644 index 0000000..f631847 --- /dev/null +++ b/edu-search-service/cmd/server/main.go @@ -0,0 +1,187 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/breakpilot/edu-search-service/internal/api/handlers" + "github.com/breakpilot/edu-search-service/internal/config" + "github.com/breakpilot/edu-search-service/internal/database" + "github.com/breakpilot/edu-search-service/internal/indexer" + "github.com/breakpilot/edu-search-service/internal/orchestrator" + "github.com/breakpilot/edu-search-service/internal/search" + "github.com/breakpilot/edu-search-service/internal/staff" + "github.com/gin-gonic/gin" +) + +func main() { + log.Println("Starting edu-search-service...") + + // Load configuration + cfg := config.Load() + log.Printf("Configuration loaded: Port=%s, OpenSearch=%s, Index=%s", + cfg.Port, cfg.OpenSearchURL, cfg.IndexName) + + // Initialize OpenSearch indexer client + indexClient, err := indexer.NewClient( + cfg.OpenSearchURL, + cfg.OpenSearchUsername, + cfg.OpenSearchPassword, + cfg.IndexName, + ) + if err != nil { + log.Fatalf("Failed to create indexer client: %v", err) + } + + // Create index if not exists + ctx := context.Background() + if err := indexClient.CreateIndex(ctx); err != nil { + log.Printf("Warning: Could not create index (may already exist): %v", err) + } + + // Initialize search service + searchService, err := search.NewService( + cfg.OpenSearchURL, + cfg.OpenSearchUsername, + cfg.OpenSearchPassword, + cfg.IndexName, + ) + if err != nil { + log.Fatalf("Failed to create search service: %v", err) + } + + // Initialize seed store for admin API + if err := handlers.InitSeedStore(cfg.SeedsDir); err != nil { + log.Printf("Warning: Could not initialize seed store: %v", err) + } + + // Create handler + handler := handlers.NewHandler(cfg, searchService, indexClient) + + // Initialize PostgreSQL for Staff/Publications database + dbCfg := &database.Config{ + Host: cfg.DBHost, + Port: cfg.DBPort, + User: cfg.DBUser, + Password: cfg.DBPassword, + DBName: cfg.DBName, + SSLMode: cfg.DBSSLMode, + } + + db, err := database.New(ctx, dbCfg) + if err != nil { + log.Printf("Warning: Could not connect to PostgreSQL for staff database: %v", err) + log.Println("Staff/Publications features will be disabled") + } else { + defer db.Close() + log.Println("Connected to PostgreSQL for staff/publications database") + + // Run migrations + if err := db.RunMigrations(ctx); err != nil { + log.Printf("Warning: Could not run migrations: %v", err) + } + } + + // Create repository for Staff handlers (may be nil if DB connection failed) + var repo *database.Repository + if db != nil { + repo = database.NewRepository(db) + } + + // Setup Gin router + gin.SetMode(gin.ReleaseMode) + router := gin.New() + router.Use(gin.Recovery()) + router.Use(gin.Logger()) + + // CORS middleware + router.Use(func(c *gin.Context) { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + + c.Next() + }) + + // Setup routes + handlers.SetupRoutes(router, handler, cfg.APIKey) + + // Setup Staff/Publications routes if database is available + if repo != nil { + staffHandlers := handlers.NewStaffHandlers(repo, cfg.StaffCrawlerEmail) + apiV1 := router.Group("/api/v1") + staffHandlers.RegisterRoutes(apiV1) + log.Println("Staff/Publications API routes registered") + + // Setup AI Extraction routes for vast.ai integration + aiHandlers := handlers.NewAIExtractionHandlers(repo) + aiHandlers.RegisterRoutes(apiV1) + log.Println("AI Extraction API routes registered") + } + + // Setup Orchestrator routes if database is available + if db != nil { + orchRepo := orchestrator.NewPostgresRepository(db.Pool) + + // Create real crawlers with adapters for orchestrator interface + staffCrawler := staff.NewStaffCrawler(repo) + staffAdapter := staff.NewOrchestratorAdapter(staffCrawler, repo) + pubAdapter := staff.NewPublicationOrchestratorAdapter(repo) + + orch := orchestrator.NewOrchestrator(orchRepo, staffAdapter, pubAdapter) + orchHandler := handlers.NewOrchestratorHandler(orch, orchRepo) + + v1 := router.Group("/v1") + v1.Use(handlers.AuthMiddleware(cfg.APIKey)) + handlers.SetupOrchestratorRoutes(v1, orchHandler) + log.Println("Orchestrator API routes registered") + + // Setup Audience routes (reuses orchRepo which implements AudienceRepository) + audienceHandler := handlers.NewAudienceHandler(orchRepo) + handlers.SetupAudienceRoutes(v1, audienceHandler) + log.Println("Audience API routes registered") + } + + // Create HTTP server + srv := &http.Server{ + Addr: ":" + cfg.Port, + Handler: router, + ReadTimeout: 10 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // Start server in goroutine + go func() { + log.Printf("Server listening on port %s", cfg.Port) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Server error: %v", err) + } + }() + + // Graceful shutdown + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("Shutting down server...") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + log.Fatalf("Server forced to shutdown: %v", err) + } + + log.Println("Server exited") +} diff --git a/edu-search-service/docker-compose.yml b/edu-search-service/docker-compose.yml new file mode 100644 index 0000000..533eef2 --- /dev/null +++ b/edu-search-service/docker-compose.yml @@ -0,0 +1,89 @@ +version: '3.8' + +services: + edu-search-service: + build: . + container_name: breakpilot-edu-search + ports: + - "8086:8086" + environment: + - PORT=8086 + - OPENSEARCH_URL=http://opensearch:9200 + - OPENSEARCH_USERNAME=admin + - OPENSEARCH_PASSWORD=${OPENSEARCH_PASSWORD:-admin} + - INDEX_NAME=bp_documents_v1 + - EDU_SEARCH_API_KEY=${EDU_SEARCH_API_KEY:-} + - "USER_AGENT=BreakpilotEduCrawler/1.0 (+contact: security@breakpilot.com)" + - RATE_LIMIT_PER_SEC=0.2 + - MAX_DEPTH=4 + - MAX_PAGES_PER_RUN=500 + - DB_HOST=breakpilot-pwa-postgres + - DB_PORT=5432 + - DB_USER=breakpilot + - DB_PASSWORD=${DB_PASSWORD:-breakpilot123} + - DB_NAME=breakpilot_db + - DB_SSLMODE=disable + - STAFF_CRAWLER_EMAIL=crawler@breakpilot.de + depends_on: + opensearch: + condition: service_healthy + networks: + - edu-search-network + - breakpilot-pwa-network + restart: unless-stopped + + opensearch: + image: opensearchproject/opensearch:2.11.1 + container_name: breakpilot-opensearch + environment: + - cluster.name=edu-search-cluster + - node.name=opensearch-node1 + - discovery.type=single-node + - bootstrap.memory_lock=true + - "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m" + - OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_PASSWORD:-Admin123!} + - plugins.security.disabled=true + ulimits: + memlock: + soft: -1 + hard: -1 + nofile: + soft: 65536 + hard: 65536 + volumes: + - opensearch-data:/usr/share/opensearch/data + ports: + - "9200:9200" + - "9600:9600" + networks: + - edu-search-network + healthcheck: + test: ["CMD-SHELL", "curl -s http://localhost:9200 >/dev/null || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + + opensearch-dashboards: + image: opensearchproject/opensearch-dashboards:2.11.1 + container_name: breakpilot-opensearch-dashboards + ports: + - "5601:5601" + environment: + - OPENSEARCH_HOSTS=["http://opensearch:9200"] + - DISABLE_SECURITY_DASHBOARDS_PLUGIN=true + depends_on: + opensearch: + condition: service_healthy + networks: + - edu-search-network + +networks: + edu-search-network: + driver: bridge + breakpilot-pwa-network: + external: true + name: breakpilot-pwa_breakpilot-pwa-network + +volumes: + opensearch-data: diff --git a/edu-search-service/go.mod b/edu-search-service/go.mod new file mode 100644 index 0000000..ea3dfd2 --- /dev/null +++ b/edu-search-service/go.mod @@ -0,0 +1,46 @@ +module github.com/breakpilot/edu-search-service + +go 1.23 + +require ( + github.com/PuerkitoBio/goquery v1.8.1 + github.com/gin-gonic/gin v1.9.1 + github.com/google/uuid v1.4.0 + github.com/jackc/pgx/v5 v5.5.1 + github.com/ledongthuc/pdf v0.0.0-20240201131950-da5b75280b06 + github.com/opensearch-project/opensearch-go/v2 v2.3.0 + golang.org/x/net v0.19.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/andybalholm/cascadia v1.3.1 // indirect + github.com/bytedance/sonic v1.9.1 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + github.com/jackc/puddle/v2 v2.2.1 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/leodido/go-urn v1.2.4 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + golang.org/x/arch v0.3.0 // indirect + golang.org/x/crypto v0.16.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/protobuf v1.30.0 // indirect +) diff --git a/edu-search-service/go.sum b/edu-search-service/go.sum new file mode 100644 index 0000000..68ae53f --- /dev/null +++ b/edu-search-service/go.sum @@ -0,0 +1,165 @@ +github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM= +github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ= +github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= +github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA= +github.com/aws/aws-sdk-go v1.44.263/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= +github.com/aws/aws-sdk-go-v2 v1.18.0/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= +github.com/aws/aws-sdk-go-v2/config v1.18.25/go.mod h1:dZnYpD5wTW/dQF0rRNLVypB396zWCcPiBIvdvSWHEg4= +github.com/aws/aws-sdk-go-v2/credentials v1.13.24/go.mod h1:jYPYi99wUOPIFi0rhiOvXeSEReVOzBqFNOX5bXYoG2o= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.3/go.mod h1:4Q0UFP0YJf0NrsEuEYHpM9fTSEVnD16Z3uyEF7J9JGM= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.33/go.mod h1:7i0PF1ME/2eUPFcjkVIwq+DOygHEoK92t5cDqNgYbIw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.27/go.mod h1:UrHnn3QV/d0pBZ6QBAEQcqFLf8FAzLmoUfPVIueOvoM= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.34/go.mod h1:Etz2dj6UHYuw+Xw830KfzCfWGMzqvUTCjUj5b76GVDc= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.27/go.mod h1:EOwBD4J4S5qYszS5/3DpkejfuK+Z5/1uzICfPaZLtqw= +github.com/aws/aws-sdk-go-v2/service/sso v1.12.10/go.mod h1:ouy2P4z6sJN70fR3ka3wD3Ro3KezSxU6eKGQI2+2fjI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.10/go.mod h1:AFvkxc8xfBe8XA+5St5XIHHrQQtkxqrRincx4hmMHOk= +github.com/aws/aws-sdk-go-v2/service/sts v1.19.0/go.mod h1:BgQOMsg8av8jset59jelyPW7NoZcZXLVpDsXunGDrk8= +github.com/aws/smithy-go v1.13.5/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= +github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= +github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.1 h1:5I9etrGkLrN+2XPCsi6XLlV5DITbSL/xBZdmAxFcXPI= +github.com/jackc/pgx/v5 v5.5.1/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= +github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/ledongthuc/pdf v0.0.0-20240201131950-da5b75280b06 h1:kacRlPN7EN++tVpGUorNGPn/4DnB7/DfTY82AOn6ccU= +github.com/ledongthuc/pdf v0.0.0-20240201131950-da5b75280b06/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/opensearch-project/opensearch-go/v2 v2.3.0 h1:nQIEMr+A92CkhHrZgUhcfsrZjibvB3APXf2a1VwCmMQ= +github.com/opensearch-project/opensearch-go/v2 v2.3.0/go.mod h1:8LDr9FCgUTVoT+5ESjc2+iaZuldqE+23Iq0r1XeNue8= +github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= +github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= +github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= +golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= +google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/edu-search-service/internal/api/handlers/admin_handlers.go b/edu-search-service/internal/api/handlers/admin_handlers.go new file mode 100644 index 0000000..e46bf2e --- /dev/null +++ b/edu-search-service/internal/api/handlers/admin_handlers.go @@ -0,0 +1,406 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// SeedURL represents a seed URL configuration +type SeedURL struct { + ID string `json:"id"` + URL string `json:"url"` + Category string `json:"category"` + Name string `json:"name"` + Description string `json:"description"` + TrustBoost float64 `json:"trustBoost"` + Enabled bool `json:"enabled"` + LastCrawled *string `json:"lastCrawled,omitempty"` + DocumentCount int `json:"documentCount,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// CrawlStats contains crawl statistics +type CrawlStats struct { + TotalDocuments int `json:"totalDocuments"` + TotalSeeds int `json:"totalSeeds"` + LastCrawlTime *string `json:"lastCrawlTime,omitempty"` + CrawlStatus string `json:"crawlStatus"` + DocumentsPerCategory map[string]int `json:"documentsPerCategory"` + DocumentsPerDocType map[string]int `json:"documentsPerDocType"` + AvgTrustScore float64 `json:"avgTrustScore"` +} + +// SeedStore manages seed URLs in memory and file +type SeedStore struct { + seeds map[string]SeedURL + mu sync.RWMutex + filePath string +} + +var seedStore *SeedStore +var crawlStatus = "idle" +var lastCrawlTime *string + +// InitSeedStore initializes the seed store +func InitSeedStore(seedsDir string) error { + seedStore = &SeedStore{ + seeds: make(map[string]SeedURL), + filePath: filepath.Join(seedsDir, "seeds.json"), + } + + // Try to load existing seeds from JSON file + if err := seedStore.loadFromFile(); err != nil { + // If file doesn't exist, load from txt files + return seedStore.loadFromTxtFiles(seedsDir) + } + return nil +} + +func (s *SeedStore) loadFromFile() error { + data, err := os.ReadFile(s.filePath) + if err != nil { + return err + } + + var seeds []SeedURL + if err := json.Unmarshal(data, &seeds); err != nil { + return err + } + + s.mu.Lock() + defer s.mu.Unlock() + + for _, seed := range seeds { + s.seeds[seed.ID] = seed + } + return nil +} + +func (s *SeedStore) loadFromTxtFiles(seedsDir string) error { + // Default seeds from category files + defaultSeeds := []SeedURL{ + {ID: uuid.New().String(), URL: "https://www.kmk.org", Category: "federal", Name: "Kultusministerkonferenz", Description: "Beschlüsse und Bildungsstandards", TrustBoost: 0.50, Enabled: true}, + {ID: uuid.New().String(), URL: "https://www.bildungsserver.de", Category: "federal", Name: "Deutscher Bildungsserver", Description: "Zentrale Bildungsinformationen", TrustBoost: 0.50, Enabled: true}, + {ID: uuid.New().String(), URL: "https://www.bpb.de", Category: "federal", Name: "Bundeszentrale politische Bildung", Description: "Politische Bildung", TrustBoost: 0.45, Enabled: true}, + {ID: uuid.New().String(), URL: "https://www.bmbf.de", Category: "federal", Name: "BMBF", Description: "Bundesbildungsministerium", TrustBoost: 0.50, Enabled: true}, + {ID: uuid.New().String(), URL: "https://www.iqb.hu-berlin.de", Category: "federal", Name: "IQB", Description: "Institut Qualitätsentwicklung", TrustBoost: 0.50, Enabled: true}, + + // Science + {ID: uuid.New().String(), URL: "https://www.bertelsmann-stiftung.de/de/themen/bildung", Category: "science", Name: "Bertelsmann Stiftung", Description: "Bildungsstudien und Ländermonitor", TrustBoost: 0.40, Enabled: true}, + {ID: uuid.New().String(), URL: "https://www.oecd.org/pisa", Category: "science", Name: "PISA-Studien", Description: "Internationale Schulleistungsstudie", TrustBoost: 0.45, Enabled: true}, + {ID: uuid.New().String(), URL: "https://www.iea.nl/studies/iea/pirls", Category: "science", Name: "IGLU/PIRLS", Description: "Internationale Grundschul-Lese-Untersuchung", TrustBoost: 0.45, Enabled: true}, + {ID: uuid.New().String(), URL: "https://www.iea.nl/studies/iea/timss", Category: "science", Name: "TIMSS", Description: "Trends in International Mathematics and Science Study", TrustBoost: 0.45, Enabled: true}, + + // Bundesländer + {ID: uuid.New().String(), URL: "https://www.km.bayern.de", Category: "states", Name: "Bayern Kultusministerium", Description: "Lehrpläne Bayern", TrustBoost: 0.45, Enabled: true}, + {ID: uuid.New().String(), URL: "https://www.schulministerium.nrw", Category: "states", Name: "NRW Schulministerium", Description: "Lehrpläne NRW", TrustBoost: 0.45, Enabled: true}, + {ID: uuid.New().String(), URL: "https://www.berlin.de/sen/bildung", Category: "states", Name: "Berlin Bildung", Description: "Rahmenlehrpläne Berlin", TrustBoost: 0.45, Enabled: true}, + {ID: uuid.New().String(), URL: "https://kultusministerium.hessen.de", Category: "states", Name: "Hessen Kultusministerium", Description: "Kerncurricula Hessen", TrustBoost: 0.45, Enabled: true}, + + // Portale + {ID: uuid.New().String(), URL: "https://www.lehrer-online.de", Category: "portals", Name: "Lehrer-Online", Description: "Unterrichtsmaterialien", TrustBoost: 0.20, Enabled: true}, + {ID: uuid.New().String(), URL: "https://www.4teachers.de", Category: "portals", Name: "4teachers", Description: "Lehrercommunity", TrustBoost: 0.20, Enabled: true}, + {ID: uuid.New().String(), URL: "https://www.zum.de", Category: "portals", Name: "ZUM", Description: "Zentrale für Unterrichtsmedien", TrustBoost: 0.25, Enabled: true}, + } + + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + for _, seed := range defaultSeeds { + seed.CreatedAt = now + seed.UpdatedAt = now + s.seeds[seed.ID] = seed + } + + return s.saveToFile() +} + +func (s *SeedStore) saveToFile() error { + seeds := make([]SeedURL, 0, len(s.seeds)) + for _, seed := range s.seeds { + seeds = append(seeds, seed) + } + + data, err := json.MarshalIndent(seeds, "", " ") + if err != nil { + return err + } + + return os.WriteFile(s.filePath, data, 0644) +} + +// GetAllSeeds returns all seeds +func (s *SeedStore) GetAllSeeds() []SeedURL { + s.mu.RLock() + defer s.mu.RUnlock() + + seeds := make([]SeedURL, 0, len(s.seeds)) + for _, seed := range s.seeds { + seeds = append(seeds, seed) + } + return seeds +} + +// GetSeed returns a single seed by ID +func (s *SeedStore) GetSeed(id string) (SeedURL, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + seed, ok := s.seeds[id] + return seed, ok +} + +// CreateSeed adds a new seed +func (s *SeedStore) CreateSeed(seed SeedURL) (SeedURL, error) { + s.mu.Lock() + defer s.mu.Unlock() + + seed.ID = uuid.New().String() + seed.CreatedAt = time.Now() + seed.UpdatedAt = time.Now() + s.seeds[seed.ID] = seed + + if err := s.saveToFile(); err != nil { + delete(s.seeds, seed.ID) + return SeedURL{}, err + } + + return seed, nil +} + +// UpdateSeed updates an existing seed +func (s *SeedStore) UpdateSeed(id string, updates SeedURL) (SeedURL, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + + seed, ok := s.seeds[id] + if !ok { + return SeedURL{}, false, nil + } + + // Update fields + if updates.URL != "" { + seed.URL = updates.URL + } + if updates.Name != "" { + seed.Name = updates.Name + } + if updates.Category != "" { + seed.Category = updates.Category + } + if updates.Description != "" { + seed.Description = updates.Description + } + seed.TrustBoost = updates.TrustBoost + seed.Enabled = updates.Enabled + seed.UpdatedAt = time.Now() + + s.seeds[id] = seed + + if err := s.saveToFile(); err != nil { + return SeedURL{}, true, err + } + + return seed, true, nil +} + +// DeleteSeed removes a seed +func (s *SeedStore) DeleteSeed(id string) bool { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.seeds[id]; !ok { + return false + } + + delete(s.seeds, id) + s.saveToFile() + return true +} + +// Admin Handlers + +// GetSeeds returns all seed URLs +func (h *Handler) GetSeeds(c *gin.Context) { + if seedStore == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Seed store not initialized"}) + return + } + + seeds := seedStore.GetAllSeeds() + c.JSON(http.StatusOK, seeds) +} + +// CreateSeed adds a new seed URL +func (h *Handler) CreateSeed(c *gin.Context) { + if seedStore == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Seed store not initialized"}) + return + } + + var seed SeedURL + if err := c.ShouldBindJSON(&seed); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) + return + } + + if seed.URL == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "URL is required"}) + return + } + + created, err := seedStore.CreateSeed(seed) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create seed", "details": err.Error()}) + return + } + + c.JSON(http.StatusCreated, created) +} + +// UpdateSeed updates an existing seed URL +func (h *Handler) UpdateSeed(c *gin.Context) { + if seedStore == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Seed store not initialized"}) + return + } + + id := c.Param("id") + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Seed ID required"}) + return + } + + var updates SeedURL + if err := c.ShouldBindJSON(&updates); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) + return + } + + updated, found, err := seedStore.UpdateSeed(id, updates) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update seed", "details": err.Error()}) + return + } + if !found { + c.JSON(http.StatusNotFound, gin.H{"error": "Seed not found"}) + return + } + + c.JSON(http.StatusOK, updated) +} + +// DeleteSeed removes a seed URL +func (h *Handler) DeleteSeed(c *gin.Context) { + if seedStore == nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Seed store not initialized"}) + return + } + + id := c.Param("id") + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Seed ID required"}) + return + } + + if !seedStore.DeleteSeed(id) { + c.JSON(http.StatusNotFound, gin.H{"error": "Seed not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"deleted": true, "id": id}) +} + +// GetStats returns crawl statistics +func (h *Handler) GetStats(c *gin.Context) { + // Get document count from OpenSearch + totalDocs := 0 + // TODO: Get real count from OpenSearch + + seeds := []SeedURL{} + if seedStore != nil { + seeds = seedStore.GetAllSeeds() + } + + enabledSeeds := 0 + for _, seed := range seeds { + if seed.Enabled { + enabledSeeds++ + } + } + + stats := CrawlStats{ + TotalDocuments: totalDocs, + TotalSeeds: enabledSeeds, + LastCrawlTime: lastCrawlTime, + CrawlStatus: crawlStatus, + DocumentsPerCategory: map[string]int{ + "federal": 0, + "states": 0, + "science": 0, + "universities": 0, + "portals": 0, + }, + DocumentsPerDocType: map[string]int{ + "Lehrplan": 0, + "Arbeitsblatt": 0, + "Unterrichtsentwurf": 0, + "Erlass_Verordnung": 0, + "Pruefung_Abitur": 0, + "Studie_Bericht": 0, + "Sonstiges": 0, + }, + AvgTrustScore: 0.0, + } + + c.JSON(http.StatusOK, stats) +} + +// StartCrawl initiates a crawl run +func (h *Handler) StartCrawl(c *gin.Context) { + if crawlStatus == "running" { + c.JSON(http.StatusConflict, gin.H{"error": "Crawl already running"}) + return + } + + crawlStatus = "running" + + // TODO: Start actual crawl in background goroutine + go func() { + time.Sleep(5 * time.Second) // Simulate crawl + now := time.Now().Format(time.RFC3339) + lastCrawlTime = &now + crawlStatus = "idle" + }() + + c.JSON(http.StatusAccepted, gin.H{ + "status": "started", + "message": "Crawl initiated", + }) +} + +// SetupAdminRoutes configures admin API routes +func SetupAdminRoutes(r *gin.RouterGroup, h *Handler) { + admin := r.Group("/admin") + { + // Seeds CRUD + admin.GET("/seeds", h.GetSeeds) + admin.POST("/seeds", h.CreateSeed) + admin.PUT("/seeds/:id", h.UpdateSeed) + admin.DELETE("/seeds/:id", h.DeleteSeed) + + // Stats + admin.GET("/stats", h.GetStats) + + // Crawl control + admin.POST("/crawl/start", h.StartCrawl) + } +} diff --git a/edu-search-service/internal/api/handlers/ai_extraction_handlers.go b/edu-search-service/internal/api/handlers/ai_extraction_handlers.go new file mode 100644 index 0000000..4419fca --- /dev/null +++ b/edu-search-service/internal/api/handlers/ai_extraction_handlers.go @@ -0,0 +1,554 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/breakpilot/edu-search-service/internal/database" +) + +// AIExtractionHandlers handles AI-based profile extraction endpoints +// These endpoints are designed for vast.ai or similar AI services to: +// 1. Get profile URLs that need extraction +// 2. Submit extracted data back +type AIExtractionHandlers struct { + repo *database.Repository +} + +// NewAIExtractionHandlers creates new AI extraction handlers +func NewAIExtractionHandlers(repo *database.Repository) *AIExtractionHandlers { + return &AIExtractionHandlers{repo: repo} +} + +// ProfileExtractionTask represents a profile URL to be processed by AI +type ProfileExtractionTask struct { + StaffID uuid.UUID `json:"staff_id"` + ProfileURL string `json:"profile_url"` + UniversityID uuid.UUID `json:"university_id"` + UniversityURL string `json:"university_url,omitempty"` + FullName string `json:"full_name,omitempty"` + CurrentData struct { + Email string `json:"email,omitempty"` + Phone string `json:"phone,omitempty"` + Office string `json:"office,omitempty"` + Position string `json:"position,omitempty"` + Department string `json:"department,omitempty"` + } `json:"current_data"` +} + +// GetPendingProfiles returns staff profiles that need AI extraction +// GET /api/v1/ai/extraction/pending?limit=10&university_id=... +func (h *AIExtractionHandlers) GetPendingProfiles(c *gin.Context) { + limit := parseIntDefault(c.Query("limit"), 10) + if limit > 100 { + limit = 100 + } + + var universityID *uuid.UUID + if uniIDStr := c.Query("university_id"); uniIDStr != "" { + id, err := uuid.Parse(uniIDStr) + if err == nil { + universityID = &id + } + } + + // Get staff that have profile URLs but missing key data + params := database.StaffSearchParams{ + UniversityID: universityID, + Limit: limit * 2, // Get more to filter + } + + result, err := h.repo.SearchStaff(c.Request.Context(), params) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Filter to only include profiles that need extraction + var tasks []ProfileExtractionTask + for _, staff := range result.Staff { + // Skip if no profile URL + if staff.ProfileURL == nil || *staff.ProfileURL == "" { + continue + } + + // Include if missing email or other important data + needsExtraction := staff.Email == nil || *staff.Email == "" + + if needsExtraction { + task := ProfileExtractionTask{ + StaffID: staff.ID, + ProfileURL: *staff.ProfileURL, + UniversityID: staff.UniversityID, + } + + if staff.FullName != nil { + task.FullName = *staff.FullName + } + if staff.Email != nil { + task.CurrentData.Email = *staff.Email + } + if staff.Phone != nil { + task.CurrentData.Phone = *staff.Phone + } + if staff.Office != nil { + task.CurrentData.Office = *staff.Office + } + if staff.Position != nil { + task.CurrentData.Position = *staff.Position + } + if staff.DepartmentName != nil { + task.CurrentData.Department = *staff.DepartmentName + } + + tasks = append(tasks, task) + if len(tasks) >= limit { + break + } + } + } + + c.JSON(http.StatusOK, gin.H{ + "tasks": tasks, + "total": len(tasks), + }) +} + +// ExtractedProfileData represents data extracted by AI from a profile page +type ExtractedProfileData struct { + StaffID uuid.UUID `json:"staff_id" binding:"required"` + + // Contact info + Email string `json:"email,omitempty"` + Phone string `json:"phone,omitempty"` + Office string `json:"office,omitempty"` + + // Professional info + Position string `json:"position,omitempty"` + PositionType string `json:"position_type,omitempty"` // professor, researcher, phd_student, staff + AcademicTitle string `json:"academic_title,omitempty"` + IsProfessor *bool `json:"is_professor,omitempty"` + DepartmentName string `json:"department_name,omitempty"` + + // Hierarchy + SupervisorName string `json:"supervisor_name,omitempty"` + TeamRole string `json:"team_role,omitempty"` // leitung, mitarbeiter, sekretariat, hiwi, doktorand + + // Research + ResearchInterests []string `json:"research_interests,omitempty"` + ResearchSummary string `json:"research_summary,omitempty"` + + // Teaching (Lehrveranstaltungen) + TeachingTopics []string `json:"teaching_topics,omitempty"` + + // External profiles + ORCID string `json:"orcid,omitempty"` + GoogleScholarID string `json:"google_scholar_id,omitempty"` + ResearchgateURL string `json:"researchgate_url,omitempty"` + LinkedInURL string `json:"linkedin_url,omitempty"` + PersonalWebsite string `json:"personal_website,omitempty"` + PhotoURL string `json:"photo_url,omitempty"` + + // Institute/Department links discovered + InstituteURL string `json:"institute_url,omitempty"` + InstituteName string `json:"institute_name,omitempty"` + + // Confidence score (0-1) + Confidence float64 `json:"confidence,omitempty"` +} + +// SubmitExtractedData saves AI-extracted profile data +// POST /api/v1/ai/extraction/submit +func (h *AIExtractionHandlers) SubmitExtractedData(c *gin.Context) { + var data ExtractedProfileData + if err := c.ShouldBindJSON(&data); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + + // Get existing staff record + staff, err := h.repo.GetStaff(c.Request.Context(), data.StaffID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Staff not found"}) + return + } + + // Update fields if provided and not empty + updated := false + + if data.Email != "" && (staff.Email == nil || *staff.Email == "") { + staff.Email = &data.Email + updated = true + } + if data.Phone != "" && (staff.Phone == nil || *staff.Phone == "") { + staff.Phone = &data.Phone + updated = true + } + if data.Office != "" && (staff.Office == nil || *staff.Office == "") { + staff.Office = &data.Office + updated = true + } + if data.Position != "" && (staff.Position == nil || *staff.Position == "") { + staff.Position = &data.Position + updated = true + } + if data.PositionType != "" && (staff.PositionType == nil || *staff.PositionType == "") { + staff.PositionType = &data.PositionType + updated = true + } + if data.AcademicTitle != "" && (staff.AcademicTitle == nil || *staff.AcademicTitle == "") { + staff.AcademicTitle = &data.AcademicTitle + updated = true + } + if data.IsProfessor != nil { + staff.IsProfessor = *data.IsProfessor + updated = true + } + if data.TeamRole != "" && (staff.TeamRole == nil || *staff.TeamRole == "") { + staff.TeamRole = &data.TeamRole + updated = true + } + if len(data.ResearchInterests) > 0 && len(staff.ResearchInterests) == 0 { + staff.ResearchInterests = data.ResearchInterests + updated = true + } + if data.ResearchSummary != "" && (staff.ResearchSummary == nil || *staff.ResearchSummary == "") { + staff.ResearchSummary = &data.ResearchSummary + updated = true + } + if data.ORCID != "" && (staff.ORCID == nil || *staff.ORCID == "") { + staff.ORCID = &data.ORCID + updated = true + } + if data.GoogleScholarID != "" && (staff.GoogleScholarID == nil || *staff.GoogleScholarID == "") { + staff.GoogleScholarID = &data.GoogleScholarID + updated = true + } + if data.ResearchgateURL != "" && (staff.ResearchgateURL == nil || *staff.ResearchgateURL == "") { + staff.ResearchgateURL = &data.ResearchgateURL + updated = true + } + if data.LinkedInURL != "" && (staff.LinkedInURL == nil || *staff.LinkedInURL == "") { + staff.LinkedInURL = &data.LinkedInURL + updated = true + } + if data.PersonalWebsite != "" && (staff.PersonalWebsite == nil || *staff.PersonalWebsite == "") { + staff.PersonalWebsite = &data.PersonalWebsite + updated = true + } + if data.PhotoURL != "" && (staff.PhotoURL == nil || *staff.PhotoURL == "") { + staff.PhotoURL = &data.PhotoURL + updated = true + } + + // Try to resolve supervisor by name + if data.SupervisorName != "" && staff.SupervisorID == nil { + // Search for supervisor in same university + supervisorParams := database.StaffSearchParams{ + Query: data.SupervisorName, + UniversityID: &staff.UniversityID, + Limit: 1, + } + result, err := h.repo.SearchStaff(c.Request.Context(), supervisorParams) + if err == nil && len(result.Staff) > 0 { + staff.SupervisorID = &result.Staff[0].ID + updated = true + } + } + + // Update last verified timestamp + now := time.Now() + staff.LastVerified = &now + + if updated { + err = h.repo.CreateStaff(c.Request.Context(), staff) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update: " + err.Error()}) + return + } + } + + c.JSON(http.StatusOK, gin.H{ + "status": "success", + "updated": updated, + "staff_id": staff.ID, + }) +} + +// SubmitBatchExtractedData saves multiple AI-extracted profile data items +// POST /api/v1/ai/extraction/submit-batch +func (h *AIExtractionHandlers) SubmitBatchExtractedData(c *gin.Context) { + var batch struct { + Items []ExtractedProfileData `json:"items" binding:"required"` + } + + if err := c.ShouldBindJSON(&batch); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + + results := make([]gin.H, 0, len(batch.Items)) + successCount := 0 + errorCount := 0 + + for _, item := range batch.Items { + // Get existing staff record + staff, err := h.repo.GetStaff(c.Request.Context(), item.StaffID) + if err != nil { + results = append(results, gin.H{ + "staff_id": item.StaffID, + "status": "error", + "error": "Staff not found", + }) + errorCount++ + continue + } + + // Apply updates (same logic as single submit) + updated := false + + if item.Email != "" && (staff.Email == nil || *staff.Email == "") { + staff.Email = &item.Email + updated = true + } + if item.Phone != "" && (staff.Phone == nil || *staff.Phone == "") { + staff.Phone = &item.Phone + updated = true + } + if item.Office != "" && (staff.Office == nil || *staff.Office == "") { + staff.Office = &item.Office + updated = true + } + if item.Position != "" && (staff.Position == nil || *staff.Position == "") { + staff.Position = &item.Position + updated = true + } + if item.PositionType != "" && (staff.PositionType == nil || *staff.PositionType == "") { + staff.PositionType = &item.PositionType + updated = true + } + if item.TeamRole != "" && (staff.TeamRole == nil || *staff.TeamRole == "") { + staff.TeamRole = &item.TeamRole + updated = true + } + if len(item.ResearchInterests) > 0 && len(staff.ResearchInterests) == 0 { + staff.ResearchInterests = item.ResearchInterests + updated = true + } + if item.ORCID != "" && (staff.ORCID == nil || *staff.ORCID == "") { + staff.ORCID = &item.ORCID + updated = true + } + + // Update last verified + now := time.Now() + staff.LastVerified = &now + + if updated { + err = h.repo.CreateStaff(c.Request.Context(), staff) + if err != nil { + results = append(results, gin.H{ + "staff_id": item.StaffID, + "status": "error", + "error": err.Error(), + }) + errorCount++ + continue + } + } + + results = append(results, gin.H{ + "staff_id": item.StaffID, + "status": "success", + "updated": updated, + }) + successCount++ + } + + c.JSON(http.StatusOK, gin.H{ + "results": results, + "success_count": successCount, + "error_count": errorCount, + "total": len(batch.Items), + }) +} + +// InstituteHierarchyTask represents an institute page to crawl for hierarchy +type InstituteHierarchyTask struct { + InstituteURL string `json:"institute_url"` + InstituteName string `json:"institute_name,omitempty"` + UniversityID uuid.UUID `json:"university_id"` +} + +// GetInstitutePages returns institute pages that need hierarchy crawling +// GET /api/v1/ai/extraction/institutes?university_id=... +func (h *AIExtractionHandlers) GetInstitutePages(c *gin.Context) { + var universityID *uuid.UUID + if uniIDStr := c.Query("university_id"); uniIDStr != "" { + id, err := uuid.Parse(uniIDStr) + if err == nil { + universityID = &id + } + } + + // Get unique institute/department URLs from staff profiles + params := database.StaffSearchParams{ + UniversityID: universityID, + Limit: 1000, + } + + result, err := h.repo.SearchStaff(c.Request.Context(), params) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Collect unique source URLs (these are typically department pages) + urlSet := make(map[string]bool) + var tasks []InstituteHierarchyTask + + for _, staff := range result.Staff { + if staff.SourceURL != nil && *staff.SourceURL != "" { + url := *staff.SourceURL + if !urlSet[url] { + urlSet[url] = true + tasks = append(tasks, InstituteHierarchyTask{ + InstituteURL: url, + UniversityID: staff.UniversityID, + }) + } + } + } + + c.JSON(http.StatusOK, gin.H{ + "institutes": tasks, + "total": len(tasks), + }) +} + +// InstituteHierarchyData represents hierarchy data extracted from an institute page +type InstituteHierarchyData struct { + InstituteURL string `json:"institute_url" binding:"required"` + UniversityID uuid.UUID `json:"university_id" binding:"required"` + InstituteName string `json:"institute_name,omitempty"` + + // Leadership + LeaderName string `json:"leader_name,omitempty"` + LeaderTitle string `json:"leader_title,omitempty"` // e.g., "Professor", "Lehrstuhlinhaber" + + // Staff organization + StaffGroups []struct { + Role string `json:"role"` // e.g., "Leitung", "Wissenschaftliche Mitarbeiter", "Sekretariat" + Members []string `json:"members"` // Names of people in this group + } `json:"staff_groups,omitempty"` + + // Teaching info (Lehrveranstaltungen) + TeachingCourses []struct { + Title string `json:"title"` + Teacher string `json:"teacher,omitempty"` + } `json:"teaching_courses,omitempty"` +} + +// SubmitInstituteHierarchy saves hierarchy data from an institute page +// POST /api/v1/ai/extraction/institutes/submit +func (h *AIExtractionHandlers) SubmitInstituteHierarchy(c *gin.Context) { + var data InstituteHierarchyData + if err := c.ShouldBindJSON(&data); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()}) + return + } + + // Find or create department + dept := &database.Department{ + UniversityID: data.UniversityID, + Name: data.InstituteName, + } + if data.InstituteURL != "" { + dept.URL = &data.InstituteURL + } + + err := h.repo.CreateDepartment(c.Request.Context(), dept) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create department: " + err.Error()}) + return + } + + // Find leader and set as supervisor for all staff in this institute + var leaderID *uuid.UUID + if data.LeaderName != "" { + // Search for leader + leaderParams := database.StaffSearchParams{ + Query: data.LeaderName, + UniversityID: &data.UniversityID, + Limit: 1, + } + result, err := h.repo.SearchStaff(c.Request.Context(), leaderParams) + if err == nil && len(result.Staff) > 0 { + leaderID = &result.Staff[0].ID + + // Update leader with department and role + leader := &result.Staff[0] + leader.DepartmentID = &dept.ID + roleLeitung := "leitung" + leader.TeamRole = &roleLeitung + leader.IsProfessor = true + if data.LeaderTitle != "" { + leader.AcademicTitle = &data.LeaderTitle + } + h.repo.CreateStaff(c.Request.Context(), leader) + } + } + + // Process staff groups + updatedCount := 0 + for _, group := range data.StaffGroups { + for _, memberName := range group.Members { + // Find staff member + memberParams := database.StaffSearchParams{ + Query: memberName, + UniversityID: &data.UniversityID, + Limit: 1, + } + result, err := h.repo.SearchStaff(c.Request.Context(), memberParams) + if err != nil || len(result.Staff) == 0 { + continue + } + + member := &result.Staff[0] + member.DepartmentID = &dept.ID + member.TeamRole = &group.Role + + // Set supervisor if leader was found and this is not the leader + if leaderID != nil && member.ID != *leaderID { + member.SupervisorID = leaderID + } + + h.repo.CreateStaff(c.Request.Context(), member) + updatedCount++ + } + } + + c.JSON(http.StatusOK, gin.H{ + "status": "success", + "department_id": dept.ID, + "leader_id": leaderID, + "members_updated": updatedCount, + }) +} + +// RegisterAIExtractionRoutes registers AI extraction routes +func (h *AIExtractionHandlers) RegisterRoutes(r *gin.RouterGroup) { + ai := r.Group("/ai/extraction") + + // Profile extraction endpoints + ai.GET("/pending", h.GetPendingProfiles) + ai.POST("/submit", h.SubmitExtractedData) + ai.POST("/submit-batch", h.SubmitBatchExtractedData) + + // Institute hierarchy endpoints + ai.GET("/institutes", h.GetInstitutePages) + ai.POST("/institutes/submit", h.SubmitInstituteHierarchy) +} diff --git a/edu-search-service/internal/api/handlers/audience_handlers.go b/edu-search-service/internal/api/handlers/audience_handlers.go new file mode 100644 index 0000000..6553a8d --- /dev/null +++ b/edu-search-service/internal/api/handlers/audience_handlers.go @@ -0,0 +1,314 @@ +package handlers + +import ( + "net/http" + "strconv" + + "github.com/breakpilot/edu-search-service/internal/orchestrator" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// AudienceHandler handles audience-related HTTP requests +type AudienceHandler struct { + repo orchestrator.AudienceRepository +} + +// NewAudienceHandler creates a new audience handler +func NewAudienceHandler(repo orchestrator.AudienceRepository) *AudienceHandler { + return &AudienceHandler{repo: repo} +} + +// CreateAudienceRequest represents a request to create an audience +type CreateAudienceRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Filters orchestrator.AudienceFilters `json:"filters"` + CreatedBy string `json:"created_by"` +} + +// UpdateAudienceRequest represents a request to update an audience +type UpdateAudienceRequest struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + Filters orchestrator.AudienceFilters `json:"filters"` + IsActive bool `json:"is_active"` +} + +// CreateExportRequest represents a request to create an export +type CreateExportRequest struct { + ExportType string `json:"export_type" binding:"required"` // csv, json, email_list + Purpose string `json:"purpose"` + ExportedBy string `json:"exported_by"` +} + +// ListAudiences returns all audiences +func (h *AudienceHandler) ListAudiences(c *gin.Context) { + activeOnly := c.Query("active_only") == "true" + + audiences, err := h.repo.ListAudiences(c.Request.Context(), activeOnly) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list audiences", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "audiences": audiences, + "count": len(audiences), + }) +} + +// GetAudience returns a single audience +func (h *AudienceHandler) GetAudience(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid audience ID"}) + return + } + + audience, err := h.repo.GetAudience(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Audience not found", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, audience) +} + +// CreateAudience creates a new audience +func (h *AudienceHandler) CreateAudience(c *gin.Context) { + var req CreateAudienceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) + return + } + + audience := &orchestrator.Audience{ + Name: req.Name, + Description: req.Description, + Filters: req.Filters, + CreatedBy: req.CreatedBy, + IsActive: true, + } + + if err := h.repo.CreateAudience(c.Request.Context(), audience); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create audience", "details": err.Error()}) + return + } + + // Update the member count + count, _ := h.repo.UpdateAudienceCount(c.Request.Context(), audience.ID) + audience.MemberCount = count + + c.JSON(http.StatusCreated, audience) +} + +// UpdateAudience updates an existing audience +func (h *AudienceHandler) UpdateAudience(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid audience ID"}) + return + } + + var req UpdateAudienceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) + return + } + + audience := &orchestrator.Audience{ + ID: id, + Name: req.Name, + Description: req.Description, + Filters: req.Filters, + IsActive: req.IsActive, + } + + if err := h.repo.UpdateAudience(c.Request.Context(), audience); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update audience", "details": err.Error()}) + return + } + + // Update the member count + count, _ := h.repo.UpdateAudienceCount(c.Request.Context(), audience.ID) + audience.MemberCount = count + + c.JSON(http.StatusOK, audience) +} + +// DeleteAudience soft-deletes an audience +func (h *AudienceHandler) DeleteAudience(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid audience ID"}) + return + } + + if err := h.repo.DeleteAudience(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete audience", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"deleted": true, "id": idStr}) +} + +// GetAudienceMembers returns members matching the audience filters +func (h *AudienceHandler) GetAudienceMembers(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid audience ID"}) + return + } + + // Parse pagination + limit := 50 + offset := 0 + if l := c.Query("limit"); l != "" { + if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 500 { + limit = parsed + } + } + if o := c.Query("offset"); o != "" { + if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 { + offset = parsed + } + } + + members, totalCount, err := h.repo.GetAudienceMembers(c.Request.Context(), id, limit, offset) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get members", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "members": members, + "count": len(members), + "total_count": totalCount, + "limit": limit, + "offset": offset, + }) +} + +// RefreshAudienceCount recalculates the member count +func (h *AudienceHandler) RefreshAudienceCount(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid audience ID"}) + return + } + + count, err := h.repo.UpdateAudienceCount(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to refresh count", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "audience_id": idStr, + "member_count": count, + }) +} + +// PreviewAudienceFilters previews the result of filters without saving +func (h *AudienceHandler) PreviewAudienceFilters(c *gin.Context) { + var filters orchestrator.AudienceFilters + if err := c.ShouldBindJSON(&filters); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) + return + } + + // Return the filters for now - preview functionality can be expanded later + c.JSON(http.StatusOK, gin.H{ + "filters": filters, + "message": "Preview functionality requires direct repository access", + }) +} + +// CreateExport creates a new export for an audience +func (h *AudienceHandler) CreateExport(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid audience ID"}) + return + } + + var req CreateExportRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) + return + } + + // Get the member count for the export + _, totalCount, err := h.repo.GetAudienceMembers(c.Request.Context(), id, 1, 0) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get members", "details": err.Error()}) + return + } + + export := &orchestrator.AudienceExport{ + AudienceID: id, + ExportType: req.ExportType, + RecordCount: totalCount, + ExportedBy: req.ExportedBy, + Purpose: req.Purpose, + } + + if err := h.repo.CreateExport(c.Request.Context(), export); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create export", "details": err.Error()}) + return + } + + c.JSON(http.StatusCreated, export) +} + +// ListExports lists exports for an audience +func (h *AudienceHandler) ListExports(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid audience ID"}) + return + } + + exports, err := h.repo.ListExports(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list exports", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "exports": exports, + "count": len(exports), + }) +} + +// SetupAudienceRoutes configures audience API routes +func SetupAudienceRoutes(r *gin.RouterGroup, h *AudienceHandler) { + audiences := r.Group("/audiences") + { + // Audience CRUD + audiences.GET("", h.ListAudiences) + audiences.GET("/:id", h.GetAudience) + audiences.POST("", h.CreateAudience) + audiences.PUT("/:id", h.UpdateAudience) + audiences.DELETE("/:id", h.DeleteAudience) + + // Members + audiences.GET("/:id/members", h.GetAudienceMembers) + audiences.POST("/:id/refresh", h.RefreshAudienceCount) + + // Exports + audiences.GET("/:id/exports", h.ListExports) + audiences.POST("/:id/exports", h.CreateExport) + + // Preview (no audience required) + audiences.POST("/preview", h.PreviewAudienceFilters) + } +} diff --git a/edu-search-service/internal/api/handlers/audience_handlers_test.go b/edu-search-service/internal/api/handlers/audience_handlers_test.go new file mode 100644 index 0000000..d2c9eb9 --- /dev/null +++ b/edu-search-service/internal/api/handlers/audience_handlers_test.go @@ -0,0 +1,630 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/breakpilot/edu-search-service/internal/orchestrator" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// MockAudienceRepository implements orchestrator.AudienceRepository for testing +type MockAudienceRepository struct { + audiences []orchestrator.Audience + exports []orchestrator.AudienceExport + members []orchestrator.AudienceMember +} + +func NewMockAudienceRepository() *MockAudienceRepository { + return &MockAudienceRepository{ + audiences: make([]orchestrator.Audience, 0), + exports: make([]orchestrator.AudienceExport, 0), + members: make([]orchestrator.AudienceMember, 0), + } +} + +func (m *MockAudienceRepository) CreateAudience(ctx context.Context, audience *orchestrator.Audience) error { + audience.ID = uuid.New() + audience.CreatedAt = time.Now() + audience.UpdatedAt = time.Now() + m.audiences = append(m.audiences, *audience) + return nil +} + +func (m *MockAudienceRepository) GetAudience(ctx context.Context, id uuid.UUID) (*orchestrator.Audience, error) { + for i := range m.audiences { + if m.audiences[i].ID == id { + return &m.audiences[i], nil + } + } + return nil, context.DeadlineExceeded // simulate not found +} + +func (m *MockAudienceRepository) ListAudiences(ctx context.Context, activeOnly bool) ([]orchestrator.Audience, error) { + if activeOnly { + var active []orchestrator.Audience + for _, a := range m.audiences { + if a.IsActive { + active = append(active, a) + } + } + return active, nil + } + return m.audiences, nil +} + +func (m *MockAudienceRepository) UpdateAudience(ctx context.Context, audience *orchestrator.Audience) error { + for i := range m.audiences { + if m.audiences[i].ID == audience.ID { + m.audiences[i].Name = audience.Name + m.audiences[i].Description = audience.Description + m.audiences[i].Filters = audience.Filters + m.audiences[i].IsActive = audience.IsActive + m.audiences[i].UpdatedAt = time.Now() + audience.UpdatedAt = m.audiences[i].UpdatedAt + return nil + } + } + return nil +} + +func (m *MockAudienceRepository) DeleteAudience(ctx context.Context, id uuid.UUID) error { + for i := range m.audiences { + if m.audiences[i].ID == id { + m.audiences[i].IsActive = false + return nil + } + } + return nil +} + +func (m *MockAudienceRepository) GetAudienceMembers(ctx context.Context, id uuid.UUID, limit, offset int) ([]orchestrator.AudienceMember, int, error) { + // Return mock members + if len(m.members) == 0 { + m.members = []orchestrator.AudienceMember{ + { + ID: uuid.New(), + Name: "Prof. Dr. Test Person", + Email: "test@university.de", + Position: "professor", + University: "Test Universität", + Department: "Informatik", + SubjectArea: "Informatik", + PublicationCount: 42, + }, + { + ID: uuid.New(), + Name: "Dr. Another Person", + Email: "another@university.de", + Position: "researcher", + University: "Test Universität", + Department: "Mathematik", + SubjectArea: "Mathematik", + PublicationCount: 15, + }, + } + } + + total := len(m.members) + if offset >= total { + return []orchestrator.AudienceMember{}, total, nil + } + + end := offset + limit + if end > total { + end = total + } + + return m.members[offset:end], total, nil +} + +func (m *MockAudienceRepository) UpdateAudienceCount(ctx context.Context, id uuid.UUID) (int, error) { + count := len(m.members) + for i := range m.audiences { + if m.audiences[i].ID == id { + m.audiences[i].MemberCount = count + now := time.Now() + m.audiences[i].LastCountUpdate = &now + } + } + return count, nil +} + +func (m *MockAudienceRepository) CreateExport(ctx context.Context, export *orchestrator.AudienceExport) error { + export.ID = uuid.New() + export.CreatedAt = time.Now() + m.exports = append(m.exports, *export) + return nil +} + +func (m *MockAudienceRepository) ListExports(ctx context.Context, audienceID uuid.UUID) ([]orchestrator.AudienceExport, error) { + var exports []orchestrator.AudienceExport + for _, e := range m.exports { + if e.AudienceID == audienceID { + exports = append(exports, e) + } + } + return exports, nil +} + +func setupAudienceRouter(repo *MockAudienceRepository) *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + + handler := NewAudienceHandler(repo) + + v1 := router.Group("/v1") + SetupAudienceRoutes(v1, handler) + + return router +} + +func TestAudienceHandler_ListAudiences_Empty(t *testing.T) { + repo := NewMockAudienceRepository() + router := setupAudienceRouter(repo) + + req := httptest.NewRequest(http.MethodGet, "/v1/audiences", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + var response struct { + Audiences []orchestrator.Audience `json:"audiences"` + Count int `json:"count"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if response.Count != 0 { + t.Errorf("Expected 0 audiences, got %d", response.Count) + } +} + +func TestAudienceHandler_CreateAudience(t *testing.T) { + repo := NewMockAudienceRepository() + router := setupAudienceRouter(repo) + + body := CreateAudienceRequest{ + Name: "Test Audience", + Description: "A test audience for professors", + Filters: orchestrator.AudienceFilters{ + PositionTypes: []string{"professor"}, + States: []string{"BW", "BY"}, + }, + CreatedBy: "test-admin", + } + + bodyJSON, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, "/v1/audiences", bytes.NewBuffer(bodyJSON)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("Expected status %d, got %d: %s", http.StatusCreated, w.Code, w.Body.String()) + } + + var response orchestrator.Audience + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if response.Name != "Test Audience" { + t.Errorf("Expected name 'Test Audience', got '%s'", response.Name) + } + + if !response.IsActive { + t.Errorf("Expected audience to be active") + } + + if len(repo.audiences) != 1 { + t.Errorf("Expected 1 audience in repo, got %d", len(repo.audiences)) + } +} + +func TestAudienceHandler_CreateAudience_InvalidJSON(t *testing.T) { + repo := NewMockAudienceRepository() + router := setupAudienceRouter(repo) + + req := httptest.NewRequest(http.MethodPost, "/v1/audiences", bytes.NewBuffer([]byte("invalid json"))) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } +} + +func TestAudienceHandler_CreateAudience_MissingName(t *testing.T) { + repo := NewMockAudienceRepository() + router := setupAudienceRouter(repo) + + body := map[string]interface{}{ + "description": "Missing name field", + } + + bodyJSON, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, "/v1/audiences", bytes.NewBuffer(bodyJSON)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } +} + +func TestAudienceHandler_GetAudience(t *testing.T) { + repo := NewMockAudienceRepository() + router := setupAudienceRouter(repo) + + // Create an audience first + audience := orchestrator.Audience{ + ID: uuid.New(), + Name: "Test Audience", + Description: "Test description", + IsActive: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + repo.audiences = append(repo.audiences, audience) + + req := httptest.NewRequest(http.MethodGet, "/v1/audiences/"+audience.ID.String(), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d: %s", http.StatusOK, w.Code, w.Body.String()) + } + + var response orchestrator.Audience + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if response.Name != "Test Audience" { + t.Errorf("Expected name 'Test Audience', got '%s'", response.Name) + } +} + +func TestAudienceHandler_GetAudience_InvalidID(t *testing.T) { + repo := NewMockAudienceRepository() + router := setupAudienceRouter(repo) + + req := httptest.NewRequest(http.MethodGet, "/v1/audiences/invalid-uuid", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status %d, got %d", http.StatusBadRequest, w.Code) + } +} + +func TestAudienceHandler_GetAudience_NotFound(t *testing.T) { + repo := NewMockAudienceRepository() + router := setupAudienceRouter(repo) + + req := httptest.NewRequest(http.MethodGet, "/v1/audiences/"+uuid.New().String(), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("Expected status %d, got %d", http.StatusNotFound, w.Code) + } +} + +func TestAudienceHandler_UpdateAudience(t *testing.T) { + repo := NewMockAudienceRepository() + router := setupAudienceRouter(repo) + + // Create an audience first + audience := orchestrator.Audience{ + ID: uuid.New(), + Name: "Old Name", + Description: "Old description", + IsActive: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + repo.audiences = append(repo.audiences, audience) + + body := UpdateAudienceRequest{ + Name: "New Name", + Description: "New description", + IsActive: true, + } + + bodyJSON, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPut, "/v1/audiences/"+audience.ID.String(), bytes.NewBuffer(bodyJSON)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d: %s", http.StatusOK, w.Code, w.Body.String()) + } + + // Verify the update + if repo.audiences[0].Name != "New Name" { + t.Errorf("Expected name 'New Name', got '%s'", repo.audiences[0].Name) + } +} + +func TestAudienceHandler_DeleteAudience(t *testing.T) { + repo := NewMockAudienceRepository() + router := setupAudienceRouter(repo) + + // Create an audience first + audience := orchestrator.Audience{ + ID: uuid.New(), + Name: "To Delete", + IsActive: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + repo.audiences = append(repo.audiences, audience) + + req := httptest.NewRequest(http.MethodDelete, "/v1/audiences/"+audience.ID.String(), nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + // Verify soft delete + if repo.audiences[0].IsActive { + t.Errorf("Expected audience to be inactive after delete") + } +} + +func TestAudienceHandler_GetAudienceMembers(t *testing.T) { + repo := NewMockAudienceRepository() + router := setupAudienceRouter(repo) + + // Create an audience first + audience := orchestrator.Audience{ + ID: uuid.New(), + Name: "Test Audience", + IsActive: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + repo.audiences = append(repo.audiences, audience) + + req := httptest.NewRequest(http.MethodGet, "/v1/audiences/"+audience.ID.String()+"/members", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d: %s", http.StatusOK, w.Code, w.Body.String()) + } + + var response struct { + Members []orchestrator.AudienceMember `json:"members"` + Count int `json:"count"` + TotalCount int `json:"total_count"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if response.TotalCount != 2 { + t.Errorf("Expected 2 total members, got %d", response.TotalCount) + } +} + +func TestAudienceHandler_GetAudienceMembers_WithPagination(t *testing.T) { + repo := NewMockAudienceRepository() + router := setupAudienceRouter(repo) + + audience := orchestrator.Audience{ + ID: uuid.New(), + Name: "Test Audience", + IsActive: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + repo.audiences = append(repo.audiences, audience) + + req := httptest.NewRequest(http.MethodGet, "/v1/audiences/"+audience.ID.String()+"/members?limit=1&offset=0", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + var response struct { + Members []orchestrator.AudienceMember `json:"members"` + Count int `json:"count"` + Limit int `json:"limit"` + Offset int `json:"offset"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if response.Count != 1 { + t.Errorf("Expected 1 member in response, got %d", response.Count) + } + + if response.Limit != 1 { + t.Errorf("Expected limit 1, got %d", response.Limit) + } +} + +func TestAudienceHandler_RefreshAudienceCount(t *testing.T) { + repo := NewMockAudienceRepository() + router := setupAudienceRouter(repo) + + audience := orchestrator.Audience{ + ID: uuid.New(), + Name: "Test Audience", + IsActive: true, + MemberCount: 0, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + repo.audiences = append(repo.audiences, audience) + + // Pre-initialize members so count works correctly + repo.members = []orchestrator.AudienceMember{ + {ID: uuid.New(), Name: "Test Person 1"}, + {ID: uuid.New(), Name: "Test Person 2"}, + } + + req := httptest.NewRequest(http.MethodPost, "/v1/audiences/"+audience.ID.String()+"/refresh", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + var response struct { + AudienceID string `json:"audience_id"` + MemberCount int `json:"member_count"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if response.MemberCount != 2 { + t.Errorf("Expected member_count 2, got %d", response.MemberCount) + } +} + +func TestAudienceHandler_CreateExport(t *testing.T) { + repo := NewMockAudienceRepository() + router := setupAudienceRouter(repo) + + audience := orchestrator.Audience{ + ID: uuid.New(), + Name: "Test Audience", + IsActive: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + repo.audiences = append(repo.audiences, audience) + + body := CreateExportRequest{ + ExportType: "csv", + Purpose: "Newsletter December 2024", + ExportedBy: "admin", + } + + bodyJSON, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, "/v1/audiences/"+audience.ID.String()+"/exports", bytes.NewBuffer(bodyJSON)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("Expected status %d, got %d: %s", http.StatusCreated, w.Code, w.Body.String()) + } + + var response orchestrator.AudienceExport + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if response.ExportType != "csv" { + t.Errorf("Expected export_type 'csv', got '%s'", response.ExportType) + } + + if response.RecordCount != 2 { + t.Errorf("Expected record_count 2, got %d", response.RecordCount) + } +} + +func TestAudienceHandler_ListExports(t *testing.T) { + repo := NewMockAudienceRepository() + router := setupAudienceRouter(repo) + + audience := orchestrator.Audience{ + ID: uuid.New(), + Name: "Test Audience", + IsActive: true, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + repo.audiences = append(repo.audiences, audience) + + // Add an export + export := orchestrator.AudienceExport{ + ID: uuid.New(), + AudienceID: audience.ID, + ExportType: "csv", + RecordCount: 100, + Purpose: "Test export", + CreatedAt: time.Now(), + } + repo.exports = append(repo.exports, export) + + req := httptest.NewRequest(http.MethodGet, "/v1/audiences/"+audience.ID.String()+"/exports", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + var response struct { + Exports []orchestrator.AudienceExport `json:"exports"` + Count int `json:"count"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if response.Count != 1 { + t.Errorf("Expected 1 export, got %d", response.Count) + } +} + +func TestAudienceHandler_ListAudiences_ActiveOnly(t *testing.T) { + repo := NewMockAudienceRepository() + router := setupAudienceRouter(repo) + + // Add active and inactive audiences + repo.audiences = []orchestrator.Audience{ + {ID: uuid.New(), Name: "Active", IsActive: true, CreatedAt: time.Now(), UpdatedAt: time.Now()}, + {ID: uuid.New(), Name: "Inactive", IsActive: false, CreatedAt: time.Now(), UpdatedAt: time.Now()}, + } + + req := httptest.NewRequest(http.MethodGet, "/v1/audiences?active_only=true", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status %d, got %d", http.StatusOK, w.Code) + } + + var response struct { + Audiences []orchestrator.Audience `json:"audiences"` + Count int `json:"count"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to unmarshal response: %v", err) + } + + if response.Count != 1 { + t.Errorf("Expected 1 active audience, got %d", response.Count) + } + + if response.Audiences[0].Name != "Active" { + t.Errorf("Expected audience 'Active', got '%s'", response.Audiences[0].Name) + } +} diff --git a/edu-search-service/internal/api/handlers/handlers.go b/edu-search-service/internal/api/handlers/handlers.go new file mode 100644 index 0000000..b9e412e --- /dev/null +++ b/edu-search-service/internal/api/handlers/handlers.go @@ -0,0 +1,146 @@ +package handlers + +import ( + "net/http" + + "github.com/breakpilot/edu-search-service/internal/config" + "github.com/breakpilot/edu-search-service/internal/indexer" + "github.com/breakpilot/edu-search-service/internal/search" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// Handler contains all HTTP handlers +type Handler struct { + cfg *config.Config + searchService *search.Service + indexClient *indexer.Client +} + +// NewHandler creates a new handler instance +func NewHandler(cfg *config.Config, searchService *search.Service, indexClient *indexer.Client) *Handler { + return &Handler{ + cfg: cfg, + searchService: searchService, + indexClient: indexClient, + } +} + +// Health returns service health status +func (h *Handler) Health(c *gin.Context) { + status := "ok" + + // Check OpenSearch health + osStatus, err := h.indexClient.Health(c.Request.Context()) + if err != nil { + status = "degraded" + osStatus = "unreachable" + } + + c.JSON(http.StatusOK, gin.H{ + "status": status, + "opensearch": osStatus, + "service": "edu-search-service", + "version": "0.1.0", + }) +} + +// Search handles /v1/search requests +func (h *Handler) Search(c *gin.Context) { + var req search.SearchRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) + return + } + + // Set defaults + if req.Limit <= 0 || req.Limit > 100 { + req.Limit = 10 + } + if req.Mode == "" { + req.Mode = "keyword" // MVP: only BM25 + } + + // Generate query ID + queryID := uuid.New().String() + + // Execute search + result, err := h.searchService.Search(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Search failed", "details": err.Error()}) + return + } + + result.QueryID = queryID + c.JSON(http.StatusOK, result) +} + +// GetDocument retrieves a single document +func (h *Handler) GetDocument(c *gin.Context) { + docID := c.Query("doc_id") + if docID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "doc_id parameter required"}) + return + } + + // TODO: Implement document retrieval + c.JSON(http.StatusNotImplemented, gin.H{"error": "Not implemented yet"}) +} + +// AuthMiddleware validates API keys +func AuthMiddleware(apiKey string) gin.HandlerFunc { + return func(c *gin.Context) { + // Skip auth for health endpoint + if c.Request.URL.Path == "/v1/health" { + c.Next() + return + } + + // Check API key + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Missing Authorization header"}) + return + } + + // Extract Bearer token + if len(authHeader) < 7 || authHeader[:7] != "Bearer " { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid Authorization format"}) + return + } + + token := authHeader[7:] + if apiKey != "" && token != apiKey { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"}) + return + } + + c.Next() + } +} + +// RateLimitMiddleware implements basic rate limiting +func RateLimitMiddleware() gin.HandlerFunc { + // TODO: Implement proper rate limiting with Redis + return func(c *gin.Context) { + c.Next() + } +} + +// SetupRoutes configures all API routes +func SetupRoutes(r *gin.Engine, h *Handler, apiKey string) { + // Health endpoint (no auth) + r.GET("/v1/health", h.Health) + + // API v1 group with auth + v1 := r.Group("/v1") + v1.Use(AuthMiddleware(apiKey)) + v1.Use(RateLimitMiddleware()) + { + v1.POST("/search", h.Search) + v1.GET("/document", h.GetDocument) + + // Admin routes + SetupAdminRoutes(v1, h) + } +} diff --git a/edu-search-service/internal/api/handlers/handlers_test.go b/edu-search-service/internal/api/handlers/handlers_test.go new file mode 100644 index 0000000..aaa9d00 --- /dev/null +++ b/edu-search-service/internal/api/handlers/handlers_test.go @@ -0,0 +1,645 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +// setupTestRouter creates a test router with the handler +func setupTestRouter(h *Handler, apiKey string) *gin.Engine { + router := gin.New() + SetupRoutes(router, h, apiKey) + return router +} + +// setupTestSeedStore creates a test seed store +func setupTestSeedStore(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + // Initialize global seed store + err := InitSeedStore(dir) + if err != nil { + t.Fatalf("Failed to initialize seed store: %v", err) + } + + return dir +} + +func TestHealthEndpoint(t *testing.T) { + // Health endpoint requires indexClient for health check + // This test verifies the route is set up correctly + // A full integration test would need a mock OpenSearch client + t.Skip("Skipping: requires mock indexer client for full test") +} + +func TestAuthMiddleware_NoAuth(t *testing.T) { + h := &Handler{} + router := setupTestRouter(h, "test-api-key") + + // Request without auth header + req, _ := http.NewRequest("POST", "/v1/search", bytes.NewBufferString(`{"q":"test"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status 401, got %d", w.Code) + } +} + +func TestAuthMiddleware_InvalidFormat(t *testing.T) { + h := &Handler{} + router := setupTestRouter(h, "test-api-key") + + // Request with wrong auth format + req, _ := http.NewRequest("POST", "/v1/search", bytes.NewBufferString(`{"q":"test"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Basic dGVzdDp0ZXN0") // Basic auth instead of Bearer + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status 401, got %d", w.Code) + } +} + +func TestAuthMiddleware_InvalidKey(t *testing.T) { + h := &Handler{} + router := setupTestRouter(h, "test-api-key") + + // Request with wrong API key + req, _ := http.NewRequest("POST", "/v1/search", bytes.NewBufferString(`{"q":"test"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer wrong-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status 401, got %d", w.Code) + } +} + +func TestAuthMiddleware_ValidKey(t *testing.T) { + h := &Handler{} + router := setupTestRouter(h, "test-api-key") + + // Request with correct API key (search will fail due to no search service, but auth should pass) + req, _ := http.NewRequest("GET", "/v1/document?doc_id=test", nil) + req.Header.Set("Authorization", "Bearer test-api-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + // Auth should pass, endpoint returns 501 (not implemented) + if w.Code == http.StatusUnauthorized { + t.Error("Expected auth to pass, got 401") + } +} + +func TestAuthMiddleware_HealthNoAuth(t *testing.T) { + // Health endpoint requires indexClient for health check + // Skipping because route calls h.indexClient.Health() which panics with nil + t.Skip("Skipping: requires mock indexer client for full test") +} + +func TestGetDocument_MissingDocID(t *testing.T) { + h := &Handler{} + router := setupTestRouter(h, "test-key") + + req, _ := http.NewRequest("GET", "/v1/document", nil) + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", w.Code) + } +} + +// Admin Handler Tests + +func TestSeedStore_InitAndLoad(t *testing.T) { + dir := t.TempDir() + + // First initialization should create default seeds + err := InitSeedStore(dir) + if err != nil { + t.Fatalf("InitSeedStore failed: %v", err) + } + + // Check that seeds file was created + seedsFile := filepath.Join(dir, "seeds.json") + if _, err := os.Stat(seedsFile); os.IsNotExist(err) { + t.Error("seeds.json was not created") + } + + // Check that default seeds were loaded + seeds := seedStore.GetAllSeeds() + if len(seeds) == 0 { + t.Error("Expected default seeds to be loaded") + } +} + +func TestSeedStore_CreateSeed(t *testing.T) { + setupTestSeedStore(t) + + newSeed := SeedURL{ + URL: "https://test.example.com", + Name: "Test Seed", + Category: "test", + Description: "A test seed", + TrustBoost: 0.5, + Enabled: true, + } + + created, err := seedStore.CreateSeed(newSeed) + if err != nil { + t.Fatalf("CreateSeed failed: %v", err) + } + + if created.ID == "" { + t.Error("Expected generated ID") + } + if created.URL != newSeed.URL { + t.Errorf("Expected URL %q, got %q", newSeed.URL, created.URL) + } + if created.CreatedAt.IsZero() { + t.Error("Expected CreatedAt to be set") + } +} + +func TestSeedStore_GetSeed(t *testing.T) { + setupTestSeedStore(t) + + // Create a seed first + newSeed := SeedURL{ + URL: "https://get-test.example.com", + Name: "Get Test", + Category: "test", + } + created, _ := seedStore.CreateSeed(newSeed) + + // Get the seed + retrieved, found := seedStore.GetSeed(created.ID) + if !found { + t.Fatal("Seed not found") + } + + if retrieved.URL != newSeed.URL { + t.Errorf("Expected URL %q, got %q", newSeed.URL, retrieved.URL) + } +} + +func TestSeedStore_GetSeed_NotFound(t *testing.T) { + setupTestSeedStore(t) + + _, found := seedStore.GetSeed("nonexistent-id") + if found { + t.Error("Expected seed not to be found") + } +} + +func TestSeedStore_UpdateSeed(t *testing.T) { + setupTestSeedStore(t) + + // Create a seed first + original := SeedURL{ + URL: "https://update-test.example.com", + Name: "Original Name", + Category: "test", + Enabled: true, + } + created, _ := seedStore.CreateSeed(original) + + // Update the seed + updates := SeedURL{ + Name: "Updated Name", + TrustBoost: 0.75, + Enabled: false, + } + + updated, found, err := seedStore.UpdateSeed(created.ID, updates) + if err != nil { + t.Fatalf("UpdateSeed failed: %v", err) + } + if !found { + t.Fatal("Seed not found for update") + } + + if updated.Name != "Updated Name" { + t.Errorf("Expected name 'Updated Name', got %q", updated.Name) + } + if updated.TrustBoost != 0.75 { + t.Errorf("Expected TrustBoost 0.75, got %f", updated.TrustBoost) + } + if updated.Enabled != false { + t.Error("Expected Enabled to be false") + } + // URL should remain unchanged since we didn't provide it + if updated.URL != original.URL { + t.Errorf("URL should remain unchanged, expected %q, got %q", original.URL, updated.URL) + } +} + +func TestSeedStore_UpdateSeed_NotFound(t *testing.T) { + setupTestSeedStore(t) + + updates := SeedURL{Name: "New Name"} + _, found, err := seedStore.UpdateSeed("nonexistent-id", updates) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if found { + t.Error("Expected seed not to be found") + } +} + +func TestSeedStore_DeleteSeed(t *testing.T) { + setupTestSeedStore(t) + + // Create a seed first + newSeed := SeedURL{ + URL: "https://delete-test.example.com", + Name: "Delete Test", + Category: "test", + } + created, _ := seedStore.CreateSeed(newSeed) + + // Delete the seed + deleted := seedStore.DeleteSeed(created.ID) + if !deleted { + t.Error("Expected delete to succeed") + } + + // Verify it's gone + _, found := seedStore.GetSeed(created.ID) + if found { + t.Error("Seed should have been deleted") + } +} + +func TestSeedStore_DeleteSeed_NotFound(t *testing.T) { + setupTestSeedStore(t) + + deleted := seedStore.DeleteSeed("nonexistent-id") + if deleted { + t.Error("Expected delete to return false for nonexistent seed") + } +} + +func TestSeedStore_Persistence(t *testing.T) { + dir := t.TempDir() + + // Create and populate seed store + err := InitSeedStore(dir) + if err != nil { + t.Fatal(err) + } + + newSeed := SeedURL{ + URL: "https://persist-test.example.com", + Name: "Persistence Test", + Category: "test", + } + created, err := seedStore.CreateSeed(newSeed) + if err != nil { + t.Fatal(err) + } + + // Re-initialize from the same directory + seedStore = nil + err = InitSeedStore(dir) + if err != nil { + t.Fatal(err) + } + + // Check if the seed persisted + retrieved, found := seedStore.GetSeed(created.ID) + if !found { + t.Error("Seed should have persisted") + } + if retrieved.URL != newSeed.URL { + t.Errorf("Persisted seed URL mismatch: expected %q, got %q", newSeed.URL, retrieved.URL) + } +} + +func TestAdminGetSeeds(t *testing.T) { + dir := setupTestSeedStore(t) + + h := &Handler{} + router := gin.New() + SetupRoutes(router, h, "test-key") + + // Initialize seed store for the test + InitSeedStore(dir) + + req, _ := http.NewRequest("GET", "/v1/admin/seeds", nil) + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var seeds []SeedURL + if err := json.Unmarshal(w.Body.Bytes(), &seeds); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + // Should have default seeds + if len(seeds) == 0 { + t.Error("Expected seeds to be returned") + } +} + +func TestAdminCreateSeed(t *testing.T) { + dir := setupTestSeedStore(t) + + h := &Handler{} + router := gin.New() + SetupRoutes(router, h, "test-key") + InitSeedStore(dir) + + newSeed := map[string]interface{}{ + "url": "https://new-seed.example.com", + "name": "New Seed", + "category": "test", + "description": "Test description", + "trustBoost": 0.5, + "enabled": true, + } + + body, _ := json.Marshal(newSeed) + req, _ := http.NewRequest("POST", "/v1/admin/seeds", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("Expected status 201, got %d: %s", w.Code, w.Body.String()) + } + + var created SeedURL + if err := json.Unmarshal(w.Body.Bytes(), &created); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if created.ID == "" { + t.Error("Expected ID to be generated") + } + if created.URL != "https://new-seed.example.com" { + t.Errorf("Expected URL to match, got %q", created.URL) + } +} + +func TestAdminCreateSeed_MissingURL(t *testing.T) { + dir := setupTestSeedStore(t) + + h := &Handler{} + router := gin.New() + SetupRoutes(router, h, "test-key") + InitSeedStore(dir) + + newSeed := map[string]interface{}{ + "name": "No URL Seed", + "category": "test", + } + + body, _ := json.Marshal(newSeed) + req, _ := http.NewRequest("POST", "/v1/admin/seeds", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400 for missing URL, got %d", w.Code) + } +} + +func TestAdminUpdateSeed(t *testing.T) { + dir := setupTestSeedStore(t) + + h := &Handler{} + router := gin.New() + SetupRoutes(router, h, "test-key") + InitSeedStore(dir) + + // Create a seed first + newSeed := SeedURL{ + URL: "https://update-api-test.example.com", + Name: "API Update Test", + Category: "test", + } + created, _ := seedStore.CreateSeed(newSeed) + + // Update via API + updates := map[string]interface{}{ + "name": "Updated via API", + "trustBoost": 0.8, + } + + body, _ := json.Marshal(updates) + req, _ := http.NewRequest("PUT", "/v1/admin/seeds/"+created.ID, bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + var updated SeedURL + if err := json.Unmarshal(w.Body.Bytes(), &updated); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if updated.Name != "Updated via API" { + t.Errorf("Expected name 'Updated via API', got %q", updated.Name) + } +} + +func TestAdminDeleteSeed(t *testing.T) { + dir := setupTestSeedStore(t) + + h := &Handler{} + router := gin.New() + SetupRoutes(router, h, "test-key") + InitSeedStore(dir) + + // Create a seed first + newSeed := SeedURL{ + URL: "https://delete-api-test.example.com", + Name: "API Delete Test", + Category: "test", + } + created, _ := seedStore.CreateSeed(newSeed) + + // Delete via API + req, _ := http.NewRequest("DELETE", "/v1/admin/seeds/"+created.ID, nil) + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + // Verify it's deleted + _, found := seedStore.GetSeed(created.ID) + if found { + t.Error("Seed should have been deleted") + } +} + +func TestAdminDeleteSeed_NotFound(t *testing.T) { + dir := setupTestSeedStore(t) + + h := &Handler{} + router := gin.New() + SetupRoutes(router, h, "test-key") + InitSeedStore(dir) + + req, _ := http.NewRequest("DELETE", "/v1/admin/seeds/nonexistent-id", nil) + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("Expected status 404, got %d", w.Code) + } +} + +func TestAdminGetStats(t *testing.T) { + dir := setupTestSeedStore(t) + + h := &Handler{} + router := gin.New() + SetupRoutes(router, h, "test-key") + InitSeedStore(dir) + + req, _ := http.NewRequest("GET", "/v1/admin/stats", nil) + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var stats CrawlStats + if err := json.Unmarshal(w.Body.Bytes(), &stats); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + // Check that stats structure is populated + if stats.CrawlStatus == "" { + t.Error("Expected CrawlStatus to be set") + } + if stats.DocumentsPerCategory == nil { + t.Error("Expected DocumentsPerCategory to be set") + } +} + +func TestAdminStartCrawl(t *testing.T) { + dir := setupTestSeedStore(t) + + h := &Handler{} + router := gin.New() + SetupRoutes(router, h, "test-key") + InitSeedStore(dir) + + // Reset crawl status + crawlStatus = "idle" + + req, _ := http.NewRequest("POST", "/v1/admin/crawl/start", nil) + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusAccepted { + t.Errorf("Expected status 202, got %d: %s", w.Code, w.Body.String()) + } + + var response map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if response["status"] != "started" { + t.Errorf("Expected status 'started', got %v", response["status"]) + } +} + +func TestAdminStartCrawl_AlreadyRunning(t *testing.T) { + dir := setupTestSeedStore(t) + + h := &Handler{} + router := gin.New() + SetupRoutes(router, h, "test-key") + InitSeedStore(dir) + + // Set crawl status to running + crawlStatus = "running" + + req, _ := http.NewRequest("POST", "/v1/admin/crawl/start", nil) + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Errorf("Expected status 409, got %d", w.Code) + } + + // Reset for other tests + crawlStatus = "idle" +} + +func TestConcurrentSeedAccess(t *testing.T) { + setupTestSeedStore(t) + + // Test concurrent reads and writes + done := make(chan bool, 10) + + // Concurrent readers + for i := 0; i < 5; i++ { + go func() { + seedStore.GetAllSeeds() + done <- true + }() + } + + // Concurrent writers + for i := 0; i < 5; i++ { + go func(n int) { + seed := SeedURL{ + URL: "https://concurrent-" + string(rune('A'+n)) + ".example.com", + Name: "Concurrent Test", + Category: "test", + } + seedStore.CreateSeed(seed) + done <- true + }(i) + } + + // Wait for all goroutines + for i := 0; i < 10; i++ { + <-done + } + + // If we get here without deadlock or race, test passes +} diff --git a/edu-search-service/internal/api/handlers/orchestrator_handlers.go b/edu-search-service/internal/api/handlers/orchestrator_handlers.go new file mode 100644 index 0000000..b3224e3 --- /dev/null +++ b/edu-search-service/internal/api/handlers/orchestrator_handlers.go @@ -0,0 +1,207 @@ +package handlers + +import ( + "net/http" + + "github.com/breakpilot/edu-search-service/internal/orchestrator" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// OrchestratorHandler handles orchestrator-related HTTP requests +type OrchestratorHandler struct { + orchestrator *orchestrator.Orchestrator + repo orchestrator.Repository +} + +// NewOrchestratorHandler creates a new orchestrator handler +func NewOrchestratorHandler(orch *orchestrator.Orchestrator, repo orchestrator.Repository) *OrchestratorHandler { + return &OrchestratorHandler{ + orchestrator: orch, + repo: repo, + } +} + +// AddToQueueRequest represents a request to add a university to the crawl queue +type AddToQueueRequest struct { + UniversityID string `json:"university_id" binding:"required"` + Priority int `json:"priority"` + InitiatedBy string `json:"initiated_by"` +} + +// GetStatus returns the current orchestrator status +func (h *OrchestratorHandler) GetStatus(c *gin.Context) { + status, err := h.orchestrator.Status(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get status", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, status) +} + +// GetQueue returns all items in the crawl queue +func (h *OrchestratorHandler) GetQueue(c *gin.Context) { + items, err := h.orchestrator.GetQueue(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get queue", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "queue": items, + "count": len(items), + }) +} + +// AddToQueue adds a university to the crawl queue +func (h *OrchestratorHandler) AddToQueue(c *gin.Context) { + var req AddToQueueRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) + return + } + + universityID, err := uuid.Parse(req.UniversityID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid university_id format"}) + return + } + + // Default priority if not specified + priority := req.Priority + if priority == 0 { + priority = 5 + } + + initiatedBy := req.InitiatedBy + if initiatedBy == "" { + initiatedBy = "api" + } + + item, err := h.orchestrator.AddUniversity(c.Request.Context(), universityID, priority, initiatedBy) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add to queue", "details": err.Error()}) + return + } + + c.JSON(http.StatusCreated, item) +} + +// RemoveFromQueue removes a university from the crawl queue +func (h *OrchestratorHandler) RemoveFromQueue(c *gin.Context) { + idStr := c.Param("id") + if idStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "University ID required"}) + return + } + + universityID, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid university_id format"}) + return + } + + if err := h.orchestrator.RemoveUniversity(c.Request.Context(), universityID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove from queue", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"deleted": true, "university_id": idStr}) +} + +// Start starts the orchestrator +func (h *OrchestratorHandler) Start(c *gin.Context) { + if err := h.orchestrator.Start(); err != nil { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": "started", + "message": "Orchestrator started successfully", + }) +} + +// Stop stops the orchestrator +func (h *OrchestratorHandler) Stop(c *gin.Context) { + if err := h.orchestrator.Stop(); err != nil { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": "stopped", + "message": "Orchestrator stopped successfully", + }) +} + +// PauseUniversity pauses crawling for a specific university +func (h *OrchestratorHandler) PauseUniversity(c *gin.Context) { + idStr := c.Param("id") + if idStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "University ID required"}) + return + } + + universityID, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid university_id format"}) + return + } + + if err := h.orchestrator.PauseUniversity(c.Request.Context(), universityID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to pause crawl", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": "paused", + "university_id": idStr, + }) +} + +// ResumeUniversity resumes crawling for a paused university +func (h *OrchestratorHandler) ResumeUniversity(c *gin.Context) { + idStr := c.Param("id") + if idStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "University ID required"}) + return + } + + universityID, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid university_id format"}) + return + } + + if err := h.orchestrator.ResumeUniversity(c.Request.Context(), universityID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resume crawl", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "status": "resumed", + "university_id": idStr, + }) +} + +// SetupOrchestratorRoutes configures orchestrator API routes +func SetupOrchestratorRoutes(r *gin.RouterGroup, h *OrchestratorHandler) { + crawl := r.Group("/crawl") + { + // Orchestrator control + crawl.GET("/status", h.GetStatus) + crawl.POST("/start", h.Start) + crawl.POST("/stop", h.Stop) + + // Queue management + crawl.GET("/queue", h.GetQueue) + crawl.POST("/queue", h.AddToQueue) + crawl.DELETE("/queue/:id", h.RemoveFromQueue) + + // Individual university control + crawl.POST("/queue/:id/pause", h.PauseUniversity) + crawl.POST("/queue/:id/resume", h.ResumeUniversity) + } +} diff --git a/edu-search-service/internal/api/handlers/orchestrator_handlers_test.go b/edu-search-service/internal/api/handlers/orchestrator_handlers_test.go new file mode 100644 index 0000000..71f0cfd --- /dev/null +++ b/edu-search-service/internal/api/handlers/orchestrator_handlers_test.go @@ -0,0 +1,659 @@ +package handlers + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/breakpilot/edu-search-service/internal/orchestrator" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +func init() { + gin.SetMode(gin.TestMode) +} + +// MockRepository implements orchestrator.Repository for testing +type MockRepository struct { + items []orchestrator.CrawlQueueItem + failOnAdd bool + failOnUpdate bool +} + +func NewMockRepository() *MockRepository { + return &MockRepository{ + items: make([]orchestrator.CrawlQueueItem, 0), + } +} + +func (m *MockRepository) GetQueueItems(ctx context.Context) ([]orchestrator.CrawlQueueItem, error) { + return m.items, nil +} + +func (m *MockRepository) GetNextInQueue(ctx context.Context) (*orchestrator.CrawlQueueItem, error) { + for i := range m.items { + if m.items[i].CurrentPhase != orchestrator.PhaseCompleted && + m.items[i].CurrentPhase != orchestrator.PhaseFailed && + m.items[i].CurrentPhase != orchestrator.PhasePaused { + return &m.items[i], nil + } + } + return nil, nil +} + +func (m *MockRepository) AddToQueue(ctx context.Context, universityID uuid.UUID, priority int, initiatedBy string) (*orchestrator.CrawlQueueItem, error) { + if m.failOnAdd { + return nil, context.DeadlineExceeded + } + + position := len(m.items) + 1 + item := orchestrator.CrawlQueueItem{ + ID: uuid.New(), + UniversityID: universityID, + QueuePosition: &position, + Priority: priority, + CurrentPhase: orchestrator.PhasePending, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + m.items = append(m.items, item) + return &item, nil +} + +func (m *MockRepository) RemoveFromQueue(ctx context.Context, universityID uuid.UUID) error { + for i, item := range m.items { + if item.UniversityID == universityID { + m.items = append(m.items[:i], m.items[i+1:]...) + return nil + } + } + return nil +} + +func (m *MockRepository) UpdateQueueItem(ctx context.Context, item *orchestrator.CrawlQueueItem) error { + if m.failOnUpdate { + return context.DeadlineExceeded + } + for i, existing := range m.items { + if existing.UniversityID == item.UniversityID { + m.items[i] = *item + return nil + } + } + return nil +} + +func (m *MockRepository) PauseQueueItem(ctx context.Context, universityID uuid.UUID) error { + for i, item := range m.items { + if item.UniversityID == universityID { + m.items[i].CurrentPhase = orchestrator.PhasePaused + return nil + } + } + return nil +} + +func (m *MockRepository) ResumeQueueItem(ctx context.Context, universityID uuid.UUID) error { + for i, item := range m.items { + if item.UniversityID == universityID && m.items[i].CurrentPhase == orchestrator.PhasePaused { + m.items[i].CurrentPhase = orchestrator.PhasePending + return nil + } + } + return nil +} + +func (m *MockRepository) CompletePhase(ctx context.Context, universityID uuid.UUID, phase orchestrator.CrawlPhase, count int) error { + return nil +} + +func (m *MockRepository) FailPhase(ctx context.Context, universityID uuid.UUID, phase orchestrator.CrawlPhase, errMsg string) error { + return nil +} + +func (m *MockRepository) GetCompletedTodayCount(ctx context.Context) (int, error) { + count := 0 + today := time.Now().Truncate(24 * time.Hour) + for _, item := range m.items { + if item.CurrentPhase == orchestrator.PhaseCompleted && + item.CompletedAt != nil && + item.CompletedAt.After(today) { + count++ + } + } + return count, nil +} + +func (m *MockRepository) GetTotalProcessedCount(ctx context.Context) (int, error) { + count := 0 + for _, item := range m.items { + if item.CurrentPhase == orchestrator.PhaseCompleted { + count++ + } + } + return count, nil +} + +// MockStaffCrawler implements orchestrator.StaffCrawlerInterface +type MockStaffCrawler struct{} + +func (m *MockStaffCrawler) DiscoverSampleProfessor(ctx context.Context, universityID uuid.UUID) (*orchestrator.CrawlProgress, error) { + return &orchestrator.CrawlProgress{ + Phase: orchestrator.PhaseDiscovery, + ItemsFound: 1, + }, nil +} + +func (m *MockStaffCrawler) CrawlProfessors(ctx context.Context, universityID uuid.UUID) (*orchestrator.CrawlProgress, error) { + return &orchestrator.CrawlProgress{ + Phase: orchestrator.PhaseProfessors, + ItemsFound: 10, + }, nil +} + +func (m *MockStaffCrawler) CrawlAllStaff(ctx context.Context, universityID uuid.UUID) (*orchestrator.CrawlProgress, error) { + return &orchestrator.CrawlProgress{ + Phase: orchestrator.PhaseAllStaff, + ItemsFound: 50, + }, nil +} + +// MockPubCrawler implements orchestrator.PublicationCrawlerInterface +type MockPubCrawler struct{} + +func (m *MockPubCrawler) CrawlPublicationsForUniversity(ctx context.Context, universityID uuid.UUID) (*orchestrator.CrawlProgress, error) { + return &orchestrator.CrawlProgress{ + Phase: orchestrator.PhasePublications, + ItemsFound: 100, + }, nil +} + +// setupOrchestratorTestRouter creates a test router with orchestrator handler +func setupOrchestratorTestRouter(orch *orchestrator.Orchestrator, repo orchestrator.Repository, apiKey string) *gin.Engine { + router := gin.New() + + handler := NewOrchestratorHandler(orch, repo) + + v1 := router.Group("/v1") + v1.Use(AuthMiddleware(apiKey)) + SetupOrchestratorRoutes(v1, handler) + + return router +} + +func TestOrchestratorGetStatus(t *testing.T) { + repo := NewMockRepository() + staffCrawler := &MockStaffCrawler{} + pubCrawler := &MockPubCrawler{} + orch := orchestrator.NewOrchestrator(repo, staffCrawler, pubCrawler) + + router := setupOrchestratorTestRouter(orch, repo, "test-key") + + req, _ := http.NewRequest("GET", "/v1/crawl/status", nil) + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + var status orchestrator.OrchestratorStatus + if err := json.Unmarshal(w.Body.Bytes(), &status); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if status.IsRunning != false { + t.Error("Expected orchestrator to not be running initially") + } +} + +func TestOrchestratorGetQueue(t *testing.T) { + repo := NewMockRepository() + staffCrawler := &MockStaffCrawler{} + pubCrawler := &MockPubCrawler{} + orch := orchestrator.NewOrchestrator(repo, staffCrawler, pubCrawler) + + router := setupOrchestratorTestRouter(orch, repo, "test-key") + + req, _ := http.NewRequest("GET", "/v1/crawl/queue", nil) + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + var response struct { + Queue []orchestrator.CrawlQueueItem `json:"queue"` + Count int `json:"count"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if response.Count != 0 { + t.Errorf("Expected empty queue, got %d items", response.Count) + } +} + +func TestOrchestratorAddToQueue(t *testing.T) { + repo := NewMockRepository() + staffCrawler := &MockStaffCrawler{} + pubCrawler := &MockPubCrawler{} + orch := orchestrator.NewOrchestrator(repo, staffCrawler, pubCrawler) + + router := setupOrchestratorTestRouter(orch, repo, "test-key") + + universityID := uuid.New() + reqBody := AddToQueueRequest{ + UniversityID: universityID.String(), + Priority: 7, + InitiatedBy: "test-user", + } + + body, _ := json.Marshal(reqBody) + req, _ := http.NewRequest("POST", "/v1/crawl/queue", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("Expected status 201, got %d: %s", w.Code, w.Body.String()) + } + + var item orchestrator.CrawlQueueItem + if err := json.Unmarshal(w.Body.Bytes(), &item); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if item.UniversityID != universityID { + t.Errorf("Expected universityID %s, got %s", universityID, item.UniversityID) + } + if item.Priority != 7 { + t.Errorf("Expected priority 7, got %d", item.Priority) + } +} + +func TestOrchestratorAddToQueue_InvalidUUID(t *testing.T) { + repo := NewMockRepository() + staffCrawler := &MockStaffCrawler{} + pubCrawler := &MockPubCrawler{} + orch := orchestrator.NewOrchestrator(repo, staffCrawler, pubCrawler) + + router := setupOrchestratorTestRouter(orch, repo, "test-key") + + reqBody := map[string]interface{}{ + "university_id": "not-a-valid-uuid", + "priority": 5, + } + + body, _ := json.Marshal(reqBody) + req, _ := http.NewRequest("POST", "/v1/crawl/queue", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestOrchestratorAddToQueue_MissingUniversityID(t *testing.T) { + repo := NewMockRepository() + staffCrawler := &MockStaffCrawler{} + pubCrawler := &MockPubCrawler{} + orch := orchestrator.NewOrchestrator(repo, staffCrawler, pubCrawler) + + router := setupOrchestratorTestRouter(orch, repo, "test-key") + + reqBody := map[string]interface{}{ + "priority": 5, + } + + body, _ := json.Marshal(reqBody) + req, _ := http.NewRequest("POST", "/v1/crawl/queue", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestOrchestratorRemoveFromQueue(t *testing.T) { + repo := NewMockRepository() + staffCrawler := &MockStaffCrawler{} + pubCrawler := &MockPubCrawler{} + orch := orchestrator.NewOrchestrator(repo, staffCrawler, pubCrawler) + + // Add an item first + universityID := uuid.New() + repo.AddToQueue(context.Background(), universityID, 5, "test") + + router := setupOrchestratorTestRouter(orch, repo, "test-key") + + req, _ := http.NewRequest("DELETE", "/v1/crawl/queue/"+universityID.String(), nil) + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + // Verify it was removed + items, _ := repo.GetQueueItems(context.Background()) + if len(items) != 0 { + t.Errorf("Expected queue to be empty, got %d items", len(items)) + } +} + +func TestOrchestratorRemoveFromQueue_InvalidUUID(t *testing.T) { + repo := NewMockRepository() + staffCrawler := &MockStaffCrawler{} + pubCrawler := &MockPubCrawler{} + orch := orchestrator.NewOrchestrator(repo, staffCrawler, pubCrawler) + + router := setupOrchestratorTestRouter(orch, repo, "test-key") + + req, _ := http.NewRequest("DELETE", "/v1/crawl/queue/invalid-uuid", nil) + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestOrchestratorStartStop(t *testing.T) { + repo := NewMockRepository() + staffCrawler := &MockStaffCrawler{} + pubCrawler := &MockPubCrawler{} + orch := orchestrator.NewOrchestrator(repo, staffCrawler, pubCrawler) + + router := setupOrchestratorTestRouter(orch, repo, "test-key") + + // Start orchestrator + req, _ := http.NewRequest("POST", "/v1/crawl/start", nil) + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200 on start, got %d: %s", w.Code, w.Body.String()) + } + + // Try to start again (should fail) + req, _ = http.NewRequest("POST", "/v1/crawl/start", nil) + req.Header.Set("Authorization", "Bearer test-key") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Errorf("Expected status 409 on duplicate start, got %d", w.Code) + } + + // Stop orchestrator + req, _ = http.NewRequest("POST", "/v1/crawl/stop", nil) + req.Header.Set("Authorization", "Bearer test-key") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200 on stop, got %d: %s", w.Code, w.Body.String()) + } + + // Try to stop again (should fail) + req, _ = http.NewRequest("POST", "/v1/crawl/stop", nil) + req.Header.Set("Authorization", "Bearer test-key") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusConflict { + t.Errorf("Expected status 409 on duplicate stop, got %d", w.Code) + } +} + +func TestOrchestratorPauseResume(t *testing.T) { + repo := NewMockRepository() + staffCrawler := &MockStaffCrawler{} + pubCrawler := &MockPubCrawler{} + orch := orchestrator.NewOrchestrator(repo, staffCrawler, pubCrawler) + + // Add an item first + universityID := uuid.New() + repo.AddToQueue(context.Background(), universityID, 5, "test") + + router := setupOrchestratorTestRouter(orch, repo, "test-key") + + // Pause university + req, _ := http.NewRequest("POST", "/v1/crawl/queue/"+universityID.String()+"/pause", nil) + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200 on pause, got %d: %s", w.Code, w.Body.String()) + } + + // Verify it's paused + items, _ := repo.GetQueueItems(context.Background()) + if len(items) != 1 || items[0].CurrentPhase != orchestrator.PhasePaused { + t.Errorf("Expected item to be paused, got phase %s", items[0].CurrentPhase) + } + + // Resume university + req, _ = http.NewRequest("POST", "/v1/crawl/queue/"+universityID.String()+"/resume", nil) + req.Header.Set("Authorization", "Bearer test-key") + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200 on resume, got %d: %s", w.Code, w.Body.String()) + } + + // Verify it's resumed + items, _ = repo.GetQueueItems(context.Background()) + if len(items) != 1 || items[0].CurrentPhase == orchestrator.PhasePaused { + t.Errorf("Expected item to not be paused, got phase %s", items[0].CurrentPhase) + } +} + +func TestOrchestratorPause_InvalidUUID(t *testing.T) { + repo := NewMockRepository() + staffCrawler := &MockStaffCrawler{} + pubCrawler := &MockPubCrawler{} + orch := orchestrator.NewOrchestrator(repo, staffCrawler, pubCrawler) + + router := setupOrchestratorTestRouter(orch, repo, "test-key") + + req, _ := http.NewRequest("POST", "/v1/crawl/queue/invalid-uuid/pause", nil) + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestOrchestratorNoAuth(t *testing.T) { + repo := NewMockRepository() + staffCrawler := &MockStaffCrawler{} + pubCrawler := &MockPubCrawler{} + orch := orchestrator.NewOrchestrator(repo, staffCrawler, pubCrawler) + + router := setupOrchestratorTestRouter(orch, repo, "test-key") + + // Request without auth + req, _ := http.NewRequest("GET", "/v1/crawl/status", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status 401, got %d", w.Code) + } +} + +func TestOrchestratorDefaultPriority(t *testing.T) { + repo := NewMockRepository() + staffCrawler := &MockStaffCrawler{} + pubCrawler := &MockPubCrawler{} + orch := orchestrator.NewOrchestrator(repo, staffCrawler, pubCrawler) + + router := setupOrchestratorTestRouter(orch, repo, "test-key") + + // Add without priority (should default to 5) + universityID := uuid.New() + reqBody := AddToQueueRequest{ + UniversityID: universityID.String(), + // Priority and InitiatedBy omitted + } + + body, _ := json.Marshal(reqBody) + req, _ := http.NewRequest("POST", "/v1/crawl/queue", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusCreated { + t.Errorf("Expected status 201, got %d: %s", w.Code, w.Body.String()) + } + + var item orchestrator.CrawlQueueItem + if err := json.Unmarshal(w.Body.Bytes(), &item); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if item.Priority != 5 { + t.Errorf("Expected default priority 5, got %d", item.Priority) + } +} + +// TestOrchestratorQueueWithNullableFields tests that queue items with NULL values +// for optional fields (UniversityShort, LastError) are handled correctly. +// This tests the COALESCE fix in repository.go that prevents NULL scan errors. +func TestOrchestratorQueueWithNullableFields(t *testing.T) { + repo := NewMockRepository() + staffCrawler := &MockStaffCrawler{} + pubCrawler := &MockPubCrawler{} + orch := orchestrator.NewOrchestrator(repo, staffCrawler, pubCrawler) + + // Add item with empty optional fields (simulates NULL from DB) + universityID := uuid.New() + item := orchestrator.CrawlQueueItem{ + ID: uuid.New(), + UniversityID: universityID, + UniversityName: "Test Universität", + UniversityShort: "", // Empty string (COALESCE converts NULL to '') + CurrentPhase: orchestrator.PhasePending, + LastError: "", // Empty string (COALESCE converts NULL to '') + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + position := 1 + item.QueuePosition = &position + repo.items = append(repo.items, item) + + router := setupOrchestratorTestRouter(orch, repo, "test-key") + + req, _ := http.NewRequest("GET", "/v1/crawl/queue", nil) + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + var response struct { + Queue []orchestrator.CrawlQueueItem `json:"queue"` + Count int `json:"count"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if response.Count != 1 { + t.Errorf("Expected 1 item in queue, got %d", response.Count) + } + + // Verify empty strings are preserved (not NULL) + if response.Queue[0].UniversityShort != "" { + t.Errorf("Expected empty UniversityShort, got %q", response.Queue[0].UniversityShort) + } + if response.Queue[0].LastError != "" { + t.Errorf("Expected empty LastError, got %q", response.Queue[0].LastError) + } +} + +// TestOrchestratorQueueWithLastError tests that queue items with an error message +// are correctly serialized and returned. +func TestOrchestratorQueueWithLastError(t *testing.T) { + repo := NewMockRepository() + staffCrawler := &MockStaffCrawler{} + pubCrawler := &MockPubCrawler{} + orch := orchestrator.NewOrchestrator(repo, staffCrawler, pubCrawler) + + // Add item with an error + universityID := uuid.New() + item := orchestrator.CrawlQueueItem{ + ID: uuid.New(), + UniversityID: universityID, + UniversityName: "Test Universität mit Fehler", + UniversityShort: "TUmF", + CurrentPhase: orchestrator.PhaseFailed, + LastError: "connection timeout after 30s", + RetryCount: 3, + MaxRetries: 3, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + position := 1 + item.QueuePosition = &position + repo.items = append(repo.items, item) + + router := setupOrchestratorTestRouter(orch, repo, "test-key") + + req, _ := http.NewRequest("GET", "/v1/crawl/queue", nil) + req.Header.Set("Authorization", "Bearer test-key") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + var response struct { + Queue []orchestrator.CrawlQueueItem `json:"queue"` + Count int `json:"count"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if response.Count != 1 { + t.Errorf("Expected 1 item in queue, got %d", response.Count) + } + + // Verify error message is preserved + if response.Queue[0].LastError != "connection timeout after 30s" { + t.Errorf("Expected LastError to be 'connection timeout after 30s', got %q", response.Queue[0].LastError) + } + if response.Queue[0].UniversityShort != "TUmF" { + t.Errorf("Expected UniversityShort 'TUmF', got %q", response.Queue[0].UniversityShort) + } +} diff --git a/edu-search-service/internal/api/handlers/policy_handlers.go b/edu-search-service/internal/api/handlers/policy_handlers.go new file mode 100644 index 0000000..27f292a --- /dev/null +++ b/edu-search-service/internal/api/handlers/policy_handlers.go @@ -0,0 +1,700 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/breakpilot/edu-search-service/internal/policy" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// PolicyHandler contains all policy-related HTTP handlers. +type PolicyHandler struct { + store *policy.Store + enforcer *policy.Enforcer +} + +// policyHandler is the singleton instance +var policyHandler *PolicyHandler + +// InitPolicyHandler initializes the policy handler with a database pool. +func InitPolicyHandler(store *policy.Store) { + policyHandler = &PolicyHandler{ + store: store, + enforcer: policy.NewEnforcer(store), + } +} + +// GetPolicyHandler returns the policy handler instance. +func GetPolicyHandler() *PolicyHandler { + return policyHandler +} + +// ============================================================================= +// POLICIES +// ============================================================================= + +// ListPolicies returns all source policies. +func (h *PolicyHandler) ListPolicies(c *gin.Context) { + var filter policy.PolicyListFilter + if err := c.ShouldBindQuery(&filter); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters", "details": err.Error()}) + return + } + + // Set defaults + if filter.Limit <= 0 || filter.Limit > 100 { + filter.Limit = 50 + } + + policies, total, err := h.store.ListPolicies(c.Request.Context(), &filter) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list policies", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "policies": policies, + "total": total, + "limit": filter.Limit, + "offset": filter.Offset, + }) +} + +// GetPolicy returns a single policy by ID. +func (h *PolicyHandler) GetPolicy(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid policy ID"}) + return + } + + p, err := h.store.GetPolicy(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get policy", "details": err.Error()}) + return + } + if p == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Policy not found"}) + return + } + + c.JSON(http.StatusOK, p) +} + +// CreatePolicy creates a new source policy. +func (h *PolicyHandler) CreatePolicy(c *gin.Context) { + var req policy.CreateSourcePolicyRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) + return + } + + p, err := h.store.CreatePolicy(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create policy", "details": err.Error()}) + return + } + + // Log audit + userEmail := getUserEmail(c) + h.enforcer.LogChange(c.Request.Context(), policy.AuditActionCreate, policy.AuditEntitySourcePolicy, &p.ID, nil, p, userEmail) + + c.JSON(http.StatusCreated, p) +} + +// UpdatePolicy updates an existing policy. +func (h *PolicyHandler) UpdatePolicy(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid policy ID"}) + return + } + + // Get old value for audit + oldPolicy, err := h.store.GetPolicy(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get policy", "details": err.Error()}) + return + } + if oldPolicy == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Policy not found"}) + return + } + + var req policy.UpdateSourcePolicyRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) + return + } + + p, err := h.store.UpdatePolicy(c.Request.Context(), id, &req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update policy", "details": err.Error()}) + return + } + + // Log audit + userEmail := getUserEmail(c) + h.enforcer.LogChange(c.Request.Context(), policy.AuditActionUpdate, policy.AuditEntitySourcePolicy, &p.ID, oldPolicy, p, userEmail) + + c.JSON(http.StatusOK, p) +} + +// ============================================================================= +// SOURCES (WHITELIST) +// ============================================================================= + +// ListSources returns all allowed sources. +func (h *PolicyHandler) ListSources(c *gin.Context) { + var filter policy.SourceListFilter + if err := c.ShouldBindQuery(&filter); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters", "details": err.Error()}) + return + } + + // Set defaults + if filter.Limit <= 0 || filter.Limit > 100 { + filter.Limit = 50 + } + + sources, total, err := h.store.ListSources(c.Request.Context(), &filter) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list sources", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "sources": sources, + "total": total, + "limit": filter.Limit, + "offset": filter.Offset, + }) +} + +// GetSource returns a single source by ID. +func (h *PolicyHandler) GetSource(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid source ID"}) + return + } + + source, err := h.store.GetSource(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get source", "details": err.Error()}) + return + } + if source == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Source not found"}) + return + } + + c.JSON(http.StatusOK, source) +} + +// CreateSource creates a new allowed source. +func (h *PolicyHandler) CreateSource(c *gin.Context) { + var req policy.CreateAllowedSourceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) + return + } + + source, err := h.store.CreateSource(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create source", "details": err.Error()}) + return + } + + // Log audit + userEmail := getUserEmail(c) + h.enforcer.LogChange(c.Request.Context(), policy.AuditActionCreate, policy.AuditEntityAllowedSource, &source.ID, nil, source, userEmail) + + c.JSON(http.StatusCreated, source) +} + +// UpdateSource updates an existing source. +func (h *PolicyHandler) UpdateSource(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid source ID"}) + return + } + + // Get old value for audit + oldSource, err := h.store.GetSource(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get source", "details": err.Error()}) + return + } + if oldSource == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Source not found"}) + return + } + + var req policy.UpdateAllowedSourceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) + return + } + + source, err := h.store.UpdateSource(c.Request.Context(), id, &req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update source", "details": err.Error()}) + return + } + + // Log audit + userEmail := getUserEmail(c) + h.enforcer.LogChange(c.Request.Context(), policy.AuditActionUpdate, policy.AuditEntityAllowedSource, &source.ID, oldSource, source, userEmail) + + c.JSON(http.StatusOK, source) +} + +// DeleteSource deletes a source. +func (h *PolicyHandler) DeleteSource(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid source ID"}) + return + } + + // Get source for audit before deletion + source, err := h.store.GetSource(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get source", "details": err.Error()}) + return + } + if source == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Source not found"}) + return + } + + if err := h.store.DeleteSource(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete source", "details": err.Error()}) + return + } + + // Log audit + userEmail := getUserEmail(c) + h.enforcer.LogChange(c.Request.Context(), policy.AuditActionDelete, policy.AuditEntityAllowedSource, &id, source, nil, userEmail) + + c.JSON(http.StatusOK, gin.H{"deleted": true, "id": id}) +} + +// ============================================================================= +// OPERATIONS MATRIX +// ============================================================================= + +// GetOperationsMatrix returns all sources with their operation permissions. +func (h *PolicyHandler) GetOperationsMatrix(c *gin.Context) { + sources, err := h.store.GetOperationsMatrix(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get operations matrix", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "sources": sources, + "operations": []string{ + string(policy.OperationLookup), + string(policy.OperationRAG), + string(policy.OperationTraining), + string(policy.OperationExport), + }, + }) +} + +// UpdateOperationPermission updates a single operation permission. +func (h *PolicyHandler) UpdateOperationPermission(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid operation permission ID"}) + return + } + + var req policy.UpdateOperationPermissionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) + return + } + + // SECURITY: Prevent enabling training + if req.IsAllowed != nil && *req.IsAllowed { + // Check if this is a training operation by querying + ops, _ := h.store.GetOperationsBySourceID(c.Request.Context(), id) + for _, op := range ops { + if op.ID == id && op.Operation == policy.OperationTraining { + c.JSON(http.StatusForbidden, gin.H{ + "error": "Training operations cannot be enabled", + "message": "Training with external data is FORBIDDEN by policy", + }) + return + } + } + } + + op, err := h.store.UpdateOperationPermission(c.Request.Context(), id, &req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update operation permission", "details": err.Error()}) + return + } + + // Log audit + userEmail := getUserEmail(c) + h.enforcer.LogChange(c.Request.Context(), policy.AuditActionUpdate, policy.AuditEntityOperationPermission, &op.ID, nil, op, userEmail) + + c.JSON(http.StatusOK, op) +} + +// ============================================================================= +// PII RULES +// ============================================================================= + +// ListPIIRules returns all PII detection rules. +func (h *PolicyHandler) ListPIIRules(c *gin.Context) { + activeOnly := c.Query("active_only") == "true" + + rules, err := h.store.ListPIIRules(c.Request.Context(), activeOnly) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list PII rules", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "rules": rules, + "total": len(rules), + }) +} + +// GetPIIRule returns a single PII rule by ID. +func (h *PolicyHandler) GetPIIRule(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid PII rule ID"}) + return + } + + rule, err := h.store.GetPIIRule(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get PII rule", "details": err.Error()}) + return + } + if rule == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "PII rule not found"}) + return + } + + c.JSON(http.StatusOK, rule) +} + +// CreatePIIRule creates a new PII detection rule. +func (h *PolicyHandler) CreatePIIRule(c *gin.Context) { + var req policy.CreatePIIRuleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) + return + } + + rule, err := h.store.CreatePIIRule(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create PII rule", "details": err.Error()}) + return + } + + // Log audit + userEmail := getUserEmail(c) + h.enforcer.LogChange(c.Request.Context(), policy.AuditActionCreate, policy.AuditEntityPIIRule, &rule.ID, nil, rule, userEmail) + + c.JSON(http.StatusCreated, rule) +} + +// UpdatePIIRule updates an existing PII rule. +func (h *PolicyHandler) UpdatePIIRule(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid PII rule ID"}) + return + } + + // Get old value for audit + oldRule, err := h.store.GetPIIRule(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get PII rule", "details": err.Error()}) + return + } + if oldRule == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "PII rule not found"}) + return + } + + var req policy.UpdatePIIRuleRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) + return + } + + rule, err := h.store.UpdatePIIRule(c.Request.Context(), id, &req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update PII rule", "details": err.Error()}) + return + } + + // Log audit + userEmail := getUserEmail(c) + h.enforcer.LogChange(c.Request.Context(), policy.AuditActionUpdate, policy.AuditEntityPIIRule, &rule.ID, oldRule, rule, userEmail) + + c.JSON(http.StatusOK, rule) +} + +// DeletePIIRule deletes a PII rule. +func (h *PolicyHandler) DeletePIIRule(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid PII rule ID"}) + return + } + + // Get rule for audit before deletion + rule, err := h.store.GetPIIRule(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get PII rule", "details": err.Error()}) + return + } + if rule == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "PII rule not found"}) + return + } + + if err := h.store.DeletePIIRule(c.Request.Context(), id); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete PII rule", "details": err.Error()}) + return + } + + // Log audit + userEmail := getUserEmail(c) + h.enforcer.LogChange(c.Request.Context(), policy.AuditActionDelete, policy.AuditEntityPIIRule, &id, rule, nil, userEmail) + + c.JSON(http.StatusOK, gin.H{"deleted": true, "id": id}) +} + +// TestPIIRules tests PII detection against sample text. +func (h *PolicyHandler) TestPIIRules(c *gin.Context) { + var req policy.PIITestRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) + return + } + + response, err := h.enforcer.DetectPII(c.Request.Context(), req.Text) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to test PII detection", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, response) +} + +// ============================================================================= +// AUDIT & COMPLIANCE +// ============================================================================= + +// ListAuditLogs returns audit log entries. +func (h *PolicyHandler) ListAuditLogs(c *gin.Context) { + var filter policy.AuditLogFilter + if err := c.ShouldBindQuery(&filter); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters", "details": err.Error()}) + return + } + + // Set defaults + if filter.Limit <= 0 || filter.Limit > 500 { + filter.Limit = 100 + } + + logs, total, err := h.store.ListAuditLogs(c.Request.Context(), &filter) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list audit logs", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "logs": logs, + "total": total, + "limit": filter.Limit, + "offset": filter.Offset, + }) +} + +// ListBlockedContent returns blocked content log entries. +func (h *PolicyHandler) ListBlockedContent(c *gin.Context) { + var filter policy.BlockedContentFilter + if err := c.ShouldBindQuery(&filter); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters", "details": err.Error()}) + return + } + + // Set defaults + if filter.Limit <= 0 || filter.Limit > 500 { + filter.Limit = 100 + } + + logs, total, err := h.store.ListBlockedContent(c.Request.Context(), &filter) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list blocked content", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "blocked": logs, + "total": total, + "limit": filter.Limit, + "offset": filter.Offset, + }) +} + +// CheckCompliance performs a compliance check for a URL. +func (h *PolicyHandler) CheckCompliance(c *gin.Context) { + var req policy.CheckComplianceRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body", "details": err.Error()}) + return + } + + response, err := h.enforcer.CheckCompliance(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check compliance", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, response) +} + +// GetPolicyStats returns aggregated statistics. +func (h *PolicyHandler) GetPolicyStats(c *gin.Context) { + stats, err := h.store.GetStats(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get stats", "details": err.Error()}) + return + } + + c.JSON(http.StatusOK, stats) +} + +// GenerateComplianceReport generates an audit report. +func (h *PolicyHandler) GenerateComplianceReport(c *gin.Context) { + var auditFilter policy.AuditLogFilter + var blockedFilter policy.BlockedContentFilter + + // Parse date filters + fromStr := c.Query("from") + toStr := c.Query("to") + + if fromStr != "" { + from, err := time.Parse("2006-01-02", fromStr) + if err == nil { + auditFilter.FromDate = &from + blockedFilter.FromDate = &from + } + } + + if toStr != "" { + to, err := time.Parse("2006-01-02", toStr) + if err == nil { + // Add 1 day to include the end date + to = to.Add(24 * time.Hour) + auditFilter.ToDate = &to + blockedFilter.ToDate = &to + } + } + + // No limit for report + auditFilter.Limit = 10000 + blockedFilter.Limit = 10000 + + auditor := policy.NewAuditor(h.store) + report, err := auditor.GenerateAuditReport(c.Request.Context(), &auditFilter, &blockedFilter) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate report", "details": err.Error()}) + return + } + + // Set filename for download + format := c.Query("format") + if format == "download" { + filename := "compliance-report-" + time.Now().Format("2006-01-02") + ".json" + c.Header("Content-Disposition", "attachment; filename="+filename) + c.Header("Content-Type", "application/json") + } + + c.JSON(http.StatusOK, report) +} + +// ============================================================================= +// HELPERS +// ============================================================================= + +// getUserEmail extracts user email from context or headers. +func getUserEmail(c *gin.Context) *string { + // Try to get from header (set by auth proxy) + email := c.GetHeader("X-User-Email") + if email != "" { + return &email + } + + // Try to get from context (set by auth middleware) + if e, exists := c.Get("user_email"); exists { + if emailStr, ok := e.(string); ok { + return &emailStr + } + } + + return nil +} + +// ============================================================================= +// ROUTE SETUP +// ============================================================================= + +// SetupPolicyRoutes configures all policy-related routes. +func SetupPolicyRoutes(r *gin.RouterGroup) { + if policyHandler == nil { + return + } + + h := policyHandler + + // Policies + r.GET("/policies", h.ListPolicies) + r.GET("/policies/:id", h.GetPolicy) + r.POST("/policies", h.CreatePolicy) + r.PUT("/policies/:id", h.UpdatePolicy) + + // Sources (Whitelist) + r.GET("/sources", h.ListSources) + r.GET("/sources/:id", h.GetSource) + r.POST("/sources", h.CreateSource) + r.PUT("/sources/:id", h.UpdateSource) + r.DELETE("/sources/:id", h.DeleteSource) + + // Operations Matrix + r.GET("/operations-matrix", h.GetOperationsMatrix) + r.PUT("/operations/:id", h.UpdateOperationPermission) + + // PII Rules + r.GET("/pii-rules", h.ListPIIRules) + r.GET("/pii-rules/:id", h.GetPIIRule) + r.POST("/pii-rules", h.CreatePIIRule) + r.PUT("/pii-rules/:id", h.UpdatePIIRule) + r.DELETE("/pii-rules/:id", h.DeletePIIRule) + r.POST("/pii-rules/test", h.TestPIIRules) + + // Audit & Compliance + r.GET("/policy-audit", h.ListAuditLogs) + r.GET("/blocked-content", h.ListBlockedContent) + r.POST("/check-compliance", h.CheckCompliance) + r.GET("/policy-stats", h.GetPolicyStats) + r.GET("/compliance-report", h.GenerateComplianceReport) +} diff --git a/edu-search-service/internal/api/handlers/staff_handlers.go b/edu-search-service/internal/api/handlers/staff_handlers.go new file mode 100644 index 0000000..4e3350a --- /dev/null +++ b/edu-search-service/internal/api/handlers/staff_handlers.go @@ -0,0 +1,374 @@ +package handlers + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + + "github.com/breakpilot/edu-search-service/internal/database" + "github.com/breakpilot/edu-search-service/internal/publications" + "github.com/breakpilot/edu-search-service/internal/staff" +) + +// StaffHandlers handles staff-related API endpoints +type StaffHandlers struct { + repo *database.Repository + crawler *staff.StaffCrawler + pubCrawler *publications.PublicationCrawler +} + +// NewStaffHandlers creates new staff handlers +func NewStaffHandlers(repo *database.Repository, email string) *StaffHandlers { + return &StaffHandlers{ + repo: repo, + crawler: staff.NewStaffCrawler(repo), + pubCrawler: publications.NewPublicationCrawler(repo, email), + } +} + +// SearchStaff searches for university staff +// GET /api/v1/staff/search?q=...&university_id=...&state=...&position_type=...&is_professor=... +func (h *StaffHandlers) SearchStaff(c *gin.Context) { + params := database.StaffSearchParams{ + Query: c.Query("q"), + Limit: parseIntDefault(c.Query("limit"), 20), + Offset: parseIntDefault(c.Query("offset"), 0), + } + + // Optional filters + if uniID := c.Query("university_id"); uniID != "" { + id, err := uuid.Parse(uniID) + if err == nil { + params.UniversityID = &id + } + } + + if deptID := c.Query("department_id"); deptID != "" { + id, err := uuid.Parse(deptID) + if err == nil { + params.DepartmentID = &id + } + } + + if state := c.Query("state"); state != "" { + params.State = &state + } + + if uniType := c.Query("uni_type"); uniType != "" { + params.UniType = &uniType + } + + if posType := c.Query("position_type"); posType != "" { + params.PositionType = &posType + } + + if isProfStr := c.Query("is_professor"); isProfStr != "" { + isProf := isProfStr == "true" || isProfStr == "1" + params.IsProfessor = &isProf + } + + result, err := h.repo.SearchStaff(c.Request.Context(), params) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + +// GetStaff gets a single staff member by ID +// GET /api/v1/staff/:id +func (h *StaffHandlers) GetStaff(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid staff ID"}) + return + } + + staff, err := h.repo.GetStaff(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Staff not found"}) + return + } + + c.JSON(http.StatusOK, staff) +} + +// GetStaffPublications gets publications for a staff member +// GET /api/v1/staff/:id/publications +func (h *StaffHandlers) GetStaffPublications(c *gin.Context) { + idStr := c.Param("id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid staff ID"}) + return + } + + pubs, err := h.repo.GetStaffPublications(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "publications": pubs, + "total": len(pubs), + "staff_id": id, + }) +} + +// SearchPublications searches for publications +// GET /api/v1/publications/search?q=...&year=...&pub_type=... +func (h *StaffHandlers) SearchPublications(c *gin.Context) { + params := database.PublicationSearchParams{ + Query: c.Query("q"), + Limit: parseIntDefault(c.Query("limit"), 20), + Offset: parseIntDefault(c.Query("offset"), 0), + } + + if staffID := c.Query("staff_id"); staffID != "" { + id, err := uuid.Parse(staffID) + if err == nil { + params.StaffID = &id + } + } + + if year := c.Query("year"); year != "" { + y := parseIntDefault(year, 0) + if y > 0 { + params.Year = &y + } + } + + if yearFrom := c.Query("year_from"); yearFrom != "" { + y := parseIntDefault(yearFrom, 0) + if y > 0 { + params.YearFrom = &y + } + } + + if yearTo := c.Query("year_to"); yearTo != "" { + y := parseIntDefault(yearTo, 0) + if y > 0 { + params.YearTo = &y + } + } + + if pubType := c.Query("pub_type"); pubType != "" { + params.PubType = &pubType + } + + result, err := h.repo.SearchPublications(c.Request.Context(), params) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, result) +} + +// GetStaffStats gets statistics about staff data +// GET /api/v1/staff/stats +func (h *StaffHandlers) GetStaffStats(c *gin.Context) { + stats, err := h.repo.GetStaffStats(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, stats) +} + +// ListUniversities lists all universities +// GET /api/v1/universities +func (h *StaffHandlers) ListUniversities(c *gin.Context) { + universities, err := h.repo.ListUniversities(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "universities": universities, + "total": len(universities), + }) +} + +// StartStaffCrawl starts a staff crawl for a university +// POST /api/v1/admin/crawl/staff +func (h *StaffHandlers) StartStaffCrawl(c *gin.Context) { + var req struct { + UniversityID string `json:"university_id"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) + return + } + + uniID, err := uuid.Parse(req.UniversityID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid university ID"}) + return + } + + uni, err := h.repo.GetUniversity(c.Request.Context(), uniID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "University not found"}) + return + } + + // Start crawl in background + go func() { + result, err := h.crawler.CrawlUniversity(c.Request.Context(), uni) + if err != nil { + // Log error + return + } + _ = result + }() + + c.JSON(http.StatusAccepted, gin.H{ + "status": "started", + "university_id": uniID, + "message": "Staff crawl started in background", + }) +} + +// StartPublicationCrawl starts a publication crawl for a university +// POST /api/v1/admin/crawl/publications +func (h *StaffHandlers) StartPublicationCrawl(c *gin.Context) { + var req struct { + UniversityID string `json:"university_id"` + Limit int `json:"limit"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"}) + return + } + + uniID, err := uuid.Parse(req.UniversityID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid university ID"}) + return + } + + limit := req.Limit + if limit <= 0 { + limit = 50 + } + + // Start crawl in background + go func() { + status, err := h.pubCrawler.CrawlForUniversity(c.Request.Context(), uniID, limit) + if err != nil { + // Log error + return + } + _ = status + }() + + c.JSON(http.StatusAccepted, gin.H{ + "status": "started", + "university_id": uniID, + "message": "Publication crawl started in background", + }) +} + +// ResolveDOI resolves a DOI and saves the publication +// POST /api/v1/publications/resolve-doi +func (h *StaffHandlers) ResolveDOI(c *gin.Context) { + var req struct { + DOI string `json:"doi"` + StaffID string `json:"staff_id,omitempty"` + } + + if err := c.ShouldBindJSON(&req); err != nil || req.DOI == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "DOI is required"}) + return + } + + pub, err := h.pubCrawler.ResolveDOI(c.Request.Context(), req.DOI) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Link to staff if provided + if req.StaffID != "" { + staffID, err := uuid.Parse(req.StaffID) + if err == nil { + link := &database.StaffPublication{ + StaffID: staffID, + PublicationID: pub.ID, + } + h.repo.LinkStaffPublication(c.Request.Context(), link) + } + } + + c.JSON(http.StatusOK, pub) +} + +// GetCrawlStatus gets crawl status for a university +// GET /api/v1/admin/crawl/status/:university_id +func (h *StaffHandlers) GetCrawlStatus(c *gin.Context) { + idStr := c.Param("university_id") + id, err := uuid.Parse(idStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid university ID"}) + return + } + + status, err := h.repo.GetCrawlStatus(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if status == nil { + c.JSON(http.StatusOK, gin.H{ + "university_id": id, + "staff_crawl_status": "never", + "pub_crawl_status": "never", + }) + return + } + + c.JSON(http.StatusOK, status) +} + +// Helper to parse int with default +func parseIntDefault(s string, def int) int { + if s == "" { + return def + } + var n int + _, err := fmt.Sscanf(s, "%d", &n) + if err != nil { + return def + } + return n +} + +// RegisterStaffRoutes registers staff-related routes +func (h *StaffHandlers) RegisterRoutes(r *gin.RouterGroup) { + // Public endpoints + r.GET("/staff/search", h.SearchStaff) + r.GET("/staff/stats", h.GetStaffStats) + r.GET("/staff/:id", h.GetStaff) + r.GET("/staff/:id/publications", h.GetStaffPublications) + + r.GET("/publications/search", h.SearchPublications) + r.POST("/publications/resolve-doi", h.ResolveDOI) + + r.GET("/universities", h.ListUniversities) + + // Admin endpoints + r.POST("/admin/crawl/staff", h.StartStaffCrawl) + r.POST("/admin/crawl/publications", h.StartPublicationCrawl) + r.GET("/admin/crawl/status/:university_id", h.GetCrawlStatus) +} diff --git a/edu-search-service/internal/config/config.go b/edu-search-service/internal/config/config.go new file mode 100644 index 0000000..6ae8d66 --- /dev/null +++ b/edu-search-service/internal/config/config.go @@ -0,0 +1,127 @@ +package config + +import ( + "os" + "strconv" +) + +type Config struct { + // Server + Port string + + // OpenSearch + OpenSearchURL string + OpenSearchUsername string + OpenSearchPassword string + IndexName string + + // Crawler + UserAgent string + RateLimitPerSec float64 + MaxDepth int + MaxPagesPerRun int + + // Paths + SeedsDir string + RulesDir string + + // API + APIKey string + + // Backend Integration + BackendURL string // URL to Python Backend for Seeds API + SeedsFromAPI bool // If true, fetch seeds from API instead of files + + // Embedding/Semantic Search + EmbeddingProvider string // "openai", "ollama", or "none" + OpenAIAPIKey string // API Key for OpenAI embeddings + EmbeddingModel string // Model name (e.g., "text-embedding-3-small") + EmbeddingDimension int // Vector dimension (1536 for OpenAI small) + OllamaURL string // Ollama base URL for local embeddings + SemanticSearchEnabled bool // Enable semantic search features + + // Scheduler + SchedulerEnabled bool // Enable automatic crawl scheduling + SchedulerInterval string // Crawl interval (e.g., "24h", "168h" for weekly) + + // PostgreSQL (for Staff/Publications database) + DBHost string + DBPort string + DBUser string + DBPassword string + DBName string + DBSSLMode string + + // Staff Crawler + StaffCrawlerEmail string // Contact email for CrossRef polite pool +} + +func Load() *Config { + return &Config{ + Port: getEnv("PORT", "8084"), + OpenSearchURL: getEnv("OPENSEARCH_URL", "http://opensearch:9200"), + OpenSearchUsername: getEnv("OPENSEARCH_USERNAME", "admin"), + OpenSearchPassword: getEnv("OPENSEARCH_PASSWORD", "admin"), + IndexName: getEnv("INDEX_NAME", "bp_documents_v1"), + UserAgent: getEnv("USER_AGENT", "BreakpilotEduCrawler/1.0 (+contact: security@breakpilot.com)"), + RateLimitPerSec: getEnvFloat("RATE_LIMIT_PER_SEC", 0.2), + MaxDepth: getEnvInt("MAX_DEPTH", 4), + MaxPagesPerRun: getEnvInt("MAX_PAGES_PER_RUN", 500), + SeedsDir: getEnv("SEEDS_DIR", "./seeds"), + RulesDir: getEnv("RULES_DIR", "./rules"), + APIKey: getEnv("EDU_SEARCH_API_KEY", ""), + BackendURL: getEnv("BACKEND_URL", "http://backend:8000"), + SeedsFromAPI: getEnvBool("SEEDS_FROM_API", true), + // Embedding/Semantic Search + EmbeddingProvider: getEnv("EMBEDDING_PROVIDER", "none"), // "openai", "ollama", or "none" + OpenAIAPIKey: getEnv("OPENAI_API_KEY", ""), + EmbeddingModel: getEnv("EMBEDDING_MODEL", "text-embedding-3-small"), + EmbeddingDimension: getEnvInt("EMBEDDING_DIMENSION", 1536), + OllamaURL: getEnv("OLLAMA_URL", "http://ollama:11434"), + SemanticSearchEnabled: getEnvBool("SEMANTIC_SEARCH_ENABLED", false), + // Scheduler + SchedulerEnabled: getEnvBool("SCHEDULER_ENABLED", false), + SchedulerInterval: getEnv("SCHEDULER_INTERVAL", "24h"), + // PostgreSQL + DBHost: getEnv("DB_HOST", "postgres"), + DBPort: getEnv("DB_PORT", "5432"), + DBUser: getEnv("DB_USER", "postgres"), + DBPassword: getEnv("DB_PASSWORD", "postgres"), + DBName: getEnv("DB_NAME", "breakpilot"), + DBSSLMode: getEnv("DB_SSLMODE", "disable"), + // Staff Crawler + StaffCrawlerEmail: getEnv("STAFF_CRAWLER_EMAIL", "crawler@breakpilot.de"), + } +} + +func getEnvBool(key string, fallback bool) bool { + if value := os.Getenv(key); value != "" { + return value == "true" || value == "1" || value == "yes" + } + return fallback +} + +func getEnv(key, fallback string) string { + if value := os.Getenv(key); value != "" { + return value + } + return fallback +} + +func getEnvInt(key string, fallback int) int { + if value := os.Getenv(key); value != "" { + if i, err := strconv.Atoi(value); err == nil { + return i + } + } + return fallback +} + +func getEnvFloat(key string, fallback float64) float64 { + if value := os.Getenv(key); value != "" { + if f, err := strconv.ParseFloat(value, 64); err == nil { + return f + } + } + return fallback +} diff --git a/edu-search-service/internal/crawler/api_client.go b/edu-search-service/internal/crawler/api_client.go new file mode 100644 index 0000000..cb524c1 --- /dev/null +++ b/edu-search-service/internal/crawler/api_client.go @@ -0,0 +1,183 @@ +package crawler + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// SeedFromAPI represents a seed URL from the Backend API +type SeedFromAPI struct { + URL string `json:"url"` + Trust float64 `json:"trust"` + Source string `json:"source"` // GOV, EDU, UNI, etc. + Scope string `json:"scope"` // FEDERAL, STATE, etc. + State string `json:"state"` // BW, BY, etc. (optional) + Depth int `json:"depth"` // Crawl depth for this seed + Category string `json:"category"` // Category name +} + +// SeedsExportResponse represents the API response from /seeds/export/for-crawler +type SeedsExportResponse struct { + Seeds []SeedFromAPI `json:"seeds"` + Total int `json:"total"` + ExportedAt string `json:"exported_at"` +} + +// APIClient handles communication with the Python Backend +type APIClient struct { + baseURL string + httpClient *http.Client +} + +// NewAPIClient creates a new API client for fetching seeds +func NewAPIClient(backendURL string) *APIClient { + return &APIClient{ + baseURL: backendURL, + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + +// FetchSeeds retrieves enabled seeds from the Backend API +func (c *APIClient) FetchSeeds(ctx context.Context) (*SeedsExportResponse, error) { + url := fmt.Sprintf("%s/v1/edu-search/seeds/export/for-crawler", c.baseURL) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "EduSearchCrawler/1.0") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch seeds: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var result SeedsExportResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &result, nil +} + +// CrawlStatusReport represents a crawl status to report to the Backend +type CrawlStatusReport struct { + SeedURL string `json:"seed_url"` + Status string `json:"status"` // "success", "error", "partial" + DocumentsCrawled int `json:"documents_crawled"` + ErrorMessage string `json:"error_message,omitempty"` + CrawlDuration float64 `json:"crawl_duration_seconds"` +} + +// CrawlStatusResponse represents the response from crawl status endpoint +type CrawlStatusResponse struct { + Success bool `json:"success"` + SeedURL string `json:"seed_url"` + Message string `json:"message"` +} + +// BulkCrawlStatusResponse represents the response from bulk crawl status endpoint +type BulkCrawlStatusResponse struct { + Updated int `json:"updated"` + Failed int `json:"failed"` + Errors []string `json:"errors"` +} + +// ReportStatus sends crawl status for a single seed to the Backend +func (c *APIClient) ReportStatus(ctx context.Context, report *CrawlStatusReport) error { + url := fmt.Sprintf("%s/v1/edu-search/seeds/crawl-status", c.baseURL) + + body, err := json.Marshal(report) + if err != nil { + return fmt.Errorf("failed to marshal report: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "EduSearchCrawler/1.0") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to report status: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(respBody)) + } + + return nil +} + +// ReportStatusBulk sends crawl status for multiple seeds in one request +func (c *APIClient) ReportStatusBulk(ctx context.Context, reports []*CrawlStatusReport) (*BulkCrawlStatusResponse, error) { + url := fmt.Sprintf("%s/v1/edu-search/seeds/crawl-status/bulk", c.baseURL) + + payload := struct { + Updates []*CrawlStatusReport `json:"updates"` + }{ + Updates: reports, + } + + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("failed to marshal reports: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "EduSearchCrawler/1.0") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to report status: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(respBody)) + } + + var result BulkCrawlStatusResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return &result, nil +} diff --git a/edu-search-service/internal/crawler/api_client_test.go b/edu-search-service/internal/crawler/api_client_test.go new file mode 100644 index 0000000..fc82539 --- /dev/null +++ b/edu-search-service/internal/crawler/api_client_test.go @@ -0,0 +1,428 @@ +package crawler + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestNewAPIClient(t *testing.T) { + client := NewAPIClient("http://backend:8000") + + if client == nil { + t.Fatal("Expected non-nil client") + } + + if client.baseURL != "http://backend:8000" { + t.Errorf("Expected baseURL 'http://backend:8000', got '%s'", client.baseURL) + } + + if client.httpClient == nil { + t.Fatal("Expected non-nil httpClient") + } +} + +func TestFetchSeeds_Success(t *testing.T) { + // Create mock server + mockResponse := SeedsExportResponse{ + Seeds: []SeedFromAPI{ + { + URL: "https://www.kmk.org", + Trust: 0.8, + Source: "GOV", + Scope: "FEDERAL", + State: "", + Depth: 3, + Category: "federal", + }, + { + URL: "https://www.km-bw.de", + Trust: 0.7, + Source: "GOV", + Scope: "STATE", + State: "BW", + Depth: 2, + Category: "states", + }, + }, + Total: 2, + ExportedAt: "2025-01-17T10:00:00Z", + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request path + if r.URL.Path != "/v1/edu-search/seeds/export/for-crawler" { + t.Errorf("Expected path '/v1/edu-search/seeds/export/for-crawler', got '%s'", r.URL.Path) + } + + // Verify headers + if r.Header.Get("Accept") != "application/json" { + t.Errorf("Expected Accept header 'application/json', got '%s'", r.Header.Get("Accept")) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(mockResponse) + })) + defer server.Close() + + // Test + client := NewAPIClient(server.URL) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + result, err := client.FetchSeeds(ctx) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if result.Total != 2 { + t.Errorf("Expected 2 seeds, got %d", result.Total) + } + + if len(result.Seeds) != 2 { + t.Fatalf("Expected 2 seeds in array, got %d", len(result.Seeds)) + } + + // Verify first seed + if result.Seeds[0].URL != "https://www.kmk.org" { + t.Errorf("Expected URL 'https://www.kmk.org', got '%s'", result.Seeds[0].URL) + } + + if result.Seeds[0].Trust != 0.8 { + t.Errorf("Expected Trust 0.8, got %f", result.Seeds[0].Trust) + } + + if result.Seeds[0].Source != "GOV" { + t.Errorf("Expected Source 'GOV', got '%s'", result.Seeds[0].Source) + } + + // Verify second seed with state + if result.Seeds[1].State != "BW" { + t.Errorf("Expected State 'BW', got '%s'", result.Seeds[1].State) + } +} + +func TestFetchSeeds_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Internal server error")) + })) + defer server.Close() + + client := NewAPIClient(server.URL) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := client.FetchSeeds(ctx) + + if err == nil { + t.Fatal("Expected error for server error response") + } +} + +func TestFetchSeeds_InvalidJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("not valid json")) + })) + defer server.Close() + + client := NewAPIClient(server.URL) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := client.FetchSeeds(ctx) + + if err == nil { + t.Fatal("Expected error for invalid JSON response") + } +} + +func TestFetchSeeds_Timeout(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Simulate slow response + time.Sleep(2 * time.Second) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := NewAPIClient(server.URL) + // Very short timeout + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + _, err := client.FetchSeeds(ctx) + + if err == nil { + t.Fatal("Expected timeout error") + } +} + +func TestFetchSeeds_EmptyResponse(t *testing.T) { + mockResponse := SeedsExportResponse{ + Seeds: []SeedFromAPI{}, + Total: 0, + ExportedAt: "2025-01-17T10:00:00Z", + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(mockResponse) + })) + defer server.Close() + + client := NewAPIClient(server.URL) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + result, err := client.FetchSeeds(ctx) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if result.Total != 0 { + t.Errorf("Expected 0 seeds, got %d", result.Total) + } + + if len(result.Seeds) != 0 { + t.Errorf("Expected empty seeds array, got %d", len(result.Seeds)) + } +} + +// Tests for Crawl Status Reporting + +func TestReportStatus_Success(t *testing.T) { + var receivedReport CrawlStatusReport + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request method and path + if r.Method != "POST" { + t.Errorf("Expected POST method, got %s", r.Method) + } + if r.URL.Path != "/v1/edu-search/seeds/crawl-status" { + t.Errorf("Expected path '/v1/edu-search/seeds/crawl-status', got '%s'", r.URL.Path) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("Expected Content-Type 'application/json', got '%s'", r.Header.Get("Content-Type")) + } + + // Parse body + json.NewDecoder(r.Body).Decode(&receivedReport) + + // Send response + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(CrawlStatusResponse{ + Success: true, + SeedURL: receivedReport.SeedURL, + Message: "Status updated", + }) + })) + defer server.Close() + + client := NewAPIClient(server.URL) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + report := &CrawlStatusReport{ + SeedURL: "https://www.kmk.org", + Status: "success", + DocumentsCrawled: 42, + CrawlDuration: 15.5, + } + + err := client.ReportStatus(ctx, report) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // Verify the report was sent correctly + if receivedReport.SeedURL != "https://www.kmk.org" { + t.Errorf("Expected SeedURL 'https://www.kmk.org', got '%s'", receivedReport.SeedURL) + } + if receivedReport.Status != "success" { + t.Errorf("Expected Status 'success', got '%s'", receivedReport.Status) + } + if receivedReport.DocumentsCrawled != 42 { + t.Errorf("Expected DocumentsCrawled 42, got %d", receivedReport.DocumentsCrawled) + } +} + +func TestReportStatus_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Internal server error")) + })) + defer server.Close() + + client := NewAPIClient(server.URL) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + report := &CrawlStatusReport{ + SeedURL: "https://www.kmk.org", + Status: "success", + } + + err := client.ReportStatus(ctx, report) + + if err == nil { + t.Fatal("Expected error for server error response") + } +} + +func TestReportStatus_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"detail": "Seed nicht gefunden"}`)) + })) + defer server.Close() + + client := NewAPIClient(server.URL) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + report := &CrawlStatusReport{ + SeedURL: "https://unknown.example.com", + Status: "error", + } + + err := client.ReportStatus(ctx, report) + + if err == nil { + t.Fatal("Expected error for 404 response") + } +} + +func TestReportStatusBulk_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request method and path + if r.Method != "POST" { + t.Errorf("Expected POST method, got %s", r.Method) + } + if r.URL.Path != "/v1/edu-search/seeds/crawl-status/bulk" { + t.Errorf("Expected path '/v1/edu-search/seeds/crawl-status/bulk', got '%s'", r.URL.Path) + } + + // Parse body + var payload struct { + Updates []*CrawlStatusReport `json:"updates"` + } + json.NewDecoder(r.Body).Decode(&payload) + + // Send response + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(BulkCrawlStatusResponse{ + Updated: len(payload.Updates), + Failed: 0, + Errors: []string{}, + }) + })) + defer server.Close() + + client := NewAPIClient(server.URL) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + reports := []*CrawlStatusReport{ + { + SeedURL: "https://www.kmk.org", + Status: "success", + DocumentsCrawled: 42, + }, + { + SeedURL: "https://www.km-bw.de", + Status: "partial", + DocumentsCrawled: 15, + }, + } + + result, err := client.ReportStatusBulk(ctx, reports) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if result.Updated != 2 { + t.Errorf("Expected 2 updated, got %d", result.Updated) + } + if result.Failed != 0 { + t.Errorf("Expected 0 failed, got %d", result.Failed) + } +} + +func TestReportStatusBulk_PartialFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(BulkCrawlStatusResponse{ + Updated: 1, + Failed: 1, + Errors: []string{"Seed nicht gefunden: https://unknown.example.com"}, + }) + })) + defer server.Close() + + client := NewAPIClient(server.URL) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + reports := []*CrawlStatusReport{ + {SeedURL: "https://www.kmk.org", Status: "success"}, + {SeedURL: "https://unknown.example.com", Status: "error"}, + } + + result, err := client.ReportStatusBulk(ctx, reports) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if result.Updated != 1 { + t.Errorf("Expected 1 updated, got %d", result.Updated) + } + if result.Failed != 1 { + t.Errorf("Expected 1 failed, got %d", result.Failed) + } + if len(result.Errors) != 1 { + t.Errorf("Expected 1 error, got %d", len(result.Errors)) + } +} + +func TestCrawlStatusReport_Struct(t *testing.T) { + report := CrawlStatusReport{ + SeedURL: "https://www.example.com", + Status: "success", + DocumentsCrawled: 100, + ErrorMessage: "", + CrawlDuration: 25.5, + } + + // Test JSON marshaling + data, err := json.Marshal(report) + if err != nil { + t.Fatalf("Failed to marshal: %v", err) + } + + var decoded CrawlStatusReport + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + + if decoded.SeedURL != report.SeedURL { + t.Errorf("SeedURL mismatch") + } + if decoded.Status != report.Status { + t.Errorf("Status mismatch") + } + if decoded.DocumentsCrawled != report.DocumentsCrawled { + t.Errorf("DocumentsCrawled mismatch") + } + if decoded.CrawlDuration != report.CrawlDuration { + t.Errorf("CrawlDuration mismatch") + } +} diff --git a/edu-search-service/internal/crawler/crawler.go b/edu-search-service/internal/crawler/crawler.go new file mode 100644 index 0000000..4bf93f9 --- /dev/null +++ b/edu-search-service/internal/crawler/crawler.go @@ -0,0 +1,364 @@ +package crawler + +import ( + "bufio" + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/google/uuid" +) + +// Note: API client is in the same package (api_client.go) + +// FetchResult contains the result of fetching a URL +type FetchResult struct { + URL string + CanonicalURL string + ContentType string + StatusCode int + Body []byte + ContentHash string + FetchTime time.Time + Error error +} + +// Seed represents a URL to crawl with metadata +type Seed struct { + URL string + TrustBoost float64 + Source string // GOV, EDU, UNI, etc. + Scope string // FEDERAL, STATE, etc. + State string // BW, BY, etc. (optional) + MaxDepth int // Custom crawl depth for this seed + Category string // Category name +} + +// Crawler handles URL fetching with rate limiting and robots.txt respect +type Crawler struct { + userAgent string + rateLimitPerSec float64 + maxDepth int + timeout time.Duration + client *http.Client + denylist map[string]bool + lastFetch map[string]time.Time + mu sync.Mutex + apiClient *APIClient // API client for fetching seeds from Backend +} + +// NewCrawler creates a new crawler instance +func NewCrawler(userAgent string, rateLimitPerSec float64, maxDepth int) *Crawler { + return &Crawler{ + userAgent: userAgent, + rateLimitPerSec: rateLimitPerSec, + maxDepth: maxDepth, + timeout: 30 * time.Second, + client: &http.Client{ + Timeout: 30 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return fmt.Errorf("too many redirects") + } + return nil + }, + }, + denylist: make(map[string]bool), + lastFetch: make(map[string]time.Time), + } +} + +// SetAPIClient sets the API client for fetching seeds from Backend +func (c *Crawler) SetAPIClient(backendURL string) { + c.apiClient = NewAPIClient(backendURL) +} + +// LoadSeedsFromAPI fetches seeds from the Backend API +func (c *Crawler) LoadSeedsFromAPI(ctx context.Context) ([]Seed, error) { + if c.apiClient == nil { + return nil, fmt.Errorf("API client not initialized - call SetAPIClient first") + } + + response, err := c.apiClient.FetchSeeds(ctx) + if err != nil { + return nil, fmt.Errorf("failed to fetch seeds from API: %w", err) + } + + seeds := make([]Seed, 0, len(response.Seeds)) + for _, apiSeed := range response.Seeds { + seed := Seed{ + URL: apiSeed.URL, + TrustBoost: apiSeed.Trust, + Source: apiSeed.Source, + Scope: apiSeed.Scope, + State: apiSeed.State, + MaxDepth: apiSeed.Depth, + Category: apiSeed.Category, + } + // Use default depth if not specified + if seed.MaxDepth <= 0 { + seed.MaxDepth = c.maxDepth + } + seeds = append(seeds, seed) + } + + log.Printf("Loaded %d seeds from API (exported at: %s)", len(seeds), response.ExportedAt) + return seeds, nil +} + +// LoadSeeds loads seed URLs from files in a directory (legacy method) +func (c *Crawler) LoadSeeds(seedsDir string) ([]string, error) { + var seeds []string + + files, err := filepath.Glob(filepath.Join(seedsDir, "*.txt")) + if err != nil { + return nil, err + } + + for _, file := range files { + if strings.Contains(file, "denylist") { + // Load denylist + if err := c.loadDenylist(file); err != nil { + log.Printf("Warning: Could not load denylist %s: %v", file, err) + } + continue + } + + fileSeeds, err := c.loadSeedFile(file) + if err != nil { + log.Printf("Warning: Could not load seed file %s: %v", file, err) + continue + } + seeds = append(seeds, fileSeeds...) + } + + log.Printf("Loaded %d seeds from files, %d domains in denylist", len(seeds), len(c.denylist)) + return seeds, nil +} + +// LoadSeedsWithMetadata loads seeds from files and converts to Seed struct +// This provides backward compatibility while allowing metadata +func (c *Crawler) LoadSeedsWithMetadata(seedsDir string) ([]Seed, error) { + urlList, err := c.LoadSeeds(seedsDir) + if err != nil { + return nil, err + } + + seeds := make([]Seed, 0, len(urlList)) + for _, url := range urlList { + seeds = append(seeds, Seed{ + URL: url, + TrustBoost: 0.5, // Default trust boost + MaxDepth: c.maxDepth, + }) + } + + return seeds, nil +} + +func (c *Crawler) loadSeedFile(filename string) ([]string, error) { + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + var seeds []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + // Skip comments and empty lines + if line == "" || strings.HasPrefix(line, "#") { + continue + } + // Extract URL (ignore comments after URL) + parts := strings.SplitN(line, " ", 2) + urlStr := strings.TrimSpace(parts[0]) + if urlStr != "" { + seeds = append(seeds, urlStr) + } + } + return seeds, scanner.Err() +} + +func (c *Crawler) loadDenylist(filename string) error { + file, err := os.Open(filename) + if err != nil { + return err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + c.denylist[strings.ToLower(line)] = true + } + return scanner.Err() +} + +// IsDenied checks if a domain is in the denylist +func (c *Crawler) IsDenied(urlStr string) bool { + u, err := url.Parse(urlStr) + if err != nil { + return true + } + + host := strings.ToLower(u.Host) + + // Check exact match + if c.denylist[host] { + return true + } + + // Check parent domains + parts := strings.Split(host, ".") + for i := 1; i < len(parts)-1; i++ { + parent := strings.Join(parts[i:], ".") + if c.denylist[parent] { + return true + } + } + + return false +} + +// Fetch fetches a single URL with rate limiting +func (c *Crawler) Fetch(ctx context.Context, urlStr string) (*FetchResult, error) { + result := &FetchResult{ + URL: urlStr, + FetchTime: time.Now(), + } + + // Check denylist + if c.IsDenied(urlStr) { + result.Error = fmt.Errorf("domain denied") + return result, result.Error + } + + // Parse URL + u, err := url.Parse(urlStr) + if err != nil { + result.Error = err + return result, err + } + + // Rate limiting per domain + c.waitForRateLimit(u.Host) + + // Create request + req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil) + if err != nil { + result.Error = err + return result, err + } + + req.Header.Set("User-Agent", c.userAgent) + req.Header.Set("Accept", "text/html,application/pdf,application/xhtml+xml") + req.Header.Set("Accept-Language", "de-DE,de;q=0.9,en;q=0.8") + + // Execute request + resp, err := c.client.Do(req) + if err != nil { + result.Error = err + return result, err + } + defer resp.Body.Close() + + result.StatusCode = resp.StatusCode + result.ContentType = resp.Header.Get("Content-Type") + result.CanonicalURL = resp.Request.URL.String() + + if resp.StatusCode != http.StatusOK { + result.Error = fmt.Errorf("HTTP %d", resp.StatusCode) + return result, result.Error + } + + // Read body (limit to 20MB) + limitedReader := io.LimitReader(resp.Body, 20*1024*1024) + body, err := io.ReadAll(limitedReader) + if err != nil { + result.Error = err + return result, err + } + + result.Body = body + + // Calculate content hash + hash := sha256.Sum256(body) + result.ContentHash = hex.EncodeToString(hash[:]) + + return result, nil +} + +func (c *Crawler) waitForRateLimit(host string) { + c.mu.Lock() + defer c.mu.Unlock() + + minInterval := time.Duration(float64(time.Second) / c.rateLimitPerSec) + + if last, ok := c.lastFetch[host]; ok { + elapsed := time.Since(last) + if elapsed < minInterval { + time.Sleep(minInterval - elapsed) + } + } + + c.lastFetch[host] = time.Now() +} + +// ExtractDomain extracts the domain from a URL +func ExtractDomain(urlStr string) string { + u, err := url.Parse(urlStr) + if err != nil { + return "" + } + return u.Host +} + +// GenerateDocID generates a unique document ID +func GenerateDocID() string { + return uuid.New().String() +} + +// NormalizeURL normalizes a URL for deduplication +func NormalizeURL(urlStr string) string { + u, err := url.Parse(urlStr) + if err != nil { + return urlStr + } + + // Remove trailing slashes + u.Path = strings.TrimSuffix(u.Path, "/") + + // Remove common tracking parameters + q := u.Query() + for key := range q { + lowerKey := strings.ToLower(key) + if strings.HasPrefix(lowerKey, "utm_") || + lowerKey == "ref" || + lowerKey == "source" || + lowerKey == "fbclid" || + lowerKey == "gclid" { + q.Del(key) + } + } + u.RawQuery = q.Encode() + + // Lowercase host + u.Host = strings.ToLower(u.Host) + + return u.String() +} diff --git a/edu-search-service/internal/crawler/crawler_test.go b/edu-search-service/internal/crawler/crawler_test.go new file mode 100644 index 0000000..b2c35aa --- /dev/null +++ b/edu-search-service/internal/crawler/crawler_test.go @@ -0,0 +1,639 @@ +package crawler + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestNewCrawler(t *testing.T) { + crawler := NewCrawler("TestBot/1.0", 1.0, 3) + + if crawler == nil { + t.Fatal("Expected non-nil crawler") + } + if crawler.userAgent != "TestBot/1.0" { + t.Errorf("Expected userAgent 'TestBot/1.0', got %q", crawler.userAgent) + } + if crawler.rateLimitPerSec != 1.0 { + t.Errorf("Expected rateLimitPerSec 1.0, got %f", crawler.rateLimitPerSec) + } + if crawler.maxDepth != 3 { + t.Errorf("Expected maxDepth 3, got %d", crawler.maxDepth) + } + if crawler.client == nil { + t.Error("Expected non-nil HTTP client") + } +} + +func TestCrawler_LoadSeeds(t *testing.T) { + // Create temp directory with seed files + dir := t.TempDir() + + // Create a seed file + seedContent := `# Federal education sources +https://www.kmk.org +https://www.bildungsserver.de + +# Comment line +https://www.bpb.de # with inline comment +` + if err := os.WriteFile(filepath.Join(dir, "federal.txt"), []byte(seedContent), 0644); err != nil { + t.Fatal(err) + } + + // Create another seed file + stateContent := `https://www.km.bayern.de +https://www.schulministerium.nrw.de +` + if err := os.WriteFile(filepath.Join(dir, "states.txt"), []byte(stateContent), 0644); err != nil { + t.Fatal(err) + } + + // Create denylist + denylistContent := `# Denylist +facebook.com +twitter.com +instagram.com +` + if err := os.WriteFile(filepath.Join(dir, "denylist.txt"), []byte(denylistContent), 0644); err != nil { + t.Fatal(err) + } + + crawler := NewCrawler("TestBot/1.0", 1.0, 3) + seeds, err := crawler.LoadSeeds(dir) + if err != nil { + t.Fatalf("LoadSeeds failed: %v", err) + } + + // Check seeds loaded + if len(seeds) != 5 { + t.Errorf("Expected 5 seeds, got %d", len(seeds)) + } + + // Check expected URLs + expectedURLs := []string{ + "https://www.kmk.org", + "https://www.bildungsserver.de", + "https://www.bpb.de", + "https://www.km.bayern.de", + "https://www.schulministerium.nrw.de", + } + + for _, expected := range expectedURLs { + found := false + for _, seed := range seeds { + if seed == expected { + found = true + break + } + } + if !found { + t.Errorf("Expected seed %q not found", expected) + } + } + + // Check denylist loaded + if len(crawler.denylist) != 3 { + t.Errorf("Expected 3 denylist entries, got %d", len(crawler.denylist)) + } +} + +func TestCrawler_IsDenied(t *testing.T) { + crawler := NewCrawler("TestBot/1.0", 1.0, 3) + crawler.denylist = map[string]bool{ + "facebook.com": true, + "twitter.com": true, + "ads.example.com": true, + } + + tests := []struct { + name string + url string + expected bool + }{ + { + name: "Exact domain match", + url: "https://facebook.com/page", + expected: true, + }, + { + name: "Subdomain of denied domain", + url: "https://www.facebook.com/page", + expected: true, + }, + { + name: "Allowed domain", + url: "https://www.kmk.org/bildung", + expected: false, + }, + { + name: "Denied subdomain", + url: "https://ads.example.com/banner", + expected: true, + }, + { + name: "Parent domain allowed", + url: "https://example.com/page", + expected: false, + }, + { + name: "Invalid URL scheme", + url: "://invalid", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := crawler.IsDenied(tt.url) + if result != tt.expected { + t.Errorf("IsDenied(%q) = %v, expected %v", tt.url, result, tt.expected) + } + }) + } +} + +func TestCrawler_Fetch_Success(t *testing.T) { + // Create test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check user agent + if r.Header.Get("User-Agent") != "TestBot/1.0" { + t.Errorf("Expected User-Agent 'TestBot/1.0', got %q", r.Header.Get("User-Agent")) + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + w.Write([]byte("Test content")) + })) + defer server.Close() + + crawler := NewCrawler("TestBot/1.0", 100.0, 3) // High rate limit for testing + ctx := context.Background() + + result, err := crawler.Fetch(ctx, server.URL+"/page") + if err != nil { + t.Fatalf("Fetch failed: %v", err) + } + + if result.StatusCode != 200 { + t.Errorf("Expected status 200, got %d", result.StatusCode) + } + if result.Error != nil { + t.Errorf("Expected no error, got %v", result.Error) + } + if !strings.Contains(result.ContentType, "text/html") { + t.Errorf("Expected Content-Type to contain 'text/html', got %q", result.ContentType) + } + if len(result.Body) == 0 { + t.Error("Expected non-empty body") + } + if result.ContentHash == "" { + t.Error("Expected non-empty content hash") + } + if result.FetchTime.IsZero() { + t.Error("Expected non-zero fetch time") + } +} + +func TestCrawler_Fetch_DeniedDomain(t *testing.T) { + crawler := NewCrawler("TestBot/1.0", 100.0, 3) + crawler.denylist = map[string]bool{ + "denied.com": true, + } + + ctx := context.Background() + result, err := crawler.Fetch(ctx, "https://denied.com/page") + + if err == nil { + t.Error("Expected error for denied domain") + } + if result.Error == nil { + t.Error("Expected error in result") + } + if !strings.Contains(result.Error.Error(), "denied") { + t.Errorf("Expected 'denied' in error message, got %v", result.Error) + } +} + +func TestCrawler_Fetch_HTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + crawler := NewCrawler("TestBot/1.0", 100.0, 3) + ctx := context.Background() + + result, err := crawler.Fetch(ctx, server.URL+"/notfound") + if err == nil { + t.Error("Expected error for 404 response") + } + if result.StatusCode != 404 { + t.Errorf("Expected status 404, got %d", result.StatusCode) + } +} + +func TestCrawler_Fetch_Redirect(t *testing.T) { + redirectCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/redirect" { + redirectCount++ + http.Redirect(w, r, "/final", http.StatusFound) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte("Final content")) + })) + defer server.Close() + + crawler := NewCrawler("TestBot/1.0", 100.0, 3) + ctx := context.Background() + + result, err := crawler.Fetch(ctx, server.URL+"/redirect") + if err != nil { + t.Fatalf("Fetch failed: %v", err) + } + + // CanonicalURL should be the final URL after redirect + if !strings.HasSuffix(result.CanonicalURL, "/final") { + t.Errorf("Expected canonical URL to end with '/final', got %q", result.CanonicalURL) + } +} + +func TestCrawler_Fetch_Timeout(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(2 * time.Second) // Delay response + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + crawler := NewCrawler("TestBot/1.0", 100.0, 3) + crawler.timeout = 100 * time.Millisecond // Very short timeout + + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + _, err := crawler.Fetch(ctx, server.URL+"/slow") + if err == nil { + t.Error("Expected timeout error") + } +} + +func TestExtractDomain(t *testing.T) { + tests := []struct { + url string + expected string + }{ + { + url: "https://www.example.com/page", + expected: "www.example.com", + }, + { + url: "https://example.com:8080/path", + expected: "example.com:8080", + }, + { + url: "http://subdomain.example.com", + expected: "subdomain.example.com", + }, + { + url: "invalid-url", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.url, func(t *testing.T) { + result := ExtractDomain(tt.url) + if result != tt.expected { + t.Errorf("ExtractDomain(%q) = %q, expected %q", tt.url, result, tt.expected) + } + }) + } +} + +func TestGenerateDocID(t *testing.T) { + id1 := GenerateDocID() + id2 := GenerateDocID() + + if id1 == "" { + t.Error("Expected non-empty ID") + } + if id1 == id2 { + t.Error("Expected unique IDs") + } + // UUID format check (basic) + if len(id1) != 36 { + t.Errorf("Expected UUID length 36, got %d", len(id1)) + } +} + +func TestNormalizeURL(t *testing.T) { + tests := []struct { + name string + url string + expected string + }{ + { + name: "Remove trailing slash", + url: "https://example.com/page/", + expected: "https://example.com/page", + }, + { + name: "Remove UTM parameters", + url: "https://example.com/page?utm_source=google&utm_medium=cpc", + expected: "https://example.com/page", + }, + { + name: "Remove multiple tracking params", + url: "https://example.com/page?id=123&utm_campaign=test&fbclid=abc", + expected: "https://example.com/page?id=123", + }, + { + name: "Keep non-tracking params", + url: "https://example.com/search?q=test&page=2", + expected: "https://example.com/search?page=2&q=test", + }, + { + name: "Lowercase host", + url: "https://EXAMPLE.COM/Page", + expected: "https://example.com/Page", + }, + { + name: "Invalid URL returns as-is", + url: "not-a-url", + expected: "not-a-url", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := NormalizeURL(tt.url) + if result != tt.expected { + t.Errorf("NormalizeURL(%q) = %q, expected %q", tt.url, result, tt.expected) + } + }) + } +} + +func TestCrawler_RateLimit(t *testing.T) { + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + })) + defer server.Close() + + // 2 requests per second = 500ms between requests + crawler := NewCrawler("TestBot/1.0", 2.0, 3) + ctx := context.Background() + + start := time.Now() + + // Make 3 requests + for i := 0; i < 3; i++ { + crawler.Fetch(ctx, server.URL+"/page") + } + + elapsed := time.Since(start) + + // With 2 req/sec, 3 requests should take at least 1 second (2 intervals) + if elapsed < 800*time.Millisecond { + t.Errorf("Rate limiting not working: 3 requests took only %v", elapsed) + } +} + +func TestLoadSeedFile_EmptyLines(t *testing.T) { + dir := t.TempDir() + + content := ` + +https://example.com + +# comment + +https://example.org + +` + if err := os.WriteFile(filepath.Join(dir, "seeds.txt"), []byte(content), 0644); err != nil { + t.Fatal(err) + } + + crawler := NewCrawler("TestBot/1.0", 1.0, 3) + seeds, err := crawler.LoadSeeds(dir) + if err != nil { + t.Fatal(err) + } + + if len(seeds) != 2 { + t.Errorf("Expected 2 seeds (ignoring empty lines and comments), got %d", len(seeds)) + } +} + +func TestCrawler_Fetch_LargeBody(t *testing.T) { + // Create a large response (but under the limit) + largeBody := strings.Repeat("A", 1024*1024) // 1MB + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte(largeBody)) + })) + defer server.Close() + + crawler := NewCrawler("TestBot/1.0", 100.0, 3) + ctx := context.Background() + + result, err := crawler.Fetch(ctx, server.URL+"/large") + if err != nil { + t.Fatalf("Fetch failed: %v", err) + } + + if len(result.Body) != len(largeBody) { + t.Errorf("Expected body length %d, got %d", len(largeBody), len(result.Body)) + } +} + +// Tests for API Integration (new functionality) + +func TestCrawler_SetAPIClient(t *testing.T) { + crawler := NewCrawler("TestBot/1.0", 1.0, 3) + + if crawler.apiClient != nil { + t.Error("Expected nil apiClient initially") + } + + crawler.SetAPIClient("http://backend:8000") + + if crawler.apiClient == nil { + t.Error("Expected non-nil apiClient after SetAPIClient") + } +} + +func TestCrawler_LoadSeedsFromAPI_NotInitialized(t *testing.T) { + crawler := NewCrawler("TestBot/1.0", 1.0, 3) + ctx := context.Background() + + _, err := crawler.LoadSeedsFromAPI(ctx) + + if err == nil { + t.Error("Expected error when API client not initialized") + } +} + +func TestCrawler_LoadSeedsFromAPI_Success(t *testing.T) { + // Create mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "seeds": [ + {"url": "https://www.kmk.org", "trust": 0.8, "source": "GOV", "scope": "FEDERAL", "state": "", "depth": 3, "category": "federal"}, + {"url": "https://www.km-bw.de", "trust": 0.7, "source": "GOV", "scope": "STATE", "state": "BW", "depth": 2, "category": "states"} + ], + "total": 2, + "exported_at": "2025-01-17T10:00:00Z" + }`)) + })) + defer server.Close() + + crawler := NewCrawler("TestBot/1.0", 1.0, 4) + crawler.SetAPIClient(server.URL) + ctx := context.Background() + + seeds, err := crawler.LoadSeedsFromAPI(ctx) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + if len(seeds) != 2 { + t.Fatalf("Expected 2 seeds, got %d", len(seeds)) + } + + // Check first seed + if seeds[0].URL != "https://www.kmk.org" { + t.Errorf("Expected URL 'https://www.kmk.org', got '%s'", seeds[0].URL) + } + if seeds[0].TrustBoost != 0.8 { + t.Errorf("Expected TrustBoost 0.8, got %f", seeds[0].TrustBoost) + } + if seeds[0].Source != "GOV" { + t.Errorf("Expected Source 'GOV', got '%s'", seeds[0].Source) + } + if seeds[0].MaxDepth != 3 { + t.Errorf("Expected MaxDepth 3, got %d", seeds[0].MaxDepth) + } + + // Check second seed with state + if seeds[1].State != "BW" { + t.Errorf("Expected State 'BW', got '%s'", seeds[1].State) + } + if seeds[1].Category != "states" { + t.Errorf("Expected Category 'states', got '%s'", seeds[1].Category) + } +} + +func TestCrawler_LoadSeedsFromAPI_DefaultDepth(t *testing.T) { + // Create mock server with seed that has no depth + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{ + "seeds": [ + {"url": "https://www.example.com", "trust": 0.5, "source": "EDU", "scope": "FEDERAL", "state": "", "depth": 0, "category": "edu"} + ], + "total": 1, + "exported_at": "2025-01-17T10:00:00Z" + }`)) + })) + defer server.Close() + + defaultDepth := 5 + crawler := NewCrawler("TestBot/1.0", 1.0, defaultDepth) + crawler.SetAPIClient(server.URL) + ctx := context.Background() + + seeds, err := crawler.LoadSeedsFromAPI(ctx) + + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + // When depth is 0 or not specified, it should use crawler's default + if seeds[0].MaxDepth != defaultDepth { + t.Errorf("Expected default MaxDepth %d, got %d", defaultDepth, seeds[0].MaxDepth) + } +} + +func TestCrawler_LoadSeedsWithMetadata(t *testing.T) { + dir := t.TempDir() + + seedContent := `https://www.kmk.org +https://www.bildungsserver.de` + + if err := os.WriteFile(filepath.Join(dir, "seeds.txt"), []byte(seedContent), 0644); err != nil { + t.Fatal(err) + } + + defaultDepth := 4 + crawler := NewCrawler("TestBot/1.0", 1.0, defaultDepth) + seeds, err := crawler.LoadSeedsWithMetadata(dir) + + if err != nil { + t.Fatalf("LoadSeedsWithMetadata failed: %v", err) + } + + if len(seeds) != 2 { + t.Fatalf("Expected 2 seeds, got %d", len(seeds)) + } + + // Check default values + for _, seed := range seeds { + if seed.TrustBoost != 0.5 { + t.Errorf("Expected default TrustBoost 0.5, got %f", seed.TrustBoost) + } + if seed.MaxDepth != defaultDepth { + t.Errorf("Expected default MaxDepth %d, got %d", defaultDepth, seed.MaxDepth) + } + } +} + +func TestSeed_Struct(t *testing.T) { + seed := Seed{ + URL: "https://www.example.com", + TrustBoost: 0.75, + Source: "GOV", + Scope: "STATE", + State: "BY", + MaxDepth: 3, + Category: "states", + } + + if seed.URL != "https://www.example.com" { + t.Errorf("URL mismatch") + } + if seed.TrustBoost != 0.75 { + t.Errorf("TrustBoost mismatch") + } + if seed.Source != "GOV" { + t.Errorf("Source mismatch") + } + if seed.Scope != "STATE" { + t.Errorf("Scope mismatch") + } + if seed.State != "BY" { + t.Errorf("State mismatch") + } + if seed.MaxDepth != 3 { + t.Errorf("MaxDepth mismatch") + } + if seed.Category != "states" { + t.Errorf("Category mismatch") + } +} diff --git a/edu-search-service/internal/database/database.go b/edu-search-service/internal/database/database.go new file mode 100644 index 0000000..a51d086 --- /dev/null +++ b/edu-search-service/internal/database/database.go @@ -0,0 +1,133 @@ +package database + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// DB holds the database connection pool +type DB struct { + Pool *pgxpool.Pool +} + +// Config holds database configuration +type Config struct { + Host string + Port string + User string + Password string + DBName string + SSLMode string +} + +// NewConfig creates a new database config from environment variables +func NewConfig() *Config { + return &Config{ + Host: getEnv("DB_HOST", "localhost"), + Port: getEnv("DB_PORT", "5432"), + User: getEnv("DB_USER", "postgres"), + Password: getEnv("DB_PASSWORD", "postgres"), + DBName: getEnv("DB_NAME", "breakpilot"), + SSLMode: getEnv("DB_SSLMODE", "disable"), + } +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// ConnectionString returns the PostgreSQL connection string +func (c *Config) ConnectionString() string { + return fmt.Sprintf( + "postgres://%s:%s@%s:%s/%s?sslmode=%s", + c.User, c.Password, c.Host, c.Port, c.DBName, c.SSLMode, + ) +} + +// New creates a new database connection +func New(ctx context.Context, cfg *Config) (*DB, error) { + config, err := pgxpool.ParseConfig(cfg.ConnectionString()) + if err != nil { + return nil, fmt.Errorf("failed to parse database config: %w", err) + } + + // Configure connection pool + config.MaxConns = 10 + config.MinConns = 2 + config.MaxConnLifetime = time.Hour + config.MaxConnIdleTime = 30 * time.Minute + + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + return nil, fmt.Errorf("failed to create connection pool: %w", err) + } + + // Test connection + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + log.Printf("Connected to database %s on %s:%s", cfg.DBName, cfg.Host, cfg.Port) + + return &DB{Pool: pool}, nil +} + +// Close closes the database connection pool +func (db *DB) Close() { + if db.Pool != nil { + db.Pool.Close() + } +} + +// RunMigrations executes all SQL migrations +func (db *DB) RunMigrations(ctx context.Context) error { + // Try multiple paths for migration file + migrationPaths := []string{ + "migrations/001_university_staff.sql", + "../migrations/001_university_staff.sql", + "../../migrations/001_university_staff.sql", + } + + var content []byte + var err error + var foundPath string + + for _, path := range migrationPaths { + absPath, _ := filepath.Abs(path) + content, err = os.ReadFile(absPath) + if err == nil { + foundPath = absPath + break + } + } + + if content == nil { + return fmt.Errorf("failed to read migration file from any path: %w", err) + } + + log.Printf("Running migrations from: %s", foundPath) + + // Execute migration + _, err = db.Pool.Exec(ctx, string(content)) + if err != nil { + return fmt.Errorf("failed to execute migration: %w", err) + } + + log.Println("Database migrations completed successfully") + return nil +} + +// Health checks if the database is healthy +func (db *DB) Health(ctx context.Context) error { + return db.Pool.Ping(ctx) +} diff --git a/edu-search-service/internal/database/models.go b/edu-search-service/internal/database/models.go new file mode 100644 index 0000000..b879da4 --- /dev/null +++ b/edu-search-service/internal/database/models.go @@ -0,0 +1,205 @@ +package database + +import ( + "time" + + "github.com/google/uuid" +) + +// University represents a German university/Hochschule +type University struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + ShortName *string `json:"short_name,omitempty"` + URL string `json:"url"` + State *string `json:"state,omitempty"` + UniType *string `json:"uni_type,omitempty"` + StaffPagePattern *string `json:"staff_page_pattern,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Department represents a faculty/department at a university +type Department struct { + ID uuid.UUID `json:"id"` + UniversityID uuid.UUID `json:"university_id"` + Name string `json:"name"` + NameEN *string `json:"name_en,omitempty"` + URL *string `json:"url,omitempty"` + Category *string `json:"category,omitempty"` + ParentID *uuid.UUID `json:"parent_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// UniversityStaff represents a staff member at a university +type UniversityStaff struct { + ID uuid.UUID `json:"id"` + UniversityID uuid.UUID `json:"university_id"` + DepartmentID *uuid.UUID `json:"department_id,omitempty"` + FirstName *string `json:"first_name,omitempty"` + LastName string `json:"last_name"` + FullName *string `json:"full_name,omitempty"` + Title *string `json:"title,omitempty"` + AcademicTitle *string `json:"academic_title,omitempty"` + Position *string `json:"position,omitempty"` + PositionType *string `json:"position_type,omitempty"` + IsProfessor bool `json:"is_professor"` + Email *string `json:"email,omitempty"` + Phone *string `json:"phone,omitempty"` + Office *string `json:"office,omitempty"` + ProfileURL *string `json:"profile_url,omitempty"` + PhotoURL *string `json:"photo_url,omitempty"` + ORCID *string `json:"orcid,omitempty"` + GoogleScholarID *string `json:"google_scholar_id,omitempty"` + ResearchgateURL *string `json:"researchgate_url,omitempty"` + LinkedInURL *string `json:"linkedin_url,omitempty"` + PersonalWebsite *string `json:"personal_website,omitempty"` + ResearchInterests []string `json:"research_interests,omitempty"` + ResearchSummary *string `json:"research_summary,omitempty"` + SupervisorID *uuid.UUID `json:"supervisor_id,omitempty"` + TeamRole *string `json:"team_role,omitempty"` // leitung, mitarbeiter, sekretariat, hiwi, doktorand + CrawledAt time.Time `json:"crawled_at"` + LastVerified *time.Time `json:"last_verified,omitempty"` + IsActive bool `json:"is_active"` + SourceURL *string `json:"source_url,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Joined fields (from views) + UniversityName *string `json:"university_name,omitempty"` + UniversityShort *string `json:"university_short,omitempty"` + DepartmentName *string `json:"department_name,omitempty"` + PublicationCount int `json:"publication_count,omitempty"` + SupervisorName *string `json:"supervisor_name,omitempty"` +} + +// Publication represents an academic publication +type Publication struct { + ID uuid.UUID `json:"id"` + Title string `json:"title"` + TitleEN *string `json:"title_en,omitempty"` + Abstract *string `json:"abstract,omitempty"` + AbstractEN *string `json:"abstract_en,omitempty"` + Year *int `json:"year,omitempty"` + Month *int `json:"month,omitempty"` + PubType *string `json:"pub_type,omitempty"` + Venue *string `json:"venue,omitempty"` + VenueShort *string `json:"venue_short,omitempty"` + Publisher *string `json:"publisher,omitempty"` + DOI *string `json:"doi,omitempty"` + ISBN *string `json:"isbn,omitempty"` + ISSN *string `json:"issn,omitempty"` + ArxivID *string `json:"arxiv_id,omitempty"` + PubmedID *string `json:"pubmed_id,omitempty"` + URL *string `json:"url,omitempty"` + PDFURL *string `json:"pdf_url,omitempty"` + CitationCount int `json:"citation_count"` + Keywords []string `json:"keywords,omitempty"` + Topics []string `json:"topics,omitempty"` + Source *string `json:"source,omitempty"` + RawData []byte `json:"raw_data,omitempty"` + CrawledAt time.Time `json:"crawled_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Joined fields + Authors []string `json:"authors,omitempty"` + AuthorCount int `json:"author_count,omitempty"` +} + +// StaffPublication represents the N:M relationship between staff and publications +type StaffPublication struct { + StaffID uuid.UUID `json:"staff_id"` + PublicationID uuid.UUID `json:"publication_id"` + AuthorPosition *int `json:"author_position,omitempty"` + IsCorresponding bool `json:"is_corresponding"` + CreatedAt time.Time `json:"created_at"` +} + +// UniversityCrawlStatus tracks crawl progress for a university +type UniversityCrawlStatus struct { + UniversityID uuid.UUID `json:"university_id"` + LastStaffCrawl *time.Time `json:"last_staff_crawl,omitempty"` + StaffCrawlStatus string `json:"staff_crawl_status"` + StaffCount int `json:"staff_count"` + StaffErrors []string `json:"staff_errors,omitempty"` + LastPubCrawl *time.Time `json:"last_pub_crawl,omitempty"` + PubCrawlStatus string `json:"pub_crawl_status"` + PubCount int `json:"pub_count"` + PubErrors []string `json:"pub_errors,omitempty"` + NextScheduledCrawl *time.Time `json:"next_scheduled_crawl,omitempty"` + CrawlPriority int `json:"crawl_priority"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// CrawlHistory represents a crawl audit log entry +type CrawlHistory struct { + ID uuid.UUID `json:"id"` + UniversityID *uuid.UUID `json:"university_id,omitempty"` + CrawlType string `json:"crawl_type"` + Status string `json:"status"` + StartedAt time.Time `json:"started_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + ItemsFound int `json:"items_found"` + ItemsNew int `json:"items_new"` + ItemsUpdated int `json:"items_updated"` + Errors []byte `json:"errors,omitempty"` + Metadata []byte `json:"metadata,omitempty"` +} + +// StaffSearchParams contains parameters for searching staff +type StaffSearchParams struct { + Query string `json:"query,omitempty"` + UniversityID *uuid.UUID `json:"university_id,omitempty"` + DepartmentID *uuid.UUID `json:"department_id,omitempty"` + State *string `json:"state,omitempty"` + UniType *string `json:"uni_type,omitempty"` + PositionType *string `json:"position_type,omitempty"` + IsProfessor *bool `json:"is_professor,omitempty"` + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` +} + +// StaffSearchResult contains search results for staff +type StaffSearchResult struct { + Staff []UniversityStaff `json:"staff"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Query string `json:"query,omitempty"` +} + +// PublicationSearchParams contains parameters for searching publications +type PublicationSearchParams struct { + Query string `json:"query,omitempty"` + StaffID *uuid.UUID `json:"staff_id,omitempty"` + Year *int `json:"year,omitempty"` + YearFrom *int `json:"year_from,omitempty"` + YearTo *int `json:"year_to,omitempty"` + PubType *string `json:"pub_type,omitempty"` + Limit int `json:"limit,omitempty"` + Offset int `json:"offset,omitempty"` +} + +// PublicationSearchResult contains search results for publications +type PublicationSearchResult struct { + Publications []Publication `json:"publications"` + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Query string `json:"query,omitempty"` +} + +// StaffStats contains statistics about staff data +type StaffStats struct { + TotalStaff int `json:"total_staff"` + TotalProfessors int `json:"total_professors"` + TotalPublications int `json:"total_publications"` + TotalUniversities int `json:"total_universities"` + ByState map[string]int `json:"by_state,omitempty"` + ByUniType map[string]int `json:"by_uni_type,omitempty"` + ByPositionType map[string]int `json:"by_position_type,omitempty"` + RecentCrawls []CrawlHistory `json:"recent_crawls,omitempty"` +} diff --git a/edu-search-service/internal/database/repository.go b/edu-search-service/internal/database/repository.go new file mode 100644 index 0000000..861dbde --- /dev/null +++ b/edu-search-service/internal/database/repository.go @@ -0,0 +1,684 @@ +package database + +import ( + "context" + "fmt" + "strings" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// Repository provides database operations for staff and publications +type Repository struct { + db *DB +} + +// NewRepository creates a new repository +func NewRepository(db *DB) *Repository { + return &Repository{db: db} +} + +// ============================================================================ +// UNIVERSITIES +// ============================================================================ + +// CreateUniversity creates a new university +func (r *Repository) CreateUniversity(ctx context.Context, u *University) error { + query := ` + INSERT INTO universities (name, short_name, url, state, uni_type, staff_page_pattern) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (url) DO UPDATE SET + name = EXCLUDED.name, + short_name = EXCLUDED.short_name, + state = EXCLUDED.state, + uni_type = EXCLUDED.uni_type, + staff_page_pattern = EXCLUDED.staff_page_pattern, + updated_at = NOW() + RETURNING id, created_at, updated_at + ` + return r.db.Pool.QueryRow(ctx, query, + u.Name, u.ShortName, u.URL, u.State, u.UniType, u.StaffPagePattern, + ).Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt) +} + +// GetUniversity retrieves a university by ID +func (r *Repository) GetUniversity(ctx context.Context, id uuid.UUID) (*University, error) { + query := `SELECT id, name, short_name, url, state, uni_type, staff_page_pattern, created_at, updated_at + FROM universities WHERE id = $1` + + u := &University{} + err := r.db.Pool.QueryRow(ctx, query, id).Scan( + &u.ID, &u.Name, &u.ShortName, &u.URL, &u.State, &u.UniType, + &u.StaffPagePattern, &u.CreatedAt, &u.UpdatedAt, + ) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return u, nil +} + +// GetUniversityByID is an alias for GetUniversity (for interface compatibility) +func (r *Repository) GetUniversityByID(ctx context.Context, id uuid.UUID) (*University, error) { + return r.GetUniversity(ctx, id) +} + +// GetUniversityByURL retrieves a university by URL +func (r *Repository) GetUniversityByURL(ctx context.Context, url string) (*University, error) { + query := `SELECT id, name, short_name, url, state, uni_type, staff_page_pattern, created_at, updated_at + FROM universities WHERE url = $1` + + u := &University{} + err := r.db.Pool.QueryRow(ctx, query, url).Scan( + &u.ID, &u.Name, &u.ShortName, &u.URL, &u.State, &u.UniType, + &u.StaffPagePattern, &u.CreatedAt, &u.UpdatedAt, + ) + if err != nil { + return nil, err + } + return u, nil +} + +// ListUniversities lists all universities +func (r *Repository) ListUniversities(ctx context.Context) ([]University, error) { + query := `SELECT id, name, short_name, url, state, uni_type, staff_page_pattern, created_at, updated_at + FROM universities ORDER BY name` + + rows, err := r.db.Pool.Query(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var universities []University + for rows.Next() { + var u University + if err := rows.Scan( + &u.ID, &u.Name, &u.ShortName, &u.URL, &u.State, &u.UniType, + &u.StaffPagePattern, &u.CreatedAt, &u.UpdatedAt, + ); err != nil { + return nil, err + } + universities = append(universities, u) + } + return universities, rows.Err() +} + +// ============================================================================ +// DEPARTMENTS +// ============================================================================ + +// CreateDepartment creates or updates a department +func (r *Repository) CreateDepartment(ctx context.Context, d *Department) error { + query := ` + INSERT INTO departments (university_id, name, name_en, url, category, parent_id) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (university_id, name) DO UPDATE SET + name_en = EXCLUDED.name_en, + url = EXCLUDED.url, + category = EXCLUDED.category, + parent_id = EXCLUDED.parent_id, + updated_at = NOW() + RETURNING id, created_at, updated_at + ` + return r.db.Pool.QueryRow(ctx, query, + d.UniversityID, d.Name, d.NameEN, d.URL, d.Category, d.ParentID, + ).Scan(&d.ID, &d.CreatedAt, &d.UpdatedAt) +} + +// GetDepartmentByName retrieves a department by university and name +func (r *Repository) GetDepartmentByName(ctx context.Context, uniID uuid.UUID, name string) (*Department, error) { + query := `SELECT id, university_id, name, name_en, url, category, parent_id, created_at, updated_at + FROM departments WHERE university_id = $1 AND name = $2` + + d := &Department{} + err := r.db.Pool.QueryRow(ctx, query, uniID, name).Scan( + &d.ID, &d.UniversityID, &d.Name, &d.NameEN, &d.URL, &d.Category, + &d.ParentID, &d.CreatedAt, &d.UpdatedAt, + ) + if err != nil { + return nil, err + } + return d, nil +} + +// ============================================================================ +// STAFF +// ============================================================================ + +// CreateStaff creates or updates a staff member +func (r *Repository) CreateStaff(ctx context.Context, s *UniversityStaff) error { + query := ` + INSERT INTO university_staff ( + university_id, department_id, first_name, last_name, full_name, + title, academic_title, position, position_type, is_professor, + email, phone, office, profile_url, photo_url, + orcid, google_scholar_id, researchgate_url, linkedin_url, personal_website, + research_interests, research_summary, supervisor_id, team_role, source_url + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, + $21, $22, $23, $24, $25 + ) + ON CONFLICT (university_id, first_name, last_name, COALESCE(department_id, '00000000-0000-0000-0000-000000000000'::uuid)) + DO UPDATE SET + full_name = EXCLUDED.full_name, + title = EXCLUDED.title, + academic_title = EXCLUDED.academic_title, + position = EXCLUDED.position, + position_type = EXCLUDED.position_type, + is_professor = EXCLUDED.is_professor, + email = COALESCE(EXCLUDED.email, university_staff.email), + phone = COALESCE(EXCLUDED.phone, university_staff.phone), + office = COALESCE(EXCLUDED.office, university_staff.office), + profile_url = COALESCE(EXCLUDED.profile_url, university_staff.profile_url), + photo_url = COALESCE(EXCLUDED.photo_url, university_staff.photo_url), + orcid = COALESCE(EXCLUDED.orcid, university_staff.orcid), + google_scholar_id = COALESCE(EXCLUDED.google_scholar_id, university_staff.google_scholar_id), + researchgate_url = COALESCE(EXCLUDED.researchgate_url, university_staff.researchgate_url), + linkedin_url = COALESCE(EXCLUDED.linkedin_url, university_staff.linkedin_url), + personal_website = COALESCE(EXCLUDED.personal_website, university_staff.personal_website), + research_interests = COALESCE(EXCLUDED.research_interests, university_staff.research_interests), + research_summary = COALESCE(EXCLUDED.research_summary, university_staff.research_summary), + supervisor_id = COALESCE(EXCLUDED.supervisor_id, university_staff.supervisor_id), + team_role = COALESCE(EXCLUDED.team_role, university_staff.team_role), + source_url = COALESCE(EXCLUDED.source_url, university_staff.source_url), + crawled_at = NOW(), + updated_at = NOW() + RETURNING id, crawled_at, created_at, updated_at + ` + return r.db.Pool.QueryRow(ctx, query, + s.UniversityID, s.DepartmentID, s.FirstName, s.LastName, s.FullName, + s.Title, s.AcademicTitle, s.Position, s.PositionType, s.IsProfessor, + s.Email, s.Phone, s.Office, s.ProfileURL, s.PhotoURL, + s.ORCID, s.GoogleScholarID, s.ResearchgateURL, s.LinkedInURL, s.PersonalWebsite, + s.ResearchInterests, s.ResearchSummary, s.SupervisorID, s.TeamRole, s.SourceURL, + ).Scan(&s.ID, &s.CrawledAt, &s.CreatedAt, &s.UpdatedAt) +} + +// GetStaff retrieves a staff member by ID +func (r *Repository) GetStaff(ctx context.Context, id uuid.UUID) (*UniversityStaff, error) { + query := `SELECT * FROM v_staff_full WHERE id = $1` + + s := &UniversityStaff{} + err := r.db.Pool.QueryRow(ctx, query, id).Scan( + &s.ID, &s.UniversityID, &s.DepartmentID, &s.FirstName, &s.LastName, &s.FullName, + &s.Title, &s.AcademicTitle, &s.Position, &s.PositionType, &s.IsProfessor, + &s.Email, &s.Phone, &s.Office, &s.ProfileURL, &s.PhotoURL, + &s.ORCID, &s.GoogleScholarID, &s.ResearchgateURL, &s.LinkedInURL, &s.PersonalWebsite, + &s.ResearchInterests, &s.ResearchSummary, &s.CrawledAt, &s.LastVerified, &s.IsActive, &s.SourceURL, + &s.CreatedAt, &s.UpdatedAt, &s.UniversityName, &s.UniversityShort, nil, nil, + &s.DepartmentName, nil, &s.PublicationCount, + ) + if err != nil { + return nil, err + } + return s, nil +} + +// SearchStaff searches for staff members +func (r *Repository) SearchStaff(ctx context.Context, params StaffSearchParams) (*StaffSearchResult, error) { + // Build query dynamically + var conditions []string + var args []interface{} + argNum := 1 + + baseQuery := ` + SELECT s.id, s.university_id, s.department_id, s.first_name, s.last_name, s.full_name, + s.title, s.academic_title, s.position, s.position_type, s.is_professor, + s.email, s.profile_url, s.photo_url, s.orcid, + s.research_interests, s.crawled_at, s.is_active, + u.name as university_name, u.short_name as university_short, u.state as university_state, + d.name as department_name, + (SELECT COUNT(*) FROM staff_publications sp WHERE sp.staff_id = s.id) as publication_count + FROM university_staff s + JOIN universities u ON s.university_id = u.id + LEFT JOIN departments d ON s.department_id = d.id + ` + + if params.Query != "" { + conditions = append(conditions, fmt.Sprintf( + `(to_tsvector('german', COALESCE(s.full_name, '') || ' ' || COALESCE(s.research_summary, '')) @@ plainto_tsquery('german', $%d) + OR s.full_name ILIKE '%%' || $%d || '%%' + OR s.last_name ILIKE '%%' || $%d || '%%')`, + argNum, argNum, argNum)) + args = append(args, params.Query) + argNum++ + } + + if params.UniversityID != nil { + conditions = append(conditions, fmt.Sprintf("s.university_id = $%d", argNum)) + args = append(args, *params.UniversityID) + argNum++ + } + + if params.DepartmentID != nil { + conditions = append(conditions, fmt.Sprintf("s.department_id = $%d", argNum)) + args = append(args, *params.DepartmentID) + argNum++ + } + + if params.State != nil { + conditions = append(conditions, fmt.Sprintf("u.state = $%d", argNum)) + args = append(args, *params.State) + argNum++ + } + + if params.UniType != nil { + conditions = append(conditions, fmt.Sprintf("u.uni_type = $%d", argNum)) + args = append(args, *params.UniType) + argNum++ + } + + if params.PositionType != nil { + conditions = append(conditions, fmt.Sprintf("s.position_type = $%d", argNum)) + args = append(args, *params.PositionType) + argNum++ + } + + if params.IsProfessor != nil { + conditions = append(conditions, fmt.Sprintf("s.is_professor = $%d", argNum)) + args = append(args, *params.IsProfessor) + argNum++ + } + + // Build WHERE clause + whereClause := "" + if len(conditions) > 0 { + whereClause = "WHERE " + strings.Join(conditions, " AND ") + } + + // Count total + countQuery := fmt.Sprintf("SELECT COUNT(*) FROM university_staff s JOIN universities u ON s.university_id = u.id LEFT JOIN departments d ON s.department_id = d.id %s", whereClause) + var total int + if err := r.db.Pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil { + return nil, err + } + + // Apply pagination + limit := params.Limit + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + + offset := params.Offset + if offset < 0 { + offset = 0 + } + + // Full query with pagination + fullQuery := fmt.Sprintf("%s %s ORDER BY s.is_professor DESC, s.last_name ASC LIMIT %d OFFSET %d", + baseQuery, whereClause, limit, offset) + + rows, err := r.db.Pool.Query(ctx, fullQuery, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var staff []UniversityStaff + for rows.Next() { + var s UniversityStaff + var uniState *string + if err := rows.Scan( + &s.ID, &s.UniversityID, &s.DepartmentID, &s.FirstName, &s.LastName, &s.FullName, + &s.Title, &s.AcademicTitle, &s.Position, &s.PositionType, &s.IsProfessor, + &s.Email, &s.ProfileURL, &s.PhotoURL, &s.ORCID, + &s.ResearchInterests, &s.CrawledAt, &s.IsActive, + &s.UniversityName, &s.UniversityShort, &uniState, + &s.DepartmentName, &s.PublicationCount, + ); err != nil { + return nil, err + } + staff = append(staff, s) + } + + return &StaffSearchResult{ + Staff: staff, + Total: total, + Limit: limit, + Offset: offset, + Query: params.Query, + }, rows.Err() +} + +// ============================================================================ +// PUBLICATIONS +// ============================================================================ + +// CreatePublication creates or updates a publication +func (r *Repository) CreatePublication(ctx context.Context, p *Publication) error { + query := ` + INSERT INTO publications ( + title, title_en, abstract, abstract_en, year, month, + pub_type, venue, venue_short, publisher, + doi, isbn, issn, arxiv_id, pubmed_id, + url, pdf_url, citation_count, keywords, topics, source, raw_data + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, + $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22 + ) + ON CONFLICT (doi) WHERE doi IS NOT NULL DO UPDATE SET + title = EXCLUDED.title, + abstract = EXCLUDED.abstract, + year = EXCLUDED.year, + venue = EXCLUDED.venue, + citation_count = EXCLUDED.citation_count, + updated_at = NOW() + RETURNING id, crawled_at, created_at, updated_at + ` + + // Handle potential duplicate without DOI + err := r.db.Pool.QueryRow(ctx, query, + p.Title, p.TitleEN, p.Abstract, p.AbstractEN, p.Year, p.Month, + p.PubType, p.Venue, p.VenueShort, p.Publisher, + p.DOI, p.ISBN, p.ISSN, p.ArxivID, p.PubmedID, + p.URL, p.PDFURL, p.CitationCount, p.Keywords, p.Topics, p.Source, p.RawData, + ).Scan(&p.ID, &p.CrawledAt, &p.CreatedAt, &p.UpdatedAt) + + if err != nil && strings.Contains(err.Error(), "duplicate") { + // Try to find existing publication by title and year + findQuery := `SELECT id FROM publications WHERE title = $1 AND year = $2` + err = r.db.Pool.QueryRow(ctx, findQuery, p.Title, p.Year).Scan(&p.ID) + } + + return err +} + +// LinkStaffPublication creates a link between staff and publication +func (r *Repository) LinkStaffPublication(ctx context.Context, sp *StaffPublication) error { + query := ` + INSERT INTO staff_publications (staff_id, publication_id, author_position, is_corresponding) + VALUES ($1, $2, $3, $4) + ON CONFLICT (staff_id, publication_id) DO UPDATE SET + author_position = EXCLUDED.author_position, + is_corresponding = EXCLUDED.is_corresponding + ` + _, err := r.db.Pool.Exec(ctx, query, + sp.StaffID, sp.PublicationID, sp.AuthorPosition, sp.IsCorresponding, + ) + return err +} + +// GetStaffPublications retrieves all publications for a staff member +func (r *Repository) GetStaffPublications(ctx context.Context, staffID uuid.UUID) ([]Publication, error) { + query := ` + SELECT p.id, p.title, p.abstract, p.year, p.pub_type, p.venue, p.doi, p.url, p.citation_count + FROM publications p + JOIN staff_publications sp ON p.id = sp.publication_id + WHERE sp.staff_id = $1 + ORDER BY p.year DESC NULLS LAST, p.title + ` + + rows, err := r.db.Pool.Query(ctx, query, staffID) + if err != nil { + return nil, err + } + defer rows.Close() + + var pubs []Publication + for rows.Next() { + var p Publication + if err := rows.Scan( + &p.ID, &p.Title, &p.Abstract, &p.Year, &p.PubType, &p.Venue, &p.DOI, &p.URL, &p.CitationCount, + ); err != nil { + return nil, err + } + pubs = append(pubs, p) + } + return pubs, rows.Err() +} + +// SearchPublications searches for publications +func (r *Repository) SearchPublications(ctx context.Context, params PublicationSearchParams) (*PublicationSearchResult, error) { + var conditions []string + var args []interface{} + argNum := 1 + + if params.Query != "" { + conditions = append(conditions, fmt.Sprintf( + `to_tsvector('german', COALESCE(title, '') || ' ' || COALESCE(abstract, '')) @@ plainto_tsquery('german', $%d)`, + argNum)) + args = append(args, params.Query) + argNum++ + } + + if params.StaffID != nil { + conditions = append(conditions, fmt.Sprintf( + `id IN (SELECT publication_id FROM staff_publications WHERE staff_id = $%d)`, + argNum)) + args = append(args, *params.StaffID) + argNum++ + } + + if params.Year != nil { + conditions = append(conditions, fmt.Sprintf("year = $%d", argNum)) + args = append(args, *params.Year) + argNum++ + } + + if params.YearFrom != nil { + conditions = append(conditions, fmt.Sprintf("year >= $%d", argNum)) + args = append(args, *params.YearFrom) + argNum++ + } + + if params.YearTo != nil { + conditions = append(conditions, fmt.Sprintf("year <= $%d", argNum)) + args = append(args, *params.YearTo) + argNum++ + } + + if params.PubType != nil { + conditions = append(conditions, fmt.Sprintf("pub_type = $%d", argNum)) + args = append(args, *params.PubType) + argNum++ + } + + whereClause := "" + if len(conditions) > 0 { + whereClause = "WHERE " + strings.Join(conditions, " AND ") + } + + // Count + countQuery := fmt.Sprintf("SELECT COUNT(*) FROM publications %s", whereClause) + var total int + if err := r.db.Pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil { + return nil, err + } + + // Pagination + limit := params.Limit + if limit <= 0 { + limit = 20 + } + offset := params.Offset + + // Query + query := fmt.Sprintf(` + SELECT id, title, abstract, year, pub_type, venue, doi, url, citation_count, keywords + FROM publications %s + ORDER BY year DESC NULLS LAST, citation_count DESC + LIMIT %d OFFSET %d + `, whereClause, limit, offset) + + rows, err := r.db.Pool.Query(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var pubs []Publication + for rows.Next() { + var p Publication + if err := rows.Scan( + &p.ID, &p.Title, &p.Abstract, &p.Year, &p.PubType, &p.Venue, &p.DOI, &p.URL, &p.CitationCount, &p.Keywords, + ); err != nil { + return nil, err + } + pubs = append(pubs, p) + } + + return &PublicationSearchResult{ + Publications: pubs, + Total: total, + Limit: limit, + Offset: offset, + Query: params.Query, + }, rows.Err() +} + +// ============================================================================ +// CRAWL STATUS +// ============================================================================ + +// UpdateCrawlStatus updates crawl status for a university +func (r *Repository) UpdateCrawlStatus(ctx context.Context, status *UniversityCrawlStatus) error { + query := ` + INSERT INTO university_crawl_status ( + university_id, last_staff_crawl, staff_crawl_status, staff_count, staff_errors, + last_pub_crawl, pub_crawl_status, pub_count, pub_errors, + next_scheduled_crawl, crawl_priority + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (university_id) DO UPDATE SET + last_staff_crawl = EXCLUDED.last_staff_crawl, + staff_crawl_status = EXCLUDED.staff_crawl_status, + staff_count = EXCLUDED.staff_count, + staff_errors = EXCLUDED.staff_errors, + last_pub_crawl = EXCLUDED.last_pub_crawl, + pub_crawl_status = EXCLUDED.pub_crawl_status, + pub_count = EXCLUDED.pub_count, + pub_errors = EXCLUDED.pub_errors, + next_scheduled_crawl = EXCLUDED.next_scheduled_crawl, + crawl_priority = EXCLUDED.crawl_priority, + updated_at = NOW() + ` + _, err := r.db.Pool.Exec(ctx, query, + status.UniversityID, status.LastStaffCrawl, status.StaffCrawlStatus, status.StaffCount, status.StaffErrors, + status.LastPubCrawl, status.PubCrawlStatus, status.PubCount, status.PubErrors, + status.NextScheduledCrawl, status.CrawlPriority, + ) + return err +} + +// GetCrawlStatus retrieves crawl status for a university +func (r *Repository) GetCrawlStatus(ctx context.Context, uniID uuid.UUID) (*UniversityCrawlStatus, error) { + query := `SELECT * FROM university_crawl_status WHERE university_id = $1` + + s := &UniversityCrawlStatus{} + err := r.db.Pool.QueryRow(ctx, query, uniID).Scan( + &s.UniversityID, &s.LastStaffCrawl, &s.StaffCrawlStatus, &s.StaffCount, &s.StaffErrors, + &s.LastPubCrawl, &s.PubCrawlStatus, &s.PubCount, &s.PubErrors, + &s.NextScheduledCrawl, &s.CrawlPriority, &s.CreatedAt, &s.UpdatedAt, + ) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return s, nil +} + +// ============================================================================ +// STATS +// ============================================================================ + +// GetStaffStats retrieves statistics about staff data +func (r *Repository) GetStaffStats(ctx context.Context) (*StaffStats, error) { + stats := &StaffStats{ + ByState: make(map[string]int), + ByUniType: make(map[string]int), + ByPositionType: make(map[string]int), + } + + // Basic counts + queries := []struct { + query string + dest *int + }{ + {"SELECT COUNT(*) FROM university_staff WHERE is_active = true", &stats.TotalStaff}, + {"SELECT COUNT(*) FROM university_staff WHERE is_professor = true AND is_active = true", &stats.TotalProfessors}, + {"SELECT COUNT(*) FROM publications", &stats.TotalPublications}, + {"SELECT COUNT(*) FROM universities", &stats.TotalUniversities}, + } + + for _, q := range queries { + if err := r.db.Pool.QueryRow(ctx, q.query).Scan(q.dest); err != nil { + return nil, err + } + } + + // By state + rows, err := r.db.Pool.Query(ctx, ` + SELECT COALESCE(u.state, 'unknown'), COUNT(*) + FROM university_staff s + JOIN universities u ON s.university_id = u.id + WHERE s.is_active = true + GROUP BY u.state + `) + if err != nil { + return nil, err + } + defer rows.Close() + + for rows.Next() { + var state string + var count int + if err := rows.Scan(&state, &count); err != nil { + return nil, err + } + stats.ByState[state] = count + } + + // By uni type + rows2, err := r.db.Pool.Query(ctx, ` + SELECT COALESCE(u.uni_type, 'unknown'), COUNT(*) + FROM university_staff s + JOIN universities u ON s.university_id = u.id + WHERE s.is_active = true + GROUP BY u.uni_type + `) + if err != nil { + return nil, err + } + defer rows2.Close() + + for rows2.Next() { + var uniType string + var count int + if err := rows2.Scan(&uniType, &count); err != nil { + return nil, err + } + stats.ByUniType[uniType] = count + } + + // By position type + rows3, err := r.db.Pool.Query(ctx, ` + SELECT COALESCE(position_type, 'unknown'), COUNT(*) + FROM university_staff + WHERE is_active = true + GROUP BY position_type + `) + if err != nil { + return nil, err + } + defer rows3.Close() + + for rows3.Next() { + var posType string + var count int + if err := rows3.Scan(&posType, &count); err != nil { + return nil, err + } + stats.ByPositionType[posType] = count + } + + return stats, nil +} diff --git a/edu-search-service/internal/embedding/embedding.go b/edu-search-service/internal/embedding/embedding.go new file mode 100644 index 0000000..5ebf5e0 --- /dev/null +++ b/edu-search-service/internal/embedding/embedding.go @@ -0,0 +1,332 @@ +package embedding + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" +) + +// EmbeddingProvider defines the interface for embedding services +type EmbeddingProvider interface { + // Embed generates embeddings for the given text + Embed(ctx context.Context, text string) ([]float32, error) + + // EmbedBatch generates embeddings for multiple texts + EmbedBatch(ctx context.Context, texts []string) ([][]float32, error) + + // Dimension returns the embedding vector dimension + Dimension() int +} + +// Service wraps an embedding provider +type Service struct { + provider EmbeddingProvider + dimension int + enabled bool +} + +// NewService creates a new embedding service based on configuration +func NewService(provider, apiKey, model, ollamaURL string, dimension int, enabled bool) (*Service, error) { + if !enabled { + return &Service{ + provider: nil, + dimension: dimension, + enabled: false, + }, nil + } + + var p EmbeddingProvider + var err error + + switch provider { + case "openai": + if apiKey == "" { + return nil, errors.New("OpenAI API key required for openai provider") + } + p = NewOpenAIProvider(apiKey, model, dimension) + case "ollama": + p, err = NewOllamaProvider(ollamaURL, model, dimension) + if err != nil { + return nil, err + } + case "none", "": + return &Service{ + provider: nil, + dimension: dimension, + enabled: false, + }, nil + default: + return nil, fmt.Errorf("unknown embedding provider: %s", provider) + } + + return &Service{ + provider: p, + dimension: dimension, + enabled: true, + }, nil +} + +// IsEnabled returns true if semantic search is enabled +func (s *Service) IsEnabled() bool { + return s.enabled && s.provider != nil +} + +// Embed generates embedding for a single text +func (s *Service) Embed(ctx context.Context, text string) ([]float32, error) { + if !s.IsEnabled() { + return nil, errors.New("embedding service not enabled") + } + return s.provider.Embed(ctx, text) +} + +// EmbedBatch generates embeddings for multiple texts +func (s *Service) EmbedBatch(ctx context.Context, texts []string) ([][]float32, error) { + if !s.IsEnabled() { + return nil, errors.New("embedding service not enabled") + } + return s.provider.EmbedBatch(ctx, texts) +} + +// Dimension returns the configured embedding dimension +func (s *Service) Dimension() int { + return s.dimension +} + +// ===================================================== +// OpenAI Embedding Provider +// ===================================================== + +// OpenAIProvider implements EmbeddingProvider using OpenAI's API +type OpenAIProvider struct { + apiKey string + model string + dimension int + httpClient *http.Client +} + +// NewOpenAIProvider creates a new OpenAI embedding provider +func NewOpenAIProvider(apiKey, model string, dimension int) *OpenAIProvider { + return &OpenAIProvider{ + apiKey: apiKey, + model: model, + dimension: dimension, + httpClient: &http.Client{ + Timeout: 60 * time.Second, + }, + } +} + +// openAIEmbeddingRequest represents the OpenAI API request +type openAIEmbeddingRequest struct { + Model string `json:"model"` + Input []string `json:"input"` + Dimensions int `json:"dimensions,omitempty"` +} + +// openAIEmbeddingResponse represents the OpenAI API response +type openAIEmbeddingResponse struct { + Data []struct { + Embedding []float32 `json:"embedding"` + Index int `json:"index"` + } `json:"data"` + Usage struct { + PromptTokens int `json:"prompt_tokens"` + TotalTokens int `json:"total_tokens"` + } `json:"usage"` + Error *struct { + Message string `json:"message"` + Type string `json:"type"` + } `json:"error,omitempty"` +} + +// Embed generates embedding for a single text +func (p *OpenAIProvider) Embed(ctx context.Context, text string) ([]float32, error) { + embeddings, err := p.EmbedBatch(ctx, []string{text}) + if err != nil { + return nil, err + } + if len(embeddings) == 0 { + return nil, errors.New("no embedding returned") + } + return embeddings[0], nil +} + +// EmbedBatch generates embeddings for multiple texts +func (p *OpenAIProvider) EmbedBatch(ctx context.Context, texts []string) ([][]float32, error) { + if len(texts) == 0 { + return nil, nil + } + + // Truncate texts to avoid token limits (max ~8000 tokens per text) + truncatedTexts := make([]string, len(texts)) + for i, text := range texts { + if len(text) > 30000 { // Rough estimate: ~4 chars per token + truncatedTexts[i] = text[:30000] + } else { + truncatedTexts[i] = text + } + } + + reqBody := openAIEmbeddingRequest{ + Model: p.model, + Input: truncatedTexts, + } + + // Only set dimensions for models that support it (text-embedding-3-*) + if p.model == "text-embedding-3-small" || p.model == "text-embedding-3-large" { + reqBody.Dimensions = p.dimension + } + + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.openai.com/v1/embeddings", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+p.apiKey) + req.Header.Set("Content-Type", "application/json") + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to call OpenAI API: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var apiResp openAIEmbeddingResponse + if err := json.Unmarshal(respBody, &apiResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + if apiResp.Error != nil { + return nil, fmt.Errorf("OpenAI API error: %s", apiResp.Error.Message) + } + + if len(apiResp.Data) != len(texts) { + return nil, fmt.Errorf("expected %d embeddings, got %d", len(texts), len(apiResp.Data)) + } + + // Sort by index to maintain order + result := make([][]float32, len(texts)) + for _, item := range apiResp.Data { + result[item.Index] = item.Embedding + } + + return result, nil +} + +// Dimension returns the embedding dimension +func (p *OpenAIProvider) Dimension() int { + return p.dimension +} + +// ===================================================== +// Ollama Embedding Provider (for local models) +// ===================================================== + +// OllamaProvider implements EmbeddingProvider using Ollama's API +type OllamaProvider struct { + baseURL string + model string + dimension int + httpClient *http.Client +} + +// NewOllamaProvider creates a new Ollama embedding provider +func NewOllamaProvider(baseURL, model string, dimension int) (*OllamaProvider, error) { + return &OllamaProvider{ + baseURL: baseURL, + model: model, + dimension: dimension, + httpClient: &http.Client{ + Timeout: 120 * time.Second, // Ollama can be slow on first inference + }, + }, nil +} + +// ollamaEmbeddingRequest represents the Ollama API request +type ollamaEmbeddingRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` +} + +// ollamaEmbeddingResponse represents the Ollama API response +type ollamaEmbeddingResponse struct { + Embedding []float32 `json:"embedding"` +} + +// Embed generates embedding for a single text +func (p *OllamaProvider) Embed(ctx context.Context, text string) ([]float32, error) { + // Truncate text + if len(text) > 30000 { + text = text[:30000] + } + + reqBody := ollamaEmbeddingRequest{ + Model: p.model, + Prompt: text, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", p.baseURL+"/api/embeddings", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to call Ollama API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("Ollama API error (status %d): %s", resp.StatusCode, string(respBody)) + } + + var apiResp ollamaEmbeddingResponse + if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + return apiResp.Embedding, nil +} + +// EmbedBatch generates embeddings for multiple texts (sequential for Ollama) +func (p *OllamaProvider) EmbedBatch(ctx context.Context, texts []string) ([][]float32, error) { + result := make([][]float32, len(texts)) + + for i, text := range texts { + embedding, err := p.Embed(ctx, text) + if err != nil { + return nil, fmt.Errorf("failed to embed text %d: %w", i, err) + } + result[i] = embedding + } + + return result, nil +} + +// Dimension returns the embedding dimension +func (p *OllamaProvider) Dimension() int { + return p.dimension +} diff --git a/edu-search-service/internal/embedding/embedding_test.go b/edu-search-service/internal/embedding/embedding_test.go new file mode 100644 index 0000000..99a2583 --- /dev/null +++ b/edu-search-service/internal/embedding/embedding_test.go @@ -0,0 +1,319 @@ +package embedding + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestNewService_Disabled(t *testing.T) { + service, err := NewService("none", "", "", "", 1536, false) + if err != nil { + t.Fatalf("NewService failed: %v", err) + } + + if service.IsEnabled() { + t.Error("Service should not be enabled") + } + + if service.Dimension() != 1536 { + t.Errorf("Expected dimension 1536, got %d", service.Dimension()) + } +} + +func TestNewService_DisabledByProvider(t *testing.T) { + service, err := NewService("none", "", "", "", 1536, true) + if err != nil { + t.Fatalf("NewService failed: %v", err) + } + + if service.IsEnabled() { + t.Error("Service should not be enabled when provider is 'none'") + } +} + +func TestNewService_OpenAIMissingKey(t *testing.T) { + _, err := NewService("openai", "", "", "", 1536, true) + if err == nil { + t.Error("Expected error for missing OpenAI API key") + } +} + +func TestNewService_UnknownProvider(t *testing.T) { + _, err := NewService("unknown", "", "", "", 1536, true) + if err == nil { + t.Error("Expected error for unknown provider") + } +} + +func TestService_EmbedWhenDisabled(t *testing.T) { + service, _ := NewService("none", "", "", "", 1536, false) + + _, err := service.Embed(context.Background(), "test text") + if err == nil { + t.Error("Expected error when embedding with disabled service") + } +} + +func TestService_EmbedBatchWhenDisabled(t *testing.T) { + service, _ := NewService("none", "", "", "", 1536, false) + + _, err := service.EmbedBatch(context.Background(), []string{"test1", "test2"}) + if err == nil { + t.Error("Expected error when embedding batch with disabled service") + } +} + +// ===================================================== +// OpenAI Provider Tests with Mock Server +// ===================================================== + +func TestOpenAIProvider_Embed(t *testing.T) { + // Create mock server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request + if r.Method != "POST" { + t.Errorf("Expected POST, got %s", r.Method) + } + if r.Header.Get("Authorization") != "Bearer test-api-key" { + t.Errorf("Expected correct Authorization header") + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("Expected Content-Type application/json") + } + + // Parse request body + var reqBody openAIEmbeddingRequest + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + t.Fatalf("Failed to parse request body: %v", err) + } + + if reqBody.Model != "text-embedding-3-small" { + t.Errorf("Expected model text-embedding-3-small, got %s", reqBody.Model) + } + + // Send mock response + resp := openAIEmbeddingResponse{ + Data: []struct { + Embedding []float32 `json:"embedding"` + Index int `json:"index"` + }{ + { + Embedding: make([]float32, 1536), + Index: 0, + }, + }, + } + resp.Data[0].Embedding[0] = 0.1 + resp.Data[0].Embedding[1] = 0.2 + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // Create provider with mock server (we need to override the URL) + provider := &OpenAIProvider{ + apiKey: "test-api-key", + model: "text-embedding-3-small", + dimension: 1536, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } + + // Note: This test won't actually work with the mock server because + // the provider hardcodes the OpenAI URL. This is a structural test. + // For real testing, we'd need to make the URL configurable. + + if provider.Dimension() != 1536 { + t.Errorf("Expected dimension 1536, got %d", provider.Dimension()) + } +} + +func TestOpenAIProvider_EmbedBatch_EmptyInput(t *testing.T) { + provider := NewOpenAIProvider("test-key", "text-embedding-3-small", 1536) + + result, err := provider.EmbedBatch(context.Background(), []string{}) + if err != nil { + t.Errorf("Empty input should not cause error: %v", err) + } + if result != nil { + t.Errorf("Expected nil result for empty input, got %v", result) + } +} + +// ===================================================== +// Ollama Provider Tests with Mock Server +// ===================================================== + +func TestOllamaProvider_Embed(t *testing.T) { + // Create mock server + mockEmbedding := make([]float32, 384) + mockEmbedding[0] = 0.5 + mockEmbedding[1] = 0.3 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("Expected POST, got %s", r.Method) + } + if r.URL.Path != "/api/embeddings" { + t.Errorf("Expected path /api/embeddings, got %s", r.URL.Path) + } + + // Parse request + var reqBody ollamaEmbeddingRequest + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + t.Fatalf("Failed to parse request: %v", err) + } + + if reqBody.Model != "nomic-embed-text" { + t.Errorf("Expected model nomic-embed-text, got %s", reqBody.Model) + } + + // Send response + resp := ollamaEmbeddingResponse{ + Embedding: mockEmbedding, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + provider, err := NewOllamaProvider(server.URL, "nomic-embed-text", 384) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + ctx := context.Background() + embedding, err := provider.Embed(ctx, "Test text für Embedding") + + if err != nil { + t.Fatalf("Embed failed: %v", err) + } + + if len(embedding) != 384 { + t.Errorf("Expected 384 dimensions, got %d", len(embedding)) + } + + if embedding[0] != 0.5 { + t.Errorf("Expected first value 0.5, got %f", embedding[0]) + } +} + +func TestOllamaProvider_EmbedBatch(t *testing.T) { + callCount := 0 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + + mockEmbedding := make([]float32, 384) + mockEmbedding[0] = float32(callCount) * 0.1 + + resp := ollamaEmbeddingResponse{ + Embedding: mockEmbedding, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + provider, err := NewOllamaProvider(server.URL, "nomic-embed-text", 384) + if err != nil { + t.Fatalf("Failed to create provider: %v", err) + } + + ctx := context.Background() + texts := []string{"Text 1", "Text 2", "Text 3"} + embeddings, err := provider.EmbedBatch(ctx, texts) + + if err != nil { + t.Fatalf("EmbedBatch failed: %v", err) + } + + if len(embeddings) != 3 { + t.Errorf("Expected 3 embeddings, got %d", len(embeddings)) + } + + // Verify each embedding was called + if callCount != 3 { + t.Errorf("Expected 3 API calls, got %d", callCount) + } +} + +func TestOllamaProvider_EmbedServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Internal server error")) + })) + defer server.Close() + + provider, _ := NewOllamaProvider(server.URL, "nomic-embed-text", 384) + + _, err := provider.Embed(context.Background(), "test") + if err == nil { + t.Error("Expected error for server error response") + } +} + +func TestOllamaProvider_Dimension(t *testing.T) { + provider, _ := NewOllamaProvider("http://localhost:11434", "nomic-embed-text", 768) + + if provider.Dimension() != 768 { + t.Errorf("Expected dimension 768, got %d", provider.Dimension()) + } +} + +// ===================================================== +// Text Truncation Tests +// ===================================================== + +func TestOllamaProvider_TextTruncation(t *testing.T) { + receivedText := "" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var reqBody ollamaEmbeddingRequest + json.NewDecoder(r.Body).Decode(&reqBody) + receivedText = reqBody.Prompt + + resp := ollamaEmbeddingResponse{ + Embedding: make([]float32, 384), + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + provider, _ := NewOllamaProvider(server.URL, "nomic-embed-text", 384) + + // Create very long text + longText := "" + for i := 0; i < 40000; i++ { + longText += "a" + } + + provider.Embed(context.Background(), longText) + + // Text should be truncated to 30000 chars + if len(receivedText) > 30000 { + t.Errorf("Expected truncated text <= 30000 chars, got %d", len(receivedText)) + } +} + +// ===================================================== +// Integration Tests (require actual service) +// ===================================================== + +func TestOpenAIProvider_Integration(t *testing.T) { + // Skip in CI/CD - only run manually with real API key + t.Skip("Integration test - requires OPENAI_API_KEY environment variable") + + // provider := NewOpenAIProvider(os.Getenv("OPENAI_API_KEY"), "text-embedding-3-small", 1536) + // embedding, err := provider.Embed(context.Background(), "Lehrplan Mathematik Bayern") + // ... +} diff --git a/edu-search-service/internal/extractor/extractor.go b/edu-search-service/internal/extractor/extractor.go new file mode 100644 index 0000000..7e40707 --- /dev/null +++ b/edu-search-service/internal/extractor/extractor.go @@ -0,0 +1,464 @@ +package extractor + +import ( + "bytes" + "io" + "regexp" + "strings" + "unicode" + + "github.com/PuerkitoBio/goquery" + "github.com/ledongthuc/pdf" + "golang.org/x/net/html" +) + +// ExtractedContent contains parsed content from HTML/PDF +type ExtractedContent struct { + Title string + ContentText string + SnippetText string + Language string + ContentLength int + Headings []string + Links []string + MetaData map[string]string + Features ContentFeatures +} + +// ContentFeatures for quality scoring +type ContentFeatures struct { + AdDensity float64 + LinkDensity float64 + TextToHTMLRatio float64 + HasMainContent bool +} + +// ExtractHTML extracts content from HTML +func ExtractHTML(body []byte) (*ExtractedContent, error) { + doc, err := goquery.NewDocumentFromReader(bytes.NewReader(body)) + if err != nil { + return nil, err + } + + content := &ExtractedContent{ + MetaData: make(map[string]string), + } + + // Extract title + content.Title = strings.TrimSpace(doc.Find("title").First().Text()) + if content.Title == "" { + content.Title = strings.TrimSpace(doc.Find("h1").First().Text()) + } + + // Extract meta tags + doc.Find("meta").Each(func(i int, s *goquery.Selection) { + name, _ := s.Attr("name") + property, _ := s.Attr("property") + contentAttr, _ := s.Attr("content") + + key := name + if key == "" { + key = property + } + + if key != "" && contentAttr != "" { + content.MetaData[strings.ToLower(key)] = contentAttr + } + }) + + // Try to get og:title if main title is empty + if content.Title == "" { + if ogTitle, ok := content.MetaData["og:title"]; ok { + content.Title = ogTitle + } + } + + // Extract headings + doc.Find("h1, h2, h3").Each(func(i int, s *goquery.Selection) { + text := strings.TrimSpace(s.Text()) + if text != "" && len(text) < 500 { + content.Headings = append(content.Headings, text) + } + }) + + // Remove unwanted elements + doc.Find("script, style, nav, header, footer, aside, iframe, noscript, form, .advertisement, .ad, .ads, #cookie-banner, .cookie-notice, .social-share").Remove() + + // Try to find main content area + mainContent := doc.Find("main, article, .content, .main-content, #content, #main").First() + if mainContent.Length() == 0 { + mainContent = doc.Find("body") + } + + // Extract text content + var textBuilder strings.Builder + mainContent.Find("p, li, td, th, h1, h2, h3, h4, h5, h6, blockquote, pre").Each(func(i int, s *goquery.Selection) { + text := strings.TrimSpace(s.Text()) + if text != "" { + textBuilder.WriteString(text) + textBuilder.WriteString("\n\n") + } + }) + + content.ContentText = cleanText(textBuilder.String()) + content.ContentLength = len(content.ContentText) + + // Generate snippet (first ~300 chars of meaningful content) + content.SnippetText = generateSnippet(content.ContentText, 300) + + // Extract links + doc.Find("a[href]").Each(func(i int, s *goquery.Selection) { + href, exists := s.Attr("href") + if exists && strings.HasPrefix(href, "http") { + content.Links = append(content.Links, href) + } + }) + + // Detect language + content.Language = detectLanguage(content.ContentText, content.MetaData) + + // Calculate features + htmlLen := float64(len(body)) + textLen := float64(len(content.ContentText)) + + if htmlLen > 0 { + content.Features.TextToHTMLRatio = textLen / htmlLen + } + + if textLen > 0 { + linkTextLen := 0.0 + doc.Find("a").Each(func(i int, s *goquery.Selection) { + linkTextLen += float64(len(s.Text())) + }) + content.Features.LinkDensity = linkTextLen / textLen + } + + content.Features.HasMainContent = content.ContentLength > 200 + + // Ad density estimation (very simple heuristic) + adCount := doc.Find(".ad, .ads, .advertisement, [class*='banner'], [id*='banner']").Length() + totalElements := doc.Find("div, p, article, section").Length() + if totalElements > 0 { + content.Features.AdDensity = float64(adCount) / float64(totalElements) + } + + return content, nil +} + +// ExtractPDF extracts text from PDF using ledongthuc/pdf library +func ExtractPDF(body []byte) (*ExtractedContent, error) { + content := &ExtractedContent{ + MetaData: make(map[string]string), + } + + // Create a reader from the byte slice + reader := bytes.NewReader(body) + pdfReader, err := pdf.NewReader(reader, int64(len(body))) + if err != nil { + // Fallback to basic extraction if PDF parsing fails + return extractPDFFallback(body) + } + + // Extract text using GetPlainText + textReader, err := pdfReader.GetPlainText() + if err != nil { + // Fallback to basic extraction + return extractPDFFallback(body) + } + + // Read all text content + var textBuilder strings.Builder + _, err = io.Copy(&textBuilder, textReader) + if err != nil { + return extractPDFFallback(body) + } + + rawText := textBuilder.String() + + // Clean and process text + content.ContentText = cleanText(rawText) + content.ContentLength = len(content.ContentText) + content.SnippetText = generateSnippet(content.ContentText, 300) + content.Language = detectLanguage(content.ContentText, nil) + content.Features.HasMainContent = content.ContentLength > 200 + + // Extract title from first significant line + content.Title = extractPDFTitle(content.ContentText) + + // Try to extract headings (larger font text often appears first in lines) + content.Headings = extractPDFHeadings(content.ContentText) + + // Set PDF-specific metadata + content.MetaData["content_type"] = "application/pdf" + content.MetaData["page_count"] = string(rune(pdfReader.NumPage())) + + return content, nil +} + +// ExtractPDFWithMetadata extracts text with page-by-page processing +// Use this when you need more control over the extraction process +func ExtractPDFWithMetadata(body []byte) (*ExtractedContent, error) { + content := &ExtractedContent{ + MetaData: make(map[string]string), + } + + reader := bytes.NewReader(body) + pdfReader, err := pdf.NewReader(reader, int64(len(body))) + if err != nil { + return extractPDFFallback(body) + } + + // Extract text page by page for better control + var textBuilder strings.Builder + numPages := pdfReader.NumPage() + + for pageNum := 1; pageNum <= numPages; pageNum++ { + page := pdfReader.Page(pageNum) + if page.V.IsNull() { + continue + } + + // Get page content + pageContent := page.Content() + for _, text := range pageContent.Text { + textBuilder.WriteString(text.S) + textBuilder.WriteString(" ") + } + textBuilder.WriteString("\n") + } + + rawText := textBuilder.String() + + // Clean and process text + content.ContentText = cleanText(rawText) + content.ContentLength = len(content.ContentText) + content.SnippetText = generateSnippet(content.ContentText, 300) + content.Language = detectLanguage(content.ContentText, nil) + content.Features.HasMainContent = content.ContentLength > 200 + + // Extract title and headings from plain text + content.Title = extractPDFTitle(content.ContentText) + content.Headings = extractPDFHeadings(content.ContentText) + + content.MetaData["content_type"] = "application/pdf" + content.MetaData["page_count"] = string(rune(numPages)) + content.MetaData["extraction_method"] = "page_by_page" + + return content, nil +} + +// extractPDFFallback uses basic regex extraction when PDF library fails +func extractPDFFallback(body []byte) (*ExtractedContent, error) { + content := &ExtractedContent{ + MetaData: make(map[string]string), + } + + // Basic PDF text extraction using regex (fallback) + pdfContent := string(body) + var textBuilder strings.Builder + + // Find text content in PDF streams + re := regexp.MustCompile(`\((.*?)\)`) + matches := re.FindAllStringSubmatch(pdfContent, -1) + + for _, match := range matches { + if len(match) > 1 { + text := match[1] + if isPrintableText(text) { + textBuilder.WriteString(text) + textBuilder.WriteString(" ") + } + } + } + + content.ContentText = cleanText(textBuilder.String()) + content.ContentLength = len(content.ContentText) + content.SnippetText = generateSnippet(content.ContentText, 300) + content.Language = detectLanguage(content.ContentText, nil) + content.Features.HasMainContent = content.ContentLength > 200 + content.Title = extractPDFTitle(content.ContentText) + content.MetaData["content_type"] = "application/pdf" + content.MetaData["extraction_method"] = "fallback" + + return content, nil +} + +// extractPDFTitle extracts title from PDF content (first significant line) +func extractPDFTitle(text string) string { + lines := strings.Split(text, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + // Title should be meaningful length + if len(line) >= 10 && len(line) <= 200 { + // Skip lines that look like page numbers or dates + if !regexp.MustCompile(`^\d+$`).MatchString(line) && + !regexp.MustCompile(`^\d{1,2}\.\d{1,2}\.\d{2,4}$`).MatchString(line) { + return line + } + } + } + return "" +} + +// extractPDFHeadings attempts to extract headings from plain text +func extractPDFHeadings(text string) []string { + var headings []string + lines := strings.Split(text, "\n") + + for i, line := range lines { + line = strings.TrimSpace(line) + // Skip very short or very long lines + if len(line) < 5 || len(line) > 200 { + continue + } + + // Heuristics for headings: + // 1. All caps lines (common in PDFs) + // 2. Lines followed by empty line or starting with numbers (1., 1.1, etc.) + // 3. Short lines at beginning of document + + isAllCaps := line == strings.ToUpper(line) && strings.ContainsAny(line, "ABCDEFGHIJKLMNOPQRSTUVWXYZÄÖÜ") + isNumbered := regexp.MustCompile(`^\d+(\.\d+)*\.?\s+\S`).MatchString(line) + isShortAndEarly := i < 20 && len(line) < 80 + + if (isAllCaps || isNumbered || isShortAndEarly) && !containsHeading(headings, line) { + headings = append(headings, line) + if len(headings) >= 10 { + break // Limit to 10 headings + } + } + } + + return headings +} + +// containsHeading checks if a heading already exists in the list +func containsHeading(headings []string, heading string) bool { + for _, h := range headings { + if h == heading { + return true + } + } + return false +} + +func isPrintableText(s string) bool { + if len(s) < 3 { + return false + } + + printable := 0 + for _, r := range s { + if unicode.IsPrint(r) && (unicode.IsLetter(r) || unicode.IsSpace(r) || unicode.IsPunct(r)) { + printable++ + } + } + + return float64(printable)/float64(len(s)) > 0.7 +} + +func cleanText(text string) string { + // Normalize whitespace + text = strings.ReplaceAll(text, "\r\n", "\n") + text = strings.ReplaceAll(text, "\r", "\n") + + // Replace multiple newlines with double newline + re := regexp.MustCompile(`\n{3,}`) + text = re.ReplaceAllString(text, "\n\n") + + // Replace multiple spaces with single space + re = regexp.MustCompile(`[ \t]+`) + text = re.ReplaceAllString(text, " ") + + // Trim each line + lines := strings.Split(text, "\n") + for i, line := range lines { + lines[i] = strings.TrimSpace(line) + } + text = strings.Join(lines, "\n") + + return strings.TrimSpace(text) +} + +func generateSnippet(text string, maxLen int) string { + // Find first paragraph with enough content + paragraphs := strings.Split(text, "\n\n") + + for _, p := range paragraphs { + p = strings.TrimSpace(p) + if len(p) >= 50 { + if len(p) > maxLen { + // Find word boundary + p = p[:maxLen] + lastSpace := strings.LastIndex(p, " ") + if lastSpace > maxLen/2 { + p = p[:lastSpace] + } + p += "..." + } + return p + } + } + + // Fallback: just truncate + if len(text) > maxLen { + text = text[:maxLen] + "..." + } + return text +} + +func detectLanguage(text string, meta map[string]string) string { + // Check meta tags first + if meta != nil { + if lang, ok := meta["og:locale"]; ok { + if strings.HasPrefix(lang, "de") { + return "de" + } + if strings.HasPrefix(lang, "en") { + return "en" + } + } + } + + // Simple heuristic based on common German words + germanWords := []string{ + "und", "der", "die", "das", "ist", "für", "mit", "von", + "werden", "wird", "sind", "auch", "als", "können", "nach", + "einer", "durch", "sich", "bei", "sein", "noch", "haben", + } + + englishWords := []string{ + "the", "and", "for", "are", "but", "not", "you", "all", + "can", "had", "her", "was", "one", "our", "with", "they", + } + + lowerText := strings.ToLower(text) + + germanCount := 0 + for _, word := range germanWords { + if strings.Contains(lowerText, " "+word+" ") { + germanCount++ + } + } + + englishCount := 0 + for _, word := range englishWords { + if strings.Contains(lowerText, " "+word+" ") { + englishCount++ + } + } + + if germanCount > englishCount && germanCount > 3 { + return "de" + } + if englishCount > germanCount && englishCount > 3 { + return "en" + } + + return "de" // Default to German for education content +} + +// UnescapeHTML unescapes HTML entities +func UnescapeHTML(s string) string { + return html.UnescapeString(s) +} diff --git a/edu-search-service/internal/extractor/extractor_test.go b/edu-search-service/internal/extractor/extractor_test.go new file mode 100644 index 0000000..3b09350 --- /dev/null +++ b/edu-search-service/internal/extractor/extractor_test.go @@ -0,0 +1,802 @@ +package extractor + +import ( + "strings" + "testing" +) + +func TestExtractHTML_BasicContent(t *testing.T) { + html := []byte(` + + + Test Page Title + + + + +

              Main Heading

              +

              This is the first paragraph with some meaningful content.

              +

              This is another paragraph that adds more information.

              + +`) + + content, err := ExtractHTML(html) + if err != nil { + t.Fatalf("ExtractHTML failed: %v", err) + } + + // Check title + if content.Title != "Test Page Title" { + t.Errorf("Expected title 'Test Page Title', got %q", content.Title) + } + + // Check metadata + if content.MetaData["description"] != "Test description" { + t.Errorf("Expected description 'Test description', got %q", content.MetaData["description"]) + } + + // Check headings + if len(content.Headings) == 0 { + t.Error("Expected at least one heading") + } + if content.Headings[0] != "Main Heading" { + t.Errorf("Expected heading 'Main Heading', got %q", content.Headings[0]) + } + + // Check content text + if !strings.Contains(content.ContentText, "first paragraph") { + t.Error("Expected content to contain 'first paragraph'") + } +} + +func TestExtractHTML_TitleFallback(t *testing.T) { + tests := []struct { + name string + html string + expected string + }{ + { + name: "Title from title tag", + html: `Page Title`, + expected: "Page Title", + }, + { + name: "Title from H1 when no title tag", + html: `

              H1 Title

              `, + expected: "H1 Title", + }, + { + name: "Title from og:title when no title or h1", + html: ``, + expected: "OG Title", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + content, err := ExtractHTML([]byte(tt.html)) + if err != nil { + t.Fatalf("ExtractHTML failed: %v", err) + } + if content.Title != tt.expected { + t.Errorf("Expected title %q, got %q", tt.expected, content.Title) + } + }) + } +} + +func TestExtractHTML_RemovesUnwantedElements(t *testing.T) { + html := []byte(` + + +
              Header content
              +
              +

              Main content paragraph

              +
              + + +
              Footer content
              + + + +`) + + content, err := ExtractHTML(html) + if err != nil { + t.Fatal(err) + } + + // Should contain main content + if !strings.Contains(content.ContentText, "Main content paragraph") { + t.Error("Expected main content to be extracted") + } + + // Should not contain unwanted elements + unwanted := []string{"Navigation menu", "alert('dangerous')", "Footer content", "Ad content"} + for _, text := range unwanted { + if strings.Contains(content.ContentText, text) { + t.Errorf("Content should not contain %q", text) + } + } +} + +func TestExtractHTML_ExtractsLinks(t *testing.T) { + html := []byte(` +
              Link 1 + Link 2 + Relative Link + Email +`) + + content, err := ExtractHTML(html) + if err != nil { + t.Fatal(err) + } + + // Should extract absolute HTTP links + if len(content.Links) != 2 { + t.Errorf("Expected 2 HTTP links, got %d", len(content.Links)) + } + + hasPage1 := false + hasPage2 := false + for _, link := range content.Links { + if link == "https://example.com/page1" { + hasPage1 = true + } + if link == "https://example.com/page2" { + hasPage2 = true + } + } + + if !hasPage1 || !hasPage2 { + t.Error("Expected to find both HTTP links") + } +} + +func TestExtractHTML_CalculatesFeatures(t *testing.T) { + html := []byte(` + +

              Some content text that is long enough to be meaningful and provide a good ratio.

              +

              More content here to increase the text length.

              + Link 1 + Link 2 +`) + + content, err := ExtractHTML(html) + if err != nil { + t.Fatal(err) + } + + // Check features are calculated + if content.Features.TextToHTMLRatio <= 0 { + t.Error("Expected positive TextToHTMLRatio") + } + + // Content should have length + if content.ContentLength == 0 { + t.Error("Expected non-zero ContentLength") + } +} + +func TestExtractHTML_GeneratesSnippet(t *testing.T) { + html := []byte(` +

              This is a short intro.

              +

              This is a longer paragraph that should be used as the snippet because it has more meaningful content and meets the minimum length requirement for a good snippet.

              +

              Another paragraph here.

              +`) + + content, err := ExtractHTML(html) + if err != nil { + t.Fatal(err) + } + + if content.SnippetText == "" { + t.Error("Expected non-empty snippet") + } + + // Snippet should be limited in length + if len(content.SnippetText) > 350 { // 300 + "..." margin + t.Errorf("Snippet too long: %d chars", len(content.SnippetText)) + } +} + +func TestDetectLanguage(t *testing.T) { + tests := []struct { + name string + text string + meta map[string]string + expected string + }{ + { + name: "German from meta", + text: "Some text", + meta: map[string]string{"og:locale": "de_DE"}, + expected: "de", + }, + { + name: "English from meta", + text: "Some text", + meta: map[string]string{"og:locale": "en_US"}, + expected: "en", + }, + { + name: "German from content", + text: "Dies ist ein Text und der Inhalt wird hier analysiert", + meta: nil, + expected: "de", + }, + { + name: "English from content", + text: "This is the content and we are analyzing the text here with all the words they can use for things but not any German", + meta: nil, + expected: "en", + }, + { + name: "Default to German for ambiguous", + text: "Hello World", + meta: nil, + expected: "de", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := detectLanguage(tt.text, tt.meta) + if result != tt.expected { + t.Errorf("detectLanguage() = %q, expected %q", result, tt.expected) + } + }) + } +} + +func TestCleanText(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "Normalize Windows line endings", + input: "Line1\r\nLine2", + expected: "Line1\nLine2", + }, + { + name: "Collapse multiple newlines", + input: "Line1\n\n\n\n\nLine2", + expected: "Line1\n\nLine2", + }, + { + name: "Collapse multiple spaces", + input: "Word1 Word2", + expected: "Word1 Word2", + }, + { + name: "Trim whitespace", + input: " Text with spaces \n More text ", + expected: "Text with spaces\nMore text", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := cleanText(tt.input) + if result != tt.expected { + t.Errorf("cleanText(%q) = %q, expected %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestGenerateSnippet(t *testing.T) { + tests := []struct { + name string + text string + maxLen int + checkFn func(string) bool + }{ + { + name: "Short text unchanged", + text: "Short paragraph.", + maxLen: 300, + checkFn: func(s string) bool { + return s == "Short paragraph." + }, + }, + { + name: "Long text truncated", + text: strings.Repeat("A long sentence that keeps going. ", 20), + maxLen: 100, + checkFn: func(s string) bool { + return len(s) <= 103 && strings.HasSuffix(s, "...") + }, + }, + { + name: "First suitable paragraph", + text: "Tiny.\n\nThis is a paragraph with enough content to be used as a snippet because it meets the minimum length.", + maxLen: 300, + checkFn: func(s string) bool { + return strings.HasPrefix(s, "This is a paragraph") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := generateSnippet(tt.text, tt.maxLen) + if !tt.checkFn(result) { + t.Errorf("generateSnippet() = %q, check failed", result) + } + }) + } +} + +func TestIsPrintableText(t *testing.T) { + tests := []struct { + name string + input string + expected bool + }{ + { + name: "Normal text", + input: "Hello World", + expected: true, + }, + { + name: "German text", + input: "Übung mit Umlauten", + expected: true, + }, + { + name: "Too short", + input: "AB", + expected: false, + }, + { + name: "Binary data", + input: "\x00\x01\x02\x03\x04", + expected: false, + }, + { + name: "Mixed printable", + input: "Text with some \x00 binary", + expected: true, // >70% printable + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isPrintableText(tt.input) + if result != tt.expected { + t.Errorf("isPrintableText(%q) = %v, expected %v", tt.input, result, tt.expected) + } + }) + } +} + +func TestExtractHTML_HeadingsExtraction(t *testing.T) { + html := []byte(` +

              Main Title

              +

              Section 1

              +

              Content

              +

              Section 2

              +

              Subsection 2.1

              +

              More content

              +`) + + content, err := ExtractHTML(html) + if err != nil { + t.Fatal(err) + } + + if len(content.Headings) != 4 { + t.Errorf("Expected 4 headings (h1, h2, h2, h3), got %d", len(content.Headings)) + } + + expectedHeadings := []string{"Main Title", "Section 1", "Section 2", "Subsection 2.1"} + for i, expected := range expectedHeadings { + if i < len(content.Headings) && content.Headings[i] != expected { + t.Errorf("Heading %d: expected %q, got %q", i, expected, content.Headings[i]) + } + } +} + +func TestExtractHTML_ContentFromMain(t *testing.T) { + html := []byte(` +
              Outside main
              +
              +
              +

              Article content that is inside the main element.

              +
              +
              +
              Also outside
              +`) + + content, err := ExtractHTML(html) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(content.ContentText, "Article content") { + t.Error("Expected content from main element") + } +} + +func TestExtractHTML_MetadataExtraction(t *testing.T) { + html := []byte(` + + + + + + +`) + + content, err := ExtractHTML(html) + if err != nil { + t.Fatal(err) + } + + if content.MetaData["author"] != "Test Author" { + t.Errorf("Expected author 'Test Author', got %q", content.MetaData["author"]) + } + if content.MetaData["keywords"] != "education, learning" { + t.Errorf("Expected keywords, got %q", content.MetaData["keywords"]) + } + if content.MetaData["og:description"] != "OG Description" { + t.Errorf("Expected og:description, got %q", content.MetaData["og:description"]) + } +} + +func TestUnescapeHTML(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"&", "&"}, + {"<script>", " + + diff --git a/h5p-service/editors/drag-drop-editor.html b/h5p-service/editors/drag-drop-editor.html new file mode 100644 index 0000000..5c92b5e --- /dev/null +++ b/h5p-service/editors/drag-drop-editor.html @@ -0,0 +1,407 @@ + + + + + + Drag and Drop Editor - BreakPilot H5P + + + +
              +

              + 🎯 + Drag and Drop Editor +

              + +
              + ✅ Drag and Drop Aufgabe erfolgreich gespeichert! +
              + +
              +

              💡 Wie funktioniert Drag and Drop?

              +

              + Erstelle Ablage-Zonen und zugehörige Elemente. Lernende müssen die Elemente per Drag & Drop in die richtigen Zonen ziehen. + Ideal für Zuordnungsaufgaben, Kategorisierung und mehr. +

              +
              + +
              + + +
              + +
              + + +
              + +
              +

              📍 Ablage-Zonen (Drop Zones)

              +
              + +
              + +
              +

              🔲 Ziehbare Elemente (Draggables)

              +
              + +
              + +
              + + + +
              +
              + + + + diff --git a/h5p-service/editors/fill-blanks-editor.html b/h5p-service/editors/fill-blanks-editor.html new file mode 100644 index 0000000..d7f6eee --- /dev/null +++ b/h5p-service/editors/fill-blanks-editor.html @@ -0,0 +1,312 @@ + + + + + + Fill in the Blanks Editor - BreakPilot H5P + + + +
              +

              + 📝 + Fill in the Blanks Editor +

              + +
              + ✅ Lückentext erfolgreich gespeichert! +
              + +
              +

              💡 Wie funktioniert es?

              +

              + Erstelle einen Text mit Lücken. Markiere die Wörter, die als Lücke erscheinen sollen, mit Sternchen: *Wort* +

              +
              + Beispiel:
              + Berlin ist die *Hauptstadt* von *Deutschland*.
              + → Wird zu: "Berlin ist die _____ von _____." +
              +
              + +
              + + +
              + +
              + + +
              + +
              + + +
              + +
              +

              👁️ Vorschau (wie es für Lernende aussieht):

              +
              +
              + +
              + + + +
              +
              + + + + diff --git a/h5p-service/editors/flashcards-editor.html b/h5p-service/editors/flashcards-editor.html new file mode 100644 index 0000000..28501b9 --- /dev/null +++ b/h5p-service/editors/flashcards-editor.html @@ -0,0 +1,291 @@ + + + + + + Flashcards Editor - BreakPilot H5P + + + +
              +

              + 🃏 + Flashcards Editor +

              + +
              + ✅ Flashcards erfolgreich gespeichert! +
              + +
              + + +
              + +
              + + +
              + +
              + + + +
              + + +
              +
              + + + + diff --git a/h5p-service/editors/interactive-video-editor.html b/h5p-service/editors/interactive-video-editor.html new file mode 100644 index 0000000..ac44b9b --- /dev/null +++ b/h5p-service/editors/interactive-video-editor.html @@ -0,0 +1,441 @@ + + + + + + Interactive Video Editor - BreakPilot H5P + + + +
              +

              + 🎬 + Interactive Video Editor +

              + +
              + ✅ Interactive Video erfolgreich gespeichert! +
              + +
              +

              💡 Wie funktioniert Interactive Video?

              +

              + Füge einem Video interaktive Elemente wie Fragen, Infotexte oder Links hinzu. + Das Video pausiert automatisch an den definierten Zeitpunkten und zeigt die Interaktionen an. +

              +
              + +
              + + +
              + +
              + + +
              Unterstützt: YouTube, Vimeo oder direkte MP4-Links
              +
              + +
              + +
              + + +
              + +
              +

              ⏱️ Interaktive Elemente

              +
              + +
              + +
              + + + +
              +
              + + + + diff --git a/h5p-service/editors/memory-editor.html b/h5p-service/editors/memory-editor.html new file mode 100644 index 0000000..11a073b --- /dev/null +++ b/h5p-service/editors/memory-editor.html @@ -0,0 +1,330 @@ + + + + + + Memory Game Editor - BreakPilot H5P + + + +
              +

              + 🧠 + Memory Game Editor +

              + +
              + ✅ Memory Game erfolgreich gespeichert! +
              + +
              +

              💡 Wie funktioniert Memory?

              +

              + Erstelle Kartenpaare. Die Lernenden müssen die zusammengehörenden Karten finden. + Jedes Paar besteht aus zwei Karten mit verschiedenen Inhalten (z.B. Wort ↔ Übersetzung, Begriff ↔ Definition). +

              +
              + +
              + + +
              + +
              + + +
              + +
              + + + +
              + + + +
              +
              + + + + diff --git a/h5p-service/editors/quiz-editor.html b/h5p-service/editors/quiz-editor.html new file mode 100644 index 0000000..10ffd32 --- /dev/null +++ b/h5p-service/editors/quiz-editor.html @@ -0,0 +1,380 @@ + + + + + + Quiz Editor - BreakPilot H5P + + + +
              +

              + ✍️ + Quiz Editor (Question Set) +

              + +
              + ✅ Quiz erfolgreich gespeichert! +
              + +
              + + +
              + +
              + + +
              + +
              + +
              + + + +
              + + + +
              +
              + + + + diff --git a/h5p-service/editors/timeline-editor.html b/h5p-service/editors/timeline-editor.html new file mode 100644 index 0000000..3f3a55c --- /dev/null +++ b/h5p-service/editors/timeline-editor.html @@ -0,0 +1,329 @@ + + + + + + Timeline Editor - BreakPilot H5P + + + +
              +

              + 📅 + Timeline Editor +

              + +
              + ✅ Timeline erfolgreich gespeichert! +
              + +
              +

              💡 Wie funktioniert Timeline?

              +

              + Erstelle eine interaktive Zeitleiste mit historischen Ereignissen oder Meilensteinen. + Ideal für Geschichte, Biografie, Projektverläufe und mehr. +

              +
              + +
              + + +
              + +
              + + +
              + +
              + + + +
              + + + +
              +
              + + + + diff --git a/h5p-service/jest.config.js b/h5p-service/jest.config.js new file mode 100644 index 0000000..b67f3fb --- /dev/null +++ b/h5p-service/jest.config.js @@ -0,0 +1,22 @@ +/** + * Jest Configuration for H5P Service (ESM) + */ + +export default { + testEnvironment: 'node', + testMatch: ['**/tests/**/*.test.js'], + collectCoverageFrom: [ + 'server-simple.js', + 'setup-h5p.js', + '!node_modules/**', + '!tests/**' + ], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'lcov', 'html'], + verbose: true, + testTimeout: 10000, + setupFilesAfterEnv: ['/tests/setup.js'], + // ESM support + transform: {}, + moduleFileExtensions: ['js', 'mjs'] +}; diff --git a/h5p-service/package.json b/h5p-service/package.json new file mode 100644 index 0000000..75e799d --- /dev/null +++ b/h5p-service/package.json @@ -0,0 +1,24 @@ +{ + "name": "breakpilot-h5p-service", + "version": "1.0.0", + "description": "H5P Interactive Content Service for BreakPilot - Simplified", + "main": "server-simple.js", + "type": "module", + "scripts": { + "start": "node server-simple.js", + "dev": "nodemon server-simple.js", + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage", + "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch", + "test:ci": "node --experimental-vm-modules node_modules/jest/bin/jest.js --ci --coverage --maxWorkers=2" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5" + }, + "devDependencies": { + "nodemon": "^3.0.3", + "jest": "^29.7.0", + "@jest/globals": "^29.7.0", + "supertest": "^6.3.4" + } +} diff --git a/h5p-service/players/course-presentation-player.html b/h5p-service/players/course-presentation-player.html new file mode 100644 index 0000000..a2b9da7 --- /dev/null +++ b/h5p-service/players/course-presentation-player.html @@ -0,0 +1,358 @@ + + + + + + Course Presentation - BreakPilot H5P + + + +
              +
              +

              +

              +
              + +
              +
              +
              + +
              + +
              + + +
              + 1 / 1 +
              + + +
              + +
              +
              + + + + diff --git a/h5p-service/players/drag-drop-player.html b/h5p-service/players/drag-drop-player.html new file mode 100644 index 0000000..233807c --- /dev/null +++ b/h5p-service/players/drag-drop-player.html @@ -0,0 +1,439 @@ + + + + + + Drag and Drop - BreakPilot H5P + + + +
              +

              +

              + +
              +

              🎉 Geschafft!

              +
              +

              +
              + + + +
              +
              + +
              +
              🔲 Ziehe die Elemente
              +
              +
              +
              + +
              + + +
              +
              + + + + diff --git a/h5p-service/players/fill-blanks-player.html b/h5p-service/players/fill-blanks-player.html new file mode 100644 index 0000000..60678f5 --- /dev/null +++ b/h5p-service/players/fill-blanks-player.html @@ -0,0 +1,314 @@ + + + + + + Fill in the Blanks Player - BreakPilot H5P + + + +
              +

              +

              + +
              +

              🎉 Fertig!

              +
              +

              +
              + + + +
              + +
              + + + +
              +
              + + + + diff --git a/h5p-service/players/interactive-video-player.html b/h5p-service/players/interactive-video-player.html new file mode 100644 index 0000000..23521f9 --- /dev/null +++ b/h5p-service/players/interactive-video-player.html @@ -0,0 +1,425 @@ + + + + + + Interactive Video - BreakPilot H5P + + + +
              +

              +

              + +
              +
              + +
              +
              +
              +
              +
              +
              + +
              +
              +
              + +
              +
              📌 Interaktive Elemente im Video:
              +
              +
              + +
              + ℹ️ Das Video pausiert automatisch bei interaktiven Elementen +
              +
              + + + + + + + diff --git a/h5p-service/players/memory-player.html b/h5p-service/players/memory-player.html new file mode 100644 index 0000000..888bb5a --- /dev/null +++ b/h5p-service/players/memory-player.html @@ -0,0 +1,340 @@ + + + + + + Memory Game - BreakPilot H5P + + + +
              +
              +

              +

              +
              +
              +
              Züge
              +
              0
              +
              +
              +
              Gefunden
              +
              0 / 0
              +
              +
              +
              Zeit
              +
              0:00
              +
              +
              +
              + +
              + +
              +
              🏆
              +

              Geschafft!

              +

              Du hast alle Paare gefunden!

              +
              +

              Züge

              + +
              +
              + + + + diff --git a/h5p-service/players/quiz-player.html b/h5p-service/players/quiz-player.html new file mode 100644 index 0000000..40601d7 --- /dev/null +++ b/h5p-service/players/quiz-player.html @@ -0,0 +1,380 @@ + + + + + + Quiz Player - BreakPilot H5P + + + +
              +
              +
              +
              + +

              +

              + +
              +

              🎉 Quiz abgeschlossen!

              +
              +
              +
              + +
              + +
              + + + +
              +
              + + + + diff --git a/h5p-service/players/timeline-player.html b/h5p-service/players/timeline-player.html new file mode 100644 index 0000000..7b9f2fd --- /dev/null +++ b/h5p-service/players/timeline-player.html @@ -0,0 +1,210 @@ + + + + + + Timeline - BreakPilot H5P + + + +
              +

              +

              + +
              +
              + + + + diff --git a/h5p-service/server-simple.js b/h5p-service/server-simple.js new file mode 100644 index 0000000..f13b3e9 --- /dev/null +++ b/h5p-service/server-simple.js @@ -0,0 +1,377 @@ +/** + * BreakPilot H5P Service - Simplified Version + * Minimal H5P integration without h5p-express complexity + */ +import express from 'express'; +import cors from 'cors'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const app = express(); +const PORT = process.env.PORT || 8080; + +// Middleware +app.use(cors()); +app.use(express.json({ limit: '500mb' })); +app.use(express.urlencoded({ extended: true, limit: '500mb' })); + +// Serve static H5P files +app.use('/h5p/core', express.static(path.join(__dirname, 'h5p-core'))); +app.use('/h5p/libraries', express.static(path.join(__dirname, 'h5p-libraries'))); +app.use('/h5p/content', express.static(path.join(__dirname, 'h5p-content'))); + +// Serve editors +app.use('/h5p/editors', express.static(path.join(__dirname, 'editors'))); + +// Serve players +app.use('/h5p/players', express.static(path.join(__dirname, 'players'))); + +// Content type specific editors +app.get('/h5p/editor/quiz', (req, res) => { + res.sendFile(path.join(__dirname, 'editors', 'quiz-editor.html')); +}); + +app.get('/h5p/player/quiz', (req, res) => { + res.sendFile(path.join(__dirname, 'players', 'quiz-player.html')); +}); + +app.get('/h5p/editor/flashcards', (req, res) => { + res.sendFile(path.join(__dirname, 'editors', 'flashcards-editor.html')); +}); + +app.get('/h5p/editor/fill-blanks', (req, res) => { + res.sendFile(path.join(__dirname, 'editors', 'fill-blanks-editor.html')); +}); + +app.get('/h5p/player/fill-blanks', (req, res) => { + res.sendFile(path.join(__dirname, 'players', 'fill-blanks-player.html')); +}); + +app.get('/h5p/editor/memory', (req, res) => { + res.sendFile(path.join(__dirname, 'editors', 'memory-editor.html')); +}); + +app.get('/h5p/player/memory', (req, res) => { + res.sendFile(path.join(__dirname, 'players', 'memory-player.html')); +}); + +app.get('/h5p/editor/drag-drop', (req, res) => { + res.sendFile(path.join(__dirname, 'editors', 'drag-drop-editor.html')); +}); + +app.get('/h5p/player/drag-drop', (req, res) => { + res.sendFile(path.join(__dirname, 'players', 'drag-drop-player.html')); +}); + +app.get('/h5p/editor/timeline', (req, res) => { + res.sendFile(path.join(__dirname, 'editors', 'timeline-editor.html')); +}); + +app.get('/h5p/player/timeline', (req, res) => { + res.sendFile(path.join(__dirname, 'players', 'timeline-player.html')); +}); + +app.get('/h5p/editor/interactive-video', (req, res) => { + res.sendFile(path.join(__dirname, 'editors', 'interactive-video-editor.html')); +}); + +app.get('/h5p/player/interactive-video', (req, res) => { + res.sendFile(path.join(__dirname, 'players', 'interactive-video-player.html')); +}); + +app.get('/h5p/editor/course-presentation', (req, res) => { + res.sendFile(path.join(__dirname, 'editors', 'course-presentation-editor.html')); +}); + +app.get('/h5p/player/course-presentation', (req, res) => { + res.sendFile(path.join(__dirname, 'players', 'course-presentation-player.html')); +}); + +// Main H5P Editor Selection Page +app.get('/h5p/editor/new', (req, res) => { + res.send(` + + + + + H5P Editor - BreakPilot + + + + + + + + + +
              +

              + 🎓 H5P Content Creator + Beta +

              + +
              +

              📚 Willkommen im H5P Content Creator!

              +

              + Erstelle interaktive Lerninhalte wie Quizze, Videos mit Fragen, Präsentationen und vieles mehr. + H5P (HTML5 Package) ermöglicht es, ansprechende und interaktive Bildungsinhalte zu erstellen, + die auf allen Geräten funktionieren. +

              +
              + +

              Beliebte Content-Typen

              + +
              +
              + ✍️ +

              Quiz (Question Set)

              +

              Multiple-Choice-Tests mit sofortigem Feedback und Punktebewertung

              +
              + +
              + 🎬 +

              Interactive Video

              +

              Videos mit eingebetteten Fragen, Links und anderen interaktiven Elementen

              +
              + +
              + 📊 +

              Course Presentation

              +

              Präsentationen mit interaktiven Folien, Fragen und Multimedia-Inhalten

              +
              + +
              + 🃏 +

              Flashcards

              +

              Lernkarten zum Üben und Wiederholen von Vokabeln und Konzepten

              +
              + +
              + 📅 +

              Timeline

              +

              Interaktive Zeitstrahle mit Bildern, Videos und Beschreibungen

              +
              + +
              + 🎯 +

              Drag and Drop

              +

              Elemente ziehen und an der richtigen Stelle ablegen - ideal für Zuordnungsaufgaben

              +
              + +
              + 📝 +

              Fill in the Blanks

              +

              Lückentexte mit automatischer Korrektur und Hinweisen

              +
              + +
              + 🧠 +

              Memory Game

              +

              Klassisches Memory-Spiel mit Bildern oder Text-Paaren

              +
              +
              + +
              +

              ✅ Alle Editoren verfügbar!

              +

              + Alle 8 Content-Typen sind jetzt verfügbar: Quiz, Interactive Video, Course Presentation, Flashcards, Timeline, Drag and Drop, Fill in the Blanks und Memory Game! + Klicke auf eine Kachel, um den Editor zu öffnen. +

              +
              +
              + + + + + + + + + + + + + +`); +}); + +// Health & Info +app.get('/', (req, res) => { + res.send(` + + + + + + BreakPilot H5P Service + + + +
              +

              🎓 H5P Service

              +
              ✅ Running (Simplified)
              +

              + Vereinfachte H5P-Integration für BreakPilot Studio +

              + H5P Editor öffnen + Health Check +
              + + + `); +}); + +app.get('/health', (req, res) => { + res.json({ + status: 'healthy', + service: 'h5p-service-simplified', + version: '1.0.0-simple' + }); +}); + +// Export app for testing +export { app }; + +// Start server only when run directly (not imported for tests) +const isMainModule = import.meta.url === `file://${process.argv[1]}`; +if (isMainModule) { + app.listen(PORT, () => { + console.log(` +╔════════════════════════════════════════════════════╗ +║ 🎓 BreakPilot H5P Service (Simplified) ║ +║ 📍 http://localhost:${PORT} ║ +║ ✅ Ready to serve H5P content! ║ +╚════════════════════════════════════════════════════╝ + `); + }); +} diff --git a/h5p-service/server.js b/h5p-service/server.js new file mode 100644 index 0000000..d38c549 --- /dev/null +++ b/h5p-service/server.js @@ -0,0 +1,438 @@ +/** + * BreakPilot H5P Service + * Self-hosted H5P Interactive Content Server using @lumieducation/h5p-express + */ +import express from 'express'; +import cors from 'cors'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import bodyParser from 'body-parser'; + +import { + H5PEditor, + H5PPlayer, + fsImplementations, + H5PConfig +} from '@lumieducation/h5p-server'; + +import { + h5pAjaxExpressRouter, + libraryAdministrationExpressRouter, + contentTypeCacheExpressRouter +} from '@lumieducation/h5p-express'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const app = express(); +const PORT = process.env.PORT || 8080; + +// Middleware +app.use(cors()); +app.use(bodyParser.json({ limit: '500mb' })); +app.use(bodyParser.urlencoded({ extended: true, limit: '500mb' })); + +// H5P Configuration +const config = new H5PConfig( + new fsImplementations.InMemoryStorage(), + { + baseUrl: 'http://localhost:8003', + contentFilesUrl: '/h5p/content', + downloadUrl: '/h5p/download', + coreUrl: '/h5p/core', + librariesUrl: '/h5p/libraries', + playUrl: '/h5p/play', + ajaxUrl: '/h5p/ajax' + } +); + +// Storage implementations +const contentStorage = new fsImplementations.FileContentStorage( + process.env.H5P_STORAGE_PATH || path.join(__dirname, 'h5p-content') +); + +const libraryStorage = new fsImplementations.FileLibraryStorage( + path.join(__dirname, 'h5p-libraries') +); + +const temporaryStorage = new fsImplementations.DirectoryTemporaryFileStorage( + path.join(__dirname, 'h5p-temp') +); + +// Initialize H5P Editor and Player +let h5pEditor; +let h5pPlayer; + +async function initH5P() { + try { + h5pEditor = new H5PEditor( + contentStorage, + config, + libraryStorage, + undefined, + undefined, + temporaryStorage + ); + + h5pPlayer = new H5PPlayer( + libraryStorage, + contentStorage, + config + ); + + // Install H5P core files + await h5pEditor.installLibraryFromHub('H5P.Column'); + + console.log('✅ H5P Editor and Player initialized'); + return true; + } catch (error) { + console.error('⚠️ H5P initialization:', error.message); + // Continue even if library install fails - editor will show library list + return true; + } +} + +// User object for H5P (simplified) +const getUser = (req) => ({ + id: req.headers['x-user-id'] || 'anonymous', + name: req.headers['x-user-name'] || 'Anonymous', + email: req.headers['x-user-email'] || 'anonymous@breakpilot.app', + canInstallRecommended: true, + canUpdateAndInstallLibraries: true, + canCreateRestricted: true, + type: 'local' +}); + +// Function to register H5P routes (called after init) +function registerH5PRoutes() { + // Serve H5P core static files (JS/CSS) + app.use('/h5p/core', express.static(path.join(__dirname, 'h5p-core'))); + app.use('/h5p/editor', express.static(path.join(__dirname, 'h5p-core'))); + + // Serve H5P libraries + app.use('/h5p/libraries', express.static(path.join(__dirname, 'h5p-libraries'))); + + // Serve H5P content files + app.use('/h5p/content', express.static(path.join(__dirname, 'h5p-content'))); + + // ============= H5P AJAX ROUTES (from h5p-express) ============= + + app.use( + '/h5p/ajax', + h5pAjaxExpressRouter( + h5pEditor, + path.resolve('h5p-core'), + path.resolve('h5p-libraries'), + (req) => getUser(req) + ) + ); + + // ============= LIBRARY ADMINISTRATION ============= + + app.use( + '/h5p/libraries-admin', + libraryAdministrationExpressRouter( + h5pEditor, + (req) => getUser(req) + ) + ); + + // ============= CONTENT TYPE CACHE ============= + + app.use( + '/h5p/content-type-cache', + contentTypeCacheExpressRouter( + h5pEditor, + (req) => getUser(req) + ) + ); +} + +// ============= EDITOR & PLAYER HTML PAGES ============= + +// Create new H5P content (Editor UI) +app.get('/h5p/editor/new', async (req, res) => { + try { + const editorModel = await h5pEditor.render(undefined, 'en', getUser(req)); + + const html = ` + + + + + H5P Editor - BreakPilot + ${editorModel.styles.map(style => ``).join('\n ')} + + + +
              +

              🎓 H5P Content Creator

              + ${editorModel.html} +
              + + + ${editorModel.scripts.map(script => ``).join('\n ')} + +`; + + res.send(html); + } catch (error) { + console.error('Editor render error:', error); + res.status(500).send(` +

              Error loading H5P Editor

              +

              Error: ${error.message}

              +

              Please check that H5P libraries are installed.

              + `); + } +}); + +// Edit existing H5P content +app.get('/h5p/editor/:contentId', async (req, res) => { + try { + const editorModel = await h5pEditor.render(req.params.contentId, 'en', getUser(req)); + + const html = ` + + + + H5P Editor - ${req.params.contentId} + ${editorModel.styles.map(style => ``).join('\n ')} + + +

              Edit H5P Content

              + ${editorModel.html} + + ${editorModel.scripts.map(script => ``).join('\n ')} + +`; + + res.send(html); + } catch (error) { + res.status(500).send(`Error: ${error.message}`); + } +}); + +// Play H5P content +app.get('/h5p/play/:contentId', async (req, res) => { + try { + const playerModel = await h5pPlayer.render(req.params.contentId, getUser(req)); + + res.send(` + + + H5P Player + ${playerModel.styles.map(s => ``).join('\n ')} + + +
              + ${playerModel.html} +
              + + ${playerModel.scripts.map(s => ``).join('\n ')} + +`); + } catch (error) { + res.status(500).send(`Error: ${error.message}`); + } +}); + +// ============= CONTENT MANAGEMENT ============= + +// Save/Update content +app.post('/h5p/content/:contentId?', async (req, res) => { + try { + const contentId = req.params.contentId; + const { library, params } = req.body; + + const savedId = await h5pEditor.saveOrUpdateContent( + contentId || undefined, + params, + library, + getUser(req) + ); + + res.json({ + success: true, + contentId: savedId, + message: 'Content saved successfully' + }); + } catch (error) { + console.error('Save content error:', error); + res.status(500).json({ + success: false, + error: error.message + }); + } +}); + +// List all content +app.get('/h5p/content', async (req, res) => { + try { + const contentIds = await contentStorage.listContent(); + res.json({ contentIds }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Delete content +app.delete('/h5p/content/:contentId', async (req, res) => { + try { + await contentStorage.deleteContent(req.params.contentId, getUser(req)); + res.json({ success: true, message: 'Content deleted' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// ============= INFO & HEALTH ENDPOINTS ============= + +app.get('/', (req, res) => { + res.send(` + + + + + + BreakPilot H5P Service + + + +
              +

              🎓 H5P Service

              +
              ✅ Running
              + Create New H5P Content + Health Check +
              + + + `); +}); + +app.get('/health', (req, res) => { + res.json({ + status: 'healthy', + service: 'h5p-service', + version: '1.0.0' + }); +}); + +app.get('/info', (req, res) => { + res.json({ + service: 'BreakPilot H5P Service', + version: '1.0.0', + endpoints: { + root: '/', + health: '/health', + editorNew: '/h5p/editor/new', + editorEdit: '/h5p/editor/:contentId', + player: '/h5p/play/:contentId', + contentList: '/h5p/content' + } + }); +}); + +// Debug endpoint to see what render() returns +app.get('/debug/editor-model', async (req, res) => { + try { + const model = await h5pEditor.render(undefined, 'en', getUser(req)); + res.json({ + styles: model.styles, + scripts: model.scripts, + integration: model.integration + }); + } catch (error) { + res.status(500).json({ error: error.message, stack: error.stack }); + } +}); + +// Start server +async function start() { + try { + await initH5P(); + + // Register H5P routes after initialization + registerH5PRoutes(); + + app.listen(PORT, () => { + console.log(` +╔════════════════════════════════════════════════════╗ +║ 🎓 BreakPilot H5P Service ║ +║ 📍 http://localhost:${PORT} ║ +║ ✅ Ready to create interactive content! ║ +╚════════════════════════════════════════════════════╝ + `); + }); + } catch (error) { + console.error('Failed to start H5P service:', error); + process.exit(1); + } +} + +start(); diff --git a/h5p-service/setup-h5p.js b/h5p-service/setup-h5p.js new file mode 100644 index 0000000..2615c30 --- /dev/null +++ b/h5p-service/setup-h5p.js @@ -0,0 +1,213 @@ +/** + * H5P Core Files Setup Script + * + * This script downloads and sets up the H5P core files required for the editor and player. + * It creates the necessary directory structure and downloads essential libraries. + */ + +import fs from 'fs'; +import path from 'path'; +import https from 'https'; +import { fileURLToPath } from 'url'; +import { pipeline } from 'stream/promises'; +import { createWriteStream } from 'fs'; +import { exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const H5P_CORE_VERSION = '1.24'; +const H5P_CORE_URL = `https://github.com/h5p/h5p-php-library/archive/refs/tags/${H5P_CORE_VERSION}.zip`; + +// Create required directories +const dirs = [ + 'h5p-core', + 'h5p-libraries', + 'h5p-content', + 'h5p-temp' +]; + +console.log('🎓 Setting up H5P Service...\n'); + +// Create directories +console.log('📁 Creating directories...'); +dirs.forEach(dir => { + const dirPath = path.join(__dirname, dir); + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + console.log(` ✅ Created ${dir}`); + } else { + console.log(` ⏭️ ${dir} already exists`); + } +}); + +// Download H5P core files +async function downloadH5PCore() { + console.log('\n📦 Downloading H5P Core files...'); + console.log(` Version: ${H5P_CORE_VERSION}`); + + const zipPath = path.join(__dirname, 'h5p-core.zip'); + const extractPath = path.join(__dirname, 'h5p-core-temp'); + + try { + // Download core zip + await new Promise((resolve, reject) => { + https.get(H5P_CORE_URL, (response) => { + if (response.statusCode === 302 || response.statusCode === 301) { + // Handle redirect + https.get(response.headers.location, (redirectResponse) => { + const fileStream = createWriteStream(zipPath); + redirectResponse.pipe(fileStream); + fileStream.on('finish', () => { + fileStream.close(); + console.log(' ✅ Downloaded H5P core'); + resolve(); + }); + }).on('error', reject); + } else { + const fileStream = createWriteStream(zipPath); + response.pipe(fileStream); + fileStream.on('finish', () => { + fileStream.close(); + console.log(' ✅ Downloaded H5P core'); + resolve(); + }); + } + }).on('error', reject); + }); + + // Extract zip + console.log(' 📂 Extracting files...'); + + // Check if unzip is available + try { + await execAsync(`unzip -q "${zipPath}" -d "${extractPath}"`); + } catch (error) { + console.log(' ⚠️ unzip not available, trying alternative method...'); + // Alternative: use Node.js built-in extraction if available + throw new Error('Please install unzip: brew install unzip (macOS) or apt-get install unzip (Linux)'); + } + + // Move core files to h5p-core directory + const extractedDir = fs.readdirSync(extractPath)[0]; + const coreSrcPath = path.join(extractPath, extractedDir, 'js'); + const coreDestPath = path.join(__dirname, 'h5p-core'); + + if (fs.existsSync(coreSrcPath)) { + // Copy js files + await execAsync(`cp -r "${coreSrcPath}"/* "${coreDestPath}/"`); + console.log(' ✅ H5P core files installed'); + } + + // Copy styles + const stylesSrcPath = path.join(extractPath, extractedDir, 'styles'); + if (fs.existsSync(stylesSrcPath)) { + const stylesDestPath = path.join(__dirname, 'h5p-core', 'styles'); + fs.mkdirSync(stylesDestPath, { recursive: true }); + await execAsync(`cp -r "${stylesSrcPath}"/* "${stylesDestPath}/"`); + console.log(' ✅ H5P styles installed'); + } + + // Cleanup + fs.unlinkSync(zipPath); + fs.rmSync(extractPath, { recursive: true, force: true }); + + } catch (error) { + console.error(' ❌ Error downloading H5P core:', error.message); + console.log('\n 💡 Manual setup:'); + console.log(' 1. Download from: https://github.com/h5p/h5p-php-library/releases'); + console.log(' 2. Extract to h5p-core/ directory'); + } +} + +// Create basic H5P integration files +function createIntegrationFiles() { + console.log('\n📝 Creating integration files...'); + + // Create a minimal H5PIntegration.js file + const integrationPath = path.join(__dirname, 'h5p-core', 'H5PIntegration.js'); + + if (!fs.existsSync(integrationPath)) { + const integrationContent = ` +// H5P Integration +// This file is automatically generated by setup-h5p.js + +var H5PIntegration = H5PIntegration || {}; + +console.log('H5P Core loaded'); + `.trim(); + + fs.writeFileSync(integrationPath, integrationContent); + console.log(' ✅ Created H5PIntegration.js'); + } else { + console.log(' ⏭️ H5PIntegration.js already exists'); + } +} + +// Create README +function createReadme() { + const readme = `# H5P Service + +## Setup erfolgreich! + +Die folgenden Verzeichnisse wurden erstellt: + +- \`h5p-core/\` - H5P Core JavaScript und CSS +- \`h5p-libraries/\` - Installierte H5P Content Type Libraries +- \`h5p-content/\` - Erstellte H5P Contents +- \`h5p-temp/\` - Temporäre Dateien + +## H5P Content Types installieren + +Um Content Types (wie Interactive Video, Quiz, etc.) zu verwenden, müssen diese +über die H5P Hub API installiert werden. + +Der Editor zeigt verfügbare Content Types automatisch an. + +## Entwicklung + +\`\`\`bash +npm run dev # Start mit nodemon (auto-reload) +npm start # Production start +\`\`\` + +## Endpoints + +- \`GET /h5p/editor/new\` - Neuen Content erstellen +- \`GET /h5p/editor/:id\` - Content bearbeiten +- \`POST /h5p/editor/:id\` - Content speichern +- \`GET /h5p/play/:id\` - Content abspielen +- \`GET /h5p/libraries\` - Installierte Libraries + +## Dokumentation + +- H5P Official: https://h5p.org +- @lumieducation/h5p-server: https://github.com/Lumieducation/H5P-Nodejs-library +`; + + fs.writeFileSync(path.join(__dirname, 'H5P-README.md'), readme); + console.log(' ✅ Created H5P-README.md'); +} + +// Main setup function +async function setup() { + try { + await downloadH5PCore(); + createIntegrationFiles(); + createReadme(); + + console.log('\n✅ H5P Service setup complete!\n'); + console.log('📚 Next steps:'); + console.log(' 1. Start the service: npm start'); + console.log(' 2. Open http://localhost:8080'); + console.log(' 3. Create H5P content via /h5p/editor/new'); + console.log(''); + } catch (error) { + console.error('\n❌ Setup failed:', error); + process.exit(1); + } +} + +setup(); diff --git a/h5p-service/tests/README.md b/h5p-service/tests/README.md new file mode 100644 index 0000000..3e632fa --- /dev/null +++ b/h5p-service/tests/README.md @@ -0,0 +1,170 @@ +# H5P Service Tests + +## Overview + +Dieser Ordner enthält Integration Tests für den BreakPilot H5P Service. + +## Test-Struktur + +``` +tests/ +├── README.md # Diese Datei +├── setup.js # Jest Test Setup +└── server.test.js # Integration Tests für Server Endpoints +``` + +## Test-Coverage + +Die Tests decken folgende Bereiche ab: + +### 1. Health & Info Endpoints +- `GET /` - Service Info Page +- `GET /health` - Health Check + +### 2. Editor Selection Page +- `GET /h5p/editor/new` - Hauptseite mit allen 8 Content-Typen + +### 3. Content Type Editors (8 Typen) +- Quiz Editor +- Interactive Video Editor +- Course Presentation Editor +- Flashcards Editor +- Timeline Editor +- Drag and Drop Editor +- Fill in the Blanks Editor +- Memory Game Editor + +### 4. Content Type Players (8 Typen) +- Quiz Player +- Interactive Video Player +- Course Presentation Player +- Flashcards Player (coming soon) +- Timeline Player +- Drag and Drop Player +- Fill in the Blanks Player +- Memory Game Player + +### 5. Static File Serving +- `/h5p/core/*` - H5P Core Files +- `/h5p/editors/*` - Editor HTML Files +- `/h5p/players/*` - Player HTML Files + +### 6. Error Handling +- 404 für nicht existierende Routes +- Invalid Editor/Player Routes + +## Tests ausführen + +### Lokale Entwicklung + +```bash +# Alle Tests ausführen +npm test + +# Tests mit Watch-Mode +npm run test:watch + +# Tests für CI/CD +npm run test:ci +``` + +### Docker Container Tests + +```bash +# Service starten +docker compose -f docker-compose.content.yml up -d h5p-service + +# Tests im Container ausführen +docker compose -f docker-compose.content.yml exec h5p-service npm test +``` + +## Test-Konfiguration + +### Environment Variables + +| Variable | Default | Beschreibung | +|----------|---------|--------------| +| `H5P_TEST_URL` | `http://localhost:8080` | Base URL für Tests | + +### Jest Konfiguration + +Siehe `jest.config.js` für Details: +- Test Timeout: 10000ms +- Coverage Reports: text, lcov, html +- Test Match: `**/tests/**/*.test.js` + +## Coverage + +Coverage Reports werden generiert in: +- `coverage/lcov-report/index.html` (HTML Report) +- `coverage/lcov.info` (LCOV Format) +- Terminal Output + +Ziel: >80% Coverage + +## Neue Tests hinzufügen + +### Test-Template + +```javascript +describe('Feature Name', () => { + test('should do something', async () => { + const response = await request(BASE_URL).get('/endpoint'); + + expect(response.status).toBe(200); + expect(response.text).toContain('Expected Content'); + }); +}); +``` + +## Troubleshooting + +### Service nicht erreichbar + +```bash +# Service Status prüfen +docker compose -f docker-compose.content.yml ps + +# Logs ansehen +docker compose -f docker-compose.content.yml logs h5p-service + +# Service neu starten +docker compose -f docker-compose.content.yml restart h5p-service +``` + +### Tests schlagen fehl + +1. Prüfe, ob Service läuft: `curl http://localhost:8003/health` +2. Prüfe Logs: `docker compose -f docker-compose.content.yml logs h5p-service` +3. Rebuilde Container: `docker compose -f docker-compose.content.yml up -d --build h5p-service` + +## Best Practices + +1. **Isolierte Tests**: Jeder Test sollte unabhängig laufen +2. **Cleanup**: Tests sollten keine persistenten Änderungen hinterlassen +3. **Assertions**: Klare und aussagekräftige Expectations +4. **Beschreibungen**: Aussagekräftige test/describe Namen +5. **Speed**: Integration Tests sollten <10s dauern + +## CI/CD Integration + +Die Tests werden automatisch ausgeführt bei: +- Pull Requests +- Commits auf `main` branch +- Release Builds + +GitHub Actions Workflow: +```yaml +- name: Run H5P Service Tests + run: | + docker compose -f docker-compose.content.yml up -d h5p-service + docker compose -f docker-compose.content.yml exec h5p-service npm run test:ci +``` + +## Zukünftige Erweiterungen + +- [ ] E2E Tests mit Playwright +- [ ] Performance Tests +- [ ] Content Validation Tests +- [ ] Security Tests (XSS, CSRF) +- [ ] Load Tests diff --git a/h5p-service/tests/server.test.js b/h5p-service/tests/server.test.js new file mode 100644 index 0000000..acf126b --- /dev/null +++ b/h5p-service/tests/server.test.js @@ -0,0 +1,236 @@ +/** + * H5P Service Integration Tests (ESM) + * Tests für alle H5P Service Endpoints + */ + +import request from 'supertest'; +import { describe, test, expect } from '@jest/globals'; +import { app } from '../server-simple.js'; + +describe('H5P Service - Health & Info', () => { + test('GET / should return service info page', async () => { + const response = await request(app).get('/'); + + expect(response.status).toBe(200); + expect(response.text).toContain('H5P Service'); + expect(response.text).toContain('Running'); + }); + + test('GET /health should return healthy status', async () => { + const response = await request(app).get('/health'); + + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ + status: 'healthy', + service: 'h5p-service-simplified' + }); + }); +}); + +describe('H5P Service - Editor Selection Page', () => { + test('GET /h5p/editor/new should return editor selection page', async () => { + const response = await request(app).get('/h5p/editor/new'); + + expect(response.status).toBe(200); + expect(response.text).toContain('H5P Content Creator'); + expect(response.text).toContain('Quiz'); + expect(response.text).toContain('Interactive Video'); + expect(response.text).toContain('Course Presentation'); + expect(response.text).toContain('Flashcards'); + expect(response.text).toContain('Timeline'); + expect(response.text).toContain('Drag and Drop'); + expect(response.text).toContain('Fill in the Blanks'); + expect(response.text).toContain('Memory Game'); + }); + + test('Editor page should contain all 8 content types', async () => { + const response = await request(app).get('/h5p/editor/new'); + + const contentTypes = [ + 'Quiz', + 'Interactive Video', + 'Course Presentation', + 'Flashcards', + 'Timeline', + 'Drag and Drop', + 'Fill in the Blanks', + 'Memory Game' + ]; + + contentTypes.forEach(type => { + expect(response.text).toContain(type); + }); + }); +}); + +describe('H5P Service - Quiz Editor & Player', () => { + test('GET /h5p/editor/quiz should return quiz editor', async () => { + const response = await request(app).get('/h5p/editor/quiz'); + + expect(response.status).toBe(200); + expect(response.text).toContain('Quiz Editor'); + expect(response.text).toContain('Frage hinzufügen'); + }); + + test('GET /h5p/player/quiz should return quiz player', async () => { + const response = await request(app).get('/h5p/player/quiz'); + + expect(response.status).toBe(200); + expect(response.text).toContain('Quiz'); + }); +}); + +describe('H5P Service - Interactive Video Editor & Player', () => { + test('GET /h5p/editor/interactive-video should return video editor', async () => { + const response = await request(app).get('/h5p/editor/interactive-video'); + + expect(response.status).toBe(200); + expect(response.text).toContain('Interactive Video Editor'); + expect(response.text).toContain('Video-URL'); + }); + + test('GET /h5p/player/interactive-video should return video player', async () => { + const response = await request(app).get('/h5p/player/interactive-video'); + + expect(response.status).toBe(200); + expect(response.text).toContain('Interactive Video'); + }); +}); + +describe('H5P Service - Course Presentation Editor & Player', () => { + test('GET /h5p/editor/course-presentation should return presentation editor', async () => { + const response = await request(app).get('/h5p/editor/course-presentation'); + + expect(response.status).toBe(200); + expect(response.text).toContain('Course Presentation Editor'); + expect(response.text).toContain('Folien'); + }); + + test('GET /h5p/player/course-presentation should return presentation player', async () => { + const response = await request(app).get('/h5p/player/course-presentation'); + + expect(response.status).toBe(200); + expect(response.text).toContain('Course Presentation'); + }); +}); + +describe('H5P Service - Flashcards Editor', () => { + test('GET /h5p/editor/flashcards should return flashcards editor', async () => { + const response = await request(app).get('/h5p/editor/flashcards'); + + expect(response.status).toBe(200); + expect(response.text).toContain('Flashcards Editor'); + expect(response.text).toContain('Karte hinzufügen'); + }); +}); + +describe('H5P Service - Timeline Editor & Player', () => { + test('GET /h5p/editor/timeline should return timeline editor', async () => { + const response = await request(app).get('/h5p/editor/timeline'); + + expect(response.status).toBe(200); + expect(response.text).toContain('Timeline Editor'); + expect(response.text).toContain('Ereignis'); + }); + + test('GET /h5p/player/timeline should return timeline player', async () => { + const response = await request(app).get('/h5p/player/timeline'); + + expect(response.status).toBe(200); + expect(response.text).toContain('Timeline'); + }); +}); + +describe('H5P Service - Drag and Drop Editor & Player', () => { + test('GET /h5p/editor/drag-drop should return drag drop editor', async () => { + const response = await request(app).get('/h5p/editor/drag-drop'); + + expect(response.status).toBe(200); + expect(response.text).toContain('Drag and Drop Editor'); + expect(response.text).toContain('Drop Zones'); + }); + + test('GET /h5p/player/drag-drop should return drag drop player', async () => { + const response = await request(app).get('/h5p/player/drag-drop'); + + expect(response.status).toBe(200); + expect(response.text).toContain('Drag and Drop'); + }); +}); + +describe('H5P Service - Fill in the Blanks Editor & Player', () => { + test('GET /h5p/editor/fill-blanks should return fill blanks editor', async () => { + const response = await request(app).get('/h5p/editor/fill-blanks'); + + expect(response.status).toBe(200); + expect(response.text).toContain('Fill in the Blanks Editor'); + expect(response.text).toContain('Lückentext'); + }); + + test('GET /h5p/player/fill-blanks should return fill blanks player', async () => { + const response = await request(app).get('/h5p/player/fill-blanks'); + + expect(response.status).toBe(200); + expect(response.text).toContain('Fill in the Blanks'); + }); +}); + +describe('H5P Service - Memory Game Editor & Player', () => { + test('GET /h5p/editor/memory should return memory editor', async () => { + const response = await request(app).get('/h5p/editor/memory'); + + expect(response.status).toBe(200); + expect(response.text).toContain('Memory Game Editor'); + expect(response.text).toContain('Paar'); + }); + + test('GET /h5p/player/memory should return memory player', async () => { + const response = await request(app).get('/h5p/player/memory'); + + expect(response.status).toBe(200); + expect(response.text).toContain('Memory'); + }); +}); + +describe('H5P Service - Static Files', () => { + test('Static core files should be accessible', async () => { + const response = await request(app).get('/h5p/core/h5p.css'); + + // May or may not exist, but should not 500 + expect([200, 404]).toContain(response.status); + }); + + test('Editors directory should be accessible', async () => { + const response = await request(app).get('/h5p/editors/quiz-editor.html'); + + expect(response.status).toBe(200); + expect(response.text).toContain('Quiz Editor'); + }); + + test('Players directory should be accessible', async () => { + const response = await request(app).get('/h5p/players/quiz-player.html'); + + expect(response.status).toBe(200); + expect(response.text).toContain('Quiz'); + }); +}); + +describe('H5P Service - Error Handling', () => { + test('GET /nonexistent should return 404', async () => { + const response = await request(app).get('/nonexistent'); + + expect(response.status).toBe(404); + }); + + test('Invalid editor route should return 404', async () => { + const response = await request(app).get('/h5p/editor/nonexistent'); + + expect(response.status).toBe(404); + }); + + test('Invalid player route should return 404', async () => { + const response = await request(app).get('/h5p/player/nonexistent'); + + expect(response.status).toBe(404); + }); +}); diff --git a/h5p-service/tests/setup.js b/h5p-service/tests/setup.js new file mode 100644 index 0000000..8477bb2 --- /dev/null +++ b/h5p-service/tests/setup.js @@ -0,0 +1,22 @@ +/** + * Jest Test Setup + * Konfiguriert Testumgebung für H5P Service Tests + * + * Note: Uses global Jest functions to avoid ESM import issues + * with setupFilesAfterEnv in some Jest versions. + */ + +// Timeout für alle Tests erhöhen (global.jest is available in Jest environment) +if (typeof jest !== 'undefined') { + jest.setTimeout(10000); +} + +// Before all tests +beforeAll(() => { + console.log('Starting H5P Service Tests...'); +}); + +// After all tests +afterAll(() => { + console.log('H5P Service Tests Completed'); +}); diff --git a/klausur-service/Dockerfile b/klausur-service/Dockerfile new file mode 100644 index 0000000..1a4b744 --- /dev/null +++ b/klausur-service/Dockerfile @@ -0,0 +1,42 @@ +# Build stage for React frontend +FROM node:20-alpine AS frontend-builder + +WORKDIR /frontend +COPY frontend/package*.json ./ +RUN npm install + +COPY frontend/ ./ +RUN npm run build + +# Production stage +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY backend/requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +# Copy backend code +COPY backend/ ./ + +# Copy built frontend to the expected path +COPY --from=frontend-builder /frontend/dist ./frontend/dist + +# Create uploads directory +RUN mkdir -p /app/uploads + +# Expose port +EXPOSE 8086 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8086/health || exit 1 + +# Run the application +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8086"] diff --git a/klausur-service/backend/admin_api.py b/klausur-service/backend/admin_api.py new file mode 100644 index 0000000..52a7d2b --- /dev/null +++ b/klausur-service/backend/admin_api.py @@ -0,0 +1,1012 @@ +""" +Admin API for NiBiS Data Management +Endpoints for ingestion, monitoring, and data management. +""" + +from fastapi import APIRouter, HTTPException, BackgroundTasks, Query, UploadFile, File, Form +from pydantic import BaseModel +from typing import Optional, List, Dict +from datetime import datetime +from pathlib import Path +import asyncio +import zipfile +import shutil +import tempfile +import os + +from nibis_ingestion import ( + run_ingestion, + discover_documents, + extract_zip_files, + DOCS_BASE_PATH, + NiBiSDocument, +) +from qdrant_service import QdrantService, search_nibis_eh, get_qdrant_client +from eh_pipeline import generate_single_embedding + +# Optional: MinIO and PostgreSQL integrations +try: + from minio_storage import upload_rag_document, get_storage_stats, init_minio_bucket + MINIO_AVAILABLE = True +except ImportError: + MINIO_AVAILABLE = False + +try: + from metrics_db import ( + init_metrics_tables, store_feedback, log_search, log_upload, + calculate_metrics, get_recent_feedback, get_upload_history + ) + METRICS_DB_AVAILABLE = True +except ImportError: + METRICS_DB_AVAILABLE = False + +router = APIRouter(prefix="/api/v1/admin", tags=["Admin"]) + +# Store for background task status +_ingestion_status: Dict = { + "running": False, + "last_run": None, + "last_result": None, +} + + +# ============================================================================= +# Models +# ============================================================================= + +class IngestionRequest(BaseModel): + ewh_only: bool = True + year_filter: Optional[int] = None + subject_filter: Optional[str] = None + + +class IngestionStatus(BaseModel): + running: bool + last_run: Optional[str] + documents_indexed: Optional[int] + chunks_created: Optional[int] + errors: Optional[List[str]] + + +class NiBiSSearchRequest(BaseModel): + query: str + year: Optional[int] = None + subject: Optional[str] = None + niveau: Optional[str] = None + limit: int = 5 + + +class NiBiSSearchResult(BaseModel): + id: str + score: float + text: str + year: Optional[int] + subject: Optional[str] + niveau: Optional[str] + task_number: Optional[int] + + +class DataSourceStats(BaseModel): + source_dir: str + year: int + document_count: int + subjects: List[str] + + +# ============================================================================= +# Endpoints +# ============================================================================= + +@router.get("/nibis/status", response_model=IngestionStatus) +async def get_ingestion_status(): + """Get status of NiBiS ingestion pipeline.""" + last_result = _ingestion_status.get("last_result") or {} + return IngestionStatus( + running=_ingestion_status["running"], + last_run=_ingestion_status.get("last_run"), + documents_indexed=last_result.get("documents_indexed"), + chunks_created=last_result.get("chunks_created"), + errors=(last_result.get("errors") or [])[:10], + ) + + +@router.post("/nibis/extract-zips") +async def extract_zip_files_endpoint(): + """Extract all ZIP files in za-download directories.""" + try: + extracted = extract_zip_files(DOCS_BASE_PATH) + return { + "status": "success", + "extracted_count": len(extracted), + "directories": [str(d) for d in extracted], + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/nibis/discover") +async def discover_nibis_documents( + ewh_only: bool = Query(True, description="Only return Erwartungshorizonte"), + year: Optional[int] = Query(None, description="Filter by year"), + subject: Optional[str] = Query(None, description="Filter by subject"), +): + """ + Discover available NiBiS documents without indexing. + Useful for previewing what will be indexed. + """ + try: + documents = discover_documents(DOCS_BASE_PATH, ewh_only=ewh_only) + + # Apply filters + if year: + documents = [d for d in documents if d.year == year] + if subject: + documents = [d for d in documents if subject.lower() in d.subject.lower()] + + # Group by year and subject + by_year: Dict[int, int] = {} + by_subject: Dict[str, int] = {} + for doc in documents: + by_year[doc.year] = by_year.get(doc.year, 0) + 1 + by_subject[doc.subject] = by_subject.get(doc.subject, 0) + 1 + + return { + "total_documents": len(documents), + "by_year": dict(sorted(by_year.items())), + "by_subject": dict(sorted(by_subject.items(), key=lambda x: -x[1])), + "sample_documents": [ + { + "id": d.id, + "filename": d.raw_filename, + "year": d.year, + "subject": d.subject, + "niveau": d.niveau, + "doc_type": d.doc_type, + } + for d in documents[:20] + ], + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/nibis/ingest") +async def start_ingestion( + request: IngestionRequest, + background_tasks: BackgroundTasks, +): + """ + Start NiBiS data ingestion in background. + This will: + 1. Extract any ZIP files + 2. Discover all Erwartungshorizonte + 3. Extract text from PDFs + 4. Generate embeddings + 5. Index in Qdrant + """ + if _ingestion_status["running"]: + raise HTTPException( + status_code=409, + detail="Ingestion already running. Check /nibis/status for progress." + ) + + async def run_ingestion_task(): + global _ingestion_status + _ingestion_status["running"] = True + _ingestion_status["last_run"] = datetime.now().isoformat() + + try: + result = await run_ingestion( + ewh_only=request.ewh_only, + dry_run=False, + year_filter=request.year_filter, + subject_filter=request.subject_filter, + ) + _ingestion_status["last_result"] = result + except Exception as e: + _ingestion_status["last_result"] = {"error": str(e), "errors": [str(e)]} + finally: + _ingestion_status["running"] = False + + background_tasks.add_task(run_ingestion_task) + + return { + "status": "started", + "message": "Ingestion started in background. Check /nibis/status for progress.", + "filters": { + "ewh_only": request.ewh_only, + "year": request.year_filter, + "subject": request.subject_filter, + }, + } + + +@router.post("/nibis/search", response_model=List[NiBiSSearchResult]) +async def search_nibis(request: NiBiSSearchRequest): + """ + Semantic search in NiBiS Erwartungshorizonte. + Returns relevant chunks based on query. + """ + try: + # Generate query embedding + query_embedding = await generate_single_embedding(request.query) + + if not query_embedding: + raise HTTPException(status_code=500, detail="Failed to generate embedding") + + # Search + results = await search_nibis_eh( + query_embedding=query_embedding, + year=request.year, + subject=request.subject, + niveau=request.niveau, + limit=request.limit, + ) + + return [ + NiBiSSearchResult( + id=r["id"], + score=r["score"], + text=r.get("text", "")[:500], # Truncate for response + year=r.get("year"), + subject=r.get("subject"), + niveau=r.get("niveau"), + task_number=r.get("task_number"), + ) + for r in results + ] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/nibis/collections") +async def get_collections_info(): + """Get information about all Qdrant collections.""" + try: + client = get_qdrant_client() + collections = client.get_collections().collections + + result = [] + for c in collections: + try: + info = client.get_collection(c.name) + result.append({ + "name": c.name, + "vectors_count": info.vectors_count, + "points_count": info.points_count, + "status": info.status.value, + }) + except Exception as e: + result.append({ + "name": c.name, + "error": str(e), + }) + + return {"collections": result} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/nibis/stats") +async def get_nibis_stats(): + """ + Get detailed statistics about indexed NiBiS data. + """ + try: + qdrant = QdrantService() + stats = await qdrant.get_stats("bp_nibis_eh") + + if "error" in stats: + return { + "indexed": False, + "message": "NiBiS collection not yet created. Run ingestion first.", + } + + # Get sample data to show coverage + client = get_qdrant_client() + + # Scroll to get unique years/subjects + scroll_result = client.scroll( + collection_name="bp_nibis_eh", + limit=1000, + with_payload=True, + with_vectors=False, + ) + + years = set() + subjects = set() + niveaus = set() + + for point in scroll_result[0]: + if point.payload: + if "year" in point.payload: + years.add(point.payload["year"]) + if "subject" in point.payload: + subjects.add(point.payload["subject"]) + if "niveau" in point.payload: + niveaus.add(point.payload["niveau"]) + + return { + "indexed": True, + "total_chunks": stats.get("points_count", 0), + "years": sorted(list(years)), + "subjects": sorted(list(subjects)), + "niveaus": sorted(list(niveaus)), + } + except Exception as e: + return { + "indexed": False, + "error": str(e), + } + + +@router.delete("/nibis/collection") +async def delete_nibis_collection(): + """ + Delete the entire NiBiS collection. + WARNING: This will remove all indexed data! + """ + try: + client = get_qdrant_client() + client.delete_collection("bp_nibis_eh") + return {"status": "deleted", "collection": "bp_nibis_eh"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================================================= +# RAG Upload API - ZIP and PDF Upload Support +# ============================================================================= + +# Upload directory configuration +RAG_UPLOAD_BASE = Path(os.getenv("RAG_UPLOAD_BASE", str(DOCS_BASE_PATH))) + +# Store for upload tracking +_upload_history: List[Dict] = [] + + +class UploadResult(BaseModel): + status: str + files_received: int + pdfs_extracted: int + target_directory: str + errors: List[str] + + +@router.post("/rag/upload", response_model=UploadResult) +async def upload_rag_documents( + background_tasks: BackgroundTasks, + file: UploadFile = File(...), + collection: str = Form(default="bp_nibis_eh"), + year: Optional[int] = Form(default=None), + auto_ingest: bool = Form(default=False), +): + """ + Upload documents for RAG indexing. + + Supports: + - ZIP archives (automatically extracted) + - Individual PDF files + + Files are stored in the NiBiS directory structure for ingestion. + """ + errors = [] + pdfs_extracted = 0 + + # Determine target year + target_year = year or datetime.now().year + + # Target directory: za-download/YYYY/ + target_dir = RAG_UPLOAD_BASE / "za-download" / str(target_year) + target_dir.mkdir(parents=True, exist_ok=True) + + try: + filename = file.filename or "upload" + + if filename.lower().endswith(".zip"): + # Handle ZIP file + with tempfile.NamedTemporaryFile(delete=False, suffix=".zip") as tmp: + content = await file.read() + tmp.write(content) + tmp_path = tmp.name + + try: + with zipfile.ZipFile(tmp_path, 'r') as zf: + # Extract PDFs from ZIP + for member in zf.namelist(): + if member.lower().endswith(".pdf") and not member.startswith("__MACOSX"): + # Get just the filename, ignore directory structure in ZIP + pdf_name = Path(member).name + if pdf_name: + target_path = target_dir / pdf_name + + # Extract to target + with zf.open(member) as src: + with open(target_path, 'wb') as dst: + dst.write(src.read()) + + pdfs_extracted += 1 + finally: + os.unlink(tmp_path) + + elif filename.lower().endswith(".pdf"): + # Handle single PDF + target_path = target_dir / filename + content = await file.read() + + with open(target_path, 'wb') as f: + f.write(content) + + pdfs_extracted = 1 + else: + raise HTTPException( + status_code=400, + detail=f"Unsupported file type: {filename}. Only .zip and .pdf are allowed." + ) + + # Track upload in memory + upload_record = { + "timestamp": datetime.now().isoformat(), + "filename": filename, + "collection": collection, + "year": target_year, + "pdfs_extracted": pdfs_extracted, + "target_directory": str(target_dir), + } + _upload_history.append(upload_record) + + # Keep only last 100 uploads in memory + if len(_upload_history) > 100: + _upload_history.pop(0) + + # Store in PostgreSQL if available + if METRICS_DB_AVAILABLE: + await log_upload( + filename=filename, + collection_name=collection, + year=target_year, + pdfs_extracted=pdfs_extracted, + minio_path=str(target_dir), + ) + + # Auto-ingest if requested + if auto_ingest and not _ingestion_status["running"]: + async def run_auto_ingest(): + global _ingestion_status + _ingestion_status["running"] = True + _ingestion_status["last_run"] = datetime.now().isoformat() + + try: + result = await run_ingestion( + ewh_only=True, + dry_run=False, + year_filter=target_year, + ) + _ingestion_status["last_result"] = result + except Exception as e: + _ingestion_status["last_result"] = {"error": str(e), "errors": [str(e)]} + finally: + _ingestion_status["running"] = False + + background_tasks.add_task(run_auto_ingest) + + return UploadResult( + status="success", + files_received=1, + pdfs_extracted=pdfs_extracted, + target_directory=str(target_dir), + errors=errors, + ) + + except HTTPException: + raise + except Exception as e: + errors.append(str(e)) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/rag/upload/history") +async def get_upload_history(limit: int = Query(default=20, le=100)): + """Get recent upload history.""" + return { + "uploads": _upload_history[-limit:][::-1], # Most recent first + "total": len(_upload_history), + } + + +@router.get("/rag/metrics") +async def get_rag_metrics( + collection: Optional[str] = Query(default=None), + days: int = Query(default=7, le=90), +): + """ + Get RAG quality metrics. + Uses PostgreSQL for real metrics if available, otherwise returns defaults. + """ + if METRICS_DB_AVAILABLE: + metrics = await calculate_metrics(collection_name=collection, days=days) + if metrics.get("connected"): + return metrics + + # Fallback: Return placeholder metrics + return { + "precision_at_5": 0.78, + "recall_at_10": 0.85, + "mrr": 0.72, + "avg_latency_ms": 52, + "total_ratings": len(_upload_history), + "error_rate": 0.3, + "score_distribution": { + "0.9+": 23, + "0.7-0.9": 41, + "0.5-0.7": 28, + "<0.5": 8, + }, + "note": "Placeholder metrics - PostgreSQL not connected", + "connected": False, + } + + +@router.post("/rag/search/feedback") +async def submit_search_feedback( + result_id: str = Form(...), + rating: int = Form(..., ge=1, le=5), + notes: Optional[str] = Form(default=None), + query: Optional[str] = Form(default=None), + collection: Optional[str] = Form(default=None), + score: Optional[float] = Form(default=None), +): + """ + Submit feedback for a search result. + Used for quality tracking and metrics. + """ + feedback_record = { + "timestamp": datetime.now().isoformat(), + "result_id": result_id, + "rating": rating, + "notes": notes, + } + + stored = False + if METRICS_DB_AVAILABLE: + stored = await store_feedback( + result_id=result_id, + rating=rating, + query_text=query, + collection_name=collection, + score=score, + notes=notes, + ) + + return { + "status": "stored" if stored else "received", + "feedback": feedback_record, + "persisted": stored, + } + + +@router.get("/rag/storage/stats") +async def get_storage_statistics(): + """Get MinIO storage statistics.""" + if MINIO_AVAILABLE: + stats = await get_storage_stats() + return stats + return { + "error": "MinIO not available", + "connected": False, + } + + +@router.post("/rag/init") +async def initialize_rag_services(): + """Initialize RAG services (MinIO bucket, PostgreSQL tables).""" + results = { + "minio": False, + "postgres": False, + } + + if MINIO_AVAILABLE: + results["minio"] = await init_minio_bucket() + + if METRICS_DB_AVAILABLE: + results["postgres"] = await init_metrics_tables() + + return { + "status": "initialized", + "services": results, + } + + +# ============================================================================= +# Legal Templates API - Document Generator Support +# ============================================================================= + +# Import legal templates modules +try: + from legal_templates_ingestion import ( + LegalTemplatesIngestion, + LEGAL_TEMPLATES_COLLECTION, + ) + from template_sources import ( + TEMPLATE_SOURCES, + TEMPLATE_TYPES, + JURISDICTIONS, + LicenseType, + get_enabled_sources, + get_sources_by_priority, + ) + from qdrant_service import ( + search_legal_templates, + get_legal_templates_stats, + init_legal_templates_collection, + ) + LEGAL_TEMPLATES_AVAILABLE = True +except ImportError as e: + print(f"Legal templates module not available: {e}") + LEGAL_TEMPLATES_AVAILABLE = False + +# Store for templates ingestion status +_templates_ingestion_status: Dict = { + "running": False, + "last_run": None, + "current_source": None, + "results": {}, +} + + +class TemplatesSearchRequest(BaseModel): + query: str + template_type: Optional[str] = None + license_types: Optional[List[str]] = None + language: Optional[str] = None + jurisdiction: Optional[str] = None + attribution_required: Optional[bool] = None + limit: int = 10 + + +class TemplatesSearchResult(BaseModel): + id: str + score: float + text: str + document_title: Optional[str] + template_type: Optional[str] + clause_category: Optional[str] + language: Optional[str] + jurisdiction: Optional[str] + license_id: Optional[str] + license_name: Optional[str] + attribution_required: Optional[bool] + attribution_text: Optional[str] + source_name: Optional[str] + source_url: Optional[str] + placeholders: Optional[List[str]] + is_complete_document: Optional[bool] + requires_customization: Optional[bool] + + +class SourceIngestRequest(BaseModel): + source_name: str + + +@router.get("/templates/status") +async def get_templates_status(): + """Get status of legal templates collection and ingestion.""" + if not LEGAL_TEMPLATES_AVAILABLE: + return { + "available": False, + "error": "Legal templates module not available", + } + + try: + stats = await get_legal_templates_stats() + + return { + "available": True, + "collection": LEGAL_TEMPLATES_COLLECTION, + "ingestion": { + "running": _templates_ingestion_status["running"], + "last_run": _templates_ingestion_status.get("last_run"), + "current_source": _templates_ingestion_status.get("current_source"), + "results": _templates_ingestion_status.get("results", {}), + }, + "stats": stats, + } + except Exception as e: + return { + "available": True, + "error": str(e), + "ingestion": _templates_ingestion_status, + } + + +@router.get("/templates/sources") +async def get_templates_sources(): + """Get list of all template sources with their configuration.""" + if not LEGAL_TEMPLATES_AVAILABLE: + raise HTTPException(status_code=503, detail="Legal templates module not available") + + sources = [] + for source in TEMPLATE_SOURCES: + sources.append({ + "name": source.name, + "description": source.description, + "license_type": source.license_type.value, + "license_name": source.license_info.name, + "template_types": source.template_types, + "languages": source.languages, + "jurisdiction": source.jurisdiction, + "repo_url": source.repo_url, + "web_url": source.web_url, + "priority": source.priority, + "enabled": source.enabled, + "attribution_required": source.license_info.attribution_required, + }) + + return { + "sources": sources, + "total": len(sources), + "enabled": len([s for s in TEMPLATE_SOURCES if s.enabled]), + "template_types": TEMPLATE_TYPES, + "jurisdictions": JURISDICTIONS, + } + + +@router.get("/templates/licenses") +async def get_templates_licenses(): + """Get license statistics for indexed templates.""" + if not LEGAL_TEMPLATES_AVAILABLE: + raise HTTPException(status_code=503, detail="Legal templates module not available") + + try: + stats = await get_legal_templates_stats() + return { + "licenses": stats.get("licenses", {}), + "total_chunks": stats.get("points_count", 0), + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/templates/ingest") +async def start_templates_ingestion( + background_tasks: BackgroundTasks, + max_priority: int = Query(default=3, ge=1, le=5, description="Maximum priority level (1=highest)"), +): + """ + Start legal templates ingestion in background. + Ingests all enabled sources up to the specified priority level. + + Priority levels: + - 1: CC0 sources (github-site-policy, opr-vc, etc.) + - 2: MIT sources (webflorist, tempest, etc.) + - 3: CC BY 4.0 sources (common-paper, etc.) + - 4: Public domain/Unlicense (bundestag-gesetze) + - 5: Reuse notice sources + """ + if not LEGAL_TEMPLATES_AVAILABLE: + raise HTTPException(status_code=503, detail="Legal templates module not available") + + if _templates_ingestion_status["running"]: + raise HTTPException( + status_code=409, + detail="Templates ingestion already running. Check /templates/status for progress." + ) + + async def run_templates_ingestion(): + global _templates_ingestion_status + _templates_ingestion_status["running"] = True + _templates_ingestion_status["last_run"] = datetime.now().isoformat() + _templates_ingestion_status["results"] = {} + + try: + ingestion = LegalTemplatesIngestion() + sources = get_sources_by_priority(max_priority) + + for source in sources: + _templates_ingestion_status["current_source"] = source.name + + try: + status = await ingestion.ingest_source(source) + _templates_ingestion_status["results"][source.name] = { + "status": status.status, + "documents_found": status.documents_found, + "chunks_indexed": status.chunks_indexed, + "errors": status.errors[:5] if status.errors else [], + } + except Exception as e: + _templates_ingestion_status["results"][source.name] = { + "status": "failed", + "error": str(e), + } + + await ingestion.close() + + except Exception as e: + _templates_ingestion_status["results"]["_global_error"] = str(e) + finally: + _templates_ingestion_status["running"] = False + _templates_ingestion_status["current_source"] = None + + background_tasks.add_task(run_templates_ingestion) + + sources = get_sources_by_priority(max_priority) + return { + "status": "started", + "message": f"Ingesting {len(sources)} sources up to priority {max_priority}", + "sources": [s.name for s in sources], + } + + +@router.post("/templates/ingest-source") +async def ingest_single_source( + request: SourceIngestRequest, + background_tasks: BackgroundTasks, +): + """Ingest a single template source by name.""" + if not LEGAL_TEMPLATES_AVAILABLE: + raise HTTPException(status_code=503, detail="Legal templates module not available") + + source = next((s for s in TEMPLATE_SOURCES if s.name == request.source_name), None) + if not source: + raise HTTPException( + status_code=404, + detail=f"Source not found: {request.source_name}. Use /templates/sources to list available sources." + ) + + if not source.enabled: + raise HTTPException( + status_code=400, + detail=f"Source is disabled: {request.source_name}" + ) + + if _templates_ingestion_status["running"]: + raise HTTPException( + status_code=409, + detail="Templates ingestion already running." + ) + + async def run_single_ingestion(): + global _templates_ingestion_status + _templates_ingestion_status["running"] = True + _templates_ingestion_status["current_source"] = source.name + _templates_ingestion_status["last_run"] = datetime.now().isoformat() + + try: + ingestion = LegalTemplatesIngestion() + status = await ingestion.ingest_source(source) + _templates_ingestion_status["results"][source.name] = { + "status": status.status, + "documents_found": status.documents_found, + "chunks_indexed": status.chunks_indexed, + "errors": status.errors[:5] if status.errors else [], + } + await ingestion.close() + + except Exception as e: + _templates_ingestion_status["results"][source.name] = { + "status": "failed", + "error": str(e), + } + finally: + _templates_ingestion_status["running"] = False + _templates_ingestion_status["current_source"] = None + + background_tasks.add_task(run_single_ingestion) + + return { + "status": "started", + "source": source.name, + "license": source.license_type.value, + "template_types": source.template_types, + } + + +@router.post("/templates/search", response_model=List[TemplatesSearchResult]) +async def search_templates(request: TemplatesSearchRequest): + """ + Semantic search in legal templates collection. + Returns relevant template chunks with license and attribution info. + """ + if not LEGAL_TEMPLATES_AVAILABLE: + raise HTTPException(status_code=503, detail="Legal templates module not available") + + try: + # Generate query embedding + query_embedding = await generate_single_embedding(request.query) + + if not query_embedding: + raise HTTPException(status_code=500, detail="Failed to generate embedding") + + # Search + results = await search_legal_templates( + query_embedding=query_embedding, + template_type=request.template_type, + license_types=request.license_types, + language=request.language, + jurisdiction=request.jurisdiction, + attribution_required=request.attribution_required, + limit=request.limit, + ) + + return [ + TemplatesSearchResult( + id=r["id"], + score=r["score"], + text=r.get("text", "")[:1000], # Truncate for response + document_title=r.get("document_title"), + template_type=r.get("template_type"), + clause_category=r.get("clause_category"), + language=r.get("language"), + jurisdiction=r.get("jurisdiction"), + license_id=r.get("license_id"), + license_name=r.get("license_name"), + attribution_required=r.get("attribution_required"), + attribution_text=r.get("attribution_text"), + source_name=r.get("source_name"), + source_url=r.get("source_url"), + placeholders=r.get("placeholders"), + is_complete_document=r.get("is_complete_document"), + requires_customization=r.get("requires_customization"), + ) + for r in results + ] + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/templates/reset") +async def reset_templates_collection(): + """ + Delete and recreate the legal templates collection. + WARNING: This will remove all indexed templates! + """ + if not LEGAL_TEMPLATES_AVAILABLE: + raise HTTPException(status_code=503, detail="Legal templates module not available") + + if _templates_ingestion_status["running"]: + raise HTTPException( + status_code=409, + detail="Cannot reset while ingestion is running" + ) + + try: + ingestion = LegalTemplatesIngestion() + ingestion.reset_collection() + await ingestion.close() + + # Clear ingestion status + _templates_ingestion_status["results"] = {} + + return { + "status": "reset", + "collection": LEGAL_TEMPLATES_COLLECTION, + "message": "Collection deleted and recreated. Run ingestion to populate.", + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/templates/source/{source_name}") +async def delete_templates_source(source_name: str): + """Delete all templates from a specific source.""" + if not LEGAL_TEMPLATES_AVAILABLE: + raise HTTPException(status_code=503, detail="Legal templates module not available") + + try: + from qdrant_service import delete_legal_templates_by_source + + count = await delete_legal_templates_by_source(source_name) + + # Update status + if source_name in _templates_ingestion_status.get("results", {}): + del _templates_ingestion_status["results"][source_name] + + return { + "status": "deleted", + "source": source_name, + "chunks_deleted": count, + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/klausur-service/backend/config.py b/klausur-service/backend/config.py new file mode 100644 index 0000000..c085bd9 --- /dev/null +++ b/klausur-service/backend/config.py @@ -0,0 +1,51 @@ +""" +Klausur-Service Configuration + +Centralized configuration for all environment variables and constants. +""" + +import os + +# ============================================= +# JWT & Authentication +# ============================================= +JWT_SECRET = os.getenv("JWT_SECRET", "your-super-secret-jwt-key-change-in-production") + +# ============================================= +# Service URLs +# ============================================= +BACKEND_URL = os.getenv("BACKEND_URL", "http://backend:8000") +SCHOOL_SERVICE_URL = os.getenv("SCHOOL_SERVICE_URL", "http://school-service:8084") +ENVIRONMENT = os.getenv("ENVIRONMENT", "development") + +# ============================================= +# BYOEH Configuration +# ============================================= +QDRANT_URL = os.getenv("QDRANT_URL", "http://localhost:6333") +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "") +BYOEH_ENCRYPTION_ENABLED = os.getenv("BYOEH_ENCRYPTION_ENABLED", "true").lower() == "true" + +# ============================================= +# File Storage Paths +# ============================================= +_BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +EH_UPLOAD_DIR = os.getenv("EH_UPLOAD_DIR", os.path.join(_BASE_DIR, "eh-uploads")) +UPLOAD_DIR = os.getenv("UPLOAD_DIR", os.path.join(_BASE_DIR, "uploads")) +FRONTEND_PATH = os.getenv("FRONTEND_PATH", os.path.join(_BASE_DIR, "frontend", "dist")) + +# ============================================= +# Rights Confirmation Text (German) +# ============================================= +RIGHTS_CONFIRMATION_TEXT = """Ich bestaetige hiermit, dass: + +1. Ich das Urheberrecht oder die notwendigen Nutzungsrechte an diesem + Erwartungshorizont besitze. + +2. Breakpilot diesen Erwartungshorizont NICHT fuer KI-Training verwendet, + sondern ausschliesslich fuer RAG-gestuetzte Korrekturvorschlaege + in meinem persoenlichen Arbeitsbereich. + +3. Der Inhalt verschluesselt gespeichert wird und Breakpilot-Mitarbeiter + keinen Zugriff auf den Klartext haben. + +4. Ich diesen Erwartungshorizont jederzeit loeschen kann.""" diff --git a/klausur-service/backend/eh_pipeline.py b/klausur-service/backend/eh_pipeline.py new file mode 100644 index 0000000..d728b49 --- /dev/null +++ b/klausur-service/backend/eh_pipeline.py @@ -0,0 +1,420 @@ +""" +BYOEH Processing Pipeline +Handles chunking, embedding generation, and encryption for Erwartungshorizonte. + +Supports multiple embedding backends: +- local: sentence-transformers (default, no API key needed) +- openai: OpenAI text-embedding-3-small (requires OPENAI_API_KEY) +""" + +import os +import io +import base64 +import hashlib +from typing import List, Tuple, Optional +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.hazmat.primitives import hashes +import httpx + +# Embedding Configuration +# Backend: "local" (sentence-transformers) or "openai" +EMBEDDING_BACKEND = os.getenv("EMBEDDING_BACKEND", "local") +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "") +EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "text-embedding-3-small") + +# Local embedding model (all-MiniLM-L6-v2: 384 dimensions, fast, good quality) +LOCAL_EMBEDDING_MODEL = os.getenv("LOCAL_EMBEDDING_MODEL", "all-MiniLM-L6-v2") + +# Vector dimensions per backend +VECTOR_DIMENSIONS = { + "local": 384, # all-MiniLM-L6-v2 + "openai": 1536, # text-embedding-3-small +} + +CHUNK_SIZE = int(os.getenv("BYOEH_CHUNK_SIZE", "1000")) +CHUNK_OVERLAP = int(os.getenv("BYOEH_CHUNK_OVERLAP", "200")) + +# Lazy-loaded sentence-transformers model +_local_model = None + + +class ChunkingError(Exception): + """Error during text chunking.""" + pass + + +class EmbeddingError(Exception): + """Error during embedding generation.""" + pass + + +class EncryptionError(Exception): + """Error during encryption/decryption.""" + pass + + +def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> List[str]: + """ + Split text into overlapping chunks. + + Uses a simple recursive character splitter approach: + - Try to split on paragraph boundaries first + - Then sentences + - Then words + - Finally characters + + Args: + text: Input text to chunk + chunk_size: Target chunk size in characters + overlap: Overlap between chunks + + Returns: + List of text chunks + """ + if not text or len(text) <= chunk_size: + return [text] if text else [] + + chunks = [] + separators = ["\n\n", "\n", ". ", " ", ""] + + def split_recursive(text: str, sep_idx: int = 0) -> List[str]: + if len(text) <= chunk_size: + return [text] + + if sep_idx >= len(separators): + # Last resort: hard split + return [text[i:i+chunk_size] for i in range(0, len(text), chunk_size - overlap)] + + sep = separators[sep_idx] + if not sep: + # Empty separator = character split + parts = list(text) + else: + parts = text.split(sep) + + result = [] + current = "" + + for part in parts: + test_chunk = current + sep + part if current else part + + if len(test_chunk) <= chunk_size: + current = test_chunk + else: + if current: + result.append(current) + # If single part is too big, recursively split it + if len(part) > chunk_size: + result.extend(split_recursive(part, sep_idx + 1)) + current = "" + else: + current = part + + if current: + result.append(current) + + return result + + raw_chunks = split_recursive(text) + + # Add overlap + final_chunks = [] + for i, chunk in enumerate(raw_chunks): + if i > 0 and overlap > 0: + # Add overlap from previous chunk + prev_chunk = raw_chunks[i-1] + overlap_text = prev_chunk[-min(overlap, len(prev_chunk)):] + chunk = overlap_text + chunk + final_chunks.append(chunk.strip()) + + return [c for c in final_chunks if c] + + +def get_vector_size() -> int: + """Get the vector dimension for the current embedding backend.""" + return VECTOR_DIMENSIONS.get(EMBEDDING_BACKEND, 384) + + +def _get_local_model(): + """Lazy-load the sentence-transformers model.""" + global _local_model + if _local_model is None: + try: + from sentence_transformers import SentenceTransformer + print(f"Loading local embedding model: {LOCAL_EMBEDDING_MODEL}") + _local_model = SentenceTransformer(LOCAL_EMBEDDING_MODEL) + print(f"Model loaded successfully (dim={_local_model.get_sentence_embedding_dimension()})") + except ImportError: + raise EmbeddingError( + "sentence-transformers not installed. " + "Install with: pip install sentence-transformers" + ) + return _local_model + + +def _generate_local_embeddings(texts: List[str]) -> List[List[float]]: + """Generate embeddings using local sentence-transformers model.""" + if not texts: + return [] + + model = _get_local_model() + embeddings = model.encode(texts, show_progress_bar=len(texts) > 10) + return [emb.tolist() for emb in embeddings] + + +async def _generate_openai_embeddings(texts: List[str]) -> List[List[float]]: + """Generate embeddings using OpenAI API.""" + if not OPENAI_API_KEY: + raise EmbeddingError("OPENAI_API_KEY not configured") + + try: + async with httpx.AsyncClient() as client: + response = await client.post( + "https://api.openai.com/v1/embeddings", + headers={ + "Authorization": f"Bearer {OPENAI_API_KEY}", + "Content-Type": "application/json" + }, + json={ + "model": EMBEDDING_MODEL, + "input": texts + }, + timeout=60.0 + ) + + if response.status_code != 200: + raise EmbeddingError(f"OpenAI API error: {response.status_code} - {response.text}") + + data = response.json() + embeddings = [item["embedding"] for item in data["data"]] + return embeddings + + except httpx.TimeoutException: + raise EmbeddingError("OpenAI API timeout") + except Exception as e: + raise EmbeddingError(f"Failed to generate embeddings: {str(e)}") + + +async def generate_embeddings(texts: List[str]) -> List[List[float]]: + """ + Generate embeddings using configured backend. + + Backends: + - local: sentence-transformers (default, no API key needed) + - openai: OpenAI text-embedding-3-small + + Args: + texts: List of text chunks + + Returns: + List of embedding vectors + + Raises: + EmbeddingError: If embedding generation fails + """ + if not texts: + return [] + + if EMBEDDING_BACKEND == "local": + # Local model runs synchronously but is fast + return _generate_local_embeddings(texts) + elif EMBEDDING_BACKEND == "openai": + return await _generate_openai_embeddings(texts) + else: + raise EmbeddingError(f"Unknown embedding backend: {EMBEDDING_BACKEND}") + + +async def generate_single_embedding(text: str) -> List[float]: + """Generate embedding for a single text.""" + embeddings = await generate_embeddings([text]) + return embeddings[0] if embeddings else [] + + +def derive_key(passphrase: str, salt: bytes) -> bytes: + """ + Derive encryption key from passphrase using PBKDF2. + + Args: + passphrase: User passphrase + salt: Random salt (16 bytes) + + Returns: + 32-byte AES key + """ + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=100000, + ) + return kdf.derive(passphrase.encode()) + + +def encrypt_text(text: str, passphrase: str, salt_hex: str) -> str: + """ + Encrypt text using AES-256-GCM. + + Args: + text: Plaintext to encrypt + passphrase: User passphrase + salt_hex: Salt as hex string + + Returns: + Base64-encoded ciphertext (IV + ciphertext) + """ + try: + salt = bytes.fromhex(salt_hex) + key = derive_key(passphrase, salt) + + aesgcm = AESGCM(key) + iv = os.urandom(12) + + ciphertext = aesgcm.encrypt(iv, text.encode(), None) + + # Combine IV + ciphertext + combined = iv + ciphertext + return base64.b64encode(combined).decode() + + except Exception as e: + raise EncryptionError(f"Encryption failed: {str(e)}") + + +def decrypt_text(encrypted_b64: str, passphrase: str, salt_hex: str) -> str: + """ + Decrypt text using AES-256-GCM. + + Args: + encrypted_b64: Base64-encoded ciphertext (IV + ciphertext) + passphrase: User passphrase + salt_hex: Salt as hex string + + Returns: + Decrypted plaintext + """ + try: + salt = bytes.fromhex(salt_hex) + key = derive_key(passphrase, salt) + + combined = base64.b64decode(encrypted_b64) + iv = combined[:12] + ciphertext = combined[12:] + + aesgcm = AESGCM(key) + plaintext = aesgcm.decrypt(iv, ciphertext, None) + + return plaintext.decode() + + except Exception as e: + raise EncryptionError(f"Decryption failed: {str(e)}") + + +def hash_key(passphrase: str, salt_hex: str) -> str: + """ + Create SHA-256 hash of derived key for verification. + + Args: + passphrase: User passphrase + salt_hex: Salt as hex string + + Returns: + Hex-encoded key hash + """ + salt = bytes.fromhex(salt_hex) + key = derive_key(passphrase, salt) + return hashlib.sha256(key).hexdigest() + + +def verify_key_hash(passphrase: str, salt_hex: str, expected_hash: str) -> bool: + """ + Verify passphrase matches stored key hash. + + Args: + passphrase: User passphrase to verify + salt_hex: Salt as hex string + expected_hash: Expected key hash + + Returns: + True if passphrase is correct + """ + computed_hash = hash_key(passphrase, salt_hex) + return computed_hash == expected_hash + + +def extract_text_from_pdf(pdf_content: bytes) -> str: + """ + Extract text from PDF file. + + Args: + pdf_content: Raw PDF bytes + + Returns: + Extracted text + """ + try: + import PyPDF2 + + pdf_file = io.BytesIO(pdf_content) + reader = PyPDF2.PdfReader(pdf_file) + + text_parts = [] + for page in reader.pages: + text = page.extract_text() + if text: + text_parts.append(text) + + return "\n\n".join(text_parts) + + except ImportError: + raise ChunkingError("PyPDF2 not installed") + except Exception as e: + raise ChunkingError(f"Failed to extract PDF text: {str(e)}") + + +async def process_eh_for_indexing( + eh_id: str, + tenant_id: str, + subject: str, + text_content: str, + passphrase: str, + salt_hex: str +) -> Tuple[int, List[dict]]: + """ + Full processing pipeline for Erwartungshorizont indexing. + + 1. Chunk the text + 2. Generate embeddings + 3. Encrypt chunks + 4. Return prepared data for Qdrant + + Args: + eh_id: Erwartungshorizont ID + tenant_id: Tenant ID + subject: Subject (deutsch, englisch, etc.) + text_content: Decrypted text content + passphrase: User passphrase for re-encryption + salt_hex: Salt for encryption + + Returns: + Tuple of (chunk_count, chunks_data) + """ + # 1. Chunk the text + chunks = chunk_text(text_content) + + if not chunks: + return 0, [] + + # 2. Generate embeddings + embeddings = await generate_embeddings(chunks) + + # 3. Encrypt chunks for storage + encrypted_chunks = [] + for i, (chunk, embedding) in enumerate(zip(chunks, embeddings)): + encrypted_content = encrypt_text(chunk, passphrase, salt_hex) + encrypted_chunks.append({ + "chunk_index": i, + "embedding": embedding, + "encrypted_content": encrypted_content + }) + + return len(chunks), encrypted_chunks diff --git a/klausur-service/backend/eh_templates.py b/klausur-service/backend/eh_templates.py new file mode 100644 index 0000000..c42bf21 --- /dev/null +++ b/klausur-service/backend/eh_templates.py @@ -0,0 +1,658 @@ +""" +Erwartungshorizont Templates for Vorabitur Mode + +Provides pre-defined templates based on German Abitur text analysis types: +- Textanalyse (pragmatische Texte) +- Sachtextanalyse +- Gedichtanalyse / Lyrikinterpretation +- Dramenanalyse +- Epische Textanalyse / Prosaanalyse +- Eroerterung (textgebunden / frei) +- Literarische Eroerterung +- Materialgestuetztes Schreiben + +Each template includes: +- Structured criteria with weights +- Typical expectations per section +- NiBiS-aligned evaluation points +""" + +from typing import Dict, List, Optional +from dataclasses import dataclass, field, asdict +from datetime import datetime +import uuid + + +# ============================================= +# TEMPLATE TYPES +# ============================================= + +AUFGABENTYPEN = { + "textanalyse_pragmatisch": { + "name": "Textanalyse (pragmatische Texte)", + "description": "Analyse von Sachtexten, Reden, Kommentaren, Essays", + "category": "analyse" + }, + "sachtextanalyse": { + "name": "Sachtextanalyse", + "description": "Analyse von informativen und appellativen Sachtexten", + "category": "analyse" + }, + "gedichtanalyse": { + "name": "Gedichtanalyse / Lyrikinterpretation", + "description": "Analyse und Interpretation lyrischer Texte", + "category": "interpretation" + }, + "dramenanalyse": { + "name": "Dramenanalyse", + "description": "Analyse dramatischer Texte und Szenen", + "category": "interpretation" + }, + "prosaanalyse": { + "name": "Epische Textanalyse / Prosaanalyse", + "description": "Analyse von Romanauszuegen, Kurzgeschichten, Novellen", + "category": "interpretation" + }, + "eroerterung_textgebunden": { + "name": "Textgebundene Eroerterung", + "description": "Eroerterung auf Basis eines Sachtextes", + "category": "argumentation" + }, + "eroerterung_frei": { + "name": "Freie Eroerterung", + "description": "Freie Eroerterung zu einem Thema", + "category": "argumentation" + }, + "eroerterung_literarisch": { + "name": "Literarische Eroerterung", + "description": "Eroerterung zu literarischen Fragestellungen", + "category": "argumentation" + }, + "materialgestuetzt": { + "name": "Materialgestuetztes Schreiben", + "description": "Verfassen eines Textes auf Materialbasis", + "category": "produktion" + } +} + + +# ============================================= +# TEMPLATE STRUCTURES +# ============================================= + +@dataclass +class EHKriterium: + """Single criterion in an Erwartungshorizont.""" + id: str + name: str + beschreibung: str + gewichtung: int # Percentage weight (0-100) + erwartungen: List[str] # Expected points/elements + max_punkte: int = 100 + + def to_dict(self): + return asdict(self) + + +@dataclass +class EHTemplate: + """Complete Erwartungshorizont template.""" + id: str + aufgabentyp: str + name: str + beschreibung: str + kriterien: List[EHKriterium] + einleitung_hinweise: List[str] + hauptteil_hinweise: List[str] + schluss_hinweise: List[str] + sprachliche_aspekte: List[str] + created_at: datetime = field(default_factory=lambda: datetime.now()) + + def to_dict(self): + d = { + 'id': self.id, + 'aufgabentyp': self.aufgabentyp, + 'name': self.name, + 'beschreibung': self.beschreibung, + 'kriterien': [k.to_dict() for k in self.kriterien], + 'einleitung_hinweise': self.einleitung_hinweise, + 'hauptteil_hinweise': self.hauptteil_hinweise, + 'schluss_hinweise': self.schluss_hinweise, + 'sprachliche_aspekte': self.sprachliche_aspekte, + 'created_at': self.created_at.isoformat() + } + return d + + +# ============================================= +# PRE-DEFINED TEMPLATES +# ============================================= + +def get_textanalyse_template() -> EHTemplate: + """Template for pragmatic text analysis.""" + return EHTemplate( + id="template_textanalyse_pragmatisch", + aufgabentyp="textanalyse_pragmatisch", + name="Textanalyse pragmatischer Texte", + beschreibung="Vorlage fuer die Analyse von Sachtexten, Reden, Kommentaren und Essays", + kriterien=[ + EHKriterium( + id="inhalt", + name="Inhaltliche Leistung", + beschreibung="Erfassung und Wiedergabe des Textinhalts", + gewichtung=40, + erwartungen=[ + "Korrekte Erfassung der Textaussage/These", + "Vollstaendige Wiedergabe der Argumentationsstruktur", + "Erkennen von Intention und Adressatenbezug", + "Einordnung in den historischen/gesellschaftlichen Kontext", + "Beruecksichtigung aller relevanten Textaspekte" + ] + ), + EHKriterium( + id="struktur", + name="Aufbau und Struktur", + beschreibung="Logischer Aufbau und Gliederung der Analyse", + gewichtung=15, + erwartungen=[ + "Sinnvolle Einleitung mit Basisinformationen", + "Logische Gliederung des Hauptteils", + "Stringente Gedankenfuehrung", + "Angemessener Schluss mit Fazit/Wertung", + "Absatzgliederung und Ueberlaenge" + ] + ), + EHKriterium( + id="analyse", + name="Analytische Qualitaet", + beschreibung="Tiefe und Qualitaet der Analyse", + gewichtung=15, + erwartungen=[ + "Erkennen rhetorischer Mittel", + "Funktionale Deutung der Stilmittel", + "Analyse der Argumentationsweise", + "Beruecksichtigung von Wortwahl und Satzbau", + "Verknuepfung von Form und Inhalt" + ] + ), + EHKriterium( + id="rechtschreibung", + name="Sprachliche Richtigkeit (Rechtschreibung)", + beschreibung="Orthografische Korrektheit", + gewichtung=15, + erwartungen=[ + "Korrekte Rechtschreibung", + "Korrekte Gross- und Kleinschreibung", + "Korrekte Getrennt- und Zusammenschreibung", + "Korrekte Fremdwortschreibung" + ] + ), + EHKriterium( + id="grammatik", + name="Sprachliche Richtigkeit (Grammatik)", + beschreibung="Grammatische Korrektheit und Zeichensetzung", + gewichtung=15, + erwartungen=[ + "Korrekter Satzbau", + "Korrekte Flexion", + "Korrekte Zeichensetzung", + "Korrekte Bezuege und Kongruenz" + ] + ) + ], + einleitung_hinweise=[ + "Nennung von Autor, Titel, Textsorte, Erscheinungsjahr", + "Benennung des Themas", + "Formulierung der Kernthese/Hauptaussage", + "Ggf. Einordnung in den Kontext" + ], + hauptteil_hinweise=[ + "Systematische Analyse der Argumentationsstruktur", + "Untersuchung der sprachlichen Gestaltung", + "Funktionale Deutung der Stilmittel", + "Beruecksichtigung von Adressatenbezug und Intention", + "Textbelege durch Zitate" + ], + schluss_hinweise=[ + "Zusammenfassung der Analyseergebnisse", + "Bewertung der Ueberzeugungskraft", + "Ggf. aktuelle Relevanz", + "Persoenliche Stellungnahme (wenn gefordert)" + ], + sprachliche_aspekte=[ + "Fachsprachliche Begriffe korrekt verwenden", + "Konjunktiv fuer indirekte Rede", + "Praesens als Tempus der Analyse", + "Sachlicher, analytischer Stil" + ] + ) + + +def get_gedichtanalyse_template() -> EHTemplate: + """Template for poetry analysis.""" + return EHTemplate( + id="template_gedichtanalyse", + aufgabentyp="gedichtanalyse", + name="Gedichtanalyse / Lyrikinterpretation", + beschreibung="Vorlage fuer die Analyse und Interpretation lyrischer Texte", + kriterien=[ + EHKriterium( + id="inhalt", + name="Inhaltliche Leistung", + beschreibung="Erfassung und Deutung des Gedichtinhalts", + gewichtung=40, + erwartungen=[ + "Korrekte Erfassung des lyrischen Ichs und der Sprechsituation", + "Vollstaendige inhaltliche Erschliessung aller Strophen", + "Erkennen der zentralen Motive und Themen", + "Epochenzuordnung und literaturgeschichtliche Einordnung", + "Deutung der Bildlichkeit und Symbolik" + ] + ), + EHKriterium( + id="struktur", + name="Aufbau und Struktur", + beschreibung="Logischer Aufbau der Interpretation", + gewichtung=15, + erwartungen=[ + "Einleitung mit Basisinformationen", + "Systematische strophenweise oder aspektorientierte Analyse", + "Verknuepfung von Form- und Inhaltsanalyse", + "Schluessige Gesamtdeutung im Schluss" + ] + ), + EHKriterium( + id="formanalyse", + name="Formale Analyse", + beschreibung="Analyse der lyrischen Gestaltungsmittel", + gewichtung=15, + erwartungen=[ + "Bestimmung von Metrum und Reimschema", + "Analyse der Klanggestaltung", + "Erkennen von Enjambements und Zaesuren", + "Deutung der formalen Mittel", + "Verknuepfung von Form und Inhalt" + ] + ), + EHKriterium( + id="rechtschreibung", + name="Sprachliche Richtigkeit (Rechtschreibung)", + beschreibung="Orthografische Korrektheit", + gewichtung=15, + erwartungen=[ + "Korrekte Rechtschreibung", + "Korrekte Gross- und Kleinschreibung", + "Korrekte Getrennt- und Zusammenschreibung" + ] + ), + EHKriterium( + id="grammatik", + name="Sprachliche Richtigkeit (Grammatik)", + beschreibung="Grammatische Korrektheit und Zeichensetzung", + gewichtung=15, + erwartungen=[ + "Korrekter Satzbau", + "Korrekte Flexion", + "Korrekte Zeichensetzung" + ] + ) + ], + einleitung_hinweise=[ + "Autor, Titel, Entstehungsjahr/Epoche", + "Thema/Motiv des Gedichts", + "Erste Deutungshypothese", + "Formale Grunddaten (Strophen, Verse)" + ], + hauptteil_hinweise=[ + "Inhaltliche Analyse (strophenweise oder aspektorientiert)", + "Formale Analyse (Metrum, Reim, Klang)", + "Sprachliche Analyse (Stilmittel, Bildlichkeit)", + "Funktionale Verknuepfung aller Ebenen", + "Textbelege durch Zitate mit Versangabe" + ], + schluss_hinweise=[ + "Zusammenfassung der Interpretationsergebnisse", + "Bestaetigung/Modifikation der Deutungshypothese", + "Einordnung in Epoche/Werk des Autors", + "Aktualitaetsbezug (wenn sinnvoll)" + ], + sprachliche_aspekte=[ + "Fachbegriffe der Lyrikanalyse verwenden", + "Zwischen lyrischem Ich und Autor unterscheiden", + "Praesens als Analysetempus", + "Deutende statt beschreibende Formulierungen" + ] + ) + + +def get_eroerterung_template() -> EHTemplate: + """Template for textgebundene Eroerterung.""" + return EHTemplate( + id="template_eroerterung_textgebunden", + aufgabentyp="eroerterung_textgebunden", + name="Textgebundene Eroerterung", + beschreibung="Vorlage fuer die textgebundene Eroerterung auf Basis eines Sachtextes", + kriterien=[ + EHKriterium( + id="inhalt", + name="Inhaltliche Leistung", + beschreibung="Qualitaet der Argumentation", + gewichtung=40, + erwartungen=[ + "Korrekte Wiedergabe der Textposition", + "Differenzierte eigene Argumentation", + "Vielfaeltige und ueberzeugende Argumente", + "Beruecksichtigung von Pro und Contra", + "Sinnvolle Beispiele und Belege", + "Eigenstaendige Schlussfolgerung" + ] + ), + EHKriterium( + id="struktur", + name="Aufbau und Struktur", + beschreibung="Logischer Aufbau der Eroerterung", + gewichtung=15, + erwartungen=[ + "Problemorientierte Einleitung", + "Klare Gliederung der Argumentation", + "Logische Argumentationsfolge", + "Sinnvolle Ueberlaetze", + "Begruendetes Fazit" + ] + ), + EHKriterium( + id="textbezug", + name="Textbezug", + beschreibung="Verknuepfung mit dem Ausgangstext", + gewichtung=15, + erwartungen=[ + "Angemessene Textwiedergabe", + "Kritische Auseinandersetzung mit Textposition", + "Korrekte Zitierweise", + "Verknuepfung eigener Argumente mit Text" + ] + ), + EHKriterium( + id="rechtschreibung", + name="Sprachliche Richtigkeit (Rechtschreibung)", + beschreibung="Orthografische Korrektheit", + gewichtung=15, + erwartungen=[ + "Korrekte Rechtschreibung", + "Korrekte Gross- und Kleinschreibung" + ] + ), + EHKriterium( + id="grammatik", + name="Sprachliche Richtigkeit (Grammatik)", + beschreibung="Grammatische Korrektheit und Zeichensetzung", + gewichtung=15, + erwartungen=[ + "Korrekter Satzbau", + "Korrekte Zeichensetzung", + "Variationsreicher Ausdruck" + ] + ) + ], + einleitung_hinweise=[ + "Hinfuehrung zum Thema", + "Nennung des Ausgangstextes", + "Formulierung der Leitfrage/These", + "Ueberleitung zum Hauptteil" + ], + hauptteil_hinweise=[ + "Kurze Wiedergabe der Textposition", + "Systematische Argumentation (dialektisch oder linear)", + "Jedes Argument: These - Begruendung - Beispiel", + "Gewichtung der Argumente", + "Verknuepfung mit Textposition" + ], + schluss_hinweise=[ + "Zusammenfassung der wichtigsten Argumente", + "Eigene begruendete Stellungnahme", + "Ggf. Ausblick oder Appell" + ], + sprachliche_aspekte=[ + "Argumentative Konnektoren verwenden", + "Sachlicher, ueberzeugender Stil", + "Eigene Meinung kennzeichnen", + "Konjunktiv fuer Textpositionen" + ] + ) + + +def get_prosaanalyse_template() -> EHTemplate: + """Template for prose/narrative text analysis.""" + return EHTemplate( + id="template_prosaanalyse", + aufgabentyp="prosaanalyse", + name="Epische Textanalyse / Prosaanalyse", + beschreibung="Vorlage fuer die Analyse von Romanauszuegen, Kurzgeschichten und Novellen", + kriterien=[ + EHKriterium( + id="inhalt", + name="Inhaltliche Leistung", + beschreibung="Erfassung und Deutung des Textinhalts", + gewichtung=40, + erwartungen=[ + "Korrekte Erfassung der Handlung", + "Charakterisierung der Figuren", + "Erkennen der Erzaehlsituation", + "Deutung der Konflikte und Motive", + "Einordnung in den Gesamtzusammenhang" + ] + ), + EHKriterium( + id="struktur", + name="Aufbau und Struktur", + beschreibung="Logischer Aufbau der Analyse", + gewichtung=15, + erwartungen=[ + "Informative Einleitung", + "Systematische Analyse im Hauptteil", + "Verknuepfung der Analyseergebnisse", + "Schluessige Gesamtdeutung" + ] + ), + EHKriterium( + id="erzaehltechnik", + name="Erzaehltechnische Analyse", + beschreibung="Analyse narrativer Gestaltungsmittel", + gewichtung=15, + erwartungen=[ + "Bestimmung der Erzaehlperspektive", + "Analyse von Zeitgestaltung", + "Raumgestaltung und Atmosphaere", + "Figurenrede und Bewusstseinsdarstellung", + "Funktionale Deutung" + ] + ), + EHKriterium( + id="rechtschreibung", + name="Sprachliche Richtigkeit (Rechtschreibung)", + beschreibung="Orthografische Korrektheit", + gewichtung=15, + erwartungen=[ + "Korrekte Rechtschreibung", + "Korrekte Gross- und Kleinschreibung" + ] + ), + EHKriterium( + id="grammatik", + name="Sprachliche Richtigkeit (Grammatik)", + beschreibung="Grammatische Korrektheit und Zeichensetzung", + gewichtung=15, + erwartungen=[ + "Korrekter Satzbau", + "Korrekte Zeichensetzung" + ] + ) + ], + einleitung_hinweise=[ + "Autor, Titel, Textsorte, Erscheinungsjahr", + "Einordnung des Auszugs in den Gesamttext", + "Thema und Deutungshypothese" + ], + hauptteil_hinweise=[ + "Kurze Inhaltsangabe des Auszugs", + "Analyse der Handlungsstruktur", + "Figurenanalyse mit Textbelegen", + "Erzaehltechnische Analyse", + "Sprachliche Analyse", + "Verknuepfung aller Ebenen" + ], + schluss_hinweise=[ + "Zusammenfassung der Analyseergebnisse", + "Bestaetigung der Deutungshypothese", + "Bedeutung fuer Gesamtwerk", + "Ggf. Aktualitaetsbezug" + ], + sprachliche_aspekte=[ + "Fachbegriffe der Erzaehltextanalyse", + "Zwischen Erzaehler und Autor unterscheiden", + "Praesens als Analysetempus", + "Deutende Formulierungen" + ] + ) + + +def get_dramenanalyse_template() -> EHTemplate: + """Template for drama analysis.""" + return EHTemplate( + id="template_dramenanalyse", + aufgabentyp="dramenanalyse", + name="Dramenanalyse", + beschreibung="Vorlage fuer die Analyse dramatischer Texte und Szenen", + kriterien=[ + EHKriterium( + id="inhalt", + name="Inhaltliche Leistung", + beschreibung="Erfassung und Deutung des Szeneninhalts", + gewichtung=40, + erwartungen=[ + "Korrekte Erfassung der Handlung", + "Analyse der Figurenkonstellation", + "Erkennen des dramatischen Konflikts", + "Einordnung in den Handlungsverlauf", + "Deutung der Szene im Gesamtzusammenhang" + ] + ), + EHKriterium( + id="struktur", + name="Aufbau und Struktur", + beschreibung="Logischer Aufbau der Analyse", + gewichtung=15, + erwartungen=[ + "Einleitung mit Kontextualisierung", + "Systematische Szenenanalyse", + "Verknuepfung der Analyseergebnisse", + "Schluessige Deutung" + ] + ), + EHKriterium( + id="dramentechnik", + name="Dramentechnische Analyse", + beschreibung="Analyse dramatischer Gestaltungsmittel", + gewichtung=15, + erwartungen=[ + "Analyse der Dialoggestaltung", + "Regieanweisungen und Buehnenraum", + "Dramatische Spannung", + "Monolog/Dialog-Formen", + "Funktionale Deutung" + ] + ), + EHKriterium( + id="rechtschreibung", + name="Sprachliche Richtigkeit (Rechtschreibung)", + beschreibung="Orthografische Korrektheit", + gewichtung=15, + erwartungen=[ + "Korrekte Rechtschreibung" + ] + ), + EHKriterium( + id="grammatik", + name="Sprachliche Richtigkeit (Grammatik)", + beschreibung="Grammatische Korrektheit und Zeichensetzung", + gewichtung=15, + erwartungen=[ + "Korrekter Satzbau", + "Korrekte Zeichensetzung" + ] + ) + ], + einleitung_hinweise=[ + "Autor, Titel, Uraufführungsjahr, Dramenform", + "Einordnung der Szene in den Handlungsverlauf", + "Thema und Deutungshypothese" + ], + hauptteil_hinweise=[ + "Situierung der Szene", + "Analyse des Dialogverlaufs", + "Figurenanalyse im Dialog", + "Sprachliche Analyse", + "Dramentechnische Mittel", + "Bedeutung fuer den Konflikt" + ], + schluss_hinweise=[ + "Zusammenfassung der Analyseergebnisse", + "Funktion der Szene im Drama", + "Bedeutung fuer die Gesamtdeutung" + ], + sprachliche_aspekte=[ + "Fachbegriffe der Dramenanalyse", + "Praesens als Analysetempus", + "Korrekte Zitierweise mit Akt/Szene/Zeile" + ] + ) + + +# ============================================= +# TEMPLATE REGISTRY +# ============================================= + +TEMPLATES: Dict[str, EHTemplate] = {} + + +def initialize_templates(): + """Initialize all pre-defined templates.""" + global TEMPLATES + TEMPLATES = { + "textanalyse_pragmatisch": get_textanalyse_template(), + "gedichtanalyse": get_gedichtanalyse_template(), + "eroerterung_textgebunden": get_eroerterung_template(), + "prosaanalyse": get_prosaanalyse_template(), + "dramenanalyse": get_dramenanalyse_template(), + } + + +def get_template(aufgabentyp: str) -> Optional[EHTemplate]: + """Get a template by Aufgabentyp.""" + if not TEMPLATES: + initialize_templates() + return TEMPLATES.get(aufgabentyp) + + +def list_templates() -> List[Dict]: + """List all available templates.""" + if not TEMPLATES: + initialize_templates() + return [ + { + "aufgabentyp": typ, + "name": AUFGABENTYPEN.get(typ, {}).get("name", typ), + "description": AUFGABENTYPEN.get(typ, {}).get("description", ""), + "category": AUFGABENTYPEN.get(typ, {}).get("category", "other"), + } + for typ in TEMPLATES.keys() + ] + + +def get_aufgabentypen() -> Dict: + """Get all Aufgabentypen definitions.""" + return AUFGABENTYPEN + + +# Initialize on import +initialize_templates() diff --git a/klausur-service/backend/embedding_client.py b/klausur-service/backend/embedding_client.py new file mode 100644 index 0000000..a12d0ee --- /dev/null +++ b/klausur-service/backend/embedding_client.py @@ -0,0 +1,314 @@ +""" +Embedding Service Client + +HTTP client for communicating with the embedding-service. +Replaces direct sentence-transformers/torch calls in the main service. +""" + +import os +import logging +from typing import List, Tuple, Optional + +import httpx + +logger = logging.getLogger(__name__) + +# Configuration +EMBEDDING_SERVICE_URL = os.getenv("EMBEDDING_SERVICE_URL", "http://embedding-service:8087") +EMBEDDING_SERVICE_TIMEOUT = float(os.getenv("EMBEDDING_SERVICE_TIMEOUT", "60.0")) + + +class EmbeddingServiceError(Exception): + """Error communicating with embedding service.""" + pass + + +class EmbeddingClient: + """ + Client for the embedding-service. + + Provides async methods for: + - Embedding generation + - Re-ranking + - PDF extraction + - Text chunking + """ + + def __init__(self, base_url: str = EMBEDDING_SERVICE_URL, timeout: float = EMBEDDING_SERVICE_TIMEOUT): + self.base_url = base_url.rstrip("/") + self.timeout = timeout + + async def health_check(self) -> dict: + """Check if the embedding service is healthy.""" + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get(f"{self.base_url}/health") + response.raise_for_status() + return response.json() + except Exception as e: + raise EmbeddingServiceError(f"Health check failed: {e}") + + async def generate_embeddings(self, texts: List[str]) -> List[List[float]]: + """ + Generate embeddings for multiple texts. + + Args: + texts: List of texts to embed + + Returns: + List of embedding vectors + """ + if not texts: + return [] + + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + f"{self.base_url}/embed", + json={"texts": texts} + ) + response.raise_for_status() + data = response.json() + return data["embeddings"] + except httpx.TimeoutException: + raise EmbeddingServiceError("Embedding service timeout") + except httpx.HTTPStatusError as e: + raise EmbeddingServiceError(f"Embedding service error: {e.response.status_code} - {e.response.text}") + except Exception as e: + raise EmbeddingServiceError(f"Failed to generate embeddings: {e}") + + async def generate_single_embedding(self, text: str) -> List[float]: + """ + Generate embedding for a single text. + + Args: + text: Text to embed + + Returns: + Embedding vector + """ + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + f"{self.base_url}/embed-single", + json={"text": text} + ) + response.raise_for_status() + data = response.json() + return data["embedding"] + except httpx.TimeoutException: + raise EmbeddingServiceError("Embedding service timeout") + except httpx.HTTPStatusError as e: + raise EmbeddingServiceError(f"Embedding service error: {e.response.status_code} - {e.response.text}") + except Exception as e: + raise EmbeddingServiceError(f"Failed to generate embedding: {e}") + + async def rerank_documents( + self, + query: str, + documents: List[str], + top_k: int = 5 + ) -> List[Tuple[int, float, str]]: + """ + Re-rank documents based on query relevance. + + Args: + query: Search query + documents: List of document texts + top_k: Number of top results to return + + Returns: + List of (original_index, score, text) tuples, sorted by score descending + """ + if not documents: + return [] + + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + f"{self.base_url}/rerank", + json={ + "query": query, + "documents": documents, + "top_k": top_k + } + ) + response.raise_for_status() + data = response.json() + + return [ + (r["index"], r["score"], r["text"]) + for r in data["results"] + ] + except httpx.TimeoutException: + raise EmbeddingServiceError("Embedding service timeout during re-ranking") + except httpx.HTTPStatusError as e: + raise EmbeddingServiceError(f"Re-ranking error: {e.response.status_code} - {e.response.text}") + except Exception as e: + raise EmbeddingServiceError(f"Failed to re-rank documents: {e}") + + async def rerank_search_results( + self, + query: str, + results: List[dict], + text_field: str = "text", + top_k: int = 5 + ) -> List[dict]: + """ + Re-rank search results (dictionaries with text field). + + Args: + query: Search query + results: List of search result dicts + text_field: Key containing the text to rank on + top_k: Number of top results + + Returns: + Re-ranked results with added 'rerank_score' field + """ + if not results: + return [] + + texts = [r.get(text_field, "") for r in results] + reranked = await self.rerank_documents(query, texts, top_k) + + reranked_results = [] + for orig_idx, score, _ in reranked: + result = results[orig_idx].copy() + result["rerank_score"] = score + result["original_rank"] = orig_idx + reranked_results.append(result) + + return reranked_results + + async def chunk_text( + self, + text: str, + chunk_size: int = 1000, + overlap: int = 200, + strategy: str = "semantic" + ) -> List[str]: + """ + Chunk text into smaller pieces. + + Args: + text: Text to chunk + chunk_size: Target chunk size + overlap: Overlap between chunks + strategy: "semantic" or "recursive" + + Returns: + List of text chunks + """ + if not text: + return [] + + try: + async with httpx.AsyncClient(timeout=self.timeout) as client: + response = await client.post( + f"{self.base_url}/chunk", + json={ + "text": text, + "chunk_size": chunk_size, + "overlap": overlap, + "strategy": strategy + } + ) + response.raise_for_status() + data = response.json() + return data["chunks"] + except Exception as e: + raise EmbeddingServiceError(f"Failed to chunk text: {e}") + + async def extract_pdf(self, pdf_content: bytes) -> dict: + """ + Extract text from PDF file. + + Args: + pdf_content: PDF file content as bytes + + Returns: + Dict with 'text', 'backend_used', 'pages', 'table_count' + """ + try: + async with httpx.AsyncClient(timeout=120.0) as client: # Longer timeout for PDFs + response = await client.post( + f"{self.base_url}/extract-pdf", + content=pdf_content, + headers={"Content-Type": "application/octet-stream"} + ) + response.raise_for_status() + return response.json() + except Exception as e: + raise EmbeddingServiceError(f"Failed to extract PDF: {e}") + + async def get_model_info(self) -> dict: + """Get information about configured models.""" + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get(f"{self.base_url}/models") + response.raise_for_status() + return response.json() + except Exception as e: + raise EmbeddingServiceError(f"Failed to get model info: {e}") + + +# Global client instance (lazy initialization) +_client: Optional[EmbeddingClient] = None + + +def get_embedding_client() -> EmbeddingClient: + """Get the global embedding client instance.""" + global _client + if _client is None: + _client = EmbeddingClient() + return _client + + +# ============================================================================= +# Compatibility functions (drop-in replacements for eh_pipeline.py) +# ============================================================================= + +async def generate_embeddings(texts: List[str]) -> List[List[float]]: + """ + Generate embeddings for texts (compatibility function). + + This is a drop-in replacement for eh_pipeline.generate_embeddings(). + """ + client = get_embedding_client() + return await client.generate_embeddings(texts) + + +async def generate_single_embedding(text: str) -> List[float]: + """ + Generate embedding for a single text (compatibility function). + + This is a drop-in replacement for eh_pipeline.generate_single_embedding(). + """ + client = get_embedding_client() + return await client.generate_single_embedding(text) + + +async def rerank_documents(query: str, documents: List[str], top_k: int = 5) -> List[Tuple[int, float, str]]: + """ + Re-rank documents (compatibility function). + + This is a drop-in replacement for reranker.rerank_documents(). + """ + client = get_embedding_client() + return await client.rerank_documents(query, documents, top_k) + + +async def rerank_search_results( + query: str, + results: List[dict], + text_field: str = "text", + top_k: int = 5 +) -> List[dict]: + """ + Re-rank search results (compatibility function). + + This is a drop-in replacement for reranker.rerank_search_results(). + """ + client = get_embedding_client() + return await client.rerank_search_results(query, results, text_field, top_k) diff --git a/klausur-service/backend/full_compliance_pipeline.py b/klausur-service/backend/full_compliance_pipeline.py new file mode 100644 index 0000000..7af71d8 --- /dev/null +++ b/klausur-service/backend/full_compliance_pipeline.py @@ -0,0 +1,723 @@ +#!/usr/bin/env python3 +""" +Full Compliance Pipeline for Legal Corpus. + +This script runs the complete pipeline: +1. Re-ingest all legal documents with improved chunking +2. Extract requirements/checkpoints from chunks +3. Generate controls using AI +4. Define remediation measures +5. Update statistics + +Run on Mac Mini: + nohup python full_compliance_pipeline.py > /tmp/compliance_pipeline.log 2>&1 & + +Checkpoints are saved to /tmp/pipeline_checkpoints.json and can be viewed in admin-v2. +""" + +import asyncio +import json +import logging +import os +import sys +import time +from datetime import datetime +from typing import Dict, List, Any, Optional +from dataclasses import dataclass, asdict +import re +import hashlib + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(sys.stdout), + logging.FileHandler('/tmp/compliance_pipeline.log') + ] +) +logger = logging.getLogger(__name__) + +# Import checkpoint manager +try: + from pipeline_checkpoints import CheckpointManager, EXPECTED_VALUES, ValidationStatus +except ImportError: + logger.warning("Checkpoint manager not available, running without checkpoints") + CheckpointManager = None + EXPECTED_VALUES = {} + ValidationStatus = None + +# Set environment variables for Docker network +# Support both QDRANT_URL and QDRANT_HOST +if not os.getenv("QDRANT_URL") and not os.getenv("QDRANT_HOST"): + os.environ["QDRANT_HOST"] = "qdrant" +os.environ.setdefault("EMBEDDING_SERVICE_URL", "http://embedding-service:8087") + +# Try to import from klausur-service +try: + from legal_corpus_ingestion import LegalCorpusIngestion, REGULATIONS, LEGAL_CORPUS_COLLECTION + from qdrant_client import QdrantClient + from qdrant_client.models import Filter, FieldCondition, MatchValue +except ImportError: + logger.error("Could not import required modules. Make sure you're in the klausur-service container.") + sys.exit(1) + + +@dataclass +class Checkpoint: + """A requirement/checkpoint extracted from legal text.""" + id: str + regulation_code: str + regulation_name: str + article: Optional[str] + title: str + description: str + original_text: str + chunk_id: str + source_url: str + + +@dataclass +class Control: + """A control derived from checkpoints.""" + id: str + domain: str + title: str + description: str + checkpoints: List[str] # List of checkpoint IDs + pass_criteria: str + implementation_guidance: str + is_automated: bool + automation_tool: Optional[str] + priority: str + + +@dataclass +class Measure: + """A remediation measure for a control.""" + id: str + control_id: str + title: str + description: str + responsible: str + deadline_days: int + status: str + + +class CompliancePipeline: + """Handles the full compliance pipeline.""" + + def __init__(self): + # Support both QDRANT_URL and QDRANT_HOST/PORT + qdrant_url = os.getenv("QDRANT_URL", "") + if qdrant_url: + from urllib.parse import urlparse + parsed = urlparse(qdrant_url) + qdrant_host = parsed.hostname or "qdrant" + qdrant_port = parsed.port or 6333 + else: + qdrant_host = os.getenv("QDRANT_HOST", "qdrant") + qdrant_port = 6333 + self.qdrant = QdrantClient(host=qdrant_host, port=qdrant_port) + self.checkpoints: List[Checkpoint] = [] + self.controls: List[Control] = [] + self.measures: List[Measure] = [] + self.stats = { + "chunks_processed": 0, + "checkpoints_extracted": 0, + "controls_created": 0, + "measures_defined": 0, + "by_regulation": {}, + "by_domain": {}, + } + # Initialize checkpoint manager + self.checkpoint_mgr = CheckpointManager() if CheckpointManager else None + + def extract_checkpoints_from_chunk(self, chunk_text: str, payload: Dict) -> List[Checkpoint]: + """ + Extract checkpoints/requirements from a chunk of text. + + Uses pattern matching to find requirement-like statements. + """ + checkpoints = [] + regulation_code = payload.get("regulation_code", "UNKNOWN") + regulation_name = payload.get("regulation_name", "Unknown") + source_url = payload.get("source_url", "") + chunk_id = hashlib.md5(chunk_text[:100].encode()).hexdigest()[:8] + + # Patterns for different requirement types + patterns = [ + # BSI-TR patterns + (r'([OT]\.[A-Za-z_]+\d*)[:\s]+(.+?)(?=\n[OT]\.|$)', 'bsi_requirement'), + # Article patterns (GDPR, AI Act, etc.) + (r'(?:Artikel|Art\.?)\s+(\d+)(?:\s+Abs(?:atz)?\.?\s*(\d+))?\s*[-–:]\s*(.+?)(?=\n|$)', 'article'), + # Numbered requirements + (r'\((\d+)\)\s+(.+?)(?=\n\(\d+\)|$)', 'numbered'), + # "Der Verantwortliche muss" patterns + (r'(?:Der Verantwortliche|Die Aufsichtsbehörde|Der Auftragsverarbeiter)\s+(muss|hat|soll)\s+(.+?)(?=\.\s|$)', 'obligation'), + # "Es ist erforderlich" patterns + (r'(?:Es ist erforderlich|Es muss gewährleistet|Es sind geeignete)\s+(.+?)(?=\.\s|$)', 'requirement'), + ] + + for pattern, pattern_type in patterns: + matches = re.finditer(pattern, chunk_text, re.MULTILINE | re.DOTALL) + for match in matches: + if pattern_type == 'bsi_requirement': + req_id = match.group(1) + description = match.group(2).strip() + title = req_id + elif pattern_type == 'article': + article_num = match.group(1) + paragraph = match.group(2) or "" + title_text = match.group(3).strip() + req_id = f"{regulation_code}-Art{article_num}" + if paragraph: + req_id += f"-{paragraph}" + title = f"Art. {article_num}" + (f" Abs. {paragraph}" if paragraph else "") + description = title_text + elif pattern_type == 'numbered': + num = match.group(1) + description = match.group(2).strip() + req_id = f"{regulation_code}-{num}" + title = f"Anforderung {num}" + else: + # Generic requirement + description = match.group(0).strip() + req_id = f"{regulation_code}-{chunk_id}-{len(checkpoints)}" + title = description[:50] + "..." if len(description) > 50 else description + + # Skip very short matches + if len(description) < 20: + continue + + checkpoint = Checkpoint( + id=req_id, + regulation_code=regulation_code, + regulation_name=regulation_name, + article=title if 'Art' in title else None, + title=title, + description=description[:500], + original_text=description, + chunk_id=chunk_id, + source_url=source_url + ) + checkpoints.append(checkpoint) + + return checkpoints + + def generate_control_for_checkpoints(self, checkpoints: List[Checkpoint]) -> Optional[Control]: + """ + Generate a control that covers the given checkpoints. + + This is a simplified version - in production this would use the AI assistant. + """ + if not checkpoints: + return None + + # Group by regulation + regulation = checkpoints[0].regulation_code + + # Determine domain based on content + all_text = " ".join([cp.description for cp in checkpoints]).lower() + + domain = "gov" # Default + if any(kw in all_text for kw in ["verschlüssel", "krypto", "encrypt", "hash"]): + domain = "crypto" + elif any(kw in all_text for kw in ["zugang", "access", "authentif", "login", "benutzer"]): + domain = "iam" + elif any(kw in all_text for kw in ["datenschutz", "personenbezogen", "privacy", "einwilligung"]): + domain = "priv" + elif any(kw in all_text for kw in ["entwicklung", "test", "code", "software"]): + domain = "sdlc" + elif any(kw in all_text for kw in ["überwach", "monitor", "log", "audit"]): + domain = "aud" + elif any(kw in all_text for kw in ["ki", "künstlich", "ai", "machine learning", "model"]): + domain = "ai" + elif any(kw in all_text for kw in ["betrieb", "operation", "verfügbar", "backup"]): + domain = "ops" + elif any(kw in all_text for kw in ["cyber", "resilience", "sbom", "vulnerab"]): + domain = "cra" + + # Generate control ID + domain_counts = self.stats.get("by_domain", {}) + domain_count = domain_counts.get(domain, 0) + 1 + control_id = f"{domain.upper()}-{domain_count:03d}" + + # Create title from first checkpoint + title = checkpoints[0].title + if len(title) > 100: + title = title[:97] + "..." + + # Create description + description = f"Control für {regulation}: " + checkpoints[0].description[:200] + + # Pass criteria + pass_criteria = f"Alle {len(checkpoints)} zugehörigen Anforderungen sind erfüllt und dokumentiert." + + # Implementation guidance + guidance = f"Implementiere Maßnahmen zur Erfüllung der Anforderungen aus {regulation}. " + guidance += f"Dokumentiere die Umsetzung und führe regelmäßige Reviews durch." + + # Determine if automated + is_automated = any(kw in all_text for kw in ["automat", "tool", "scan", "test"]) + + control = Control( + id=control_id, + domain=domain, + title=title, + description=description, + checkpoints=[cp.id for cp in checkpoints], + pass_criteria=pass_criteria, + implementation_guidance=guidance, + is_automated=is_automated, + automation_tool="CI/CD Pipeline" if is_automated else None, + priority="high" if "muss" in all_text or "erforderlich" in all_text else "medium" + ) + + return control + + def generate_measure_for_control(self, control: Control) -> Measure: + """Generate a remediation measure for a control.""" + measure_id = f"M-{control.id}" + + # Determine deadline based on priority + deadline_days = { + "critical": 30, + "high": 60, + "medium": 90, + "low": 180 + }.get(control.priority, 90) + + # Determine responsible team + responsible = { + "priv": "Datenschutzbeauftragter", + "iam": "IT-Security Team", + "sdlc": "Entwicklungsteam", + "crypto": "IT-Security Team", + "ops": "Operations Team", + "aud": "Compliance Team", + "ai": "AI/ML Team", + "cra": "IT-Security Team", + "gov": "Management" + }.get(control.domain, "Compliance Team") + + measure = Measure( + id=measure_id, + control_id=control.id, + title=f"Umsetzung: {control.title[:50]}", + description=f"Implementierung und Dokumentation von {control.id}: {control.description[:100]}", + responsible=responsible, + deadline_days=deadline_days, + status="pending" + ) + + return measure + + async def run_ingestion_phase(self, force_reindex: bool = False) -> int: + """Phase 1: Ingest documents (incremental - only missing ones).""" + logger.info("\n" + "=" * 60) + logger.info("PHASE 1: DOCUMENT INGESTION (INCREMENTAL)") + logger.info("=" * 60) + + if self.checkpoint_mgr: + self.checkpoint_mgr.start_checkpoint("ingestion", "Document Ingestion") + + ingestion = LegalCorpusIngestion() + + try: + # Check existing chunks per regulation + existing_chunks = {} + try: + for regulation in REGULATIONS: + count_result = self.qdrant.count( + collection_name=LEGAL_CORPUS_COLLECTION, + count_filter=Filter( + must=[FieldCondition(key="regulation_code", match=MatchValue(value=regulation.code))] + ) + ) + existing_chunks[regulation.code] = count_result.count + logger.info(f" {regulation.code}: {count_result.count} existing chunks") + except Exception as e: + logger.warning(f"Could not check existing chunks: {e}") + # Collection might not exist, that's OK + + # Determine which regulations need ingestion + regulations_to_ingest = [] + for regulation in REGULATIONS: + existing = existing_chunks.get(regulation.code, 0) + if force_reindex or existing == 0: + regulations_to_ingest.append(regulation) + logger.info(f" -> Will ingest: {regulation.code} (existing: {existing}, force: {force_reindex})") + else: + logger.info(f" -> Skipping: {regulation.code} (already has {existing} chunks)") + self.stats["by_regulation"][regulation.code] = existing + + if not regulations_to_ingest: + logger.info("All regulations already indexed. Skipping ingestion phase.") + total_chunks = sum(existing_chunks.values()) + self.stats["chunks_processed"] = total_chunks + if self.checkpoint_mgr: + self.checkpoint_mgr.add_metric("total_chunks", total_chunks) + self.checkpoint_mgr.add_metric("skipped", True) + self.checkpoint_mgr.complete_checkpoint(success=True) + return total_chunks + + # Ingest only missing regulations + total_chunks = sum(existing_chunks.values()) + for i, regulation in enumerate(regulations_to_ingest, 1): + logger.info(f"[{i}/{len(regulations_to_ingest)}] Ingesting {regulation.code}...") + try: + count = await ingestion.ingest_regulation(regulation) + total_chunks += count + self.stats["by_regulation"][regulation.code] = count + logger.info(f" -> {count} chunks") + + # Add metric for this regulation + if self.checkpoint_mgr: + self.checkpoint_mgr.add_metric(f"chunks_{regulation.code}", count) + + except Exception as e: + logger.error(f" -> FAILED: {e}") + self.stats["by_regulation"][regulation.code] = 0 + + self.stats["chunks_processed"] = total_chunks + logger.info(f"\nTotal chunks in collection: {total_chunks}") + + # Validate ingestion results + if self.checkpoint_mgr: + self.checkpoint_mgr.add_metric("total_chunks", total_chunks) + self.checkpoint_mgr.add_metric("regulations_count", len(REGULATIONS)) + + # Validate total chunks + expected = EXPECTED_VALUES.get("ingestion", {}) + self.checkpoint_mgr.validate( + "total_chunks", + expected=expected.get("total_chunks", 8000), + actual=total_chunks, + min_value=expected.get("min_chunks", 7000) + ) + + # Validate key regulations + reg_expected = expected.get("regulations", {}) + for reg_code, reg_exp in reg_expected.items(): + actual = self.stats["by_regulation"].get(reg_code, 0) + self.checkpoint_mgr.validate( + f"chunks_{reg_code}", + expected=reg_exp.get("expected", 0), + actual=actual, + min_value=reg_exp.get("min", 0) + ) + + self.checkpoint_mgr.complete_checkpoint(success=True) + + return total_chunks + + except Exception as e: + if self.checkpoint_mgr: + self.checkpoint_mgr.fail_checkpoint(str(e)) + raise + + finally: + await ingestion.close() + + async def run_extraction_phase(self) -> int: + """Phase 2: Extract checkpoints from chunks.""" + logger.info("\n" + "=" * 60) + logger.info("PHASE 2: CHECKPOINT EXTRACTION") + logger.info("=" * 60) + + if self.checkpoint_mgr: + self.checkpoint_mgr.start_checkpoint("extraction", "Checkpoint Extraction") + + try: + # Scroll through all chunks + offset = None + total_checkpoints = 0 + + while True: + result = self.qdrant.scroll( + collection_name=LEGAL_CORPUS_COLLECTION, + limit=100, + offset=offset, + with_payload=True, + with_vectors=False + ) + + points, next_offset = result + + if not points: + break + + for point in points: + payload = point.payload + text = payload.get("text", "") + + checkpoints = self.extract_checkpoints_from_chunk(text, payload) + self.checkpoints.extend(checkpoints) + total_checkpoints += len(checkpoints) + + logger.info(f"Processed {len(points)} chunks, extracted {total_checkpoints} checkpoints so far...") + + if next_offset is None: + break + offset = next_offset + + self.stats["checkpoints_extracted"] = len(self.checkpoints) + logger.info(f"\nTotal checkpoints extracted: {len(self.checkpoints)}") + + # Log per regulation + by_reg = {} + for cp in self.checkpoints: + by_reg[cp.regulation_code] = by_reg.get(cp.regulation_code, 0) + 1 + for reg, count in sorted(by_reg.items()): + logger.info(f" {reg}: {count} checkpoints") + + # Validate extraction results + if self.checkpoint_mgr: + self.checkpoint_mgr.add_metric("total_checkpoints", len(self.checkpoints)) + self.checkpoint_mgr.add_metric("checkpoints_by_regulation", by_reg) + + expected = EXPECTED_VALUES.get("extraction", {}) + self.checkpoint_mgr.validate( + "total_checkpoints", + expected=expected.get("total_checkpoints", 3500), + actual=len(self.checkpoints), + min_value=expected.get("min_checkpoints", 3000) + ) + + self.checkpoint_mgr.complete_checkpoint(success=True) + + return len(self.checkpoints) + + except Exception as e: + if self.checkpoint_mgr: + self.checkpoint_mgr.fail_checkpoint(str(e)) + raise + + async def run_control_generation_phase(self) -> int: + """Phase 3: Generate controls from checkpoints.""" + logger.info("\n" + "=" * 60) + logger.info("PHASE 3: CONTROL GENERATION") + logger.info("=" * 60) + + if self.checkpoint_mgr: + self.checkpoint_mgr.start_checkpoint("controls", "Control Generation") + + try: + # Group checkpoints by regulation + by_regulation: Dict[str, List[Checkpoint]] = {} + for cp in self.checkpoints: + reg = cp.regulation_code + if reg not in by_regulation: + by_regulation[reg] = [] + by_regulation[reg].append(cp) + + # Generate controls per regulation (group every 3-5 checkpoints) + for regulation, checkpoints in by_regulation.items(): + logger.info(f"Generating controls for {regulation} ({len(checkpoints)} checkpoints)...") + + # Group checkpoints into batches of 3-5 + batch_size = 4 + for i in range(0, len(checkpoints), batch_size): + batch = checkpoints[i:i + batch_size] + control = self.generate_control_for_checkpoints(batch) + + if control: + self.controls.append(control) + self.stats["by_domain"][control.domain] = self.stats["by_domain"].get(control.domain, 0) + 1 + + self.stats["controls_created"] = len(self.controls) + logger.info(f"\nTotal controls created: {len(self.controls)}") + + # Log per domain + for domain, count in sorted(self.stats["by_domain"].items()): + logger.info(f" {domain}: {count} controls") + + # Validate control generation + if self.checkpoint_mgr: + self.checkpoint_mgr.add_metric("total_controls", len(self.controls)) + self.checkpoint_mgr.add_metric("controls_by_domain", dict(self.stats["by_domain"])) + + expected = EXPECTED_VALUES.get("controls", {}) + self.checkpoint_mgr.validate( + "total_controls", + expected=expected.get("total_controls", 900), + actual=len(self.controls), + min_value=expected.get("min_controls", 800) + ) + + self.checkpoint_mgr.complete_checkpoint(success=True) + + return len(self.controls) + + except Exception as e: + if self.checkpoint_mgr: + self.checkpoint_mgr.fail_checkpoint(str(e)) + raise + + async def run_measure_generation_phase(self) -> int: + """Phase 4: Generate measures for controls.""" + logger.info("\n" + "=" * 60) + logger.info("PHASE 4: MEASURE GENERATION") + logger.info("=" * 60) + + if self.checkpoint_mgr: + self.checkpoint_mgr.start_checkpoint("measures", "Measure Generation") + + try: + for control in self.controls: + measure = self.generate_measure_for_control(control) + self.measures.append(measure) + + self.stats["measures_defined"] = len(self.measures) + logger.info(f"\nTotal measures defined: {len(self.measures)}") + + # Validate measure generation + if self.checkpoint_mgr: + self.checkpoint_mgr.add_metric("total_measures", len(self.measures)) + + expected = EXPECTED_VALUES.get("measures", {}) + self.checkpoint_mgr.validate( + "total_measures", + expected=expected.get("total_measures", 900), + actual=len(self.measures), + min_value=expected.get("min_measures", 800) + ) + + self.checkpoint_mgr.complete_checkpoint(success=True) + + return len(self.measures) + + except Exception as e: + if self.checkpoint_mgr: + self.checkpoint_mgr.fail_checkpoint(str(e)) + raise + + def save_results(self, output_dir: str = "/tmp/compliance_output"): + """Save results to JSON files.""" + logger.info("\n" + "=" * 60) + logger.info("SAVING RESULTS") + logger.info("=" * 60) + + os.makedirs(output_dir, exist_ok=True) + + # Save checkpoints + checkpoints_file = os.path.join(output_dir, "checkpoints.json") + with open(checkpoints_file, "w") as f: + json.dump([asdict(cp) for cp in self.checkpoints], f, indent=2, ensure_ascii=False) + logger.info(f"Saved {len(self.checkpoints)} checkpoints to {checkpoints_file}") + + # Save controls + controls_file = os.path.join(output_dir, "controls.json") + with open(controls_file, "w") as f: + json.dump([asdict(c) for c in self.controls], f, indent=2, ensure_ascii=False) + logger.info(f"Saved {len(self.controls)} controls to {controls_file}") + + # Save measures + measures_file = os.path.join(output_dir, "measures.json") + with open(measures_file, "w") as f: + json.dump([asdict(m) for m in self.measures], f, indent=2, ensure_ascii=False) + logger.info(f"Saved {len(self.measures)} measures to {measures_file}") + + # Save statistics + stats_file = os.path.join(output_dir, "statistics.json") + self.stats["generated_at"] = datetime.now().isoformat() + with open(stats_file, "w") as f: + json.dump(self.stats, f, indent=2, ensure_ascii=False) + logger.info(f"Saved statistics to {stats_file}") + + async def run_full_pipeline(self, force_reindex: bool = False, skip_ingestion: bool = False): + """Run the complete pipeline. + + Args: + force_reindex: If True, re-ingest all documents even if they exist + skip_ingestion: If True, skip ingestion phase entirely (use existing chunks) + """ + start_time = time.time() + + logger.info("=" * 60) + logger.info("FULL COMPLIANCE PIPELINE (INCREMENTAL)") + logger.info(f"Started at: {datetime.now().isoformat()}") + logger.info(f"Force reindex: {force_reindex}") + logger.info(f"Skip ingestion: {skip_ingestion}") + if self.checkpoint_mgr: + logger.info(f"Pipeline ID: {self.checkpoint_mgr.pipeline_id}") + logger.info("=" * 60) + + try: + # Phase 1: Ingestion (skip if requested or run incrementally) + if skip_ingestion: + logger.info("Skipping ingestion phase as requested...") + # Still get the chunk count + try: + collection_info = self.qdrant.get_collection(LEGAL_CORPUS_COLLECTION) + self.stats["chunks_processed"] = collection_info.points_count + except Exception: + self.stats["chunks_processed"] = 0 + else: + await self.run_ingestion_phase(force_reindex=force_reindex) + + # Phase 2: Extraction + await self.run_extraction_phase() + + # Phase 3: Control Generation + await self.run_control_generation_phase() + + # Phase 4: Measure Generation + await self.run_measure_generation_phase() + + # Save results + self.save_results() + + # Final summary + elapsed = time.time() - start_time + logger.info("\n" + "=" * 60) + logger.info("PIPELINE COMPLETE") + logger.info("=" * 60) + logger.info(f"Duration: {elapsed:.1f} seconds") + logger.info(f"Chunks processed: {self.stats['chunks_processed']}") + logger.info(f"Checkpoints extracted: {self.stats['checkpoints_extracted']}") + logger.info(f"Controls created: {self.stats['controls_created']}") + logger.info(f"Measures defined: {self.stats['measures_defined']}") + logger.info(f"\nResults saved to: /tmp/compliance_output/") + logger.info("Checkpoint status: /tmp/pipeline_checkpoints.json") + logger.info("=" * 60) + + # Complete pipeline checkpoint + if self.checkpoint_mgr: + self.checkpoint_mgr.complete_pipeline({ + "duration_seconds": elapsed, + "chunks_processed": self.stats['chunks_processed'], + "checkpoints_extracted": self.stats['checkpoints_extracted'], + "controls_created": self.stats['controls_created'], + "measures_defined": self.stats['measures_defined'], + "by_regulation": self.stats['by_regulation'], + "by_domain": self.stats['by_domain'], + }) + + except Exception as e: + logger.error(f"Pipeline failed: {e}") + if self.checkpoint_mgr: + self.checkpoint_mgr.state.status = "failed" + self.checkpoint_mgr._save() + raise + + +async def main(): + import argparse + parser = argparse.ArgumentParser(description="Run the compliance pipeline") + parser.add_argument("--force-reindex", action="store_true", + help="Force re-ingestion of all documents") + parser.add_argument("--skip-ingestion", action="store_true", + help="Skip ingestion phase, use existing chunks") + args = parser.parse_args() + + pipeline = CompliancePipeline() + await pipeline.run_full_pipeline( + force_reindex=args.force_reindex, + skip_ingestion=args.skip_ingestion + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/klausur-service/backend/full_reingestion.py b/klausur-service/backend/full_reingestion.py new file mode 100644 index 0000000..98224bb --- /dev/null +++ b/klausur-service/backend/full_reingestion.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +""" +Full Re-Ingestion Script for Legal Corpus and UCCA. + +This script: +1. Deletes all existing chunks from bp_legal_corpus +2. Re-ingests all 19 regulations with improved semantic chunking +3. Logs progress to a file for monitoring + +Run in background on Mac Mini: + nohup python full_reingestion.py > /tmp/reingestion.log 2>&1 & +""" + +import asyncio +import logging +import os +import sys +from datetime import datetime + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.StreamHandler(sys.stdout), + logging.FileHandler('/tmp/legal_corpus_reingestion.log') + ] +) +logger = logging.getLogger(__name__) + +# Set environment variables for Docker network +os.environ.setdefault("QDRANT_HOST", "qdrant") +os.environ.setdefault("EMBEDDING_SERVICE_URL", "http://embedding-service:8087") + +from legal_corpus_ingestion import LegalCorpusIngestion, REGULATIONS, LEGAL_CORPUS_COLLECTION +from qdrant_client import QdrantClient + + +async def main(): + logger.info("=" * 60) + logger.info("FULL LEGAL CORPUS RE-INGESTION") + logger.info(f"Started at: {datetime.now().isoformat()}") + logger.info("=" * 60) + + # Initialize + ingestion = LegalCorpusIngestion() + + try: + # Step 1: Delete all existing points + logger.info("\n[Step 1] Deleting all existing chunks...") + qdrant = QdrantClient(host=os.getenv("QDRANT_HOST", "qdrant"), port=6333) + + # Get current count + try: + collection_info = qdrant.get_collection(LEGAL_CORPUS_COLLECTION) + old_count = collection_info.points_count + logger.info(f" Current chunk count: {old_count}") + except Exception as e: + logger.warning(f" Could not get collection info: {e}") + old_count = 0 + + # Delete all points by recreating collection + logger.info(" Deleting collection and recreating...") + try: + qdrant.delete_collection(LEGAL_CORPUS_COLLECTION) + logger.info(" Collection deleted.") + except Exception as e: + logger.warning(f" Could not delete collection: {e}") + + # The ingestion class will recreate the collection + ingestion = LegalCorpusIngestion() + logger.info(" Collection recreated.") + + # Step 2: Re-ingest all regulations + logger.info("\n[Step 2] Re-ingesting all 19 regulations...") + logger.info(f" Regulations: {[r.code for r in REGULATIONS]}") + + results = {} + total_chunks = 0 + + for i, regulation in enumerate(REGULATIONS, 1): + logger.info(f"\n [{i}/19] Processing {regulation.code}: {regulation.name}") + try: + count = await ingestion.ingest_regulation(regulation) + results[regulation.code] = count + total_chunks += count + logger.info(f" -> {count} chunks indexed") + except Exception as e: + logger.error(f" -> FAILED: {e}") + results[regulation.code] = 0 + + # Step 3: Summary + logger.info("\n" + "=" * 60) + logger.info("SUMMARY") + logger.info("=" * 60) + logger.info(f" Previous chunk count: {old_count}") + logger.info(f" New chunk count: {total_chunks}") + logger.info(f" Difference: {total_chunks - old_count:+d}") + logger.info("\n Per regulation:") + for code, count in sorted(results.items()): + logger.info(f" {code}: {count} chunks") + + # BSI specific + bsi_total = sum(results.get(f"BSI-TR-03161-{i}", 0) for i in [1, 2, 3]) + logger.info(f"\n BSI-TR-03161 total: {bsi_total} chunks (was 18)") + + logger.info("\n" + "=" * 60) + logger.info(f"Completed at: {datetime.now().isoformat()}") + logger.info("=" * 60) + + finally: + await ingestion.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/klausur-service/backend/github_crawler.py b/klausur-service/backend/github_crawler.py new file mode 100644 index 0000000..ff0b4da --- /dev/null +++ b/klausur-service/backend/github_crawler.py @@ -0,0 +1,767 @@ +""" +GitHub Repository Crawler for Legal Templates. + +Crawls GitHub and GitLab repositories to extract legal template documents +(Markdown, HTML, JSON, etc.) for ingestion into the RAG system. + +Features: +- Clone repositories via Git or download as ZIP +- Parse Markdown, HTML, JSON, and plain text files +- Extract structured content with metadata +- Track git commit hashes for reproducibility +- Handle rate limiting and errors gracefully +""" + +import asyncio +import hashlib +import json +import logging +import os +import re +import shutil +import tempfile +import zipfile +from dataclasses import dataclass, field +from datetime import datetime +from fnmatch import fnmatch +from pathlib import Path +from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple +from urllib.parse import urlparse + +import httpx + +from template_sources import LicenseType, SourceConfig, LICENSES + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Configuration +GITHUB_API_URL = "https://api.github.com" +GITLAB_API_URL = "https://gitlab.com/api/v4" +GITHUB_TOKEN = os.getenv("GITHUB_TOKEN", "") # Optional for higher rate limits +MAX_FILE_SIZE = 1024 * 1024 # 1 MB max file size +REQUEST_TIMEOUT = 60.0 +RATE_LIMIT_DELAY = 1.0 # Delay between requests to avoid rate limiting + + +@dataclass +class ExtractedDocument: + """A document extracted from a repository.""" + text: str + title: str + file_path: str + file_type: str # "markdown", "html", "json", "text" + source_url: str + source_commit: Optional[str] = None + source_hash: str = "" # SHA256 of original content + sections: List[Dict[str, Any]] = field(default_factory=list) + placeholders: List[str] = field(default_factory=list) + language: str = "en" + metadata: Dict[str, Any] = field(default_factory=dict) + + def __post_init__(self): + if not self.source_hash and self.text: + self.source_hash = hashlib.sha256(self.text.encode()).hexdigest() + + +class MarkdownParser: + """Parse Markdown files into structured content.""" + + # Common placeholder patterns + PLACEHOLDER_PATTERNS = [ + r'\[([A-Z_]+)\]', # [COMPANY_NAME] + r'\{([a-z_]+)\}', # {company_name} + r'\{\{([a-z_]+)\}\}', # {{company_name}} + r'__([A-Z_]+)__', # __COMPANY_NAME__ + r'<([A-Z_]+)>', # + ] + + @classmethod + def parse(cls, content: str, filename: str = "") -> ExtractedDocument: + """Parse markdown content into an ExtractedDocument.""" + # Extract title from first heading or filename + title = cls._extract_title(content, filename) + + # Extract sections + sections = cls._extract_sections(content) + + # Find placeholders + placeholders = cls._find_placeholders(content) + + # Detect language + language = cls._detect_language(content) + + # Clean content for indexing + clean_text = cls._clean_for_indexing(content) + + return ExtractedDocument( + text=clean_text, + title=title, + file_path=filename, + file_type="markdown", + source_url="", # Will be set by caller + sections=sections, + placeholders=placeholders, + language=language, + ) + + @classmethod + def _extract_title(cls, content: str, filename: str) -> str: + """Extract title from markdown heading or filename.""" + # Look for first h1 heading + h1_match = re.search(r'^#\s+(.+)$', content, re.MULTILINE) + if h1_match: + return h1_match.group(1).strip() + + # Look for YAML frontmatter title + frontmatter_match = re.search( + r'^---\s*\n.*?title:\s*["\']?(.+?)["\']?\s*\n.*?---', + content, re.DOTALL + ) + if frontmatter_match: + return frontmatter_match.group(1).strip() + + # Fall back to filename + if filename: + name = Path(filename).stem + # Convert kebab-case or snake_case to title case + return name.replace('-', ' ').replace('_', ' ').title() + + return "Untitled" + + @classmethod + def _extract_sections(cls, content: str) -> List[Dict[str, Any]]: + """Extract sections from markdown content.""" + sections = [] + current_section = {"heading": "", "level": 0, "content": "", "start": 0} + + for match in re.finditer(r'^(#{1,6})\s+(.+)$', content, re.MULTILINE): + # Save previous section if it has content + if current_section["heading"] or current_section["content"].strip(): + current_section["content"] = current_section["content"].strip() + sections.append(current_section.copy()) + + # Start new section + level = len(match.group(1)) + heading = match.group(2).strip() + current_section = { + "heading": heading, + "level": level, + "content": "", + "start": match.end(), + } + + # Add final section + if current_section["heading"] or current_section["content"].strip(): + current_section["content"] = content[current_section["start"]:].strip() + sections.append(current_section) + + return sections + + @classmethod + def _find_placeholders(cls, content: str) -> List[str]: + """Find placeholder patterns in content.""" + placeholders = set() + for pattern in cls.PLACEHOLDER_PATTERNS: + for match in re.finditer(pattern, content): + placeholder = match.group(0) + placeholders.add(placeholder) + return sorted(list(placeholders)) + + @classmethod + def _detect_language(cls, content: str) -> str: + """Detect language from content.""" + # Look for German-specific words + german_indicators = [ + 'Datenschutz', 'Impressum', 'Nutzungsbedingungen', 'Haftung', + 'Widerruf', 'Verantwortlicher', 'personenbezogene', 'Verarbeitung', + 'und', 'der', 'die', 'das', 'ist', 'wird', 'werden', 'sind', + ] + + lower_content = content.lower() + german_count = sum(1 for word in german_indicators if word.lower() in lower_content) + + if german_count >= 3: + return "de" + return "en" + + @classmethod + def _clean_for_indexing(cls, content: str) -> str: + """Clean markdown content for text indexing.""" + # Remove YAML frontmatter + content = re.sub(r'^---\s*\n.*?---\s*\n', '', content, flags=re.DOTALL) + + # Remove HTML comments + content = re.sub(r'', '', content, flags=re.DOTALL) + + # Remove inline HTML tags but keep content + content = re.sub(r'<[^>]+>', '', content) + + # Convert markdown formatting + content = re.sub(r'\*\*(.+?)\*\*', r'\1', content) # Bold + content = re.sub(r'\*(.+?)\*', r'\1', content) # Italic + content = re.sub(r'`(.+?)`', r'\1', content) # Inline code + content = re.sub(r'~~(.+?)~~', r'\1', content) # Strikethrough + + # Remove link syntax but keep text + content = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', content) + + # Remove image syntax + content = re.sub(r'!\[([^\]]*)\]\([^)]+\)', r'\1', content) + + # Clean up whitespace + content = re.sub(r'\n{3,}', '\n\n', content) + content = re.sub(r' +', ' ', content) + + return content.strip() + + +class HTMLParser: + """Parse HTML files into structured content.""" + + @classmethod + def parse(cls, content: str, filename: str = "") -> ExtractedDocument: + """Parse HTML content into an ExtractedDocument.""" + # Extract title + title_match = re.search(r'(.+?)', content, re.IGNORECASE) + title = title_match.group(1) if title_match else Path(filename).stem + + # Convert to text + text = cls._html_to_text(content) + + # Find placeholders + placeholders = MarkdownParser._find_placeholders(text) + + # Detect language + lang_match = re.search(r']*lang=["\']([a-z]{2})["\']', content, re.IGNORECASE) + language = lang_match.group(1) if lang_match else MarkdownParser._detect_language(text) + + return ExtractedDocument( + text=text, + title=title, + file_path=filename, + file_type="html", + source_url="", + placeholders=placeholders, + language=language, + ) + + @classmethod + def _html_to_text(cls, html: str) -> str: + """Convert HTML to clean text.""" + # Remove script and style tags + html = re.sub(r']*>.*?', '', html, flags=re.DOTALL | re.IGNORECASE) + html = re.sub(r']*>.*?', '', html, flags=re.DOTALL | re.IGNORECASE) + + # Remove comments + html = re.sub(r'', '', html, flags=re.DOTALL) + + # Replace common entities + html = html.replace(' ', ' ') + html = html.replace('&', '&') + html = html.replace('<', '<') + html = html.replace('>', '>') + html = html.replace('"', '"') + html = html.replace(''', "'") + + # Add line breaks for block elements + html = re.sub(r'', '\n', html, flags=re.IGNORECASE) + html = re.sub(r'

              ', '\n\n', html, flags=re.IGNORECASE) + html = re.sub(r'
              ', '\n', html, flags=re.IGNORECASE) + html = re.sub(r'', '\n\n', html, flags=re.IGNORECASE) + html = re.sub(r'', '\n', html, flags=re.IGNORECASE) + + # Remove remaining tags + html = re.sub(r'<[^>]+>', '', html) + + # Clean whitespace + html = re.sub(r'[ \t]+', ' ', html) + html = re.sub(r'\n[ \t]+', '\n', html) + html = re.sub(r'[ \t]+\n', '\n', html) + html = re.sub(r'\n{3,}', '\n\n', html) + + return html.strip() + + +class JSONParser: + """Parse JSON files containing legal template data.""" + + @classmethod + def parse(cls, content: str, filename: str = "") -> List[ExtractedDocument]: + """Parse JSON content into ExtractedDocuments.""" + try: + data = json.loads(content) + except json.JSONDecodeError as e: + logger.warning(f"Failed to parse JSON from {filename}: {e}") + return [] + + documents = [] + + if isinstance(data, dict): + # Handle different JSON structures + documents.extend(cls._parse_dict(data, filename)) + elif isinstance(data, list): + for i, item in enumerate(data): + if isinstance(item, dict): + docs = cls._parse_dict(item, f"{filename}[{i}]") + documents.extend(docs) + + return documents + + @classmethod + def _parse_dict(cls, data: dict, filename: str) -> List[ExtractedDocument]: + """Parse a dictionary into documents.""" + documents = [] + + # Look for text content in common keys + text_keys = ['text', 'content', 'body', 'description', 'value'] + title_keys = ['title', 'name', 'heading', 'label', 'key'] + + # Try to find main text content + text = "" + for key in text_keys: + if key in data and isinstance(data[key], str): + text = data[key] + break + + if not text: + # Check for nested structures (like webflorist format) + for key, value in data.items(): + if isinstance(value, dict): + nested_docs = cls._parse_dict(value, f"{filename}.{key}") + documents.extend(nested_docs) + elif isinstance(value, list): + for i, item in enumerate(value): + if isinstance(item, dict): + nested_docs = cls._parse_dict(item, f"{filename}.{key}[{i}]") + documents.extend(nested_docs) + elif isinstance(item, str) and len(item) > 50: + # Treat long strings as content + documents.append(ExtractedDocument( + text=item, + title=f"{key} {i+1}", + file_path=filename, + file_type="json", + source_url="", + language=MarkdownParser._detect_language(item), + )) + return documents + + # Found text content + title = "" + for key in title_keys: + if key in data and isinstance(data[key], str): + title = data[key] + break + + if not title: + title = Path(filename).stem + + # Extract metadata + metadata = {} + for key, value in data.items(): + if key not in text_keys + title_keys and not isinstance(value, (dict, list)): + metadata[key] = value + + placeholders = MarkdownParser._find_placeholders(text) + language = data.get('lang', data.get('language', MarkdownParser._detect_language(text))) + + documents.append(ExtractedDocument( + text=text, + title=title, + file_path=filename, + file_type="json", + source_url="", + placeholders=placeholders, + language=language, + metadata=metadata, + )) + + return documents + + +class GitHubCrawler: + """Crawl GitHub repositories for legal templates.""" + + def __init__(self, token: Optional[str] = None): + self.token = token or GITHUB_TOKEN + self.headers = { + "Accept": "application/vnd.github.v3+json", + "User-Agent": "LegalTemplatesCrawler/1.0", + } + if self.token: + self.headers["Authorization"] = f"token {self.token}" + + self.http_client: Optional[httpx.AsyncClient] = None + + async def __aenter__(self): + self.http_client = httpx.AsyncClient( + timeout=REQUEST_TIMEOUT, + headers=self.headers, + follow_redirects=True, + ) + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if self.http_client: + await self.http_client.aclose() + + def _parse_repo_url(self, url: str) -> Tuple[str, str, str]: + """Parse repository URL into owner, repo, and host.""" + parsed = urlparse(url) + path_parts = parsed.path.strip('/').split('/') + + if len(path_parts) < 2: + raise ValueError(f"Invalid repository URL: {url}") + + owner = path_parts[0] + repo = path_parts[1].replace('.git', '') + + if 'gitlab' in parsed.netloc: + host = 'gitlab' + else: + host = 'github' + + return owner, repo, host + + async def get_default_branch(self, owner: str, repo: str) -> str: + """Get the default branch of a repository.""" + if not self.http_client: + raise RuntimeError("Crawler not initialized. Use 'async with' context.") + + url = f"{GITHUB_API_URL}/repos/{owner}/{repo}" + response = await self.http_client.get(url) + response.raise_for_status() + data = response.json() + return data.get("default_branch", "main") + + async def get_latest_commit(self, owner: str, repo: str, branch: str = "main") -> str: + """Get the latest commit SHA for a branch.""" + if not self.http_client: + raise RuntimeError("Crawler not initialized. Use 'async with' context.") + + url = f"{GITHUB_API_URL}/repos/{owner}/{repo}/commits/{branch}" + response = await self.http_client.get(url) + response.raise_for_status() + data = response.json() + return data.get("sha", "") + + async def list_files( + self, + owner: str, + repo: str, + path: str = "", + branch: str = "main", + patterns: List[str] = None, + exclude_patterns: List[str] = None, + ) -> List[Dict[str, Any]]: + """List files in a repository matching the given patterns.""" + if not self.http_client: + raise RuntimeError("Crawler not initialized. Use 'async with' context.") + + patterns = patterns or ["*.md", "*.txt", "*.html"] + exclude_patterns = exclude_patterns or [] + + url = f"{GITHUB_API_URL}/repos/{owner}/{repo}/git/trees/{branch}?recursive=1" + response = await self.http_client.get(url) + response.raise_for_status() + data = response.json() + + files = [] + for item in data.get("tree", []): + if item["type"] != "blob": + continue + + file_path = item["path"] + + # Check exclude patterns + excluded = any(fnmatch(file_path, pattern) for pattern in exclude_patterns) + if excluded: + continue + + # Check include patterns + matched = any(fnmatch(file_path, pattern) for pattern in patterns) + if not matched: + continue + + # Skip large files + if item.get("size", 0) > MAX_FILE_SIZE: + logger.warning(f"Skipping large file: {file_path} ({item['size']} bytes)") + continue + + files.append({ + "path": file_path, + "sha": item["sha"], + "size": item.get("size", 0), + "url": item.get("url", ""), + }) + + return files + + async def get_file_content(self, owner: str, repo: str, path: str, branch: str = "main") -> str: + """Get the content of a file from a repository.""" + if not self.http_client: + raise RuntimeError("Crawler not initialized. Use 'async with' context.") + + # Use raw content URL for simplicity + url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{path}" + response = await self.http_client.get(url) + response.raise_for_status() + return response.text + + async def crawl_repository( + self, + source: SourceConfig, + ) -> AsyncGenerator[ExtractedDocument, None]: + """Crawl a repository and yield extracted documents.""" + if not source.repo_url: + logger.warning(f"No repo URL for source: {source.name}") + return + + try: + owner, repo, host = self._parse_repo_url(source.repo_url) + except ValueError as e: + logger.error(f"Failed to parse repo URL for {source.name}: {e}") + return + + if host == "gitlab": + logger.info(f"GitLab repos not yet supported: {source.name}") + return + + logger.info(f"Crawling repository: {owner}/{repo}") + + try: + # Get default branch and latest commit + branch = await self.get_default_branch(owner, repo) + commit_sha = await self.get_latest_commit(owner, repo, branch) + + await asyncio.sleep(RATE_LIMIT_DELAY) + + # List files matching patterns + files = await self.list_files( + owner, repo, + branch=branch, + patterns=source.file_patterns, + exclude_patterns=source.exclude_patterns, + ) + + logger.info(f"Found {len(files)} matching files in {source.name}") + + for file_info in files: + await asyncio.sleep(RATE_LIMIT_DELAY) + + try: + content = await self.get_file_content( + owner, repo, file_info["path"], branch + ) + + # Parse based on file type + file_path = file_info["path"] + source_url = f"https://github.com/{owner}/{repo}/blob/{branch}/{file_path}" + + if file_path.endswith('.md'): + doc = MarkdownParser.parse(content, file_path) + doc.source_url = source_url + doc.source_commit = commit_sha + yield doc + + elif file_path.endswith('.html') or file_path.endswith('.htm'): + doc = HTMLParser.parse(content, file_path) + doc.source_url = source_url + doc.source_commit = commit_sha + yield doc + + elif file_path.endswith('.json'): + docs = JSONParser.parse(content, file_path) + for doc in docs: + doc.source_url = source_url + doc.source_commit = commit_sha + yield doc + + elif file_path.endswith('.txt'): + # Plain text file + yield ExtractedDocument( + text=content, + title=Path(file_path).stem, + file_path=file_path, + file_type="text", + source_url=source_url, + source_commit=commit_sha, + language=MarkdownParser._detect_language(content), + placeholders=MarkdownParser._find_placeholders(content), + ) + + except httpx.HTTPError as e: + logger.warning(f"Failed to fetch {file_path}: {e}") + continue + except Exception as e: + logger.error(f"Error processing {file_path}: {e}") + continue + + except httpx.HTTPError as e: + logger.error(f"HTTP error crawling {source.name}: {e}") + except Exception as e: + logger.error(f"Error crawling {source.name}: {e}") + + +class RepositoryDownloader: + """Download and extract repository archives.""" + + def __init__(self): + self.http_client: Optional[httpx.AsyncClient] = None + + async def __aenter__(self): + self.http_client = httpx.AsyncClient( + timeout=120.0, + follow_redirects=True, + ) + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if self.http_client: + await self.http_client.aclose() + + async def download_zip(self, repo_url: str, branch: str = "main") -> Path: + """Download repository as ZIP and extract to temp directory.""" + if not self.http_client: + raise RuntimeError("Downloader not initialized. Use 'async with' context.") + + parsed = urlparse(repo_url) + path_parts = parsed.path.strip('/').split('/') + owner = path_parts[0] + repo = path_parts[1].replace('.git', '') + + zip_url = f"https://github.com/{owner}/{repo}/archive/refs/heads/{branch}.zip" + + logger.info(f"Downloading ZIP from {zip_url}") + + response = await self.http_client.get(zip_url) + response.raise_for_status() + + # Save to temp file + temp_dir = Path(tempfile.mkdtemp()) + zip_path = temp_dir / f"{repo}.zip" + + with open(zip_path, 'wb') as f: + f.write(response.content) + + # Extract ZIP + extract_dir = temp_dir / repo + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + zip_ref.extractall(temp_dir) + + # The extracted directory is usually named repo-branch + extracted_dirs = list(temp_dir.glob(f"{repo}-*")) + if extracted_dirs: + return extracted_dirs[0] + + return extract_dir + + async def crawl_local_directory( + self, + directory: Path, + source: SourceConfig, + base_url: str, + ) -> AsyncGenerator[ExtractedDocument, None]: + """Crawl a local directory for documents.""" + patterns = source.file_patterns or ["*.md", "*.txt", "*.html"] + exclude_patterns = source.exclude_patterns or [] + + for pattern in patterns: + for file_path in directory.rglob(pattern.replace("**/", "")): + if not file_path.is_file(): + continue + + rel_path = str(file_path.relative_to(directory)) + + # Check exclude patterns + excluded = any(fnmatch(rel_path, ep) for ep in exclude_patterns) + if excluded: + continue + + # Skip large files + if file_path.stat().st_size > MAX_FILE_SIZE: + continue + + try: + content = file_path.read_text(encoding='utf-8') + except UnicodeDecodeError: + try: + content = file_path.read_text(encoding='latin-1') + except Exception: + continue + + source_url = f"{base_url}/{rel_path}" + + if file_path.suffix == '.md': + doc = MarkdownParser.parse(content, rel_path) + doc.source_url = source_url + yield doc + + elif file_path.suffix in ['.html', '.htm']: + doc = HTMLParser.parse(content, rel_path) + doc.source_url = source_url + yield doc + + elif file_path.suffix == '.json': + docs = JSONParser.parse(content, rel_path) + for doc in docs: + doc.source_url = source_url + yield doc + + elif file_path.suffix == '.txt': + yield ExtractedDocument( + text=content, + title=file_path.stem, + file_path=rel_path, + file_type="text", + source_url=source_url, + language=MarkdownParser._detect_language(content), + placeholders=MarkdownParser._find_placeholders(content), + ) + + def cleanup(self, directory: Path): + """Clean up temporary directory.""" + if directory.exists(): + shutil.rmtree(directory, ignore_errors=True) + + +async def crawl_source(source: SourceConfig) -> List[ExtractedDocument]: + """Crawl a source configuration and return all extracted documents.""" + documents = [] + + if source.repo_url: + async with GitHubCrawler() as crawler: + async for doc in crawler.crawl_repository(source): + documents.append(doc) + + return documents + + +# CLI for testing +async def main(): + """Test crawler with a sample source.""" + from template_sources import TEMPLATE_SOURCES + + # Test with github-site-policy + source = next(s for s in TEMPLATE_SOURCES if s.name == "github-site-policy") + + async with GitHubCrawler() as crawler: + count = 0 + async for doc in crawler.crawl_repository(source): + count += 1 + print(f"\n{'='*60}") + print(f"Title: {doc.title}") + print(f"Path: {doc.file_path}") + print(f"Type: {doc.file_type}") + print(f"Language: {doc.language}") + print(f"URL: {doc.source_url}") + print(f"Placeholders: {doc.placeholders[:5] if doc.placeholders else 'None'}") + print(f"Text preview: {doc.text[:200]}...") + + print(f"\n\nTotal documents: {count}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/klausur-service/backend/hybrid_search.py b/klausur-service/backend/hybrid_search.py new file mode 100644 index 0000000..010f204 --- /dev/null +++ b/klausur-service/backend/hybrid_search.py @@ -0,0 +1,285 @@ +""" +Hybrid Search Module + +Combines dense (semantic) search with sparse (BM25/keyword) search +for better retrieval, especially for German compound words. + +Why Hybrid Search? +- Dense search: Great for semantic similarity ("Analyse" ≈ "Untersuchung") +- Sparse search: Great for exact matches, compound words ("Erwartungshorizont") +- Combined: Best of both worlds, 10-15% better recall + +German compound nouns like "Erwartungshorizont", "Bewertungskriterien" +may not match semantically but should match lexically. +""" + +import os +import re +from typing import List, Dict, Optional, Tuple +from collections import Counter +import math + +# Configuration +HYBRID_ENABLED = os.getenv("HYBRID_SEARCH_ENABLED", "true").lower() == "true" +DENSE_WEIGHT = float(os.getenv("HYBRID_DENSE_WEIGHT", "0.7")) # 70% dense +SPARSE_WEIGHT = float(os.getenv("HYBRID_SPARSE_WEIGHT", "0.3")) # 30% sparse + +# German stopwords for BM25 +GERMAN_STOPWORDS = { + 'der', 'die', 'das', 'den', 'dem', 'des', 'ein', 'eine', 'einer', 'eines', + 'einem', 'einen', 'und', 'oder', 'aber', 'als', 'auch', 'auf', 'aus', 'bei', + 'bis', 'durch', 'für', 'gegen', 'in', 'mit', 'nach', 'ohne', 'über', 'unter', + 'von', 'vor', 'zu', 'zum', 'zur', 'ist', 'sind', 'war', 'waren', 'wird', + 'werden', 'hat', 'haben', 'kann', 'können', 'muss', 'müssen', 'soll', 'sollen', + 'nicht', 'sich', 'es', 'er', 'sie', 'wir', 'ihr', 'man', 'was', 'wie', 'wo', + 'wenn', 'weil', 'dass', 'ob', 'so', 'sehr', 'nur', 'noch', 'schon', 'mehr', + 'also', 'dabei', 'dabei', 'sowie', 'bzw', 'etc', 'ca', 'vgl' +} + + +class BM25: + """ + BM25 (Best Matching 25) implementation for German text. + + BM25 is a ranking function used for keyword-based retrieval. + It considers term frequency, document length, and inverse document frequency. + """ + + def __init__(self, k1: float = 1.5, b: float = 0.75): + """ + Initialize BM25 with tuning parameters. + + Args: + k1: Term frequency saturation parameter (1.2-2.0 typical) + b: Document length normalization (0.75 typical) + """ + self.k1 = k1 + self.b = b + self.corpus = [] + self.doc_lengths = [] + self.avg_doc_length = 0 + self.doc_freqs = Counter() + self.idf = {} + self.N = 0 + + def _tokenize(self, text: str) -> List[str]: + """Tokenize German text.""" + # Lowercase and split on non-word characters + text = text.lower() + # Keep German umlauts + tokens = re.findall(r'[a-zäöüß]+', text) + # Remove stopwords and short tokens + tokens = [t for t in tokens if t not in GERMAN_STOPWORDS and len(t) > 2] + return tokens + + def fit(self, documents: List[str]): + """ + Fit BM25 on a corpus of documents. + + Args: + documents: List of document texts + """ + self.corpus = [self._tokenize(doc) for doc in documents] + self.N = len(self.corpus) + self.doc_lengths = [len(doc) for doc in self.corpus] + self.avg_doc_length = sum(self.doc_lengths) / max(self.N, 1) + + # Calculate document frequencies + self.doc_freqs = Counter() + for doc in self.corpus: + unique_terms = set(doc) + for term in unique_terms: + self.doc_freqs[term] += 1 + + # Calculate IDF + self.idf = {} + for term, df in self.doc_freqs.items(): + # IDF with smoothing + self.idf[term] = math.log((self.N - df + 0.5) / (df + 0.5) + 1) + + def score(self, query: str, doc_idx: int) -> float: + """ + Calculate BM25 score for a query against a document. + + Args: + query: Query text + doc_idx: Index of document in corpus + + Returns: + BM25 score (higher = more relevant) + """ + query_tokens = self._tokenize(query) + doc = self.corpus[doc_idx] + doc_len = self.doc_lengths[doc_idx] + + score = 0.0 + doc_term_freqs = Counter(doc) + + for term in query_tokens: + if term not in self.idf: + continue + + tf = doc_term_freqs.get(term, 0) + idf = self.idf[term] + + # BM25 formula + numerator = tf * (self.k1 + 1) + denominator = tf + self.k1 * (1 - self.b + self.b * doc_len / self.avg_doc_length) + score += idf * numerator / denominator + + return score + + def search(self, query: str, top_k: int = 10) -> List[Tuple[int, float]]: + """ + Search corpus for query. + + Args: + query: Query text + top_k: Number of results to return + + Returns: + List of (doc_idx, score) tuples, sorted by score descending + """ + scores = [(i, self.score(query, i)) for i in range(self.N)] + scores.sort(key=lambda x: x[1], reverse=True) + return scores[:top_k] + + +def normalize_scores(scores: List[float]) -> List[float]: + """Normalize scores to 0-1 range using min-max normalization.""" + if not scores: + return [] + min_score = min(scores) + max_score = max(scores) + if max_score == min_score: + return [1.0] * len(scores) + return [(s - min_score) / (max_score - min_score) for s in scores] + + +def combine_scores( + dense_results: List[Dict], + sparse_scores: List[Tuple[int, float]], + documents: List[str], + dense_weight: float = DENSE_WEIGHT, + sparse_weight: float = SPARSE_WEIGHT, +) -> List[Dict]: + """ + Combine dense and sparse search results using Reciprocal Rank Fusion (RRF). + + Args: + dense_results: Results from dense (vector) search with 'score' field + sparse_scores: BM25 scores as (idx, score) tuples + documents: Original documents (for mapping) + dense_weight: Weight for dense scores + sparse_weight: Weight for sparse scores + + Returns: + Combined results with hybrid_score field + """ + # Create document ID to result mapping + result_map = {} + + # Add dense results + for rank, result in enumerate(dense_results): + doc_id = result.get("id", str(rank)) + if doc_id not in result_map: + result_map[doc_id] = result.copy() + result_map[doc_id]["dense_score"] = result.get("score", 0) + result_map[doc_id]["dense_rank"] = rank + 1 + result_map[doc_id]["sparse_score"] = 0 + result_map[doc_id]["sparse_rank"] = len(dense_results) + 1 + + # Add sparse scores + for rank, (doc_idx, score) in enumerate(sparse_scores): + # Try to match with dense results by text similarity + if doc_idx < len(dense_results): + doc_id = dense_results[doc_idx].get("id", str(doc_idx)) + if doc_id in result_map: + result_map[doc_id]["sparse_score"] = score + result_map[doc_id]["sparse_rank"] = rank + 1 + + # Calculate hybrid scores using RRF + k = 60 # RRF constant + for doc_id, result in result_map.items(): + dense_rrf = 1 / (k + result.get("dense_rank", 1000)) + sparse_rrf = 1 / (k + result.get("sparse_rank", 1000)) + result["hybrid_score"] = (dense_weight * dense_rrf + sparse_weight * sparse_rrf) + + # Sort by hybrid score + results = list(result_map.values()) + results.sort(key=lambda x: x.get("hybrid_score", 0), reverse=True) + + return results + + +async def hybrid_search( + query: str, + documents: List[str], + dense_search_func, + top_k: int = 10, + dense_weight: float = DENSE_WEIGHT, + sparse_weight: float = SPARSE_WEIGHT, + **dense_kwargs +) -> Dict: + """ + Perform hybrid search combining dense and sparse retrieval. + + Args: + query: Search query + documents: List of document texts for BM25 + dense_search_func: Async function for dense search + top_k: Number of results to return + dense_weight: Weight for dense (semantic) scores + sparse_weight: Weight for sparse (BM25) scores + **dense_kwargs: Additional args for dense search + + Returns: + Combined results with metadata + """ + if not HYBRID_ENABLED: + # Fall back to dense-only search + results = await dense_search_func(query=query, limit=top_k, **dense_kwargs) + return { + "results": results, + "hybrid_enabled": False, + "dense_weight": 1.0, + "sparse_weight": 0.0, + } + + # Perform dense search + dense_results = await dense_search_func(query=query, limit=top_k * 2, **dense_kwargs) + + # Perform sparse (BM25) search + bm25 = BM25() + doc_texts = [r.get("text", "") for r in dense_results] + if doc_texts: + bm25.fit(doc_texts) + sparse_scores = bm25.search(query, top_k=top_k * 2) + else: + sparse_scores = [] + + # Combine results + combined = combine_scores( + dense_results=dense_results, + sparse_scores=sparse_scores, + documents=doc_texts, + dense_weight=dense_weight, + sparse_weight=sparse_weight, + ) + + return { + "results": combined[:top_k], + "hybrid_enabled": True, + "dense_weight": dense_weight, + "sparse_weight": sparse_weight, + } + + +def get_hybrid_search_info() -> dict: + """Get information about hybrid search configuration.""" + return { + "enabled": HYBRID_ENABLED, + "dense_weight": DENSE_WEIGHT, + "sparse_weight": SPARSE_WEIGHT, + "algorithm": "BM25 + Dense Vector (RRF fusion)", + } diff --git a/klausur-service/backend/hybrid_vocab_extractor.py b/klausur-service/backend/hybrid_vocab_extractor.py new file mode 100644 index 0000000..134379d --- /dev/null +++ b/klausur-service/backend/hybrid_vocab_extractor.py @@ -0,0 +1,664 @@ +""" +Hybrid OCR + LLM Vocabulary Extractor + +Zweistufiger Ansatz fuer optimale Vokabel-Extraktion: +1. PaddleOCR fuer schnelle, praezise Texterkennung mit Bounding-Boxes +2. qwen2.5:14b (via LLM Gateway) fuer semantische Strukturierung + +Vorteile gegenueber reinem Vision LLM: +- 4x schneller (~7-15 Sek vs 30-60 Sek pro Seite) +- Hoehere Genauigkeit bei gedrucktem Text (95-99%) +- Weniger Halluzinationen (LLM korrigiert nur, erfindet nicht) +- Position-basierte Spaltenerkennung moeglich + +DATENSCHUTZ: Alle Verarbeitung erfolgt lokal (Mac Mini). +""" + +import os +import io +import json +import logging +import re +from typing import List, Dict, Any, Optional, Tuple +from dataclasses import dataclass +import uuid + +import httpx +import numpy as np +from PIL import Image + +# OpenCV is optional - only required for actual image processing +try: + import cv2 + CV2_AVAILABLE = True +except ImportError: + cv2 = None + CV2_AVAILABLE = False + +logger = logging.getLogger(__name__) + +# Configuration - Use Ollama directly (no separate LLM Gateway) +OLLAMA_URL = os.getenv("OLLAMA_URL", "http://host.docker.internal:11434") +LLM_MODEL = os.getenv("LLM_MODEL", "qwen2.5:14b") + +# PaddleOCR - Lazy loading +_paddle_ocr = None + + +def get_paddle_ocr(): + """ + Lazy load PaddleOCR to avoid startup delay. + + PaddleOCR 3.x API (released May 2025): + - Only 'lang' parameter confirmed valid + - Removed parameters: use_gpu, device, show_log, det, rec, use_onnx + - GPU/CPU selection is automatic + """ + global _paddle_ocr + if _paddle_ocr is None: + try: + from paddleocr import PaddleOCR + import logging as std_logging + + # Suppress verbose logging from PaddleOCR and PaddlePaddle + for logger_name in ['ppocr', 'paddle', 'paddleocr', 'root']: + std_logging.getLogger(logger_name).setLevel(std_logging.WARNING) + + # PaddleOCR 3.x: Only use 'lang' parameter + # Try German first, then English, then minimal + try: + _paddle_ocr = PaddleOCR(lang="de") + logger.info("PaddleOCR 3.x initialized (lang=de)") + except Exception as e1: + logger.warning(f"PaddleOCR lang=de failed: {e1}") + try: + _paddle_ocr = PaddleOCR(lang="en") + logger.info("PaddleOCR 3.x initialized (lang=en)") + except Exception as e2: + logger.warning(f"PaddleOCR lang=en failed: {e2}") + _paddle_ocr = PaddleOCR() + logger.info("PaddleOCR 3.x initialized (defaults)") + + except Exception as e: + logger.error(f"PaddleOCR initialization failed: {e}") + _paddle_ocr = None + + return _paddle_ocr + + +@dataclass +class OCRRegion: + """Ein erkannter Textbereich mit Position.""" + text: str + confidence: float + x1: int + y1: int + x2: int + y2: int + + @property + def center_x(self) -> int: + return (self.x1 + self.x2) // 2 + + @property + def center_y(self) -> int: + return (self.y1 + self.y2) // 2 + + +# ============================================================================= +# OCR Pipeline +# ============================================================================= + +def preprocess_image(img: Image.Image) -> np.ndarray: + """ + Bildvorverarbeitung fuer bessere OCR-Ergebnisse. + + - Konvertierung zu RGB + - Optional: Kontrastverstarkung + + Raises: + ImportError: If OpenCV is not available + """ + if not CV2_AVAILABLE: + raise ImportError( + "OpenCV (cv2) is required for image preprocessing. " + "Install with: pip install opencv-python-headless" + ) + + # PIL zu numpy array + img_array = np.array(img) + + # Zu RGB konvertieren falls noetig + if len(img_array.shape) == 2: + # Graustufen zu RGB + img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB) + elif img_array.shape[2] == 4: + # RGBA zu RGB + img_array = cv2.cvtColor(img_array, cv2.COLOR_RGBA2RGB) + + return img_array + + +def run_paddle_ocr(image_bytes: bytes) -> Tuple[List[OCRRegion], str]: + """ + Fuehrt PaddleOCR auf einem Bild aus. + + PaddleOCR 3.x returns results in format: + - result = ocr.ocr(img) returns list of pages + - Each page contains list of text lines + - Each line: [bbox_points, (text, confidence)] + + Returns: + Tuple of (list of OCRRegion, raw_text) + """ + ocr = get_paddle_ocr() + if ocr is None: + logger.error("PaddleOCR not available") + return [], "" + + try: + # Bild laden und vorverarbeiten + img = Image.open(io.BytesIO(image_bytes)) + img_array = preprocess_image(img) + + # OCR ausfuehren - PaddleOCR 3.x API + # Note: cls parameter may not be supported in 3.x, try without it + try: + result = ocr.ocr(img_array) + except TypeError: + # Fallback if ocr() doesn't accept the array directly + logger.warning("Trying alternative OCR call method") + result = ocr.ocr(img_array) + + if not result: + logger.warning("PaddleOCR returned empty result") + return [], "" + + # Handle different result formats + # PaddleOCR 3.x returns list of OCRResult objects (dict-like) + if isinstance(result, dict): + # Direct dict format with 'rec_texts', 'rec_scores', 'dt_polys' + logger.info("Processing PaddleOCR 3.x dict format") + return _parse_paddleocr_v3_dict(result) + elif isinstance(result, list) and len(result) > 0: + first_item = result[0] + if first_item is None: + logger.warning("PaddleOCR returned None for first page") + return [], "" + + # PaddleOCR 3.x: list contains OCRResult objects (dict-like) + # Check if first item has 'rec_texts' key (new format) + if hasattr(first_item, 'get') or isinstance(first_item, dict): + # Try to extract dict keys for new 3.x format + item_dict = dict(first_item) if hasattr(first_item, 'items') else first_item + if 'rec_texts' in item_dict or 'texts' in item_dict: + logger.info("Processing PaddleOCR 3.x OCRResult format") + return _parse_paddleocr_v3_dict(item_dict) + + # Check if first item is a list (traditional format) + if isinstance(first_item, list): + # Check if it's the traditional line format [[bbox, (text, conf)], ...] + if len(first_item) > 0 and isinstance(first_item[0], (list, tuple)): + logger.info("Processing PaddleOCR traditional list format") + return _parse_paddleocr_list(first_item) + + # Unknown format - try to inspect + logger.warning(f"Unknown result format. Type: {type(first_item)}, Keys: {dir(first_item) if hasattr(first_item, '__dir__') else 'N/A'}") + # Try dict conversion as last resort + try: + item_dict = dict(first_item) + if 'rec_texts' in item_dict: + return _parse_paddleocr_v3_dict(item_dict) + except Exception as e: + logger.warning(f"Could not convert to dict: {e}") + return [], "" + else: + logger.warning(f"Unexpected PaddleOCR result type: {type(result)}") + return [], "" + + except Exception as e: + logger.error(f"PaddleOCR execution failed: {e}") + import traceback + logger.error(traceback.format_exc()) + return [], "" + + +def _parse_paddleocr_v3_dict(result: dict) -> Tuple[List[OCRRegion], str]: + """Parse PaddleOCR 3.x dict format result.""" + regions = [] + all_text_lines = [] + + texts = result.get('rec_texts', result.get('texts', [])) + scores = result.get('rec_scores', result.get('scores', [])) + polys = result.get('dt_polys', result.get('boxes', [])) + # Also try rec_boxes which gives direct [x1, y1, x2, y2] format + rec_boxes = result.get('rec_boxes', []) + + logger.info(f"PaddleOCR 3.x: {len(texts)} texts, {len(scores)} scores, {len(polys)} polys, {len(rec_boxes)} rec_boxes") + + for i, (text, score) in enumerate(zip(texts, scores)): + if not text or not str(text).strip(): + continue + + # Try to get bounding box - prefer rec_boxes if available + x1, y1, x2, y2 = 0, 0, 100, 50 # Default fallback + + if i < len(rec_boxes) and rec_boxes[i] is not None: + # rec_boxes format: [x1, y1, x2, y2] or [[x1, y1, x2, y2]] + box = rec_boxes[i] + try: + if hasattr(box, 'flatten'): + box = box.flatten().tolist() + if len(box) >= 4: + x1, y1, x2, y2 = int(box[0]), int(box[1]), int(box[2]), int(box[3]) + except Exception as e: + logger.debug(f"Could not parse rec_box: {e}") + + elif i < len(polys) and polys[i] is not None: + # dt_polys format: [[x1,y1], [x2,y2], [x3,y3], [x4,y4]] or numpy array + poly = polys[i] + try: + # Convert numpy array to list if needed + if hasattr(poly, 'tolist'): + poly = poly.tolist() + if len(poly) >= 4: + x_coords = [p[0] for p in poly] + y_coords = [p[1] for p in poly] + x1, y1 = int(min(x_coords)), int(min(y_coords)) + x2, y2 = int(max(x_coords)), int(max(y_coords)) + except Exception as e: + logger.debug(f"Could not parse polygon: {e}") + + region = OCRRegion( + text=text.strip(), + confidence=float(score) if score else 0.5, + x1=x1, y1=y1, x2=x2, y2=y2 + ) + regions.append(region) + all_text_lines.append(text.strip()) + + regions.sort(key=lambda r: r.y1) + raw_text = "\n".join(all_text_lines) + logger.info(f"PaddleOCR 3.x extracted {len(regions)} text regions") + return regions, raw_text + + +def _parse_paddleocr_list(page_result: list) -> Tuple[List[OCRRegion], str]: + """Parse PaddleOCR traditional list format result.""" + regions = [] + all_text_lines = [] + + for line in page_result: + if not line or len(line) < 2: + continue + + bbox_points = line[0] # [[x1,y1], [x2,y2], [x3,y3], [x4,y4]] + text_info = line[1] + + # Handle different text_info formats + if isinstance(text_info, tuple) and len(text_info) >= 2: + text, confidence = text_info[0], text_info[1] + elif isinstance(text_info, str): + text, confidence = text_info, 0.5 + else: + continue + + if not text or not text.strip(): + continue + + # Bounding Box extrahieren + x_coords = [p[0] for p in bbox_points] + y_coords = [p[1] for p in bbox_points] + + region = OCRRegion( + text=text.strip(), + confidence=float(confidence), + x1=int(min(x_coords)), + y1=int(min(y_coords)), + x2=int(max(x_coords)), + y2=int(max(y_coords)) + ) + regions.append(region) + all_text_lines.append(text.strip()) + + # Regionen nach Y-Position sortieren (oben nach unten) + regions.sort(key=lambda r: r.y1) + raw_text = "\n".join(all_text_lines) + logger.info(f"PaddleOCR extracted {len(regions)} text regions") + + return regions, raw_text + + +def group_regions_by_rows(regions: List[OCRRegion], y_tolerance: int = 20) -> List[List[OCRRegion]]: + """ + Gruppiert Textregionen in Zeilen basierend auf Y-Position. + + Args: + regions: Liste von OCRRegion + y_tolerance: Max Y-Differenz um zur gleichen Zeile zu gehoeren + + Returns: + Liste von Zeilen, jede Zeile ist eine Liste von OCRRegion sortiert nach X + """ + if not regions: + return [] + + rows = [] + current_row = [regions[0]] + current_y = regions[0].center_y + + for region in regions[1:]: + if abs(region.center_y - current_y) <= y_tolerance: + # Gleiche Zeile + current_row.append(region) + else: + # Neue Zeile + # Sortiere aktuelle Zeile nach X + current_row.sort(key=lambda r: r.x1) + rows.append(current_row) + current_row = [region] + current_y = region.center_y + + # Letzte Zeile nicht vergessen + if current_row: + current_row.sort(key=lambda r: r.x1) + rows.append(current_row) + + return rows + + +def detect_columns(rows: List[List[OCRRegion]]) -> int: + """ + Erkennt die Anzahl der Spalten basierend auf den Textpositionen. + + Returns: + Geschaetzte Spaltenanzahl (2 oder 3 fuer Vokabellisten) + """ + if not rows: + return 2 + + # Zaehle wie viele Elemente pro Zeile + items_per_row = [len(row) for row in rows if len(row) >= 2] + + if not items_per_row: + return 2 + + # Durchschnitt und haeufigster Wert + avg_items = sum(items_per_row) / len(items_per_row) + + if avg_items >= 2.5: + return 3 # 3 Spalten: Englisch | Deutsch | Beispiel + else: + return 2 # 2 Spalten: Englisch | Deutsch + + +def format_ocr_for_llm(regions: List[OCRRegion]) -> str: + """ + Formatiert OCR-Output fuer LLM-Verarbeitung. + Inkludiert Positionsinformationen fuer bessere Strukturerkennung. + """ + rows = group_regions_by_rows(regions) + num_columns = detect_columns(rows) + + lines = [] + lines.append(f"Erkannte Spalten: {num_columns}") + lines.append("---") + + for row in rows: + if len(row) >= 2: + # Tab-separierte Werte fuer LLM + row_text = "\t".join(r.text for r in row) + lines.append(row_text) + elif len(row) == 1: + lines.append(row[0].text) + + return "\n".join(lines) + + +# ============================================================================= +# LLM Strukturierung +# ============================================================================= + +STRUCTURE_PROMPT = """Du erhältst OCR-Output einer Vokabelliste aus einem englischen Schulbuch. +Die Zeilen sind Tab-separiert und enthalten typischerweise: +- 2 Spalten: Englisch | Deutsch +- 3 Spalten: Englisch | Deutsch | Beispielsatz + +OCR-Text: +{ocr_text} + +AUFGABE: Strukturiere die Vokabeln als JSON-Array. + +AUSGABE-FORMAT (nur JSON, keine Erklärungen): +{{ + "vocabulary": [ + {{"english": "to improve", "german": "verbessern", "example": "I want to improve my English."}}, + {{"english": "achievement", "german": "Leistung", "example": null}} + ] +}} + +REGELN: +1. Erkenne das Spalten-Layout aus den Tab-Trennungen +2. Korrigiere offensichtliche OCR-Fehler kontextuell (z.B. "vereessern" → "verbessern", "0" → "o") +3. Bei fehlenden Beispielsätzen: "example": null +4. Überspringe Überschriften, Seitenzahlen, Kapitelnummern +5. Behalte Wortarten bei wenn vorhanden (n, v, adj am Ende des englischen Worts) +6. Gib NUR valides JSON zurück""" + + +async def structure_vocabulary_with_llm(ocr_text: str) -> List[Dict[str, Any]]: + """ + Verwendet Ollama LLM um OCR-Text zu strukturieren. + + Args: + ocr_text: Formatierter OCR-Output + + Returns: + Liste von Vokabel-Dictionaries + """ + prompt = STRUCTURE_PROMPT.format(ocr_text=ocr_text) + + try: + async with httpx.AsyncClient(timeout=120.0) as client: + # Use Ollama's native /api/chat endpoint + response = await client.post( + f"{OLLAMA_URL}/api/chat", + json={ + "model": LLM_MODEL, + "messages": [ + {"role": "user", "content": prompt} + ], + "stream": False, + "options": { + "temperature": 0.1, + "num_predict": 4096 + } + } + ) + response.raise_for_status() + + data = response.json() + content = data.get("message", {}).get("content", "") + + logger.info(f"Ollama LLM response received: {len(content)} chars") + + # JSON parsen + return parse_llm_vocabulary_json(content) + + except httpx.TimeoutException: + logger.error("Ollama LLM request timed out") + return [] + except httpx.HTTPStatusError as e: + logger.error(f"Ollama LLM HTTP error: {e}") + return [] + except Exception as e: + logger.error(f"LLM structuring failed: {e}") + return [] + + +def parse_llm_vocabulary_json(text: str) -> List[Dict[str, Any]]: + """Robustes JSON-Parsing des LLM-Outputs.""" + try: + # JSON im Text finden + start = text.find('{') + end = text.rfind('}') + 1 + + if start == -1 or end == 0: + logger.warning("No JSON found in LLM response") + return [] + + json_str = text[start:end] + data = json.loads(json_str) + + vocabulary = data.get("vocabulary", []) + + # Validierung + valid_entries = [] + for entry in vocabulary: + english = entry.get("english", "").strip() + german = entry.get("german", "").strip() + + if english and german: + valid_entries.append({ + "english": english, + "german": german, + "example": entry.get("example") + }) + + return valid_entries + + except json.JSONDecodeError as e: + logger.error(f"JSON parse error: {e}") + # Fallback: Regex extraction + return extract_vocabulary_regex(text) + except Exception as e: + logger.error(f"Vocabulary parsing failed: {e}") + return [] + + +def extract_vocabulary_regex(text: str) -> List[Dict[str, Any]]: + """Fallback: Vokabeln via Regex extrahieren.""" + pattern = r'"english"\s*:\s*"([^"]+)"\s*,\s*"german"\s*:\s*"([^"]+)"' + matches = re.findall(pattern, text) + + vocabulary = [] + for english, german in matches: + vocabulary.append({ + "english": english.strip(), + "german": german.strip(), + "example": None + }) + + logger.info(f"Regex fallback extracted {len(vocabulary)} entries") + return vocabulary + + +# ============================================================================= +# Public API +# ============================================================================= + +async def extract_vocabulary_hybrid( + image_bytes: bytes, + page_number: int = 0 +) -> Tuple[List[Dict[str, Any]], float, str]: + """ + Hybrid-Extraktion: PaddleOCR + LLM Strukturierung. + + Args: + image_bytes: Bild als Bytes + page_number: Seitennummer (0-indexed) fuer Fehlermeldungen + + Returns: + Tuple of (vocabulary_list, confidence, error_message) + """ + try: + # Step 1: PaddleOCR + logger.info(f"Starting hybrid extraction for page {page_number + 1}") + regions, raw_text = run_paddle_ocr(image_bytes) + + if not regions: + return [], 0.0, f"Seite {page_number + 1}: Kein Text erkannt (OCR)" + + # Step 2: Formatieren fuer LLM + formatted_text = format_ocr_for_llm(regions) + logger.info(f"Formatted OCR text: {len(formatted_text)} chars") + + # Step 3: LLM Strukturierung + vocabulary = await structure_vocabulary_with_llm(formatted_text) + + if not vocabulary: + # Fallback: Versuche direkte Zeilen-Analyse + vocabulary = extract_from_rows_directly(regions) + + if not vocabulary: + return [], 0.0, f"Seite {page_number + 1}: Keine Vokabeln erkannt" + + # Durchschnittliche OCR-Confidence + avg_confidence = sum(r.confidence for r in regions) / len(regions) if regions else 0.0 + + logger.info(f"Hybrid extraction completed: {len(vocabulary)} entries, {avg_confidence:.2f} confidence") + + return vocabulary, avg_confidence, "" + + except Exception as e: + logger.error(f"Hybrid extraction failed: {e}") + import traceback + logger.error(traceback.format_exc()) + return [], 0.0, f"Seite {page_number + 1}: Fehler - {str(e)[:50]}" + + +def extract_from_rows_directly(regions: List[OCRRegion]) -> List[Dict[str, Any]]: + """ + Direkter Fallback: Extrahiere Vokabeln ohne LLM basierend auf Zeilen-Struktur. + Funktioniert nur bei klarem 2-3 Spalten-Layout. + """ + rows = group_regions_by_rows(regions) + vocabulary = [] + + for row in rows: + if len(row) >= 2: + english = row[0].text.strip() + german = row[1].text.strip() + example = row[2].text.strip() if len(row) >= 3 else None + + # Einfache Validierung + if english and german and len(english) > 1 and len(german) > 1: + vocabulary.append({ + "english": english, + "german": german, + "example": example + }) + + logger.info(f"Direct row extraction: {len(vocabulary)} entries") + return vocabulary + + +# ============================================================================= +# Test/Debug +# ============================================================================= + +async def test_hybrid_extraction(image_path: str): + """Test-Funktion fuer Entwicklung.""" + with open(image_path, "rb") as f: + image_bytes = f.read() + + vocab, confidence, error = await extract_vocabulary_hybrid(image_bytes) + + print(f"\n=== Hybrid OCR Test ===") + print(f"Confidence: {confidence:.2f}") + print(f"Error: {error or 'None'}") + print(f"Vocabulary ({len(vocab)} entries):") + for v in vocab[:10]: + print(f" - {v['english']} = {v['german']}") + + return vocab + + +if __name__ == "__main__": + import asyncio + import sys + + if len(sys.argv) > 1: + asyncio.run(test_hybrid_extraction(sys.argv[1])) + else: + print("Usage: python hybrid_vocab_extractor.py ") diff --git a/klausur-service/backend/hyde.py b/klausur-service/backend/hyde.py new file mode 100644 index 0000000..3b89ca2 --- /dev/null +++ b/klausur-service/backend/hyde.py @@ -0,0 +1,209 @@ +""" +HyDE (Hypothetical Document Embeddings) Module + +Improves RAG retrieval by generating hypothetical "ideal" documents +that would answer a query, then searching for similar real documents. + +This bridges the semantic gap between: +- Short, informal user queries ("Was ist wichtig bei Gedichtanalyse?") +- Formal, detailed Erwartungshorizonte documents + +Research shows HyDE can improve retrieval by 10-20% for queries +where there's a vocabulary mismatch between query and documents. +""" + +import os +from typing import Optional, List +import httpx + +# Configuration +# IMPORTANT: HyDE is DISABLED by default for privacy reasons! +# When enabled, user queries are sent to external LLM APIs (OpenAI/Anthropic) +# to generate hypothetical documents. This may expose search queries to third parties. +# Only enable if you have explicit user consent for data processing. +HYDE_ENABLED = os.getenv("HYDE_ENABLED", "false").lower() == "true" +HYDE_LLM_BACKEND = os.getenv("HYDE_LLM_BACKEND", "openai") # openai, anthropic, or local +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "") +ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY", "") +HYDE_MODEL = os.getenv("HYDE_MODEL", "gpt-4o-mini") # Fast, cheap model for HyDE + +# German education-specific prompt template +HYDE_PROMPT_TEMPLATE = """Du bist ein Experte für deutsche Bildungsstandards und Erwartungshorizonte. + +Gegeben ist folgende Suchanfrage eines Lehrers: +"{query}" + +Schreibe einen kurzen, fachlich korrekten Textabschnitt (2-3 Sätze), der wie ein Auszug aus einem offiziellen Erwartungshorizont für Abiturklausuren klingt und diese Anfrage beantworten würde. + +Der Text sollte: +- Formelle, präzise Sprache verwenden +- Konkrete Bewertungskriterien oder Anforderungen nennen +- Wie ein echtes Dokument aus dem Bildungsministerium klingen + +Antworte NUR mit dem Textabschnitt, ohne Einleitung oder Erklärung.""" + + +class HyDEError(Exception): + """Error during HyDE processing.""" + pass + + +async def generate_hypothetical_document( + query: str, + subject: Optional[str] = None, + niveau: Optional[str] = None, +) -> str: + """ + Generate a hypothetical document that would answer the query. + + Args: + query: The user's search query + subject: Optional subject context (e.g., "Deutsch", "Mathematik") + niveau: Optional niveau context (e.g., "eA", "gA") + + Returns: + A hypothetical document text optimized for embedding + """ + if not HYDE_ENABLED: + return query # Fall back to original query + + # Enhance prompt with context if available + context_info = "" + if subject: + context_info += f"\nFach: {subject}" + if niveau: + context_info += f"\nNiveau: {niveau}" + + prompt = HYDE_PROMPT_TEMPLATE.format(query=query) + if context_info: + prompt = prompt.replace( + "Gegeben ist folgende Suchanfrage", + f"Kontext:{context_info}\n\nGegeben ist folgende Suchanfrage" + ) + + try: + if HYDE_LLM_BACKEND == "openai": + return await _generate_openai(prompt) + elif HYDE_LLM_BACKEND == "anthropic": + return await _generate_anthropic(prompt) + else: + # No LLM available, return original query + return query + except Exception as e: + print(f"HyDE generation failed, using original query: {e}") + return query + + +async def _generate_openai(prompt: str) -> str: + """Generate using OpenAI API.""" + if not OPENAI_API_KEY: + raise HyDEError("OPENAI_API_KEY not configured for HyDE") + + async with httpx.AsyncClient() as client: + response = await client.post( + "https://api.openai.com/v1/chat/completions", + headers={ + "Authorization": f"Bearer {OPENAI_API_KEY}", + "Content-Type": "application/json" + }, + json={ + "model": HYDE_MODEL, + "messages": [ + {"role": "system", "content": "Du bist ein Experte für deutsche Bildungsstandards."}, + {"role": "user", "content": prompt} + ], + "max_tokens": 200, + "temperature": 0.7, + }, + timeout=30.0 + ) + + if response.status_code != 200: + raise HyDEError(f"OpenAI API error: {response.status_code}") + + data = response.json() + return data["choices"][0]["message"]["content"].strip() + + +async def _generate_anthropic(prompt: str) -> str: + """Generate using Anthropic API.""" + if not ANTHROPIC_API_KEY: + raise HyDEError("ANTHROPIC_API_KEY not configured for HyDE") + + async with httpx.AsyncClient() as client: + response = await client.post( + "https://api.anthropic.com/v1/messages", + headers={ + "x-api-key": ANTHROPIC_API_KEY, + "Content-Type": "application/json", + "anthropic-version": "2023-06-01" + }, + json={ + "model": "claude-3-haiku-20240307", + "max_tokens": 200, + "messages": [ + {"role": "user", "content": prompt} + ] + }, + timeout=30.0 + ) + + if response.status_code != 200: + raise HyDEError(f"Anthropic API error: {response.status_code}") + + data = response.json() + return data["content"][0]["text"].strip() + + +async def hyde_search( + query: str, + search_func, + subject: Optional[str] = None, + niveau: Optional[str] = None, + **search_kwargs +) -> dict: + """ + Perform HyDE-enhanced search. + + Args: + query: Original user query + search_func: Async function to perform the actual search + subject: Optional subject context + niveau: Optional niveau context + **search_kwargs: Additional arguments passed to search_func + + Returns: + Search results with HyDE metadata + """ + # Generate hypothetical document + hypothetical_doc = await generate_hypothetical_document(query, subject, niveau) + + # Check if HyDE was actually used + hyde_used = hypothetical_doc != query + + # Perform search with hypothetical document + results = await search_func( + query=hypothetical_doc, + **search_kwargs + ) + + return { + "results": results, + "hyde_used": hyde_used, + "original_query": query, + "hypothetical_document": hypothetical_doc if hyde_used else None, + } + + +def get_hyde_info() -> dict: + """Get information about HyDE configuration.""" + return { + "enabled": HYDE_ENABLED, + "llm_backend": HYDE_LLM_BACKEND, + "model": HYDE_MODEL, + "openai_configured": bool(OPENAI_API_KEY), + "anthropic_configured": bool(ANTHROPIC_API_KEY), + "sends_data_externally": True, # ALWAYS true when enabled - queries go to LLM APIs + "privacy_warning": "When enabled, user search queries are sent to external LLM APIs", + "default_enabled": False, # Disabled by default for privacy + } diff --git a/klausur-service/backend/legal_corpus_api.py b/klausur-service/backend/legal_corpus_api.py new file mode 100644 index 0000000..8c1730a --- /dev/null +++ b/klausur-service/backend/legal_corpus_api.py @@ -0,0 +1,790 @@ +""" +Legal Corpus API - Endpoints for RAG page in admin-v2 + +Provides endpoints for: +- GET /api/v1/admin/legal-corpus/status - Collection status with chunk counts +- GET /api/v1/admin/legal-corpus/search - Semantic search +- POST /api/v1/admin/legal-corpus/ingest - Trigger ingestion +- GET /api/v1/admin/legal-corpus/ingestion-status - Ingestion status +- POST /api/v1/admin/legal-corpus/upload - Upload document +- POST /api/v1/admin/legal-corpus/add-link - Add link for ingestion +- POST /api/v1/admin/pipeline/start - Start compliance pipeline +""" + +import os +import asyncio +import httpx +import uuid +import shutil +from datetime import datetime +from typing import Optional, List, Dict, Any +from fastapi import APIRouter, HTTPException, Query, BackgroundTasks, UploadFile, File, Form +from pydantic import BaseModel +import logging + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/admin/legal-corpus", tags=["legal-corpus"]) + +# Configuration +QDRANT_URL = os.getenv("QDRANT_URL", "http://localhost:6333") +EMBEDDING_SERVICE_URL = os.getenv("EMBEDDING_SERVICE_URL", "http://embedding-service:8087") +COLLECTION_NAME = "bp_legal_corpus" + +# All regulations for status endpoint +REGULATIONS = [ + {"code": "GDPR", "name": "DSGVO", "fullName": "Datenschutz-Grundverordnung", "type": "eu_regulation"}, + {"code": "EPRIVACY", "name": "ePrivacy-Richtlinie", "fullName": "Richtlinie 2002/58/EG", "type": "eu_directive"}, + {"code": "TDDDG", "name": "TDDDG", "fullName": "Telekommunikation-Digitale-Dienste-Datenschutz-Gesetz", "type": "de_law"}, + {"code": "SCC", "name": "Standardvertragsklauseln", "fullName": "2021/914/EU", "type": "eu_regulation"}, + {"code": "DPF", "name": "EU-US Data Privacy Framework", "fullName": "Angemessenheitsbeschluss", "type": "eu_regulation"}, + {"code": "AIACT", "name": "EU AI Act", "fullName": "Verordnung (EU) 2024/1689", "type": "eu_regulation"}, + {"code": "CRA", "name": "Cyber Resilience Act", "fullName": "Verordnung (EU) 2024/2847", "type": "eu_regulation"}, + {"code": "NIS2", "name": "NIS2-Richtlinie", "fullName": "Richtlinie (EU) 2022/2555", "type": "eu_directive"}, + {"code": "EUCSA", "name": "EU Cybersecurity Act", "fullName": "Verordnung (EU) 2019/881", "type": "eu_regulation"}, + {"code": "DATAACT", "name": "Data Act", "fullName": "Verordnung (EU) 2023/2854", "type": "eu_regulation"}, + {"code": "DGA", "name": "Data Governance Act", "fullName": "Verordnung (EU) 2022/868", "type": "eu_regulation"}, + {"code": "DSA", "name": "Digital Services Act", "fullName": "Verordnung (EU) 2022/2065", "type": "eu_regulation"}, + {"code": "EAA", "name": "European Accessibility Act", "fullName": "Richtlinie (EU) 2019/882", "type": "eu_directive"}, + {"code": "DSM", "name": "DSM-Urheberrechtsrichtlinie", "fullName": "Richtlinie (EU) 2019/790", "type": "eu_directive"}, + {"code": "PLD", "name": "Produkthaftungsrichtlinie", "fullName": "Richtlinie 85/374/EWG", "type": "eu_directive"}, + {"code": "GPSR", "name": "General Product Safety", "fullName": "Verordnung (EU) 2023/988", "type": "eu_regulation"}, + {"code": "BSI-TR-03161-1", "name": "BSI-TR Teil 1", "fullName": "BSI TR-03161 Teil 1 - Mobile Anwendungen", "type": "bsi_standard"}, + {"code": "BSI-TR-03161-2", "name": "BSI-TR Teil 2", "fullName": "BSI TR-03161 Teil 2 - Web-Anwendungen", "type": "bsi_standard"}, + {"code": "BSI-TR-03161-3", "name": "BSI-TR Teil 3", "fullName": "BSI TR-03161 Teil 3 - Hintergrundsysteme", "type": "bsi_standard"}, +] + +# Ingestion state (in-memory for now) +ingestion_state = { + "running": False, + "completed": False, + "current_regulation": None, + "processed": 0, + "total": len(REGULATIONS), + "error": None, +} + + +class SearchRequest(BaseModel): + query: str + regulations: Optional[List[str]] = None + top_k: int = 5 + + +class IngestRequest(BaseModel): + force: bool = False + regulations: Optional[List[str]] = None + + +class AddLinkRequest(BaseModel): + url: str + title: str + code: str # Regulation code (e.g. "CUSTOM-1") + document_type: str = "custom" # custom, eu_regulation, eu_directive, de_law, bsi_standard + + +class StartPipelineRequest(BaseModel): + force_reindex: bool = False + skip_ingestion: bool = False + + +# Store for custom documents (in-memory for now, should be persisted) +custom_documents: List[Dict[str, Any]] = [] + + +async def get_qdrant_client(): + """Get async HTTP client for Qdrant.""" + return httpx.AsyncClient(timeout=30.0) + + +@router.get("/status") +async def get_legal_corpus_status(): + """ + Get status of the legal corpus collection including chunk counts per regulation. + """ + async with httpx.AsyncClient(timeout=30.0) as client: + try: + # Get collection info + collection_res = await client.get(f"{QDRANT_URL}/collections/{COLLECTION_NAME}") + if collection_res.status_code != 200: + return { + "collection": COLLECTION_NAME, + "totalPoints": 0, + "vectorSize": 1024, + "status": "not_found", + "regulations": {}, + } + + collection_data = collection_res.json() + result = collection_data.get("result", {}) + + # Get chunk counts per regulation + regulation_counts = {} + for reg in REGULATIONS: + count_res = await client.post( + f"{QDRANT_URL}/collections/{COLLECTION_NAME}/points/count", + json={ + "filter": { + "must": [{"key": "regulation_code", "match": {"value": reg["code"]}}] + } + }, + ) + if count_res.status_code == 200: + count_data = count_res.json() + regulation_counts[reg["code"]] = count_data.get("result", {}).get("count", 0) + else: + regulation_counts[reg["code"]] = 0 + + return { + "collection": COLLECTION_NAME, + "totalPoints": result.get("points_count", 0), + "vectorSize": result.get("config", {}).get("params", {}).get("vectors", {}).get("size", 1024), + "status": result.get("status", "unknown"), + "regulations": regulation_counts, + } + + except httpx.RequestError as e: + logger.error(f"Failed to get Qdrant status: {e}") + raise HTTPException(status_code=503, detail=f"Qdrant not available: {str(e)}") + + +@router.get("/search") +async def search_legal_corpus( + query: str = Query(..., description="Search query"), + top_k: int = Query(5, ge=1, le=20, description="Number of results"), + regulations: Optional[str] = Query(None, description="Comma-separated regulation codes to filter"), +): + """ + Semantic search in legal corpus using BGE-M3 embeddings. + """ + async with httpx.AsyncClient(timeout=60.0) as client: + try: + # Generate embedding for query + embed_res = await client.post( + f"{EMBEDDING_SERVICE_URL}/embed", + json={"texts": [query]}, + ) + if embed_res.status_code != 200: + raise HTTPException(status_code=500, detail="Embedding service error") + + embed_data = embed_res.json() + query_vector = embed_data["embeddings"][0] + + # Build Qdrant search request + search_request = { + "vector": query_vector, + "limit": top_k, + "with_payload": True, + } + + # Add regulation filter if specified + if regulations: + reg_codes = [r.strip() for r in regulations.split(",")] + search_request["filter"] = { + "should": [ + {"key": "regulation_code", "match": {"value": code}} + for code in reg_codes + ] + } + + # Search Qdrant + search_res = await client.post( + f"{QDRANT_URL}/collections/{COLLECTION_NAME}/points/search", + json=search_request, + ) + + if search_res.status_code != 200: + raise HTTPException(status_code=500, detail="Search failed") + + search_data = search_res.json() + results = [] + for point in search_data.get("result", []): + payload = point.get("payload", {}) + results.append({ + "text": payload.get("text", ""), + "regulation_code": payload.get("regulation_code", ""), + "regulation_name": payload.get("regulation_name", ""), + "article": payload.get("article"), + "paragraph": payload.get("paragraph"), + "source_url": payload.get("source_url", ""), + "score": point.get("score", 0), + }) + + return {"results": results, "query": query, "count": len(results)} + + except httpx.RequestError as e: + logger.error(f"Search failed: {e}") + raise HTTPException(status_code=503, detail=f"Service not available: {str(e)}") + + +@router.post("/ingest") +async def trigger_ingestion(request: IngestRequest, background_tasks: BackgroundTasks): + """ + Trigger legal corpus ingestion in background. + """ + global ingestion_state + + if ingestion_state["running"]: + raise HTTPException(status_code=409, detail="Ingestion already running") + + # Reset state + ingestion_state = { + "running": True, + "completed": False, + "current_regulation": None, + "processed": 0, + "total": len(REGULATIONS), + "error": None, + } + + # Start ingestion in background + background_tasks.add_task(run_ingestion, request.force, request.regulations) + + return { + "status": "started", + "job_id": "manual-trigger", + "message": f"Ingestion started for {len(REGULATIONS)} regulations", + } + + +async def run_ingestion(force: bool, regulations: Optional[List[str]]): + """Background task for running ingestion.""" + global ingestion_state + + try: + # Import ingestion module + from legal_corpus_ingestion import LegalCorpusIngestion + + ingestion = LegalCorpusIngestion() + + # Filter regulations if specified + regs_to_process = regulations or [r["code"] for r in REGULATIONS] + + for i, reg_code in enumerate(regs_to_process): + ingestion_state["current_regulation"] = reg_code + ingestion_state["processed"] = i + + try: + await ingestion.ingest_single(reg_code, force=force) + except Exception as e: + logger.error(f"Failed to ingest {reg_code}: {e}") + + ingestion_state["completed"] = True + ingestion_state["processed"] = len(regs_to_process) + + except Exception as e: + logger.error(f"Ingestion failed: {e}") + ingestion_state["error"] = str(e) + + finally: + ingestion_state["running"] = False + + +@router.get("/ingestion-status") +async def get_ingestion_status(): + """ + Get current ingestion status. + """ + return ingestion_state + + +@router.get("/regulations") +async def get_regulations(): + """ + Get list of all supported regulations. + """ + return {"regulations": REGULATIONS} + + +@router.get("/custom-documents") +async def get_custom_documents(): + """ + Get list of custom documents added by user. + """ + return {"documents": custom_documents} + + +@router.post("/upload") +async def upload_document( + background_tasks: BackgroundTasks, + file: UploadFile = File(...), + title: str = Form(...), + code: str = Form(...), + document_type: str = Form("custom"), +): + """ + Upload a document (PDF) for ingestion into the legal corpus. + + The document will be saved and queued for processing. + """ + global custom_documents + + # Validate file type + if not file.filename.endswith(('.pdf', '.PDF')): + raise HTTPException(status_code=400, detail="Only PDF files are supported") + + # Create upload directory if needed + upload_dir = "/tmp/legal_corpus_uploads" + os.makedirs(upload_dir, exist_ok=True) + + # Save file with unique name + doc_id = str(uuid.uuid4())[:8] + safe_filename = f"{doc_id}_{file.filename}" + file_path = os.path.join(upload_dir, safe_filename) + + try: + with open(file_path, "wb") as buffer: + shutil.copyfileobj(file.file, buffer) + except Exception as e: + logger.error(f"Failed to save uploaded file: {e}") + raise HTTPException(status_code=500, detail=f"Failed to save file: {str(e)}") + + # Create document record + doc_record = { + "id": doc_id, + "code": code, + "title": title, + "filename": file.filename, + "file_path": file_path, + "document_type": document_type, + "uploaded_at": datetime.now().isoformat(), + "status": "uploaded", + "chunk_count": 0, + } + + custom_documents.append(doc_record) + + # Queue for background ingestion + background_tasks.add_task(ingest_uploaded_document, doc_record) + + return { + "status": "uploaded", + "document_id": doc_id, + "message": f"Document '{title}' uploaded and queued for ingestion", + "document": doc_record, + } + + +async def ingest_uploaded_document(doc_record: Dict[str, Any]): + """Background task to ingest an uploaded document.""" + global custom_documents + + try: + doc_record["status"] = "processing" + + from legal_corpus_ingestion import LegalCorpusIngestion + ingestion = LegalCorpusIngestion() + + # Read PDF and extract text + import fitz # PyMuPDF + + doc = fitz.open(doc_record["file_path"]) + full_text = "" + for page in doc: + full_text += page.get_text() + doc.close() + + if not full_text.strip(): + doc_record["status"] = "error" + doc_record["error"] = "No text could be extracted from PDF" + return + + # Chunk the text + chunks = ingestion.chunk_text(full_text, doc_record["code"]) + + # Add metadata + for chunk in chunks: + chunk["regulation_code"] = doc_record["code"] + chunk["regulation_name"] = doc_record["title"] + chunk["document_type"] = doc_record["document_type"] + chunk["source_url"] = f"upload://{doc_record['filename']}" + + # Generate embeddings and upsert to Qdrant + if chunks: + await ingestion.embed_and_upsert(chunks) + doc_record["chunk_count"] = len(chunks) + doc_record["status"] = "indexed" + logger.info(f"Ingested {len(chunks)} chunks from uploaded document {doc_record['code']}") + else: + doc_record["status"] = "error" + doc_record["error"] = "No chunks generated from document" + + except Exception as e: + logger.error(f"Failed to ingest uploaded document: {e}") + doc_record["status"] = "error" + doc_record["error"] = str(e) + + +@router.post("/add-link") +async def add_link(request: AddLinkRequest, background_tasks: BackgroundTasks): + """ + Add a URL/link for ingestion into the legal corpus. + + The content will be fetched, extracted, and indexed. + """ + global custom_documents + + # Create document record + doc_id = str(uuid.uuid4())[:8] + doc_record = { + "id": doc_id, + "code": request.code, + "title": request.title, + "url": request.url, + "document_type": request.document_type, + "uploaded_at": datetime.now().isoformat(), + "status": "queued", + "chunk_count": 0, + } + + custom_documents.append(doc_record) + + # Queue for background ingestion + background_tasks.add_task(ingest_link_document, doc_record) + + return { + "status": "queued", + "document_id": doc_id, + "message": f"Link '{request.title}' queued for ingestion", + "document": doc_record, + } + + +async def ingest_link_document(doc_record: Dict[str, Any]): + """Background task to ingest content from a URL.""" + global custom_documents + + try: + doc_record["status"] = "fetching" + + async with httpx.AsyncClient(timeout=60.0) as client: + # Fetch the URL + response = await client.get(doc_record["url"], follow_redirects=True) + response.raise_for_status() + + content_type = response.headers.get("content-type", "") + + if "application/pdf" in content_type: + # Save PDF and process + import tempfile + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as f: + f.write(response.content) + pdf_path = f.name + + import fitz + pdf_doc = fitz.open(pdf_path) + full_text = "" + for page in pdf_doc: + full_text += page.get_text() + pdf_doc.close() + os.unlink(pdf_path) + + elif "text/html" in content_type: + # Extract text from HTML + from bs4 import BeautifulSoup + soup = BeautifulSoup(response.text, "html.parser") + + # Remove script and style elements + for script in soup(["script", "style", "nav", "footer", "header"]): + script.decompose() + + full_text = soup.get_text(separator="\n", strip=True) + + else: + # Try to use as plain text + full_text = response.text + + if not full_text.strip(): + doc_record["status"] = "error" + doc_record["error"] = "No text could be extracted from URL" + return + + doc_record["status"] = "processing" + + from legal_corpus_ingestion import LegalCorpusIngestion + ingestion = LegalCorpusIngestion() + + # Chunk the text + chunks = ingestion.chunk_text(full_text, doc_record["code"]) + + # Add metadata + for chunk in chunks: + chunk["regulation_code"] = doc_record["code"] + chunk["regulation_name"] = doc_record["title"] + chunk["document_type"] = doc_record["document_type"] + chunk["source_url"] = doc_record["url"] + + # Generate embeddings and upsert to Qdrant + if chunks: + await ingestion.embed_and_upsert(chunks) + doc_record["chunk_count"] = len(chunks) + doc_record["status"] = "indexed" + logger.info(f"Ingested {len(chunks)} chunks from URL {doc_record['url']}") + else: + doc_record["status"] = "error" + doc_record["error"] = "No chunks generated from content" + + except httpx.HTTPError as e: + logger.error(f"Failed to fetch URL: {e}") + doc_record["status"] = "error" + doc_record["error"] = f"Failed to fetch URL: {str(e)}" + except Exception as e: + logger.error(f"Failed to ingest URL content: {e}") + doc_record["status"] = "error" + doc_record["error"] = str(e) + + +@router.delete("/custom-documents/{doc_id}") +async def delete_custom_document(doc_id: str): + """ + Delete a custom document from the list. + Note: This does not remove the chunks from Qdrant yet. + """ + global custom_documents + + doc = next((d for d in custom_documents if d["id"] == doc_id), None) + if not doc: + raise HTTPException(status_code=404, detail="Document not found") + + custom_documents = [d for d in custom_documents if d["id"] != doc_id] + + # TODO: Also remove chunks from Qdrant by filtering on code + + return {"status": "deleted", "document_id": doc_id} + + +# ========== Pipeline Checkpoints ========== + +# Create a separate router for pipeline-related endpoints +pipeline_router = APIRouter(prefix="/api/v1/admin/pipeline", tags=["pipeline"]) + + +@pipeline_router.get("/checkpoints") +async def get_pipeline_checkpoints(): + """ + Get current pipeline checkpoint state. + + Returns the current state of the compliance pipeline including: + - Pipeline ID and overall status + - Start and completion times + - All checkpoints with their validations and metrics + - Summary data + """ + from pipeline_checkpoints import CheckpointManager + + state = CheckpointManager.load_state() + + if state is None: + return { + "status": "no_data", + "message": "No pipeline run data available yet.", + "pipeline_id": None, + "checkpoints": [], + "summary": {} + } + + # Enrich with validation summary + validation_summary = { + "passed": 0, + "warning": 0, + "failed": 0, + "total": 0 + } + + for checkpoint in state.get("checkpoints", []): + for validation in checkpoint.get("validations", []): + validation_summary["total"] += 1 + status = validation.get("status", "not_run") + if status in validation_summary: + validation_summary[status] += 1 + + state["validation_summary"] = validation_summary + + return state + + +@pipeline_router.get("/checkpoints/history") +async def get_pipeline_history(): + """ + Get list of previous pipeline runs (if stored). + For now, returns only current run. + """ + from pipeline_checkpoints import CheckpointManager + + state = CheckpointManager.load_state() + + if state is None: + return {"runs": []} + + return { + "runs": [{ + "pipeline_id": state.get("pipeline_id"), + "status": state.get("status"), + "started_at": state.get("started_at"), + "completed_at": state.get("completed_at"), + }] + } + + +# Pipeline state for start/stop +pipeline_process_state = { + "running": False, + "pid": None, + "started_at": None, +} + + +@pipeline_router.post("/start") +async def start_pipeline(request: StartPipelineRequest, background_tasks: BackgroundTasks): + """ + Start the compliance pipeline in the background. + + This runs the full_compliance_pipeline.py script which: + 1. Ingests all legal documents (unless skip_ingestion=True) + 2. Extracts requirements and controls + 3. Generates compliance measures + 4. Creates checkpoint data for monitoring + """ + global pipeline_process_state + + # Check if already running + from pipeline_checkpoints import CheckpointManager + state = CheckpointManager.load_state() + + if state and state.get("status") == "running": + raise HTTPException( + status_code=409, + detail="Pipeline is already running" + ) + + if pipeline_process_state["running"]: + raise HTTPException( + status_code=409, + detail="Pipeline start already in progress" + ) + + pipeline_process_state["running"] = True + pipeline_process_state["started_at"] = datetime.now().isoformat() + + # Start pipeline in background + background_tasks.add_task( + run_pipeline_background, + request.force_reindex, + request.skip_ingestion + ) + + return { + "status": "starting", + "message": "Compliance pipeline is starting in background", + "started_at": pipeline_process_state["started_at"], + } + + +async def run_pipeline_background(force_reindex: bool, skip_ingestion: bool): + """Background task to run the compliance pipeline.""" + global pipeline_process_state + + try: + import subprocess + import sys + + # Build command + cmd = [sys.executable, "full_compliance_pipeline.py"] + if force_reindex: + cmd.append("--force-reindex") + if skip_ingestion: + cmd.append("--skip-ingestion") + + # Run as subprocess + logger.info(f"Starting pipeline: {' '.join(cmd)}") + + process = subprocess.Popen( + cmd, + cwd=os.path.dirname(os.path.abspath(__file__)), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + + pipeline_process_state["pid"] = process.pid + + # Wait for completion (non-blocking via asyncio) + import asyncio + while process.poll() is None: + await asyncio.sleep(5) + + return_code = process.returncode + + if return_code != 0: + output = process.stdout.read() if process.stdout else "" + logger.error(f"Pipeline failed with code {return_code}: {output}") + else: + logger.info("Pipeline completed successfully") + + except Exception as e: + logger.error(f"Failed to run pipeline: {e}") + + finally: + pipeline_process_state["running"] = False + pipeline_process_state["pid"] = None + + +@pipeline_router.get("/status") +async def get_pipeline_status(): + """ + Get current pipeline running status. + """ + from pipeline_checkpoints import CheckpointManager + + state = CheckpointManager.load_state() + checkpoint_status = state.get("status") if state else "no_data" + + return { + "process_running": pipeline_process_state["running"], + "process_pid": pipeline_process_state["pid"], + "process_started_at": pipeline_process_state["started_at"], + "checkpoint_status": checkpoint_status, + "current_phase": state.get("current_phase") if state else None, + } + + +# ========== Traceability / Quality Endpoints ========== + +@router.get("/traceability") +async def get_traceability( + chunk_id: str = Query(..., description="Chunk ID or identifier"), + regulation: str = Query(..., description="Regulation code"), +): + """ + Get traceability information for a specific chunk. + + Returns: + - The chunk details + - Requirements extracted from this chunk + - Controls derived from those requirements + + Note: This is a placeholder that will be enhanced once the + requirements extraction pipeline is fully implemented. + """ + async with httpx.AsyncClient(timeout=30.0) as client: + try: + # Try to find the chunk by scrolling through points with the regulation filter + # In a production system, we would have proper IDs and indexing + + # For now, return placeholder structure + # The actual implementation will query: + # 1. The chunk from Qdrant + # 2. Requirements from a requirements collection/table + # 3. Controls from a controls collection/table + + return { + "chunk_id": chunk_id, + "regulation": regulation, + "requirements": [], + "controls": [], + "message": "Traceability-Daten werden verfuegbar sein, sobald die Requirements-Extraktion und Control-Ableitung implementiert sind." + } + + except Exception as e: + logger.error(f"Failed to get traceability: {e}") + raise HTTPException(status_code=500, detail=f"Traceability lookup failed: {str(e)}") diff --git a/klausur-service/backend/legal_corpus_ingestion.py b/klausur-service/backend/legal_corpus_ingestion.py new file mode 100644 index 0000000..2e29d39 --- /dev/null +++ b/klausur-service/backend/legal_corpus_ingestion.py @@ -0,0 +1,937 @@ +""" +Legal Corpus Ingestion for UCCA RAG Integration. + +Indexes all 19 regulations from the Compliance Hub into Qdrant for +semantic search during UCCA assessments and explanations. + +Collections: +- bp_legal_corpus: All regulation texts (GDPR, AI Act, CRA, BSI, etc.) + +Usage: + python legal_corpus_ingestion.py --ingest-all + python legal_corpus_ingestion.py --ingest GDPR AIACT + python legal_corpus_ingestion.py --status +""" + +import asyncio +import hashlib +import json +import logging +import os +import re +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Tuple +from urllib.parse import urlparse + +import httpx +from qdrant_client import QdrantClient +from qdrant_client.models import ( + Distance, + FieldCondition, + Filter, + MatchValue, + PointStruct, + VectorParams, +) + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Configuration - Support both QDRANT_URL and QDRANT_HOST/PORT +_qdrant_url = os.getenv("QDRANT_URL", "") +if _qdrant_url: + # Parse URL: http://qdrant:6333 -> host=qdrant, port=6333 + from urllib.parse import urlparse + _parsed = urlparse(_qdrant_url) + QDRANT_HOST = _parsed.hostname or "localhost" + QDRANT_PORT = _parsed.port or 6333 +else: + QDRANT_HOST = os.getenv("QDRANT_HOST", "localhost") + QDRANT_PORT = int(os.getenv("QDRANT_PORT", "6333")) +EMBEDDING_SERVICE_URL = os.getenv("EMBEDDING_SERVICE_URL", "http://localhost:8087") +LEGAL_CORPUS_COLLECTION = "bp_legal_corpus" +VECTOR_SIZE = 1024 # BGE-M3 dimension + +# Chunking configuration - matched to NIBIS settings for semantic chunking +CHUNK_SIZE = int(os.getenv("LEGAL_CHUNK_SIZE", "1000")) +CHUNK_OVERLAP = int(os.getenv("LEGAL_CHUNK_OVERLAP", "200")) + +# Base path for local PDF/HTML files +# In Docker: /app/docs/legal_corpus (mounted volume) +# Local dev: relative to script location +_default_docs_path = Path(__file__).parent.parent / "docs" / "legal_corpus" +LEGAL_DOCS_PATH = Path(os.getenv("LEGAL_DOCS_PATH", str(_default_docs_path))) +# Docker-specific override: if /app/docs exists, use it +if Path("/app/docs/legal_corpus").exists(): + LEGAL_DOCS_PATH = Path("/app/docs/legal_corpus") + + +@dataclass +class Regulation: + """Regulation metadata.""" + code: str + name: str + full_name: str + regulation_type: str + source_url: str + description: str + celex: Optional[str] = None # CELEX number for EUR-Lex direct access + local_path: Optional[str] = None + language: str = "de" + requirement_count: int = 0 + + +# All 19 regulations from Compliance Hub +REGULATIONS: List[Regulation] = [ + Regulation( + code="GDPR", + name="DSGVO", + full_name="Verordnung (EU) 2016/679 - Datenschutz-Grundverordnung", + regulation_type="eu_regulation", + source_url="https://eur-lex.europa.eu/eli/reg/2016/679/oj/deu", + description="Grundverordnung zum Schutz natuerlicher Personen bei der Verarbeitung personenbezogener Daten.", + celex="32016R0679", + requirement_count=99, + ), + Regulation( + code="EPRIVACY", + name="ePrivacy-Richtlinie", + full_name="Richtlinie 2002/58/EG", + regulation_type="eu_directive", + source_url="https://eur-lex.europa.eu/eli/dir/2002/58/oj/deu", + description="Datenschutz in der elektronischen Kommunikation, Cookies und Tracking.", + celex="32002L0058", + requirement_count=25, + ), + Regulation( + code="TDDDG", + name="TDDDG", + full_name="Telekommunikation-Digitale-Dienste-Datenschutz-Gesetz", + regulation_type="de_law", + source_url="https://www.gesetze-im-internet.de/ttdsg/TDDDG.pdf", + description="Deutsche Umsetzung der ePrivacy-Richtlinie (30 Paragraphen).", + requirement_count=30, + ), + Regulation( + code="SCC", + name="Standardvertragsklauseln", + full_name="Durchfuehrungsbeschluss (EU) 2021/914", + regulation_type="eu_regulation", + source_url="https://eur-lex.europa.eu/eli/dec_impl/2021/914/oj/deu", + description="Standardvertragsklauseln fuer Drittlandtransfers.", + celex="32021D0914", + requirement_count=18, + ), + Regulation( + code="DPF", + name="EU-US Data Privacy Framework", + full_name="Durchfuehrungsbeschluss (EU) 2023/1795", + regulation_type="eu_regulation", + source_url="https://eur-lex.europa.eu/eli/dec_impl/2023/1795/oj", + description="Angemessenheitsbeschluss fuer USA-Transfers.", + celex="32023D1795", + requirement_count=12, + ), + Regulation( + code="AIACT", + name="EU AI Act", + full_name="Verordnung (EU) 2024/1689 - KI-Verordnung", + regulation_type="eu_regulation", + source_url="https://eur-lex.europa.eu/eli/reg/2024/1689/oj/deu", + description="EU-Verordnung zur Regulierung von KI-Systemen nach Risikostufen.", + celex="32024R1689", + requirement_count=85, + ), + Regulation( + code="CRA", + name="Cyber Resilience Act", + full_name="Verordnung (EU) 2024/2847", + regulation_type="eu_regulation", + source_url="https://eur-lex.europa.eu/eli/reg/2024/2847/oj/deu", + description="Cybersicherheitsanforderungen, SBOM-Pflicht.", + celex="32024R2847", + requirement_count=45, + ), + Regulation( + code="NIS2", + name="NIS2-Richtlinie", + full_name="Richtlinie (EU) 2022/2555", + regulation_type="eu_directive", + source_url="https://eur-lex.europa.eu/eli/dir/2022/2555/oj/deu", + description="Cybersicherheit fuer wesentliche Einrichtungen.", + celex="32022L2555", + requirement_count=46, + ), + Regulation( + code="EUCSA", + name="EU Cybersecurity Act", + full_name="Verordnung (EU) 2019/881", + regulation_type="eu_regulation", + source_url="https://eur-lex.europa.eu/eli/reg/2019/881/oj/deu", + description="ENISA und Cybersicherheitszertifizierung.", + celex="32019R0881", + requirement_count=35, + ), + Regulation( + code="DATAACT", + name="Data Act", + full_name="Verordnung (EU) 2023/2854", + regulation_type="eu_regulation", + source_url="https://eur-lex.europa.eu/eli/reg/2023/2854/oj/deu", + description="Fairer Datenzugang, IoT-Daten, Cloud-Wechsel.", + celex="32023R2854", + requirement_count=42, + ), + Regulation( + code="DGA", + name="Data Governance Act", + full_name="Verordnung (EU) 2022/868", + regulation_type="eu_regulation", + source_url="https://eur-lex.europa.eu/eli/reg/2022/868/oj/deu", + description="Weiterverwendung oeffentlicher Daten.", + celex="32022R0868", + requirement_count=35, + ), + Regulation( + code="DSA", + name="Digital Services Act", + full_name="Verordnung (EU) 2022/2065", + regulation_type="eu_regulation", + source_url="https://eur-lex.europa.eu/eli/reg/2022/2065/oj/deu", + description="Digitale Dienste, Transparenzpflichten.", + celex="32022R2065", + requirement_count=93, + ), + Regulation( + code="EAA", + name="European Accessibility Act", + full_name="Richtlinie (EU) 2019/882", + regulation_type="eu_directive", + source_url="https://eur-lex.europa.eu/eli/dir/2019/882/oj/deu", + description="Barrierefreiheit digitaler Produkte.", + celex="32019L0882", + requirement_count=25, + ), + Regulation( + code="DSM", + name="DSM-Urheberrechtsrichtlinie", + full_name="Richtlinie (EU) 2019/790", + regulation_type="eu_directive", + source_url="https://eur-lex.europa.eu/eli/dir/2019/790/oj/deu", + description="Urheberrecht, Text- und Data-Mining.", + celex="32019L0790", + requirement_count=22, + ), + Regulation( + code="PLD", + name="Produkthaftungsrichtlinie", + full_name="Richtlinie (EU) 2024/2853", + regulation_type="eu_directive", + source_url="https://eur-lex.europa.eu/eli/dir/2024/2853/oj/deu", + description="Produkthaftung inkl. Software und KI.", + celex="32024L2853", + requirement_count=18, + ), + Regulation( + code="GPSR", + name="General Product Safety", + full_name="Verordnung (EU) 2023/988", + regulation_type="eu_regulation", + source_url="https://eur-lex.europa.eu/eli/reg/2023/988/oj/deu", + description="Allgemeine Produktsicherheit.", + celex="32023R0988", + requirement_count=30, + ), + Regulation( + code="BSI-TR-03161-1", + name="BSI-TR-03161 Teil 1", + full_name="BSI Technische Richtlinie - Allgemeine Anforderungen", + regulation_type="bsi_standard", + source_url="https://www.bsi.bund.de/SharedDocs/Downloads/DE/BSI/Publikationen/TechnischeRichtlinien/TR03161/BSI-TR-03161-1.pdf?__blob=publicationFile&v=6", + description="Allgemeine Sicherheitsanforderungen (45 Pruefaspekte).", + requirement_count=45, + ), + Regulation( + code="BSI-TR-03161-2", + name="BSI-TR-03161 Teil 2", + full_name="BSI Technische Richtlinie - Web-Anwendungen", + regulation_type="bsi_standard", + source_url="https://www.bsi.bund.de/SharedDocs/Downloads/DE/BSI/Publikationen/TechnischeRichtlinien/TR03161/BSI-TR-03161-2.pdf?__blob=publicationFile&v=5", + description="Web-Sicherheit (40 Pruefaspekte).", + requirement_count=40, + ), + Regulation( + code="BSI-TR-03161-3", + name="BSI-TR-03161 Teil 3", + full_name="BSI Technische Richtlinie - Hintergrundsysteme", + regulation_type="bsi_standard", + source_url="https://www.bsi.bund.de/SharedDocs/Downloads/DE/BSI/Publikationen/TechnischeRichtlinien/TR03161/BSI-TR-03161-3.pdf?__blob=publicationFile&v=5", + description="Backend-Sicherheit (35 Pruefaspekte).", + requirement_count=35, + ), + # Additional regulations for financial sector and health + Regulation( + code="DORA", + name="DORA", + full_name="Verordnung (EU) 2022/2554 - Digital Operational Resilience Act", + regulation_type="eu_regulation", + source_url="https://eur-lex.europa.eu/eli/reg/2022/2554/oj/deu", + description="Digitale operationale Resilienz fuer den Finanzsektor. IKT-Risikomanagement, Vorfallmeldung, Resilienz-Tests.", + celex="32022R2554", + requirement_count=64, + ), + Regulation( + code="PSD2", + name="PSD2", + full_name="Richtlinie (EU) 2015/2366 - Zahlungsdiensterichtlinie", + regulation_type="eu_directive", + source_url="https://eur-lex.europa.eu/eli/dir/2015/2366/oj/deu", + description="Zahlungsdienste im Binnenmarkt. Starke Kundenauthentifizierung, Open Banking APIs.", + celex="32015L2366", + requirement_count=117, + ), + Regulation( + code="AMLR", + name="AML-Verordnung", + full_name="Verordnung (EU) 2024/1624 - Geldwaeschebekaempfung", + regulation_type="eu_regulation", + source_url="https://eur-lex.europa.eu/eli/reg/2024/1624/oj/deu", + description="Verhinderung der Nutzung des Finanzsystems zur Geldwaesche und Terrorismusfinanzierung.", + celex="32024R1624", + requirement_count=89, + ), + Regulation( + code="EHDS", + name="EHDS", + full_name="Verordnung (EU) 2025/327 - Europaeischer Gesundheitsdatenraum", + regulation_type="eu_regulation", + source_url="https://eur-lex.europa.eu/eli/reg/2025/327/oj/deu", + description="Europaeischer Raum fuer Gesundheitsdaten. Primaer- und Sekundaernutzung von Gesundheitsdaten.", + celex="32025R0327", + requirement_count=95, + ), + Regulation( + code="MiCA", + name="MiCA", + full_name="Verordnung (EU) 2023/1114 - Markets in Crypto-Assets", + regulation_type="eu_regulation", + source_url="https://eur-lex.europa.eu/eli/reg/2023/1114/oj/deu", + description="Regulierung von Kryptowerten, Stablecoins und Crypto-Asset-Dienstleistern.", + celex="32023R1114", + requirement_count=149, + ), +] + + +class LegalCorpusIngestion: + """Handles ingestion of legal documents into Qdrant.""" + + def __init__(self): + self.qdrant = QdrantClient(host=QDRANT_HOST, port=QDRANT_PORT) + self.http_client = httpx.AsyncClient(timeout=60.0) + self._ensure_collection() + + def _ensure_collection(self): + """Create the legal corpus collection if it doesn't exist.""" + collections = self.qdrant.get_collections().collections + collection_names = [c.name for c in collections] + + if LEGAL_CORPUS_COLLECTION not in collection_names: + logger.info(f"Creating collection: {LEGAL_CORPUS_COLLECTION}") + self.qdrant.create_collection( + collection_name=LEGAL_CORPUS_COLLECTION, + vectors_config=VectorParams( + size=VECTOR_SIZE, + distance=Distance.COSINE, + ), + ) + logger.info(f"Collection {LEGAL_CORPUS_COLLECTION} created") + else: + logger.info(f"Collection {LEGAL_CORPUS_COLLECTION} already exists") + + async def _generate_embeddings(self, texts: List[str]) -> List[List[float]]: + """Generate embeddings via the embedding service.""" + try: + response = await self.http_client.post( + f"{EMBEDDING_SERVICE_URL}/embed", + json={"texts": texts}, + timeout=120.0, + ) + response.raise_for_status() + data = response.json() + return data["embeddings"] + except Exception as e: + logger.error(f"Embedding generation failed: {e}") + raise + + # German abbreviations that don't end sentences + GERMAN_ABBREVIATIONS = { + 'bzw', 'ca', 'chr', 'd.h', 'dr', 'etc', 'evtl', 'ggf', 'inkl', 'max', + 'min', 'mio', 'mrd', 'nr', 'prof', 's', 'sog', 'u.a', 'u.ä', 'usw', + 'v.a', 'vgl', 'vs', 'z.b', 'z.t', 'zzgl', 'abs', 'art', 'aufl', + 'bd', 'betr', 'bzgl', 'dgl', 'ebd', 'hrsg', 'jg', 'kap', 'lt', + 'rdnr', 'rn', 'std', 'str', 'tel', 'ua', 'uvm', 'va', 'zb', + 'bsi', 'tr', 'owasp', 'iso', 'iec', 'din', 'en' + } + + def _split_into_sentences(self, text: str) -> List[str]: + """Split text into sentences with German language support.""" + if not text: + return [] + + text = re.sub(r'\s+', ' ', text).strip() + + # Protect abbreviations + protected_text = text + for abbrev in self.GERMAN_ABBREVIATIONS: + pattern = re.compile(r'\b' + re.escape(abbrev) + r'\.', re.IGNORECASE) + protected_text = pattern.sub(abbrev.replace('.', '') + '', protected_text) + + # Protect decimal/ordinal numbers and requirement IDs (e.g., "O.Data_1") + protected_text = re.sub(r'(\d)\.(\d)', r'\1\2', protected_text) + protected_text = re.sub(r'(\d+)\.(\s)', r'\1\2', protected_text) + protected_text = re.sub(r'([A-Z])\.([A-Z])', r'\1\2', protected_text) # O.Data_1 + + # Split on sentence endings + sentence_pattern = r'(?<=[.!?])\s+(?=[A-ZÄÖÜ0-9])|(?<=[.!?])$' + raw_sentences = re.split(sentence_pattern, protected_text) + + # Restore protected characters + sentences = [] + for s in raw_sentences: + s = s.replace('', '.').replace('', '.').replace('', '.').replace('', '.').replace('', '.') + s = s.strip() + if s: + sentences.append(s) + + return sentences + + def _split_into_paragraphs(self, text: str) -> List[str]: + """Split text into paragraphs.""" + if not text: + return [] + + raw_paragraphs = re.split(r'\n\s*\n', text) + return [para.strip() for para in raw_paragraphs if para.strip()] + + def _chunk_text_semantic(self, text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> List[Tuple[str, int]]: + """ + Semantic chunking that respects paragraph and sentence boundaries. + Matches NIBIS chunking strategy for consistency. + + Returns list of (chunk_text, start_position) tuples. + """ + if not text: + return [] + + if len(text) <= chunk_size: + return [(text.strip(), 0)] + + paragraphs = self._split_into_paragraphs(text) + overlap_sentences = max(1, overlap // 100) # Convert char overlap to sentence overlap + + chunks = [] + current_chunk_parts = [] + current_chunk_length = 0 + chunk_start = 0 + position = 0 + + for para in paragraphs: + if len(para) > chunk_size: + # Large paragraph: split into sentences + sentences = self._split_into_sentences(para) + + for sentence in sentences: + sentence_len = len(sentence) + + if sentence_len > chunk_size: + # Very long sentence: save current chunk first + if current_chunk_parts: + chunk_text = ' '.join(current_chunk_parts) + chunks.append((chunk_text, chunk_start)) + overlap_buffer = current_chunk_parts[-overlap_sentences:] if overlap_sentences > 0 else [] + current_chunk_parts = list(overlap_buffer) + current_chunk_length = sum(len(s) + 1 for s in current_chunk_parts) + + # Add long sentence as its own chunk + chunks.append((sentence, position)) + current_chunk_parts = [sentence] + current_chunk_length = len(sentence) + 1 + position += sentence_len + 1 + continue + + if current_chunk_length + sentence_len + 1 > chunk_size and current_chunk_parts: + # Current chunk is full, save it + chunk_text = ' '.join(current_chunk_parts) + chunks.append((chunk_text, chunk_start)) + overlap_buffer = current_chunk_parts[-overlap_sentences:] if overlap_sentences > 0 else [] + current_chunk_parts = list(overlap_buffer) + current_chunk_length = sum(len(s) + 1 for s in current_chunk_parts) + chunk_start = position - current_chunk_length + + current_chunk_parts.append(sentence) + current_chunk_length += sentence_len + 1 + position += sentence_len + 1 + else: + # Small paragraph: try to keep together + para_len = len(para) + if current_chunk_length + para_len + 2 > chunk_size and current_chunk_parts: + chunk_text = ' '.join(current_chunk_parts) + chunks.append((chunk_text, chunk_start)) + last_para_sentences = self._split_into_sentences(current_chunk_parts[-1] if current_chunk_parts else "") + overlap_buffer = last_para_sentences[-overlap_sentences:] if overlap_sentences > 0 and last_para_sentences else [] + current_chunk_parts = list(overlap_buffer) + current_chunk_length = sum(len(s) + 1 for s in current_chunk_parts) + chunk_start = position - current_chunk_length + + if current_chunk_parts: + current_chunk_parts.append(para) + current_chunk_length += para_len + 2 + else: + current_chunk_parts = [para] + current_chunk_length = para_len + chunk_start = position + + position += para_len + 2 + + # Don't forget the last chunk + if current_chunk_parts: + chunk_text = ' '.join(current_chunk_parts) + chunks.append((chunk_text, chunk_start)) + + # Clean up whitespace + return [(re.sub(r'\s+', ' ', c).strip(), pos) for c, pos in chunks if c.strip()] + + def _extract_article_info(self, text: str) -> Optional[Dict]: + """Extract article number and paragraph from text.""" + # Pattern for "Artikel X" or "Art. X" + article_match = re.search(r'(?:Artikel|Art\.?)\s+(\d+)', text) + paragraph_match = re.search(r'(?:Absatz|Abs\.?)\s+(\d+)', text) + + if article_match: + return { + "article": article_match.group(1), + "paragraph": paragraph_match.group(1) if paragraph_match else None, + } + return None + + async def _fetch_document_text(self, regulation: Regulation) -> Optional[str]: + """ + Fetch document text from local file or URL. + + Priority: + 1. Local file in docs/legal_corpus/ (.txt or .pdf) + 2. EUR-Lex via CELEX URL (for EU regulations/directives) + 3. Fallback to original source URL + """ + # Check for local file first + local_file = LEGAL_DOCS_PATH / f"{regulation.code}.txt" + if local_file.exists(): + logger.info(f"Loading {regulation.code} from local file: {local_file}") + return local_file.read_text(encoding="utf-8") + + local_pdf = LEGAL_DOCS_PATH / f"{regulation.code}.pdf" + if local_pdf.exists(): + logger.info(f"Extracting text from PDF: {local_pdf}") + try: + # Use embedding service for PDF extraction + response = await self.http_client.post( + f"{EMBEDDING_SERVICE_URL}/extract-pdf", + files={"file": open(local_pdf, "rb")}, + timeout=120.0, + ) + response.raise_for_status() + data = response.json() + return data.get("text", "") + except Exception as e: + logger.error(f"PDF extraction failed for {regulation.code}: {e}") + + # Try EUR-Lex CELEX URL if available (bypasses JavaScript CAPTCHA) + if regulation.celex: + celex_url = f"https://eur-lex.europa.eu/legal-content/DE/TXT/HTML/?uri=CELEX:{regulation.celex}" + logger.info(f"Fetching {regulation.code} from EUR-Lex CELEX: {celex_url}") + try: + response = await self.http_client.get( + celex_url, + follow_redirects=True, + headers={ + "Accept": "text/html,application/xhtml+xml", + "Accept-Language": "de-DE,de;q=0.9", + "User-Agent": "Mozilla/5.0 (compatible; LegalCorpusIndexer/1.0)", + }, + timeout=120.0, + ) + response.raise_for_status() + + html_content = response.text + + # Check if we got actual content, not a CAPTCHA page + if "verify that you're not a robot" not in html_content and len(html_content) > 10000: + text = self._html_to_text(html_content) + if text and len(text) > 1000: + logger.info(f"Successfully fetched {regulation.code} via CELEX ({len(text)} chars)") + return text + else: + logger.warning(f"CELEX response too short for {regulation.code}, trying fallback") + else: + logger.warning(f"CELEX returned CAPTCHA for {regulation.code}, trying fallback") + except Exception as e: + logger.warning(f"CELEX fetch failed for {regulation.code}: {e}, trying fallback") + + # Fallback to original source URL + logger.info(f"Fetching {regulation.code} from: {regulation.source_url}") + try: + # Check if source URL is a PDF (handle URLs with query parameters) + parsed_url = urlparse(regulation.source_url) + is_pdf_url = parsed_url.path.lower().endswith('.pdf') + if is_pdf_url: + logger.info(f"Downloading PDF from URL for {regulation.code}") + response = await self.http_client.get( + regulation.source_url, + follow_redirects=True, + headers={ + "Accept": "application/pdf", + "User-Agent": "Mozilla/5.0 (compatible; LegalCorpusIndexer/1.0)", + }, + timeout=180.0, + ) + response.raise_for_status() + + # Extract text from PDF via embedding service + pdf_content = response.content + extract_response = await self.http_client.post( + f"{EMBEDDING_SERVICE_URL}/extract-pdf", + files={"file": ("document.pdf", pdf_content, "application/pdf")}, + timeout=180.0, + ) + extract_response.raise_for_status() + data = extract_response.json() + text = data.get("text", "") + if text: + logger.info(f"Successfully extracted PDF text for {regulation.code} ({len(text)} chars)") + return text + else: + logger.warning(f"PDF extraction returned empty text for {regulation.code}") + return None + else: + # Regular HTML fetch + response = await self.http_client.get( + regulation.source_url, + follow_redirects=True, + headers={ + "Accept": "text/html,application/xhtml+xml", + "Accept-Language": "de-DE,de;q=0.9", + "User-Agent": "Mozilla/5.0 (compatible; LegalCorpusIndexer/1.0)", + }, + timeout=120.0, + ) + response.raise_for_status() + + text = self._html_to_text(response.text) + return text + except Exception as e: + logger.error(f"Failed to fetch {regulation.code}: {e}") + return None + + def _html_to_text(self, html_content: str) -> str: + """Convert HTML to clean text.""" + # Remove script and style tags + html_content = re.sub(r']*>.*?', '', html_content, flags=re.DOTALL) + html_content = re.sub(r']*>.*?', '', html_content, flags=re.DOTALL) + # Remove comments + html_content = re.sub(r'', '', html_content, flags=re.DOTALL) + # Replace common HTML entities + html_content = html_content.replace(' ', ' ') + html_content = html_content.replace('&', '&') + html_content = html_content.replace('<', '<') + html_content = html_content.replace('>', '>') + html_content = html_content.replace('"', '"') + # Convert breaks and paragraphs to newlines for better chunking + html_content = re.sub(r'', '\n', html_content, flags=re.IGNORECASE) + html_content = re.sub(r'

              ', '\n\n', html_content, flags=re.IGNORECASE) + html_content = re.sub(r'
              ', '\n', html_content, flags=re.IGNORECASE) + html_content = re.sub(r'', '\n\n', html_content, flags=re.IGNORECASE) + # Remove remaining HTML tags + text = re.sub(r'<[^>]+>', ' ', html_content) + # Clean up whitespace (but preserve paragraph breaks) + text = re.sub(r'[ \t]+', ' ', text) + text = re.sub(r'\n[ \t]+', '\n', text) + text = re.sub(r'[ \t]+\n', '\n', text) + text = re.sub(r'\n{3,}', '\n\n', text) + return text.strip() + + async def ingest_regulation(self, regulation: Regulation) -> int: + """ + Ingest a single regulation into Qdrant. + + Returns number of chunks indexed. + """ + logger.info(f"Ingesting {regulation.code}: {regulation.name}") + + # Fetch document text + text = await self._fetch_document_text(regulation) + if not text or len(text) < 100: + logger.warning(f"No text found for {regulation.code}, skipping") + return 0 + + # Chunk the text + chunks = self._chunk_text_semantic(text) + logger.info(f"Created {len(chunks)} chunks for {regulation.code}") + + if not chunks: + return 0 + + # Generate embeddings in batches (very small for CPU stability) + batch_size = 4 + all_points = [] + max_retries = 3 + + for i in range(0, len(chunks), batch_size): + batch_chunks = chunks[i:i + batch_size] + chunk_texts = [c[0] for c in batch_chunks] + + # Retry logic for embedding service stability + embeddings = None + for retry in range(max_retries): + try: + embeddings = await self._generate_embeddings(chunk_texts) + break + except Exception as e: + logger.warning(f"Embedding attempt {retry+1}/{max_retries} failed for batch {i//batch_size}: {e}") + if retry < max_retries - 1: + await asyncio.sleep(3 * (retry + 1)) # Longer backoff: 3s, 6s, 9s + else: + logger.error(f"Embedding failed permanently for batch {i//batch_size}") + + if embeddings is None: + continue + + # Longer delay between batches for CPU stability + await asyncio.sleep(1.5) + + for j, ((chunk_text, position), embedding) in enumerate(zip(batch_chunks, embeddings)): + chunk_idx = i + j + point_id = hashlib.md5(f"{regulation.code}-{chunk_idx}".encode()).hexdigest() + + # Extract article info if present + article_info = self._extract_article_info(chunk_text) + + point = PointStruct( + id=point_id, + vector=embedding, + payload={ + "text": chunk_text, + "regulation_code": regulation.code, + "regulation_name": regulation.name, + "regulation_full_name": regulation.full_name, + "regulation_type": regulation.regulation_type, + "source_url": regulation.source_url, + "chunk_index": chunk_idx, + "chunk_position": position, + "article": article_info.get("article") if article_info else None, + "paragraph": article_info.get("paragraph") if article_info else None, + "language": regulation.language, + "indexed_at": datetime.utcnow().isoformat(), + "training_allowed": False, # Legal texts - no training + }, + ) + all_points.append(point) + + # Upsert to Qdrant + if all_points: + self.qdrant.upsert( + collection_name=LEGAL_CORPUS_COLLECTION, + points=all_points, + ) + logger.info(f"Indexed {len(all_points)} chunks for {regulation.code}") + + return len(all_points) + + async def ingest_all(self) -> Dict[str, int]: + """Ingest all regulations.""" + results = {} + total = 0 + + for regulation in REGULATIONS: + try: + count = await self.ingest_regulation(regulation) + results[regulation.code] = count + total += count + except Exception as e: + logger.error(f"Failed to ingest {regulation.code}: {e}") + results[regulation.code] = 0 + + logger.info(f"Ingestion complete: {total} total chunks indexed") + return results + + async def ingest_selected(self, codes: List[str]) -> Dict[str, int]: + """Ingest selected regulations by code.""" + results = {} + + for code in codes: + regulation = next((r for r in REGULATIONS if r.code == code), None) + if not regulation: + logger.warning(f"Unknown regulation code: {code}") + results[code] = 0 + continue + + try: + count = await self.ingest_regulation(regulation) + results[code] = count + except Exception as e: + logger.error(f"Failed to ingest {code}: {e}") + results[code] = 0 + + return results + + def get_status(self) -> Dict: + """Get collection status and indexed regulations.""" + try: + collection_info = self.qdrant.get_collection(LEGAL_CORPUS_COLLECTION) + + # Count points per regulation + regulation_counts = {} + for reg in REGULATIONS: + result = self.qdrant.count( + collection_name=LEGAL_CORPUS_COLLECTION, + count_filter=Filter( + must=[ + FieldCondition( + key="regulation_code", + match=MatchValue(value=reg.code), + ) + ] + ), + ) + regulation_counts[reg.code] = result.count + + return { + "collection": LEGAL_CORPUS_COLLECTION, + "total_points": collection_info.points_count, + "vector_size": VECTOR_SIZE, + "regulations": regulation_counts, + "status": "ready" if collection_info.points_count > 0 else "empty", + } + except Exception as e: + return { + "collection": LEGAL_CORPUS_COLLECTION, + "error": str(e), + "status": "error", + } + + async def search( + self, + query: str, + regulation_codes: Optional[List[str]] = None, + top_k: int = 5, + ) -> List[Dict]: + """ + Search the legal corpus for relevant passages. + + Args: + query: Search query text + regulation_codes: Optional list of regulation codes to filter + top_k: Number of results to return + + Returns: + List of search results with text and metadata + """ + # Generate query embedding + embeddings = await self._generate_embeddings([query]) + query_vector = embeddings[0] + + # Build filter + search_filter = None + if regulation_codes: + search_filter = Filter( + should=[ + FieldCondition( + key="regulation_code", + match=MatchValue(value=code), + ) + for code in regulation_codes + ] + ) + + # Search + results = self.qdrant.search( + collection_name=LEGAL_CORPUS_COLLECTION, + query_vector=query_vector, + query_filter=search_filter, + limit=top_k, + ) + + return [ + { + "text": hit.payload.get("text"), + "regulation_code": hit.payload.get("regulation_code"), + "regulation_name": hit.payload.get("regulation_name"), + "article": hit.payload.get("article"), + "paragraph": hit.payload.get("paragraph"), + "source_url": hit.payload.get("source_url"), + "score": hit.score, + } + for hit in results + ] + + async def close(self): + """Close HTTP client.""" + await self.http_client.aclose() + + +async def main(): + """CLI entry point.""" + import argparse + + parser = argparse.ArgumentParser(description="Legal Corpus Ingestion for UCCA") + parser.add_argument("--ingest-all", action="store_true", help="Ingest all 19 regulations") + parser.add_argument("--ingest", nargs="+", metavar="CODE", help="Ingest specific regulations by code") + parser.add_argument("--status", action="store_true", help="Show collection status") + parser.add_argument("--search", type=str, help="Test search query") + + args = parser.parse_args() + + ingestion = LegalCorpusIngestion() + + try: + if args.status: + status = ingestion.get_status() + print(json.dumps(status, indent=2)) + + elif args.ingest_all: + print("Ingesting all 19 regulations...") + results = await ingestion.ingest_all() + print("\nResults:") + for code, count in results.items(): + print(f" {code}: {count} chunks") + print(f"\nTotal: {sum(results.values())} chunks") + + elif args.ingest: + print(f"Ingesting: {', '.join(args.ingest)}") + results = await ingestion.ingest_selected(args.ingest) + print("\nResults:") + for code, count in results.items(): + print(f" {code}: {count} chunks") + + elif args.search: + print(f"Searching: {args.search}") + results = await ingestion.search(args.search) + print(f"\nFound {len(results)} results:") + for i, result in enumerate(results, 1): + print(f"\n{i}. [{result['regulation_code']}] Score: {result['score']:.3f}") + if result.get('article'): + print(f" Art. {result['article']}" + (f" Abs. {result['paragraph']}" if result.get('paragraph') else "")) + print(f" {result['text'][:200]}...") + + else: + parser.print_help() + + finally: + await ingestion.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/klausur-service/backend/legal_corpus_robust.py b/klausur-service/backend/legal_corpus_robust.py new file mode 100644 index 0000000..b078e8b --- /dev/null +++ b/klausur-service/backend/legal_corpus_robust.py @@ -0,0 +1,455 @@ +""" +Robust Legal Corpus Ingestion for UCCA RAG Integration. + +This version handles large documents and unstable embedding services by: +- Processing one text at a time +- Health checks before each embedding +- Automatic retry with exponential backoff +- Progress tracking for resume capability +- Longer delays to prevent service overload + +Usage: + python legal_corpus_robust.py --ingest DPF + python legal_corpus_robust.py --ingest-all-missing + python legal_corpus_robust.py --status +""" + +import asyncio +import hashlib +import json +import logging +import os +import re +import sys +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import httpx +from qdrant_client import QdrantClient +from qdrant_client.models import ( + Distance, + FieldCondition, + Filter, + MatchValue, + PointStruct, + VectorParams, +) + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + +# Configuration +QDRANT_HOST = os.getenv("QDRANT_HOST", "localhost") +QDRANT_PORT = int(os.getenv("QDRANT_PORT", "6333")) +EMBEDDING_SERVICE_URL = os.getenv("EMBEDDING_SERVICE_URL", "http://localhost:8087") +LEGAL_CORPUS_COLLECTION = "bp_legal_corpus" +VECTOR_SIZE = 1024 +CHUNK_SIZE = 800 +CHUNK_OVERLAP = 150 + +# Robust settings +MAX_RETRIES = 5 +INITIAL_DELAY = 2.0 +DELAY_BETWEEN_EMBEDDINGS = 2.0 +HEALTH_CHECK_INTERVAL = 10 # Check health every N embeddings + + +@dataclass +class Regulation: + """Regulation metadata.""" + code: str + name: str + full_name: str + regulation_type: str + source_url: str + description: str + language: str = "de" + + +# Regulations that need robust loading +ROBUST_REGULATIONS: List[Regulation] = [ + Regulation( + code="DPF", + name="EU-US Data Privacy Framework", + full_name="Durchführungsbeschluss (EU) 2023/1795", + regulation_type="eu_regulation", + source_url="https://eur-lex.europa.eu/eli/dec_impl/2023/1795/oj", + description="Angemessenheitsbeschluss für USA-Transfers.", + ), + Regulation( + code="BSI-TR-03161-1", + name="BSI-TR-03161 Teil 1", + full_name="BSI Technische Richtlinie - Allgemeine Anforderungen", + regulation_type="bsi_standard", + source_url="https://www.bsi.bund.de/SharedDocs/Downloads/DE/BSI/Publikationen/TechnischeRichtlinien/TR03161/BSI-TR-03161-1.pdf", + description="Allgemeine Sicherheitsanforderungen (45 Prüfaspekte).", + ), + Regulation( + code="BSI-TR-03161-2", + name="BSI-TR-03161 Teil 2", + full_name="BSI Technische Richtlinie - Web-Anwendungen", + regulation_type="bsi_standard", + source_url="https://www.bsi.bund.de/SharedDocs/Downloads/DE/BSI/Publikationen/TechnischeRichtlinien/TR03161/BSI-TR-03161-2.pdf", + description="Web-Sicherheit (40 Prüfaspekte).", + ), + Regulation( + code="BSI-TR-03161-3", + name="BSI-TR-03161 Teil 3", + full_name="BSI Technische Richtlinie - Hintergrundsysteme", + regulation_type="bsi_standard", + source_url="https://www.bsi.bund.de/SharedDocs/Downloads/DE/BSI/Publikationen/TechnischeRichtlinien/TR03161/BSI-TR-03161-3.pdf", + description="Backend-Sicherheit (35 Prüfaspekte).", + ), +] + + +class RobustLegalCorpusIngestion: + """Handles robust ingestion of large legal documents.""" + + def __init__(self): + self.qdrant = QdrantClient(host=QDRANT_HOST, port=QDRANT_PORT) + self.http_client = None + self.embeddings_since_health_check = 0 + self._ensure_collection() + + def _ensure_collection(self): + """Create the legal corpus collection if it doesn't exist.""" + collections = self.qdrant.get_collections().collections + collection_names = [c.name for c in collections] + + if LEGAL_CORPUS_COLLECTION not in collection_names: + logger.info(f"Creating collection: {LEGAL_CORPUS_COLLECTION}") + self.qdrant.create_collection( + collection_name=LEGAL_CORPUS_COLLECTION, + vectors_config=VectorParams( + size=VECTOR_SIZE, + distance=Distance.COSINE, + ), + ) + + async def _get_client(self) -> httpx.AsyncClient: + """Get or create HTTP client.""" + if self.http_client is None: + self.http_client = httpx.AsyncClient(timeout=120.0) + return self.http_client + + async def _check_embedding_service_health(self) -> bool: + """Check if embedding service is healthy.""" + try: + client = await self._get_client() + response = await client.get(f"{EMBEDDING_SERVICE_URL}/health", timeout=10.0) + return response.status_code == 200 + except Exception as e: + logger.warning(f"Health check failed: {e}") + return False + + async def _wait_for_healthy_service(self, max_wait: int = 60) -> bool: + """Wait for embedding service to become healthy.""" + logger.info("Waiting for embedding service to become healthy...") + start = datetime.now() + while (datetime.now() - start).seconds < max_wait: + if await self._check_embedding_service_health(): + logger.info("Embedding service is healthy") + return True + await asyncio.sleep(5) + logger.error("Embedding service did not become healthy") + return False + + async def _generate_single_embedding(self, text: str) -> Optional[List[float]]: + """Generate embedding for a single text with robust retry.""" + for attempt in range(MAX_RETRIES): + try: + # Health check periodically + self.embeddings_since_health_check += 1 + if self.embeddings_since_health_check >= HEALTH_CHECK_INTERVAL: + if not await self._check_embedding_service_health(): + await self._wait_for_healthy_service() + self.embeddings_since_health_check = 0 + + client = await self._get_client() + response = await client.post( + f"{EMBEDDING_SERVICE_URL}/embed", + json={"texts": [text]}, + timeout=60.0, + ) + response.raise_for_status() + data = response.json() + return data["embeddings"][0] + + except Exception as e: + delay = INITIAL_DELAY * (2 ** attempt) + logger.warning(f"Embedding attempt {attempt + 1}/{MAX_RETRIES} failed: {e}") + logger.info(f"Waiting {delay}s before retry...") + + # Close and recreate client on connection errors + if "disconnect" in str(e).lower() or "connection" in str(e).lower(): + if self.http_client: + await self.http_client.aclose() + self.http_client = None + # Wait for service to recover + await asyncio.sleep(delay) + if not await self._wait_for_healthy_service(): + continue + else: + await asyncio.sleep(delay) + + logger.error(f"Failed to generate embedding after {MAX_RETRIES} attempts") + return None + + def _chunk_text_semantic(self, text: str) -> List[Tuple[str, int]]: + """Chunk text semantically, respecting German sentence boundaries.""" + sentence_endings = re.compile(r'(?<=[.!?])\s+(?=[A-ZÄÖÜ])') + sentences = sentence_endings.split(text) + + chunks = [] + current_chunk = [] + current_length = 0 + chunk_start = 0 + position = 0 + + for sentence in sentences: + sentence = sentence.strip() + if not sentence: + continue + + sentence_length = len(sentence) + + if current_length + sentence_length > CHUNK_SIZE and current_chunk: + chunk_text = " ".join(current_chunk) + chunks.append((chunk_text, chunk_start)) + + # Keep some sentences for overlap + overlap_sentences = [] + overlap_length = 0 + for s in reversed(current_chunk): + if overlap_length + len(s) > CHUNK_OVERLAP: + break + overlap_sentences.insert(0, s) + overlap_length += len(s) + + current_chunk = overlap_sentences + current_length = overlap_length + chunk_start = position - overlap_length + + current_chunk.append(sentence) + current_length += sentence_length + position += sentence_length + 1 + + if current_chunk: + chunk_text = " ".join(current_chunk) + chunks.append((chunk_text, chunk_start)) + + return chunks + + def _extract_article_info(self, text: str) -> Optional[Dict]: + """Extract article number and paragraph from text.""" + article_match = re.search(r'(?:Artikel|Art\.?)\s+(\d+)', text) + paragraph_match = re.search(r'(?:Absatz|Abs\.?)\s+(\d+)', text) + + if article_match: + return { + "article": article_match.group(1), + "paragraph": paragraph_match.group(1) if paragraph_match else None, + } + return None + + async def _fetch_document_text(self, regulation: Regulation) -> Optional[str]: + """Fetch document text from URL.""" + logger.info(f"Fetching {regulation.code} from: {regulation.source_url}") + try: + client = await self._get_client() + response = await client.get( + regulation.source_url, + follow_redirects=True, + headers={"Accept": "text/html,application/xhtml+xml"}, + timeout=60.0, + ) + response.raise_for_status() + + html_content = response.text + html_content = re.sub(r']*>.*?', '', html_content, flags=re.DOTALL) + html_content = re.sub(r']*>.*?', '', html_content, flags=re.DOTALL) + text = re.sub(r'<[^>]+>', ' ', html_content) + text = re.sub(r'\s+', ' ', text).strip() + + return text + except Exception as e: + logger.error(f"Failed to fetch {regulation.code}: {e}") + return None + + def get_existing_chunk_count(self, regulation_code: str) -> int: + """Get count of existing chunks for a regulation.""" + try: + result = self.qdrant.count( + collection_name=LEGAL_CORPUS_COLLECTION, + count_filter=Filter( + must=[ + FieldCondition( + key="regulation_code", + match=MatchValue(value=regulation_code), + ) + ] + ), + ) + return result.count + except: + return 0 + + async def ingest_regulation_robust(self, regulation: Regulation, resume: bool = True) -> int: + """ + Ingest a regulation with robust error handling. + + Args: + regulation: The regulation to ingest + resume: If True, skip already indexed chunks + + Returns: + Number of chunks indexed + """ + logger.info(f"=== Starting robust ingestion for {regulation.code} ===") + + # Check existing chunks + existing_count = self.get_existing_chunk_count(regulation.code) + logger.info(f"Existing chunks for {regulation.code}: {existing_count}") + + # Fetch document + text = await self._fetch_document_text(regulation) + if not text or len(text) < 100: + logger.warning(f"No text found for {regulation.code}") + return 0 + + # Chunk the text + chunks = self._chunk_text_semantic(text) + total_chunks = len(chunks) + logger.info(f"Total chunks to process: {total_chunks}") + + if resume and existing_count >= total_chunks: + logger.info(f"{regulation.code} already fully indexed") + return existing_count + + # Determine starting point + start_idx = existing_count if resume else 0 + logger.info(f"Starting from chunk {start_idx}") + + indexed = 0 + for idx, (chunk_text, position) in enumerate(chunks[start_idx:], start=start_idx): + # Progress logging + if idx % 10 == 0: + logger.info(f"Progress: {idx}/{total_chunks} chunks ({idx*100//total_chunks}%)") + + # Generate embedding + embedding = await self._generate_single_embedding(chunk_text) + if embedding is None: + logger.error(f"Failed to embed chunk {idx}, stopping") + break + + # Create point + point_id = hashlib.md5(f"{regulation.code}-{idx}".encode()).hexdigest() + article_info = self._extract_article_info(chunk_text) + + point = PointStruct( + id=point_id, + vector=embedding, + payload={ + "text": chunk_text, + "regulation_code": regulation.code, + "regulation_name": regulation.name, + "regulation_full_name": regulation.full_name, + "regulation_type": regulation.regulation_type, + "source_url": regulation.source_url, + "chunk_index": idx, + "chunk_position": position, + "article": article_info.get("article") if article_info else None, + "paragraph": article_info.get("paragraph") if article_info else None, + "language": regulation.language, + "indexed_at": datetime.utcnow().isoformat(), + "training_allowed": False, + }, + ) + + # Upsert single point + self.qdrant.upsert( + collection_name=LEGAL_CORPUS_COLLECTION, + points=[point], + ) + indexed += 1 + + # Delay between embeddings + await asyncio.sleep(DELAY_BETWEEN_EMBEDDINGS) + + logger.info(f"=== Completed {regulation.code}: {indexed} new chunks indexed ===") + return existing_count + indexed + + def get_status(self) -> Dict: + """Get ingestion status for all robust regulations.""" + status = { + "collection": LEGAL_CORPUS_COLLECTION, + "regulations": {}, + } + + for reg in ROBUST_REGULATIONS: + count = self.get_existing_chunk_count(reg.code) + status["regulations"][reg.code] = { + "name": reg.name, + "chunks": count, + "status": "complete" if count > 0 else "missing", + } + + return status + + async def close(self): + """Close HTTP client.""" + if self.http_client: + await self.http_client.aclose() + + +async def main(): + """CLI entry point.""" + import argparse + + parser = argparse.ArgumentParser(description="Robust Legal Corpus Ingestion") + parser.add_argument("--ingest", nargs="+", metavar="CODE", help="Ingest specific regulations") + parser.add_argument("--ingest-all-missing", action="store_true", help="Ingest all missing regulations") + parser.add_argument("--status", action="store_true", help="Show status") + parser.add_argument("--no-resume", action="store_true", help="Don't resume from existing chunks") + + args = parser.parse_args() + + ingestion = RobustLegalCorpusIngestion() + + try: + if args.status: + status = ingestion.get_status() + print(json.dumps(status, indent=2)) + + elif args.ingest_all_missing: + print("Ingesting all missing regulations...") + for reg in ROBUST_REGULATIONS: + if ingestion.get_existing_chunk_count(reg.code) == 0: + count = await ingestion.ingest_regulation_robust(reg, resume=not args.no_resume) + print(f"{reg.code}: {count} chunks") + + elif args.ingest: + for code in args.ingest: + reg = next((r for r in ROBUST_REGULATIONS if r.code == code), None) + if not reg: + print(f"Unknown regulation: {code}") + continue + count = await ingestion.ingest_regulation_robust(reg, resume=not args.no_resume) + print(f"{code}: {count} chunks") + + else: + parser.print_help() + + finally: + await ingestion.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/klausur-service/backend/legal_templates_ingestion.py b/klausur-service/backend/legal_templates_ingestion.py new file mode 100644 index 0000000..16580cd --- /dev/null +++ b/klausur-service/backend/legal_templates_ingestion.py @@ -0,0 +1,942 @@ +""" +Legal Templates Ingestion Pipeline for RAG. + +Indexes legal template documents from various open-source repositories +into Qdrant for semantic search. Supports multiple license types with +proper attribution tracking. + +Collection: bp_legal_templates + +Usage: + python legal_templates_ingestion.py --ingest-all + python legal_templates_ingestion.py --ingest-source github-site-policy + python legal_templates_ingestion.py --status + python legal_templates_ingestion.py --search "Datenschutzerklaerung" +""" + +import asyncio +import hashlib +import json +import logging +import os +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict, List, Optional +from urllib.parse import urlparse + +import httpx +from qdrant_client import QdrantClient +from qdrant_client.models import ( + Distance, + FieldCondition, + Filter, + MatchValue, + PointStruct, + VectorParams, +) + +from template_sources import ( + LICENSES, + TEMPLATE_SOURCES, + TEMPLATE_TYPES, + LicenseType, + SourceConfig, + get_enabled_sources, + get_sources_by_priority, +) +from github_crawler import ( + ExtractedDocument, + GitHubCrawler, + RepositoryDownloader, +) + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Configuration - Support both QDRANT_URL and QDRANT_HOST/PORT +_qdrant_url = os.getenv("QDRANT_URL", "") +if _qdrant_url: + _parsed = urlparse(_qdrant_url) + QDRANT_HOST = _parsed.hostname or "localhost" + QDRANT_PORT = _parsed.port or 6333 +else: + QDRANT_HOST = os.getenv("QDRANT_HOST", "localhost") + QDRANT_PORT = int(os.getenv("QDRANT_PORT", "6333")) + +EMBEDDING_SERVICE_URL = os.getenv("EMBEDDING_SERVICE_URL", "http://localhost:8087") +LEGAL_TEMPLATES_COLLECTION = "bp_legal_templates" +VECTOR_SIZE = 1024 # BGE-M3 dimension + +# Chunking configuration +CHUNK_SIZE = int(os.getenv("TEMPLATE_CHUNK_SIZE", "1000")) +CHUNK_OVERLAP = int(os.getenv("TEMPLATE_CHUNK_OVERLAP", "200")) + +# Batch processing +EMBEDDING_BATCH_SIZE = 4 +MAX_RETRIES = 3 +RETRY_DELAY = 3.0 + + +@dataclass +class IngestionStatus: + """Status of a source ingestion.""" + source_name: str + status: str # "pending", "running", "completed", "failed" + documents_found: int = 0 + chunks_created: int = 0 + chunks_indexed: int = 0 + errors: List[str] = field(default_factory=list) + started_at: Optional[datetime] = None + completed_at: Optional[datetime] = None + + +@dataclass +class TemplateChunk: + """A chunk of template text ready for indexing.""" + text: str + chunk_index: int + document_title: str + template_type: str + clause_category: Optional[str] + language: str + jurisdiction: str + license_id: str + license_name: str + license_url: str + attribution_required: bool + share_alike: bool + no_derivatives: bool + commercial_use: bool + source_name: str + source_url: str + source_repo: Optional[str] + source_commit: Optional[str] + source_file: str + source_hash: str + attribution_text: Optional[str] + copyright_notice: Optional[str] + is_complete_document: bool + is_modular: bool + requires_customization: bool + placeholders: List[str] + training_allowed: bool + output_allowed: bool + modification_allowed: bool + distortion_prohibited: bool + + +class LegalTemplatesIngestion: + """Handles ingestion of legal templates into Qdrant.""" + + def __init__(self): + self.qdrant = QdrantClient(host=QDRANT_HOST, port=QDRANT_PORT) + self.http_client = httpx.AsyncClient(timeout=120.0) + self._ensure_collection() + self._ingestion_status: Dict[str, IngestionStatus] = {} + + def _ensure_collection(self): + """Create the legal templates collection if it doesn't exist.""" + collections = self.qdrant.get_collections().collections + collection_names = [c.name for c in collections] + + if LEGAL_TEMPLATES_COLLECTION not in collection_names: + logger.info(f"Creating collection: {LEGAL_TEMPLATES_COLLECTION}") + self.qdrant.create_collection( + collection_name=LEGAL_TEMPLATES_COLLECTION, + vectors_config=VectorParams( + size=VECTOR_SIZE, + distance=Distance.COSINE, + ), + ) + logger.info(f"Collection {LEGAL_TEMPLATES_COLLECTION} created") + else: + logger.info(f"Collection {LEGAL_TEMPLATES_COLLECTION} already exists") + + async def _generate_embeddings(self, texts: List[str]) -> List[List[float]]: + """Generate embeddings via the embedding service.""" + try: + response = await self.http_client.post( + f"{EMBEDDING_SERVICE_URL}/embed", + json={"texts": texts}, + timeout=120.0, + ) + response.raise_for_status() + data = response.json() + return data["embeddings"] + except Exception as e: + logger.error(f"Embedding generation failed: {e}") + raise + + def _chunk_text(self, text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> List[str]: + """ + Split text into overlapping chunks. + Respects paragraph and sentence boundaries where possible. + """ + if not text: + return [] + + if len(text) <= chunk_size: + return [text.strip()] + + # Split into paragraphs first + paragraphs = text.split('\n\n') + chunks = [] + current_chunk = [] + current_length = 0 + + for para in paragraphs: + para = para.strip() + if not para: + continue + + para_length = len(para) + + if para_length > chunk_size: + # Large paragraph: split by sentences + if current_chunk: + chunks.append('\n\n'.join(current_chunk)) + current_chunk = [] + current_length = 0 + + # Split long paragraph by sentences + sentences = self._split_sentences(para) + for sentence in sentences: + if current_length + len(sentence) + 1 > chunk_size: + if current_chunk: + chunks.append(' '.join(current_chunk)) + # Keep overlap + overlap_count = max(1, len(current_chunk) // 3) + current_chunk = current_chunk[-overlap_count:] + current_length = sum(len(s) + 1 for s in current_chunk) + current_chunk.append(sentence) + current_length += len(sentence) + 1 + + elif current_length + para_length + 2 > chunk_size: + # Paragraph would exceed chunk size + if current_chunk: + chunks.append('\n\n'.join(current_chunk)) + current_chunk = [] + current_length = 0 + current_chunk.append(para) + current_length = para_length + + else: + current_chunk.append(para) + current_length += para_length + 2 + + # Add final chunk + if current_chunk: + chunks.append('\n\n'.join(current_chunk)) + + return [c.strip() for c in chunks if c.strip()] + + def _split_sentences(self, text: str) -> List[str]: + """Split text into sentences with basic abbreviation handling.""" + import re + + # Protect common abbreviations + abbreviations = ['bzw', 'ca', 'd.h', 'etc', 'ggf', 'inkl', 'u.a', 'usw', 'z.B', 'z.b', 'e.g', 'i.e', 'vs', 'no'] + protected = text + for abbr in abbreviations: + pattern = re.compile(r'\b' + re.escape(abbr) + r'\.', re.IGNORECASE) + protected = pattern.sub(abbr.replace('.', '') + '', protected) + + # Protect decimal numbers + protected = re.sub(r'(\d)\.(\d)', r'\1\2', protected) + + # Split on sentence endings + sentences = re.split(r'(?<=[.!?])\s+', protected) + + # Restore protected characters + result = [] + for s in sentences: + s = s.replace('', '.').replace('', '.').replace('', '.') + s = s.strip() + if s: + result.append(s) + + return result + + def _infer_template_type(self, doc: ExtractedDocument, source: SourceConfig) -> str: + """Infer the template type from document content and metadata.""" + text_lower = doc.text.lower() + title_lower = doc.title.lower() + + # Check known indicators + type_indicators = { + "privacy_policy": ["datenschutz", "privacy", "personal data", "personenbezogen"], + "terms_of_service": ["nutzungsbedingungen", "terms of service", "terms of use", "agb"], + "cookie_banner": ["cookie", "cookies", "tracking"], + "impressum": ["impressum", "legal notice", "imprint"], + "widerruf": ["widerruf", "cancellation", "withdrawal", "right to cancel"], + "dpa": ["auftragsverarbeitung", "data processing agreement", "dpa"], + "sla": ["service level", "availability", "uptime"], + "nda": ["confidential", "non-disclosure", "geheimhaltung", "vertraulich"], + "community_guidelines": ["community", "guidelines", "conduct", "verhaltens"], + "acceptable_use": ["acceptable use", "acceptable usage", "nutzungsrichtlinien"], + } + + for template_type, indicators in type_indicators.items(): + for indicator in indicators: + if indicator in text_lower or indicator in title_lower: + return template_type + + # Fall back to source's first template type + if source.template_types: + return source.template_types[0] + + return "clause" # Generic fallback + + def _infer_clause_category(self, text: str) -> Optional[str]: + """Infer the clause category from text content.""" + text_lower = text.lower() + + categories = { + "haftung": ["haftung", "liability", "haftungsausschluss", "limitation"], + "datenschutz": ["datenschutz", "privacy", "personal data", "personenbezogen"], + "widerruf": ["widerruf", "cancellation", "withdrawal"], + "gewaehrleistung": ["gewaehrleistung", "warranty", "garantie"], + "kuendigung": ["kuendigung", "termination", "beendigung"], + "zahlung": ["zahlung", "payment", "preis", "price"], + "gerichtsstand": ["gerichtsstand", "jurisdiction", "governing law"], + "aenderungen": ["aenderung", "modification", "amendment"], + "schlussbestimmungen": ["schlussbestimmung", "miscellaneous", "final provisions"], + } + + for category, indicators in categories.items(): + for indicator in indicators: + if indicator in text_lower: + return category + + return None + + def _create_chunks( + self, + doc: ExtractedDocument, + source: SourceConfig, + ) -> List[TemplateChunk]: + """Create template chunks from an extracted document.""" + license_info = source.license_info + template_type = self._infer_template_type(doc, source) + + # Chunk the text + text_chunks = self._chunk_text(doc.text) + + chunks = [] + for i, chunk_text in enumerate(text_chunks): + # Determine if this is a complete document or a clause + is_complete = len(text_chunks) == 1 and len(chunk_text) > 500 + is_modular = len(doc.sections) > 0 or '##' in doc.text + requires_customization = len(doc.placeholders) > 0 + + # Generate attribution text + attribution_text = None + if license_info.attribution_required: + attribution_text = license_info.get_attribution_text( + source.name, + doc.source_url or source.get_source_url() + ) + + chunk = TemplateChunk( + text=chunk_text, + chunk_index=i, + document_title=doc.title, + template_type=template_type, + clause_category=self._infer_clause_category(chunk_text), + language=doc.language, + jurisdiction=source.jurisdiction, + license_id=license_info.id.value, + license_name=license_info.name, + license_url=license_info.url, + attribution_required=license_info.attribution_required, + share_alike=license_info.share_alike, + no_derivatives=license_info.no_derivatives, + commercial_use=license_info.commercial_use, + source_name=source.name, + source_url=doc.source_url or source.get_source_url(), + source_repo=source.repo_url, + source_commit=doc.source_commit, + source_file=doc.file_path, + source_hash=doc.source_hash, + attribution_text=attribution_text, + copyright_notice=None, # Could be extracted from doc if present + is_complete_document=is_complete, + is_modular=is_modular, + requires_customization=requires_customization, + placeholders=doc.placeholders, + training_allowed=license_info.training_allowed, + output_allowed=license_info.output_allowed, + modification_allowed=license_info.modification_allowed, + distortion_prohibited=license_info.distortion_prohibited, + ) + chunks.append(chunk) + + return chunks + + async def ingest_source(self, source: SourceConfig) -> IngestionStatus: + """Ingest a single source into Qdrant.""" + status = IngestionStatus( + source_name=source.name, + status="running", + started_at=datetime.utcnow(), + ) + self._ingestion_status[source.name] = status + + logger.info(f"Ingesting source: {source.name}") + + try: + # Crawl the source + documents: List[ExtractedDocument] = [] + + if source.repo_url: + async with GitHubCrawler() as crawler: + async for doc in crawler.crawl_repository(source): + documents.append(doc) + status.documents_found += 1 + + logger.info(f"Found {len(documents)} documents in {source.name}") + + if not documents: + status.status = "completed" + status.completed_at = datetime.utcnow() + return status + + # Create chunks from all documents + all_chunks: List[TemplateChunk] = [] + for doc in documents: + chunks = self._create_chunks(doc, source) + all_chunks.extend(chunks) + status.chunks_created += len(chunks) + + logger.info(f"Created {len(all_chunks)} chunks from {source.name}") + + # Generate embeddings and index in batches + for i in range(0, len(all_chunks), EMBEDDING_BATCH_SIZE): + batch_chunks = all_chunks[i:i + EMBEDDING_BATCH_SIZE] + chunk_texts = [c.text for c in batch_chunks] + + # Retry logic for embeddings + embeddings = None + for retry in range(MAX_RETRIES): + try: + embeddings = await self._generate_embeddings(chunk_texts) + break + except Exception as e: + logger.warning( + f"Embedding attempt {retry+1}/{MAX_RETRIES} failed: {e}" + ) + if retry < MAX_RETRIES - 1: + await asyncio.sleep(RETRY_DELAY * (retry + 1)) + else: + status.errors.append(f"Embedding failed for batch {i}: {e}") + + if embeddings is None: + continue + + # Create points for Qdrant + points = [] + for j, (chunk, embedding) in enumerate(zip(batch_chunks, embeddings)): + point_id = hashlib.md5( + f"{source.name}-{chunk.source_file}-{chunk.chunk_index}".encode() + ).hexdigest() + + payload = { + "text": chunk.text, + "chunk_index": chunk.chunk_index, + "document_title": chunk.document_title, + "template_type": chunk.template_type, + "clause_category": chunk.clause_category, + "language": chunk.language, + "jurisdiction": chunk.jurisdiction, + "license_id": chunk.license_id, + "license_name": chunk.license_name, + "license_url": chunk.license_url, + "attribution_required": chunk.attribution_required, + "share_alike": chunk.share_alike, + "no_derivatives": chunk.no_derivatives, + "commercial_use": chunk.commercial_use, + "source_name": chunk.source_name, + "source_url": chunk.source_url, + "source_repo": chunk.source_repo, + "source_commit": chunk.source_commit, + "source_file": chunk.source_file, + "source_hash": chunk.source_hash, + "attribution_text": chunk.attribution_text, + "copyright_notice": chunk.copyright_notice, + "is_complete_document": chunk.is_complete_document, + "is_modular": chunk.is_modular, + "requires_customization": chunk.requires_customization, + "placeholders": chunk.placeholders, + "training_allowed": chunk.training_allowed, + "output_allowed": chunk.output_allowed, + "modification_allowed": chunk.modification_allowed, + "distortion_prohibited": chunk.distortion_prohibited, + "indexed_at": datetime.utcnow().isoformat(), + } + + points.append(PointStruct( + id=point_id, + vector=embedding, + payload=payload, + )) + + # Upsert to Qdrant + if points: + self.qdrant.upsert( + collection_name=LEGAL_TEMPLATES_COLLECTION, + points=points, + ) + status.chunks_indexed += len(points) + + # Rate limiting + await asyncio.sleep(1.0) + + status.status = "completed" + status.completed_at = datetime.utcnow() + logger.info( + f"Completed {source.name}: {status.chunks_indexed} chunks indexed" + ) + + except Exception as e: + status.status = "failed" + status.errors.append(str(e)) + status.completed_at = datetime.utcnow() + logger.error(f"Failed to ingest {source.name}: {e}") + + return status + + async def ingest_all(self, max_priority: int = 5) -> Dict[str, IngestionStatus]: + """Ingest all enabled sources up to a priority level.""" + sources = get_sources_by_priority(max_priority) + results = {} + + for source in sources: + result = await self.ingest_source(source) + results[source.name] = result + + return results + + async def ingest_by_license(self, license_type: LicenseType) -> Dict[str, IngestionStatus]: + """Ingest all sources of a specific license type.""" + from template_sources import get_sources_by_license + + sources = get_sources_by_license(license_type) + results = {} + + for source in sources: + result = await self.ingest_source(source) + results[source.name] = result + + return results + + def get_status(self) -> Dict[str, Any]: + """Get collection status and ingestion results.""" + try: + collection_info = self.qdrant.get_collection(LEGAL_TEMPLATES_COLLECTION) + + # Count points per source + source_counts = {} + for source in TEMPLATE_SOURCES: + result = self.qdrant.count( + collection_name=LEGAL_TEMPLATES_COLLECTION, + count_filter=Filter( + must=[ + FieldCondition( + key="source_name", + match=MatchValue(value=source.name), + ) + ] + ), + ) + source_counts[source.name] = result.count + + # Count by license type + license_counts = {} + for license_type in LicenseType: + result = self.qdrant.count( + collection_name=LEGAL_TEMPLATES_COLLECTION, + count_filter=Filter( + must=[ + FieldCondition( + key="license_id", + match=MatchValue(value=license_type.value), + ) + ] + ), + ) + license_counts[license_type.value] = result.count + + # Count by template type + template_type_counts = {} + for template_type in TEMPLATE_TYPES.keys(): + result = self.qdrant.count( + collection_name=LEGAL_TEMPLATES_COLLECTION, + count_filter=Filter( + must=[ + FieldCondition( + key="template_type", + match=MatchValue(value=template_type), + ) + ] + ), + ) + if result.count > 0: + template_type_counts[template_type] = result.count + + # Count by language + language_counts = {} + for lang in ["de", "en"]: + result = self.qdrant.count( + collection_name=LEGAL_TEMPLATES_COLLECTION, + count_filter=Filter( + must=[ + FieldCondition( + key="language", + match=MatchValue(value=lang), + ) + ] + ), + ) + language_counts[lang] = result.count + + return { + "collection": LEGAL_TEMPLATES_COLLECTION, + "total_points": collection_info.points_count, + "vector_size": VECTOR_SIZE, + "sources": source_counts, + "licenses": license_counts, + "template_types": template_type_counts, + "languages": language_counts, + "status": "ready" if collection_info.points_count > 0 else "empty", + "ingestion_status": { + name: { + "status": s.status, + "documents_found": s.documents_found, + "chunks_indexed": s.chunks_indexed, + "errors": s.errors, + } + for name, s in self._ingestion_status.items() + }, + } + + except Exception as e: + return { + "collection": LEGAL_TEMPLATES_COLLECTION, + "error": str(e), + "status": "error", + } + + async def search( + self, + query: str, + template_type: Optional[str] = None, + license_types: Optional[List[str]] = None, + language: Optional[str] = None, + jurisdiction: Optional[str] = None, + attribution_required: Optional[bool] = None, + top_k: int = 10, + ) -> List[Dict[str, Any]]: + """ + Search the legal templates collection. + + Args: + query: Search query text + template_type: Filter by template type (e.g., "privacy_policy") + license_types: Filter by license types (e.g., ["cc0", "mit"]) + language: Filter by language (e.g., "de") + jurisdiction: Filter by jurisdiction (e.g., "DE") + attribution_required: Filter by attribution requirement + top_k: Number of results to return + + Returns: + List of search results with full metadata + """ + # Generate query embedding + embeddings = await self._generate_embeddings([query]) + query_vector = embeddings[0] + + # Build filter conditions + must_conditions = [] + + if template_type: + must_conditions.append( + FieldCondition( + key="template_type", + match=MatchValue(value=template_type), + ) + ) + + if language: + must_conditions.append( + FieldCondition( + key="language", + match=MatchValue(value=language), + ) + ) + + if jurisdiction: + must_conditions.append( + FieldCondition( + key="jurisdiction", + match=MatchValue(value=jurisdiction), + ) + ) + + if attribution_required is not None: + must_conditions.append( + FieldCondition( + key="attribution_required", + match=MatchValue(value=attribution_required), + ) + ) + + # License type filter (OR condition) + should_conditions = [] + if license_types: + for license_type in license_types: + should_conditions.append( + FieldCondition( + key="license_id", + match=MatchValue(value=license_type), + ) + ) + + # Construct filter + search_filter = None + if must_conditions or should_conditions: + filter_dict = {} + if must_conditions: + filter_dict["must"] = must_conditions + if should_conditions: + filter_dict["should"] = should_conditions + search_filter = Filter(**filter_dict) + + # Execute search + results = self.qdrant.search( + collection_name=LEGAL_TEMPLATES_COLLECTION, + query_vector=query_vector, + query_filter=search_filter, + limit=top_k, + ) + + return [ + { + "id": hit.id, + "score": hit.score, + "text": hit.payload.get("text"), + "document_title": hit.payload.get("document_title"), + "template_type": hit.payload.get("template_type"), + "clause_category": hit.payload.get("clause_category"), + "language": hit.payload.get("language"), + "jurisdiction": hit.payload.get("jurisdiction"), + "license_id": hit.payload.get("license_id"), + "license_name": hit.payload.get("license_name"), + "attribution_required": hit.payload.get("attribution_required"), + "attribution_text": hit.payload.get("attribution_text"), + "source_name": hit.payload.get("source_name"), + "source_url": hit.payload.get("source_url"), + "placeholders": hit.payload.get("placeholders"), + "is_complete_document": hit.payload.get("is_complete_document"), + "requires_customization": hit.payload.get("requires_customization"), + "output_allowed": hit.payload.get("output_allowed"), + "modification_allowed": hit.payload.get("modification_allowed"), + } + for hit in results + ] + + def delete_source(self, source_name: str) -> int: + """Delete all chunks from a specific source.""" + # First count how many we're deleting + count_result = self.qdrant.count( + collection_name=LEGAL_TEMPLATES_COLLECTION, + count_filter=Filter( + must=[ + FieldCondition( + key="source_name", + match=MatchValue(value=source_name), + ) + ] + ), + ) + + # Delete by filter + self.qdrant.delete( + collection_name=LEGAL_TEMPLATES_COLLECTION, + points_selector=Filter( + must=[ + FieldCondition( + key="source_name", + match=MatchValue(value=source_name), + ) + ] + ), + ) + + return count_result.count + + def reset_collection(self): + """Delete and recreate the collection.""" + logger.warning(f"Resetting collection: {LEGAL_TEMPLATES_COLLECTION}") + + # Delete collection + try: + self.qdrant.delete_collection(LEGAL_TEMPLATES_COLLECTION) + except Exception: + pass # Collection might not exist + + # Recreate + self._ensure_collection() + self._ingestion_status.clear() + + logger.info(f"Collection {LEGAL_TEMPLATES_COLLECTION} reset") + + async def close(self): + """Close HTTP client.""" + await self.http_client.aclose() + + +async def main(): + """CLI entry point.""" + import argparse + + parser = argparse.ArgumentParser(description="Legal Templates Ingestion") + parser.add_argument( + "--ingest-all", + action="store_true", + help="Ingest all enabled sources" + ) + parser.add_argument( + "--ingest-source", + type=str, + metavar="NAME", + help="Ingest a specific source by name" + ) + parser.add_argument( + "--ingest-license", + type=str, + choices=["cc0", "mit", "cc_by_4", "public_domain"], + help="Ingest all sources of a specific license type" + ) + parser.add_argument( + "--max-priority", + type=int, + default=3, + help="Maximum priority level to ingest (1=highest, 5=lowest)" + ) + parser.add_argument( + "--status", + action="store_true", + help="Show collection status" + ) + parser.add_argument( + "--search", + type=str, + metavar="QUERY", + help="Test search query" + ) + parser.add_argument( + "--template-type", + type=str, + help="Filter search by template type" + ) + parser.add_argument( + "--language", + type=str, + help="Filter search by language" + ) + parser.add_argument( + "--reset", + action="store_true", + help="Reset (delete and recreate) the collection" + ) + parser.add_argument( + "--delete-source", + type=str, + metavar="NAME", + help="Delete all chunks from a source" + ) + + args = parser.parse_args() + + ingestion = LegalTemplatesIngestion() + + try: + if args.reset: + ingestion.reset_collection() + print("Collection reset successfully") + + elif args.delete_source: + count = ingestion.delete_source(args.delete_source) + print(f"Deleted {count} chunks from {args.delete_source}") + + elif args.status: + status = ingestion.get_status() + print(json.dumps(status, indent=2, default=str)) + + elif args.ingest_all: + print(f"Ingesting all sources (max priority: {args.max_priority})...") + results = await ingestion.ingest_all(max_priority=args.max_priority) + print("\nResults:") + for name, status in results.items(): + print(f" {name}: {status.chunks_indexed} chunks ({status.status})") + if status.errors: + for error in status.errors: + print(f" ERROR: {error}") + total = sum(s.chunks_indexed for s in results.values()) + print(f"\nTotal: {total} chunks indexed") + + elif args.ingest_source: + source = next( + (s for s in TEMPLATE_SOURCES if s.name == args.ingest_source), + None + ) + if not source: + print(f"Unknown source: {args.ingest_source}") + print("Available sources:") + for s in TEMPLATE_SOURCES: + print(f" - {s.name}") + return + + print(f"Ingesting: {source.name}") + status = await ingestion.ingest_source(source) + print(f"\nResult: {status.chunks_indexed} chunks ({status.status})") + if status.errors: + for error in status.errors: + print(f" ERROR: {error}") + + elif args.ingest_license: + license_type = LicenseType(args.ingest_license) + print(f"Ingesting all {license_type.value} sources...") + results = await ingestion.ingest_by_license(license_type) + print("\nResults:") + for name, status in results.items(): + print(f" {name}: {status.chunks_indexed} chunks ({status.status})") + + elif args.search: + print(f"Searching: {args.search}") + results = await ingestion.search( + args.search, + template_type=args.template_type, + language=args.language, + ) + print(f"\nFound {len(results)} results:") + for i, result in enumerate(results, 1): + print(f"\n{i}. [{result['template_type']}] {result['document_title']}") + print(f" Score: {result['score']:.3f}") + print(f" License: {result['license_name']}") + print(f" Source: {result['source_name']}") + print(f" Language: {result['language']}") + if result['attribution_required']: + print(f" Attribution: {result['attribution_text']}") + print(f" Text: {result['text'][:200]}...") + + else: + parser.print_help() + + finally: + await ingestion.close() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/klausur-service/backend/mail/__init__.py b/klausur-service/backend/mail/__init__.py new file mode 100644 index 0000000..dd697fa --- /dev/null +++ b/klausur-service/backend/mail/__init__.py @@ -0,0 +1,106 @@ +""" +Unified Inbox Mail Module + +Multi-Account IMAP aggregation with KI-powered analysis. + +Features: +- Multi-account IMAP aggregation +- Secure credential storage (Vault/encrypted) +- KI-powered email analysis (sender classification, deadline extraction) +- Arbeitsvorrat (task management) with deadline tracking +- Response suggestions + +Usage: + from mail.api import router as mail_router + app.include_router(mail_router) + +API Endpoints: + POST /api/v1/mail/init - Initialize database tables + POST /api/v1/mail/accounts - Create email account + GET /api/v1/mail/accounts - List accounts + GET /api/v1/mail/inbox - Get unified inbox + POST /api/v1/mail/analyze/{id} - Analyze email with AI + GET /api/v1/mail/tasks - Get tasks (Arbeitsvorrat) + GET /api/v1/mail/tasks/dashboard - Dashboard statistics +""" + +from .models import ( + # Enums + AccountStatus, + TaskStatus, + TaskPriority, + EmailCategory, + SenderType, + # Account models + EmailAccountCreate, + EmailAccountUpdate, + EmailAccount, + AccountTestResult, + # Email models + AggregatedEmail, + EmailSearchParams, + EmailComposeRequest, + EmailSendResult, + # Task models + TaskCreate, + TaskUpdate, + InboxTask, + TaskDashboardStats, + # AI models + SenderClassification, + DeadlineExtraction, + EmailAnalysisResult, + ResponseSuggestion, + # Stats + MailStats, + MailHealthCheck, + # Templates + EmailTemplate, + EmailTemplateCreate, +) + +from .api import router +from .aggregator import get_mail_aggregator +from .ai_service import get_ai_email_service +from .task_service import get_task_service +from .credentials import get_credentials_service +from .mail_db import init_mail_tables + +__all__ = [ + # Router + "router", + # Services + "get_mail_aggregator", + "get_ai_email_service", + "get_task_service", + "get_credentials_service", + # Database + "init_mail_tables", + # Enums + "AccountStatus", + "TaskStatus", + "TaskPriority", + "EmailCategory", + "SenderType", + # Models + "EmailAccountCreate", + "EmailAccountUpdate", + "EmailAccount", + "AccountTestResult", + "AggregatedEmail", + "EmailSearchParams", + "EmailComposeRequest", + "EmailSendResult", + "TaskCreate", + "TaskUpdate", + "InboxTask", + "TaskDashboardStats", + "SenderClassification", + "DeadlineExtraction", + "EmailAnalysisResult", + "ResponseSuggestion", + "MailStats", + "MailHealthCheck", + "EmailTemplate", + "EmailTemplateCreate", +] diff --git a/klausur-service/backend/mail/aggregator.py b/klausur-service/backend/mail/aggregator.py new file mode 100644 index 0000000..081a61c --- /dev/null +++ b/klausur-service/backend/mail/aggregator.py @@ -0,0 +1,541 @@ +""" +Mail Aggregator Service + +Multi-account IMAP aggregation with async support. +""" + +import os +import ssl +import email +import asyncio +import logging +import smtplib +from typing import Optional, List, Dict, Any, Tuple +from datetime import datetime, timezone +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from email.header import decode_header, make_header +from email.utils import parsedate_to_datetime, parseaddr + +from .credentials import get_credentials_service, MailCredentials +from .mail_db import ( + get_email_accounts, + get_email_account, + update_account_status, + upsert_email, + get_unified_inbox, +) +from .models import ( + AccountStatus, + AccountTestResult, + AggregatedEmail, + EmailComposeRequest, + EmailSendResult, +) + +logger = logging.getLogger(__name__) + + +class IMAPConnectionError(Exception): + """Raised when IMAP connection fails.""" + pass + + +class SMTPConnectionError(Exception): + """Raised when SMTP connection fails.""" + pass + + +class MailAggregator: + """ + Aggregates emails from multiple IMAP accounts into a unified inbox. + + Features: + - Connect to multiple IMAP accounts + - Fetch and cache emails in PostgreSQL + - Send emails via SMTP + - Handle connection pooling + """ + + def __init__(self): + self._credentials_service = get_credentials_service() + self._imap_connections: Dict[str, Any] = {} + self._sync_lock = asyncio.Lock() + + async def test_account_connection( + self, + imap_host: str, + imap_port: int, + imap_ssl: bool, + smtp_host: str, + smtp_port: int, + smtp_ssl: bool, + email_address: str, + password: str, + ) -> AccountTestResult: + """ + Test IMAP and SMTP connection with provided credentials. + + Returns: + AccountTestResult with connection status + """ + result = AccountTestResult( + success=False, + imap_connected=False, + smtp_connected=False, + ) + + # Test IMAP + try: + import imaplib + + if imap_ssl: + imap = imaplib.IMAP4_SSL(imap_host, imap_port) + else: + imap = imaplib.IMAP4(imap_host, imap_port) + + imap.login(email_address, password) + result.imap_connected = True + + # List folders + status, folders = imap.list() + if status == "OK": + result.folders_found = [ + self._parse_folder_name(f) for f in folders if f + ] + + imap.logout() + + except Exception as e: + result.error_message = f"IMAP Error: {str(e)}" + logger.warning(f"IMAP test failed for {email_address}: {e}") + + # Test SMTP + try: + if smtp_ssl: + smtp = smtplib.SMTP_SSL(smtp_host, smtp_port) + else: + smtp = smtplib.SMTP(smtp_host, smtp_port) + smtp.starttls() + + smtp.login(email_address, password) + result.smtp_connected = True + smtp.quit() + + except Exception as e: + smtp_error = f"SMTP Error: {str(e)}" + if result.error_message: + result.error_message += f"; {smtp_error}" + else: + result.error_message = smtp_error + logger.warning(f"SMTP test failed for {email_address}: {e}") + + result.success = result.imap_connected and result.smtp_connected + return result + + def _parse_folder_name(self, folder_response: bytes) -> str: + """Parse folder name from IMAP LIST response.""" + try: + # Format: '(\\HasNoChildren) "/" "INBOX"' + decoded = folder_response.decode("utf-8") if isinstance(folder_response, bytes) else folder_response + parts = decoded.rsplit('" "', 1) + if len(parts) == 2: + return parts[1].rstrip('"') + return decoded + except Exception: + return str(folder_response) + + async def sync_account( + self, + account_id: str, + user_id: str, + max_emails: int = 100, + folders: Optional[List[str]] = None, + ) -> Tuple[int, int]: + """ + Sync emails from an IMAP account. + + Args: + account_id: The account ID + user_id: The user ID + max_emails: Maximum emails to fetch + folders: Specific folders to sync (default: INBOX) + + Returns: + Tuple of (new_emails, total_emails) + """ + import imaplib + + account = await get_email_account(account_id, user_id) + if not account: + raise ValueError(f"Account not found: {account_id}") + + # Get credentials + vault_path = account.get("vault_path", "") + creds = await self._credentials_service.get_credentials(account_id, vault_path) + if not creds: + await update_account_status(account_id, "error", "Credentials not found") + raise IMAPConnectionError("Credentials not found") + + new_count = 0 + total_count = 0 + + try: + # Connect to IMAP + if account["imap_ssl"]: + imap = imaplib.IMAP4_SSL(account["imap_host"], account["imap_port"]) + else: + imap = imaplib.IMAP4(account["imap_host"], account["imap_port"]) + + imap.login(creds.email, creds.password) + + # Sync specified folders or just INBOX + sync_folders = folders or ["INBOX"] + + for folder in sync_folders: + try: + status, _ = imap.select(folder) + if status != "OK": + continue + + # Search for recent emails + status, messages = imap.search(None, "ALL") + if status != "OK": + continue + + message_ids = messages[0].split() + total_count += len(message_ids) + + # Fetch most recent emails + recent_ids = message_ids[-max_emails:] if len(message_ids) > max_emails else message_ids + + for msg_id in recent_ids: + try: + email_data = await self._fetch_and_store_email( + imap, msg_id, account_id, user_id, account["tenant_id"], folder + ) + if email_data: + new_count += 1 + except Exception as e: + logger.warning(f"Failed to fetch email {msg_id}: {e}") + + except Exception as e: + logger.warning(f"Failed to sync folder {folder}: {e}") + + imap.logout() + + # Update account status + await update_account_status( + account_id, + "active", + email_count=total_count, + unread_count=new_count, # Will be recalculated + ) + + return new_count, total_count + + except Exception as e: + logger.error(f"Account sync failed: {e}") + await update_account_status(account_id, "error", str(e)) + raise IMAPConnectionError(str(e)) + + async def _fetch_and_store_email( + self, + imap, + msg_id: bytes, + account_id: str, + user_id: str, + tenant_id: str, + folder: str, + ) -> Optional[str]: + """Fetch a single email and store it in the database.""" + try: + status, msg_data = imap.fetch(msg_id, "(RFC822)") + if status != "OK" or not msg_data or not msg_data[0]: + return None + + raw_email = msg_data[0][1] + msg = email.message_from_bytes(raw_email) + + # Parse headers + message_id = msg.get("Message-ID", str(msg_id)) + subject = self._decode_header(msg.get("Subject", "")) + from_header = msg.get("From", "") + sender_name, sender_email = parseaddr(from_header) + sender_name = self._decode_header(sender_name) + + # Parse recipients + to_header = msg.get("To", "") + recipients = [addr[1] for addr in email.utils.getaddresses([to_header])] + + cc_header = msg.get("Cc", "") + cc = [addr[1] for addr in email.utils.getaddresses([cc_header])] + + # Parse dates + date_str = msg.get("Date") + try: + date_sent = parsedate_to_datetime(date_str) if date_str else datetime.now(timezone.utc) + except Exception: + date_sent = datetime.now(timezone.utc) + + date_received = datetime.now(timezone.utc) + + # Parse body + body_text, body_html, attachments = self._parse_body(msg) + + # Create preview + body_preview = (body_text[:200] + "...") if body_text and len(body_text) > 200 else body_text + + # Get headers dict + headers = {k: self._decode_header(v) for k, v in msg.items() if k not in ["Body"]} + + # Store in database + email_id = await upsert_email( + account_id=account_id, + user_id=user_id, + tenant_id=tenant_id, + message_id=message_id, + subject=subject, + sender_email=sender_email, + sender_name=sender_name, + recipients=recipients, + cc=cc, + body_preview=body_preview, + body_text=body_text, + body_html=body_html, + has_attachments=len(attachments) > 0, + attachments=attachments, + headers=headers, + folder=folder, + date_sent=date_sent, + date_received=date_received, + ) + + return email_id + + except Exception as e: + logger.error(f"Failed to parse email: {e}") + return None + + def _decode_header(self, header_value: str) -> str: + """Decode email header value.""" + if not header_value: + return "" + try: + decoded = decode_header(header_value) + return str(make_header(decoded)) + except Exception: + return str(header_value) + + def _parse_body(self, msg) -> Tuple[Optional[str], Optional[str], List[Dict]]: + """ + Parse email body and attachments. + + Returns: + Tuple of (body_text, body_html, attachments) + """ + body_text = None + body_html = None + attachments = [] + + if msg.is_multipart(): + for part in msg.walk(): + content_type = part.get_content_type() + content_disposition = str(part.get("Content-Disposition", "")) + + # Skip multipart containers + if content_type.startswith("multipart/"): + continue + + # Check for attachments + if "attachment" in content_disposition: + filename = part.get_filename() + if filename: + attachments.append({ + "filename": self._decode_header(filename), + "content_type": content_type, + "size": len(part.get_payload(decode=True) or b""), + }) + continue + + # Get body content + try: + payload = part.get_payload(decode=True) + charset = part.get_content_charset() or "utf-8" + + if payload: + text = payload.decode(charset, errors="replace") + + if content_type == "text/plain" and not body_text: + body_text = text + elif content_type == "text/html" and not body_html: + body_html = text + except Exception as e: + logger.debug(f"Failed to decode body part: {e}") + + else: + # Single part message + content_type = msg.get_content_type() + try: + payload = msg.get_payload(decode=True) + charset = msg.get_content_charset() or "utf-8" + + if payload: + text = payload.decode(charset, errors="replace") + + if content_type == "text/plain": + body_text = text + elif content_type == "text/html": + body_html = text + except Exception as e: + logger.debug(f"Failed to decode body: {e}") + + return body_text, body_html, attachments + + async def send_email( + self, + account_id: str, + user_id: str, + request: EmailComposeRequest, + ) -> EmailSendResult: + """ + Send an email via SMTP. + + Args: + account_id: The account to send from + user_id: The user ID + request: The compose request with recipients and content + + Returns: + EmailSendResult with success status + """ + account = await get_email_account(account_id, user_id) + if not account: + return EmailSendResult(success=False, error="Account not found") + + # Verify the account_id matches + if request.account_id != account_id: + return EmailSendResult(success=False, error="Account mismatch") + + # Get credentials + vault_path = account.get("vault_path", "") + creds = await self._credentials_service.get_credentials(account_id, vault_path) + if not creds: + return EmailSendResult(success=False, error="Credentials not found") + + try: + # Create message + if request.is_html: + msg = MIMEMultipart("alternative") + msg.attach(MIMEText(request.body, "html")) + else: + msg = MIMEText(request.body, "plain") + + msg["Subject"] = request.subject + msg["From"] = account["email"] + msg["To"] = ", ".join(request.to) + + if request.cc: + msg["Cc"] = ", ".join(request.cc) + + if request.reply_to_message_id: + msg["In-Reply-To"] = request.reply_to_message_id + msg["References"] = request.reply_to_message_id + + # Send via SMTP + if account["smtp_ssl"]: + smtp = smtplib.SMTP_SSL(account["smtp_host"], account["smtp_port"]) + else: + smtp = smtplib.SMTP(account["smtp_host"], account["smtp_port"]) + smtp.starttls() + + smtp.login(creds.email, creds.password) + + # All recipients + all_recipients = list(request.to) + if request.cc: + all_recipients.extend(request.cc) + if request.bcc: + all_recipients.extend(request.bcc) + + smtp.sendmail(account["email"], all_recipients, msg.as_string()) + smtp.quit() + + return EmailSendResult( + success=True, + message_id=msg.get("Message-ID"), + ) + + except Exception as e: + logger.error(f"Failed to send email: {e}") + return EmailSendResult(success=False, error=str(e)) + + async def sync_all_accounts(self, user_id: str, tenant_id: Optional[str] = None) -> Dict[str, Any]: + """ + Sync all accounts for a user. + + Returns: + Dict with sync results per account + """ + async with self._sync_lock: + accounts = await get_email_accounts(user_id, tenant_id) + results = {} + + for account in accounts: + account_id = account["id"] + try: + new_count, total_count = await self.sync_account( + account_id, user_id, max_emails=50 + ) + results[account_id] = { + "status": "success", + "new_emails": new_count, + "total_emails": total_count, + } + except Exception as e: + results[account_id] = { + "status": "error", + "error": str(e), + } + + return results + + async def get_unified_inbox_emails( + self, + user_id: str, + account_ids: Optional[List[str]] = None, + categories: Optional[List[str]] = None, + is_read: Optional[bool] = None, + is_starred: Optional[bool] = None, + limit: int = 50, + offset: int = 0, + ) -> List[Dict]: + """ + Get unified inbox with all filters. + + Returns: + List of email dictionaries + """ + return await get_unified_inbox( + user_id=user_id, + account_ids=account_ids, + categories=categories, + is_read=is_read, + is_starred=is_starred, + limit=limit, + offset=offset, + ) + + +# Global instance +_aggregator: Optional[MailAggregator] = None + + +def get_mail_aggregator() -> MailAggregator: + """Get or create the global MailAggregator instance.""" + global _aggregator + + if _aggregator is None: + _aggregator = MailAggregator() + + return _aggregator diff --git a/klausur-service/backend/mail/ai_service.py b/klausur-service/backend/mail/ai_service.py new file mode 100644 index 0000000..0323084 --- /dev/null +++ b/klausur-service/backend/mail/ai_service.py @@ -0,0 +1,747 @@ +""" +AI Email Analysis Service + +KI-powered email analysis with: +- Sender classification (authority recognition) +- Deadline extraction +- Category classification +- Response suggestions +""" + +import os +import re +import logging +from typing import Optional, List, Dict, Any, Tuple +from datetime import datetime, timedelta +import httpx + +from .models import ( + EmailCategory, + SenderType, + TaskPriority, + SenderClassification, + DeadlineExtraction, + EmailAnalysisResult, + ResponseSuggestion, + KNOWN_AUTHORITIES_NI, + classify_sender_by_domain, + get_priority_from_sender_type, +) +from .mail_db import update_email_ai_analysis + +logger = logging.getLogger(__name__) + +# LLM Gateway configuration +LLM_GATEWAY_URL = os.getenv("LLM_GATEWAY_URL", "http://localhost:8090") + + +class AIEmailService: + """ + AI-powered email analysis service. + + Features: + - Domain-based sender classification (fast, no LLM) + - LLM-based sender classification (fallback) + - Deadline extraction using regex + LLM + - Category classification + - Response suggestions + """ + + def __init__(self): + self._http_client = None + + async def get_http_client(self) -> httpx.AsyncClient: + """Get or create HTTP client for LLM gateway.""" + if self._http_client is None: + self._http_client = httpx.AsyncClient(timeout=30.0) + return self._http_client + + # ========================================================================= + # Sender Classification + # ========================================================================= + + async def classify_sender( + self, + sender_email: str, + sender_name: Optional[str] = None, + subject: Optional[str] = None, + body_preview: Optional[str] = None, + ) -> SenderClassification: + """ + Classify the sender of an email. + + First tries domain matching, then falls back to LLM. + + Args: + sender_email: Sender's email address + sender_name: Sender's display name + subject: Email subject + body_preview: First 200 chars of body + + Returns: + SenderClassification with type and confidence + """ + # Try domain-based classification first (fast, high confidence) + domain_result = classify_sender_by_domain(sender_email) + if domain_result: + return domain_result + + # Fall back to LLM classification + return await self._classify_sender_llm( + sender_email, sender_name, subject, body_preview + ) + + async def _classify_sender_llm( + self, + sender_email: str, + sender_name: Optional[str], + subject: Optional[str], + body_preview: Optional[str], + ) -> SenderClassification: + """Classify sender using LLM.""" + try: + client = await self.get_http_client() + + prompt = f"""Analysiere den Absender dieser E-Mail und klassifiziere ihn: + +Absender E-Mail: {sender_email} +Absender Name: {sender_name or "Nicht angegeben"} +Betreff: {subject or "Nicht angegeben"} +Vorschau: {body_preview[:200] if body_preview else "Nicht verfügbar"} + +Klassifiziere den Absender in EINE der folgenden Kategorien: +- kultusministerium: Kultusministerium/Bildungsministerium +- landesschulbehoerde: Landesschulbehörde +- rlsb: Regionales Landesamt für Schule und Bildung +- schulamt: Schulamt +- nibis: Niedersächsischer Bildungsserver +- schultraeger: Schulträger/Kommune +- elternvertreter: Elternvertreter/Elternrat +- gewerkschaft: Gewerkschaft (GEW, VBE, etc.) +- fortbildungsinstitut: Fortbildungsinstitut (NLQ, etc.) +- privatperson: Privatperson +- unternehmen: Unternehmen/Firma +- unbekannt: Nicht einzuordnen + +Antworte NUR mit dem Kategorienamen (z.B. "kultusministerium") und einer Konfidenz von 0.0 bis 1.0. +Format: kategorie|konfidenz|kurze_begründung +""" + + response = await client.post( + f"{LLM_GATEWAY_URL}/api/v1/inference", + json={ + "prompt": prompt, + "playbook": "mail_analysis", + "max_tokens": 100, + }, + ) + + if response.status_code == 200: + data = response.json() + result_text = data.get("response", "unbekannt|0.5|") + + # Parse response + parts = result_text.strip().split("|") + if len(parts) >= 2: + sender_type_str = parts[0].strip().lower() + confidence = float(parts[1].strip()) + + # Map to enum + type_mapping = { + "kultusministerium": SenderType.KULTUSMINISTERIUM, + "landesschulbehoerde": SenderType.LANDESSCHULBEHOERDE, + "rlsb": SenderType.RLSB, + "schulamt": SenderType.SCHULAMT, + "nibis": SenderType.NIBIS, + "schultraeger": SenderType.SCHULTRAEGER, + "elternvertreter": SenderType.ELTERNVERTRETER, + "gewerkschaft": SenderType.GEWERKSCHAFT, + "fortbildungsinstitut": SenderType.FORTBILDUNGSINSTITUT, + "privatperson": SenderType.PRIVATPERSON, + "unternehmen": SenderType.UNTERNEHMEN, + } + + sender_type = type_mapping.get(sender_type_str, SenderType.UNBEKANNT) + + return SenderClassification( + sender_type=sender_type, + confidence=min(max(confidence, 0.0), 1.0), + domain_matched=False, + ai_classified=True, + ) + + except Exception as e: + logger.warning(f"LLM sender classification failed: {e}") + + # Default fallback + return SenderClassification( + sender_type=SenderType.UNBEKANNT, + confidence=0.3, + domain_matched=False, + ai_classified=False, + ) + + # ========================================================================= + # Deadline Extraction + # ========================================================================= + + async def extract_deadlines( + self, + subject: str, + body_text: str, + ) -> List[DeadlineExtraction]: + """ + Extract deadlines from email content. + + Uses regex patterns first, then LLM for complex cases. + + Args: + subject: Email subject + body_text: Email body text + + Returns: + List of extracted deadlines + """ + deadlines = [] + + # Combine subject and body + full_text = f"{subject}\n{body_text}" if body_text else subject + + # Try regex extraction first + regex_deadlines = self._extract_deadlines_regex(full_text) + deadlines.extend(regex_deadlines) + + # If no regex matches, try LLM + if not deadlines and body_text: + llm_deadlines = await self._extract_deadlines_llm(subject, body_text[:1000]) + deadlines.extend(llm_deadlines) + + return deadlines + + def _extract_deadlines_regex(self, text: str) -> List[DeadlineExtraction]: + """Extract deadlines using regex patterns.""" + deadlines = [] + now = datetime.now() + + # German date patterns + patterns = [ + # "bis zum 15.01.2025" + (r"bis\s+(?:zum\s+)?(\d{1,2})\.(\d{1,2})\.(\d{2,4})", True), + # "spätestens am 15.01.2025" + (r"spätestens\s+(?:am\s+)?(\d{1,2})\.(\d{1,2})\.(\d{2,4})", True), + # "Abgabetermin: 15.01.2025" + (r"(?:Abgabe|Termin|Frist)[:\s]+(\d{1,2})\.(\d{1,2})\.(\d{2,4})", True), + # "innerhalb von 14 Tagen" + (r"innerhalb\s+von\s+(\d+)\s+(?:Tagen|Wochen)", False), + # "bis Ende Januar" + (r"bis\s+(?:Ende\s+)?(Januar|Februar|März|April|Mai|Juni|Juli|August|September|Oktober|November|Dezember)", False), + ] + + for pattern, is_specific_date in patterns: + matches = re.finditer(pattern, text, re.IGNORECASE) + + for match in matches: + try: + if is_specific_date: + day = int(match.group(1)) + month = int(match.group(2)) + year = int(match.group(3)) + + # Handle 2-digit years + if year < 100: + year += 2000 + + deadline_date = datetime(year, month, day) + + # Skip past dates + if deadline_date < now: + continue + + # Get surrounding context + start = max(0, match.start() - 50) + end = min(len(text), match.end() + 50) + context = text[start:end].strip() + + deadlines.append(DeadlineExtraction( + deadline_date=deadline_date, + description=f"Frist: {match.group(0)}", + confidence=0.85, + source_text=context, + is_firm=True, + )) + + else: + # Relative dates (innerhalb von X Tagen) + if "Tagen" in pattern or "Wochen" in pattern: + days = int(match.group(1)) + if "Wochen" in match.group(0).lower(): + days *= 7 + deadline_date = now + timedelta(days=days) + + deadlines.append(DeadlineExtraction( + deadline_date=deadline_date, + description=f"Relative Frist: {match.group(0)}", + confidence=0.7, + source_text=match.group(0), + is_firm=False, + )) + + except (ValueError, IndexError) as e: + logger.debug(f"Failed to parse date: {e}") + continue + + return deadlines + + async def _extract_deadlines_llm( + self, + subject: str, + body_preview: str, + ) -> List[DeadlineExtraction]: + """Extract deadlines using LLM.""" + try: + client = await self.get_http_client() + + prompt = f"""Analysiere diese E-Mail und extrahiere alle genannten Fristen und Termine: + +Betreff: {subject} +Inhalt: {body_preview} + +Liste alle Fristen im folgenden Format auf (eine pro Zeile): +DATUM|BESCHREIBUNG|VERBINDLICH +Beispiel: 2025-01-15|Abgabe der Berichte|ja + +Wenn keine Fristen gefunden werden, antworte mit: KEINE_FRISTEN + +Antworte NUR im angegebenen Format. +""" + + response = await client.post( + f"{LLM_GATEWAY_URL}/api/v1/inference", + json={ + "prompt": prompt, + "playbook": "mail_analysis", + "max_tokens": 200, + }, + ) + + if response.status_code == 200: + data = response.json() + result_text = data.get("response", "") + + if "KEINE_FRISTEN" in result_text: + return [] + + deadlines = [] + for line in result_text.strip().split("\n"): + parts = line.split("|") + if len(parts) >= 2: + try: + date_str = parts[0].strip() + deadline_date = datetime.fromisoformat(date_str) + description = parts[1].strip() + is_firm = parts[2].strip().lower() == "ja" if len(parts) > 2 else True + + deadlines.append(DeadlineExtraction( + deadline_date=deadline_date, + description=description, + confidence=0.7, + source_text=line, + is_firm=is_firm, + )) + except (ValueError, IndexError): + continue + + return deadlines + + except Exception as e: + logger.warning(f"LLM deadline extraction failed: {e}") + + return [] + + # ========================================================================= + # Email Category Classification + # ========================================================================= + + async def classify_category( + self, + subject: str, + body_preview: str, + sender_type: SenderType, + ) -> Tuple[EmailCategory, float]: + """ + Classify email into a category. + + Args: + subject: Email subject + body_preview: First 200 chars of body + sender_type: Already classified sender type + + Returns: + Tuple of (category, confidence) + """ + # Rule-based classification first + category, confidence = self._classify_category_rules(subject, body_preview, sender_type) + + if confidence > 0.7: + return category, confidence + + # Fall back to LLM + return await self._classify_category_llm(subject, body_preview) + + def _classify_category_rules( + self, + subject: str, + body_preview: str, + sender_type: SenderType, + ) -> Tuple[EmailCategory, float]: + """Rule-based category classification.""" + text = f"{subject} {body_preview}".lower() + + # Keywords for each category + category_keywords = { + EmailCategory.DIENSTLICH: [ + "dienstlich", "dienstanweisung", "erlass", "verordnung", + "bescheid", "verfügung", "ministerium", "behörde" + ], + EmailCategory.PERSONAL: [ + "personalrat", "stellenausschreibung", "versetzung", + "beurteilung", "dienstzeugnis", "krankmeldung", "elternzeit" + ], + EmailCategory.FINANZEN: [ + "budget", "haushalt", "etat", "abrechnung", "rechnung", + "erstattung", "zuschuss", "fördermittel" + ], + EmailCategory.ELTERN: [ + "elternbrief", "elternabend", "schulkonferenz", + "elternvertreter", "elternbeirat" + ], + EmailCategory.SCHUELER: [ + "schüler", "schülerin", "zeugnis", "klasse", "unterricht", + "prüfung", "klassenfahrt", "schulpflicht" + ], + EmailCategory.FORTBILDUNG: [ + "fortbildung", "seminar", "workshop", "schulung", + "weiterbildung", "nlq", "didaktik" + ], + EmailCategory.VERANSTALTUNG: [ + "einladung", "veranstaltung", "termin", "konferenz", + "sitzung", "tagung", "feier" + ], + EmailCategory.SICHERHEIT: [ + "sicherheit", "notfall", "brandschutz", "evakuierung", + "hygiene", "corona", "infektionsschutz" + ], + EmailCategory.TECHNIK: [ + "it", "software", "computer", "netzwerk", "login", + "passwort", "digitalisierung", "iserv" + ], + EmailCategory.NEWSLETTER: [ + "newsletter", "rundschreiben", "info-mail", "mitteilung" + ], + EmailCategory.WERBUNG: [ + "angebot", "rabatt", "aktion", "werbung", "abonnement" + ], + } + + best_category = EmailCategory.SONSTIGES + best_score = 0.0 + + for category, keywords in category_keywords.items(): + score = sum(1 for kw in keywords if kw in text) + if score > best_score: + best_score = score + best_category = category + + # Adjust based on sender type + if sender_type in [SenderType.KULTUSMINISTERIUM, SenderType.LANDESSCHULBEHOERDE, SenderType.RLSB]: + if best_category == EmailCategory.SONSTIGES: + best_category = EmailCategory.DIENSTLICH + best_score = 2 + + # Convert score to confidence + confidence = min(0.9, 0.4 + (best_score * 0.15)) + + return best_category, confidence + + async def _classify_category_llm( + self, + subject: str, + body_preview: str, + ) -> Tuple[EmailCategory, float]: + """LLM-based category classification.""" + try: + client = await self.get_http_client() + + categories = ", ".join([c.value for c in EmailCategory]) + + prompt = f"""Klassifiziere diese E-Mail in EINE Kategorie: + +Betreff: {subject} +Inhalt: {body_preview[:500]} + +Kategorien: {categories} + +Antworte NUR mit dem Kategorienamen und einer Konfidenz (0.0-1.0): +Format: kategorie|konfidenz +""" + + response = await client.post( + f"{LLM_GATEWAY_URL}/api/v1/inference", + json={ + "prompt": prompt, + "playbook": "mail_analysis", + "max_tokens": 50, + }, + ) + + if response.status_code == 200: + data = response.json() + result = data.get("response", "sonstiges|0.5") + parts = result.strip().split("|") + + if len(parts) >= 2: + category_str = parts[0].strip().lower() + confidence = float(parts[1].strip()) + + try: + category = EmailCategory(category_str) + return category, min(max(confidence, 0.0), 1.0) + except ValueError: + pass + + except Exception as e: + logger.warning(f"LLM category classification failed: {e}") + + return EmailCategory.SONSTIGES, 0.5 + + # ========================================================================= + # Full Analysis Pipeline + # ========================================================================= + + async def analyze_email( + self, + email_id: str, + sender_email: str, + sender_name: Optional[str], + subject: str, + body_text: Optional[str], + body_preview: Optional[str], + ) -> EmailAnalysisResult: + """ + Run full analysis pipeline on an email. + + Args: + email_id: Database ID of the email + sender_email: Sender's email address + sender_name: Sender's display name + subject: Email subject + body_text: Full body text + body_preview: Preview text + + Returns: + Complete analysis result + """ + # 1. Classify sender + sender_classification = await self.classify_sender( + sender_email, sender_name, subject, body_preview + ) + + # 2. Extract deadlines + deadlines = await self.extract_deadlines(subject, body_text or "") + + # 3. Classify category + category, category_confidence = await self.classify_category( + subject, body_preview or "", sender_classification.sender_type + ) + + # 4. Determine priority + suggested_priority = get_priority_from_sender_type(sender_classification.sender_type) + + # Upgrade priority if deadlines are found + if deadlines: + nearest_deadline = min(d.deadline_date for d in deadlines) + days_until = (nearest_deadline - datetime.now()).days + + if days_until <= 1: + suggested_priority = TaskPriority.URGENT + elif days_until <= 3: + suggested_priority = TaskPriority.HIGH + elif days_until <= 7: + suggested_priority = max(suggested_priority, TaskPriority.MEDIUM) + + # 5. Generate summary (optional, can be expensive) + summary = None # Could add LLM summary generation here + + # 6. Determine if task should be auto-created + auto_create_task = ( + len(deadlines) > 0 or + sender_classification.sender_type in [ + SenderType.KULTUSMINISTERIUM, + SenderType.LANDESSCHULBEHOERDE, + SenderType.RLSB, + ] + ) + + # 7. Store analysis in database + await update_email_ai_analysis( + email_id=email_id, + category=category.value, + sender_type=sender_classification.sender_type.value, + sender_authority_name=sender_classification.authority_name, + detected_deadlines=[ + { + "date": d.deadline_date.isoformat(), + "description": d.description, + "is_firm": d.is_firm, + } + for d in deadlines + ], + suggested_priority=suggested_priority.value, + ai_summary=summary, + ) + + return EmailAnalysisResult( + email_id=email_id, + category=category, + category_confidence=category_confidence, + sender_classification=sender_classification, + deadlines=deadlines, + suggested_priority=suggested_priority, + summary=summary, + suggested_actions=[], + auto_create_task=auto_create_task, + ) + + # ========================================================================= + # Response Suggestions + # ========================================================================= + + async def suggest_response( + self, + subject: str, + body_text: str, + sender_type: SenderType, + category: EmailCategory, + ) -> List[ResponseSuggestion]: + """ + Generate response suggestions for an email. + + Args: + subject: Original email subject + body_text: Original email body + sender_type: Classified sender type + category: Classified category + + Returns: + List of response suggestions + """ + suggestions = [] + + # Add standard templates based on sender type and category + if sender_type in [SenderType.KULTUSMINISTERIUM, SenderType.LANDESSCHULBEHOERDE, SenderType.RLSB]: + suggestions.append(ResponseSuggestion( + template_type="acknowledgment", + subject=f"Re: {subject}", + body="""Sehr geehrte Damen und Herren, + +vielen Dank für Ihre Nachricht. + +Ich bestätige den Eingang und werde die Angelegenheit fristgerecht bearbeiten. + +Mit freundlichen Grüßen""", + confidence=0.8, + )) + + if category == EmailCategory.ELTERN: + suggestions.append(ResponseSuggestion( + template_type="parent_response", + subject=f"Re: {subject}", + body="""Liebe Eltern, + +vielen Dank für Ihre Nachricht. + +[Ihre Antwort hier] + +Mit freundlichen Grüßen""", + confidence=0.7, + )) + + # Add LLM-generated suggestion + try: + llm_suggestion = await self._generate_response_llm(subject, body_text[:500], sender_type) + if llm_suggestion: + suggestions.append(llm_suggestion) + except Exception as e: + logger.warning(f"LLM response generation failed: {e}") + + return suggestions + + async def _generate_response_llm( + self, + subject: str, + body_preview: str, + sender_type: SenderType, + ) -> Optional[ResponseSuggestion]: + """Generate a response suggestion using LLM.""" + try: + client = await self.get_http_client() + + sender_desc = { + SenderType.KULTUSMINISTERIUM: "dem Kultusministerium", + SenderType.LANDESSCHULBEHOERDE: "der Landesschulbehörde", + SenderType.RLSB: "dem RLSB", + SenderType.ELTERNVERTRETER: "einem Elternvertreter", + }.get(sender_type, "einem Absender") + + prompt = f"""Du bist eine Schulleiterin in Niedersachsen. Formuliere eine professionelle, kurze Antwort auf diese E-Mail von {sender_desc}: + +Betreff: {subject} +Inhalt: {body_preview} + +Die Antwort sollte: +- Höflich und formell sein +- Den Eingang bestätigen +- Eine konkrete nächste Aktion nennen oder um Klärung bitten + +Antworte NUR mit dem Antworttext (ohne Betreffzeile, ohne "Betreff:"). +""" + + response = await client.post( + f"{LLM_GATEWAY_URL}/api/v1/inference", + json={ + "prompt": prompt, + "playbook": "mail_analysis", + "max_tokens": 300, + }, + ) + + if response.status_code == 200: + data = response.json() + body = data.get("response", "").strip() + + if body: + return ResponseSuggestion( + template_type="ai_generated", + subject=f"Re: {subject}", + body=body, + confidence=0.6, + ) + + except Exception as e: + logger.warning(f"LLM response generation failed: {e}") + + return None + + +# Global instance +_ai_service: Optional[AIEmailService] = None + + +def get_ai_email_service() -> AIEmailService: + """Get or create the global AIEmailService instance.""" + global _ai_service + + if _ai_service is None: + _ai_service = AIEmailService() + + return _ai_service diff --git a/klausur-service/backend/mail/api.py b/klausur-service/backend/mail/api.py new file mode 100644 index 0000000..ceb88ab --- /dev/null +++ b/klausur-service/backend/mail/api.py @@ -0,0 +1,651 @@ +""" +Unified Inbox Mail API + +FastAPI router for the mail system. +""" + +import logging +from typing import Optional, List +from datetime import datetime + +from fastapi import APIRouter, HTTPException, Depends, Query, BackgroundTasks +from pydantic import BaseModel + +from .models import ( + EmailAccountCreate, + EmailAccountUpdate, + EmailAccount, + AccountTestResult, + AggregatedEmail, + EmailSearchParams, + TaskCreate, + TaskUpdate, + InboxTask, + TaskDashboardStats, + EmailComposeRequest, + EmailSendResult, + MailStats, + MailHealthCheck, + EmailAnalysisResult, + ResponseSuggestion, + TaskStatus, + TaskPriority, + EmailCategory, +) +from .mail_db import ( + init_mail_tables, + create_email_account, + get_email_accounts, + get_email_account, + delete_email_account, + get_unified_inbox, + get_email, + mark_email_read, + mark_email_starred, + get_mail_stats, + log_mail_audit, +) +from .credentials import get_credentials_service +from .aggregator import get_mail_aggregator +from .ai_service import get_ai_email_service +from .task_service import get_task_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/mail", tags=["Mail"]) + + +# ============================================================================= +# Health & Init +# ============================================================================= + +@router.get("/health", response_model=MailHealthCheck) +async def health_check(): + """Health check for the mail system.""" + # TODO: Implement full health check + return MailHealthCheck( + status="healthy", + database_connected=True, + vault_connected=True, + ) + + +@router.post("/init") +async def initialize_mail_system(): + """Initialize mail database tables.""" + success = await init_mail_tables() + if not success: + raise HTTPException(status_code=500, detail="Failed to initialize mail tables") + return {"status": "initialized"} + + +# ============================================================================= +# Account Management +# ============================================================================= + +class AccountCreateRequest(BaseModel): + """Request to create an email account.""" + email: str + display_name: str + account_type: str = "personal" + imap_host: str + imap_port: int = 993 + imap_ssl: bool = True + smtp_host: str + smtp_port: int = 465 + smtp_ssl: bool = True + password: str + + +@router.post("/accounts", response_model=dict) +async def create_account( + request: AccountCreateRequest, + user_id: str = Query(..., description="User ID"), + tenant_id: str = Query(..., description="Tenant ID"), +): + """Create a new email account.""" + credentials_service = get_credentials_service() + + # Store credentials securely + vault_path = await credentials_service.store_credentials( + account_id=f"{user_id}_{request.email}", + email=request.email, + password=request.password, + imap_host=request.imap_host, + imap_port=request.imap_port, + smtp_host=request.smtp_host, + smtp_port=request.smtp_port, + ) + + # Create account in database + account_id = await create_email_account( + user_id=user_id, + tenant_id=tenant_id, + email=request.email, + display_name=request.display_name, + account_type=request.account_type, + imap_host=request.imap_host, + imap_port=request.imap_port, + imap_ssl=request.imap_ssl, + smtp_host=request.smtp_host, + smtp_port=request.smtp_port, + smtp_ssl=request.smtp_ssl, + vault_path=vault_path, + ) + + if not account_id: + raise HTTPException(status_code=500, detail="Failed to create account") + + # Log audit + await log_mail_audit( + user_id=user_id, + action="account_created", + entity_type="account", + entity_id=account_id, + details={"email": request.email}, + tenant_id=tenant_id, + ) + + return {"id": account_id, "status": "created"} + + +@router.get("/accounts", response_model=List[dict]) +async def list_accounts( + user_id: str = Query(..., description="User ID"), + tenant_id: Optional[str] = Query(None, description="Tenant ID"), +): + """List all email accounts for a user.""" + accounts = await get_email_accounts(user_id, tenant_id) + # Remove sensitive fields + for account in accounts: + account.pop("vault_path", None) + return accounts + + +@router.get("/accounts/{account_id}", response_model=dict) +async def get_account( + account_id: str, + user_id: str = Query(..., description="User ID"), +): + """Get a single email account.""" + account = await get_email_account(account_id, user_id) + if not account: + raise HTTPException(status_code=404, detail="Account not found") + account.pop("vault_path", None) + return account + + +@router.delete("/accounts/{account_id}") +async def remove_account( + account_id: str, + user_id: str = Query(..., description="User ID"), +): + """Delete an email account.""" + account = await get_email_account(account_id, user_id) + if not account: + raise HTTPException(status_code=404, detail="Account not found") + + # Delete credentials + credentials_service = get_credentials_service() + vault_path = account.get("vault_path", "") + if vault_path: + await credentials_service.delete_credentials(account_id, vault_path) + + # Delete from database (cascades to emails) + success = await delete_email_account(account_id, user_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to delete account") + + await log_mail_audit( + user_id=user_id, + action="account_deleted", + entity_type="account", + entity_id=account_id, + ) + + return {"status": "deleted"} + + +@router.post("/accounts/{account_id}/test", response_model=AccountTestResult) +async def test_account_connection( + account_id: str, + user_id: str = Query(..., description="User ID"), +): + """Test connection for an email account.""" + account = await get_email_account(account_id, user_id) + if not account: + raise HTTPException(status_code=404, detail="Account not found") + + # Get credentials + credentials_service = get_credentials_service() + vault_path = account.get("vault_path", "") + creds = await credentials_service.get_credentials(account_id, vault_path) + + if not creds: + return AccountTestResult( + success=False, + error_message="Credentials not found" + ) + + # Test connection + aggregator = get_mail_aggregator() + result = await aggregator.test_account_connection( + imap_host=account["imap_host"], + imap_port=account["imap_port"], + imap_ssl=account["imap_ssl"], + smtp_host=account["smtp_host"], + smtp_port=account["smtp_port"], + smtp_ssl=account["smtp_ssl"], + email_address=creds.email, + password=creds.password, + ) + + return result + + +class ConnectionTestRequest(BaseModel): + """Request to test connection before saving account.""" + email: str + imap_host: str + imap_port: int = 993 + imap_ssl: bool = True + smtp_host: str + smtp_port: int = 465 + smtp_ssl: bool = True + password: str + + +@router.post("/accounts/test-connection", response_model=AccountTestResult) +async def test_connection_before_save(request: ConnectionTestRequest): + """ + Test IMAP/SMTP connection before saving an account. + + This allows the wizard to verify credentials are correct + before creating the account in the database. + """ + aggregator = get_mail_aggregator() + + result = await aggregator.test_account_connection( + imap_host=request.imap_host, + imap_port=request.imap_port, + imap_ssl=request.imap_ssl, + smtp_host=request.smtp_host, + smtp_port=request.smtp_port, + smtp_ssl=request.smtp_ssl, + email_address=request.email, + password=request.password, + ) + + return result + + +@router.post("/accounts/{account_id}/sync") +async def sync_account( + account_id: str, + user_id: str = Query(..., description="User ID"), + max_emails: int = Query(100, ge=1, le=500), + background_tasks: BackgroundTasks = None, +): + """Sync emails from an account.""" + aggregator = get_mail_aggregator() + + try: + new_count, total_count = await aggregator.sync_account( + account_id=account_id, + user_id=user_id, + max_emails=max_emails, + ) + + return { + "status": "synced", + "new_emails": new_count, + "total_emails": total_count, + } + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================================================= +# Unified Inbox +# ============================================================================= + +@router.get("/inbox", response_model=List[dict]) +async def get_inbox( + user_id: str = Query(..., description="User ID"), + account_ids: Optional[str] = Query(None, description="Comma-separated account IDs"), + categories: Optional[str] = Query(None, description="Comma-separated categories"), + is_read: Optional[bool] = Query(None), + is_starred: Optional[bool] = Query(None), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), +): + """Get unified inbox with all accounts aggregated.""" + # Parse comma-separated values + account_id_list = account_ids.split(",") if account_ids else None + category_list = categories.split(",") if categories else None + + emails = await get_unified_inbox( + user_id=user_id, + account_ids=account_id_list, + categories=category_list, + is_read=is_read, + is_starred=is_starred, + limit=limit, + offset=offset, + ) + + return emails + + +@router.get("/inbox/{email_id}", response_model=dict) +async def get_email_detail( + email_id: str, + user_id: str = Query(..., description="User ID"), +): + """Get a single email with full details.""" + email_data = await get_email(email_id, user_id) + if not email_data: + raise HTTPException(status_code=404, detail="Email not found") + + # Mark as read + await mark_email_read(email_id, user_id, is_read=True) + + return email_data + + +@router.post("/inbox/{email_id}/read") +async def mark_read( + email_id: str, + user_id: str = Query(..., description="User ID"), + is_read: bool = Query(True), +): + """Mark email as read/unread.""" + success = await mark_email_read(email_id, user_id, is_read) + if not success: + raise HTTPException(status_code=500, detail="Failed to update email") + return {"status": "updated", "is_read": is_read} + + +@router.post("/inbox/{email_id}/star") +async def mark_starred( + email_id: str, + user_id: str = Query(..., description="User ID"), + is_starred: bool = Query(True), +): + """Mark email as starred/unstarred.""" + success = await mark_email_starred(email_id, user_id, is_starred) + if not success: + raise HTTPException(status_code=500, detail="Failed to update email") + return {"status": "updated", "is_starred": is_starred} + + +# ============================================================================= +# Send Email +# ============================================================================= + +@router.post("/send", response_model=EmailSendResult) +async def send_email( + request: EmailComposeRequest, + user_id: str = Query(..., description="User ID"), +): + """Send an email.""" + aggregator = get_mail_aggregator() + result = await aggregator.send_email( + account_id=request.account_id, + user_id=user_id, + request=request, + ) + + if result.success: + await log_mail_audit( + user_id=user_id, + action="email_sent", + entity_type="email", + details={ + "account_id": request.account_id, + "to": request.to, + "subject": request.subject, + }, + ) + + return result + + +# ============================================================================= +# AI Analysis +# ============================================================================= + +@router.post("/analyze/{email_id}", response_model=EmailAnalysisResult) +async def analyze_email( + email_id: str, + user_id: str = Query(..., description="User ID"), +): + """Run AI analysis on an email.""" + email_data = await get_email(email_id, user_id) + if not email_data: + raise HTTPException(status_code=404, detail="Email not found") + + ai_service = get_ai_email_service() + result = await ai_service.analyze_email( + email_id=email_id, + sender_email=email_data.get("sender_email", ""), + sender_name=email_data.get("sender_name"), + subject=email_data.get("subject", ""), + body_text=email_data.get("body_text"), + body_preview=email_data.get("body_preview"), + ) + + return result + + +@router.get("/suggestions/{email_id}", response_model=List[ResponseSuggestion]) +async def get_response_suggestions( + email_id: str, + user_id: str = Query(..., description="User ID"), +): + """Get AI-generated response suggestions for an email.""" + email_data = await get_email(email_id, user_id) + if not email_data: + raise HTTPException(status_code=404, detail="Email not found") + + ai_service = get_ai_email_service() + + # Use stored analysis if available + from .models import SenderType, EmailCategory as EC + sender_type = SenderType(email_data.get("sender_type", "unbekannt")) + category = EC(email_data.get("category", "sonstiges")) + + suggestions = await ai_service.suggest_response( + subject=email_data.get("subject", ""), + body_text=email_data.get("body_text", ""), + sender_type=sender_type, + category=category, + ) + + return suggestions + + +# ============================================================================= +# Tasks (Arbeitsvorrat) +# ============================================================================= + +@router.get("/tasks", response_model=List[dict]) +async def list_tasks( + user_id: str = Query(..., description="User ID"), + status: Optional[str] = Query(None, description="Filter by status"), + priority: Optional[str] = Query(None, description="Filter by priority"), + include_completed: bool = Query(False), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), +): + """Get all tasks for a user.""" + task_service = get_task_service() + + status_enum = TaskStatus(status) if status else None + priority_enum = TaskPriority(priority) if priority else None + + tasks = await task_service.get_user_tasks( + user_id=user_id, + status=status_enum, + priority=priority_enum, + include_completed=include_completed, + limit=limit, + offset=offset, + ) + + return tasks + + +@router.post("/tasks", response_model=dict) +async def create_task( + request: TaskCreate, + user_id: str = Query(..., description="User ID"), + tenant_id: str = Query(..., description="Tenant ID"), +): + """Create a new task manually.""" + task_service = get_task_service() + + task_id = await task_service.create_manual_task( + user_id=user_id, + tenant_id=tenant_id, + task_data=request, + ) + + if not task_id: + raise HTTPException(status_code=500, detail="Failed to create task") + + return {"id": task_id, "status": "created"} + + +@router.get("/tasks/dashboard", response_model=TaskDashboardStats) +async def get_task_dashboard( + user_id: str = Query(..., description="User ID"), +): + """Get dashboard statistics for tasks.""" + task_service = get_task_service() + return await task_service.get_dashboard_stats(user_id) + + +@router.get("/tasks/{task_id}", response_model=dict) +async def get_task( + task_id: str, + user_id: str = Query(..., description="User ID"), +): + """Get a single task.""" + task_service = get_task_service() + task = await task_service.get_task(task_id, user_id) + + if not task: + raise HTTPException(status_code=404, detail="Task not found") + + return task + + +@router.put("/tasks/{task_id}") +async def update_task( + task_id: str, + request: TaskUpdate, + user_id: str = Query(..., description="User ID"), +): + """Update a task.""" + task_service = get_task_service() + + success = await task_service.update_task(task_id, user_id, request) + if not success: + raise HTTPException(status_code=500, detail="Failed to update task") + + return {"status": "updated"} + + +@router.post("/tasks/{task_id}/complete") +async def complete_task( + task_id: str, + user_id: str = Query(..., description="User ID"), +): + """Mark a task as completed.""" + task_service = get_task_service() + + success = await task_service.mark_completed(task_id, user_id) + if not success: + raise HTTPException(status_code=500, detail="Failed to complete task") + + return {"status": "completed"} + + +@router.post("/tasks/from-email/{email_id}") +async def create_task_from_email( + email_id: str, + user_id: str = Query(..., description="User ID"), + tenant_id: str = Query(..., description="Tenant ID"), +): + """Create a task from an email (after analysis).""" + email_data = await get_email(email_id, user_id) + if not email_data: + raise HTTPException(status_code=404, detail="Email not found") + + # Get deadlines from stored analysis + deadlines_raw = email_data.get("detected_deadlines", []) + from .models import DeadlineExtraction, SenderType + + deadlines = [] + for d in deadlines_raw: + try: + deadlines.append(DeadlineExtraction( + deadline_date=datetime.fromisoformat(d["date"]), + description=d.get("description", "Frist"), + confidence=0.8, + source_text="", + is_firm=d.get("is_firm", True), + )) + except (KeyError, ValueError): + continue + + sender_type = None + if email_data.get("sender_type"): + try: + sender_type = SenderType(email_data["sender_type"]) + except ValueError: + pass + + task_service = get_task_service() + task_id = await task_service.create_task_from_email( + user_id=user_id, + tenant_id=tenant_id, + email_id=email_id, + deadlines=deadlines, + sender_type=sender_type, + ) + + if not task_id: + raise HTTPException(status_code=500, detail="Failed to create task") + + return {"id": task_id, "status": "created"} + + +# ============================================================================= +# Statistics +# ============================================================================= + +@router.get("/stats", response_model=MailStats) +async def get_statistics( + user_id: str = Query(..., description="User ID"), +): + """Get overall mail statistics for a user.""" + stats = await get_mail_stats(user_id) + return MailStats(**stats) + + +# ============================================================================= +# Sync All +# ============================================================================= + +@router.post("/sync-all") +async def sync_all_accounts( + user_id: str = Query(..., description="User ID"), + tenant_id: Optional[str] = Query(None), +): + """Sync all email accounts for a user.""" + aggregator = get_mail_aggregator() + results = await aggregator.sync_all_accounts(user_id, tenant_id) + return {"status": "synced", "results": results} diff --git a/klausur-service/backend/mail/credentials.py b/klausur-service/backend/mail/credentials.py new file mode 100644 index 0000000..4348474 --- /dev/null +++ b/klausur-service/backend/mail/credentials.py @@ -0,0 +1,373 @@ +""" +Mail Credentials Service + +Secure storage and retrieval of email account credentials using HashiCorp Vault. +Falls back to encrypted database storage in development. +""" + +import os +import base64 +import hashlib +import logging +from typing import Optional, Dict +from dataclasses import dataclass +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC + +logger = logging.getLogger(__name__) + +# Environment +ENVIRONMENT = os.getenv("ENVIRONMENT", "development") +VAULT_ADDR = os.getenv("VAULT_ADDR", "") +MAIL_CREDENTIALS_DIR = os.getenv("MAIL_CREDENTIALS_DIR", os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "mail_credentials")) + + +@dataclass +class MailCredentials: + """Mail account credentials.""" + email: str + password: str + imap_host: str + imap_port: int + smtp_host: str + smtp_port: int + + +class MailCredentialsService: + """ + Service for storing and retrieving mail credentials securely. + + In production: Uses HashiCorp Vault KV v2 + In development: Uses Fernet encryption with a derived key + """ + + def __init__(self): + self._vault_client = None + self._vault_available = False + self._encryption_key = None + + # Try to initialize Vault + if VAULT_ADDR: + self._init_vault() + else: + # Development fallback: use encryption key + self._init_encryption() + + def _init_vault(self): + """Initialize Vault client for credential storage.""" + try: + import hvac + + vault_token = os.getenv("VAULT_TOKEN") + if not vault_token: + logger.warning("VAULT_ADDR set but no VAULT_TOKEN - Vault disabled") + self._init_encryption() + return + + self._vault_client = hvac.Client( + url=VAULT_ADDR, + token=vault_token, + ) + + if self._vault_client.is_authenticated(): + self._vault_available = True + logger.info("Mail credentials service: Vault initialized") + else: + logger.warning("Vault authentication failed - using encryption fallback") + self._init_encryption() + + except ImportError: + logger.warning("hvac not installed - using encryption fallback") + self._init_encryption() + except Exception as e: + logger.warning(f"Vault initialization failed: {e}") + self._init_encryption() + + def _init_encryption(self): + """Initialize encryption for development/fallback mode.""" + # Derive key from environment secret - REQUIRED + secret = os.getenv("MAIL_ENCRYPTION_SECRET") + if not secret: + raise RuntimeError("MAIL_ENCRYPTION_SECRET nicht konfiguriert - bitte via Vault oder Umgebungsvariable setzen") + salt = os.getenv("MAIL_ENCRYPTION_SALT", "breakpilot-mail-salt").encode() + + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=salt, + iterations=480000, + ) + + key = base64.urlsafe_b64encode(kdf.derive(secret.encode())) + self._encryption_key = Fernet(key) + logger.info("Mail credentials service: Using encrypted storage") + + def _get_vault_path(self, account_id: str) -> str: + """Generate Vault path for a mail account.""" + return f"breakpilot/mail/accounts/{account_id}" + + async def store_credentials( + self, + account_id: str, + email: str, + password: str, + imap_host: str, + imap_port: int, + smtp_host: str, + smtp_port: int, + ) -> str: + """ + Store mail credentials securely. + + Returns: + vault_path: Path or reference to stored credentials + """ + if self._vault_available: + return await self._store_in_vault( + account_id, email, password, imap_host, imap_port, smtp_host, smtp_port + ) + else: + return await self._store_encrypted( + account_id, email, password, imap_host, imap_port, smtp_host, smtp_port + ) + + async def _store_in_vault( + self, + account_id: str, + email: str, + password: str, + imap_host: str, + imap_port: int, + smtp_host: str, + smtp_port: int, + ) -> str: + """Store credentials in Vault KV v2.""" + path = self._get_vault_path(account_id) + + try: + self._vault_client.secrets.kv.v2.create_or_update_secret( + path=path, + secret={ + "email": email, + "password": password, + "imap_host": imap_host, + "imap_port": str(imap_port), + "smtp_host": smtp_host, + "smtp_port": str(smtp_port), + }, + mount_point="secret", + ) + logger.info(f"Stored credentials in Vault for account {account_id}") + return f"vault:{path}" + + except Exception as e: + logger.error(f"Failed to store credentials in Vault: {e}") + raise + + async def _store_encrypted( + self, + account_id: str, + email: str, + password: str, + imap_host: str, + imap_port: int, + smtp_host: str, + smtp_port: int, + ) -> str: + """Store credentials encrypted (development fallback).""" + import json + + credentials = { + "email": email, + "password": password, + "imap_host": imap_host, + "imap_port": imap_port, + "smtp_host": smtp_host, + "smtp_port": smtp_port, + } + + # Encrypt the credentials + encrypted = self._encryption_key.encrypt(json.dumps(credentials).encode()) + + # Store in file (development only) + os.makedirs(MAIL_CREDENTIALS_DIR, exist_ok=True) + + path = f"{MAIL_CREDENTIALS_DIR}/{account_id}.enc" + with open(path, "wb") as f: + f.write(encrypted) + + logger.info(f"Stored encrypted credentials for account {account_id}") + return f"file:{path}" + + async def get_credentials(self, account_id: str, vault_path: str) -> Optional[MailCredentials]: + """ + Retrieve mail credentials. + + Args: + account_id: The account ID + vault_path: The storage path (from store_credentials) + + Returns: + MailCredentials or None if not found + """ + if vault_path.startswith("vault:"): + return await self._get_from_vault(vault_path[6:]) + elif vault_path.startswith("file:"): + return await self._get_from_file(vault_path[5:]) + else: + # Legacy path format + return await self._get_from_vault(vault_path) + + async def _get_from_vault(self, path: str) -> Optional[MailCredentials]: + """Retrieve credentials from Vault.""" + if not self._vault_available: + logger.warning("Vault not available for credential retrieval") + return None + + try: + response = self._vault_client.secrets.kv.v2.read_secret_version( + path=path, + mount_point="secret", + ) + + if response and "data" in response and "data" in response["data"]: + data = response["data"]["data"] + return MailCredentials( + email=data["email"], + password=data["password"], + imap_host=data["imap_host"], + imap_port=int(data["imap_port"]), + smtp_host=data["smtp_host"], + smtp_port=int(data["smtp_port"]), + ) + + except Exception as e: + logger.error(f"Failed to retrieve credentials from Vault: {e}") + + return None + + async def _get_from_file(self, path: str) -> Optional[MailCredentials]: + """Retrieve credentials from encrypted file (development).""" + import json + + try: + with open(path, "rb") as f: + encrypted = f.read() + + decrypted = self._encryption_key.decrypt(encrypted) + data = json.loads(decrypted.decode()) + + return MailCredentials( + email=data["email"], + password=data["password"], + imap_host=data["imap_host"], + imap_port=data["imap_port"], + smtp_host=data["smtp_host"], + smtp_port=data["smtp_port"], + ) + + except FileNotFoundError: + logger.warning(f"Credentials file not found: {path}") + except Exception as e: + logger.error(f"Failed to decrypt credentials: {e}") + + return None + + async def delete_credentials(self, account_id: str, vault_path: str) -> bool: + """ + Delete stored credentials. + + Args: + account_id: The account ID + vault_path: The storage path + + Returns: + True if deleted successfully + """ + if vault_path.startswith("vault:"): + return await self._delete_from_vault(vault_path[6:]) + elif vault_path.startswith("file:"): + return await self._delete_from_file(vault_path[5:]) + return False + + async def _delete_from_vault(self, path: str) -> bool: + """Delete credentials from Vault.""" + if not self._vault_available: + return False + + try: + self._vault_client.secrets.kv.v2.delete_metadata_and_all_versions( + path=path, + mount_point="secret", + ) + logger.info(f"Deleted credentials from Vault: {path}") + return True + + except Exception as e: + logger.error(f"Failed to delete credentials from Vault: {e}") + return False + + async def _delete_from_file(self, path: str) -> bool: + """Delete credentials file.""" + try: + os.remove(path) + logger.info(f"Deleted credentials file: {path}") + return True + except FileNotFoundError: + return True # Already deleted + except Exception as e: + logger.error(f"Failed to delete credentials file: {e}") + return False + + async def update_password( + self, + account_id: str, + vault_path: str, + new_password: str, + ) -> bool: + """ + Update the password for stored credentials. + + Args: + account_id: The account ID + vault_path: The storage path + new_password: The new password + + Returns: + True if updated successfully + """ + # Get existing credentials + creds = await self.get_credentials(account_id, vault_path) + if not creds: + return False + + # Store with new password + try: + await self.store_credentials( + account_id=account_id, + email=creds.email, + password=new_password, + imap_host=creds.imap_host, + imap_port=creds.imap_port, + smtp_host=creds.smtp_host, + smtp_port=creds.smtp_port, + ) + return True + except Exception as e: + logger.error(f"Failed to update password: {e}") + return False + + +# Global instance +_credentials_service: Optional[MailCredentialsService] = None + + +def get_credentials_service() -> MailCredentialsService: + """Get or create the global MailCredentialsService instance.""" + global _credentials_service + + if _credentials_service is None: + _credentials_service = MailCredentialsService() + + return _credentials_service diff --git a/klausur-service/backend/mail/mail_db.py b/klausur-service/backend/mail/mail_db.py new file mode 100644 index 0000000..36f5c44 --- /dev/null +++ b/klausur-service/backend/mail/mail_db.py @@ -0,0 +1,987 @@ +""" +Unified Inbox Mail Database Service + +PostgreSQL database operations for multi-account mail aggregation. +""" + +import os +import json +import uuid +from typing import Optional, List, Dict, Any +from datetime import datetime, timedelta + +# Database Configuration - from Vault or environment (test default for CI) +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://test:test@localhost:5432/test") + +# Flag to check if using test defaults +_DB_CONFIGURED = DATABASE_URL != "postgresql://test:test@localhost:5432/test" + +# Connection pool (shared with metrics_db) +_pool = None + + +async def get_pool(): + """Get or create database connection pool.""" + global _pool + if _pool is None: + try: + import asyncpg + _pool = await asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=10) + except ImportError: + print("Warning: asyncpg not installed. Mail database disabled.") + return None + except Exception as e: + print(f"Warning: Failed to connect to PostgreSQL: {e}") + return None + return _pool + + +async def init_mail_tables() -> bool: + """Initialize mail tables in PostgreSQL.""" + pool = await get_pool() + if pool is None: + return False + + create_tables_sql = """ + -- ============================================================================= + -- External Email Accounts + -- ============================================================================= + CREATE TABLE IF NOT EXISTS external_email_accounts ( + id VARCHAR(36) PRIMARY KEY, + user_id VARCHAR(36) NOT NULL, + tenant_id VARCHAR(36) NOT NULL, + email VARCHAR(255) NOT NULL, + display_name VARCHAR(255), + account_type VARCHAR(50) DEFAULT 'personal', + + -- IMAP Settings (password stored in Vault) + imap_host VARCHAR(255) NOT NULL, + imap_port INTEGER DEFAULT 993, + imap_ssl BOOLEAN DEFAULT TRUE, + + -- SMTP Settings + smtp_host VARCHAR(255) NOT NULL, + smtp_port INTEGER DEFAULT 465, + smtp_ssl BOOLEAN DEFAULT TRUE, + + -- Vault path for credentials + vault_path VARCHAR(500), + + -- Status tracking + status VARCHAR(20) DEFAULT 'pending', + last_sync TIMESTAMP, + sync_error TEXT, + email_count INTEGER DEFAULT 0, + unread_count INTEGER DEFAULT 0, + + -- Timestamps + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + + -- Constraints + UNIQUE(user_id, email) + ); + + CREATE INDEX IF NOT EXISTS idx_mail_accounts_user ON external_email_accounts(user_id); + CREATE INDEX IF NOT EXISTS idx_mail_accounts_tenant ON external_email_accounts(tenant_id); + CREATE INDEX IF NOT EXISTS idx_mail_accounts_status ON external_email_accounts(status); + + -- ============================================================================= + -- Aggregated Emails + -- ============================================================================= + CREATE TABLE IF NOT EXISTS aggregated_emails ( + id VARCHAR(36) PRIMARY KEY, + account_id VARCHAR(36) REFERENCES external_email_accounts(id) ON DELETE CASCADE, + user_id VARCHAR(36) NOT NULL, + tenant_id VARCHAR(36) NOT NULL, + + -- Email identification + message_id VARCHAR(500) NOT NULL, + folder VARCHAR(100) DEFAULT 'INBOX', + + -- Email content + subject TEXT, + sender_email VARCHAR(255), + sender_name VARCHAR(255), + recipients JSONB DEFAULT '[]', + cc JSONB DEFAULT '[]', + body_preview TEXT, + body_text TEXT, + body_html TEXT, + has_attachments BOOLEAN DEFAULT FALSE, + attachments JSONB DEFAULT '[]', + headers JSONB DEFAULT '{}', + + -- Status flags + is_read BOOLEAN DEFAULT FALSE, + is_starred BOOLEAN DEFAULT FALSE, + is_deleted BOOLEAN DEFAULT FALSE, + + -- Dates + date_sent TIMESTAMP, + date_received TIMESTAMP, + + -- AI enrichment + category VARCHAR(50), + sender_type VARCHAR(50), + sender_authority_name VARCHAR(255), + detected_deadlines JSONB DEFAULT '[]', + suggested_priority VARCHAR(20), + ai_summary TEXT, + ai_analyzed_at TIMESTAMP, + + created_at TIMESTAMP DEFAULT NOW(), + + -- Prevent duplicate imports + UNIQUE(account_id, message_id) + ); + + CREATE INDEX IF NOT EXISTS idx_emails_account ON aggregated_emails(account_id); + CREATE INDEX IF NOT EXISTS idx_emails_user ON aggregated_emails(user_id); + CREATE INDEX IF NOT EXISTS idx_emails_tenant ON aggregated_emails(tenant_id); + CREATE INDEX IF NOT EXISTS idx_emails_date ON aggregated_emails(date_received DESC); + CREATE INDEX IF NOT EXISTS idx_emails_category ON aggregated_emails(category); + CREATE INDEX IF NOT EXISTS idx_emails_unread ON aggregated_emails(is_read) WHERE is_read = FALSE; + CREATE INDEX IF NOT EXISTS idx_emails_starred ON aggregated_emails(is_starred) WHERE is_starred = TRUE; + CREATE INDEX IF NOT EXISTS idx_emails_sender ON aggregated_emails(sender_email); + + -- ============================================================================= + -- Inbox Tasks (Arbeitsvorrat) + -- ============================================================================= + CREATE TABLE IF NOT EXISTS inbox_tasks ( + id VARCHAR(36) PRIMARY KEY, + user_id VARCHAR(36) NOT NULL, + tenant_id VARCHAR(36) NOT NULL, + email_id VARCHAR(36) REFERENCES aggregated_emails(id) ON DELETE SET NULL, + account_id VARCHAR(36) REFERENCES external_email_accounts(id) ON DELETE SET NULL, + + -- Task content + title VARCHAR(500) NOT NULL, + description TEXT, + priority VARCHAR(20) DEFAULT 'medium', + status VARCHAR(20) DEFAULT 'pending', + deadline TIMESTAMP, + + -- Source information + source_email_subject TEXT, + source_sender VARCHAR(255), + source_sender_type VARCHAR(50), + + -- AI extraction info + ai_extracted BOOLEAN DEFAULT FALSE, + confidence_score FLOAT, + + -- Completion tracking + completed_at TIMESTAMP, + reminder_at TIMESTAMP, + + -- Timestamps + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_tasks_user ON inbox_tasks(user_id); + CREATE INDEX IF NOT EXISTS idx_tasks_tenant ON inbox_tasks(tenant_id); + CREATE INDEX IF NOT EXISTS idx_tasks_status ON inbox_tasks(status); + CREATE INDEX IF NOT EXISTS idx_tasks_deadline ON inbox_tasks(deadline) WHERE deadline IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_tasks_priority ON inbox_tasks(priority); + CREATE INDEX IF NOT EXISTS idx_tasks_email ON inbox_tasks(email_id) WHERE email_id IS NOT NULL; + + -- ============================================================================= + -- Email Templates + -- ============================================================================= + CREATE TABLE IF NOT EXISTS email_templates ( + id VARCHAR(36) PRIMARY KEY, + user_id VARCHAR(36), -- NULL for system templates + tenant_id VARCHAR(36), + + name VARCHAR(255) NOT NULL, + category VARCHAR(100), + subject_template TEXT, + body_template TEXT, + variables JSONB DEFAULT '[]', + + is_system BOOLEAN DEFAULT FALSE, + usage_count INTEGER DEFAULT 0, + + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_templates_user ON email_templates(user_id); + CREATE INDEX IF NOT EXISTS idx_templates_tenant ON email_templates(tenant_id); + CREATE INDEX IF NOT EXISTS idx_templates_system ON email_templates(is_system); + + -- ============================================================================= + -- Mail Audit Log + -- ============================================================================= + CREATE TABLE IF NOT EXISTS mail_audit_log ( + id VARCHAR(36) PRIMARY KEY, + user_id VARCHAR(36) NOT NULL, + tenant_id VARCHAR(36), + action VARCHAR(100) NOT NULL, + entity_type VARCHAR(50), -- account, email, task + entity_id VARCHAR(36), + details JSONB, + ip_address VARCHAR(45), + user_agent TEXT, + created_at TIMESTAMP DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_mail_audit_user ON mail_audit_log(user_id); + CREATE INDEX IF NOT EXISTS idx_mail_audit_created ON mail_audit_log(created_at DESC); + CREATE INDEX IF NOT EXISTS idx_mail_audit_action ON mail_audit_log(action); + + -- ============================================================================= + -- Sync Status Tracking + -- ============================================================================= + CREATE TABLE IF NOT EXISTS mail_sync_status ( + id VARCHAR(36) PRIMARY KEY, + account_id VARCHAR(36) REFERENCES external_email_accounts(id) ON DELETE CASCADE, + folder VARCHAR(100), + last_uid INTEGER DEFAULT 0, + last_sync TIMESTAMP, + sync_errors INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + + UNIQUE(account_id, folder) + ); + """ + + try: + async with pool.acquire() as conn: + await conn.execute(create_tables_sql) + print("Mail tables initialized successfully") + return True + except Exception as e: + print(f"Failed to initialize mail tables: {e}") + return False + + +# ============================================================================= +# Email Account Operations +# ============================================================================= + +async def create_email_account( + user_id: str, + tenant_id: str, + email: str, + display_name: str, + account_type: str, + imap_host: str, + imap_port: int, + imap_ssl: bool, + smtp_host: str, + smtp_port: int, + smtp_ssl: bool, + vault_path: str, +) -> Optional[str]: + """Create a new email account. Returns the account ID.""" + pool = await get_pool() + if pool is None: + return None + + account_id = str(uuid.uuid4()) + try: + async with pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO external_email_accounts + (id, user_id, tenant_id, email, display_name, account_type, + imap_host, imap_port, imap_ssl, smtp_host, smtp_port, smtp_ssl, vault_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) + """, + account_id, user_id, tenant_id, email, display_name, account_type, + imap_host, imap_port, imap_ssl, smtp_host, smtp_port, smtp_ssl, vault_path + ) + return account_id + except Exception as e: + print(f"Failed to create email account: {e}") + return None + + +async def get_email_accounts( + user_id: str, + tenant_id: Optional[str] = None, +) -> List[Dict]: + """Get all email accounts for a user.""" + pool = await get_pool() + if pool is None: + return [] + + try: + async with pool.acquire() as conn: + if tenant_id: + rows = await conn.fetch( + """ + SELECT * FROM external_email_accounts + WHERE user_id = $1 AND tenant_id = $2 + ORDER BY created_at + """, + user_id, tenant_id + ) + else: + rows = await conn.fetch( + """ + SELECT * FROM external_email_accounts + WHERE user_id = $1 + ORDER BY created_at + """, + user_id + ) + return [dict(r) for r in rows] + except Exception as e: + print(f"Failed to get email accounts: {e}") + return [] + + +async def get_email_account(account_id: str, user_id: str) -> Optional[Dict]: + """Get a single email account.""" + pool = await get_pool() + if pool is None: + return None + + try: + async with pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT * FROM external_email_accounts + WHERE id = $1 AND user_id = $2 + """, + account_id, user_id + ) + return dict(row) if row else None + except Exception as e: + print(f"Failed to get email account: {e}") + return None + + +async def update_account_status( + account_id: str, + status: str, + sync_error: Optional[str] = None, + email_count: Optional[int] = None, + unread_count: Optional[int] = None, +) -> bool: + """Update account sync status.""" + pool = await get_pool() + if pool is None: + return False + + try: + async with pool.acquire() as conn: + await conn.execute( + """ + UPDATE external_email_accounts SET + status = $2, + sync_error = $3, + email_count = COALESCE($4, email_count), + unread_count = COALESCE($5, unread_count), + last_sync = NOW(), + updated_at = NOW() + WHERE id = $1 + """, + account_id, status, sync_error, email_count, unread_count + ) + return True + except Exception as e: + print(f"Failed to update account status: {e}") + return False + + +async def delete_email_account(account_id: str, user_id: str) -> bool: + """Delete an email account (cascades to emails).""" + pool = await get_pool() + if pool is None: + return False + + try: + async with pool.acquire() as conn: + result = await conn.execute( + """ + DELETE FROM external_email_accounts + WHERE id = $1 AND user_id = $2 + """, + account_id, user_id + ) + return "DELETE" in result + except Exception as e: + print(f"Failed to delete email account: {e}") + return False + + +# ============================================================================= +# Aggregated Email Operations +# ============================================================================= + +async def upsert_email( + account_id: str, + user_id: str, + tenant_id: str, + message_id: str, + subject: str, + sender_email: str, + sender_name: Optional[str], + recipients: List[str], + cc: List[str], + body_preview: Optional[str], + body_text: Optional[str], + body_html: Optional[str], + has_attachments: bool, + attachments: List[Dict], + headers: Dict, + folder: str, + date_sent: datetime, + date_received: datetime, +) -> Optional[str]: + """Insert or update an email. Returns the email ID.""" + pool = await get_pool() + if pool is None: + return None + + email_id = str(uuid.uuid4()) + try: + async with pool.acquire() as conn: + # Try insert, on conflict update (for re-sync scenarios) + row = await conn.fetchrow( + """ + INSERT INTO aggregated_emails + (id, account_id, user_id, tenant_id, message_id, subject, + sender_email, sender_name, recipients, cc, body_preview, + body_text, body_html, has_attachments, attachments, headers, + folder, date_sent, date_received) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) + ON CONFLICT (account_id, message_id) DO UPDATE SET + subject = EXCLUDED.subject, + is_read = EXCLUDED.is_read, + folder = EXCLUDED.folder + RETURNING id + """, + email_id, account_id, user_id, tenant_id, message_id, subject, + sender_email, sender_name, json.dumps(recipients), json.dumps(cc), + body_preview, body_text, body_html, has_attachments, + json.dumps(attachments), json.dumps(headers), folder, + date_sent, date_received + ) + return row['id'] if row else None + except Exception as e: + print(f"Failed to upsert email: {e}") + return None + + +async def get_unified_inbox( + user_id: str, + account_ids: Optional[List[str]] = None, + categories: Optional[List[str]] = None, + is_read: Optional[bool] = None, + is_starred: Optional[bool] = None, + limit: int = 50, + offset: int = 0, +) -> List[Dict]: + """Get unified inbox with filtering.""" + pool = await get_pool() + if pool is None: + return [] + + try: + async with pool.acquire() as conn: + # Build dynamic query + conditions = ["user_id = $1", "is_deleted = FALSE"] + params = [user_id] + param_idx = 2 + + if account_ids: + conditions.append(f"account_id = ANY(${param_idx})") + params.append(account_ids) + param_idx += 1 + + if categories: + conditions.append(f"category = ANY(${param_idx})") + params.append(categories) + param_idx += 1 + + if is_read is not None: + conditions.append(f"is_read = ${param_idx}") + params.append(is_read) + param_idx += 1 + + if is_starred is not None: + conditions.append(f"is_starred = ${param_idx}") + params.append(is_starred) + param_idx += 1 + + where_clause = " AND ".join(conditions) + params.extend([limit, offset]) + + query = f""" + SELECT e.*, a.email as account_email, a.display_name as account_name + FROM aggregated_emails e + JOIN external_email_accounts a ON e.account_id = a.id + WHERE {where_clause} + ORDER BY e.date_received DESC + LIMIT ${param_idx} OFFSET ${param_idx + 1} + """ + + rows = await conn.fetch(query, *params) + return [dict(r) for r in rows] + except Exception as e: + print(f"Failed to get unified inbox: {e}") + return [] + + +async def get_email(email_id: str, user_id: str) -> Optional[Dict]: + """Get a single email by ID.""" + pool = await get_pool() + if pool is None: + return None + + try: + async with pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT e.*, a.email as account_email, a.display_name as account_name + FROM aggregated_emails e + JOIN external_email_accounts a ON e.account_id = a.id + WHERE e.id = $1 AND e.user_id = $2 + """, + email_id, user_id + ) + return dict(row) if row else None + except Exception as e: + print(f"Failed to get email: {e}") + return None + + +async def update_email_ai_analysis( + email_id: str, + category: str, + sender_type: str, + sender_authority_name: Optional[str], + detected_deadlines: List[Dict], + suggested_priority: str, + ai_summary: Optional[str], +) -> bool: + """Update email with AI analysis results.""" + pool = await get_pool() + if pool is None: + return False + + try: + async with pool.acquire() as conn: + await conn.execute( + """ + UPDATE aggregated_emails SET + category = $2, + sender_type = $3, + sender_authority_name = $4, + detected_deadlines = $5, + suggested_priority = $6, + ai_summary = $7, + ai_analyzed_at = NOW() + WHERE id = $1 + """, + email_id, category, sender_type, sender_authority_name, + json.dumps(detected_deadlines), suggested_priority, ai_summary + ) + return True + except Exception as e: + print(f"Failed to update email AI analysis: {e}") + return False + + +async def mark_email_read(email_id: str, user_id: str, is_read: bool = True) -> bool: + """Mark email as read/unread.""" + pool = await get_pool() + if pool is None: + return False + + try: + async with pool.acquire() as conn: + await conn.execute( + """ + UPDATE aggregated_emails SET is_read = $3 + WHERE id = $1 AND user_id = $2 + """, + email_id, user_id, is_read + ) + return True + except Exception as e: + print(f"Failed to mark email read: {e}") + return False + + +async def mark_email_starred(email_id: str, user_id: str, is_starred: bool = True) -> bool: + """Mark email as starred/unstarred.""" + pool = await get_pool() + if pool is None: + return False + + try: + async with pool.acquire() as conn: + await conn.execute( + """ + UPDATE aggregated_emails SET is_starred = $3 + WHERE id = $1 AND user_id = $2 + """, + email_id, user_id, is_starred + ) + return True + except Exception as e: + print(f"Failed to mark email starred: {e}") + return False + + +# ============================================================================= +# Inbox Task Operations +# ============================================================================= + +async def create_task( + user_id: str, + tenant_id: str, + title: str, + description: Optional[str] = None, + priority: str = "medium", + deadline: Optional[datetime] = None, + email_id: Optional[str] = None, + account_id: Optional[str] = None, + source_email_subject: Optional[str] = None, + source_sender: Optional[str] = None, + source_sender_type: Optional[str] = None, + ai_extracted: bool = False, + confidence_score: Optional[float] = None, +) -> Optional[str]: + """Create a new inbox task.""" + pool = await get_pool() + if pool is None: + return None + + task_id = str(uuid.uuid4()) + try: + async with pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO inbox_tasks + (id, user_id, tenant_id, title, description, priority, deadline, + email_id, account_id, source_email_subject, source_sender, + source_sender_type, ai_extracted, confidence_score) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + """, + task_id, user_id, tenant_id, title, description, priority, deadline, + email_id, account_id, source_email_subject, source_sender, + source_sender_type, ai_extracted, confidence_score + ) + return task_id + except Exception as e: + print(f"Failed to create task: {e}") + return None + + +async def get_tasks( + user_id: str, + status: Optional[str] = None, + priority: Optional[str] = None, + include_completed: bool = False, + limit: int = 50, + offset: int = 0, +) -> List[Dict]: + """Get tasks for a user.""" + pool = await get_pool() + if pool is None: + return [] + + try: + async with pool.acquire() as conn: + conditions = ["user_id = $1"] + params = [user_id] + param_idx = 2 + + if not include_completed: + conditions.append("status != 'completed'") + + if status: + conditions.append(f"status = ${param_idx}") + params.append(status) + param_idx += 1 + + if priority: + conditions.append(f"priority = ${param_idx}") + params.append(priority) + param_idx += 1 + + where_clause = " AND ".join(conditions) + params.extend([limit, offset]) + + query = f""" + SELECT * FROM inbox_tasks + WHERE {where_clause} + ORDER BY + CASE priority + WHEN 'urgent' THEN 1 + WHEN 'high' THEN 2 + WHEN 'medium' THEN 3 + WHEN 'low' THEN 4 + END, + deadline ASC NULLS LAST, + created_at DESC + LIMIT ${param_idx} OFFSET ${param_idx + 1} + """ + + rows = await conn.fetch(query, *params) + return [dict(r) for r in rows] + except Exception as e: + print(f"Failed to get tasks: {e}") + return [] + + +async def get_task(task_id: str, user_id: str) -> Optional[Dict]: + """Get a single task.""" + pool = await get_pool() + if pool is None: + return None + + try: + async with pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT * FROM inbox_tasks WHERE id = $1 AND user_id = $2", + task_id, user_id + ) + return dict(row) if row else None + except Exception as e: + print(f"Failed to get task: {e}") + return None + + +async def update_task( + task_id: str, + user_id: str, + title: Optional[str] = None, + description: Optional[str] = None, + priority: Optional[str] = None, + status: Optional[str] = None, + deadline: Optional[datetime] = None, +) -> bool: + """Update a task.""" + pool = await get_pool() + if pool is None: + return False + + try: + async with pool.acquire() as conn: + # Build dynamic update + updates = ["updated_at = NOW()"] + params = [task_id, user_id] + param_idx = 3 + + if title is not None: + updates.append(f"title = ${param_idx}") + params.append(title) + param_idx += 1 + + if description is not None: + updates.append(f"description = ${param_idx}") + params.append(description) + param_idx += 1 + + if priority is not None: + updates.append(f"priority = ${param_idx}") + params.append(priority) + param_idx += 1 + + if status is not None: + updates.append(f"status = ${param_idx}") + params.append(status) + param_idx += 1 + if status == "completed": + updates.append("completed_at = NOW()") + + if deadline is not None: + updates.append(f"deadline = ${param_idx}") + params.append(deadline) + param_idx += 1 + + set_clause = ", ".join(updates) + await conn.execute( + f"UPDATE inbox_tasks SET {set_clause} WHERE id = $1 AND user_id = $2", + *params + ) + return True + except Exception as e: + print(f"Failed to update task: {e}") + return False + + +async def get_task_dashboard_stats(user_id: str) -> Dict: + """Get dashboard statistics for tasks.""" + pool = await get_pool() + if pool is None: + return {} + + try: + async with pool.acquire() as conn: + now = datetime.now() + today_end = now.replace(hour=23, minute=59, second=59) + week_end = now + timedelta(days=7) + + stats = await conn.fetchrow( + """ + SELECT + COUNT(*) as total_tasks, + COUNT(*) FILTER (WHERE status = 'pending') as pending_tasks, + COUNT(*) FILTER (WHERE status = 'in_progress') as in_progress_tasks, + COUNT(*) FILTER (WHERE status = 'completed') as completed_tasks, + COUNT(*) FILTER (WHERE status != 'completed' AND deadline < $2) as overdue_tasks, + COUNT(*) FILTER (WHERE status != 'completed' AND deadline <= $3) as due_today, + COUNT(*) FILTER (WHERE status != 'completed' AND deadline <= $4) as due_this_week + FROM inbox_tasks + WHERE user_id = $1 + """, + user_id, now, today_end, week_end + ) + + by_priority = await conn.fetch( + """ + SELECT priority, COUNT(*) as count + FROM inbox_tasks + WHERE user_id = $1 AND status != 'completed' + GROUP BY priority + """, + user_id + ) + + by_sender = await conn.fetch( + """ + SELECT source_sender_type, COUNT(*) as count + FROM inbox_tasks + WHERE user_id = $1 AND status != 'completed' AND source_sender_type IS NOT NULL + GROUP BY source_sender_type + """, + user_id + ) + + return { + "total_tasks": stats['total_tasks'] or 0, + "pending_tasks": stats['pending_tasks'] or 0, + "in_progress_tasks": stats['in_progress_tasks'] or 0, + "completed_tasks": stats['completed_tasks'] or 0, + "overdue_tasks": stats['overdue_tasks'] or 0, + "due_today": stats['due_today'] or 0, + "due_this_week": stats['due_this_week'] or 0, + "by_priority": {r['priority']: r['count'] for r in by_priority}, + "by_sender_type": {r['source_sender_type']: r['count'] for r in by_sender}, + } + except Exception as e: + print(f"Failed to get task stats: {e}") + return {} + + +# ============================================================================= +# Statistics & Audit +# ============================================================================= + +async def get_mail_stats(user_id: str) -> Dict: + """Get overall mail statistics for a user.""" + pool = await get_pool() + if pool is None: + return {} + + try: + async with pool.acquire() as conn: + today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) + + # Account stats + accounts = await conn.fetch( + """ + SELECT id, email, display_name, status, email_count, unread_count, last_sync + FROM external_email_accounts + WHERE user_id = $1 + """, + user_id + ) + + # Email counts + email_stats = await conn.fetchrow( + """ + SELECT + COUNT(*) as total_emails, + COUNT(*) FILTER (WHERE is_read = FALSE) as unread_emails, + COUNT(*) FILTER (WHERE date_received >= $2) as emails_today, + COUNT(*) FILTER (WHERE ai_analyzed_at >= $2) as ai_analyses_today + FROM aggregated_emails + WHERE user_id = $1 + """, + user_id, today + ) + + # Task counts + task_stats = await conn.fetchrow( + """ + SELECT + COUNT(*) as total_tasks, + COUNT(*) FILTER (WHERE status = 'pending') as pending_tasks, + COUNT(*) FILTER (WHERE status != 'completed' AND deadline < NOW()) as overdue_tasks + FROM inbox_tasks + WHERE user_id = $1 + """, + user_id + ) + + return { + "total_accounts": len(accounts), + "active_accounts": sum(1 for a in accounts if a['status'] == 'active'), + "error_accounts": sum(1 for a in accounts if a['status'] == 'error'), + "total_emails": email_stats['total_emails'] or 0, + "unread_emails": email_stats['unread_emails'] or 0, + "total_tasks": task_stats['total_tasks'] or 0, + "pending_tasks": task_stats['pending_tasks'] or 0, + "overdue_tasks": task_stats['overdue_tasks'] or 0, + "emails_today": email_stats['emails_today'] or 0, + "ai_analyses_today": email_stats['ai_analyses_today'] or 0, + "per_account": [ + { + "id": a['id'], + "email": a['email'], + "display_name": a['display_name'], + "status": a['status'], + "email_count": a['email_count'], + "unread_count": a['unread_count'], + "last_sync": a['last_sync'].isoformat() if a['last_sync'] else None, + } + for a in accounts + ], + } + except Exception as e: + print(f"Failed to get mail stats: {e}") + return {} + + +async def log_mail_audit( + user_id: str, + action: str, + entity_type: Optional[str] = None, + entity_id: Optional[str] = None, + details: Optional[Dict] = None, + tenant_id: Optional[str] = None, + ip_address: Optional[str] = None, + user_agent: Optional[str] = None, +) -> bool: + """Log a mail action for audit trail.""" + pool = await get_pool() + if pool is None: + return False + + try: + async with pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO mail_audit_log + (id, user_id, tenant_id, action, entity_type, entity_id, details, ip_address, user_agent) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + """, + str(uuid.uuid4()), user_id, tenant_id, action, entity_type, entity_id, + json.dumps(details) if details else None, ip_address, user_agent + ) + return True + except Exception as e: + print(f"Failed to log mail audit: {e}") + return False diff --git a/klausur-service/backend/mail/models.py b/klausur-service/backend/mail/models.py new file mode 100644 index 0000000..16b43c2 --- /dev/null +++ b/klausur-service/backend/mail/models.py @@ -0,0 +1,455 @@ +""" +Unified Inbox Mail Models + +Pydantic models for API requests/responses and internal data structures. +Database schema is defined in mail_db.py. +""" + +from datetime import datetime +from enum import Enum +from typing import Optional, List, Dict, Any +from pydantic import BaseModel, Field, EmailStr +import uuid + + +# ============================================================================= +# Enums +# ============================================================================= + +class AccountStatus(str, Enum): + """Status of an email account connection.""" + ACTIVE = "active" + INACTIVE = "inactive" + ERROR = "error" + PENDING = "pending" + + +class TaskStatus(str, Enum): + """Status of an inbox task.""" + PENDING = "pending" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + ARCHIVED = "archived" + + +class TaskPriority(str, Enum): + """Priority level for tasks.""" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + URGENT = "urgent" + + +class EmailCategory(str, Enum): + """AI-detected email categories.""" + DIENSTLICH = "dienstlich" # Official government/authority + PERSONAL = "personal" # Staff/HR matters + FINANZEN = "finanzen" # Finance/budget + ELTERN = "eltern" # Parent communication + SCHUELER = "schueler" # Student matters + KOLLEGIUM = "kollegium" # Teacher colleagues + FORTBILDUNG = "fortbildung" # Professional development + VERANSTALTUNG = "veranstaltung" # Events + SICHERHEIT = "sicherheit" # Safety/security + TECHNIK = "technik" # IT/technical + NEWSLETTER = "newsletter" # Newsletters + WERBUNG = "werbung" # Advertising/spam + SONSTIGES = "sonstiges" # Other + + +class SenderType(str, Enum): + """Type of sender for classification.""" + KULTUSMINISTERIUM = "kultusministerium" + LANDESSCHULBEHOERDE = "landesschulbehoerde" + RLSB = "rlsb" # Regionales Landesamt für Schule und Bildung + SCHULAMT = "schulamt" + NIBIS = "nibis" + SCHULTRAEGER = "schultraeger" + ELTERNVERTRETER = "elternvertreter" + GEWERKSCHAFT = "gewerkschaft" + FORTBILDUNGSINSTITUT = "fortbildungsinstitut" + PRIVATPERSON = "privatperson" + UNTERNEHMEN = "unternehmen" + UNBEKANNT = "unbekannt" + + +# ============================================================================= +# Known Authority Domains (Niedersachsen) +# ============================================================================= + +KNOWN_AUTHORITIES_NI = { + "@mk.niedersachsen.de": {"type": SenderType.KULTUSMINISTERIUM, "name": "Kultusministerium Niedersachsen"}, + "@rlsb.de": {"type": SenderType.RLSB, "name": "Regionales Landesamt für Schule und Bildung"}, + "@rlsb-bs.niedersachsen.de": {"type": SenderType.RLSB, "name": "RLSB Braunschweig"}, + "@rlsb-h.niedersachsen.de": {"type": SenderType.RLSB, "name": "RLSB Hannover"}, + "@rlsb-lg.niedersachsen.de": {"type": SenderType.RLSB, "name": "RLSB Lüneburg"}, + "@rlsb-os.niedersachsen.de": {"type": SenderType.RLSB, "name": "RLSB Osnabrück"}, + "@landesschulbehoerde-nds.de": {"type": SenderType.LANDESSCHULBEHOERDE, "name": "Landesschulbehörde"}, + "@nibis.de": {"type": SenderType.NIBIS, "name": "NiBiS"}, + "@schule.niedersachsen.de": {"type": SenderType.LANDESSCHULBEHOERDE, "name": "Schulnetzwerk NI"}, + "@nlq.nibis.de": {"type": SenderType.FORTBILDUNGSINSTITUT, "name": "NLQ"}, + "@gew-nds.de": {"type": SenderType.GEWERKSCHAFT, "name": "GEW Niedersachsen"}, + "@vbe-nds.de": {"type": SenderType.GEWERKSCHAFT, "name": "VBE Niedersachsen"}, +} + + +# ============================================================================= +# Email Account Models +# ============================================================================= + +class EmailAccountBase(BaseModel): + """Base model for email account.""" + email: EmailStr = Field(..., description="Email address") + display_name: str = Field(..., description="Display name for the account") + account_type: str = Field("personal", description="Type: personal, schulleitung, personal, verwaltung") + imap_host: str = Field(..., description="IMAP server hostname") + imap_port: int = Field(993, description="IMAP port (default: 993 for SSL)") + imap_ssl: bool = Field(True, description="Use SSL for IMAP") + smtp_host: str = Field(..., description="SMTP server hostname") + smtp_port: int = Field(465, description="SMTP port (default: 465 for SSL)") + smtp_ssl: bool = Field(True, description="Use SSL for SMTP") + + +class EmailAccountCreate(EmailAccountBase): + """Model for creating a new email account.""" + password: str = Field(..., description="Password (will be stored in Vault)") + + +class EmailAccountUpdate(BaseModel): + """Model for updating an email account.""" + display_name: Optional[str] = None + account_type: Optional[str] = None + imap_host: Optional[str] = None + imap_port: Optional[int] = None + smtp_host: Optional[str] = None + smtp_port: Optional[int] = None + password: Optional[str] = None # Only if changing + + +class EmailAccount(EmailAccountBase): + """Full email account model (without password).""" + id: str + user_id: str + tenant_id: str + status: AccountStatus = AccountStatus.PENDING + last_sync: Optional[datetime] = None + sync_error: Optional[str] = None + email_count: int = 0 + unread_count: int = 0 + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class AccountTestResult(BaseModel): + """Result of testing email account connection.""" + success: bool + imap_connected: bool = False + smtp_connected: bool = False + error_message: Optional[str] = None + folders_found: List[str] = [] + + +# ============================================================================= +# Aggregated Email Models +# ============================================================================= + +class AggregatedEmailBase(BaseModel): + """Base model for an aggregated email.""" + subject: str + sender_email: str + sender_name: Optional[str] = None + recipients: List[str] = [] + cc: List[str] = [] + body_preview: Optional[str] = None + has_attachments: bool = False + + +class AggregatedEmail(AggregatedEmailBase): + """Full aggregated email model.""" + id: str + account_id: str + message_id: str # Original IMAP message ID + folder: str = "INBOX" + is_read: bool = False + is_starred: bool = False + is_deleted: bool = False + body_text: Optional[str] = None + body_html: Optional[str] = None + attachments: List[Dict[str, Any]] = [] + headers: Dict[str, str] = {} + date_sent: datetime + date_received: datetime + + # AI-enriched fields + category: Optional[EmailCategory] = None + sender_type: Optional[SenderType] = None + sender_authority_name: Optional[str] = None + detected_deadlines: List[Dict[str, Any]] = [] + suggested_priority: Optional[TaskPriority] = None + ai_summary: Optional[str] = None + + created_at: datetime + + class Config: + from_attributes = True + + +class EmailSearchParams(BaseModel): + """Parameters for searching emails.""" + query: Optional[str] = None + account_ids: Optional[List[str]] = None + categories: Optional[List[EmailCategory]] = None + is_read: Optional[bool] = None + is_starred: Optional[bool] = None + has_deadline: Optional[bool] = None + date_from: Optional[datetime] = None + date_to: Optional[datetime] = None + limit: int = Field(50, ge=1, le=200) + offset: int = Field(0, ge=0) + + +# ============================================================================= +# Inbox Task Models (Arbeitsvorrat) +# ============================================================================= + +class TaskBase(BaseModel): + """Base model for inbox task.""" + title: str = Field(..., description="Task title") + description: Optional[str] = None + priority: TaskPriority = TaskPriority.MEDIUM + deadline: Optional[datetime] = None + + +class TaskCreate(TaskBase): + """Model for creating a task manually.""" + email_id: Optional[str] = None # Link to source email + + +class TaskUpdate(BaseModel): + """Model for updating a task.""" + title: Optional[str] = None + description: Optional[str] = None + priority: Optional[TaskPriority] = None + status: Optional[TaskStatus] = None + deadline: Optional[datetime] = None + completed_at: Optional[datetime] = None + + +class InboxTask(TaskBase): + """Full inbox task model.""" + id: str + user_id: str + tenant_id: str + email_id: Optional[str] = None + account_id: Optional[str] = None + status: TaskStatus = TaskStatus.PENDING + + # Source information + source_email_subject: Optional[str] = None + source_sender: Optional[str] = None + source_sender_type: Optional[SenderType] = None + + # AI-extracted information + ai_extracted: bool = False + confidence_score: Optional[float] = None + + completed_at: Optional[datetime] = None + reminder_at: Optional[datetime] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class TaskDashboardStats(BaseModel): + """Dashboard statistics for tasks.""" + total_tasks: int = 0 + pending_tasks: int = 0 + in_progress_tasks: int = 0 + completed_tasks: int = 0 + overdue_tasks: int = 0 + due_today: int = 0 + due_this_week: int = 0 + by_priority: Dict[str, int] = {} + by_sender_type: Dict[str, int] = {} + + +# ============================================================================= +# AI Analysis Models +# ============================================================================= + +class SenderClassification(BaseModel): + """Result of AI sender classification.""" + sender_type: SenderType + authority_name: Optional[str] = None + confidence: float = Field(..., ge=0, le=1) + domain_matched: bool = False + ai_classified: bool = False + + +class DeadlineExtraction(BaseModel): + """Extracted deadline from email.""" + deadline_date: datetime + description: str + confidence: float = Field(..., ge=0, le=1) + source_text: str # Original text containing the deadline + is_firm: bool = True # True for "bis zum", False for "etwa" + + +class EmailAnalysisResult(BaseModel): + """Complete AI analysis result for an email.""" + email_id: str + category: EmailCategory + category_confidence: float + sender_classification: SenderClassification + deadlines: List[DeadlineExtraction] = [] + suggested_priority: TaskPriority + summary: Optional[str] = None + suggested_actions: List[str] = [] + auto_create_task: bool = False + + +class ResponseSuggestion(BaseModel): + """AI-generated response suggestion.""" + template_type: str # "acknowledgment", "request_info", "delegation", etc. + subject: str + body: str + confidence: float + + +# ============================================================================= +# Email Template Models +# ============================================================================= + +class EmailTemplateBase(BaseModel): + """Base model for email template.""" + name: str + category: str # "acknowledgment", "request", "forwarding", etc. + subject_template: str + body_template: str + variables: List[str] = [] # e.g., ["sender_name", "deadline", "topic"] + + +class EmailTemplateCreate(EmailTemplateBase): + """Model for creating a template.""" + pass + + +class EmailTemplate(EmailTemplateBase): + """Full email template model.""" + id: str + user_id: Optional[str] = None # None = system template + tenant_id: Optional[str] = None + is_system: bool = False + usage_count: int = 0 + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +# ============================================================================= +# Compose Email Models +# ============================================================================= + +class EmailComposeRequest(BaseModel): + """Request to compose/send an email.""" + account_id: str = Field(..., description="Account to send from") + to: List[EmailStr] + cc: Optional[List[EmailStr]] = [] + bcc: Optional[List[EmailStr]] = [] + subject: str + body: str + is_html: bool = False + reply_to_message_id: Optional[str] = None + attachments: Optional[List[Dict[str, Any]]] = None + + +class EmailSendResult(BaseModel): + """Result of sending an email.""" + success: bool + message_id: Optional[str] = None + error: Optional[str] = None + + +# ============================================================================= +# Statistics & Health Models +# ============================================================================= + +class MailStats(BaseModel): + """Overall mail system statistics.""" + total_accounts: int = 0 + active_accounts: int = 0 + error_accounts: int = 0 + total_emails: int = 0 + unread_emails: int = 0 + total_tasks: int = 0 + pending_tasks: int = 0 + overdue_tasks: int = 0 + emails_today: int = 0 + ai_analyses_today: int = 0 + per_account: List[Dict[str, Any]] = [] + + +class MailHealthCheck(BaseModel): + """Health check for mail system.""" + status: str # "healthy", "degraded", "unhealthy" + database_connected: bool = False + vault_connected: bool = False + accounts_checked: int = 0 + accounts_healthy: int = 0 + last_sync: Optional[datetime] = None + errors: List[str] = [] + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def generate_id() -> str: + """Generate a new UUID.""" + return str(uuid.uuid4()) + + +def classify_sender_by_domain(email: str) -> Optional[SenderClassification]: + """ + Classify sender by known authority domains. + Returns None if domain is not recognized. + """ + email_lower = email.lower() + for domain, info in KNOWN_AUTHORITIES_NI.items(): + if domain in email_lower: + return SenderClassification( + sender_type=info["type"], + authority_name=info["name"], + confidence=0.95, + domain_matched=True, + ai_classified=False, + ) + return None + + +def get_priority_from_sender_type(sender_type: SenderType) -> TaskPriority: + """Get suggested priority based on sender type.""" + high_priority = { + SenderType.KULTUSMINISTERIUM, + SenderType.LANDESSCHULBEHOERDE, + SenderType.RLSB, + SenderType.SCHULAMT, + } + medium_priority = { + SenderType.NIBIS, + SenderType.SCHULTRAEGER, + SenderType.ELTERNVERTRETER, + } + + if sender_type in high_priority: + return TaskPriority.HIGH + elif sender_type in medium_priority: + return TaskPriority.MEDIUM + return TaskPriority.LOW diff --git a/klausur-service/backend/mail/task_service.py b/klausur-service/backend/mail/task_service.py new file mode 100644 index 0000000..9579bd1 --- /dev/null +++ b/klausur-service/backend/mail/task_service.py @@ -0,0 +1,421 @@ +""" +Inbox Task Service (Arbeitsvorrat) + +Manages tasks extracted from emails and manual task creation. +""" + +import logging +from typing import Optional, List, Dict, Any +from datetime import datetime, timedelta + +from .models import ( + TaskStatus, + TaskPriority, + InboxTask, + TaskCreate, + TaskUpdate, + TaskDashboardStats, + SenderType, + DeadlineExtraction, +) +from .mail_db import ( + create_task as db_create_task, + get_tasks as db_get_tasks, + get_task as db_get_task, + update_task as db_update_task, + get_task_dashboard_stats as db_get_dashboard_stats, + get_email, + log_mail_audit, +) + +logger = logging.getLogger(__name__) + + +class TaskService: + """ + Service for managing inbox tasks (Arbeitsvorrat). + + Features: + - Create tasks from emails (auto or manual) + - Track deadlines and priorities + - Dashboard statistics + - Reminders (to be integrated with notification service) + """ + + async def create_task_from_email( + self, + user_id: str, + tenant_id: str, + email_id: str, + deadlines: List[DeadlineExtraction], + sender_type: Optional[SenderType] = None, + auto_created: bool = False, + ) -> Optional[str]: + """ + Create a task from an analyzed email. + + Args: + user_id: The user ID + tenant_id: The tenant ID + email_id: The source email ID + deadlines: Extracted deadlines + sender_type: Classified sender type + auto_created: Whether this was auto-created by AI + + Returns: + Task ID if created successfully + """ + # Get email details + email_data = await get_email(email_id, user_id) + if not email_data: + logger.warning(f"Email not found: {email_id}") + return None + + # Determine priority + priority = TaskPriority.MEDIUM + if sender_type: + priority = self._get_priority_from_sender(sender_type) + + # Get earliest deadline + deadline = None + if deadlines: + deadline = min(d.deadline_date for d in deadlines) + + # Adjust priority based on deadline + priority = self._adjust_priority_for_deadline(priority, deadline) + + # Create task title from email subject + subject = email_data.get("subject", "Keine Betreffzeile") + title = f"Bearbeiten: {subject[:100]}" + + # Create description from deadlines + description = self._build_task_description(deadlines, email_data) + + # Create the task + task_id = await db_create_task( + user_id=user_id, + tenant_id=tenant_id, + title=title, + description=description, + priority=priority.value, + deadline=deadline, + email_id=email_id, + account_id=email_data.get("account_id"), + source_email_subject=subject, + source_sender=email_data.get("sender_email"), + source_sender_type=sender_type.value if sender_type else None, + ai_extracted=auto_created, + confidence_score=deadlines[0].confidence if deadlines else None, + ) + + if task_id: + # Log audit event + await log_mail_audit( + user_id=user_id, + action="task_created", + entity_type="task", + entity_id=task_id, + details={ + "email_id": email_id, + "auto_created": auto_created, + "deadline": deadline.isoformat() if deadline else None, + }, + tenant_id=tenant_id, + ) + + logger.info(f"Created task {task_id} from email {email_id}") + + return task_id + + async def create_manual_task( + self, + user_id: str, + tenant_id: str, + task_data: TaskCreate, + ) -> Optional[str]: + """ + Create a task manually (not from email). + + Args: + user_id: The user ID + tenant_id: The tenant ID + task_data: Task creation data + + Returns: + Task ID if created successfully + """ + # Get email details if linked + source_subject = None + source_sender = None + account_id = None + + if task_data.email_id: + email_data = await get_email(task_data.email_id, user_id) + if email_data: + source_subject = email_data.get("subject") + source_sender = email_data.get("sender_email") + account_id = email_data.get("account_id") + + task_id = await db_create_task( + user_id=user_id, + tenant_id=tenant_id, + title=task_data.title, + description=task_data.description, + priority=task_data.priority.value, + deadline=task_data.deadline, + email_id=task_data.email_id, + account_id=account_id, + source_email_subject=source_subject, + source_sender=source_sender, + ai_extracted=False, + ) + + if task_id: + await log_mail_audit( + user_id=user_id, + action="task_created_manual", + entity_type="task", + entity_id=task_id, + details={"title": task_data.title}, + tenant_id=tenant_id, + ) + + return task_id + + async def get_user_tasks( + self, + user_id: str, + status: Optional[TaskStatus] = None, + priority: Optional[TaskPriority] = None, + include_completed: bool = False, + limit: int = 50, + offset: int = 0, + ) -> List[Dict]: + """ + Get tasks for a user with filtering. + + Args: + user_id: The user ID + status: Filter by status + priority: Filter by priority + include_completed: Include completed tasks + limit: Maximum results + offset: Pagination offset + + Returns: + List of task dictionaries + """ + return await db_get_tasks( + user_id=user_id, + status=status.value if status else None, + priority=priority.value if priority else None, + include_completed=include_completed, + limit=limit, + offset=offset, + ) + + async def get_task(self, task_id: str, user_id: str) -> Optional[Dict]: + """Get a single task by ID.""" + return await db_get_task(task_id, user_id) + + async def update_task( + self, + task_id: str, + user_id: str, + updates: TaskUpdate, + ) -> bool: + """ + Update a task. + + Args: + task_id: The task ID + user_id: The user ID + updates: Fields to update + + Returns: + True if successful + """ + update_dict = {} + + if updates.title is not None: + update_dict["title"] = updates.title + if updates.description is not None: + update_dict["description"] = updates.description + if updates.priority is not None: + update_dict["priority"] = updates.priority.value + if updates.status is not None: + update_dict["status"] = updates.status.value + if updates.deadline is not None: + update_dict["deadline"] = updates.deadline + + success = await db_update_task(task_id, user_id, **update_dict) + + if success: + await log_mail_audit( + user_id=user_id, + action="task_updated", + entity_type="task", + entity_id=task_id, + details={"updates": update_dict}, + ) + + return success + + async def mark_completed(self, task_id: str, user_id: str) -> bool: + """Mark a task as completed.""" + success = await db_update_task( + task_id, user_id, status=TaskStatus.COMPLETED.value + ) + + if success: + await log_mail_audit( + user_id=user_id, + action="task_completed", + entity_type="task", + entity_id=task_id, + ) + + return success + + async def mark_in_progress(self, task_id: str, user_id: str) -> bool: + """Mark a task as in progress.""" + return await db_update_task( + task_id, user_id, status=TaskStatus.IN_PROGRESS.value + ) + + async def get_dashboard_stats(self, user_id: str) -> TaskDashboardStats: + """ + Get dashboard statistics for a user. + + Returns: + TaskDashboardStats with all metrics + """ + stats = await db_get_dashboard_stats(user_id) + + return TaskDashboardStats( + total_tasks=stats.get("total_tasks", 0), + pending_tasks=stats.get("pending_tasks", 0), + in_progress_tasks=stats.get("in_progress_tasks", 0), + completed_tasks=stats.get("completed_tasks", 0), + overdue_tasks=stats.get("overdue_tasks", 0), + due_today=stats.get("due_today", 0), + due_this_week=stats.get("due_this_week", 0), + by_priority=stats.get("by_priority", {}), + by_sender_type=stats.get("by_sender_type", {}), + ) + + async def get_overdue_tasks(self, user_id: str) -> List[Dict]: + """Get all overdue tasks for a user.""" + all_tasks = await db_get_tasks(user_id, include_completed=False, limit=500) + + now = datetime.now() + overdue = [ + task for task in all_tasks + if task.get("deadline") and task["deadline"] < now + ] + + return overdue + + async def get_tasks_due_soon( + self, + user_id: str, + days: int = 3, + ) -> List[Dict]: + """Get tasks due within the specified number of days.""" + all_tasks = await db_get_tasks(user_id, include_completed=False, limit=500) + + now = datetime.now() + deadline_cutoff = now + timedelta(days=days) + + due_soon = [ + task for task in all_tasks + if task.get("deadline") and now <= task["deadline"] <= deadline_cutoff + ] + + return sorted(due_soon, key=lambda t: t["deadline"]) + + # ========================================================================= + # Helper Methods + # ========================================================================= + + def _get_priority_from_sender(self, sender_type: SenderType) -> TaskPriority: + """Determine priority based on sender type.""" + high_priority_senders = { + SenderType.KULTUSMINISTERIUM, + SenderType.LANDESSCHULBEHOERDE, + SenderType.RLSB, + SenderType.SCHULAMT, + } + + medium_priority_senders = { + SenderType.NIBIS, + SenderType.SCHULTRAEGER, + SenderType.ELTERNVERTRETER, + } + + if sender_type in high_priority_senders: + return TaskPriority.HIGH + elif sender_type in medium_priority_senders: + return TaskPriority.MEDIUM + else: + return TaskPriority.LOW + + def _adjust_priority_for_deadline( + self, + current_priority: TaskPriority, + deadline: datetime, + ) -> TaskPriority: + """Adjust priority based on deadline proximity.""" + now = datetime.now() + days_until = (deadline - now).days + + if days_until <= 1: + return TaskPriority.URGENT + elif days_until <= 3: + return max(current_priority, TaskPriority.HIGH) + elif days_until <= 7: + return max(current_priority, TaskPriority.MEDIUM) + else: + return current_priority + + def _build_task_description( + self, + deadlines: List[DeadlineExtraction], + email_data: Dict, + ) -> str: + """Build a task description from deadlines and email data.""" + parts = [] + + # Add deadlines + if deadlines: + parts.append("**Fristen:**") + for d in deadlines: + date_str = d.deadline_date.strftime("%d.%m.%Y") + firm_str = " (verbindlich)" if d.is_firm else "" + parts.append(f"- {date_str}: {d.description}{firm_str}") + parts.append("") + + # Add email info + sender = email_data.get("sender_email", "Unbekannt") + parts.append(f"**Von:** {sender}") + + # Add preview + preview = email_data.get("body_preview", "") + if preview: + parts.append(f"\n**Vorschau:**\n{preview[:300]}...") + + return "\n".join(parts) + + +# Global instance +_task_service: Optional[TaskService] = None + + +def get_task_service() -> TaskService: + """Get or create the global TaskService instance.""" + global _task_service + + if _task_service is None: + _task_service = TaskService() + + return _task_service diff --git a/klausur-service/backend/main.py b/klausur-service/backend/main.py new file mode 100644 index 0000000..52b16d2 --- /dev/null +++ b/klausur-service/backend/main.py @@ -0,0 +1,133 @@ +""" +Klausur-Service - Abitur/Vorabitur Klausurkorrektur Microservice + +Eigenstaendiger Service fuer: +- Klausurverwaltung (Abitur/Vorabitur) +- OCR-Verarbeitung handschriftlicher Arbeiten +- KI-gestuetzte Bewertung +- Gutachten-Generierung +- 15-Punkte-Notensystem +- BYOEH (Bring-Your-Own-Expectation-Horizon) + +This is the main entry point. All functionality is organized in modular packages: +- models/: Data models and Pydantic schemas +- routes/: API endpoint handlers +- services/: Business logic +- storage.py: In-memory data storage +- config.py: Configuration constants +""" + +import os +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse + +# Configuration +from config import EH_UPLOAD_DIR, FRONTEND_PATH + +# Routes +from routes import api_router + +# External module routers (already modular) +from admin_api import router as admin_router +from zeugnis_api import router as zeugnis_router +from training_api import router as training_router +from mail.api import router as mail_router +from trocr_api import router as trocr_router + +# BYOEH Qdrant initialization +from qdrant_service import init_qdrant_collection + + +# ============================================= +# APP SETUP +# ============================================= + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Application lifespan manager for startup and shutdown events.""" + print("Klausur-Service starting...") + + # Initialize Qdrant collection for BYOEH + try: + await init_qdrant_collection() + print("Qdrant BYOEH collection initialized") + except Exception as e: + print(f"Warning: Qdrant initialization failed: {e}") + + # Ensure EH upload directory exists + os.makedirs(EH_UPLOAD_DIR, exist_ok=True) + + yield + + print("Klausur-Service shutting down...") + + +app = FastAPI( + title="Klausur-Service", + description="Abitur/Vorabitur Klausurkorrektur Microservice", + version="1.0.0", + lifespan=lifespan +) + +# CORS Middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# ============================================= +# INCLUDE ROUTERS +# ============================================= + +# Main API routes (modular) +app.include_router(api_router) + +# External module routers +app.include_router(admin_router) # NiBiS Ingestion +app.include_router(zeugnis_router) # Zeugnis Rights-Aware Crawler +app.include_router(training_router) # Training Management +app.include_router(mail_router) # Unified Inbox Mail +app.include_router(trocr_router) # TrOCR Handwriting OCR + + +# ============================================= +# HEALTH CHECK +# ============================================= + +@app.get("/health") +async def health(): + """Health check endpoint.""" + return {"status": "healthy", "service": "klausur-service"} + + +# ============================================= +# SERVE FRONTEND +# ============================================= + +if os.path.exists(FRONTEND_PATH): + app.mount("/assets", StaticFiles(directory=f"{FRONTEND_PATH}/assets"), name="assets") + + @app.get("/") + async def serve_frontend(): + """Serve the React frontend.""" + return FileResponse(f"{FRONTEND_PATH}/index.html") + + @app.get("/{path:path}") + async def serve_frontend_routes(path: str): + """Serve index.html for all non-API routes (SPA routing).""" + if not path.startswith("api/") and not path.startswith("health"): + return FileResponse(f"{FRONTEND_PATH}/index.html") + from fastapi import HTTPException + raise HTTPException(status_code=404) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8086) diff --git a/klausur-service/backend/metrics_db.py b/klausur-service/backend/metrics_db.py new file mode 100644 index 0000000..f3b0ff7 --- /dev/null +++ b/klausur-service/backend/metrics_db.py @@ -0,0 +1,833 @@ +""" +PostgreSQL Metrics Database Service +Stores search feedback, calculates quality metrics (Precision, Recall, MRR). +""" + +import os +from typing import Optional, List, Dict +from datetime import datetime, timedelta +import asyncio + +# Database Configuration - uses test default if not configured (for CI) +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://test:test@localhost:5432/test_metrics") + +# Connection pool +_pool = None + + +async def get_pool(): + """Get or create database connection pool.""" + global _pool + if _pool is None: + try: + import asyncpg + _pool = await asyncpg.create_pool(DATABASE_URL, min_size=2, max_size=10) + except ImportError: + print("Warning: asyncpg not installed. Metrics storage disabled.") + return None + except Exception as e: + print(f"Warning: Failed to connect to PostgreSQL: {e}") + return None + return _pool + + +async def init_metrics_tables() -> bool: + """Initialize metrics tables in PostgreSQL.""" + pool = await get_pool() + if pool is None: + return False + + create_tables_sql = """ + -- RAG Search Feedback Table + CREATE TABLE IF NOT EXISTS rag_search_feedback ( + id SERIAL PRIMARY KEY, + result_id VARCHAR(255) NOT NULL, + query_text TEXT, + collection_name VARCHAR(100), + score FLOAT, + rating INTEGER CHECK (rating >= 1 AND rating <= 5), + notes TEXT, + user_id VARCHAR(100), + created_at TIMESTAMP DEFAULT NOW() + ); + + -- Index for efficient querying + CREATE INDEX IF NOT EXISTS idx_feedback_created_at ON rag_search_feedback(created_at); + CREATE INDEX IF NOT EXISTS idx_feedback_collection ON rag_search_feedback(collection_name); + CREATE INDEX IF NOT EXISTS idx_feedback_rating ON rag_search_feedback(rating); + + -- RAG Search Logs Table (for latency tracking) + CREATE TABLE IF NOT EXISTS rag_search_logs ( + id SERIAL PRIMARY KEY, + query_text TEXT NOT NULL, + collection_name VARCHAR(100), + result_count INTEGER, + latency_ms INTEGER, + top_score FLOAT, + filters JSONB, + created_at TIMESTAMP DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_search_logs_created_at ON rag_search_logs(created_at); + + -- RAG Upload History Table + CREATE TABLE IF NOT EXISTS rag_upload_history ( + id SERIAL PRIMARY KEY, + filename VARCHAR(500) NOT NULL, + collection_name VARCHAR(100), + year INTEGER, + pdfs_extracted INTEGER, + minio_path VARCHAR(1000), + uploaded_by VARCHAR(100), + created_at TIMESTAMP DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_upload_history_created_at ON rag_upload_history(created_at); + + -- Binäre Relevanz-Judgments für echte Precision/Recall + CREATE TABLE IF NOT EXISTS rag_relevance_judgments ( + id SERIAL PRIMARY KEY, + query_id VARCHAR(255) NOT NULL, + query_text TEXT NOT NULL, + result_id VARCHAR(255) NOT NULL, + result_rank INTEGER, + is_relevant BOOLEAN NOT NULL, + collection_name VARCHAR(100), + user_id VARCHAR(100), + created_at TIMESTAMP DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_relevance_query ON rag_relevance_judgments(query_id); + CREATE INDEX IF NOT EXISTS idx_relevance_created_at ON rag_relevance_judgments(created_at); + + -- Zeugnisse Source Tracking + CREATE TABLE IF NOT EXISTS zeugnis_sources ( + id VARCHAR(36) PRIMARY KEY, + bundesland VARCHAR(10) NOT NULL, + name VARCHAR(255) NOT NULL, + base_url TEXT, + license_type VARCHAR(50) NOT NULL, + training_allowed BOOLEAN DEFAULT FALSE, + verified_by VARCHAR(100), + verified_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_zeugnis_sources_bundesland ON zeugnis_sources(bundesland); + + -- Zeugnisse Seed URLs + CREATE TABLE IF NOT EXISTS zeugnis_seed_urls ( + id VARCHAR(36) PRIMARY KEY, + source_id VARCHAR(36) REFERENCES zeugnis_sources(id), + url TEXT NOT NULL, + doc_type VARCHAR(50), + status VARCHAR(20) DEFAULT 'pending', + last_crawled TIMESTAMP, + error_message TEXT, + created_at TIMESTAMP DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_zeugnis_seed_urls_source ON zeugnis_seed_urls(source_id); + CREATE INDEX IF NOT EXISTS idx_zeugnis_seed_urls_status ON zeugnis_seed_urls(status); + + -- Zeugnisse Documents + CREATE TABLE IF NOT EXISTS zeugnis_documents ( + id VARCHAR(36) PRIMARY KEY, + seed_url_id VARCHAR(36) REFERENCES zeugnis_seed_urls(id), + title VARCHAR(500), + url TEXT NOT NULL, + content_hash VARCHAR(64), + minio_path TEXT, + training_allowed BOOLEAN DEFAULT FALSE, + indexed_in_qdrant BOOLEAN DEFAULT FALSE, + file_size INTEGER, + content_type VARCHAR(100), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_zeugnis_documents_seed ON zeugnis_documents(seed_url_id); + CREATE INDEX IF NOT EXISTS idx_zeugnis_documents_hash ON zeugnis_documents(content_hash); + + -- Zeugnisse Document Versions + CREATE TABLE IF NOT EXISTS zeugnis_document_versions ( + id VARCHAR(36) PRIMARY KEY, + document_id VARCHAR(36) REFERENCES zeugnis_documents(id), + version INTEGER NOT NULL, + content_hash VARCHAR(64), + minio_path TEXT, + change_summary TEXT, + created_at TIMESTAMP DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_zeugnis_versions_doc ON zeugnis_document_versions(document_id); + + -- Zeugnisse Usage Events (Audit Trail) + CREATE TABLE IF NOT EXISTS zeugnis_usage_events ( + id VARCHAR(36) PRIMARY KEY, + document_id VARCHAR(36) REFERENCES zeugnis_documents(id), + event_type VARCHAR(50) NOT NULL, + user_id VARCHAR(100), + details JSONB, + created_at TIMESTAMP DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_zeugnis_events_doc ON zeugnis_usage_events(document_id); + CREATE INDEX IF NOT EXISTS idx_zeugnis_events_type ON zeugnis_usage_events(event_type); + CREATE INDEX IF NOT EXISTS idx_zeugnis_events_created ON zeugnis_usage_events(created_at); + + -- Crawler Queue + CREATE TABLE IF NOT EXISTS zeugnis_crawler_queue ( + id VARCHAR(36) PRIMARY KEY, + source_id VARCHAR(36) REFERENCES zeugnis_sources(id), + priority INTEGER DEFAULT 5, + status VARCHAR(20) DEFAULT 'pending', + started_at TIMESTAMP, + completed_at TIMESTAMP, + documents_found INTEGER DEFAULT 0, + documents_indexed INTEGER DEFAULT 0, + error_count INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW() + ); + + CREATE INDEX IF NOT EXISTS idx_crawler_queue_status ON zeugnis_crawler_queue(status); + """ + + try: + async with pool.acquire() as conn: + await conn.execute(create_tables_sql) + print("RAG metrics tables initialized") + return True + except Exception as e: + print(f"Failed to initialize metrics tables: {e}") + return False + + +# ============================================================================= +# Feedback Storage +# ============================================================================= + +async def store_feedback( + result_id: str, + rating: int, + query_text: Optional[str] = None, + collection_name: Optional[str] = None, + score: Optional[float] = None, + notes: Optional[str] = None, + user_id: Optional[str] = None, +) -> bool: + """Store search result feedback.""" + pool = await get_pool() + if pool is None: + return False + + try: + async with pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO rag_search_feedback + (result_id, query_text, collection_name, score, rating, notes, user_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) + """, + result_id, query_text, collection_name, score, rating, notes, user_id + ) + return True + except Exception as e: + print(f"Failed to store feedback: {e}") + return False + + +async def log_search( + query_text: str, + collection_name: str, + result_count: int, + latency_ms: int, + top_score: Optional[float] = None, + filters: Optional[Dict] = None, +) -> bool: + """Log a search for metrics tracking.""" + pool = await get_pool() + if pool is None: + return False + + try: + import json + async with pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO rag_search_logs + (query_text, collection_name, result_count, latency_ms, top_score, filters) + VALUES ($1, $2, $3, $4, $5, $6) + """, + query_text, collection_name, result_count, latency_ms, top_score, + json.dumps(filters) if filters else None + ) + return True + except Exception as e: + print(f"Failed to log search: {e}") + return False + + +async def log_upload( + filename: str, + collection_name: str, + year: int, + pdfs_extracted: int, + minio_path: Optional[str] = None, + uploaded_by: Optional[str] = None, +) -> bool: + """Log an upload for history tracking.""" + pool = await get_pool() + if pool is None: + return False + + try: + async with pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO rag_upload_history + (filename, collection_name, year, pdfs_extracted, minio_path, uploaded_by) + VALUES ($1, $2, $3, $4, $5, $6) + """, + filename, collection_name, year, pdfs_extracted, minio_path, uploaded_by + ) + return True + except Exception as e: + print(f"Failed to log upload: {e}") + return False + + +# ============================================================================= +# Metrics Calculation +# ============================================================================= + +async def calculate_metrics( + collection_name: Optional[str] = None, + days: int = 7, +) -> Dict: + """ + Calculate RAG quality metrics from stored feedback. + + Returns: + Dict with precision, recall, MRR, latency, etc. + """ + pool = await get_pool() + if pool is None: + return {"error": "Database not available", "connected": False} + + try: + async with pool.acquire() as conn: + # Date filter + since = datetime.now() - timedelta(days=days) + + # Collection filter + collection_filter = "" + params = [since] + if collection_name: + collection_filter = "AND collection_name = $2" + params.append(collection_name) + + # Total feedback count + total_feedback = await conn.fetchval( + f""" + SELECT COUNT(*) FROM rag_search_feedback + WHERE created_at >= $1 {collection_filter} + """, + *params + ) + + # Rating distribution + rating_dist = await conn.fetch( + f""" + SELECT rating, COUNT(*) as count + FROM rag_search_feedback + WHERE created_at >= $1 {collection_filter} + GROUP BY rating + ORDER BY rating DESC + """, + *params + ) + + # Average rating (proxy for precision) + avg_rating = await conn.fetchval( + f""" + SELECT AVG(rating) FROM rag_search_feedback + WHERE created_at >= $1 {collection_filter} + """, + *params + ) + + # Score distribution + score_dist = await conn.fetch( + f""" + SELECT + CASE + WHEN score >= 0.9 THEN '0.9+' + WHEN score >= 0.7 THEN '0.7-0.9' + WHEN score >= 0.5 THEN '0.5-0.7' + ELSE '<0.5' + END as range, + COUNT(*) as count + FROM rag_search_feedback + WHERE created_at >= $1 AND score IS NOT NULL {collection_filter} + GROUP BY range + ORDER BY range DESC + """, + *params + ) + + # Search logs for latency + latency_stats = await conn.fetchrow( + f""" + SELECT + AVG(latency_ms) as avg_latency, + COUNT(*) as total_searches, + AVG(result_count) as avg_results + FROM rag_search_logs + WHERE created_at >= $1 {collection_filter.replace('collection_name', 'collection_name')} + """, + *params + ) + + # Calculate precision@5 (% of top 5 rated 4+) + precision_at_5 = await conn.fetchval( + f""" + SELECT + CASE WHEN COUNT(*) > 0 + THEN CAST(SUM(CASE WHEN rating >= 4 THEN 1 ELSE 0 END) AS FLOAT) / COUNT(*) + ELSE 0 END + FROM rag_search_feedback + WHERE created_at >= $1 {collection_filter} + """, + *params + ) or 0 + + # Calculate MRR (Mean Reciprocal Rank) - simplified + # Using average rating as proxy for relevance + mrr = (avg_rating or 0) / 5.0 + + # Error rate (ratings of 1 or 2) + error_count = sum( + r['count'] for r in rating_dist if r['rating'] and r['rating'] <= 2 + ) + error_rate = (error_count / total_feedback * 100) if total_feedback > 0 else 0 + + # Score distribution as percentages + total_scored = sum(s['count'] for s in score_dist) + score_distribution = {} + for s in score_dist: + if total_scored > 0: + score_distribution[s['range']] = round(s['count'] / total_scored * 100) + else: + score_distribution[s['range']] = 0 + + return { + "connected": True, + "period_days": days, + "precision_at_5": round(precision_at_5, 2), + "recall_at_10": round(precision_at_5 * 1.1, 2), # Estimated + "mrr": round(mrr, 2), + "avg_latency_ms": round(latency_stats['avg_latency'] or 0), + "total_ratings": total_feedback, + "total_searches": latency_stats['total_searches'] or 0, + "error_rate": round(error_rate, 1), + "score_distribution": score_distribution, + "rating_distribution": { + str(r['rating']): r['count'] for r in rating_dist if r['rating'] + }, + } + + except Exception as e: + print(f"Failed to calculate metrics: {e}") + return {"error": str(e), "connected": False} + + +async def get_recent_feedback(limit: int = 20) -> List[Dict]: + """Get recent feedback entries.""" + pool = await get_pool() + if pool is None: + return [] + + try: + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT result_id, rating, query_text, collection_name, score, notes, created_at + FROM rag_search_feedback + ORDER BY created_at DESC + LIMIT $1 + """, + limit + ) + return [ + { + "result_id": r['result_id'], + "rating": r['rating'], + "query_text": r['query_text'], + "collection_name": r['collection_name'], + "score": r['score'], + "notes": r['notes'], + "created_at": r['created_at'].isoformat() if r['created_at'] else None, + } + for r in rows + ] + except Exception as e: + print(f"Failed to get recent feedback: {e}") + return [] + + +async def get_upload_history(limit: int = 20) -> List[Dict]: + """Get recent upload history.""" + pool = await get_pool() + if pool is None: + return [] + + try: + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT filename, collection_name, year, pdfs_extracted, minio_path, uploaded_by, created_at + FROM rag_upload_history + ORDER BY created_at DESC + LIMIT $1 + """, + limit + ) + return [ + { + "filename": r['filename'], + "collection_name": r['collection_name'], + "year": r['year'], + "pdfs_extracted": r['pdfs_extracted'], + "minio_path": r['minio_path'], + "uploaded_by": r['uploaded_by'], + "created_at": r['created_at'].isoformat() if r['created_at'] else None, + } + for r in rows + ] + except Exception as e: + print(f"Failed to get upload history: {e}") + return [] + + +# ============================================================================= +# Relevance Judgments (Binary Precision/Recall) +# ============================================================================= + +async def store_relevance_judgment( + query_id: str, + query_text: str, + result_id: str, + is_relevant: bool, + result_rank: Optional[int] = None, + collection_name: Optional[str] = None, + user_id: Optional[str] = None, +) -> bool: + """Store binary relevance judgment for Precision/Recall calculation.""" + pool = await get_pool() + if pool is None: + return False + + try: + async with pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO rag_relevance_judgments + (query_id, query_text, result_id, result_rank, is_relevant, collection_name, user_id) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT DO NOTHING + """, + query_id, query_text, result_id, result_rank, is_relevant, collection_name, user_id + ) + return True + except Exception as e: + print(f"Failed to store relevance judgment: {e}") + return False + + +async def calculate_precision_recall( + collection_name: Optional[str] = None, + days: int = 7, + k: int = 10, +) -> Dict: + """ + Calculate true Precision@k and Recall@k from binary relevance judgments. + + Precision@k = (Relevant docs in top k) / k + Recall@k = (Relevant docs in top k) / (Total relevant docs for query) + """ + pool = await get_pool() + if pool is None: + return {"error": "Database not available", "connected": False} + + try: + async with pool.acquire() as conn: + since = datetime.now() - timedelta(days=days) + + collection_filter = "" + params = [since, k] + if collection_name: + collection_filter = "AND collection_name = $3" + params.append(collection_name) + + # Get precision@k per query, then average + precision_result = await conn.fetchval( + f""" + WITH query_precision AS ( + SELECT + query_id, + COUNT(CASE WHEN is_relevant THEN 1 END)::FLOAT / + GREATEST(COUNT(*), 1) as precision + FROM rag_relevance_judgments + WHERE created_at >= $1 + AND (result_rank IS NULL OR result_rank <= $2) + {collection_filter} + GROUP BY query_id + ) + SELECT AVG(precision) FROM query_precision + """, + *params + ) or 0 + + # Get recall@k per query, then average + recall_result = await conn.fetchval( + f""" + WITH query_recall AS ( + SELECT + query_id, + COUNT(CASE WHEN is_relevant AND (result_rank IS NULL OR result_rank <= $2) THEN 1 END)::FLOAT / + GREATEST(COUNT(CASE WHEN is_relevant THEN 1 END), 1) as recall + FROM rag_relevance_judgments + WHERE created_at >= $1 + {collection_filter} + GROUP BY query_id + ) + SELECT AVG(recall) FROM query_recall + """, + *params + ) or 0 + + # Total judgments + total_judgments = await conn.fetchval( + f""" + SELECT COUNT(*) FROM rag_relevance_judgments + WHERE created_at >= $1 {collection_filter} + """, + since, *([collection_name] if collection_name else []) + ) + + # Unique queries + unique_queries = await conn.fetchval( + f""" + SELECT COUNT(DISTINCT query_id) FROM rag_relevance_judgments + WHERE created_at >= $1 {collection_filter} + """, + since, *([collection_name] if collection_name else []) + ) + + return { + "connected": True, + "period_days": days, + "k": k, + "precision_at_k": round(precision_result, 3), + "recall_at_k": round(recall_result, 3), + "f1_score": round( + 2 * precision_result * recall_result / max(precision_result + recall_result, 0.001), 3 + ), + "total_judgments": total_judgments or 0, + "unique_queries": unique_queries or 0, + } + + except Exception as e: + print(f"Failed to calculate precision/recall: {e}") + return {"error": str(e), "connected": False} + + +# ============================================================================= +# Zeugnis Database Operations +# ============================================================================= + +async def get_zeugnis_sources() -> List[Dict]: + """Get all zeugnis sources (Bundesländer).""" + pool = await get_pool() + if pool is None: + return [] + + try: + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT id, bundesland, name, base_url, license_type, training_allowed, + verified_by, verified_at, created_at, updated_at + FROM zeugnis_sources + ORDER BY bundesland + """ + ) + return [dict(r) for r in rows] + except Exception as e: + print(f"Failed to get zeugnis sources: {e}") + return [] + + +async def upsert_zeugnis_source( + id: str, + bundesland: str, + name: str, + license_type: str, + training_allowed: bool, + base_url: Optional[str] = None, + verified_by: Optional[str] = None, +) -> bool: + """Insert or update a zeugnis source.""" + pool = await get_pool() + if pool is None: + return False + + try: + async with pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO zeugnis_sources (id, bundesland, name, base_url, license_type, training_allowed, verified_by, verified_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) + ON CONFLICT (id) DO UPDATE SET + name = EXCLUDED.name, + base_url = EXCLUDED.base_url, + license_type = EXCLUDED.license_type, + training_allowed = EXCLUDED.training_allowed, + verified_by = EXCLUDED.verified_by, + verified_at = NOW(), + updated_at = NOW() + """, + id, bundesland, name, base_url, license_type, training_allowed, verified_by + ) + return True + except Exception as e: + print(f"Failed to upsert zeugnis source: {e}") + return False + + +async def get_zeugnis_documents( + bundesland: Optional[str] = None, + limit: int = 100, + offset: int = 0, +) -> List[Dict]: + """Get zeugnis documents with optional filtering.""" + pool = await get_pool() + if pool is None: + return [] + + try: + async with pool.acquire() as conn: + if bundesland: + rows = await conn.fetch( + """ + SELECT d.*, s.bundesland, s.name as source_name + FROM zeugnis_documents d + JOIN zeugnis_seed_urls u ON d.seed_url_id = u.id + JOIN zeugnis_sources s ON u.source_id = s.id + WHERE s.bundesland = $1 + ORDER BY d.created_at DESC + LIMIT $2 OFFSET $3 + """, + bundesland, limit, offset + ) + else: + rows = await conn.fetch( + """ + SELECT d.*, s.bundesland, s.name as source_name + FROM zeugnis_documents d + JOIN zeugnis_seed_urls u ON d.seed_url_id = u.id + JOIN zeugnis_sources s ON u.source_id = s.id + ORDER BY d.created_at DESC + LIMIT $1 OFFSET $2 + """, + limit, offset + ) + return [dict(r) for r in rows] + except Exception as e: + print(f"Failed to get zeugnis documents: {e}") + return [] + + +async def get_zeugnis_stats() -> Dict: + """Get zeugnis crawler statistics.""" + pool = await get_pool() + if pool is None: + return {"error": "Database not available"} + + try: + async with pool.acquire() as conn: + # Total sources + sources = await conn.fetchval("SELECT COUNT(*) FROM zeugnis_sources") + + # Total documents + documents = await conn.fetchval("SELECT COUNT(*) FROM zeugnis_documents") + + # Indexed documents + indexed = await conn.fetchval( + "SELECT COUNT(*) FROM zeugnis_documents WHERE indexed_in_qdrant = true" + ) + + # Training allowed + training_allowed = await conn.fetchval( + "SELECT COUNT(*) FROM zeugnis_documents WHERE training_allowed = true" + ) + + # Per Bundesland stats + per_bundesland = await conn.fetch( + """ + SELECT s.bundesland, s.name, s.training_allowed, COUNT(d.id) as doc_count + FROM zeugnis_sources s + LEFT JOIN zeugnis_seed_urls u ON s.id = u.source_id + LEFT JOIN zeugnis_documents d ON u.id = d.seed_url_id + GROUP BY s.bundesland, s.name, s.training_allowed + ORDER BY s.bundesland + """ + ) + + # Active crawls + active_crawls = await conn.fetchval( + "SELECT COUNT(*) FROM zeugnis_crawler_queue WHERE status = 'running'" + ) + + return { + "total_sources": sources or 0, + "total_documents": documents or 0, + "indexed_documents": indexed or 0, + "training_allowed_documents": training_allowed or 0, + "active_crawls": active_crawls or 0, + "per_bundesland": [dict(r) for r in per_bundesland], + } + except Exception as e: + print(f"Failed to get zeugnis stats: {e}") + return {"error": str(e)} + + +async def log_zeugnis_event( + document_id: str, + event_type: str, + user_id: Optional[str] = None, + details: Optional[Dict] = None, +) -> bool: + """Log a zeugnis usage event for audit trail.""" + pool = await get_pool() + if pool is None: + return False + + try: + import json + import uuid + async with pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO zeugnis_usage_events (id, document_id, event_type, user_id, details) + VALUES ($1, $2, $3, $4, $5) + """, + str(uuid.uuid4()), document_id, event_type, user_id, + json.dumps(details) if details else None + ) + return True + except Exception as e: + print(f"Failed to log zeugnis event: {e}") + return False diff --git a/klausur-service/backend/minio_storage.py b/klausur-service/backend/minio_storage.py new file mode 100644 index 0000000..505cea5 --- /dev/null +++ b/klausur-service/backend/minio_storage.py @@ -0,0 +1,360 @@ +""" +MinIO Storage Service for RAG Documents +Provides S3-compatible object storage for PDFs and other training documents. +""" + +import os +from typing import Optional, List, Dict, BinaryIO +from datetime import datetime, timedelta +from pathlib import Path +import io + +# MinIO Configuration - Credentials from Vault or environment (test defaults for CI) +MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "localhost:9000") +MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "test-access-key") +MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "test-secret-key") +MINIO_BUCKET = os.getenv("MINIO_BUCKET", "breakpilot-rag") +MINIO_SECURE = os.getenv("MINIO_SECURE", "false").lower() == "true" + +# Flag to check if using test defaults (MinIO operations will fail gracefully) +_MINIO_CONFIGURED = MINIO_ACCESS_KEY != "test-access-key" + +# Lazy import to avoid issues when minio is not installed +_minio_client = None + + +def _get_minio_client(): + """Get or create MinIO client singleton.""" + global _minio_client + if _minio_client is None: + try: + from minio import Minio + _minio_client = Minio( + MINIO_ENDPOINT, + access_key=MINIO_ACCESS_KEY, + secret_key=MINIO_SECRET_KEY, + secure=MINIO_SECURE, + ) + except ImportError: + print("Warning: minio package not installed. MinIO storage disabled.") + return None + except Exception as e: + print(f"Warning: Failed to connect to MinIO: {e}") + return None + return _minio_client + + +async def init_minio_bucket() -> bool: + """Initialize MinIO bucket if not exists.""" + client = _get_minio_client() + if client is None: + return False + + try: + if not client.bucket_exists(MINIO_BUCKET): + client.make_bucket(MINIO_BUCKET) + print(f"Created MinIO bucket: {MINIO_BUCKET}") + return True + except Exception as e: + print(f"Failed to initialize MinIO bucket: {e}") + return False + + +async def upload_document( + file_data: bytes, + object_name: str, + content_type: str = "application/pdf", + metadata: Optional[Dict[str, str]] = None, +) -> Optional[str]: + """ + Upload a document to MinIO. + + Args: + file_data: File content as bytes + object_name: Path in bucket (e.g., "landes-daten/ni/klausur/2024/doc.pdf") + content_type: MIME type + metadata: Optional metadata dict + + Returns: + Full object path or None on failure + """ + client = _get_minio_client() + if client is None: + return None + + try: + # Ensure bucket exists + await init_minio_bucket() + + # Upload file + data_stream = io.BytesIO(file_data) + client.put_object( + bucket_name=MINIO_BUCKET, + object_name=object_name, + data=data_stream, + length=len(file_data), + content_type=content_type, + metadata=metadata or {}, + ) + + return f"{MINIO_BUCKET}/{object_name}" + except Exception as e: + print(f"Failed to upload to MinIO: {e}") + return None + + +async def download_document(object_name: str) -> Optional[bytes]: + """ + Download a document from MinIO. + + Args: + object_name: Path in bucket + + Returns: + File content as bytes or None on failure + """ + client = _get_minio_client() + if client is None: + return None + + try: + response = client.get_object(MINIO_BUCKET, object_name) + data = response.read() + response.close() + response.release_conn() + return data + except Exception as e: + print(f"Failed to download from MinIO: {e}") + return None + + +async def list_documents( + prefix: str = "", + recursive: bool = True, +) -> List[Dict]: + """ + List documents in MinIO bucket. + + Args: + prefix: Path prefix to filter (e.g., "landes-daten/ni/") + recursive: Whether to list recursively + + Returns: + List of document info dicts + """ + client = _get_minio_client() + if client is None: + return [] + + try: + objects = client.list_objects( + MINIO_BUCKET, + prefix=prefix, + recursive=recursive, + ) + + return [ + { + "name": obj.object_name, + "size": obj.size, + "last_modified": obj.last_modified.isoformat() if obj.last_modified else None, + "etag": obj.etag, + } + for obj in objects + ] + except Exception as e: + print(f"Failed to list MinIO objects: {e}") + return [] + + +async def delete_document(object_name: str) -> bool: + """ + Delete a document from MinIO. + + Args: + object_name: Path in bucket + + Returns: + True on success + """ + client = _get_minio_client() + if client is None: + return False + + try: + client.remove_object(MINIO_BUCKET, object_name) + return True + except Exception as e: + print(f"Failed to delete from MinIO: {e}") + return False + + +async def get_presigned_url( + object_name: str, + expires: int = 3600, +) -> Optional[str]: + """ + Get a presigned URL for temporary access to a document. + + Args: + object_name: Path in bucket + expires: URL expiration time in seconds (default 1 hour) + + Returns: + Presigned URL or None on failure + """ + client = _get_minio_client() + if client is None: + return None + + try: + url = client.presigned_get_object( + MINIO_BUCKET, + object_name, + expires=timedelta(seconds=expires), + ) + return url + except Exception as e: + print(f"Failed to generate presigned URL: {e}") + return None + + +async def get_storage_stats() -> Dict: + """Get storage statistics.""" + client = _get_minio_client() + if client is None: + return {"error": "MinIO not available", "connected": False} + + try: + # Count objects and calculate total size + objects = list(client.list_objects(MINIO_BUCKET, recursive=True)) + total_size = sum(obj.size for obj in objects) + total_count = len(objects) + + # Group by prefix + by_prefix: Dict[str, int] = {} + for obj in objects: + parts = obj.object_name.split("/") + if len(parts) >= 2: + prefix = f"{parts[0]}/{parts[1]}" + by_prefix[prefix] = by_prefix.get(prefix, 0) + 1 + + return { + "connected": True, + "bucket": MINIO_BUCKET, + "total_objects": total_count, + "total_size_bytes": total_size, + "total_size_mb": round(total_size / (1024 * 1024), 2), + "by_prefix": by_prefix, + } + except Exception as e: + return {"error": str(e), "connected": False} + + +# ============================================================================= +# RAG-specific Storage Functions +# ============================================================================= + +def get_minio_path( + data_type: str, # "landes-daten" or "lehrer-daten" + bundesland: str, # "ni", "by", etc. + use_case: str, # "klausur", "zeugnis", "lehrplan" + year: int, + filename: str, +) -> str: + """ + Generate MinIO path following the RAG-Admin-Spec.md structure. + + Example: landes-daten/ni/klausur/2024/2024_Deutsch_eA_I_EWH.pdf + """ + return f"{data_type}/{bundesland.lower()}/{use_case}/{year}/{filename}" + + +async def upload_rag_document( + file_data: bytes, + filename: str, + bundesland: str = "ni", + use_case: str = "klausur", + year: Optional[int] = None, + metadata: Optional[Dict[str, str]] = None, +) -> Optional[str]: + """ + Upload a document to the RAG storage structure. + + Args: + file_data: PDF content + filename: Original filename + bundesland: State code (ni, by, etc.) + use_case: klausur, zeugnis, lehrplan + year: Document year (defaults to current year) + metadata: Optional metadata + + Returns: + MinIO path on success + """ + if year is None: + year = datetime.now().year + + object_path = get_minio_path( + data_type="landes-daten", + bundesland=bundesland, + use_case=use_case, + year=year, + filename=filename, + ) + + # Add RAG metadata + rag_metadata = { + "bundesland": bundesland, + "use_case": use_case, + "year": str(year), + "training_allowed": "true", # Landes-Daten allow training + **(metadata or {}), + } + + return await upload_document( + file_data=file_data, + object_name=object_path, + content_type="application/pdf", + metadata=rag_metadata, + ) + + +async def upload_teacher_document( + file_data: bytes, + filename: str, + tenant_id: str, + teacher_id: str, + metadata: Optional[Dict[str, str]] = None, +) -> Optional[str]: + """ + Upload a teacher's document (BYOEH - encrypted, no training). + + Args: + file_data: Encrypted PDF content + filename: Original filename (will be stored as .enc) + tenant_id: School/tenant ID + teacher_id: Teacher ID + metadata: Optional metadata + + Returns: + MinIO path on success + """ + enc_filename = filename if filename.endswith(".enc") else f"{filename}.enc" + object_path = f"lehrer-daten/{tenant_id}/{teacher_id}/{enc_filename}" + + # Teacher data - never allow training + teacher_metadata = { + "tenant_id": tenant_id, + "teacher_id": teacher_id, + "training_allowed": "false", # CRITICAL: Never train on teacher data + "encrypted": "true", + **(metadata or {}), + } + + return await upload_document( + file_data=file_data, + object_name=object_path, + content_type="application/octet-stream", + metadata=teacher_metadata, + ) diff --git a/klausur-service/backend/models/__init__.py b/klausur-service/backend/models/__init__.py new file mode 100644 index 0000000..291dcd6 --- /dev/null +++ b/klausur-service/backend/models/__init__.py @@ -0,0 +1,74 @@ +""" +Klausur-Service Models + +Data models for exams, students, grading, and Erwartungshorizont. +""" + +from .enums import KlausurModus, StudentKlausurStatus, EHStatus +from .exam import Klausur, StudentKlausur +from .grading import AuditLogEntry, GRADE_THRESHOLDS, GRADE_LABELS, DEFAULT_CRITERIA +from .eh import ( + Erwartungshorizont, + EHRightsConfirmation, + EHAuditLogEntry, + EHKeyShare, + EHKlausurLink, + EHShareInvitation, +) +from .requests import ( + KlausurCreate, + KlausurUpdate, + StudentUpload, + CriterionScoreUpdate, + GutachtenUpdate, + ExaminerAssignment, + ExaminerResult, + GutachtenGenerateRequest, + EHMetadata, + EHUploadMetadata, + EHRAGQuery, + EHIndexRequest, + EHShareRequest, + EHLinkKlausurRequest, + EHInviteRequest, + EHAcceptInviteRequest, +) + +__all__ = [ + # Enums + "KlausurModus", + "StudentKlausurStatus", + "EHStatus", + # Exam Models + "Klausur", + "StudentKlausur", + # Grading + "AuditLogEntry", + "GRADE_THRESHOLDS", + "GRADE_LABELS", + "DEFAULT_CRITERIA", + # EH Models + "Erwartungshorizont", + "EHRightsConfirmation", + "EHAuditLogEntry", + "EHKeyShare", + "EHKlausurLink", + "EHShareInvitation", + # Request Models + "KlausurCreate", + "KlausurUpdate", + "StudentUpload", + "CriterionScoreUpdate", + "GutachtenUpdate", + "ExaminerAssignment", + "ExaminerResult", + "GutachtenGenerateRequest", + "EHMetadata", + "EHUploadMetadata", + "EHRAGQuery", + "EHIndexRequest", + "EHShareRequest", + "EHLinkKlausurRequest", + "EHInviteRequest", + "EHAcceptInviteRequest", +] diff --git a/klausur-service/backend/models/eh.py b/klausur-service/backend/models/eh.py new file mode 100644 index 0000000..af11551 --- /dev/null +++ b/klausur-service/backend/models/eh.py @@ -0,0 +1,197 @@ +""" +Klausur-Service Erwartungshorizont Models + +Data classes for BYOEH (Bring-Your-Own-Expectation-Horizon). +""" + +from dataclasses import dataclass +from datetime import datetime +from typing import Optional, Dict, Any + +from .enums import EHStatus + + +@dataclass +class Erwartungshorizont: + """An encrypted Erwartungshorizont (expectation horizon).""" + id: str + tenant_id: str + teacher_id: str + title: str + subject: str + niveau: str # 'eA' or 'gA' + year: int + aufgaben_nummer: Optional[str] + encryption_key_hash: str + salt: str + encrypted_file_path: str + file_size_bytes: int + original_filename: str + rights_confirmed: bool + rights_confirmed_at: Optional[datetime] + status: EHStatus + chunk_count: int + indexed_at: Optional[datetime] + error_message: Optional[str] + training_allowed: bool # ALWAYS FALSE + created_at: datetime + deleted_at: Optional[datetime] + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + status_value = self.status.value if hasattr(self.status, 'value') else self.status + return { + 'id': self.id, + 'tenant_id': self.tenant_id, + 'teacher_id': self.teacher_id, + 'title': self.title, + 'subject': self.subject, + 'niveau': self.niveau, + 'year': self.year, + 'aufgaben_nummer': self.aufgaben_nummer, + 'status': status_value, + 'chunk_count': self.chunk_count, + 'rights_confirmed': self.rights_confirmed, + 'rights_confirmed_at': self.rights_confirmed_at.isoformat() if self.rights_confirmed_at else None, + 'indexed_at': self.indexed_at.isoformat() if self.indexed_at else None, + 'file_size_bytes': self.file_size_bytes, + 'original_filename': self.original_filename, + 'training_allowed': self.training_allowed, + 'created_at': self.created_at.isoformat(), + 'deleted_at': self.deleted_at.isoformat() if self.deleted_at else None + } + + +@dataclass +class EHRightsConfirmation: + """Rights confirmation for an Erwartungshorizont upload.""" + id: str + eh_id: str + teacher_id: str + confirmation_type: str # 'upload' | 'annual' + confirmation_text: str + ip_address: Optional[str] + user_agent: Optional[str] + confirmed_at: datetime + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return { + 'id': self.id, + 'eh_id': self.eh_id, + 'teacher_id': self.teacher_id, + 'confirmation_type': self.confirmation_type, + 'confirmation_text': self.confirmation_text, + 'confirmed_at': self.confirmed_at.isoformat() + } + + +@dataclass +class EHAuditLogEntry: + """Audit log entry for EH operations.""" + id: str + eh_id: Optional[str] + tenant_id: str + user_id: str + action: str # upload, index, rag_query, download, delete + details: Optional[Dict] + ip_address: Optional[str] + user_agent: Optional[str] + created_at: datetime + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return { + 'id': self.id, + 'eh_id': self.eh_id, + 'tenant_id': self.tenant_id, + 'user_id': self.user_id, + 'action': self.action, + 'details': self.details, + 'created_at': self.created_at.isoformat() + } + + +@dataclass +class EHKeyShare: + """Encrypted passphrase share for authorized users.""" + id: str + eh_id: str + user_id: str + encrypted_passphrase: str # Passphrase encrypted with recipient's public key + passphrase_hint: str # Optional hint for the passphrase + granted_by: str + granted_at: datetime + role: str # 'second_examiner', 'third_examiner', 'supervisor' + klausur_id: Optional[str] # Link to specific Klausur if applicable + active: bool + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return { + 'id': self.id, + 'eh_id': self.eh_id, + 'user_id': self.user_id, + 'passphrase_hint': self.passphrase_hint, + 'granted_by': self.granted_by, + 'granted_at': self.granted_at.isoformat(), + 'role': self.role, + 'klausur_id': self.klausur_id, + 'active': self.active + } + + +@dataclass +class EHKlausurLink: + """Link between an EH and a Klausur.""" + id: str + eh_id: str + klausur_id: str + linked_by: str + linked_at: datetime + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return { + 'id': self.id, + 'eh_id': self.eh_id, + 'klausur_id': self.klausur_id, + 'linked_by': self.linked_by, + 'linked_at': self.linked_at.isoformat() + } + + +@dataclass +class EHShareInvitation: + """Invitation to share an EH with another user.""" + id: str + eh_id: str + inviter_id: str # User who sent the invitation + invitee_id: str # User receiving the invitation + invitee_email: str # Email for notification + role: str # Target role for the invitee + klausur_id: Optional[str] # Optional link to specific Klausur + message: Optional[str] # Optional message from inviter + status: str # 'pending', 'accepted', 'declined', 'expired', 'revoked' + expires_at: datetime # Invitation expiration + created_at: datetime + accepted_at: Optional[datetime] + declined_at: Optional[datetime] + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return { + 'id': self.id, + 'eh_id': self.eh_id, + 'inviter_id': self.inviter_id, + 'invitee_id': self.invitee_id, + 'invitee_email': self.invitee_email, + 'role': self.role, + 'klausur_id': self.klausur_id, + 'message': self.message, + 'status': self.status, + 'expires_at': self.expires_at.isoformat(), + 'created_at': self.created_at.isoformat(), + 'accepted_at': self.accepted_at.isoformat() if self.accepted_at else None, + 'declined_at': self.declined_at.isoformat() if self.declined_at else None + } diff --git a/klausur-service/backend/models/enums.py b/klausur-service/backend/models/enums.py new file mode 100644 index 0000000..b6b8385 --- /dev/null +++ b/klausur-service/backend/models/enums.py @@ -0,0 +1,33 @@ +""" +Klausur-Service Enums + +Status and type enumerations. +""" + +from enum import Enum + + +class KlausurModus(str, Enum): + """Klausur mode: Landes-Abitur or Vorabitur.""" + LANDES_ABITUR = "landes_abitur" + VORABITUR = "vorabitur" + + +class StudentKlausurStatus(str, Enum): + """Processing status of a student's work.""" + UPLOADED = "uploaded" + OCR_PROCESSING = "ocr_processing" + OCR_COMPLETE = "ocr_complete" + ANALYZING = "analyzing" + FIRST_EXAMINER = "first_examiner" + SECOND_EXAMINER = "second_examiner" + COMPLETED = "completed" + ERROR = "error" + + +class EHStatus(str, Enum): + """Status of an Erwartungshorizont.""" + PENDING_RIGHTS = "pending_rights" + PROCESSING = "processing" + INDEXED = "indexed" + ERROR = "error" diff --git a/klausur-service/backend/models/exam.py b/klausur-service/backend/models/exam.py new file mode 100644 index 0000000..4d4ac9f --- /dev/null +++ b/klausur-service/backend/models/exam.py @@ -0,0 +1,68 @@ +""" +Klausur-Service Exam Models + +Data classes for Klausur and StudentKlausur. +""" + +from dataclasses import dataclass, asdict +from datetime import datetime +from typing import Optional, List, Dict, Any + +from .enums import KlausurModus, StudentKlausurStatus + + +@dataclass +class StudentKlausur: + """A student's exam work.""" + id: str + klausur_id: str + student_name: str + student_id: Optional[str] + file_path: Optional[str] + ocr_text: Optional[str] + status: StudentKlausurStatus + criteria_scores: Dict[str, Dict] + gutachten: Optional[Dict] + raw_points: int + grade_points: int + created_at: datetime + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + d = asdict(self) + d['status'] = self.status.value if hasattr(self.status, 'value') else self.status + d['created_at'] = self.created_at.isoformat() + return d + + +@dataclass +class Klausur: + """An exam/Klausur with associated student works.""" + id: str + title: str + subject: str + modus: KlausurModus + class_id: Optional[str] + year: int + semester: str + erwartungshorizont: Optional[Dict] + students: List[StudentKlausur] + created_at: datetime + teacher_id: str + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return { + 'id': self.id, + 'title': self.title, + 'subject': self.subject, + 'modus': self.modus.value, + 'class_id': self.class_id, + 'year': self.year, + 'semester': self.semester, + 'erwartungshorizont': self.erwartungshorizont, + 'student_count': len(self.students), + 'students': [s.to_dict() for s in self.students], + 'created_at': self.created_at.isoformat(), + 'teacher_id': self.teacher_id + } diff --git a/klausur-service/backend/models/grading.py b/klausur-service/backend/models/grading.py new file mode 100644 index 0000000..5c65fc4 --- /dev/null +++ b/klausur-service/backend/models/grading.py @@ -0,0 +1,71 @@ +""" +Klausur-Service Grading Models + +Grade thresholds, labels, criteria, and audit logging. +""" + +from dataclasses import dataclass +from datetime import datetime +from typing import Optional, Dict, Any + + +# ============================================= +# GRADE CONSTANTS +# ============================================= + +GRADE_THRESHOLDS = { + 15: 95, 14: 90, 13: 85, 12: 80, 11: 75, 10: 70, + 9: 65, 8: 60, 7: 55, 6: 50, 5: 45, 4: 40, + 3: 33, 2: 27, 1: 20, 0: 0 +} + +GRADE_LABELS = { + 15: "1+ (sehr gut plus)", 14: "1 (sehr gut)", 13: "1- (sehr gut minus)", + 12: "2+ (gut plus)", 11: "2 (gut)", 10: "2- (gut minus)", + 9: "3+ (befriedigend plus)", 8: "3 (befriedigend)", 7: "3- (befriedigend minus)", + 6: "4+ (ausreichend plus)", 5: "4 (ausreichend)", 4: "4- (ausreichend minus)", + 3: "5+ (mangelhaft plus)", 2: "5 (mangelhaft)", 1: "5- (mangelhaft minus)", + 0: "6 (ungenuegend)" +} + +DEFAULT_CRITERIA = { + "rechtschreibung": {"weight": 0.15, "label": "Rechtschreibung"}, + "grammatik": {"weight": 0.15, "label": "Grammatik"}, + "inhalt": {"weight": 0.40, "label": "Inhalt"}, + "struktur": {"weight": 0.15, "label": "Struktur"}, + "stil": {"weight": 0.15, "label": "Stil"}, +} + + +# ============================================= +# AUDIT LOG +# ============================================= + +@dataclass +class AuditLogEntry: + """Audit log entry for tracking changes.""" + id: str + timestamp: datetime + user_id: str + action: str # score_update, gutachten_update, status_change, examiner_assign + entity_type: str # klausur, student + entity_id: str + field: Optional[str] + old_value: Optional[str] + new_value: Optional[str] + details: Optional[Dict] + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return { + 'id': self.id, + 'timestamp': self.timestamp.isoformat(), + 'user_id': self.user_id, + 'action': self.action, + 'entity_type': self.entity_type, + 'entity_id': self.entity_id, + 'field': self.field, + 'old_value': self.old_value, + 'new_value': self.new_value, + 'details': self.details + } diff --git a/klausur-service/backend/models/requests.py b/klausur-service/backend/models/requests.py new file mode 100644 index 0000000..973eca2 --- /dev/null +++ b/klausur-service/backend/models/requests.py @@ -0,0 +1,152 @@ +""" +Klausur-Service Request Models + +Pydantic models for API request/response validation. +""" + +from typing import Optional, List, Dict, Any +from pydantic import BaseModel + +from .enums import KlausurModus + + +# ============================================= +# KLAUSUR REQUESTS +# ============================================= + +class KlausurCreate(BaseModel): + """Request to create a new Klausur.""" + title: str + subject: str + modus: KlausurModus = KlausurModus.VORABITUR + class_id: Optional[str] = None + year: int = 2025 + semester: str = "Q1" + + +class KlausurUpdate(BaseModel): + """Request to update a Klausur.""" + title: Optional[str] = None + subject: Optional[str] = None + erwartungshorizont: Optional[Dict[str, Any]] = None + + +# ============================================= +# STUDENT REQUESTS +# ============================================= + +class StudentUpload(BaseModel): + """Request for student work upload metadata.""" + student_name: str + student_id: Optional[str] = None + + +# ============================================= +# GRADING REQUESTS +# ============================================= + +class CriterionScoreUpdate(BaseModel): + """Request to update a criterion score.""" + criterion: str + score: int + annotations: Optional[List[str]] = None + + +class GutachtenUpdate(BaseModel): + """Request to update the Gutachten.""" + einleitung: str + hauptteil: str + fazit: str + staerken: Optional[List[str]] = None + schwaechen: Optional[List[str]] = None + + +class GutachtenGenerateRequest(BaseModel): + """Request to generate a KI-based Gutachten.""" + include_strengths: bool = True + include_weaknesses: bool = True + tone: str = "formal" # formal, friendly, constructive + # BYOEH RAG Integration + use_eh: bool = False # Whether to use Erwartungshorizont for context + eh_passphrase: Optional[str] = None # Passphrase for EH decryption + + +# ============================================= +# EXAMINER REQUESTS +# ============================================= + +class ExaminerAssignment(BaseModel): + """Request to assign an examiner.""" + examiner_id: str + examiner_role: str # first_examiner, second_examiner + notes: Optional[str] = None + + +class ExaminerResult(BaseModel): + """Request to submit an examiner's result.""" + grade_points: int + notes: Optional[str] = None + + +# ============================================= +# BYOEH REQUESTS +# ============================================= + +class EHMetadata(BaseModel): + """Metadata for an Erwartungshorizont.""" + title: str + subject: str + niveau: str # 'eA' | 'gA' + year: int + aufgaben_nummer: Optional[str] = None + + +class EHUploadMetadata(BaseModel): + """Metadata for EH upload including encryption info.""" + metadata: EHMetadata + encryption_key_hash: str + salt: str + rights_confirmed: bool + original_filename: str + + +class EHRAGQuery(BaseModel): + """Request for RAG query against Erwartungshorizonte.""" + query_text: str + passphrase: str + subject: Optional[str] = None + limit: int = 5 + + +class EHIndexRequest(BaseModel): + """Request to index an Erwartungshorizont.""" + passphrase: str + + +class EHShareRequest(BaseModel): + """Request to share EH with another examiner.""" + user_id: str # User to share with + role: str # second_examiner, third_examiner, supervisor + encrypted_passphrase: str # Passphrase encrypted for recipient + passphrase_hint: Optional[str] = None + klausur_id: Optional[str] = None # Optional: link to specific Klausur + + +class EHLinkKlausurRequest(BaseModel): + """Request to link EH to a Klausur.""" + klausur_id: str + + +class EHInviteRequest(BaseModel): + """Request to invite another user to access an EH.""" + invitee_email: str # Email of the recipient + invitee_id: Optional[str] = None # User ID if known + role: str # second_examiner, third_examiner, supervisor, department_head + klausur_id: Optional[str] = None # Optional: link to specific Klausur + message: Optional[str] = None # Optional message for the invitee + expires_in_days: int = 14 # Default 14 days expiration + + +class EHAcceptInviteRequest(BaseModel): + """Request to accept an invitation and receive the encrypted passphrase.""" + encrypted_passphrase: str # The passphrase encrypted for the invitee diff --git a/klausur-service/backend/nibis_ingestion.py b/klausur-service/backend/nibis_ingestion.py new file mode 100644 index 0000000..3fe22e0 --- /dev/null +++ b/klausur-service/backend/nibis_ingestion.py @@ -0,0 +1,516 @@ +""" +NiBiS Ingestion Pipeline +Automatisierte Verarbeitung von Abitur-Erwartungshorizonten aus Niedersachsen. + +Unterstützt: +- Mehrere Jahre (2016, 2017, 2024, 2025, ...) +- Verschiedene Namenskonventionen (alt: *Lehrer/*L.pdf, neu: *_EWH.pdf) +- Automatisches Entpacken von ZIP-Dateien +- Flexible Erweiterung für andere Bundesländer +""" + +import os +import re +import zipfile +import hashlib +import json +from pathlib import Path +from typing import List, Dict, Optional, Tuple +from dataclasses import dataclass, asdict +from datetime import datetime +import asyncio + +# Local imports +from eh_pipeline import chunk_text, generate_embeddings, extract_text_from_pdf, get_vector_size, EMBEDDING_BACKEND +from qdrant_service import QdrantService + +# Configuration +DOCS_BASE_PATH = Path("/Users/benjaminadmin/projekte/breakpilot-pwa/docs") +ZA_DOWNLOAD_DIRS = ["za-download", "za-download-2", "za-download-3"] + +# Qdrant collection for NiBiS data (separate from user EH) +NIBIS_COLLECTION = "bp_nibis_eh" + + +@dataclass +class NiBiSDocument: + """Strukturierte Repräsentation eines NiBiS-Dokuments.""" + id: str + file_path: str + year: int + subject: str + niveau: str # eA, gA, EA, GA + task_number: Optional[int] + doc_type: str # EWH, Aufgabe, Material, GBU, etc. + bundesland: str + source_dir: str + file_hash: str + extracted_at: datetime + + # Metadaten aus Dateinamen + raw_filename: str + variant: Optional[str] = None # BG, Tech, Wirt, etc. + + def to_dict(self) -> dict: + d = asdict(self) + d['extracted_at'] = d['extracted_at'].isoformat() + return d + + +# Fach-Mapping (Kurzform -> Langform) +SUBJECT_MAPPING = { + "deutsch": "Deutsch", + "englisch": "Englisch", + "englischbg": "Englisch (Berufliches Gymnasium)", + "mathe": "Mathematik", + "mathebg": "Mathematik (Berufliches Gymnasium)", + "mathezwb": "Mathematik (Zweiter Bildungsweg)", + "informatik": "Informatik", + "biologie": "Biologie", + "chemie": "Chemie", + "physik": "Physik", + "geschichte": "Geschichte", + "erdkunde": "Erdkunde/Geografie", + "kunst": "Kunst", + "musik": "Musik", + "sport": "Sport", + "latein": "Latein", + "griechisch": "Griechisch", + "französisch": "Französisch", + "franzîsisch": "Französisch", # Encoding-Problem in 2017 + "spanisch": "Spanisch", + "kathreligion": "Katholische Religion", + "evreligion": "Evangelische Religion", + "wertenormen": "Werte und Normen", + "brc": "Betriebswirtschaft mit Rechnungswesen/Controlling", + "bvw": "Betriebswirtschaft mit Rechnungswesen", + "gespfl": "Gesundheit-Pflege", +} + +# Niveau-Mapping +NIVEAU_MAPPING = { + "ea": "eA", # erhöhtes Anforderungsniveau + "ga": "gA", # grundlegendes Anforderungsniveau + "neuga": "gA (neu einsetzend)", + "neuea": "eA (neu einsetzend)", +} + + +def compute_file_hash(file_path: Path) -> str: + """Berechnet SHA-256 Hash einer Datei.""" + sha256 = hashlib.sha256() + with open(file_path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + sha256.update(chunk) + return sha256.hexdigest()[:16] + + +def extract_zip_files(base_path: Path) -> List[Path]: + """Entpackt alle ZIP-Dateien in den za-download Verzeichnissen.""" + extracted = [] + + for za_dir in ZA_DOWNLOAD_DIRS: + za_path = base_path / za_dir + if not za_path.exists(): + continue + + for zip_file in za_path.glob("*.zip"): + # Zielverzeichnis = Name ohne .zip + target_dir = za_path / zip_file.stem + + if target_dir.exists(): + print(f" Bereits entpackt: {zip_file.name} -> {target_dir.name}/") + extracted.append(target_dir) + continue + + print(f" Entpacke: {zip_file.name}...") + try: + with zipfile.ZipFile(zip_file, 'r') as zf: + zf.extractall(target_dir) + print(f" -> {len(list(target_dir.rglob('*')))} Dateien extrahiert") + extracted.append(target_dir) + except Exception as e: + print(f" FEHLER: {e}") + + return extracted + + +def parse_filename_old_format(filename: str, file_path: Path) -> Optional[Dict]: + """ + Parst alte Namenskonvention (2016, 2017): + - {Jahr}{Fach}{Niveau}Lehrer/{Jahr}{Fach}{Niveau}A{Nr}L.pdf + - Beispiel: 2016DeutschEALehrer/2016DeutschEAA1L.pdf + """ + # Pattern für Lehrer-Dateien + pattern = r"(\d{4})([A-Za-zäöüÄÖÜ]+)(EA|GA|NeuGA|NeuEA)(?:Lehrer)?.*?(?:A(\d+)|Aufg(\d+))?L?\.pdf$" + + match = re.search(pattern, filename, re.IGNORECASE) + if not match: + return None + + year = int(match.group(1)) + subject_raw = match.group(2).lower() + niveau = match.group(3).upper() + task_num = match.group(4) or match.group(5) + + # Prüfe ob es ein Lehrer-Dokument ist (EWH) + is_ewh = "lehrer" in str(file_path).lower() or filename.endswith("L.pdf") + + # Extrahiere Variante (Tech, Wirt, CAS, GTR, etc.) + variant = None + variant_patterns = ["Tech", "Wirt", "CAS", "GTR", "Pflicht", "BG", "mitExp", "ohneExp"] + for v in variant_patterns: + if v.lower() in str(file_path).lower(): + variant = v + break + + return { + "year": year, + "subject": subject_raw, + "niveau": NIVEAU_MAPPING.get(niveau.lower(), niveau), + "task_number": int(task_num) if task_num else None, + "doc_type": "EWH" if is_ewh else "Aufgabe", + "variant": variant, + } + + +def parse_filename_new_format(filename: str, file_path: Path) -> Optional[Dict]: + """ + Parst neue Namenskonvention (2024, 2025): + - {Jahr}_{Fach}_{niveau}_{Nr}_EWH.pdf + - Beispiel: 2025_Deutsch_eA_I_EWH.pdf + """ + # Pattern für neue Dateien + pattern = r"(\d{4})_([A-Za-zäöüÄÖÜ]+)(?:BG)?_(eA|gA)(?:_([IVX\d]+))?(?:_(.+))?\.pdf$" + + match = re.search(pattern, filename, re.IGNORECASE) + if not match: + return None + + year = int(match.group(1)) + subject_raw = match.group(2).lower() + niveau = match.group(3) + task_id = match.group(4) + suffix = match.group(5) or "" + + # Task-Nummer aus römischen Zahlen + task_num = None + if task_id: + roman_map = {"I": 1, "II": 2, "III": 3, "IV": 4, "V": 5} + task_num = roman_map.get(task_id) or (int(task_id) if task_id.isdigit() else None) + + # Dokumenttyp + is_ewh = "EWH" in filename or "ewh" in filename.lower() + + # Spezielle Dokumenttypen + doc_type = "EWH" if is_ewh else "Aufgabe" + if "Material" in suffix: + doc_type = "Material" + elif "GBU" in suffix: + doc_type = "GBU" + elif "Ergebnis" in suffix: + doc_type = "Ergebnis" + elif "Bewertungsbogen" in suffix: + doc_type = "Bewertungsbogen" + elif "HV" in suffix: + doc_type = "Hörverstehen" + elif "ME" in suffix: + doc_type = "Mediation" + + # BG Variante + variant = "BG" if "BG" in filename else None + if "mitExp" in str(file_path): + variant = "mitExp" + + return { + "year": year, + "subject": subject_raw, + "niveau": NIVEAU_MAPPING.get(niveau.lower(), niveau), + "task_number": task_num, + "doc_type": doc_type, + "variant": variant, + } + + +def discover_documents(base_path: Path, ewh_only: bool = True) -> List[NiBiSDocument]: + """ + Findet alle relevanten Dokumente in den za-download Verzeichnissen. + + Args: + base_path: Basis-Pfad zu docs/ + ewh_only: Nur Erwartungshorizonte (keine Aufgaben) + """ + documents = [] + + for za_dir in ZA_DOWNLOAD_DIRS: + za_path = base_path / za_dir + if not za_path.exists(): + continue + + print(f"\nSuche in {za_dir}...") + + for pdf_file in za_path.rglob("*.pdf"): + filename = pdf_file.name + + # Versuche beide Formate + parsed = parse_filename_new_format(filename, pdf_file) + if not parsed: + parsed = parse_filename_old_format(filename, pdf_file) + + if not parsed: + # Unbekanntes Format + continue + + # Filter: Nur EWH? + if ewh_only and parsed["doc_type"] != "EWH": + continue + + # Erstelle Dokument + doc_id = f"nibis_{parsed['year']}_{parsed['subject']}_{parsed['niveau']}_{parsed.get('task_number', 0)}_{compute_file_hash(pdf_file)}" + + doc = NiBiSDocument( + id=doc_id, + file_path=str(pdf_file), + year=parsed["year"], + subject=SUBJECT_MAPPING.get(parsed["subject"], parsed["subject"].capitalize()), + niveau=parsed["niveau"], + task_number=parsed.get("task_number"), + doc_type=parsed["doc_type"], + bundesland="NI", # Niedersachsen + source_dir=za_dir, + file_hash=compute_file_hash(pdf_file), + extracted_at=datetime.now(), + raw_filename=filename, + variant=parsed.get("variant"), + ) + + documents.append(doc) + + return documents + + +async def index_document_to_qdrant( + doc: NiBiSDocument, + qdrant: QdrantService, + collection: str = NIBIS_COLLECTION +) -> int: + """ + Indexiert ein einzelnes Dokument in Qdrant. + + Returns: + Anzahl der indexierten Chunks + """ + # 1. PDF lesen + try: + with open(doc.file_path, "rb") as f: + pdf_content = f.read() + except Exception as e: + print(f" FEHLER beim Lesen: {e}") + return 0 + + # 2. Text extrahieren + try: + text = extract_text_from_pdf(pdf_content) + if not text or len(text.strip()) < 50: + print(f" Warnung: Wenig Text extrahiert ({len(text)} Zeichen)") + return 0 + except Exception as e: + print(f" FEHLER bei PDF-Extraktion: {e}") + return 0 + + # 3. Chunking + chunks = chunk_text(text) + if not chunks: + return 0 + + # 4. Embeddings generieren + try: + embeddings = await generate_embeddings(chunks) + except Exception as e: + print(f" FEHLER bei Embedding-Generierung: {e}") + return 0 + + # 5. In Qdrant indexieren + points = [] + for i, (chunk, embedding) in enumerate(zip(chunks, embeddings)): + point_id = f"{doc.id}_chunk_{i}" + + payload = { + "doc_id": doc.id, + "chunk_index": i, + "text": chunk, + "year": doc.year, + "subject": doc.subject, + "niveau": doc.niveau, + "task_number": doc.task_number, + "doc_type": doc.doc_type, + "bundesland": doc.bundesland, + "variant": doc.variant, + "source": "nibis", + "training_allowed": True, # NiBiS-Daten dürfen für Training genutzt werden + } + + points.append({ + "id": point_id, + "vector": embedding, + "payload": payload, + }) + + # Batch-Upload + try: + await qdrant.upsert_points(collection, points) + return len(points) + except Exception as e: + print(f" FEHLER beim Qdrant-Upload: {e}") + return 0 + + +async def run_ingestion( + ewh_only: bool = True, + dry_run: bool = False, + year_filter: Optional[int] = None, + subject_filter: Optional[str] = None, +) -> Dict: + """ + Hauptfunktion für die Ingestion-Pipeline. + + Args: + ewh_only: Nur Erwartungshorizonte indexieren + dry_run: Nur analysieren, nicht indexieren + year_filter: Optional - nur bestimmtes Jahr + subject_filter: Optional - nur bestimmtes Fach + + Returns: + Statistiken über die Ingestion + """ + stats = { + "started_at": datetime.now().isoformat(), + "zip_extracted": 0, + "documents_found": 0, + "documents_indexed": 0, + "chunks_created": 0, + "errors": [], + "by_year": {}, + "by_subject": {}, + } + + print("=" * 60) + print("NiBiS Ingestion Pipeline") + print("=" * 60) + + # 1. ZIP-Dateien entpacken + print("\n1. Entpacke ZIP-Dateien...") + extracted = extract_zip_files(DOCS_BASE_PATH) + stats["zip_extracted"] = len(extracted) + + # 2. Dokumente finden + print("\n2. Suche Dokumente...") + documents = discover_documents(DOCS_BASE_PATH, ewh_only=ewh_only) + + # Filter anwenden + if year_filter: + documents = [d for d in documents if d.year == year_filter] + if subject_filter: + documents = [d for d in documents if subject_filter.lower() in d.subject.lower()] + + stats["documents_found"] = len(documents) + + print(f"\n Gefunden: {len(documents)} Dokumente") + + # Statistiken nach Jahr/Fach + for doc in documents: + year_key = str(doc.year) + stats["by_year"][year_key] = stats["by_year"].get(year_key, 0) + 1 + stats["by_subject"][doc.subject] = stats["by_subject"].get(doc.subject, 0) + 1 + + print("\n Nach Jahr:") + for year, count in sorted(stats["by_year"].items()): + print(f" {year}: {count}") + + print("\n Nach Fach (Top 10):") + sorted_subjects = sorted(stats["by_subject"].items(), key=lambda x: -x[1])[:10] + for subject, count in sorted_subjects: + print(f" {subject}: {count}") + + if dry_run: + print("\n[DRY RUN] Keine Indexierung durchgeführt.") + return stats + + # 3. Qdrant initialisieren + vector_size = get_vector_size() + print(f"\n3. Initialisiere Qdrant...") + print(f" Embedding Backend: {EMBEDDING_BACKEND}") + print(f" Vektorgröße: {vector_size} Dimensionen") + qdrant = QdrantService() + await qdrant.ensure_collection(NIBIS_COLLECTION, vector_size=vector_size) + + # 4. Dokumente indexieren + print("\n4. Indexiere Dokumente...") + for i, doc in enumerate(documents, 1): + print(f" [{i}/{len(documents)}] {doc.raw_filename}...") + + try: + chunk_count = await index_document_to_qdrant(doc, qdrant) + if chunk_count > 0: + stats["documents_indexed"] += 1 + stats["chunks_created"] += chunk_count + print(f" -> {chunk_count} Chunks indexiert") + except Exception as e: + error_msg = f"{doc.raw_filename}: {str(e)}" + stats["errors"].append(error_msg) + print(f" FEHLER: {e}") + + stats["completed_at"] = datetime.now().isoformat() + + # 5. Zusammenfassung + print("\n" + "=" * 60) + print("ZUSAMMENFASSUNG") + print("=" * 60) + print(f" ZIP-Dateien entpackt: {stats['zip_extracted']}") + print(f" Dokumente gefunden: {stats['documents_found']}") + print(f" Dokumente indexiert: {stats['documents_indexed']}") + print(f" Chunks erstellt: {stats['chunks_created']}") + print(f" Fehler: {len(stats['errors'])}") + + return stats + + +def generate_manifest(documents: List[NiBiSDocument], output_path: Path) -> None: + """Erstellt ein Manifest aller gefundenen Dokumente.""" + manifest = { + "generated_at": datetime.now().isoformat(), + "total_documents": len(documents), + "documents": [doc.to_dict() for doc in documents], + } + + with open(output_path, "w", encoding="utf-8") as f: + json.dump(manifest, f, indent=2, ensure_ascii=False) + + print(f"Manifest geschrieben: {output_path}") + + +# CLI Entry Point +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="NiBiS Ingestion Pipeline") + parser.add_argument("--dry-run", action="store_true", help="Nur analysieren") + parser.add_argument("--year", type=int, help="Filter nach Jahr") + parser.add_argument("--subject", type=str, help="Filter nach Fach") + parser.add_argument("--all-docs", action="store_true", help="Alle Dokumente (nicht nur EWH)") + parser.add_argument("--manifest", type=str, help="Manifest-Datei erstellen") + + args = parser.parse_args() + + # Manifest erstellen? + if args.manifest: + docs = discover_documents(DOCS_BASE_PATH, ewh_only=not args.all_docs) + generate_manifest(docs, Path(args.manifest)) + else: + # Ingestion ausführen + asyncio.run(run_ingestion( + ewh_only=not args.all_docs, + dry_run=args.dry_run, + year_filter=args.year, + subject_filter=args.subject, + )) diff --git a/klausur-service/backend/nru_worksheet_generator.py b/klausur-service/backend/nru_worksheet_generator.py new file mode 100644 index 0000000..d75715b --- /dev/null +++ b/klausur-service/backend/nru_worksheet_generator.py @@ -0,0 +1,557 @@ +""" +NRU Worksheet Generator - Generate vocabulary worksheets in NRU format. + +Format: +- Page 1 (Vokabeln): 3-column table + - Column 1: English vocabulary + - Column 2: Empty (child writes German translation) + - Column 3: Empty (child writes corrected English after parent review) + +- Page 2 (Lernsätze): Full-width table + - Row 1: German sentence (pre-filled) + - Row 2-3: Empty lines (child writes English translation) + +Per scanned page, we generate 2 worksheet pages. +""" + +import io +import logging +from typing import List, Dict, Tuple +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + + +@dataclass +class VocabEntry: + english: str + german: str + source_page: int = 1 + + +@dataclass +class SentenceEntry: + german: str + english: str # For solution sheet + source_page: int = 1 + + +def separate_vocab_and_sentences(entries: List[Dict]) -> Tuple[List[VocabEntry], List[SentenceEntry]]: + """ + Separate vocabulary entries into single words/phrases and full sentences. + + Sentences are identified by: + - Ending with punctuation (. ! ?) + - Being longer than 40 characters + - Containing multiple words with capital letters mid-sentence + """ + vocab_list = [] + sentence_list = [] + + for entry in entries: + english = entry.get("english", "").strip() + german = entry.get("german", "").strip() + source_page = entry.get("source_page", 1) + + if not english or not german: + continue + + # Detect if this is a sentence + is_sentence = ( + english.endswith('.') or + english.endswith('!') or + english.endswith('?') or + len(english) > 50 or + (len(english.split()) > 5 and any(w[0].isupper() for w in english.split()[1:] if w)) + ) + + if is_sentence: + sentence_list.append(SentenceEntry( + german=german, + english=english, + source_page=source_page + )) + else: + vocab_list.append(VocabEntry( + english=english, + german=german, + source_page=source_page + )) + + return vocab_list, sentence_list + + +def generate_nru_html( + vocab_list: List[VocabEntry], + sentence_list: List[SentenceEntry], + page_number: int, + title: str = "Vokabeltest", + show_solutions: bool = False, + line_height_px: int = 28 +) -> str: + """ + Generate HTML for NRU-format worksheet. + + Returns HTML for 2 pages: + - Page 1: Vocabulary table (3 columns) + - Page 2: Sentence practice (full width) + """ + + # Filter by page + page_vocab = [v for v in vocab_list if v.source_page == page_number] + page_sentences = [s for s in sentence_list if s.source_page == page_number] + + html = f""" + + + + + + +""" + + # ========== PAGE 1: VOCABULARY TABLE ========== + if page_vocab: + html += f""" +
              +
              +

              {title} - Vokabeln (Seite {page_number})

              +
              Name: _________________________ Datum: _____________
              +
              + + + + + + + + + + +""" + for v in page_vocab: + if show_solutions: + html += f""" + + + + + +""" + else: + html += f""" + + + + + +""" + + html += """ + +
              EnglischDeutschKorrektur
              {v.english}{v.german}
              {v.english}
              +
              Vokabeln aus Unit
              +
              +""" + + # ========== PAGE 2: SENTENCE PRACTICE ========== + if page_sentences: + html += f""" +
              +
              +

              {title} - Lernsaetze (Seite {page_number})

              +
              Name: _________________________ Datum: _____________
              +
              +""" + for s in page_sentences: + html += f""" + + + + +""" + if show_solutions: + html += f""" + + + + + + +""" + else: + html += """ + + + + + + +""" + html += """ +
              {s.german}
              {s.english}
              +""" + + html += """ +
              Lernsaetze aus Unit
              +
              +""" + + html += """ + + +""" + return html + + +def generate_nru_worksheet_html( + entries: List[Dict], + title: str = "Vokabeltest", + show_solutions: bool = False, + specific_pages: List[int] = None +) -> str: + """ + Generate complete NRU worksheet HTML for all pages. + + Args: + entries: List of vocabulary entries with source_page + title: Worksheet title + show_solutions: Whether to show answers + specific_pages: List of specific page numbers to include (1-indexed) + + Returns: + Complete HTML document + """ + # Separate into vocab and sentences + vocab_list, sentence_list = separate_vocab_and_sentences(entries) + + # Get unique page numbers + all_pages = set() + for v in vocab_list: + all_pages.add(v.source_page) + for s in sentence_list: + all_pages.add(s.source_page) + + # Filter to specific pages if requested + if specific_pages: + all_pages = all_pages.intersection(set(specific_pages)) + + pages_sorted = sorted(all_pages) + + logger.info(f"Generating NRU worksheet for pages {pages_sorted}") + logger.info(f"Total vocab: {len(vocab_list)}, Total sentences: {len(sentence_list)}") + + # Generate HTML for each page + combined_html = """ + + + + + + +""" + + for page_num in pages_sorted: + page_vocab = [v for v in vocab_list if v.source_page == page_num] + page_sentences = [s for s in sentence_list if s.source_page == page_num] + + # PAGE 1: VOCABULARY TABLE + if page_vocab: + combined_html += f""" +
              +
              +

              {title} - Vokabeln (Seite {page_num})

              +
              Name: _________________________ Datum: _____________
              +
              + + + + + + + + + + +""" + for v in page_vocab: + if show_solutions: + combined_html += f""" + + + + + +""" + else: + combined_html += f""" + + + + + +""" + + combined_html += f""" + +
              EnglischDeutschKorrektur
              {v.english}{v.german}
              {v.english}
              +
              {title} - Seite {page_num}
              +
              +""" + + # PAGE 2: SENTENCE PRACTICE + if page_sentences: + combined_html += f""" +
              +
              +

              {title} - Lernsaetze (Seite {page_num})

              +
              Name: _________________________ Datum: _____________
              +
              +""" + for s in page_sentences: + combined_html += f""" + + + + +""" + if show_solutions: + combined_html += f""" + + + + + + +""" + else: + combined_html += """ + + + + + + +""" + combined_html += """ +
              {s.german}
              {s.english}
              +""" + + combined_html += f""" +
              {title} - Seite {page_num}
              +
              +""" + + combined_html += """ + + +""" + return combined_html + + +async def generate_nru_pdf(entries: List[Dict], title: str = "Vokabeltest", include_solutions: bool = True) -> Tuple[bytes, bytes]: + """ + Generate NRU worksheet PDFs. + + Returns: + Tuple of (worksheet_pdf_bytes, solution_pdf_bytes) + """ + from weasyprint import HTML + + # Generate worksheet HTML + worksheet_html = generate_nru_worksheet_html(entries, title, show_solutions=False) + worksheet_pdf = HTML(string=worksheet_html).write_pdf() + + # Generate solution HTML + solution_pdf = None + if include_solutions: + solution_html = generate_nru_worksheet_html(entries, title, show_solutions=True) + solution_pdf = HTML(string=solution_html).write_pdf() + + return worksheet_pdf, solution_pdf diff --git a/klausur-service/backend/ocr_labeling_api.py b/klausur-service/backend/ocr_labeling_api.py new file mode 100644 index 0000000..43e7f61 --- /dev/null +++ b/klausur-service/backend/ocr_labeling_api.py @@ -0,0 +1,845 @@ +""" +OCR Labeling API for Handwriting Training Data Collection + +DATENSCHUTZ/PRIVACY: +- Alle Verarbeitung erfolgt lokal (Mac Mini mit Ollama) +- Keine Daten werden an externe Server gesendet +- Bilder werden mit SHA256-Hash dedupliziert +- Export nur für lokales Fine-Tuning (TrOCR, llama3.2-vision) + +Endpoints: +- POST /sessions - Create labeling session +- POST /sessions/{id}/upload - Upload images for labeling +- GET /queue - Get next items to label +- POST /confirm - Confirm OCR as correct +- POST /correct - Save corrected ground truth +- POST /skip - Skip unusable item +- GET /stats - Get labeling statistics +- POST /export - Export training data +""" + +from fastapi import APIRouter, HTTPException, UploadFile, File, Form, Query, BackgroundTasks +from pydantic import BaseModel +from typing import Optional, List, Dict, Any +from datetime import datetime +import uuid +import hashlib +import os +import base64 + +# Import database functions +from metrics_db import ( + create_ocr_labeling_session, + get_ocr_labeling_sessions, + get_ocr_labeling_session, + add_ocr_labeling_item, + get_ocr_labeling_queue, + get_ocr_labeling_item, + confirm_ocr_label, + correct_ocr_label, + skip_ocr_item, + get_ocr_labeling_stats, + export_training_samples, + get_training_samples, +) + +# Try to import Vision OCR service +try: + import sys + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'backend', 'klausur', 'services')) + from vision_ocr_service import get_vision_ocr_service, VisionOCRService + VISION_OCR_AVAILABLE = True +except ImportError: + VISION_OCR_AVAILABLE = False + print("Warning: Vision OCR service not available") + +# Try to import PaddleOCR from hybrid_vocab_extractor +try: + from hybrid_vocab_extractor import run_paddle_ocr + PADDLEOCR_AVAILABLE = True +except ImportError: + PADDLEOCR_AVAILABLE = False + print("Warning: PaddleOCR not available") + +# Try to import TrOCR service +try: + from services.trocr_service import run_trocr_ocr + TROCR_AVAILABLE = True +except ImportError: + TROCR_AVAILABLE = False + print("Warning: TrOCR service not available") + +# Try to import Donut service +try: + from services.donut_ocr_service import run_donut_ocr + DONUT_AVAILABLE = True +except ImportError: + DONUT_AVAILABLE = False + print("Warning: Donut OCR service not available") + +# Try to import MinIO storage +try: + from minio_storage import upload_ocr_image, get_ocr_image, MINIO_BUCKET + MINIO_AVAILABLE = True +except ImportError: + MINIO_AVAILABLE = False + print("Warning: MinIO storage not available, using local storage") + +# Try to import Training Export Service +try: + from training_export_service import ( + TrainingExportService, + TrainingSample, + get_training_export_service, + ) + TRAINING_EXPORT_AVAILABLE = True +except ImportError: + TRAINING_EXPORT_AVAILABLE = False + print("Warning: Training export service not available") + +router = APIRouter(prefix="/api/v1/ocr-label", tags=["OCR Labeling"]) + +# Local storage path (fallback if MinIO not available) +LOCAL_STORAGE_PATH = os.getenv("OCR_STORAGE_PATH", "/app/ocr-labeling") + + +# ============================================================================= +# Pydantic Models +# ============================================================================= + +class SessionCreate(BaseModel): + name: str + source_type: str = "klausur" # klausur, handwriting_sample, scan + description: Optional[str] = None + ocr_model: Optional[str] = "llama3.2-vision:11b" + + +class SessionResponse(BaseModel): + id: str + name: str + source_type: str + description: Optional[str] + ocr_model: Optional[str] + total_items: int + labeled_items: int + confirmed_items: int + corrected_items: int + skipped_items: int + created_at: datetime + + +class ItemResponse(BaseModel): + id: str + session_id: str + session_name: str + image_path: str + image_url: Optional[str] + ocr_text: Optional[str] + ocr_confidence: Optional[float] + ground_truth: Optional[str] + status: str + metadata: Optional[Dict] + created_at: datetime + + +class ConfirmRequest(BaseModel): + item_id: str + label_time_seconds: Optional[int] = None + + +class CorrectRequest(BaseModel): + item_id: str + ground_truth: str + label_time_seconds: Optional[int] = None + + +class SkipRequest(BaseModel): + item_id: str + + +class ExportRequest(BaseModel): + export_format: str = "generic" # generic, trocr, llama_vision + session_id: Optional[str] = None + batch_id: Optional[str] = None + + +class StatsResponse(BaseModel): + total_sessions: Optional[int] = None + total_items: int + labeled_items: int + confirmed_items: int + corrected_items: int + pending_items: int + exportable_items: Optional[int] = None + accuracy_rate: float + avg_label_time_seconds: Optional[float] = None + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def compute_image_hash(image_data: bytes) -> str: + """Compute SHA256 hash of image data.""" + return hashlib.sha256(image_data).hexdigest() + + +async def run_ocr_on_image(image_data: bytes, filename: str, model: str = "llama3.2-vision:11b") -> tuple: + """ + Run OCR on an image using the specified model. + + Models: + - llama3.2-vision:11b: Vision LLM (default, best for handwriting) + - trocr: Microsoft TrOCR (fast for printed text) + - paddleocr: PaddleOCR + LLM hybrid (4x faster) + - donut: Document Understanding Transformer (structured documents) + + Returns: + Tuple of (ocr_text, confidence) + """ + print(f"Running OCR with model: {model}") + + # Route to appropriate OCR service based on model + if model == "paddleocr": + return await run_paddleocr_wrapper(image_data, filename) + elif model == "donut": + return await run_donut_wrapper(image_data, filename) + elif model == "trocr": + return await run_trocr_wrapper(image_data, filename) + else: + # Default: Vision LLM (llama3.2-vision or similar) + return await run_vision_ocr_wrapper(image_data, filename) + + +async def run_vision_ocr_wrapper(image_data: bytes, filename: str) -> tuple: + """Vision LLM OCR wrapper.""" + if not VISION_OCR_AVAILABLE: + print("Vision OCR service not available") + return None, 0.0 + + try: + service = get_vision_ocr_service() + if not await service.is_available(): + print("Vision OCR service not available (is_available check failed)") + return None, 0.0 + + result = await service.extract_text( + image_data, + filename=filename, + is_handwriting=True + ) + return result.text, result.confidence + except Exception as e: + print(f"Vision OCR failed: {e}") + return None, 0.0 + + +async def run_paddleocr_wrapper(image_data: bytes, filename: str) -> tuple: + """PaddleOCR wrapper - uses hybrid_vocab_extractor.""" + if not PADDLEOCR_AVAILABLE: + print("PaddleOCR not available, falling back to Vision OCR") + return await run_vision_ocr_wrapper(image_data, filename) + + try: + # run_paddle_ocr returns (regions, raw_text) + regions, raw_text = run_paddle_ocr(image_data) + + if not raw_text: + print("PaddleOCR returned empty text") + return None, 0.0 + + # Calculate average confidence from regions + if regions: + avg_confidence = sum(r.confidence for r in regions) / len(regions) + else: + avg_confidence = 0.5 + + return raw_text, avg_confidence + except Exception as e: + print(f"PaddleOCR failed: {e}, falling back to Vision OCR") + return await run_vision_ocr_wrapper(image_data, filename) + + +async def run_trocr_wrapper(image_data: bytes, filename: str) -> tuple: + """TrOCR wrapper.""" + if not TROCR_AVAILABLE: + print("TrOCR not available, falling back to Vision OCR") + return await run_vision_ocr_wrapper(image_data, filename) + + try: + text, confidence = await run_trocr_ocr(image_data) + return text, confidence + except Exception as e: + print(f"TrOCR failed: {e}, falling back to Vision OCR") + return await run_vision_ocr_wrapper(image_data, filename) + + +async def run_donut_wrapper(image_data: bytes, filename: str) -> tuple: + """Donut OCR wrapper.""" + if not DONUT_AVAILABLE: + print("Donut not available, falling back to Vision OCR") + return await run_vision_ocr_wrapper(image_data, filename) + + try: + text, confidence = await run_donut_ocr(image_data) + return text, confidence + except Exception as e: + print(f"Donut OCR failed: {e}, falling back to Vision OCR") + return await run_vision_ocr_wrapper(image_data, filename) + + +def save_image_locally(session_id: str, item_id: str, image_data: bytes, extension: str = "png") -> str: + """Save image to local storage.""" + session_dir = os.path.join(LOCAL_STORAGE_PATH, session_id) + os.makedirs(session_dir, exist_ok=True) + + filename = f"{item_id}.{extension}" + filepath = os.path.join(session_dir, filename) + + with open(filepath, 'wb') as f: + f.write(image_data) + + return filepath + + +def get_image_url(image_path: str) -> str: + """Get URL for an image.""" + # For local images, return a relative path that the frontend can use + if image_path.startswith(LOCAL_STORAGE_PATH): + relative_path = image_path[len(LOCAL_STORAGE_PATH):].lstrip('/') + return f"/api/v1/ocr-label/images/{relative_path}" + # For MinIO images, the path is already a URL or key + return image_path + + +# ============================================================================= +# API Endpoints +# ============================================================================= + +@router.post("/sessions", response_model=SessionResponse) +async def create_session(session: SessionCreate): + """ + Create a new OCR labeling session. + + A session groups related images for labeling (e.g., all scans from one class). + """ + session_id = str(uuid.uuid4()) + + success = await create_ocr_labeling_session( + session_id=session_id, + name=session.name, + source_type=session.source_type, + description=session.description, + ocr_model=session.ocr_model, + ) + + if not success: + raise HTTPException(status_code=500, detail="Failed to create session") + + return SessionResponse( + id=session_id, + name=session.name, + source_type=session.source_type, + description=session.description, + ocr_model=session.ocr_model, + total_items=0, + labeled_items=0, + confirmed_items=0, + corrected_items=0, + skipped_items=0, + created_at=datetime.utcnow(), + ) + + +@router.get("/sessions", response_model=List[SessionResponse]) +async def list_sessions(limit: int = Query(50, ge=1, le=100)): + """List all OCR labeling sessions.""" + sessions = await get_ocr_labeling_sessions(limit=limit) + + return [ + SessionResponse( + id=s['id'], + name=s['name'], + source_type=s['source_type'], + description=s.get('description'), + ocr_model=s.get('ocr_model'), + total_items=s.get('total_items', 0), + labeled_items=s.get('labeled_items', 0), + confirmed_items=s.get('confirmed_items', 0), + corrected_items=s.get('corrected_items', 0), + skipped_items=s.get('skipped_items', 0), + created_at=s.get('created_at', datetime.utcnow()), + ) + for s in sessions + ] + + +@router.get("/sessions/{session_id}", response_model=SessionResponse) +async def get_session(session_id: str): + """Get a specific OCR labeling session.""" + session = await get_ocr_labeling_session(session_id) + + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + return SessionResponse( + id=session['id'], + name=session['name'], + source_type=session['source_type'], + description=session.get('description'), + ocr_model=session.get('ocr_model'), + total_items=session.get('total_items', 0), + labeled_items=session.get('labeled_items', 0), + confirmed_items=session.get('confirmed_items', 0), + corrected_items=session.get('corrected_items', 0), + skipped_items=session.get('skipped_items', 0), + created_at=session.get('created_at', datetime.utcnow()), + ) + + +@router.post("/sessions/{session_id}/upload") +async def upload_images( + session_id: str, + background_tasks: BackgroundTasks, + files: List[UploadFile] = File(...), + run_ocr: bool = Form(True), + metadata: Optional[str] = Form(None), # JSON string +): + """ + Upload images to a labeling session. + + Args: + session_id: Session to add images to + files: Image files to upload (PNG, JPG, PDF) + run_ocr: Whether to run OCR immediately (default: True) + metadata: Optional JSON metadata (subject, year, etc.) + """ + import json + + # Verify session exists + session = await get_ocr_labeling_session(session_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + # Parse metadata + meta_dict = None + if metadata: + try: + meta_dict = json.loads(metadata) + except json.JSONDecodeError: + meta_dict = {"raw": metadata} + + results = [] + ocr_model = session.get('ocr_model', 'llama3.2-vision:11b') + + for file in files: + # Read file content + content = await file.read() + + # Compute hash for deduplication + image_hash = compute_image_hash(content) + + # Generate item ID + item_id = str(uuid.uuid4()) + + # Determine file extension + extension = file.filename.split('.')[-1].lower() if file.filename else 'png' + if extension not in ['png', 'jpg', 'jpeg', 'pdf']: + extension = 'png' + + # Save image + if MINIO_AVAILABLE: + # Upload to MinIO + try: + image_path = upload_ocr_image(session_id, item_id, content, extension) + except Exception as e: + print(f"MinIO upload failed, using local storage: {e}") + image_path = save_image_locally(session_id, item_id, content, extension) + else: + # Save locally + image_path = save_image_locally(session_id, item_id, content, extension) + + # Run OCR if requested + ocr_text = None + ocr_confidence = None + + if run_ocr and extension != 'pdf': # Skip OCR for PDFs for now + ocr_text, ocr_confidence = await run_ocr_on_image( + content, + file.filename or f"{item_id}.{extension}", + model=ocr_model + ) + + # Add to database + success = await add_ocr_labeling_item( + item_id=item_id, + session_id=session_id, + image_path=image_path, + image_hash=image_hash, + ocr_text=ocr_text, + ocr_confidence=ocr_confidence, + ocr_model=ocr_model if ocr_text else None, + metadata=meta_dict, + ) + + if success: + results.append({ + "id": item_id, + "filename": file.filename, + "image_path": image_path, + "image_hash": image_hash, + "ocr_text": ocr_text, + "ocr_confidence": ocr_confidence, + "status": "pending", + }) + + return { + "session_id": session_id, + "uploaded_count": len(results), + "items": results, + } + + +@router.get("/queue", response_model=List[ItemResponse]) +async def get_labeling_queue( + session_id: Optional[str] = Query(None), + status: str = Query("pending"), + limit: int = Query(10, ge=1, le=50), +): + """ + Get items from the labeling queue. + + Args: + session_id: Optional filter by session + status: Filter by status (pending, confirmed, corrected, skipped) + limit: Number of items to return + """ + items = await get_ocr_labeling_queue( + session_id=session_id, + status=status, + limit=limit, + ) + + return [ + ItemResponse( + id=item['id'], + session_id=item['session_id'], + session_name=item.get('session_name', ''), + image_path=item['image_path'], + image_url=get_image_url(item['image_path']), + ocr_text=item.get('ocr_text'), + ocr_confidence=item.get('ocr_confidence'), + ground_truth=item.get('ground_truth'), + status=item.get('status', 'pending'), + metadata=item.get('metadata'), + created_at=item.get('created_at', datetime.utcnow()), + ) + for item in items + ] + + +@router.get("/items/{item_id}", response_model=ItemResponse) +async def get_item(item_id: str): + """Get a specific labeling item.""" + item = await get_ocr_labeling_item(item_id) + + if not item: + raise HTTPException(status_code=404, detail="Item not found") + + return ItemResponse( + id=item['id'], + session_id=item['session_id'], + session_name=item.get('session_name', ''), + image_path=item['image_path'], + image_url=get_image_url(item['image_path']), + ocr_text=item.get('ocr_text'), + ocr_confidence=item.get('ocr_confidence'), + ground_truth=item.get('ground_truth'), + status=item.get('status', 'pending'), + metadata=item.get('metadata'), + created_at=item.get('created_at', datetime.utcnow()), + ) + + +@router.post("/confirm") +async def confirm_item(request: ConfirmRequest): + """ + Confirm that OCR text is correct. + + Sets ground_truth = ocr_text and marks item as confirmed. + """ + success = await confirm_ocr_label( + item_id=request.item_id, + labeled_by="admin", # TODO: Get from auth + label_time_seconds=request.label_time_seconds, + ) + + if not success: + raise HTTPException(status_code=400, detail="Failed to confirm item") + + return {"status": "confirmed", "item_id": request.item_id} + + +@router.post("/correct") +async def correct_item(request: CorrectRequest): + """ + Save corrected ground truth for an item. + + Use this when OCR text is wrong and needs manual correction. + """ + success = await correct_ocr_label( + item_id=request.item_id, + ground_truth=request.ground_truth, + labeled_by="admin", # TODO: Get from auth + label_time_seconds=request.label_time_seconds, + ) + + if not success: + raise HTTPException(status_code=400, detail="Failed to correct item") + + return {"status": "corrected", "item_id": request.item_id} + + +@router.post("/skip") +async def skip_item(request: SkipRequest): + """ + Skip an item (unusable image, etc.). + + Skipped items are not included in training exports. + """ + success = await skip_ocr_item( + item_id=request.item_id, + labeled_by="admin", # TODO: Get from auth + ) + + if not success: + raise HTTPException(status_code=400, detail="Failed to skip item") + + return {"status": "skipped", "item_id": request.item_id} + + +@router.get("/stats") +async def get_stats(session_id: Optional[str] = Query(None)): + """ + Get labeling statistics. + + Args: + session_id: Optional session ID for session-specific stats + """ + stats = await get_ocr_labeling_stats(session_id=session_id) + + if "error" in stats: + raise HTTPException(status_code=500, detail=stats["error"]) + + return stats + + +@router.post("/export") +async def export_data(request: ExportRequest): + """ + Export labeled data for training. + + Formats: + - generic: JSONL with image_path and ground_truth + - trocr: Format for TrOCR/Microsoft Transformer fine-tuning + - llama_vision: Format for llama3.2-vision fine-tuning + + Exports are saved to disk at /app/ocr-exports/{format}/{batch_id}/ + """ + # First, get samples from database + db_samples = await export_training_samples( + export_format=request.export_format, + session_id=request.session_id, + batch_id=request.batch_id, + exported_by="admin", # TODO: Get from auth + ) + + if not db_samples: + return { + "export_format": request.export_format, + "batch_id": request.batch_id, + "exported_count": 0, + "samples": [], + "message": "No labeled samples found to export", + } + + # If training export service is available, also write to disk + export_result = None + if TRAINING_EXPORT_AVAILABLE: + try: + export_service = get_training_export_service() + + # Convert DB samples to TrainingSample objects + training_samples = [] + for s in db_samples: + training_samples.append(TrainingSample( + id=s.get('id', s.get('item_id', '')), + image_path=s.get('image_path', ''), + ground_truth=s.get('ground_truth', ''), + ocr_text=s.get('ocr_text'), + ocr_confidence=s.get('ocr_confidence'), + metadata=s.get('metadata'), + )) + + # Export to files + export_result = export_service.export( + samples=training_samples, + export_format=request.export_format, + batch_id=request.batch_id, + ) + except Exception as e: + print(f"Training export failed: {e}") + # Continue without file export + + response = { + "export_format": request.export_format, + "batch_id": request.batch_id or (export_result.batch_id if export_result else None), + "exported_count": len(db_samples), + "samples": db_samples, + } + + if export_result: + response["export_path"] = export_result.export_path + response["manifest_path"] = export_result.manifest_path + + return response + + +@router.get("/training-samples") +async def list_training_samples( + export_format: Optional[str] = Query(None), + batch_id: Optional[str] = Query(None), + limit: int = Query(100, ge=1, le=1000), +): + """Get exported training samples.""" + samples = await get_training_samples( + export_format=export_format, + batch_id=batch_id, + limit=limit, + ) + + return { + "count": len(samples), + "samples": samples, + } + + +@router.get("/images/{path:path}") +async def get_image(path: str): + """ + Serve an image from local storage. + + This endpoint is used when images are stored locally (not in MinIO). + """ + from fastapi.responses import FileResponse + + filepath = os.path.join(LOCAL_STORAGE_PATH, path) + + if not os.path.exists(filepath): + raise HTTPException(status_code=404, detail="Image not found") + + # Determine content type + extension = filepath.split('.')[-1].lower() + content_type = { + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'pdf': 'application/pdf', + }.get(extension, 'application/octet-stream') + + return FileResponse(filepath, media_type=content_type) + + +@router.post("/run-ocr/{item_id}") +async def run_ocr_for_item(item_id: str): + """ + Run OCR on an existing item. + + Use this to re-run OCR or run it if it was skipped during upload. + """ + item = await get_ocr_labeling_item(item_id) + + if not item: + raise HTTPException(status_code=404, detail="Item not found") + + # Load image + image_path = item['image_path'] + + if image_path.startswith(LOCAL_STORAGE_PATH): + # Load from local storage + if not os.path.exists(image_path): + raise HTTPException(status_code=404, detail="Image file not found") + with open(image_path, 'rb') as f: + image_data = f.read() + elif MINIO_AVAILABLE: + # Load from MinIO + try: + image_data = get_ocr_image(image_path) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to load image: {e}") + else: + raise HTTPException(status_code=500, detail="Cannot load image") + + # Get OCR model from session + session = await get_ocr_labeling_session(item['session_id']) + ocr_model = session.get('ocr_model', 'llama3.2-vision:11b') if session else 'llama3.2-vision:11b' + + # Run OCR + ocr_text, ocr_confidence = await run_ocr_on_image( + image_data, + os.path.basename(image_path), + model=ocr_model + ) + + if ocr_text is None: + raise HTTPException(status_code=500, detail="OCR failed") + + # Update item in database + from metrics_db import get_pool + pool = await get_pool() + if pool: + async with pool.acquire() as conn: + await conn.execute( + """ + UPDATE ocr_labeling_items + SET ocr_text = $2, ocr_confidence = $3, ocr_model = $4 + WHERE id = $1 + """, + item_id, ocr_text, ocr_confidence, ocr_model + ) + + return { + "item_id": item_id, + "ocr_text": ocr_text, + "ocr_confidence": ocr_confidence, + "ocr_model": ocr_model, + } + + +@router.get("/exports") +async def list_exports(export_format: Optional[str] = Query(None)): + """ + List all available training data exports. + + Args: + export_format: Optional filter by format (generic, trocr, llama_vision) + + Returns: + List of export manifests with paths and metadata + """ + if not TRAINING_EXPORT_AVAILABLE: + return { + "exports": [], + "message": "Training export service not available", + } + + try: + export_service = get_training_export_service() + exports = export_service.list_exports(export_format=export_format) + + return { + "count": len(exports), + "exports": exports, + } + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to list exports: {e}") diff --git a/klausur-service/backend/pdf_export.py b/klausur-service/backend/pdf_export.py new file mode 100644 index 0000000..cd9df72 --- /dev/null +++ b/klausur-service/backend/pdf_export.py @@ -0,0 +1,677 @@ +""" +PDF Export Module for Abiturkorrektur System + +Generates: +- Individual Gutachten PDFs for each student +- Klausur overview PDFs with grade distribution +- Niedersachsen-compliant formatting +""" + +import io +from datetime import datetime +from typing import Dict, List, Optional, Any + +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle +from reportlab.lib.units import cm, mm +from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY, TA_RIGHT +from reportlab.platypus import ( + SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, + PageBreak, HRFlowable, Image, KeepTogether +) +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont + + +# ============================================= +# CONSTANTS +# ============================================= + +GRADE_POINTS_TO_NOTE = { + 15: "1+", 14: "1", 13: "1-", + 12: "2+", 11: "2", 10: "2-", + 9: "3+", 8: "3", 7: "3-", + 6: "4+", 5: "4", 4: "4-", + 3: "5+", 2: "5", 1: "5-", + 0: "6" +} + +CRITERIA_DISPLAY_NAMES = { + "rechtschreibung": "Sprachliche Richtigkeit (Rechtschreibung)", + "grammatik": "Sprachliche Richtigkeit (Grammatik)", + "inhalt": "Inhaltliche Leistung", + "struktur": "Aufbau und Struktur", + "stil": "Ausdruck und Stil" +} + +CRITERIA_WEIGHTS = { + "rechtschreibung": 15, + "grammatik": 15, + "inhalt": 40, + "struktur": 15, + "stil": 15 +} + + +# ============================================= +# STYLES +# ============================================= + +def get_custom_styles(): + """Create custom paragraph styles for Gutachten.""" + styles = getSampleStyleSheet() + + # Title style + styles.add(ParagraphStyle( + name='GutachtenTitle', + parent=styles['Heading1'], + fontSize=16, + spaceAfter=12, + alignment=TA_CENTER, + textColor=colors.HexColor('#1e3a5f') + )) + + # Subtitle style + styles.add(ParagraphStyle( + name='GutachtenSubtitle', + parent=styles['Heading2'], + fontSize=12, + spaceAfter=8, + spaceBefore=16, + textColor=colors.HexColor('#2c5282') + )) + + # Section header + styles.add(ParagraphStyle( + name='SectionHeader', + parent=styles['Heading3'], + fontSize=11, + spaceAfter=6, + spaceBefore=12, + textColor=colors.HexColor('#2d3748'), + borderColor=colors.HexColor('#e2e8f0'), + borderWidth=0, + borderPadding=0 + )) + + # Body text + styles.add(ParagraphStyle( + name='GutachtenBody', + parent=styles['Normal'], + fontSize=10, + leading=14, + alignment=TA_JUSTIFY, + spaceAfter=6 + )) + + # Small text for footer/meta + styles.add(ParagraphStyle( + name='MetaText', + parent=styles['Normal'], + fontSize=8, + textColor=colors.grey, + alignment=TA_LEFT + )) + + # List item + styles.add(ParagraphStyle( + name='ListItem', + parent=styles['Normal'], + fontSize=10, + leftIndent=20, + bulletIndent=10, + spaceAfter=4 + )) + + return styles + + +# ============================================= +# PDF GENERATION FUNCTIONS +# ============================================= + +def generate_gutachten_pdf( + student_data: Dict[str, Any], + klausur_data: Dict[str, Any], + annotations: List[Dict[str, Any]] = None, + workflow_data: Dict[str, Any] = None +) -> bytes: + """ + Generate a PDF Gutachten for a single student. + + Args: + student_data: Student work data including criteria_scores, gutachten, grade_points + klausur_data: Klausur metadata (title, subject, year, etc.) + annotations: List of annotations for annotation summary + workflow_data: Examiner workflow data (EK, ZK, DK info) + + Returns: + PDF as bytes + """ + buffer = io.BytesIO() + doc = SimpleDocTemplate( + buffer, + pagesize=A4, + rightMargin=2*cm, + leftMargin=2*cm, + topMargin=2*cm, + bottomMargin=2*cm + ) + + styles = get_custom_styles() + story = [] + + # Header + story.append(Paragraph("Gutachten zur Abiturklausur", styles['GutachtenTitle'])) + story.append(Paragraph(f"{klausur_data.get('subject', 'Deutsch')} - {klausur_data.get('title', '')}", styles['GutachtenSubtitle'])) + story.append(Spacer(1, 0.5*cm)) + + # Meta information table + meta_data = [ + ["Pruefling:", student_data.get('student_name', 'Anonym')], + ["Schuljahr:", f"{klausur_data.get('year', 2025)}"], + ["Kurs:", klausur_data.get('semester', 'Abitur')], + ["Datum:", datetime.now().strftime("%d.%m.%Y")] + ] + + meta_table = Table(meta_data, colWidths=[4*cm, 10*cm]) + meta_table.setStyle(TableStyle([ + ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, -1), 10), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ])) + story.append(meta_table) + story.append(Spacer(1, 0.5*cm)) + story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0'))) + story.append(Spacer(1, 0.5*cm)) + + # Gutachten content + gutachten = student_data.get('gutachten', {}) + + if gutachten: + # Einleitung + if gutachten.get('einleitung'): + story.append(Paragraph("Einleitung", styles['SectionHeader'])) + story.append(Paragraph(gutachten['einleitung'], styles['GutachtenBody'])) + story.append(Spacer(1, 0.3*cm)) + + # Hauptteil + if gutachten.get('hauptteil'): + story.append(Paragraph("Hauptteil", styles['SectionHeader'])) + story.append(Paragraph(gutachten['hauptteil'], styles['GutachtenBody'])) + story.append(Spacer(1, 0.3*cm)) + + # Fazit + if gutachten.get('fazit'): + story.append(Paragraph("Fazit", styles['SectionHeader'])) + story.append(Paragraph(gutachten['fazit'], styles['GutachtenBody'])) + story.append(Spacer(1, 0.3*cm)) + + # Staerken und Schwaechen + if gutachten.get('staerken') or gutachten.get('schwaechen'): + story.append(Spacer(1, 0.3*cm)) + + if gutachten.get('staerken'): + story.append(Paragraph("Staerken:", styles['SectionHeader'])) + for s in gutachten['staerken']: + story.append(Paragraph(f"• {s}", styles['ListItem'])) + + if gutachten.get('schwaechen'): + story.append(Paragraph("Verbesserungspotenzial:", styles['SectionHeader'])) + for s in gutachten['schwaechen']: + story.append(Paragraph(f"• {s}", styles['ListItem'])) + else: + story.append(Paragraph("Kein Gutachten-Text vorhanden.", styles['GutachtenBody'])) + + story.append(Spacer(1, 0.5*cm)) + story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0'))) + story.append(Spacer(1, 0.5*cm)) + + # Bewertungstabelle + story.append(Paragraph("Bewertung nach Kriterien", styles['SectionHeader'])) + story.append(Spacer(1, 0.2*cm)) + + criteria_scores = student_data.get('criteria_scores', {}) + + # Build criteria table data + table_data = [["Kriterium", "Gewichtung", "Erreicht", "Punkte"]] + total_weighted = 0 + total_weight = 0 + + for key, display_name in CRITERIA_DISPLAY_NAMES.items(): + weight = CRITERIA_WEIGHTS.get(key, 0) + score_data = criteria_scores.get(key, {}) + score = score_data.get('score', 0) if isinstance(score_data, dict) else score_data + + # Calculate weighted contribution + weighted_score = (score / 100) * weight if score else 0 + total_weighted += weighted_score + total_weight += weight + + table_data.append([ + display_name, + f"{weight}%", + f"{score}%", + f"{weighted_score:.1f}" + ]) + + # Add total row + table_data.append([ + "Gesamt", + f"{total_weight}%", + "", + f"{total_weighted:.1f}" + ]) + + criteria_table = Table(table_data, colWidths=[8*cm, 2.5*cm, 2.5*cm, 2.5*cm]) + criteria_table.setStyle(TableStyle([ + # Header row + ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#2c5282')), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), + ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, 0), 10), + ('ALIGN', (1, 0), (-1, -1), 'CENTER'), + # Body rows + ('FONTSIZE', (0, 1), (-1, -1), 9), + ('BOTTOMPADDING', (0, 0), (-1, -1), 6), + ('TOPPADDING', (0, 0), (-1, -1), 6), + # Grid + ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')), + # Total row + ('BACKGROUND', (0, -1), (-1, -1), colors.HexColor('#f7fafc')), + ('FONTNAME', (0, -1), (-1, -1), 'Helvetica-Bold'), + # Alternating row colors + ('ROWBACKGROUNDS', (0, 1), (-1, -2), [colors.white, colors.HexColor('#f7fafc')]), + ])) + story.append(criteria_table) + + story.append(Spacer(1, 0.5*cm)) + + # Final grade box + grade_points = student_data.get('grade_points', 0) + grade_note = GRADE_POINTS_TO_NOTE.get(grade_points, "?") + raw_points = student_data.get('raw_points', 0) + + grade_data = [ + ["Rohpunkte:", f"{raw_points} / 100"], + ["Notenpunkte:", f"{grade_points} Punkte"], + ["Note:", grade_note] + ] + + grade_table = Table(grade_data, colWidths=[4*cm, 4*cm]) + grade_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#ebf8ff')), + ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), + ('FONTNAME', (1, -1), (1, -1), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, -1), 11), + ('FONTSIZE', (1, -1), (1, -1), 14), + ('TEXTCOLOR', (1, -1), (1, -1), colors.HexColor('#2c5282')), + ('BOTTOMPADDING', (0, 0), (-1, -1), 8), + ('TOPPADDING', (0, 0), (-1, -1), 8), + ('LEFTPADDING', (0, 0), (-1, -1), 12), + ('BOX', (0, 0), (-1, -1), 1, colors.HexColor('#2c5282')), + ('ALIGN', (1, 0), (1, -1), 'RIGHT'), + ])) + + story.append(KeepTogether([ + Paragraph("Endergebnis", styles['SectionHeader']), + Spacer(1, 0.2*cm), + grade_table + ])) + + # Examiner workflow information + if workflow_data: + story.append(Spacer(1, 0.5*cm)) + story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0'))) + story.append(Spacer(1, 0.3*cm)) + story.append(Paragraph("Korrekturverlauf", styles['SectionHeader'])) + + workflow_rows = [] + + if workflow_data.get('erst_korrektor'): + ek = workflow_data['erst_korrektor'] + workflow_rows.append([ + "Erstkorrektor:", + ek.get('name', 'Unbekannt'), + f"{ek.get('grade_points', '-')} Punkte" + ]) + + if workflow_data.get('zweit_korrektor'): + zk = workflow_data['zweit_korrektor'] + workflow_rows.append([ + "Zweitkorrektor:", + zk.get('name', 'Unbekannt'), + f"{zk.get('grade_points', '-')} Punkte" + ]) + + if workflow_data.get('dritt_korrektor'): + dk = workflow_data['dritt_korrektor'] + workflow_rows.append([ + "Drittkorrektor:", + dk.get('name', 'Unbekannt'), + f"{dk.get('grade_points', '-')} Punkte" + ]) + + if workflow_data.get('final_grade_source'): + workflow_rows.append([ + "Endnote durch:", + workflow_data['final_grade_source'], + "" + ]) + + if workflow_rows: + workflow_table = Table(workflow_rows, colWidths=[4*cm, 6*cm, 4*cm]) + workflow_table.setStyle(TableStyle([ + ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, -1), 9), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ])) + story.append(workflow_table) + + # Annotation summary (if any) + if annotations: + story.append(Spacer(1, 0.5*cm)) + story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0'))) + story.append(Spacer(1, 0.3*cm)) + story.append(Paragraph("Anmerkungen (Zusammenfassung)", styles['SectionHeader'])) + + # Group annotations by type + by_type = {} + for ann in annotations: + ann_type = ann.get('type', 'comment') + if ann_type not in by_type: + by_type[ann_type] = [] + by_type[ann_type].append(ann) + + for ann_type, anns in by_type.items(): + type_name = CRITERIA_DISPLAY_NAMES.get(ann_type, ann_type.replace('_', ' ').title()) + story.append(Paragraph(f"{type_name} ({len(anns)} Anmerkungen)", styles['ListItem'])) + + # Footer with generation info + story.append(Spacer(1, 1*cm)) + story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor('#cbd5e0'))) + story.append(Spacer(1, 0.2*cm)) + story.append(Paragraph( + f"Erstellt am {datetime.now().strftime('%d.%m.%Y um %H:%M Uhr')} | BreakPilot Abiturkorrektur-System", + styles['MetaText'] + )) + + # Build PDF + doc.build(story) + buffer.seek(0) + return buffer.getvalue() + + +def generate_klausur_overview_pdf( + klausur_data: Dict[str, Any], + students: List[Dict[str, Any]], + fairness_data: Optional[Dict[str, Any]] = None +) -> bytes: + """ + Generate an overview PDF for an entire Klausur with all student grades. + + Args: + klausur_data: Klausur metadata + students: List of all student work data + fairness_data: Optional fairness analysis data + + Returns: + PDF as bytes + """ + buffer = io.BytesIO() + doc = SimpleDocTemplate( + buffer, + pagesize=A4, + rightMargin=1.5*cm, + leftMargin=1.5*cm, + topMargin=2*cm, + bottomMargin=2*cm + ) + + styles = get_custom_styles() + story = [] + + # Header + story.append(Paragraph("Notenuebersicht", styles['GutachtenTitle'])) + story.append(Paragraph(f"{klausur_data.get('subject', 'Deutsch')} - {klausur_data.get('title', '')}", styles['GutachtenSubtitle'])) + story.append(Spacer(1, 0.5*cm)) + + # Meta information + meta_data = [ + ["Schuljahr:", f"{klausur_data.get('year', 2025)}"], + ["Kurs:", klausur_data.get('semester', 'Abitur')], + ["Anzahl Arbeiten:", str(len(students))], + ["Stand:", datetime.now().strftime("%d.%m.%Y")] + ] + + meta_table = Table(meta_data, colWidths=[4*cm, 10*cm]) + meta_table.setStyle(TableStyle([ + ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, -1), 10), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ])) + story.append(meta_table) + story.append(Spacer(1, 0.5*cm)) + + # Statistics (if fairness data available) + if fairness_data and fairness_data.get('statistics'): + stats = fairness_data['statistics'] + story.append(Paragraph("Statistik", styles['SectionHeader'])) + + stats_data = [ + ["Durchschnitt:", f"{stats.get('average_grade', 0):.1f} Punkte"], + ["Minimum:", f"{stats.get('min_grade', 0)} Punkte"], + ["Maximum:", f"{stats.get('max_grade', 0)} Punkte"], + ["Standardabweichung:", f"{stats.get('standard_deviation', 0):.2f}"], + ] + + stats_table = Table(stats_data, colWidths=[4*cm, 4*cm]) + stats_table.setStyle(TableStyle([ + ('FONTNAME', (0, 0), (0, -1), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, -1), 9), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ('BACKGROUND', (0, 0), (-1, -1), colors.HexColor('#f7fafc')), + ('BOX', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')), + ])) + story.append(stats_table) + story.append(Spacer(1, 0.5*cm)) + + story.append(HRFlowable(width="100%", thickness=1, color=colors.HexColor('#e2e8f0'))) + story.append(Spacer(1, 0.5*cm)) + + # Student grades table + story.append(Paragraph("Einzelergebnisse", styles['SectionHeader'])) + story.append(Spacer(1, 0.2*cm)) + + # Sort students by grade (descending) + sorted_students = sorted(students, key=lambda s: s.get('grade_points', 0), reverse=True) + + # Build table header + table_data = [["#", "Name", "Rohpunkte", "Notenpunkte", "Note", "Status"]] + + for idx, student in enumerate(sorted_students, 1): + grade_points = student.get('grade_points', 0) + grade_note = GRADE_POINTS_TO_NOTE.get(grade_points, "-") + raw_points = student.get('raw_points', 0) + status = student.get('status', 'unknown') + + # Format status + status_display = { + 'completed': 'Abgeschlossen', + 'first_examiner': 'In Korrektur', + 'second_examiner': 'Zweitkorrektur', + 'uploaded': 'Hochgeladen', + 'ocr_complete': 'OCR fertig', + 'analyzing': 'Wird analysiert' + }.get(status, status) + + table_data.append([ + str(idx), + student.get('student_name', 'Anonym'), + f"{raw_points}/100", + str(grade_points), + grade_note, + status_display + ]) + + # Create table + student_table = Table(table_data, colWidths=[1*cm, 5*cm, 2.5*cm, 3*cm, 2*cm, 3*cm]) + student_table.setStyle(TableStyle([ + # Header + ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#2c5282')), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), + ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, 0), 9), + ('ALIGN', (0, 0), (-1, 0), 'CENTER'), + # Body + ('FONTSIZE', (0, 1), (-1, -1), 9), + ('ALIGN', (0, 1), (0, -1), 'CENTER'), + ('ALIGN', (2, 1), (4, -1), 'CENTER'), + ('BOTTOMPADDING', (0, 0), (-1, -1), 6), + ('TOPPADDING', (0, 0), (-1, -1), 6), + # Grid + ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')), + # Alternating rows + ('ROWBACKGROUNDS', (0, 1), (-1, -1), [colors.white, colors.HexColor('#f7fafc')]), + ])) + story.append(student_table) + + # Grade distribution + story.append(Spacer(1, 0.5*cm)) + story.append(Paragraph("Notenverteilung", styles['SectionHeader'])) + story.append(Spacer(1, 0.2*cm)) + + # Count grades + grade_counts = {} + for student in sorted_students: + gp = student.get('grade_points', 0) + grade_counts[gp] = grade_counts.get(gp, 0) + 1 + + # Build grade distribution table + dist_data = [["Punkte", "Note", "Anzahl"]] + for points in range(15, -1, -1): + if points in grade_counts: + note = GRADE_POINTS_TO_NOTE.get(points, "-") + count = grade_counts[points] + dist_data.append([str(points), note, str(count)]) + + if len(dist_data) > 1: + dist_table = Table(dist_data, colWidths=[2.5*cm, 2.5*cm, 2.5*cm]) + dist_table.setStyle(TableStyle([ + ('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#2c5282')), + ('TEXTCOLOR', (0, 0), (-1, 0), colors.white), + ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), + ('FONTSIZE', (0, 0), (-1, -1), 9), + ('ALIGN', (0, 0), (-1, -1), 'CENTER'), + ('BOTTOMPADDING', (0, 0), (-1, -1), 4), + ('TOPPADDING', (0, 0), (-1, -1), 4), + ('GRID', (0, 0), (-1, -1), 0.5, colors.HexColor('#e2e8f0')), + ])) + story.append(dist_table) + + # Footer + story.append(Spacer(1, 1*cm)) + story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor('#cbd5e0'))) + story.append(Spacer(1, 0.2*cm)) + story.append(Paragraph( + f"Erstellt am {datetime.now().strftime('%d.%m.%Y um %H:%M Uhr')} | BreakPilot Abiturkorrektur-System", + styles['MetaText'] + )) + + # Build PDF + doc.build(story) + buffer.seek(0) + return buffer.getvalue() + + +def generate_annotations_pdf( + student_data: Dict[str, Any], + klausur_data: Dict[str, Any], + annotations: List[Dict[str, Any]] +) -> bytes: + """ + Generate a PDF with all annotations for a student work. + + Args: + student_data: Student work data + klausur_data: Klausur metadata + annotations: List of all annotations + + Returns: + PDF as bytes + """ + buffer = io.BytesIO() + doc = SimpleDocTemplate( + buffer, + pagesize=A4, + rightMargin=2*cm, + leftMargin=2*cm, + topMargin=2*cm, + bottomMargin=2*cm + ) + + styles = get_custom_styles() + story = [] + + # Header + story.append(Paragraph("Anmerkungen zur Klausur", styles['GutachtenTitle'])) + story.append(Paragraph(f"{student_data.get('student_name', 'Anonym')}", styles['GutachtenSubtitle'])) + story.append(Spacer(1, 0.5*cm)) + + if not annotations: + story.append(Paragraph("Keine Anmerkungen vorhanden.", styles['GutachtenBody'])) + else: + # Group by type + by_type = {} + for ann in annotations: + ann_type = ann.get('type', 'comment') + if ann_type not in by_type: + by_type[ann_type] = [] + by_type[ann_type].append(ann) + + for ann_type, anns in by_type.items(): + type_name = CRITERIA_DISPLAY_NAMES.get(ann_type, ann_type.replace('_', ' ').title()) + story.append(Paragraph(f"{type_name} ({len(anns)})", styles['SectionHeader'])) + story.append(Spacer(1, 0.2*cm)) + + # Sort by page then position + sorted_anns = sorted(anns, key=lambda a: (a.get('page', 0), a.get('position', {}).get('y', 0))) + + for idx, ann in enumerate(sorted_anns, 1): + page = ann.get('page', 1) + text = ann.get('text', '') + suggestion = ann.get('suggestion', '') + severity = ann.get('severity', 'minor') + + # Build annotation text + ann_text = f"[S.{page}] {text}" + if suggestion: + ann_text += f" → {suggestion}" + + # Color code by severity + if severity == 'critical': + ann_text = f"{ann_text}" + elif severity == 'major': + ann_text = f"{ann_text}" + + story.append(Paragraph(f"{idx}. {ann_text}", styles['ListItem'])) + + story.append(Spacer(1, 0.3*cm)) + + # Footer + story.append(Spacer(1, 1*cm)) + story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor('#cbd5e0'))) + story.append(Spacer(1, 0.2*cm)) + story.append(Paragraph( + f"Erstellt am {datetime.now().strftime('%d.%m.%Y um %H:%M Uhr')} | BreakPilot Abiturkorrektur-System", + styles['MetaText'] + )) + + # Build PDF + doc.build(story) + buffer.seek(0) + return buffer.getvalue() diff --git a/klausur-service/backend/pdf_extraction.py b/klausur-service/backend/pdf_extraction.py new file mode 100644 index 0000000..3afc7bc --- /dev/null +++ b/klausur-service/backend/pdf_extraction.py @@ -0,0 +1,164 @@ +""" +PDF Extraction Module + +NOTE: This module delegates ML-heavy operations to the embedding-service via HTTP. + +Provides enhanced PDF text extraction using multiple backends (in embedding-service): +1. Unstructured.io - Best for complex layouts, tables, headers (Apache 2.0) +2. pypdf - Modern, BSD-licensed PDF library (recommended default) + +License Compliance: +- Default backends (unstructured, pypdf) are BSD/Apache licensed +""" + +import os +import logging +from typing import Dict, List, Optional + +logger = logging.getLogger(__name__) + +# Configuration (for backward compatibility - actual config in embedding-service) +EMBEDDING_SERVICE_URL = os.getenv("EMBEDDING_SERVICE_URL", "http://embedding-service:8087") +PDF_BACKEND = os.getenv("PDF_EXTRACTION_BACKEND", "auto") + + +class PDFExtractionError(Exception): + """Error during PDF extraction.""" + pass + + +class PDFExtractionResult: + """Result of PDF extraction with metadata.""" + + def __init__( + self, + text: str, + backend_used: str, + pages: int = 0, + elements: Optional[List[Dict]] = None, + tables: Optional[List[Dict]] = None, + metadata: Optional[Dict] = None, + ): + self.text = text + self.backend_used = backend_used + self.pages = pages + self.elements = elements or [] + self.tables = tables or [] + self.metadata = metadata or {} + + def to_dict(self) -> Dict: + return { + "text": self.text, + "backend_used": self.backend_used, + "pages": self.pages, + "element_count": len(self.elements), + "table_count": len(self.tables), + "metadata": self.metadata, + } + + +def _detect_available_backends() -> List[str]: + """Get available backends from embedding-service.""" + import httpx + + try: + with httpx.Client(timeout=5.0) as client: + response = client.get(f"{EMBEDDING_SERVICE_URL}/models") + if response.status_code == 200: + data = response.json() + return data.get("available_pdf_backends", ["pypdf"]) + except Exception as e: + logger.warning(f"Could not reach embedding-service: {e}") + + return [] + + +def extract_text_from_pdf_enhanced( + pdf_content: bytes, + backend: str = PDF_BACKEND, + fallback: bool = True, +) -> PDFExtractionResult: + """ + Extract text from PDF using embedding-service. + + Args: + pdf_content: PDF file content as bytes + backend: Preferred backend (auto, unstructured, pypdf) + fallback: If True, try other backends if preferred fails + + Returns: + PDFExtractionResult with extracted text and metadata + """ + import httpx + + try: + with httpx.Client(timeout=120.0) as client: + response = client.post( + f"{EMBEDDING_SERVICE_URL}/extract-pdf", + content=pdf_content, + headers={"Content-Type": "application/octet-stream"} + ) + response.raise_for_status() + data = response.json() + + return PDFExtractionResult( + text=data.get("text", ""), + backend_used=data.get("backend_used", "unknown"), + pages=data.get("pages", 0), + tables=[{"count": data.get("table_count", 0)}] if data.get("table_count", 0) > 0 else [], + metadata={"embedding_service": True} + ) + except httpx.TimeoutException: + raise PDFExtractionError("PDF extraction timeout") + except httpx.HTTPStatusError as e: + raise PDFExtractionError(f"PDF extraction error: {e.response.status_code}") + except Exception as e: + raise PDFExtractionError(f"Failed to extract PDF: {str(e)}") + + +def extract_text_from_pdf(pdf_content: bytes) -> str: + """ + Extract text from PDF (simple interface). + + This is a drop-in replacement for the original function + that uses the embedding-service internally. + """ + result = extract_text_from_pdf_enhanced(pdf_content) + return result.text + + +def get_pdf_extraction_info() -> dict: + """Get information about PDF extraction configuration.""" + import httpx + + try: + with httpx.Client(timeout=5.0) as client: + response = client.get(f"{EMBEDDING_SERVICE_URL}/models") + if response.status_code == 200: + data = response.json() + available = data.get("available_pdf_backends", []) + return { + "configured_backend": data.get("pdf_backend", PDF_BACKEND), + "available_backends": available, + "recommended": "unstructured" if "unstructured" in available else "pypdf", + "backend_licenses": { + "unstructured": "Apache-2.0", + "pypdf": "BSD-3-Clause", + }, + "commercial_safe_backends": available, + "embedding_service_url": EMBEDDING_SERVICE_URL, + "embedding_service_available": True, + } + except Exception as e: + logger.warning(f"Could not reach embedding-service: {e}") + + # Fallback when embedding-service is not available + return { + "configured_backend": PDF_BACKEND, + "available_backends": [], + "recommended": None, + "backend_licenses": {}, + "commercial_safe_backends": [], + "embedding_service_url": EMBEDDING_SERVICE_URL, + "embedding_service_available": False, + } diff --git a/klausur-service/backend/pipeline_checkpoints.py b/klausur-service/backend/pipeline_checkpoints.py new file mode 100644 index 0000000..b335d7d --- /dev/null +++ b/klausur-service/backend/pipeline_checkpoints.py @@ -0,0 +1,276 @@ +""" +Pipeline Checkpoint System for Compliance Pipeline. + +Provides checkpoint tracking, validation, and persistence for the compliance pipeline. +Checkpoints are saved to /tmp/pipeline_checkpoints.json and can be queried via API. +""" + +import json +import os +from datetime import datetime +from dataclasses import dataclass, asdict, field +from typing import Dict, List, Optional, Any +from enum import Enum +import logging + +logger = logging.getLogger(__name__) + +CHECKPOINT_FILE = "/tmp/pipeline_checkpoints.json" + + +class CheckpointStatus(str, Enum): + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + SKIPPED = "skipped" + + +class ValidationStatus(str, Enum): + PASSED = "passed" + WARNING = "warning" + FAILED = "failed" + NOT_RUN = "not_run" + + +@dataclass +class ValidationResult: + """Result of a validation check.""" + name: str + status: ValidationStatus + expected: Any + actual: Any + message: str + + +@dataclass +class PipelineCheckpoint: + """A checkpoint in the pipeline.""" + phase: str + name: str + status: CheckpointStatus + started_at: Optional[str] = None + completed_at: Optional[str] = None + duration_seconds: Optional[float] = None + metrics: Dict[str, Any] = field(default_factory=dict) + validations: List[Dict] = field(default_factory=list) + error: Optional[str] = None + + +@dataclass +class PipelineState: + """Overall pipeline state.""" + pipeline_id: str + status: str + started_at: str + completed_at: Optional[str] = None + current_phase: Optional[str] = None + checkpoints: List[PipelineCheckpoint] = field(default_factory=list) + summary: Dict[str, Any] = field(default_factory=dict) + + +class CheckpointManager: + """Manages pipeline checkpoints and validations.""" + + def __init__(self, pipeline_id: Optional[str] = None): + self.pipeline_id = pipeline_id or datetime.now().strftime("%Y%m%d_%H%M%S") + self.state = PipelineState( + pipeline_id=self.pipeline_id, + status="initializing", + started_at=datetime.now().isoformat(), + checkpoints=[] + ) + self._current_checkpoint: Optional[PipelineCheckpoint] = None + self._checkpoint_start_time: Optional[float] = None + + def start_checkpoint(self, phase: str, name: str) -> None: + """Start a new checkpoint.""" + import time + self._checkpoint_start_time = time.time() + self._current_checkpoint = PipelineCheckpoint( + phase=phase, + name=name, + status=CheckpointStatus.RUNNING, + started_at=datetime.now().isoformat(), + metrics={}, + validations=[] + ) + self.state.current_phase = phase + self.state.status = "running" + logger.info(f"[CHECKPOINT] Started: {phase} - {name}") + self._save() + + def add_metric(self, name: str, value: Any) -> None: + """Add a metric to the current checkpoint.""" + if self._current_checkpoint: + self._current_checkpoint.metrics[name] = value + self._save() + + def validate(self, name: str, expected: Any, actual: Any, + tolerance: float = 0.0, min_value: Optional[Any] = None) -> ValidationResult: + """ + Validate a metric against expected value. + + Args: + name: Validation name + expected: Expected value (can be exact or minimum) + actual: Actual value + tolerance: Percentage tolerance for numeric values (0.0 = exact match) + min_value: If set, validates that actual >= min_value + """ + status = ValidationStatus.PASSED + message = "OK" + + if min_value is not None: + # Minimum value check + if actual < min_value: + status = ValidationStatus.FAILED + message = f"Below minimum: {actual} < {min_value}" + elif actual < expected: + status = ValidationStatus.WARNING + message = f"Below expected but above minimum: {min_value} <= {actual} < {expected}" + else: + message = f"OK: {actual} >= {expected}" + elif isinstance(expected, (int, float)) and isinstance(actual, (int, float)): + # Numeric comparison with tolerance + if tolerance > 0: + lower = expected * (1 - tolerance) + upper = expected * (1 + tolerance) + if actual < lower: + status = ValidationStatus.FAILED + message = f"Below tolerance: {actual} < {lower:.0f} (expected {expected} +/- {tolerance*100:.0f}%)" + elif actual > upper: + status = ValidationStatus.WARNING + message = f"Above expected: {actual} > {upper:.0f}" + else: + message = f"Within tolerance: {lower:.0f} <= {actual} <= {upper:.0f}" + else: + if actual < expected: + status = ValidationStatus.FAILED + message = f"Below expected: {actual} < {expected}" + elif actual > expected: + status = ValidationStatus.WARNING + message = f"Above expected: {actual} > {expected}" + else: + # Exact match + if actual != expected: + status = ValidationStatus.FAILED + message = f"Mismatch: {actual} != {expected}" + + result = ValidationResult( + name=name, + status=status, + expected=expected, + actual=actual, + message=message + ) + + if self._current_checkpoint: + self._current_checkpoint.validations.append(asdict(result)) + self._save() + + log_level = logging.INFO if status == ValidationStatus.PASSED else ( + logging.WARNING if status == ValidationStatus.WARNING else logging.ERROR + ) + logger.log(log_level, f"[VALIDATION] {name}: {message}") + + return result + + def complete_checkpoint(self, success: bool = True, error: Optional[str] = None) -> None: + """Complete the current checkpoint.""" + import time + if self._current_checkpoint: + self._current_checkpoint.status = CheckpointStatus.COMPLETED if success else CheckpointStatus.FAILED + self._current_checkpoint.completed_at = datetime.now().isoformat() + if self._checkpoint_start_time: + self._current_checkpoint.duration_seconds = time.time() - self._checkpoint_start_time + if error: + self._current_checkpoint.error = error + + self.state.checkpoints.append(self._current_checkpoint) + + status_icon = "✅" if success else "❌" + logger.info(f"[CHECKPOINT] {status_icon} Completed: {self._current_checkpoint.phase} - {self._current_checkpoint.name}") + + self._current_checkpoint = None + self._checkpoint_start_time = None + self._save() + + def fail_checkpoint(self, error: str) -> None: + """Mark current checkpoint as failed.""" + self.complete_checkpoint(success=False, error=error) + self.state.status = "failed" + self._save() + + def complete_pipeline(self, summary: Dict[str, Any]) -> None: + """Mark pipeline as complete.""" + self.state.status = "completed" + self.state.completed_at = datetime.now().isoformat() + self.state.current_phase = None + self.state.summary = summary + self._save() + logger.info("[PIPELINE] Complete!") + + def get_state(self) -> Dict[str, Any]: + """Get current pipeline state as dict.""" + return asdict(self.state) + + def _save(self) -> None: + """Save state to file.""" + try: + with open(CHECKPOINT_FILE, "w") as f: + json.dump(asdict(self.state), f, indent=2, ensure_ascii=False) + except Exception as e: + logger.error(f"Failed to save checkpoint state: {e}") + + @staticmethod + def load_state() -> Optional[Dict[str, Any]]: + """Load state from file.""" + try: + if os.path.exists(CHECKPOINT_FILE): + with open(CHECKPOINT_FILE, "r") as f: + return json.load(f) + except Exception as e: + logger.error(f"Failed to load checkpoint state: {e}") + return None + + +# Expected values for validation (can be adjusted based on experience) +EXPECTED_VALUES = { + "ingestion": { + "total_chunks": 11000, # Expected minimum chunks (24 regulations) + "min_chunks": 10000, # Absolute minimum acceptable + "regulations": { + "GDPR": {"min": 600, "expected": 700}, + "AIACT": {"min": 1000, "expected": 1200}, + "CRA": {"min": 600, "expected": 700}, + "NIS2": {"min": 400, "expected": 530}, + "TDDDG": {"min": 150, "expected": 187}, + "BSI-TR-03161-1": {"min": 180, "expected": 227}, + "BSI-TR-03161-2": {"min": 170, "expected": 214}, + "BSI-TR-03161-3": {"min": 160, "expected": 199}, + "DORA": {"min": 300, "expected": 500}, + "PSD2": {"min": 400, "expected": 600}, + "AMLR": {"min": 350, "expected": 550}, + "EHDS": {"min": 400, "expected": 600}, + "MiCA": {"min": 500, "expected": 800}, + } + }, + "extraction": { + "total_checkpoints": 4500, + "min_checkpoints": 4000, + "checkpoints_per_regulation": { + "GDPR": {"min": 150, "expected": 250}, + "AIACT": {"min": 200, "expected": 350}, + "DORA": {"min": 100, "expected": 180}, + } + }, + "controls": { + "total_controls": 1100, + "min_controls": 1000, + }, + "measures": { + "total_measures": 1100, + "min_measures": 1000, + } +} diff --git a/klausur-service/backend/policies/bundeslaender.json b/klausur-service/backend/policies/bundeslaender.json new file mode 100644 index 0000000..67197fa --- /dev/null +++ b/klausur-service/backend/policies/bundeslaender.json @@ -0,0 +1,753 @@ +{ + "$schema": "https://breakpilot.app/schemas/policy-set-v1.json", + "version": "1.1.0", + "description": "Policy Sets fuer alle deutschen Bundeslaender - Abitur 2025 + Zeugnisse", + "last_updated": "2025-01-09", + + "defaults": { + "zk_visibility_mode": "full", + "eh_visibility_mode": "shared", + "allow_teacher_uploaded_eh": true, + "allow_land_uploaded_eh": true, + "require_rights_confirmation_on_upload": true, + "require_dual_control_for_official_eh_update": false, + "third_correction_threshold": 4, + "final_signoff_role": "fachvorsitz", + "quote_verbatim_allowed": false, + "export_template_id": "default" + }, + + "zeugnis_defaults": { + "require_klassenlehrer_approval": true, + "require_schulleitung_signoff": true, + "allow_sekretariat_edit_after_approval": false, + "konferenz_protokoll_required": true, + "bemerkungen_require_review": true, + "fehlzeiten_auto_import": true, + "kopfnoten_enabled": false, + "versetzung_auto_calculate": true, + "export_template_id": "zeugnis_default" + }, + + "policies": { + "DEFAULT-2025": { + "bundesland": "DEFAULT", + "jahr": 2025, + "fach": null, + "verfahren": "abitur", + "description": "Fallback Policy fuer alle nicht spezifizierten Konfigurationen", + "inherits": null, + "overrides": {} + }, + + "BW-2025-ABITUR": { + "bundesland": "baden-wuerttemberg", + "jahr": 2025, + "fach": null, + "verfahren": "abitur", + "description": "Baden-Wuerttemberg Abitur 2025", + "inherits": "DEFAULT-2025", + "overrides": { + "export_template_id": "baden-wuerttemberg-abitur", + "flags": { + "pruefungsamt_integration": false, + "notes": "Zentrale Pruefungsaufgaben vom Kultusministerium" + } + } + }, + + "BY-2025-ABITUR": { + "bundesland": "bayern", + "jahr": 2025, + "fach": null, + "verfahren": "abitur", + "description": "Bayern Abitur 2025 - Semi-blinde Zweitkorrektur", + "inherits": "DEFAULT-2025", + "overrides": { + "zk_visibility_mode": "semi", + "export_template_id": "bayern-abitur", + "flags": { + "isb_integration": false, + "notes": "ZK sieht Annotationen, aber nicht die vorgeschlagene Note des EK" + } + } + }, + + "BE-2025-ABITUR": { + "bundesland": "berlin", + "jahr": 2025, + "fach": null, + "verfahren": "abitur", + "description": "Berlin Abitur 2025", + "inherits": "DEFAULT-2025", + "overrides": { + "export_template_id": "berlin-abitur", + "flags": { + "senbjf_integration": false + } + } + }, + + "BB-2025-ABITUR": { + "bundesland": "brandenburg", + "jahr": 2025, + "fach": null, + "verfahren": "abitur", + "description": "Brandenburg Abitur 2025", + "inherits": "DEFAULT-2025", + "overrides": { + "export_template_id": "brandenburg-abitur" + } + }, + + "HB-2025-ABITUR": { + "bundesland": "bremen", + "jahr": 2025, + "fach": null, + "verfahren": "abitur", + "description": "Bremen Abitur 2025", + "inherits": "DEFAULT-2025", + "overrides": { + "export_template_id": "bremen-abitur" + } + }, + + "HH-2025-ABITUR": { + "bundesland": "hamburg", + "jahr": 2025, + "fach": null, + "verfahren": "abitur", + "description": "Hamburg Abitur 2025", + "inherits": "DEFAULT-2025", + "overrides": { + "export_template_id": "hamburg-abitur", + "flags": { + "bsb_integration": false + } + } + }, + + "HE-2025-ABITUR": { + "bundesland": "hessen", + "jahr": 2025, + "fach": null, + "verfahren": "abitur", + "description": "Hessen Abitur 2025", + "inherits": "DEFAULT-2025", + "overrides": { + "export_template_id": "hessen-abitur", + "flags": { + "hkm_integration": false + } + } + }, + + "MV-2025-ABITUR": { + "bundesland": "mecklenburg-vorpommern", + "jahr": 2025, + "fach": null, + "verfahren": "abitur", + "description": "Mecklenburg-Vorpommern Abitur 2025", + "inherits": "DEFAULT-2025", + "overrides": { + "export_template_id": "mecklenburg-vorpommern-abitur" + } + }, + + "NI-2025-ABITUR": { + "bundesland": "niedersachsen", + "jahr": 2025, + "fach": null, + "verfahren": "abitur", + "description": "Niedersachsen Abitur 2025 - Hauptpilot", + "inherits": "DEFAULT-2025", + "overrides": { + "export_template_id": "niedersachsen-abitur", + "flags": { + "mk_integration": true, + "is_pilot_land": true, + "notes": "Niedersachsen ist Hauptpilot fuer Breakpilot" + } + } + }, + + "NW-2025-ABITUR": { + "bundesland": "nordrhein-westfalen", + "jahr": 2025, + "fach": null, + "verfahren": "abitur", + "description": "Nordrhein-Westfalen Abitur 2025", + "inherits": "DEFAULT-2025", + "overrides": { + "export_template_id": "nrw-abitur", + "flags": { + "msb_integration": false, + "notes": "Groesstes Bundesland nach Schuelerzahlen" + } + } + }, + + "RP-2025-ABITUR": { + "bundesland": "rheinland-pfalz", + "jahr": 2025, + "fach": null, + "verfahren": "abitur", + "description": "Rheinland-Pfalz Abitur 2025", + "inherits": "DEFAULT-2025", + "overrides": { + "export_template_id": "rheinland-pfalz-abitur" + } + }, + + "SL-2025-ABITUR": { + "bundesland": "saarland", + "jahr": 2025, + "fach": null, + "verfahren": "abitur", + "description": "Saarland Abitur 2025", + "inherits": "DEFAULT-2025", + "overrides": { + "export_template_id": "saarland-abitur" + } + }, + + "SN-2025-ABITUR": { + "bundesland": "sachsen", + "jahr": 2025, + "fach": null, + "verfahren": "abitur", + "description": "Sachsen Abitur 2025", + "inherits": "DEFAULT-2025", + "overrides": { + "export_template_id": "sachsen-abitur", + "flags": { + "smk_integration": false + } + } + }, + + "ST-2025-ABITUR": { + "bundesland": "sachsen-anhalt", + "jahr": 2025, + "fach": null, + "verfahren": "abitur", + "description": "Sachsen-Anhalt Abitur 2025", + "inherits": "DEFAULT-2025", + "overrides": { + "export_template_id": "sachsen-anhalt-abitur" + } + }, + + "SH-2025-ABITUR": { + "bundesland": "schleswig-holstein", + "jahr": 2025, + "fach": null, + "verfahren": "abitur", + "description": "Schleswig-Holstein Abitur 2025", + "inherits": "DEFAULT-2025", + "overrides": { + "export_template_id": "schleswig-holstein-abitur" + } + }, + + "TH-2025-ABITUR": { + "bundesland": "thueringen", + "jahr": 2025, + "fach": null, + "verfahren": "abitur", + "description": "Thueringen Abitur 2025", + "inherits": "DEFAULT-2025", + "overrides": { + "export_template_id": "thueringen-abitur" + } + } + }, + + "role_permission_matrix": { + "description": "Standard-Berechtigungsmatrix fuer alle Rollen. Kann durch Policies ueberschrieben werden.", + + "erstkorrektor": { + "exam_package": ["read", "update", "share_key", "lock"], + "student_work": ["read", "update"], + "eh_document": ["read", "upload", "update"], + "rubric": ["read", "update"], + "annotation": ["create", "read", "update", "delete"], + "evaluation": ["create", "read", "update"], + "report": ["create", "read", "update"], + "grade_decision": ["create", "read", "update"], + "export": ["create", "read", "download"], + "audit_log": ["read"] + }, + + "zweitkorrektor": { + "exam_package": ["read"], + "student_work": ["read", "update"], + "eh_document": ["read"], + "rubric": ["read"], + "annotation": ["create", "read", "update"], + "evaluation": ["create", "read", "update"], + "report": ["create", "read", "update"], + "grade_decision": ["create", "read", "update"], + "export": ["read", "download"], + "audit_log": ["read"] + }, + + "drittkorrektor": { + "exam_package": ["read"], + "student_work": ["read", "update"], + "eh_document": ["read"], + "rubric": ["read"], + "annotation": ["create", "read", "update"], + "evaluation": ["create", "read", "update"], + "report": ["create", "read", "update"], + "grade_decision": ["create", "read", "update"], + "audit_log": ["read"] + }, + + "fachvorsitz": { + "tenant": ["read"], + "namespace": ["read", "update"], + "exam_package": ["read", "update", "lock", "unlock", "sign_off"], + "student_work": ["read", "update"], + "eh_document": ["read", "upload", "update"], + "rubric": ["read", "update"], + "annotation": ["read", "update"], + "evaluation": ["read", "update"], + "report": ["read", "update"], + "grade_decision": ["read", "update", "sign_off"], + "export": ["create", "read", "download"], + "audit_log": ["read"] + }, + + "pruefungsvorsitz": { + "tenant": ["read"], + "namespace": ["read", "create"], + "exam_package": ["read", "sign_off"], + "student_work": ["read"], + "eh_document": ["read"], + "grade_decision": ["read", "sign_off"], + "export": ["create", "read", "download"], + "audit_log": ["read"] + }, + + "schul_admin": { + "tenant": ["read", "update"], + "namespace": ["create", "read", "update", "delete"], + "exam_package": ["create", "read", "delete", "assign_role"], + "eh_document": ["read", "upload", "delete"], + "audit_log": ["read"] + }, + + "land_admin": { + "tenant": ["read"], + "eh_document": ["read", "upload", "update", "delete", "publish_official"], + "audit_log": ["read"] + }, + + "auditor": { + "audit_log": ["read"], + "exam_package": ["read"] + }, + + "operator": { + "tenant": ["read"], + "namespace": ["read"], + "exam_package": ["read"], + "audit_log": ["read"] + }, + + "teacher_assistant": { + "student_work": ["read"], + "annotation": ["create", "read"], + "eh_document": ["read"] + }, + + "exam_author": { + "eh_document": ["create", "read", "update", "delete"], + "rubric": ["create", "read", "update", "delete"] + }, + + "fachlehrer": { + "fachnote": ["create", "read", "update"], + "schueler_daten": ["read"], + "zeugnis_entwurf": ["read"], + "bemerkung": ["create", "read", "update"], + "audit_log": ["read"] + }, + + "klassenlehrer": { + "zeugnis": ["create", "read", "update"], + "zeugnis_entwurf": ["create", "read", "update", "delete"], + "zeugnis_vorlage": ["read"], + "schueler_daten": ["read", "update"], + "fachnote": ["read", "update"], + "kopfnote": ["create", "read", "update"], + "fehlzeiten": ["read", "update"], + "bemerkung": ["create", "read", "update", "delete"], + "versetzung": ["read", "update"], + "audit_log": ["read"] + }, + + "stufenleitung": { + "zeugnis": ["read", "update"], + "zeugnis_entwurf": ["read", "update"], + "schueler_daten": ["read"], + "fachnote": ["read"], + "kopfnote": ["read", "update"], + "fehlzeiten": ["read"], + "bemerkung": ["read", "update"], + "versetzung": ["read", "update", "approve"], + "konferenz_beschluss": ["read", "create"], + "audit_log": ["read"] + }, + + "zeugnisbeauftragter": { + "zeugnis": ["read", "update", "approve"], + "zeugnis_entwurf": ["read", "update"], + "zeugnis_vorlage": ["create", "read", "update", "delete"], + "schueler_daten": ["read"], + "fachnote": ["read"], + "kopfnote": ["read"], + "fehlzeiten": ["read", "update"], + "bemerkung": ["read"], + "versetzung": ["read"], + "konferenz_beschluss": ["read"], + "audit_log": ["read"] + }, + + "schulleitung": { + "zeugnis": ["read", "sign_off"], + "zeugnis_entwurf": ["read"], + "zeugnis_vorlage": ["read", "approve"], + "schueler_daten": ["read"], + "fachnote": ["read"], + "kopfnote": ["read"], + "fehlzeiten": ["read"], + "bemerkung": ["read"], + "versetzung": ["read", "sign_off"], + "konferenz_beschluss": ["read", "sign_off"], + "audit_log": ["read"] + }, + + "sekretariat": { + "zeugnis": ["read", "export", "print"], + "schueler_daten": ["read", "update"], + "fehlzeiten": ["create", "read", "update"], + "audit_log": ["read"] + } + }, + + "visibility_rules": { + "blind": { + "description": "ZK sieht keine EK-Outputs (Note, Gutachten)", + "zk_can_see_ek_annotations": false, + "zk_can_see_ek_evaluation": false, + "zk_can_see_ek_report": false, + "zk_can_see_ek_grade": false + }, + "semi": { + "description": "ZK sieht Annotationen, aber keine Note/Gutachten", + "zk_can_see_ek_annotations": true, + "zk_can_see_ek_evaluation": false, + "zk_can_see_ek_report": false, + "zk_can_see_ek_grade": false + }, + "full": { + "description": "ZK sieht alles vom EK", + "zk_can_see_ek_annotations": true, + "zk_can_see_ek_evaluation": true, + "zk_can_see_ek_report": true, + "zk_can_see_ek_grade": true + } + }, + + "key_share_scopes": { + "full": { + "description": "Voller Zugriff auf alle verschluesselten Inhalte", + "permissions": ["read_original", "read_eh", "read_ek_outputs", "read_zk_outputs", "write_annotations"] + }, + "original_only": { + "description": "Nur Zugriff auf Original (Schuelerarbeit)", + "permissions": ["read_original"] + }, + "eh_only": { + "description": "Nur Zugriff auf Erwartungshorizont", + "permissions": ["read_eh"] + }, + "outputs_only": { + "description": "Nur Zugriff auf Korrekturergebnisse", + "permissions": ["read_ek_outputs", "read_zk_outputs"] + }, + "blind_zk": { + "description": "Zugriff fuer blinde Zweitkorrektur", + "permissions": ["read_original", "read_eh", "write_annotations"] + } + }, + + "workflows": { + "standard_correction": { + "description": "Standard-Korrekturablauf", + "steps": [ + { + "name": "upload", + "actor": "erstkorrektor", + "actions": ["upload_student_work", "upload_eh"] + }, + { + "name": "first_correction", + "actor": "erstkorrektor", + "actions": ["annotate", "evaluate", "create_report", "set_grade"] + }, + { + "name": "share_to_zk", + "actor": "erstkorrektor", + "actions": ["share_key", "assign_role"] + }, + { + "name": "second_correction", + "actor": "zweitkorrektor", + "actions": ["annotate", "evaluate", "create_report", "set_grade"] + }, + { + "name": "grade_agreement", + "actors": ["erstkorrektor", "zweitkorrektor"], + "actions": ["agree_final_grade"] + }, + { + "name": "third_correction", + "condition": "grade_deviation >= third_correction_threshold", + "actor": "drittkorrektor", + "actions": ["annotate", "evaluate", "create_report", "set_final_grade"] + }, + { + "name": "sign_off", + "actor": "fachvorsitz", + "actions": ["review", "sign_off"] + }, + { + "name": "final_approval", + "actor": "pruefungsvorsitz", + "actions": ["final_sign_off", "export"] + } + ] + }, + + "zeugnis_halbjahr": { + "description": "Workflow fuer Halbjahreszeugnis", + "verfahren": "halbjahreszeugnis", + "steps": [ + { + "name": "noten_eingabe", + "actor": "fachlehrer", + "actions": ["create_fachnote", "update_fachnote"], + "deadline_days": -14, + "description": "Fachlehrer tragen Noten ein" + }, + { + "name": "entwurf_erstellen", + "actor": "klassenlehrer", + "actions": ["create_zeugnis_entwurf", "add_bemerkung", "add_kopfnote", "update_fehlzeiten"], + "deadline_days": -7, + "description": "Klassenlehrer erstellt Zeugnisentwurf" + }, + { + "name": "entwurf_pruefen", + "actor": "zeugnisbeauftragter", + "actions": ["review_zeugnis", "update_zeugnis", "approve_zeugnis"], + "deadline_days": -5, + "description": "Zeugnisbeauftragter prueft formale Korrektheit" + }, + { + "name": "schulleitung_signoff", + "actor": "schulleitung", + "actions": ["review_zeugnis", "sign_off_zeugnis"], + "deadline_days": -3, + "description": "Schulleitung unterschreibt" + }, + { + "name": "druck_ausgabe", + "actor": "sekretariat", + "actions": ["print_zeugnis", "export_zeugnis"], + "deadline_days": 0, + "description": "Sekretariat druckt und gibt aus" + } + ] + }, + + "zeugnis_jahresende": { + "description": "Workflow fuer Jahreszeugnis mit Versetzungsentscheidung", + "verfahren": "jahreszeugnis", + "steps": [ + { + "name": "noten_eingabe", + "actor": "fachlehrer", + "actions": ["create_fachnote", "update_fachnote"], + "deadline_days": -21, + "description": "Fachlehrer tragen Jahresnoten ein" + }, + { + "name": "versetzung_vorbereiten", + "actor": "klassenlehrer", + "actions": ["calculate_versetzung", "prepare_konferenz"], + "deadline_days": -14, + "description": "Klassenlehrer bereitet Versetzungsentscheidung vor" + }, + { + "name": "zeugniskonferenz", + "actor": "stufenleitung", + "actions": ["create_konferenz_beschluss", "approve_versetzung"], + "deadline_days": -10, + "description": "Zeugniskonferenz tagt und entscheidet" + }, + { + "name": "entwurf_erstellen", + "actor": "klassenlehrer", + "actions": ["create_zeugnis_entwurf", "add_bemerkung", "add_kopfnote", "update_fehlzeiten", "set_versetzung"], + "deadline_days": -7, + "description": "Klassenlehrer erstellt finalen Zeugnisentwurf" + }, + { + "name": "entwurf_pruefen", + "actor": "zeugnisbeauftragter", + "actions": ["review_zeugnis", "update_zeugnis", "approve_zeugnis"], + "deadline_days": -5, + "description": "Zeugnisbeauftragter prueft formale Korrektheit" + }, + { + "name": "schulleitung_signoff", + "actor": "schulleitung", + "actions": ["review_zeugnis", "sign_off_versetzung", "sign_off_zeugnis"], + "deadline_days": -3, + "description": "Schulleitung unterschreibt Zeugnis und Versetzung" + }, + { + "name": "druck_ausgabe", + "actor": "sekretariat", + "actions": ["print_zeugnis", "export_zeugnis", "archive_zeugnis"], + "deadline_days": 0, + "description": "Sekretariat druckt, archiviert und gibt aus" + } + ] + }, + + "zeugnis_abschluss": { + "description": "Workflow fuer Abschlusszeugnis (Abitur, MSA, etc.)", + "verfahren": "abschlusszeugnis", + "steps": [ + { + "name": "pruefungsnoten_eintragen", + "actor": "fachlehrer", + "actions": ["create_fachnote", "update_fachnote"], + "deadline_days": -14, + "description": "Pruefungsnoten werden eingetragen" + }, + { + "name": "gesamtqualifikation", + "actor": "stufenleitung", + "actions": ["calculate_gesamtnote", "verify_zulassung"], + "deadline_days": -10, + "description": "Berechnung der Gesamtqualifikation" + }, + { + "name": "pruefungsausschuss", + "actor": "pruefungsvorsitz", + "actions": ["create_konferenz_beschluss", "approve_abschluss"], + "deadline_days": -7, + "description": "Pruefungsausschuss bestaetigt Abschluss" + }, + { + "name": "zeugnis_erstellen", + "actor": "zeugnisbeauftragter", + "actions": ["create_zeugnis", "add_abschluss_bemerkung"], + "deadline_days": -5, + "description": "Abschlusszeugnis wird erstellt" + }, + { + "name": "schulleitung_signoff", + "actor": "schulleitung", + "actions": ["review_zeugnis", "sign_off_zeugnis"], + "deadline_days": -3, + "description": "Schulleitung unterschreibt" + }, + { + "name": "aushändigung", + "actor": "sekretariat", + "actions": ["print_zeugnis", "create_beglaubigte_kopie", "archive_zeugnis"], + "deadline_days": 0, + "description": "Feierliche Uebergabe und Archivierung" + } + ] + }, + + "zeugnis_abgang": { + "description": "Workflow fuer Abgangszeugnis bei Schulwechsel", + "verfahren": "abgangszeugnis", + "steps": [ + { + "name": "aktuelle_noten", + "actor": "fachlehrer", + "actions": ["create_fachnote"], + "deadline_days": -3, + "description": "Aktuelle Noten werden ermittelt" + }, + { + "name": "zeugnis_erstellen", + "actor": "klassenlehrer", + "actions": ["create_zeugnis_entwurf", "add_bemerkung"], + "deadline_days": -2, + "description": "Abgangszeugnis wird erstellt" + }, + { + "name": "schnell_pruefung", + "actor": "zeugnisbeauftragter", + "actions": ["review_zeugnis", "approve_zeugnis"], + "deadline_days": -1, + "description": "Verkuerzte Pruefung" + }, + { + "name": "signoff_ausgabe", + "actor": "schulleitung", + "actions": ["sign_off_zeugnis", "export_zeugnis"], + "deadline_days": 0, + "description": "Unterschrift und sofortige Ausgabe" + } + ] + } + }, + + "verfahren_types": { + "description": "Unterstuetzte Verfahrenstypen", + "exam": { + "abitur": { + "label": "Abiturpruefung", + "workflow": "standard_correction" + }, + "vorabitur": { + "label": "Vorabiturklausur", + "workflow": "standard_correction" + }, + "klausur": { + "label": "Regulaere Klausur", + "workflow": "standard_correction" + } + }, + "certificate": { + "halbjahreszeugnis": { + "label": "Halbjahreszeugnis", + "workflow": "zeugnis_halbjahr" + }, + "jahreszeugnis": { + "label": "Jahreszeugnis", + "workflow": "zeugnis_jahresende" + }, + "abschlusszeugnis": { + "label": "Abschlusszeugnis (Abitur, MSA)", + "workflow": "zeugnis_abschluss" + }, + "abgangszeugnis": { + "label": "Abgangszeugnis", + "workflow": "zeugnis_abgang" + } + } + } +} diff --git a/klausur-service/backend/pyproject.toml b/klausur-service/backend/pyproject.toml new file mode 100644 index 0000000..77a7622 --- /dev/null +++ b/klausur-service/backend/pyproject.toml @@ -0,0 +1,25 @@ +[project] +name = "klausur-service" +version = "1.0.0" +description = "BreakPilot Klausur Service - RAG & Document Analysis" +requires-python = ">=3.10" + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +asyncio_mode = "auto" +# Add current directory to PYTHONPATH so local modules like hyde, hybrid_search etc. are found +pythonpath = ["."] + +[tool.coverage.run] +source = ["."] +omit = ["tests/*", "venv/*", "*/__pycache__/*"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if __name__ == .__main__.:", + "raise NotImplementedError", +] diff --git a/klausur-service/backend/qdrant_service.py b/klausur-service/backend/qdrant_service.py new file mode 100644 index 0000000..d086bd5 --- /dev/null +++ b/klausur-service/backend/qdrant_service.py @@ -0,0 +1,638 @@ +""" +Qdrant Vector Database Service for BYOEH +Manages vector storage and semantic search for Erwartungshorizonte. +""" + +import os +from typing import List, Dict, Optional +from qdrant_client import QdrantClient +from qdrant_client.http import models +from qdrant_client.models import VectorParams, Distance, PointStruct, Filter, FieldCondition, MatchValue + +QDRANT_URL = os.getenv("QDRANT_URL", "http://localhost:6333") +COLLECTION_NAME = "bp_eh" +VECTOR_SIZE = 1536 # OpenAI text-embedding-3-small + +_client: Optional[QdrantClient] = None + + +def get_qdrant_client() -> QdrantClient: + """Get or create Qdrant client singleton.""" + global _client + if _client is None: + _client = QdrantClient(url=QDRANT_URL) + return _client + + +async def init_qdrant_collection() -> bool: + """Initialize Qdrant collection for BYOEH if not exists.""" + try: + client = get_qdrant_client() + + # Check if collection exists + collections = client.get_collections().collections + collection_names = [c.name for c in collections] + + if COLLECTION_NAME not in collection_names: + client.create_collection( + collection_name=COLLECTION_NAME, + vectors_config=VectorParams( + size=VECTOR_SIZE, + distance=Distance.COSINE + ) + ) + print(f"Created Qdrant collection: {COLLECTION_NAME}") + else: + print(f"Qdrant collection {COLLECTION_NAME} already exists") + + return True + except Exception as e: + print(f"Failed to initialize Qdrant: {e}") + return False + + +async def index_eh_chunks( + eh_id: str, + tenant_id: str, + subject: str, + chunks: List[Dict] +) -> int: + """ + Index EH chunks in Qdrant. + + Args: + eh_id: Erwartungshorizont ID + tenant_id: Tenant/School ID for isolation + subject: Subject (deutsch, englisch, etc.) + chunks: List of {text, embedding, encrypted_content} + + Returns: + Number of indexed chunks + """ + client = get_qdrant_client() + + points = [] + for i, chunk in enumerate(chunks): + point_id = f"{eh_id}_{i}" + points.append( + PointStruct( + id=point_id, + vector=chunk["embedding"], + payload={ + "tenant_id": tenant_id, + "eh_id": eh_id, + "chunk_index": i, + "subject": subject, + "encrypted_content": chunk.get("encrypted_content", ""), + "training_allowed": False # ALWAYS FALSE - critical for compliance + } + ) + ) + + if points: + client.upsert(collection_name=COLLECTION_NAME, points=points) + + return len(points) + + +async def search_eh( + query_embedding: List[float], + tenant_id: str, + subject: Optional[str] = None, + limit: int = 5 +) -> List[Dict]: + """ + Semantic search in tenant's Erwartungshorizonte. + + Args: + query_embedding: Query vector (1536 dimensions) + tenant_id: Tenant ID for isolation + subject: Optional subject filter + limit: Max results + + Returns: + List of matching chunks with scores + """ + client = get_qdrant_client() + + # Build filter conditions + must_conditions = [ + FieldCondition(key="tenant_id", match=MatchValue(value=tenant_id)) + ] + + if subject: + must_conditions.append( + FieldCondition(key="subject", match=MatchValue(value=subject)) + ) + + query_filter = Filter(must=must_conditions) + + results = client.search( + collection_name=COLLECTION_NAME, + query_vector=query_embedding, + query_filter=query_filter, + limit=limit + ) + + return [ + { + "id": str(r.id), + "score": r.score, + "eh_id": r.payload.get("eh_id"), + "chunk_index": r.payload.get("chunk_index"), + "encrypted_content": r.payload.get("encrypted_content"), + "subject": r.payload.get("subject") + } + for r in results + ] + + +async def delete_eh_vectors(eh_id: str) -> int: + """ + Delete all vectors for a specific Erwartungshorizont. + + Args: + eh_id: Erwartungshorizont ID + + Returns: + Number of deleted points + """ + client = get_qdrant_client() + + # Get all points for this EH first + scroll_result = client.scroll( + collection_name=COLLECTION_NAME, + scroll_filter=Filter( + must=[FieldCondition(key="eh_id", match=MatchValue(value=eh_id))] + ), + limit=1000 + ) + + point_ids = [str(p.id) for p in scroll_result[0]] + + if point_ids: + client.delete( + collection_name=COLLECTION_NAME, + points_selector=models.PointIdsList(points=point_ids) + ) + + return len(point_ids) + + +async def get_collection_info() -> Dict: + """Get collection statistics.""" + try: + client = get_qdrant_client() + info = client.get_collection(COLLECTION_NAME) + return { + "name": COLLECTION_NAME, + "vectors_count": info.vectors_count, + "points_count": info.points_count, + "status": info.status.value + } + except Exception as e: + return {"error": str(e)} + + +# ============================================================================= +# QdrantService Class (for NiBiS Ingestion Pipeline) +# ============================================================================= + +class QdrantService: + """ + Class-based Qdrant service for flexible collection management. + Used by nibis_ingestion.py for bulk indexing. + """ + + def __init__(self, url: str = None): + self.url = url or QDRANT_URL + self._client = None + + @property + def client(self) -> QdrantClient: + if self._client is None: + self._client = QdrantClient(url=self.url) + return self._client + + async def ensure_collection(self, collection_name: str, vector_size: int = VECTOR_SIZE) -> bool: + """ + Ensure collection exists, create if needed. + + Args: + collection_name: Name of the collection + vector_size: Dimension of vectors + + Returns: + True if collection exists/created + """ + try: + collections = self.client.get_collections().collections + collection_names = [c.name for c in collections] + + if collection_name not in collection_names: + self.client.create_collection( + collection_name=collection_name, + vectors_config=VectorParams( + size=vector_size, + distance=Distance.COSINE + ) + ) + print(f"Created collection: {collection_name}") + return True + except Exception as e: + print(f"Error ensuring collection: {e}") + return False + + async def upsert_points(self, collection_name: str, points: List[Dict]) -> int: + """ + Upsert points into collection. + + Args: + collection_name: Target collection + points: List of {id, vector, payload} + + Returns: + Number of upserted points + """ + import uuid + + if not points: + return 0 + + qdrant_points = [] + for p in points: + # Convert string ID to UUID for Qdrant compatibility + point_id = p["id"] + if isinstance(point_id, str): + # Use uuid5 with DNS namespace for deterministic UUID from string + point_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, point_id)) + + qdrant_points.append( + PointStruct( + id=point_id, + vector=p["vector"], + payload={**p.get("payload", {}), "original_id": p["id"]} # Keep original ID in payload + ) + ) + + self.client.upsert(collection_name=collection_name, points=qdrant_points) + return len(qdrant_points) + + async def search( + self, + collection_name: str, + query_vector: List[float], + filter_conditions: Optional[Dict] = None, + limit: int = 10 + ) -> List[Dict]: + """ + Semantic search in collection. + + Args: + collection_name: Collection to search + query_vector: Query embedding + filter_conditions: Optional filters (key: value pairs) + limit: Max results + + Returns: + List of matching points with scores + """ + query_filter = None + if filter_conditions: + must_conditions = [ + FieldCondition(key=k, match=MatchValue(value=v)) + for k, v in filter_conditions.items() + ] + query_filter = Filter(must=must_conditions) + + results = self.client.search( + collection_name=collection_name, + query_vector=query_vector, + query_filter=query_filter, + limit=limit + ) + + return [ + { + "id": str(r.id), + "score": r.score, + "payload": r.payload + } + for r in results + ] + + async def get_stats(self, collection_name: str) -> Dict: + """Get collection statistics.""" + try: + info = self.client.get_collection(collection_name) + return { + "name": collection_name, + "vectors_count": info.vectors_count, + "points_count": info.points_count, + "status": info.status.value + } + except Exception as e: + return {"error": str(e), "name": collection_name} + + +# ============================================================================= +# NiBiS RAG Search (for Klausurkorrektur Module) +# ============================================================================= + +async def search_nibis_eh( + query_embedding: List[float], + year: Optional[int] = None, + subject: Optional[str] = None, + niveau: Optional[str] = None, + limit: int = 5 +) -> List[Dict]: + """ + Search in NiBiS Erwartungshorizonte (public, pre-indexed data). + + Unlike search_eh(), this searches in the public NiBiS collection + and returns plaintext (not encrypted). + + Args: + query_embedding: Query vector + year: Optional year filter (2016, 2017, 2024, 2025) + subject: Optional subject filter + niveau: Optional niveau filter (eA, gA) + limit: Max results + + Returns: + List of matching chunks with metadata + """ + client = get_qdrant_client() + collection = "bp_nibis_eh" + + # Build filter + must_conditions = [] + + if year: + must_conditions.append( + FieldCondition(key="year", match=MatchValue(value=year)) + ) + if subject: + must_conditions.append( + FieldCondition(key="subject", match=MatchValue(value=subject)) + ) + if niveau: + must_conditions.append( + FieldCondition(key="niveau", match=MatchValue(value=niveau)) + ) + + query_filter = Filter(must=must_conditions) if must_conditions else None + + try: + results = client.search( + collection_name=collection, + query_vector=query_embedding, + query_filter=query_filter, + limit=limit + ) + + return [ + { + "id": str(r.id), + "score": r.score, + "text": r.payload.get("text", ""), + "year": r.payload.get("year"), + "subject": r.payload.get("subject"), + "niveau": r.payload.get("niveau"), + "task_number": r.payload.get("task_number"), + "doc_type": r.payload.get("doc_type"), + "variant": r.payload.get("variant"), + } + for r in results + ] + except Exception as e: + print(f"NiBiS search error: {e}") + return [] + + +# ============================================================================= +# Legal Templates RAG Search (for Document Generator) +# ============================================================================= + +LEGAL_TEMPLATES_COLLECTION = "bp_legal_templates" +LEGAL_TEMPLATES_VECTOR_SIZE = 1024 # BGE-M3 + + +async def init_legal_templates_collection() -> bool: + """Initialize Qdrant collection for legal templates if not exists.""" + try: + client = get_qdrant_client() + collections = client.get_collections().collections + collection_names = [c.name for c in collections] + + if LEGAL_TEMPLATES_COLLECTION not in collection_names: + client.create_collection( + collection_name=LEGAL_TEMPLATES_COLLECTION, + vectors_config=VectorParams( + size=LEGAL_TEMPLATES_VECTOR_SIZE, + distance=Distance.COSINE + ) + ) + print(f"Created Qdrant collection: {LEGAL_TEMPLATES_COLLECTION}") + else: + print(f"Qdrant collection {LEGAL_TEMPLATES_COLLECTION} already exists") + + return True + except Exception as e: + print(f"Failed to initialize legal templates collection: {e}") + return False + + +async def search_legal_templates( + query_embedding: List[float], + template_type: Optional[str] = None, + license_types: Optional[List[str]] = None, + language: Optional[str] = None, + jurisdiction: Optional[str] = None, + attribution_required: Optional[bool] = None, + limit: int = 10 +) -> List[Dict]: + """ + Search in legal templates collection for document generation. + + Args: + query_embedding: Query vector (1024 dimensions, BGE-M3) + template_type: Filter by template type (privacy_policy, terms_of_service, etc.) + license_types: Filter by license types (cc0, mit, cc_by_4, etc.) + language: Filter by language (de, en) + jurisdiction: Filter by jurisdiction (DE, EU, US, etc.) + attribution_required: Filter by attribution requirement + limit: Max results + + Returns: + List of matching template chunks with full metadata + """ + client = get_qdrant_client() + + # Build filter conditions + must_conditions = [] + + if template_type: + must_conditions.append( + FieldCondition(key="template_type", match=MatchValue(value=template_type)) + ) + + if language: + must_conditions.append( + FieldCondition(key="language", match=MatchValue(value=language)) + ) + + if jurisdiction: + must_conditions.append( + FieldCondition(key="jurisdiction", match=MatchValue(value=jurisdiction)) + ) + + if attribution_required is not None: + must_conditions.append( + FieldCondition(key="attribution_required", match=MatchValue(value=attribution_required)) + ) + + # License type filter (OR condition) + should_conditions = [] + if license_types: + for license_type in license_types: + should_conditions.append( + FieldCondition(key="license_id", match=MatchValue(value=license_type)) + ) + + # Construct filter + query_filter = None + if must_conditions or should_conditions: + filter_args = {} + if must_conditions: + filter_args["must"] = must_conditions + if should_conditions: + filter_args["should"] = should_conditions + query_filter = Filter(**filter_args) + + try: + results = client.search( + collection_name=LEGAL_TEMPLATES_COLLECTION, + query_vector=query_embedding, + query_filter=query_filter, + limit=limit + ) + + return [ + { + "id": str(r.id), + "score": r.score, + "text": r.payload.get("text", ""), + "document_title": r.payload.get("document_title"), + "template_type": r.payload.get("template_type"), + "clause_category": r.payload.get("clause_category"), + "language": r.payload.get("language"), + "jurisdiction": r.payload.get("jurisdiction"), + "license_id": r.payload.get("license_id"), + "license_name": r.payload.get("license_name"), + "license_url": r.payload.get("license_url"), + "attribution_required": r.payload.get("attribution_required"), + "attribution_text": r.payload.get("attribution_text"), + "source_name": r.payload.get("source_name"), + "source_url": r.payload.get("source_url"), + "source_repo": r.payload.get("source_repo"), + "placeholders": r.payload.get("placeholders", []), + "is_complete_document": r.payload.get("is_complete_document"), + "is_modular": r.payload.get("is_modular"), + "requires_customization": r.payload.get("requires_customization"), + "output_allowed": r.payload.get("output_allowed"), + "modification_allowed": r.payload.get("modification_allowed"), + "distortion_prohibited": r.payload.get("distortion_prohibited"), + } + for r in results + ] + except Exception as e: + print(f"Legal templates search error: {e}") + return [] + + +async def get_legal_templates_stats() -> Dict: + """Get statistics for the legal templates collection.""" + try: + client = get_qdrant_client() + info = client.get_collection(LEGAL_TEMPLATES_COLLECTION) + + # Count by template type + template_types = ["privacy_policy", "terms_of_service", "cookie_banner", + "impressum", "widerruf", "dpa", "sla", "agb"] + type_counts = {} + for ttype in template_types: + result = client.count( + collection_name=LEGAL_TEMPLATES_COLLECTION, + count_filter=Filter( + must=[FieldCondition(key="template_type", match=MatchValue(value=ttype))] + ) + ) + if result.count > 0: + type_counts[ttype] = result.count + + # Count by language + lang_counts = {} + for lang in ["de", "en"]: + result = client.count( + collection_name=LEGAL_TEMPLATES_COLLECTION, + count_filter=Filter( + must=[FieldCondition(key="language", match=MatchValue(value=lang))] + ) + ) + lang_counts[lang] = result.count + + # Count by license + license_counts = {} + for license_id in ["cc0", "mit", "cc_by_4", "public_domain", "unlicense"]: + result = client.count( + collection_name=LEGAL_TEMPLATES_COLLECTION, + count_filter=Filter( + must=[FieldCondition(key="license_id", match=MatchValue(value=license_id))] + ) + ) + if result.count > 0: + license_counts[license_id] = result.count + + return { + "collection": LEGAL_TEMPLATES_COLLECTION, + "vectors_count": info.vectors_count, + "points_count": info.points_count, + "status": info.status.value, + "template_types": type_counts, + "languages": lang_counts, + "licenses": license_counts, + } + except Exception as e: + return {"error": str(e), "collection": LEGAL_TEMPLATES_COLLECTION} + + +async def delete_legal_templates_by_source(source_name: str) -> int: + """ + Delete all legal template chunks from a specific source. + + Args: + source_name: Name of the source to delete + + Returns: + Number of deleted points + """ + client = get_qdrant_client() + + # Count first + count_result = client.count( + collection_name=LEGAL_TEMPLATES_COLLECTION, + count_filter=Filter( + must=[FieldCondition(key="source_name", match=MatchValue(value=source_name))] + ) + ) + + # Delete by filter + client.delete( + collection_name=LEGAL_TEMPLATES_COLLECTION, + points_selector=Filter( + must=[FieldCondition(key="source_name", match=MatchValue(value=source_name))] + ) + ) + + return count_result.count diff --git a/klausur-service/backend/rag_evaluation.py b/klausur-service/backend/rag_evaluation.py new file mode 100644 index 0000000..0152a27 --- /dev/null +++ b/klausur-service/backend/rag_evaluation.py @@ -0,0 +1,393 @@ +""" +RAG Evaluation Module + +Implements key RAG quality metrics inspired by RAGAS framework: +- Context Precision: Is the retrieved context relevant? +- Context Recall: Did we retrieve all necessary information? +- Faithfulness: Are answers grounded in the context? +- Answer Relevancy: Does the answer address the question? + +These metrics help continuously monitor and improve RAG quality. +""" + +import os +from typing import List, Dict, Optional, Tuple +from datetime import datetime +import json +from pathlib import Path +import httpx + +# Configuration +EVALUATION_ENABLED = os.getenv("RAG_EVALUATION_ENABLED", "true").lower() == "true" +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "") +EVAL_MODEL = os.getenv("RAG_EVAL_MODEL", "gpt-4o-mini") + +# Storage for evaluation results +EVAL_RESULTS_FILE = Path(os.getenv("RAG_EVAL_RESULTS_FILE", "/app/docs/rag_evaluation_results.json")) + + +class RAGEvaluationError(Exception): + """Error during RAG evaluation.""" + pass + + +def _load_eval_results() -> List[Dict]: + """Load evaluation results from file.""" + if EVAL_RESULTS_FILE.exists(): + try: + with open(EVAL_RESULTS_FILE, 'r') as f: + return json.load(f) + except Exception: + return [] + return [] + + +def _save_eval_results(results: List[Dict]) -> None: + """Save evaluation results to file.""" + EVAL_RESULTS_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(EVAL_RESULTS_FILE, 'w') as f: + json.dump(results[-1000:], f, indent=2) # Keep last 1000 + + +def calculate_context_precision( + query: str, + retrieved_contexts: List[str], + relevant_contexts: List[str] +) -> float: + """ + Calculate Context Precision: What fraction of retrieved contexts are relevant? + + Precision = |Relevant ∩ Retrieved| / |Retrieved| + + Args: + query: The search query + retrieved_contexts: Contexts returned by RAG + relevant_contexts: Ground truth relevant contexts + + Returns: + Precision score between 0 and 1 + """ + if not retrieved_contexts: + return 0.0 + + # Simple text overlap check + relevant_count = 0 + for ret_ctx in retrieved_contexts: + for rel_ctx in relevant_contexts: + # Check if there's significant overlap + if _text_similarity(ret_ctx, rel_ctx) > 0.5: + relevant_count += 1 + break + + return relevant_count / len(retrieved_contexts) + + +def calculate_context_recall( + query: str, + retrieved_contexts: List[str], + relevant_contexts: List[str] +) -> float: + """ + Calculate Context Recall: What fraction of relevant contexts were retrieved? + + Recall = |Relevant ∩ Retrieved| / |Relevant| + + Args: + query: The search query + retrieved_contexts: Contexts returned by RAG + relevant_contexts: Ground truth relevant contexts + + Returns: + Recall score between 0 and 1 + """ + if not relevant_contexts: + return 1.0 # No relevant contexts to miss + + found_count = 0 + for rel_ctx in relevant_contexts: + for ret_ctx in retrieved_contexts: + if _text_similarity(ret_ctx, rel_ctx) > 0.5: + found_count += 1 + break + + return found_count / len(relevant_contexts) + + +def _text_similarity(text1: str, text2: str) -> float: + """ + Simple text similarity using word overlap (Jaccard similarity). + """ + words1 = set(text1.lower().split()) + words2 = set(text2.lower().split()) + + if not words1 or not words2: + return 0.0 + + intersection = len(words1 & words2) + union = len(words1 | words2) + + return intersection / union if union > 0 else 0.0 + + +async def evaluate_faithfulness( + answer: str, + contexts: List[str], +) -> Tuple[float, str]: + """ + Evaluate Faithfulness: Is the answer grounded in the provided contexts? + + Uses LLM to check if claims in the answer are supported by contexts. + + Args: + answer: The generated answer + contexts: The retrieved contexts used to generate the answer + + Returns: + Tuple of (faithfulness_score, explanation) + """ + if not OPENAI_API_KEY: + return 0.5, "LLM not configured for faithfulness evaluation" + + context_text = "\n---\n".join(contexts[:5]) # Limit context length + + prompt = f"""Bewerte, ob die folgende Antwort vollständig durch die gegebenen Kontexte gestützt wird. + +KONTEXTE: +{context_text} + +ANTWORT: +{answer} + +Analysiere: +1. Sind alle Aussagen in der Antwort durch die Kontexte belegt? +2. Gibt es Behauptungen ohne Grundlage in den Kontexten? + +Antworte im Format: +SCORE: [0.0-1.0] +BEGRÜNDUNG: [Kurze Erklärung]""" + + try: + async with httpx.AsyncClient() as client: + response = await client.post( + "https://api.openai.com/v1/chat/completions", + headers={ + "Authorization": f"Bearer {OPENAI_API_KEY}", + "Content-Type": "application/json" + }, + json={ + "model": EVAL_MODEL, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 200, + "temperature": 0.0, + }, + timeout=30.0 + ) + + if response.status_code != 200: + return 0.5, f"API error: {response.status_code}" + + result = response.json()["choices"][0]["message"]["content"] + + # Parse score from response + import re + score_match = re.search(r'SCORE:\s*([\d.]+)', result) + score = float(score_match.group(1)) if score_match else 0.5 + + reason_match = re.search(r'BEGRÜNDUNG:\s*(.+)', result, re.DOTALL) + reason = reason_match.group(1).strip() if reason_match else result + + return min(max(score, 0.0), 1.0), reason + + except Exception as e: + return 0.5, f"Evaluation error: {str(e)}" + + +async def evaluate_answer_relevancy( + query: str, + answer: str, +) -> Tuple[float, str]: + """ + Evaluate Answer Relevancy: Does the answer address the question? + + Uses LLM to assess if the answer is relevant to the query. + + Args: + query: The original question + answer: The generated answer + + Returns: + Tuple of (relevancy_score, explanation) + """ + if not OPENAI_API_KEY: + return 0.5, "LLM not configured for relevancy evaluation" + + prompt = f"""Bewerte, wie relevant die Antwort für die gestellte Frage ist. + +FRAGE: {query} + +ANTWORT: {answer} + +Analysiere: +1. Beantwortet die Antwort die gestellte Frage direkt? +2. Ist die Antwort vollständig oder fehlen wichtige Aspekte? +3. Enthält die Antwort irrelevante Informationen? + +Antworte im Format: +SCORE: [0.0-1.0] +BEGRÜNDUNG: [Kurze Erklärung]""" + + try: + async with httpx.AsyncClient() as client: + response = await client.post( + "https://api.openai.com/v1/chat/completions", + headers={ + "Authorization": f"Bearer {OPENAI_API_KEY}", + "Content-Type": "application/json" + }, + json={ + "model": EVAL_MODEL, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 200, + "temperature": 0.0, + }, + timeout=30.0 + ) + + if response.status_code != 200: + return 0.5, f"API error: {response.status_code}" + + result = response.json()["choices"][0]["message"]["content"] + + import re + score_match = re.search(r'SCORE:\s*([\d.]+)', result) + score = float(score_match.group(1)) if score_match else 0.5 + + reason_match = re.search(r'BEGRÜNDUNG:\s*(.+)', result, re.DOTALL) + reason = reason_match.group(1).strip() if reason_match else result + + return min(max(score, 0.0), 1.0), reason + + except Exception as e: + return 0.5, f"Evaluation error: {str(e)}" + + +async def evaluate_rag_response( + query: str, + answer: str, + retrieved_contexts: List[str], + ground_truth_contexts: Optional[List[str]] = None, + ground_truth_answer: Optional[str] = None, +) -> Dict: + """ + Comprehensive RAG evaluation combining all metrics. + + Args: + query: The original question + answer: The generated answer + retrieved_contexts: Contexts retrieved by RAG + ground_truth_contexts: Optional ground truth relevant contexts + ground_truth_answer: Optional ground truth answer + + Returns: + Evaluation results with all metrics + """ + results = { + "timestamp": datetime.now().isoformat(), + "query": query, + "answer_length": len(answer), + "contexts_count": len(retrieved_contexts), + "metrics": {}, + } + + # Context metrics (if ground truth available) + if ground_truth_contexts: + results["metrics"]["context_precision"] = calculate_context_precision( + query, retrieved_contexts, ground_truth_contexts + ) + results["metrics"]["context_recall"] = calculate_context_recall( + query, retrieved_contexts, ground_truth_contexts + ) + + # Faithfulness (requires LLM) + if OPENAI_API_KEY and retrieved_contexts: + faith_score, faith_reason = await evaluate_faithfulness(answer, retrieved_contexts) + results["metrics"]["faithfulness"] = faith_score + results["faithfulness_reason"] = faith_reason + + # Answer relevancy (requires LLM) + if OPENAI_API_KEY: + rel_score, rel_reason = await evaluate_answer_relevancy(query, answer) + results["metrics"]["answer_relevancy"] = rel_score + results["answer_relevancy_reason"] = rel_reason + + # Calculate overall score + metric_values = list(results["metrics"].values()) + if metric_values: + results["overall_score"] = sum(metric_values) / len(metric_values) + + # Store results + all_results = _load_eval_results() + all_results.append(results) + _save_eval_results(all_results) + + return results + + +def get_evaluation_summary(last_n: int = 100) -> Dict: + """ + Get summary statistics of recent evaluations. + + Args: + last_n: Number of recent evaluations to include + + Returns: + Summary with average scores and trends + """ + all_results = _load_eval_results() + recent = all_results[-last_n:] if all_results else [] + + if not recent: + return { + "total_evaluations": 0, + "message": "No evaluations yet", + } + + # Calculate averages + metrics_sums = {} + metrics_counts = {} + + for result in recent: + for metric, value in result.get("metrics", {}).items(): + if metric not in metrics_sums: + metrics_sums[metric] = 0 + metrics_counts[metric] = 0 + metrics_sums[metric] += value + metrics_counts[metric] += 1 + + averages = { + metric: metrics_sums[metric] / metrics_counts[metric] + for metric in metrics_sums + } + + return { + "total_evaluations": len(all_results), + "evaluations_in_summary": len(recent), + "average_metrics": averages, + "overall_average": sum(averages.values()) / len(averages) if averages else 0, + } + + +def get_evaluation_info() -> dict: + """Get information about evaluation configuration.""" + return { + "enabled": EVALUATION_ENABLED, + "llm_configured": bool(OPENAI_API_KEY), + "eval_model": EVAL_MODEL, + "results_file": str(EVAL_RESULTS_FILE), + "metrics": [ + "context_precision", + "context_recall", + "faithfulness", + "answer_relevancy", + ], + } diff --git a/klausur-service/backend/rbac.py b/klausur-service/backend/rbac.py new file mode 100644 index 0000000..7165260 --- /dev/null +++ b/klausur-service/backend/rbac.py @@ -0,0 +1,1132 @@ +""" +RBAC/ABAC Policy System for Klausur-Service + +Implements: +- Role-Based Access Control (RBAC) with hierarchical roles +- Attribute-Based Access Control (ABAC) via policy sets +- Bundesland-specific policies +- Key sharing for exam packages +""" + +import json +from enum import Enum +from dataclasses import dataclass, field, asdict +from typing import Optional, List, Dict, Set, Any +from datetime import datetime, timezone +import uuid + + +# ============================================= +# ENUMS: Roles, Actions, Resources +# ============================================= + +class Role(str, Enum): + """Fachliche Rollen in Korrektur- und Zeugniskette.""" + + # === Klausur-Korrekturkette === + ERSTKORREKTOR = "erstkorrektor" # EK + ZWEITKORREKTOR = "zweitkorrektor" # ZK + DRITTKORREKTOR = "drittkorrektor" # DK + + # === Zeugnis-Workflow === + KLASSENLEHRER = "klassenlehrer" # KL - Erstellt Zeugnis, Kopfnoten, Bemerkungen + FACHLEHRER = "fachlehrer" # FL - Traegt Fachnoten ein + ZEUGNISBEAUFTRAGTER = "zeugnisbeauftragter" # ZB - Qualitaetskontrolle + SEKRETARIAT = "sekretariat" # SEK - Druck, Versand, Archivierung + + # === Leitung (Klausur + Zeugnis) === + FACHVORSITZ = "fachvorsitz" # FVL - Fachpruefungsleitung + PRUEFUNGSVORSITZ = "pruefungsvorsitz" # PV - Schulleitung / Pruefungsvorsitz + SCHULLEITUNG = "schulleitung" # SL - Finale Zeugnis-Freigabe + STUFENLEITUNG = "stufenleitung" # STL - Stufenkoordination + + # === Administration === + SCHUL_ADMIN = "schul_admin" # SA + LAND_ADMIN = "land_admin" # LA - Behoerde + + # === Spezial === + AUDITOR = "auditor" # DSB/Auditor + OPERATOR = "operator" # OPS - Support + TEACHER_ASSISTANT = "teacher_assistant" # TA - Referendar + EXAM_AUTHOR = "exam_author" # EA - nur Vorabi + + +class Action(str, Enum): + """Moegliche Operationen auf Ressourcen.""" + CREATE = "create" + READ = "read" + UPDATE = "update" + DELETE = "delete" + + ASSIGN_ROLE = "assign_role" + INVITE_USER = "invite_user" + REMOVE_USER = "remove_user" + + UPLOAD = "upload" + DOWNLOAD = "download" + + LOCK = "lock" # Finalisieren + UNLOCK = "unlock" # Nur mit Sonderrecht + SIGN_OFF = "sign_off" # Freigabe + + SHARE_KEY = "share_key" # Key Share erzeugen + VIEW_PII = "view_pii" # Falls PII vorhanden + BREAK_GLASS = "break_glass" # Notfallzugriff + + PUBLISH_OFFICIAL = "publish_official" # Amtliche EH verteilen + + +class ResourceType(str, Enum): + """Ressourcentypen im System.""" + TENANT = "tenant" + NAMESPACE = "namespace" + + # === Klausur-Korrektur === + EXAM_PACKAGE = "exam_package" + STUDENT_WORK = "student_work" + EH_DOCUMENT = "eh_document" + RUBRIC = "rubric" # Punkteraster + ANNOTATION = "annotation" + EVALUATION = "evaluation" # Kriterien/Punkte + REPORT = "report" # Gutachten + GRADE_DECISION = "grade_decision" + + # === Zeugnisgenerator === + ZEUGNIS = "zeugnis" # Zeugnisdokument + ZEUGNIS_VORLAGE = "zeugnis_vorlage" # Zeugnisvorlage/Template + ZEUGNIS_ENTWURF = "zeugnis_entwurf" # Zeugnisentwurf (vor Freigabe) + SCHUELER_DATEN = "schueler_daten" # Schueler-Stammdaten, Noten + FACHNOTE = "fachnote" # Einzelne Fachnote + KOPFNOTE = "kopfnote" # Arbeits-/Sozialverhalten + FEHLZEITEN = "fehlzeiten" # Fehlzeiten + BEMERKUNG = "bemerkung" # Zeugnisbemerkungen + KONFERENZ_BESCHLUSS = "konferenz_beschluss" # Konferenzergebnis + VERSETZUNG = "versetzung" # Versetzungsentscheidung + + # === Allgemein === + DOCUMENT = "document" # Generischer Dokumenttyp (EH, Vorlagen, etc.) + TEMPLATE = "template" # Generische Vorlagen + EXPORT = "export" + AUDIT_LOG = "audit_log" + KEY_MATERIAL = "key_material" + + +class ZKVisibilityMode(str, Enum): + """Sichtbarkeitsmodus fuer Zweitkorrektoren.""" + BLIND = "blind" # ZK sieht keine EK-Note/Gutachten + SEMI = "semi" # ZK sieht Annotationen, aber keine Note + FULL = "full" # ZK sieht alles + + +class EHVisibilityMode(str, Enum): + """Sichtbarkeitsmodus fuer Erwartungshorizonte.""" + BLIND = "blind" # ZK sieht EH nicht (selten) + SHARED = "shared" # ZK sieht EH (Standard) + + +class VerfahrenType(str, Enum): + """Verfahrenstypen fuer Klausuren und Zeugnisse.""" + + # === Klausur/Pruefungsverfahren === + ABITUR = "abitur" + VORABITUR = "vorabitur" + KLAUSUR = "klausur" + NACHPRUEFUNG = "nachpruefung" + + # === Zeugnisverfahren === + HALBJAHRESZEUGNIS = "halbjahreszeugnis" + JAHRESZEUGNIS = "jahreszeugnis" + ABSCHLUSSZEUGNIS = "abschlusszeugnis" + ABGANGSZEUGNIS = "abgangszeugnis" + + @classmethod + def is_exam_type(cls, verfahren: str) -> bool: + """Pruefe ob Verfahren ein Pruefungstyp ist.""" + exam_types = {cls.ABITUR, cls.VORABITUR, cls.KLAUSUR, cls.NACHPRUEFUNG} + try: + return cls(verfahren) in exam_types + except ValueError: + return False + + @classmethod + def is_certificate_type(cls, verfahren: str) -> bool: + """Pruefe ob Verfahren ein Zeugnistyp ist.""" + cert_types = {cls.HALBJAHRESZEUGNIS, cls.JAHRESZEUGNIS, cls.ABSCHLUSSZEUGNIS, cls.ABGANGSZEUGNIS} + try: + return cls(verfahren) in cert_types + except ValueError: + return False + + +# ============================================= +# DATA STRUCTURES +# ============================================= + +@dataclass +class PolicySet: + """ + Policy-Konfiguration pro Bundesland/Jahr/Fach. + + Ermoeglicht bundesland-spezifische Unterschiede ohne + harte Codierung im Quellcode. + + Unterstuetzte Verfahrenstypen: + - Pruefungen: abitur, vorabitur, klausur, nachpruefung + - Zeugnisse: halbjahreszeugnis, jahreszeugnis, abschlusszeugnis, abgangszeugnis + """ + id: str + bundesland: str + jahr: int + fach: Optional[str] # None = gilt fuer alle Faecher + verfahren: str # See VerfahrenType enum + + # Sichtbarkeitsregeln (Klausur) + zk_visibility_mode: ZKVisibilityMode = ZKVisibilityMode.FULL + eh_visibility_mode: EHVisibilityMode = EHVisibilityMode.SHARED + + # EH-Quellen (Klausur) + allow_teacher_uploaded_eh: bool = True + allow_land_uploaded_eh: bool = True + require_rights_confirmation_on_upload: bool = True + require_dual_control_for_official_eh_update: bool = False + + # Korrekturregeln (Klausur) + third_correction_threshold: int = 4 # Notenpunkte Abweichung + final_signoff_role: str = "fachvorsitz" + + # Zeugnisregeln (Zeugnis) + require_klassenlehrer_approval: bool = True + require_schulleitung_signoff: bool = True + allow_sekretariat_edit_after_approval: bool = False + konferenz_protokoll_required: bool = True + bemerkungen_require_review: bool = True + fehlzeiten_auto_import: bool = True + kopfnoten_enabled: bool = False + versetzung_auto_calculate: bool = True + + # Export & Anzeige + quote_verbatim_allowed: bool = False # Amtliche Texte in UI + export_template_id: str = "default" + + # Zusaetzliche Flags + flags: Dict[str, Any] = field(default_factory=dict) + + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + def is_exam_policy(self) -> bool: + """Pruefe ob diese Policy fuer Pruefungen ist.""" + return VerfahrenType.is_exam_type(self.verfahren) + + def is_certificate_policy(self) -> bool: + """Pruefe ob diese Policy fuer Zeugnisse ist.""" + return VerfahrenType.is_certificate_type(self.verfahren) + + def to_dict(self): + d = asdict(self) + d['zk_visibility_mode'] = self.zk_visibility_mode.value + d['eh_visibility_mode'] = self.eh_visibility_mode.value + d['created_at'] = self.created_at.isoformat() + return d + + +@dataclass +class RoleAssignment: + """ + Zuweisung einer Rolle zu einem User fuer eine spezifische Ressource. + """ + id: str + user_id: str + role: Role + resource_type: ResourceType + resource_id: str + + # Optionale Einschraenkungen + tenant_id: Optional[str] = None + namespace_id: Optional[str] = None + + # Gueltigkeit + valid_from: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + valid_to: Optional[datetime] = None + + # Metadaten + granted_by: str = "" + granted_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + revoked_at: Optional[datetime] = None + + def is_active(self) -> bool: + now = datetime.now(timezone.utc) + if self.revoked_at: + return False + if self.valid_to and now > self.valid_to: + return False + return now >= self.valid_from + + def to_dict(self): + return { + 'id': self.id, + 'user_id': self.user_id, + 'role': self.role.value, + 'resource_type': self.resource_type.value, + 'resource_id': self.resource_id, + 'tenant_id': self.tenant_id, + 'namespace_id': self.namespace_id, + 'valid_from': self.valid_from.isoformat(), + 'valid_to': self.valid_to.isoformat() if self.valid_to else None, + 'granted_by': self.granted_by, + 'granted_at': self.granted_at.isoformat(), + 'revoked_at': self.revoked_at.isoformat() if self.revoked_at else None, + 'is_active': self.is_active() + } + + +@dataclass +class KeyShare: + """ + Berechtigung fuer einen User, auf verschluesselte Inhalte zuzugreifen. + + Ein KeyShare ist KEIN Schluessel im Klartext, sondern eine + Berechtigung in Verbindung mit Role Assignment. + """ + id: str + user_id: str + package_id: str + + # Berechtigungsumfang + permissions: Set[str] = field(default_factory=set) + # z.B. {"read_original", "read_eh", "read_ek_outputs", "write_annotations"} + + # Optionale Einschraenkungen + scope: str = "full" # "full", "original_only", "eh_only", "outputs_only" + + # Kette + granted_by: str = "" + granted_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + # Akzeptanz (fuer Invite-Flow) + invite_token: Optional[str] = None + accepted_at: Optional[datetime] = None + + # Widerruf + revoked_at: Optional[datetime] = None + revoked_by: Optional[str] = None + + def is_active(self) -> bool: + return self.revoked_at is None and ( + self.invite_token is None or self.accepted_at is not None + ) + + def to_dict(self): + return { + 'id': self.id, + 'user_id': self.user_id, + 'package_id': self.package_id, + 'permissions': list(self.permissions), + 'scope': self.scope, + 'granted_by': self.granted_by, + 'granted_at': self.granted_at.isoformat(), + 'invite_token': self.invite_token, + 'accepted_at': self.accepted_at.isoformat() if self.accepted_at else None, + 'revoked_at': self.revoked_at.isoformat() if self.revoked_at else None, + 'is_active': self.is_active() + } + + +@dataclass +class Tenant: + """ + Hoechste Isolationseinheit - typischerweise eine Schule. + """ + id: str + name: str + bundesland: str + tenant_type: str = "school" # "school", "pruefungszentrum", "behoerde" + + # Verschluesselung + encryption_enabled: bool = True + + # Metadaten + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + deleted_at: Optional[datetime] = None + + def to_dict(self): + return { + 'id': self.id, + 'name': self.name, + 'bundesland': self.bundesland, + 'tenant_type': self.tenant_type, + 'encryption_enabled': self.encryption_enabled, + 'created_at': self.created_at.isoformat() + } + + +@dataclass +class Namespace: + """ + Arbeitsraum innerhalb eines Tenants. + z.B. "Abitur 2026 - Deutsch LK - Kurs 12a" + """ + id: str + tenant_id: str + name: str + + # Kontext + jahr: int + fach: str + kurs: Optional[str] = None + pruefungsart: str = "abitur" # "abitur", "vorabitur" + + # Policy + policy_set_id: Optional[str] = None + + # Metadaten + created_by: str = "" + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + deleted_at: Optional[datetime] = None + + def to_dict(self): + return { + 'id': self.id, + 'tenant_id': self.tenant_id, + 'name': self.name, + 'jahr': self.jahr, + 'fach': self.fach, + 'kurs': self.kurs, + 'pruefungsart': self.pruefungsart, + 'policy_set_id': self.policy_set_id, + 'created_by': self.created_by, + 'created_at': self.created_at.isoformat() + } + + +@dataclass +class ExamPackage: + """ + Pruefungspaket - kompletter Satz Arbeiten mit allen Artefakten. + """ + id: str + namespace_id: str + tenant_id: str + + name: str + beschreibung: Optional[str] = None + + # Workflow-Status + status: str = "draft" # "draft", "in_progress", "locked", "signed_off" + + # Beteiligte (Rollen werden separat zugewiesen) + owner_id: str = "" # Typischerweise EK + + # Verschluesselung + encryption_key_id: Optional[str] = None + + # Timestamps + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + locked_at: Optional[datetime] = None + signed_off_at: Optional[datetime] = None + signed_off_by: Optional[str] = None + + def to_dict(self): + return { + 'id': self.id, + 'namespace_id': self.namespace_id, + 'tenant_id': self.tenant_id, + 'name': self.name, + 'beschreibung': self.beschreibung, + 'status': self.status, + 'owner_id': self.owner_id, + 'created_at': self.created_at.isoformat(), + 'locked_at': self.locked_at.isoformat() if self.locked_at else None, + 'signed_off_at': self.signed_off_at.isoformat() if self.signed_off_at else None, + 'signed_off_by': self.signed_off_by + } + + +# ============================================= +# RBAC PERMISSION MATRIX +# ============================================= + +# Standard-Berechtigungsmatrix (kann durch Policies ueberschrieben werden) +DEFAULT_PERMISSIONS: Dict[Role, Dict[ResourceType, Set[Action]]] = { + # Erstkorrektor + Role.ERSTKORREKTOR: { + ResourceType.EXAM_PACKAGE: {Action.READ, Action.UPDATE, Action.SHARE_KEY, Action.LOCK}, + ResourceType.STUDENT_WORK: {Action.READ, Action.UPDATE}, + ResourceType.EH_DOCUMENT: {Action.READ, Action.UPLOAD, Action.UPDATE}, + ResourceType.RUBRIC: {Action.READ, Action.UPDATE}, + ResourceType.ANNOTATION: {Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE}, + ResourceType.EVALUATION: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.REPORT: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.GRADE_DECISION: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.EXPORT: {Action.CREATE, Action.READ, Action.DOWNLOAD}, + ResourceType.AUDIT_LOG: {Action.READ}, + }, + + # Zweitkorrektor (Standard: FULL visibility) + Role.ZWEITKORREKTOR: { + ResourceType.EXAM_PACKAGE: {Action.READ}, + ResourceType.STUDENT_WORK: {Action.READ, Action.UPDATE}, + ResourceType.EH_DOCUMENT: {Action.READ}, + ResourceType.RUBRIC: {Action.READ}, + ResourceType.ANNOTATION: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.EVALUATION: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.REPORT: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.GRADE_DECISION: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.EXPORT: {Action.READ, Action.DOWNLOAD}, + ResourceType.AUDIT_LOG: {Action.READ}, + }, + + # Drittkorrektor + Role.DRITTKORREKTOR: { + ResourceType.EXAM_PACKAGE: {Action.READ}, + ResourceType.STUDENT_WORK: {Action.READ, Action.UPDATE}, + ResourceType.EH_DOCUMENT: {Action.READ}, + ResourceType.RUBRIC: {Action.READ}, + ResourceType.ANNOTATION: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.EVALUATION: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.REPORT: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.GRADE_DECISION: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.AUDIT_LOG: {Action.READ}, + }, + + # Fachvorsitz + Role.FACHVORSITZ: { + ResourceType.TENANT: {Action.READ}, + ResourceType.NAMESPACE: {Action.READ, Action.UPDATE}, + ResourceType.EXAM_PACKAGE: {Action.READ, Action.UPDATE, Action.LOCK, Action.UNLOCK, Action.SIGN_OFF}, + ResourceType.STUDENT_WORK: {Action.READ, Action.UPDATE}, + ResourceType.EH_DOCUMENT: {Action.READ, Action.UPLOAD, Action.UPDATE}, + ResourceType.RUBRIC: {Action.READ, Action.UPDATE}, + ResourceType.ANNOTATION: {Action.READ, Action.UPDATE}, + ResourceType.EVALUATION: {Action.READ, Action.UPDATE}, + ResourceType.REPORT: {Action.READ, Action.UPDATE}, + ResourceType.GRADE_DECISION: {Action.READ, Action.UPDATE, Action.SIGN_OFF}, + ResourceType.EXPORT: {Action.CREATE, Action.READ, Action.DOWNLOAD}, + ResourceType.AUDIT_LOG: {Action.READ}, + }, + + # Pruefungsvorsitz + Role.PRUEFUNGSVORSITZ: { + ResourceType.TENANT: {Action.READ}, + ResourceType.NAMESPACE: {Action.READ, Action.CREATE}, + ResourceType.EXAM_PACKAGE: {Action.READ, Action.SIGN_OFF}, + ResourceType.STUDENT_WORK: {Action.READ}, + ResourceType.EH_DOCUMENT: {Action.READ}, + ResourceType.GRADE_DECISION: {Action.READ, Action.SIGN_OFF}, + ResourceType.EXPORT: {Action.CREATE, Action.READ, Action.DOWNLOAD}, + ResourceType.AUDIT_LOG: {Action.READ}, + }, + + # Schul-Admin + Role.SCHUL_ADMIN: { + ResourceType.TENANT: {Action.READ, Action.UPDATE}, + ResourceType.NAMESPACE: {Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE}, + ResourceType.EXAM_PACKAGE: {Action.CREATE, Action.READ, Action.DELETE, Action.ASSIGN_ROLE}, + ResourceType.EH_DOCUMENT: {Action.READ, Action.UPLOAD, Action.DELETE}, + ResourceType.AUDIT_LOG: {Action.READ}, + }, + + # Land-Admin (Behoerde) + Role.LAND_ADMIN: { + ResourceType.TENANT: {Action.READ}, + ResourceType.EH_DOCUMENT: {Action.READ, Action.UPLOAD, Action.UPDATE, Action.DELETE, Action.PUBLISH_OFFICIAL}, + ResourceType.AUDIT_LOG: {Action.READ}, + }, + + # Auditor + Role.AUDITOR: { + ResourceType.AUDIT_LOG: {Action.READ}, + ResourceType.EXAM_PACKAGE: {Action.READ}, # Nur Metadaten + # Kein Zugriff auf Inhalte! + }, + + # Operator + Role.OPERATOR: { + ResourceType.TENANT: {Action.READ}, + ResourceType.NAMESPACE: {Action.READ}, + ResourceType.EXAM_PACKAGE: {Action.READ}, # Nur Metadaten + ResourceType.AUDIT_LOG: {Action.READ}, + # Break-glass separat gehandhabt + }, + + # Teacher Assistant + Role.TEACHER_ASSISTANT: { + ResourceType.STUDENT_WORK: {Action.READ}, + ResourceType.ANNOTATION: {Action.CREATE, Action.READ}, # Nur bestimmte Typen + ResourceType.EH_DOCUMENT: {Action.READ}, + }, + + # Exam Author (nur Vorabi) + Role.EXAM_AUTHOR: { + ResourceType.EH_DOCUMENT: {Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE}, + ResourceType.RUBRIC: {Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE}, + }, + + # ============================================= + # ZEUGNIS-WORKFLOW ROLLEN + # ============================================= + + # Klassenlehrer - Erstellt Zeugnisse, Kopfnoten, Bemerkungen + Role.KLASSENLEHRER: { + ResourceType.NAMESPACE: {Action.READ}, + ResourceType.ZEUGNIS: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.ZEUGNIS_ENTWURF: {Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE}, + ResourceType.ZEUGNIS_VORLAGE: {Action.READ}, + ResourceType.SCHUELER_DATEN: {Action.READ, Action.UPDATE}, + ResourceType.FACHNOTE: {Action.READ}, # Liest Fachnoten der Fachlehrer + ResourceType.KOPFNOTE: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.FEHLZEITEN: {Action.READ, Action.UPDATE}, + ResourceType.BEMERKUNG: {Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE}, + ResourceType.VERSETZUNG: {Action.READ}, + ResourceType.EXPORT: {Action.CREATE, Action.READ, Action.DOWNLOAD}, + ResourceType.AUDIT_LOG: {Action.READ}, + }, + + # Fachlehrer - Traegt Fachnoten ein + Role.FACHLEHRER: { + ResourceType.NAMESPACE: {Action.READ}, + ResourceType.SCHUELER_DATEN: {Action.READ}, # Nur eigene Schueler + ResourceType.FACHNOTE: {Action.CREATE, Action.READ, Action.UPDATE}, # Nur eigenes Fach + ResourceType.BEMERKUNG: {Action.CREATE, Action.READ}, # Fachbezogene Bemerkungen + ResourceType.AUDIT_LOG: {Action.READ}, + }, + + # Zeugnisbeauftragter - Qualitaetskontrolle + Role.ZEUGNISBEAUFTRAGTER: { + ResourceType.NAMESPACE: {Action.READ, Action.UPDATE}, + ResourceType.ZEUGNIS: {Action.READ, Action.UPDATE}, + ResourceType.ZEUGNIS_ENTWURF: {Action.READ, Action.UPDATE}, + ResourceType.ZEUGNIS_VORLAGE: {Action.READ, Action.UPDATE, Action.UPLOAD}, + ResourceType.SCHUELER_DATEN: {Action.READ}, + ResourceType.FACHNOTE: {Action.READ}, + ResourceType.KOPFNOTE: {Action.READ, Action.UPDATE}, + ResourceType.FEHLZEITEN: {Action.READ}, + ResourceType.BEMERKUNG: {Action.READ, Action.UPDATE}, + ResourceType.VERSETZUNG: {Action.READ}, + ResourceType.EXPORT: {Action.CREATE, Action.READ, Action.DOWNLOAD}, + ResourceType.AUDIT_LOG: {Action.READ}, + }, + + # Sekretariat - Druck, Versand, Archivierung + Role.SEKRETARIAT: { + ResourceType.ZEUGNIS: {Action.READ, Action.DOWNLOAD}, + ResourceType.ZEUGNIS_VORLAGE: {Action.READ}, + ResourceType.SCHUELER_DATEN: {Action.READ}, # Fuer Adressdaten + ResourceType.EXPORT: {Action.CREATE, Action.READ, Action.DOWNLOAD}, + ResourceType.AUDIT_LOG: {Action.READ}, + }, + + # Schulleitung - Finale Zeugnis-Freigabe + Role.SCHULLEITUNG: { + ResourceType.TENANT: {Action.READ}, + ResourceType.NAMESPACE: {Action.READ, Action.CREATE}, + ResourceType.ZEUGNIS: {Action.READ, Action.SIGN_OFF, Action.LOCK}, + ResourceType.ZEUGNIS_ENTWURF: {Action.READ, Action.UPDATE}, + ResourceType.ZEUGNIS_VORLAGE: {Action.READ, Action.UPDATE}, + ResourceType.SCHUELER_DATEN: {Action.READ}, + ResourceType.FACHNOTE: {Action.READ}, + ResourceType.KOPFNOTE: {Action.READ, Action.UPDATE}, + ResourceType.FEHLZEITEN: {Action.READ}, + ResourceType.BEMERKUNG: {Action.READ, Action.UPDATE}, + ResourceType.KONFERENZ_BESCHLUSS: {Action.CREATE, Action.READ, Action.UPDATE, Action.SIGN_OFF}, + ResourceType.VERSETZUNG: {Action.CREATE, Action.READ, Action.UPDATE, Action.SIGN_OFF}, + ResourceType.EXPORT: {Action.CREATE, Action.READ, Action.DOWNLOAD}, + ResourceType.AUDIT_LOG: {Action.READ}, + }, + + # Stufenleitung - Stufenkoordination (z.B. Oberstufe) + Role.STUFENLEITUNG: { + ResourceType.NAMESPACE: {Action.READ, Action.UPDATE}, + ResourceType.ZEUGNIS: {Action.READ, Action.UPDATE}, + ResourceType.ZEUGNIS_ENTWURF: {Action.READ, Action.UPDATE}, + ResourceType.SCHUELER_DATEN: {Action.READ}, + ResourceType.FACHNOTE: {Action.READ}, + ResourceType.KOPFNOTE: {Action.READ}, + ResourceType.FEHLZEITEN: {Action.READ}, + ResourceType.BEMERKUNG: {Action.READ, Action.UPDATE}, + ResourceType.KONFERENZ_BESCHLUSS: {Action.READ}, + ResourceType.VERSETZUNG: {Action.READ, Action.UPDATE}, + ResourceType.EXPORT: {Action.READ, Action.DOWNLOAD}, + ResourceType.AUDIT_LOG: {Action.READ}, + }, +} + + +# ============================================= +# POLICY ENGINE +# ============================================= + +class PolicyEngine: + """ + Engine fuer RBAC/ABAC Entscheidungen. + + Prueft: + 1. Basis-Rollenberechtigung (RBAC) + 2. Policy-Einschraenkungen (ABAC) + 3. Key Share Berechtigungen + """ + + def __init__(self): + self.policy_sets: Dict[str, PolicySet] = {} + self.role_assignments: Dict[str, List[RoleAssignment]] = {} # user_id -> assignments + self.key_shares: Dict[str, List[KeyShare]] = {} # user_id -> shares + + def register_policy_set(self, policy: PolicySet): + """Registriere ein Policy Set.""" + self.policy_sets[policy.id] = policy + + def get_policy_for_context( + self, + bundesland: str, + jahr: int, + fach: Optional[str] = None, + verfahren: str = "abitur" + ) -> Optional[PolicySet]: + """Finde das passende Policy Set fuer einen Kontext.""" + # Exakte Uebereinstimmung + for policy in self.policy_sets.values(): + if (policy.bundesland == bundesland and + policy.jahr == jahr and + policy.verfahren == verfahren): + if policy.fach is None or policy.fach == fach: + return policy + + # Fallback: Default Policy + for policy in self.policy_sets.values(): + if policy.bundesland == "DEFAULT": + return policy + + return None + + def assign_role( + self, + user_id: str, + role: Role, + resource_type: ResourceType, + resource_id: str, + granted_by: str, + tenant_id: Optional[str] = None, + namespace_id: Optional[str] = None, + valid_to: Optional[datetime] = None + ) -> RoleAssignment: + """Weise einem User eine Rolle zu.""" + assignment = RoleAssignment( + id=str(uuid.uuid4()), + user_id=user_id, + role=role, + resource_type=resource_type, + resource_id=resource_id, + tenant_id=tenant_id, + namespace_id=namespace_id, + granted_by=granted_by, + valid_to=valid_to + ) + + if user_id not in self.role_assignments: + self.role_assignments[user_id] = [] + self.role_assignments[user_id].append(assignment) + + return assignment + + def revoke_role(self, assignment_id: str, revoked_by: str) -> bool: + """Widerrufe eine Rollenzuweisung.""" + for user_assignments in self.role_assignments.values(): + for assignment in user_assignments: + if assignment.id == assignment_id: + assignment.revoked_at = datetime.now(timezone.utc) + return True + return False + + def get_user_roles( + self, + user_id: str, + resource_type: Optional[ResourceType] = None, + resource_id: Optional[str] = None + ) -> List[Role]: + """Hole alle aktiven Rollen eines Users.""" + assignments = self.role_assignments.get(user_id, []) + roles = [] + + for assignment in assignments: + if not assignment.is_active(): + continue + if resource_type and assignment.resource_type != resource_type: + continue + if resource_id and assignment.resource_id != resource_id: + continue + roles.append(assignment.role) + + return list(set(roles)) + + def create_key_share( + self, + user_id: str, + package_id: str, + permissions: Set[str], + granted_by: str, + scope: str = "full", + invite_token: Optional[str] = None + ) -> KeyShare: + """Erstelle einen Key Share.""" + share = KeyShare( + id=str(uuid.uuid4()), + user_id=user_id, + package_id=package_id, + permissions=permissions, + scope=scope, + granted_by=granted_by, + invite_token=invite_token + ) + + if user_id not in self.key_shares: + self.key_shares[user_id] = [] + self.key_shares[user_id].append(share) + + return share + + def accept_key_share(self, share_id: str, token: str) -> bool: + """Akzeptiere einen Key Share via Invite Token.""" + for user_shares in self.key_shares.values(): + for share in user_shares: + if share.id == share_id and share.invite_token == token: + share.accepted_at = datetime.now(timezone.utc) + return True + return False + + def revoke_key_share(self, share_id: str, revoked_by: str) -> bool: + """Widerrufe einen Key Share.""" + for user_shares in self.key_shares.values(): + for share in user_shares: + if share.id == share_id: + share.revoked_at = datetime.now(timezone.utc) + share.revoked_by = revoked_by + return True + return False + + def check_permission( + self, + user_id: str, + action: Action, + resource_type: ResourceType, + resource_id: str, + policy: Optional[PolicySet] = None, + package_id: Optional[str] = None + ) -> bool: + """ + Pruefe ob ein User eine Aktion ausfuehren darf. + + Prueft: + 1. Basis-RBAC + 2. Policy-Einschraenkungen + 3. Key Share (falls package_id angegeben) + """ + # 1. Hole aktive Rollen + roles = self.get_user_roles(user_id, resource_type, resource_id) + + if not roles: + return False + + # 2. Pruefe Basis-RBAC + has_permission = False + for role in roles: + role_permissions = DEFAULT_PERMISSIONS.get(role, {}) + resource_permissions = role_permissions.get(resource_type, set()) + if action in resource_permissions: + has_permission = True + break + + if not has_permission: + return False + + # 3. Pruefe Policy-Einschraenkungen + if policy: + # ZK Visibility Mode + if Role.ZWEITKORREKTOR in roles: + if policy.zk_visibility_mode == ZKVisibilityMode.BLIND: + # Blind: ZK darf EK-Outputs nicht sehen + if resource_type in [ResourceType.EVALUATION, ResourceType.REPORT, ResourceType.GRADE_DECISION]: + if action == Action.READ: + # Pruefe ob es EK-Outputs sind (muesste ueber Metadaten geprueft werden) + pass # Implementierung abhaengig von Datenmodell + + elif policy.zk_visibility_mode == ZKVisibilityMode.SEMI: + # Semi: ZK sieht Annotationen, aber keine Note + if resource_type == ResourceType.GRADE_DECISION and action == Action.READ: + return False + + # 4. Pruefe Key Share (falls Package-basiert) + if package_id: + user_shares = self.key_shares.get(user_id, []) + has_key_share = any( + share.package_id == package_id and share.is_active() + for share in user_shares + ) + if not has_key_share: + return False + + return True + + def get_allowed_actions( + self, + user_id: str, + resource_type: ResourceType, + resource_id: str, + policy: Optional[PolicySet] = None + ) -> Set[Action]: + """Hole alle erlaubten Aktionen fuer einen User auf einer Ressource.""" + roles = self.get_user_roles(user_id, resource_type, resource_id) + allowed = set() + + for role in roles: + role_permissions = DEFAULT_PERMISSIONS.get(role, {}) + resource_permissions = role_permissions.get(resource_type, set()) + allowed.update(resource_permissions) + + # Policy-Einschraenkungen anwenden + if policy and Role.ZWEITKORREKTOR in roles: + if policy.zk_visibility_mode == ZKVisibilityMode.BLIND: + # Entferne READ fuer bestimmte Ressourcen + pass # Detailimplementierung + + return allowed + + +# ============================================= +# DEFAULT POLICY SETS (alle Bundeslaender) +# ============================================= + +def create_default_policy_sets() -> List[PolicySet]: + """ + Erstelle Default Policy Sets fuer alle Bundeslaender. + + Diese koennen spaeter pro Land verfeinert werden. + """ + bundeslaender = [ + "baden-wuerttemberg", "bayern", "berlin", "brandenburg", + "bremen", "hamburg", "hessen", "mecklenburg-vorpommern", + "niedersachsen", "nordrhein-westfalen", "rheinland-pfalz", + "saarland", "sachsen", "sachsen-anhalt", "schleswig-holstein", + "thueringen" + ] + + policies = [] + + # Default Policy (Fallback) + policies.append(PolicySet( + id="DEFAULT-2025", + bundesland="DEFAULT", + jahr=2025, + fach=None, + verfahren="abitur", + zk_visibility_mode=ZKVisibilityMode.FULL, + eh_visibility_mode=EHVisibilityMode.SHARED, + allow_teacher_uploaded_eh=True, + allow_land_uploaded_eh=True, + require_rights_confirmation_on_upload=True, + third_correction_threshold=4, + final_signoff_role="fachvorsitz" + )) + + # Niedersachsen (Beispiel mit spezifischen Anpassungen) + policies.append(PolicySet( + id="NI-2025-ABITUR", + bundesland="niedersachsen", + jahr=2025, + fach=None, + verfahren="abitur", + zk_visibility_mode=ZKVisibilityMode.FULL, # In NI sieht ZK alles + eh_visibility_mode=EHVisibilityMode.SHARED, + allow_teacher_uploaded_eh=True, + allow_land_uploaded_eh=True, + require_rights_confirmation_on_upload=True, + third_correction_threshold=4, + final_signoff_role="fachvorsitz", + export_template_id="niedersachsen-abitur" + )) + + # Bayern (Beispiel mit SEMI visibility) + policies.append(PolicySet( + id="BY-2025-ABITUR", + bundesland="bayern", + jahr=2025, + fach=None, + verfahren="abitur", + zk_visibility_mode=ZKVisibilityMode.SEMI, # ZK sieht Annotationen, nicht Note + eh_visibility_mode=EHVisibilityMode.SHARED, + allow_teacher_uploaded_eh=True, + allow_land_uploaded_eh=True, + require_rights_confirmation_on_upload=True, + third_correction_threshold=4, + final_signoff_role="fachvorsitz", + export_template_id="bayern-abitur" + )) + + # NRW (Beispiel) + policies.append(PolicySet( + id="NW-2025-ABITUR", + bundesland="nordrhein-westfalen", + jahr=2025, + fach=None, + verfahren="abitur", + zk_visibility_mode=ZKVisibilityMode.FULL, + eh_visibility_mode=EHVisibilityMode.SHARED, + allow_teacher_uploaded_eh=True, + allow_land_uploaded_eh=True, + require_rights_confirmation_on_upload=True, + third_correction_threshold=4, + final_signoff_role="fachvorsitz", + export_template_id="nrw-abitur" + )) + + # Generiere Basis-Policies fuer alle anderen Bundeslaender + for bl in bundeslaender: + if bl not in ["niedersachsen", "bayern", "nordrhein-westfalen"]: + policies.append(PolicySet( + id=f"{bl[:2].upper()}-2025-ABITUR", + bundesland=bl, + jahr=2025, + fach=None, + verfahren="abitur", + zk_visibility_mode=ZKVisibilityMode.FULL, + eh_visibility_mode=EHVisibilityMode.SHARED, + allow_teacher_uploaded_eh=True, + allow_land_uploaded_eh=True, + require_rights_confirmation_on_upload=True, + third_correction_threshold=4, + final_signoff_role="fachvorsitz" + )) + + return policies + + +# ============================================= +# GLOBAL POLICY ENGINE INSTANCE +# ============================================= + +# Singleton Policy Engine +_policy_engine: Optional[PolicyEngine] = None + + +def get_policy_engine() -> PolicyEngine: + """Hole die globale Policy Engine Instanz.""" + global _policy_engine + if _policy_engine is None: + _policy_engine = PolicyEngine() + # Registriere Default Policies + for policy in create_default_policy_sets(): + _policy_engine.register_policy_set(policy) + return _policy_engine + + +# ============================================= +# API GUARDS (Decorators fuer FastAPI) +# ============================================= + +from functools import wraps +from fastapi import HTTPException, Request + + +def require_permission( + action: Action, + resource_type: ResourceType, + resource_id_param: str = "resource_id" +): + """ + Decorator fuer FastAPI Endpoints. + + Prueft ob der aktuelle User die angegebene Berechtigung hat. + + Usage: + @app.get("/api/v1/packages/{package_id}") + @require_permission(Action.READ, ResourceType.EXAM_PACKAGE, "package_id") + async def get_package(package_id: str, request: Request): + ... + """ + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + request = kwargs.get('request') + if not request: + for arg in args: + if isinstance(arg, Request): + request = arg + break + + if not request: + raise HTTPException(status_code=500, detail="Request not found") + + # User aus Token holen + user = getattr(request.state, 'user', None) + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + + user_id = user.get('user_id') + resource_id = kwargs.get(resource_id_param) + + # Policy Engine pruefen + engine = get_policy_engine() + + # Optional: Policy aus Kontext laden + policy = None + bundesland = user.get('bundesland') + if bundesland: + policy = engine.get_policy_for_context(bundesland, 2025) + + if not engine.check_permission( + user_id=user_id, + action=action, + resource_type=resource_type, + resource_id=resource_id, + policy=policy + ): + raise HTTPException( + status_code=403, + detail=f"Permission denied: {action.value} on {resource_type.value}" + ) + + return await func(*args, **kwargs) + + return wrapper + return decorator + + +def require_role(role: Role): + """ + Decorator der prueft ob User eine bestimmte Rolle hat. + + Usage: + @app.post("/api/v1/eh/publish") + @require_role(Role.LAND_ADMIN) + async def publish_eh(request: Request): + ... + """ + def decorator(func): + @wraps(func) + async def wrapper(*args, **kwargs): + request = kwargs.get('request') + if not request: + for arg in args: + if isinstance(arg, Request): + request = arg + break + + if not request: + raise HTTPException(status_code=500, detail="Request not found") + + user = getattr(request.state, 'user', None) + if not user: + raise HTTPException(status_code=401, detail="Not authenticated") + + user_id = user.get('user_id') + engine = get_policy_engine() + + user_roles = engine.get_user_roles(user_id) + if role not in user_roles: + raise HTTPException( + status_code=403, + detail=f"Role required: {role.value}" + ) + + return await func(*args, **kwargs) + + return wrapper + return decorator diff --git a/klausur-service/backend/requirements.txt b/klausur-service/backend/requirements.txt new file mode 100644 index 0000000..98c2b0a --- /dev/null +++ b/klausur-service/backend/requirements.txt @@ -0,0 +1,35 @@ +fastapi>=0.109.0 +uvicorn[standard]>=0.27.0 +python-multipart>=0.0.6 +pyjwt>=2.8.0 +httpx>=0.26.0 +python-dotenv>=1.0.0 + +# BYOEH Dependencies +qdrant-client>=1.7.0 +cryptography>=41.0.0 +PyPDF2>=3.0.0 + +# PyTorch CPU-only (smaller, no CUDA needed for Docker on Mac) +--extra-index-url https://download.pytorch.org/whl/cpu +torch>=2.0.0 + +# Local Embeddings (no API key needed) +sentence-transformers>=2.2.0 + +# MinIO Object Storage +minio>=7.2.0 + +# OpenCV for handwriting detection (headless = no GUI, smaller for CI) +opencv-python-headless>=4.8.0 + +# PostgreSQL (for metrics storage) +psycopg2-binary>=2.9.0 +asyncpg>=0.29.0 + +# Email validation for Pydantic +email-validator>=2.0.0 + +# Testing +pytest>=8.0.0 +pytest-asyncio>=0.23.0 diff --git a/klausur-service/backend/reranker.py b/klausur-service/backend/reranker.py new file mode 100644 index 0000000..a15aaee --- /dev/null +++ b/klausur-service/backend/reranker.py @@ -0,0 +1,148 @@ +""" +Re-Ranking Module for RAG Quality Improvement + +NOTE: This module delegates ML-heavy operations to the embedding-service via HTTP. + +Implements two-stage retrieval: +1. Initial retrieval with bi-encoder (fast, many results) +2. Re-ranking with cross-encoder (slower, but much more accurate) + +This consistently improves RAG accuracy by 20-35% and reduces hallucinations. + +Supported re-rankers (configured in embedding-service): +- local: sentence-transformers cross-encoder (default, no API key needed) +- cohere: Cohere Rerank API (requires COHERE_API_KEY) +""" + +import os +import logging +from typing import List, Tuple + +logger = logging.getLogger(__name__) + +# Configuration (for backward compatibility - actual config in embedding-service) +EMBEDDING_SERVICE_URL = os.getenv("EMBEDDING_SERVICE_URL", "http://embedding-service:8087") +EMBEDDING_SERVICE_TIMEOUT = float(os.getenv("EMBEDDING_SERVICE_TIMEOUT", "60.0")) +RERANKER_BACKEND = os.getenv("RERANKER_BACKEND", "local") +COHERE_API_KEY = os.getenv("COHERE_API_KEY", "") +LOCAL_RERANKER_MODEL = os.getenv("LOCAL_RERANKER_MODEL", "BAAI/bge-reranker-v2-m3") + + +class RerankerError(Exception): + """Error during re-ranking.""" + pass + + +async def rerank_documents( + query: str, + documents: List[str], + top_k: int = 5 +) -> List[Tuple[int, float, str]]: + """ + Re-rank documents using embedding-service. + + Args: + query: The search query + documents: List of document texts to re-rank + top_k: Number of top results to return + + Returns: + List of (original_index, score, text) tuples, sorted by score descending + """ + if not documents: + return [] + + import httpx + + try: + async with httpx.AsyncClient(timeout=EMBEDDING_SERVICE_TIMEOUT) as client: + response = await client.post( + f"{EMBEDDING_SERVICE_URL}/rerank", + json={ + "query": query, + "documents": documents, + "top_k": top_k + } + ) + response.raise_for_status() + data = response.json() + + return [ + (r["index"], r["score"], r["text"]) + for r in data["results"] + ] + except httpx.TimeoutException: + raise RerankerError("Embedding service timeout during re-ranking") + except httpx.HTTPStatusError as e: + raise RerankerError(f"Re-ranking error: {e.response.status_code} - {e.response.text}") + except Exception as e: + raise RerankerError(f"Failed to re-rank documents: {e}") + + +async def rerank_search_results( + query: str, + results: List[dict], + text_field: str = "text", + top_k: int = 5 +) -> List[dict]: + """ + Re-rank search results (dictionaries with text field). + + Convenience function for re-ranking Qdrant search results. + + Args: + query: The search query + results: List of search result dicts + text_field: Key in dict containing the text to rank on + top_k: Number of top results to return + + Returns: + Re-ranked list of search result dicts with added 'rerank_score' field + """ + if not results: + return [] + + texts = [r.get(text_field, "") for r in results] + reranked = await rerank_documents(query, texts, top_k) + + reranked_results = [] + for orig_idx, score, _ in reranked: + result = results[orig_idx].copy() + result["rerank_score"] = score + result["original_rank"] = orig_idx + reranked_results.append(result) + + return reranked_results + + +def get_reranker_info() -> dict: + """Get information about the current reranker configuration.""" + import httpx + + try: + with httpx.Client(timeout=5.0) as client: + response = client.get(f"{EMBEDDING_SERVICE_URL}/models") + if response.status_code == 200: + data = response.json() + return { + "backend": data.get("reranker_backend", RERANKER_BACKEND), + "model": data.get("reranker_model", LOCAL_RERANKER_MODEL), + "model_license": "See embedding-service", + "commercial_safe": True, + "cohere_configured": bool(COHERE_API_KEY), + "embedding_service_url": EMBEDDING_SERVICE_URL, + "embedding_service_available": True, + } + except Exception as e: + logger.warning(f"Could not reach embedding-service: {e}") + + # Fallback when embedding-service is not available + return { + "backend": RERANKER_BACKEND, + "model": LOCAL_RERANKER_MODEL, + "model_license": "Unknown (embedding-service unavailable)", + "commercial_safe": True, + "cohere_configured": bool(COHERE_API_KEY), + "embedding_service_url": EMBEDDING_SERVICE_URL, + "embedding_service_available": False, + } diff --git a/klausur-service/backend/routes/__init__.py b/klausur-service/backend/routes/__init__.py new file mode 100644 index 0000000..59b6455 --- /dev/null +++ b/klausur-service/backend/routes/__init__.py @@ -0,0 +1,35 @@ +""" +Klausur-Service Routes + +API route modules for the Klausur service. +""" + +from fastapi import APIRouter + +from .exams import router as exams_router +from .students import router as students_router +from .grading import router as grading_router +from .fairness import router as fairness_router +from .eh import router as eh_router +from .archiv import router as archiv_router + +# Create main API router that includes all sub-routers +api_router = APIRouter() + +# Include all route modules +api_router.include_router(exams_router, tags=["Klausuren"]) +api_router.include_router(students_router, tags=["Students"]) +api_router.include_router(grading_router, tags=["Grading"]) +api_router.include_router(fairness_router, tags=["Fairness"]) +api_router.include_router(eh_router, tags=["BYOEH"]) +api_router.include_router(archiv_router, tags=["Abitur-Archiv"]) + +__all__ = [ + "api_router", + "exams_router", + "students_router", + "grading_router", + "fairness_router", + "eh_router", + "archiv_router", +] diff --git a/klausur-service/backend/routes/archiv.py b/klausur-service/backend/routes/archiv.py new file mode 100644 index 0000000..ade05da --- /dev/null +++ b/klausur-service/backend/routes/archiv.py @@ -0,0 +1,490 @@ +""" +Klausur-Service Abitur-Archiv Routes + +Endpoints for accessing NiBiS Zentralabitur documents (public archive). +Provides filtered listing and presigned URLs for PDF access. +""" + +from typing import Optional, List, Dict +from datetime import datetime + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel + +from qdrant_service import get_qdrant_client, search_nibis_eh +from minio_storage import get_presigned_url, list_documents +from eh_pipeline import generate_single_embedding + +router = APIRouter() + + +# ============================================= +# MODELS +# ============================================= + +class AbiturDokument(BaseModel): + """Abitur document from the archive.""" + id: str + title: str + subject: str + niveau: str # eA or gA + year: int + task_number: Optional[str] = None # Can be "1", "2A", "2C", etc. + doc_type: str # EWH, Aufgabe, Material + variant: Optional[str] = None + bundesland: str = "NI" + minio_path: Optional[str] = None + preview_url: Optional[str] = None + + +class ArchivSearchResponse(BaseModel): + """Response for archive listing.""" + total: int + documents: List[AbiturDokument] + filters: Dict + + +class SemanticSearchResult(BaseModel): + """Result from semantic search.""" + id: str + score: float + text: str + year: int + subject: str + niveau: str + task_number: Optional[str] = None # Can be "1", "2A", "2C", etc. + doc_type: str + + +# ============================================= +# ARCHIVE LISTING & FILTERS +# ============================================= + +# IMPORTANT: Specific routes MUST come before parameterized routes! +# Otherwise /api/v1/archiv/stats would be caught by /api/v1/archiv/{doc_id} + +# ============================================= +# STATS (must be before {doc_id}) +# ============================================= + +@router.get("/api/v1/archiv/stats") +async def get_archiv_stats(): + """ + Get archive statistics (document counts, available years, etc.). + """ + try: + client = get_qdrant_client() + collection = "bp_nibis_eh" + + # Get collection info + info = client.get_collection(collection) + + # Scroll to get stats by year/subject + all_points, _ = client.scroll( + collection_name=collection, + limit=1000, + with_payload=True, + with_vectors=False + ) + + # Aggregate stats + by_year = {} + by_subject = {} + by_niveau = {} + + seen_docs = set() + + for point in all_points: + payload = point.payload + doc_id = payload.get("doc_id") or payload.get("original_id", str(point.id)) + + if doc_id in seen_docs: + continue + seen_docs.add(doc_id) + + year = str(payload.get("year", "unknown")) + subject = payload.get("subject", "unknown") + niveau = payload.get("niveau", "unknown") + + by_year[year] = by_year.get(year, 0) + 1 + by_subject[subject] = by_subject.get(subject, 0) + 1 + by_niveau[niveau] = by_niveau.get(niveau, 0) + 1 + + return { + "total_documents": len(seen_docs), + "total_chunks": info.points_count, + "by_year": dict(sorted(by_year.items(), reverse=True)), + "by_subject": dict(sorted(by_subject.items(), key=lambda x: -x[1])), + "by_niveau": by_niveau, + "collection_status": info.status.value + } + + except Exception as e: + print(f"Stats error: {e}") + return { + "total_documents": 0, + "total_chunks": 0, + "by_year": {}, + "by_subject": {}, + "by_niveau": {}, + "error": str(e) + } + + +# ============================================= +# THEME SUGGESTIONS (must be before {doc_id}) +# ============================================= + +@router.get("/api/v1/archiv/suggest") +async def suggest_themes( + query: str = Query(..., min_length=2, description="Partial search query") +) -> List[Dict]: + """ + Get theme suggestions for autocomplete. + + Returns popular themes/topics that match the query. + """ + # Predefined themes for autocomplete + THEMES = [ + {"label": "Textanalyse", "type": "Analyse"}, + {"label": "Gedichtanalyse", "type": "Analyse"}, + {"label": "Dramenanalyse", "type": "Analyse"}, + {"label": "Prosaanalyse", "type": "Analyse"}, + {"label": "Eroerterung", "type": "Aufsatz"}, + {"label": "Textgebundene Eroerterung", "type": "Aufsatz"}, + {"label": "Materialgestuetzte Eroerterung", "type": "Aufsatz"}, + {"label": "Sprachreflexion", "type": "Analyse"}, + {"label": "Kafka", "type": "Autor"}, + {"label": "Goethe", "type": "Autor"}, + {"label": "Schiller", "type": "Autor"}, + {"label": "Romantik", "type": "Epoche"}, + {"label": "Expressionismus", "type": "Epoche"}, + {"label": "Sturm und Drang", "type": "Epoche"}, + {"label": "Aufklaerung", "type": "Epoche"}, + {"label": "Sprachvarietaeten", "type": "Thema"}, + {"label": "Sprachwandel", "type": "Thema"}, + {"label": "Kommunikation", "type": "Thema"}, + {"label": "Medien", "type": "Thema"}, + ] + + query_lower = query.lower() + matches = [ + theme for theme in THEMES + if query_lower in theme["label"].lower() + ] + + return matches[:10] + + +# ============================================= +# SEMANTIC SEARCH (must be before {doc_id}) +# ============================================= + +@router.get("/api/v1/archiv/search/semantic") +async def semantic_search( + query: str = Query(..., min_length=3, description="Search query"), + year: Optional[int] = Query(None), + subject: Optional[str] = Query(None), + niveau: Optional[str] = Query(None), + limit: int = Query(10, ge=1, le=50) +) -> List[SemanticSearchResult]: + """ + Perform semantic search across the archive using embeddings. + + This searches for conceptually similar content, not just keyword matches. + """ + try: + # Generate query embedding + query_embedding = await generate_single_embedding(query) + + # Search in Qdrant + results = await search_nibis_eh( + query_embedding=query_embedding, + year=year, + subject=subject, + niveau=niveau, + limit=limit + ) + + return [ + SemanticSearchResult( + id=r["id"], + score=r["score"], + text=r.get("text", "")[:500], # Truncate for response + year=r.get("year", 0), + subject=r.get("subject", ""), + niveau=r.get("niveau", ""), + task_number=r.get("task_number"), + doc_type=r.get("doc_type", "EWH") + ) + for r in results + ] + + except Exception as e: + print(f"Semantic search error: {e}") + return [] + + +# ============================================= +# ARCHIVE LISTING +# ============================================= + +@router.get("/api/v1/archiv", response_model=ArchivSearchResponse) +async def list_archiv_documents( + subject: Optional[str] = Query(None, description="Filter by subject (e.g., Deutsch, Englisch)"), + year: Optional[int] = Query(None, description="Filter by year (e.g., 2024)"), + bundesland: Optional[str] = Query(None, description="Filter by state (e.g., NI)"), + niveau: Optional[str] = Query(None, description="Filter by level (eA or gA)"), + doc_type: Optional[str] = Query(None, description="Filter by type (EWH, Aufgabe)"), + search: Optional[str] = Query(None, description="Theme/keyword search"), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0) +): + """ + List all documents in the Abitur archive with optional filters. + + Returns metadata for documents stored in the bp_nibis_eh Qdrant collection. + PDF URLs are generated on-demand via MinIO presigned URLs. + """ + try: + client = get_qdrant_client() + collection = "bp_nibis_eh" + + # Get all unique documents (dedup by doc_id) + # We scroll through the collection to get document metadata + from qdrant_client.models import Filter, FieldCondition, MatchValue + + # Build filter conditions + must_conditions = [] + + if subject: + must_conditions.append( + FieldCondition(key="subject", match=MatchValue(value=subject)) + ) + if year: + must_conditions.append( + FieldCondition(key="year", match=MatchValue(value=year)) + ) + if bundesland: + must_conditions.append( + FieldCondition(key="bundesland", match=MatchValue(value=bundesland)) + ) + if niveau: + must_conditions.append( + FieldCondition(key="niveau", match=MatchValue(value=niveau)) + ) + if doc_type: + must_conditions.append( + FieldCondition(key="doc_type", match=MatchValue(value=doc_type)) + ) + + query_filter = Filter(must=must_conditions) if must_conditions else None + + # Scroll through all points to get unique documents + all_points, _ = client.scroll( + collection_name=collection, + scroll_filter=query_filter, + limit=1000, # Get more to ensure we find unique docs + with_payload=True, + with_vectors=False + ) + + # Deduplicate by doc_id and collect unique documents + seen_docs = {} + for point in all_points: + payload = point.payload + doc_id = payload.get("doc_id") or payload.get("original_id", str(point.id)) + + # Skip if already seen + if doc_id in seen_docs: + continue + + # Apply text search filter if provided + if search: + text = payload.get("text", "") + if search.lower() not in text.lower(): + continue + + # Build document title from metadata + subject_name = payload.get("subject", "Unbekannt") + doc_year = payload.get("year", 0) + doc_niveau = payload.get("niveau", "") + task_num = payload.get("task_number") + doc_type_val = payload.get("doc_type", "EWH") + variant = payload.get("variant") + + # Generate title + title_parts = [subject_name, str(doc_year), doc_niveau] + if task_num: + title_parts.append(f"Aufgabe {task_num}") + if doc_type_val and doc_type_val != "EWH": + title_parts.append(doc_type_val) + if variant: + title_parts.append(f"({variant})") + + title = " ".join(title_parts) + + # Generate MinIO path for this document + # Path pattern: landes-daten/ni/klausur/{year}/{filename}.pdf + minio_path = f"landes-daten/ni/klausur/{doc_year}/{doc_year}_{subject_name}_{doc_niveau}" + if task_num: + minio_path += f"_{task_num}" + minio_path += "_EWH.pdf" + + seen_docs[doc_id] = AbiturDokument( + id=doc_id, + title=title, + subject=subject_name, + niveau=doc_niveau, + year=doc_year, + task_number=task_num, + doc_type=doc_type_val, + variant=variant, + bundesland=payload.get("bundesland", "NI"), + minio_path=minio_path + ) + + # Convert to list and apply pagination + documents = list(seen_docs.values()) + + # Sort by year descending, then subject + documents.sort(key=lambda d: (-d.year, d.subject)) + + total = len(documents) + paginated = documents[offset:offset + limit] + + # Get available filter options for UI + filters = { + "subjects": sorted(list(set(d.subject for d in documents))), + "years": sorted(list(set(d.year for d in documents)), reverse=True), + "niveaus": sorted(list(set(d.niveau for d in documents if d.niveau))), + "doc_types": sorted(list(set(d.doc_type for d in documents if d.doc_type))), + } + + return ArchivSearchResponse( + total=total, + documents=paginated, + filters=filters + ) + + except Exception as e: + print(f"Archiv list error: {e}") + # Return empty response with mock data if Qdrant fails + return ArchivSearchResponse( + total=0, + documents=[], + filters={ + "subjects": ["Deutsch", "Englisch", "Mathematik"], + "years": [2025, 2024, 2023, 2022, 2021], + "niveaus": ["eA", "gA"], + "doc_types": ["EWH", "Aufgabe"] + } + ) + + +@router.get("/api/v1/archiv/{doc_id}") +async def get_archiv_document(doc_id: str): + """ + Get details for a specific document including presigned PDF URL. + """ + try: + client = get_qdrant_client() + collection = "bp_nibis_eh" + + from qdrant_client.models import Filter, FieldCondition, MatchValue + + # Find document by doc_id in payload + results, _ = client.scroll( + collection_name=collection, + scroll_filter=Filter(must=[ + FieldCondition(key="doc_id", match=MatchValue(value=doc_id)) + ]), + limit=1, + with_payload=True + ) + + if not results: + # Try original_id + results, _ = client.scroll( + collection_name=collection, + scroll_filter=Filter(must=[ + FieldCondition(key="original_id", match=MatchValue(value=doc_id)) + ]), + limit=1, + with_payload=True + ) + + if not results: + raise HTTPException(status_code=404, detail="Document not found") + + payload = results[0].payload + + # Generate MinIO presigned URL + subject_name = payload.get("subject", "Unbekannt") + doc_year = payload.get("year", 0) + doc_niveau = payload.get("niveau", "") + task_num = payload.get("task_number") + + minio_path = f"landes-daten/ni/klausur/{doc_year}/{doc_year}_{subject_name}_{doc_niveau}" + if task_num: + minio_path += f"_{task_num}" + minio_path += "_EWH.pdf" + + # Get presigned URL (1 hour expiry) + preview_url = await get_presigned_url(minio_path, expires=3600) + + return { + "id": doc_id, + "title": f"{subject_name} {doc_year} {doc_niveau}", + "subject": subject_name, + "niveau": doc_niveau, + "year": doc_year, + "task_number": task_num, + "doc_type": payload.get("doc_type", "EWH"), + "variant": payload.get("variant"), + "bundesland": payload.get("bundesland", "NI"), + "minio_path": minio_path, + "preview_url": preview_url, + "text_preview": payload.get("text", "")[:500] + "..." if payload.get("text") else None + } + + except HTTPException: + raise + except Exception as e: + print(f"Get document error: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get document: {str(e)}") + + +@router.get("/api/v1/archiv/{doc_id}/url") +async def get_document_url(doc_id: str, expires: int = Query(3600, ge=300, le=86400)): + """ + Get a presigned URL for downloading the PDF. + + Args: + doc_id: Document ID + expires: URL expiration time in seconds (default 1 hour, max 24 hours) + """ + try: + # First, get the document to find the MinIO path + doc = await get_archiv_document(doc_id) + + if not doc.get("minio_path"): + raise HTTPException(status_code=404, detail="Document path not found") + + # Generate presigned URL + url = await get_presigned_url(doc["minio_path"], expires=expires) + + if not url: + raise HTTPException(status_code=500, detail="Failed to generate download URL") + + return { + "url": url, + "expires_in": expires, + "filename": doc["minio_path"].split("/")[-1] + } + + except HTTPException: + raise + except Exception as e: + print(f"Get URL error: {e}") + raise HTTPException(status_code=500, detail=f"Failed to generate URL: {str(e)}") diff --git a/klausur-service/backend/routes/eh.py b/klausur-service/backend/routes/eh.py new file mode 100644 index 0000000..e83c3ec --- /dev/null +++ b/klausur-service/backend/routes/eh.py @@ -0,0 +1,1111 @@ +""" +Klausur-Service BYOEH Routes + +Endpoints for Bring-Your-Own-Expectation-Horizon (BYOEH). +""" + +import os +import uuid +import json +from datetime import datetime, timezone, timedelta +from typing import Optional + +from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Form, BackgroundTasks + +from models.enums import EHStatus +from models.eh import ( + Erwartungshorizont, + EHRightsConfirmation, + EHKeyShare, + EHKlausurLink, + EHShareInvitation, +) +from models.requests import ( + EHMetadata, + EHUploadMetadata, + EHRAGQuery, + EHIndexRequest, + EHShareRequest, + EHLinkKlausurRequest, + EHInviteRequest, + EHAcceptInviteRequest, +) +from services.auth_service import get_current_user +from services.eh_service import log_eh_audit +from config import EH_UPLOAD_DIR, OPENAI_API_KEY, ENVIRONMENT, RIGHTS_CONFIRMATION_TEXT +import storage + +# BYOEH imports +from qdrant_service import ( + get_collection_info, delete_eh_vectors, search_eh, index_eh_chunks +) +from eh_pipeline import ( + decrypt_text, verify_key_hash, process_eh_for_indexing, + generate_single_embedding, EncryptionError, EmbeddingError +) + +router = APIRouter() + + +# ============================================= +# EH UPLOAD & LIST +# ============================================= + +@router.post("/api/v1/eh/upload") +async def upload_erwartungshorizont( + file: UploadFile = File(...), + metadata_json: str = Form(...), + request: Request = None, + background_tasks: BackgroundTasks = None +): + """ + Upload an encrypted Erwartungshorizont. + + The file MUST be client-side encrypted. + Server stores only the encrypted blob + key hash (never the passphrase). + """ + user = get_current_user(request) + tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] + + try: + data = EHUploadMetadata(**json.loads(metadata_json)) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid metadata: {str(e)}") + + if not data.rights_confirmed: + raise HTTPException(status_code=400, detail="Rights confirmation required") + + eh_id = str(uuid.uuid4()) + + # Create tenant-isolated directory + upload_dir = f"{EH_UPLOAD_DIR}/{tenant_id}/{eh_id}" + os.makedirs(upload_dir, exist_ok=True) + + # Save encrypted file + encrypted_path = f"{upload_dir}/encrypted.bin" + content = await file.read() + with open(encrypted_path, "wb") as f: + f.write(content) + + # Save salt separately + with open(f"{upload_dir}/salt.txt", "w") as f: + f.write(data.salt) + + # Create EH record + eh = Erwartungshorizont( + id=eh_id, + tenant_id=tenant_id, + teacher_id=user["user_id"], + title=data.metadata.title, + subject=data.metadata.subject, + niveau=data.metadata.niveau, + year=data.metadata.year, + aufgaben_nummer=data.metadata.aufgaben_nummer, + encryption_key_hash=data.encryption_key_hash, + salt=data.salt, + encrypted_file_path=encrypted_path, + file_size_bytes=len(content), + original_filename=data.original_filename, + rights_confirmed=True, + rights_confirmed_at=datetime.now(timezone.utc), + status=EHStatus.PENDING_RIGHTS, + chunk_count=0, + indexed_at=None, + error_message=None, + training_allowed=False, # ALWAYS FALSE - critical for compliance + created_at=datetime.now(timezone.utc), + deleted_at=None + ) + + storage.eh_db[eh_id] = eh + + # Store rights confirmation + rights_confirmation = EHRightsConfirmation( + id=str(uuid.uuid4()), + eh_id=eh_id, + teacher_id=user["user_id"], + confirmation_type="upload", + confirmation_text=RIGHTS_CONFIRMATION_TEXT, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + confirmed_at=datetime.now(timezone.utc) + ) + storage.eh_rights_db[rights_confirmation.id] = rights_confirmation + + # Audit log + log_eh_audit( + tenant_id=tenant_id, + user_id=user["user_id"], + action="upload", + eh_id=eh_id, + details={ + "subject": data.metadata.subject, + "year": data.metadata.year, + "file_size": len(content) + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent") + ) + + return eh.to_dict() + + +@router.get("/api/v1/eh") +async def list_erwartungshorizonte( + request: Request, + subject: Optional[str] = None, + year: Optional[int] = None +): + """List all Erwartungshorizonte for the current teacher.""" + user = get_current_user(request) + tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] + + results = [] + for eh in storage.eh_db.values(): + if eh.tenant_id == tenant_id and eh.deleted_at is None: + if subject and eh.subject != subject: + continue + if year and eh.year != year: + continue + results.append(eh.to_dict()) + + return results + + +# ============================================= +# SPECIFIC EH ROUTES (must come before {eh_id} catch-all) +# ============================================= + +@router.get("/api/v1/eh/audit-log") +async def get_eh_audit_log( + request: Request, + eh_id: Optional[str] = None, + limit: int = 100 +): + """Get BYOEH audit log entries.""" + user = get_current_user(request) + tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] + + # Filter by tenant + entries = [e for e in storage.eh_audit_db if e.tenant_id == tenant_id] + + # Filter by EH if specified + if eh_id: + entries = [e for e in entries if e.eh_id == eh_id] + + # Sort and limit + entries = sorted(entries, key=lambda e: e.created_at, reverse=True)[:limit] + + return [e.to_dict() for e in entries] + + +@router.get("/api/v1/eh/rights-text") +async def get_rights_confirmation_text(): + """Get the rights confirmation text for display in UI.""" + return { + "text": RIGHTS_CONFIRMATION_TEXT, + "version": "v1.0" + } + + +@router.get("/api/v1/eh/qdrant-status") +async def get_qdrant_status(request: Request): + """Get Qdrant collection status (admin only).""" + user = get_current_user(request) + if user.get("role") != "admin" and ENVIRONMENT != "development": + raise HTTPException(status_code=403, detail="Admin access required") + + return await get_collection_info() + + +@router.get("/api/v1/eh/shared-with-me") +async def list_shared_eh(request: Request): + """List all EH shared with the current user.""" + user = get_current_user(request) + user_id = user["user_id"] + + shared_ehs = [] + for eh_id, shares in storage.eh_key_shares_db.items(): + for share in shares: + if share.user_id == user_id and share.active: + if eh_id in storage.eh_db: + eh = storage.eh_db[eh_id] + shared_ehs.append({ + "eh": eh.to_dict(), + "share": share.to_dict() + }) + + return shared_ehs + + +# ============================================= +# GENERIC EH ROUTES +# ============================================= + +@router.get("/api/v1/eh/{eh_id}") +async def get_erwartungshorizont(eh_id: str, request: Request): + """Get a specific Erwartungshorizont by ID.""" + user = get_current_user(request) + + if eh_id not in storage.eh_db: + raise HTTPException(status_code=404, detail="Erwartungshorizont not found") + + eh = storage.eh_db[eh_id] + if eh.teacher_id != user["user_id"] and user.get("role") != "admin": + raise HTTPException(status_code=403, detail="Access denied") + + if eh.deleted_at is not None: + raise HTTPException(status_code=404, detail="Erwartungshorizont was deleted") + + return eh.to_dict() + + +@router.delete("/api/v1/eh/{eh_id}") +async def delete_erwartungshorizont(eh_id: str, request: Request): + """Soft-delete an Erwartungshorizont and remove vectors from Qdrant.""" + user = get_current_user(request) + + if eh_id not in storage.eh_db: + raise HTTPException(status_code=404, detail="Erwartungshorizont not found") + + eh = storage.eh_db[eh_id] + if eh.teacher_id != user["user_id"] and user.get("role") != "admin": + raise HTTPException(status_code=403, detail="Access denied") + + # Soft delete + eh.deleted_at = datetime.now(timezone.utc) + + # Delete vectors from Qdrant + try: + deleted_count = await delete_eh_vectors(eh_id) + print(f"Deleted {deleted_count} vectors for EH {eh_id}") + except Exception as e: + print(f"Warning: Failed to delete vectors: {e}") + + # Audit log + log_eh_audit( + tenant_id=eh.tenant_id, + user_id=user["user_id"], + action="delete", + eh_id=eh_id, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent") + ) + + return {"status": "deleted", "id": eh_id} + + +@router.post("/api/v1/eh/{eh_id}/index") +async def index_erwartungshorizont( + eh_id: str, + data: EHIndexRequest, + request: Request +): + """ + Index an Erwartungshorizont for RAG queries. + + Requires the passphrase to decrypt, chunk, embed, and re-encrypt chunks. + The passphrase is only used transiently and never stored. + """ + user = get_current_user(request) + + if eh_id not in storage.eh_db: + raise HTTPException(status_code=404, detail="Erwartungshorizont not found") + + eh = storage.eh_db[eh_id] + if eh.teacher_id != user["user_id"] and user.get("role") != "admin": + raise HTTPException(status_code=403, detail="Access denied") + + # Verify passphrase matches key hash + if not verify_key_hash(data.passphrase, eh.salt, eh.encryption_key_hash): + raise HTTPException(status_code=401, detail="Invalid passphrase") + + eh.status = EHStatus.PROCESSING + + try: + # Read encrypted file + with open(eh.encrypted_file_path, "rb") as f: + encrypted_content = f.read() + + # Decrypt the file + decrypted_text = decrypt_text( + encrypted_content.decode('utf-8'), + data.passphrase, + eh.salt + ) + + # Process for indexing + chunk_count, chunks_data = await process_eh_for_indexing( + eh_id=eh_id, + tenant_id=eh.tenant_id, + subject=eh.subject, + text_content=decrypted_text, + passphrase=data.passphrase, + salt_hex=eh.salt + ) + + # Index in Qdrant + await index_eh_chunks( + eh_id=eh_id, + tenant_id=eh.tenant_id, + subject=eh.subject, + chunks=chunks_data + ) + + # Update EH record + eh.status = EHStatus.INDEXED + eh.chunk_count = chunk_count + eh.indexed_at = datetime.now(timezone.utc) + + # Audit log + log_eh_audit( + tenant_id=eh.tenant_id, + user_id=user["user_id"], + action="indexed", + eh_id=eh_id, + details={"chunk_count": chunk_count} + ) + + return { + "status": "indexed", + "id": eh_id, + "chunk_count": chunk_count + } + + except EncryptionError as e: + eh.status = EHStatus.ERROR + eh.error_message = str(e) + raise HTTPException(status_code=400, detail=f"Decryption failed: {str(e)}") + except EmbeddingError as e: + eh.status = EHStatus.ERROR + eh.error_message = str(e) + raise HTTPException(status_code=500, detail=f"Embedding generation failed: {str(e)}") + except Exception as e: + eh.status = EHStatus.ERROR + eh.error_message = str(e) + raise HTTPException(status_code=500, detail=f"Indexing failed: {str(e)}") + + +@router.post("/api/v1/eh/rag-query") +async def rag_query_eh(data: EHRAGQuery, request: Request): + """ + RAG query against teacher's Erwartungshorizonte. + + 1. Semantic search in Qdrant (tenant-isolated) + 2. Decrypt relevant chunks on-the-fly + 3. Return context for LLM usage + """ + user = get_current_user(request) + tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] + + if not OPENAI_API_KEY: + raise HTTPException(status_code=500, detail="OpenAI API key not configured") + + try: + # Generate embedding for query + query_embedding = await generate_single_embedding(data.query_text) + + # Search in Qdrant (tenant-isolated) + results = await search_eh( + query_embedding=query_embedding, + tenant_id=tenant_id, + subject=data.subject, + limit=data.limit + ) + + # Decrypt matching chunks + decrypted_chunks = [] + for r in results: + eh = storage.eh_db.get(r["eh_id"]) + if eh and r.get("encrypted_content"): + try: + decrypted = decrypt_text( + r["encrypted_content"], + data.passphrase, + eh.salt + ) + decrypted_chunks.append({ + "text": decrypted, + "eh_id": r["eh_id"], + "eh_title": eh.title, + "chunk_index": r["chunk_index"], + "score": r["score"] + }) + except EncryptionError: + # Skip chunks that can't be decrypted (wrong passphrase for different EH) + pass + + # Audit log + log_eh_audit( + tenant_id=tenant_id, + user_id=user["user_id"], + action="rag_query", + details={ + "query_length": len(data.query_text), + "results_count": len(results), + "decrypted_count": len(decrypted_chunks) + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent") + ) + + return { + "context": "\n\n---\n\n".join([c["text"] for c in decrypted_chunks]), + "sources": decrypted_chunks, + "query": data.query_text + } + + except EmbeddingError as e: + raise HTTPException(status_code=500, detail=f"Query embedding failed: {str(e)}") + except Exception as e: + raise HTTPException(status_code=500, detail=f"RAG query failed: {str(e)}") + + +# ============================================= +# BYOEH KEY SHARING +# ============================================= + +@router.post("/api/v1/eh/{eh_id}/share") +async def share_erwartungshorizont( + eh_id: str, + share_request: EHShareRequest, + request: Request +): + """ + Share an Erwartungshorizont with another examiner. + + The first examiner shares their EH by providing an encrypted passphrase + that the recipient can use. + """ + user = get_current_user(request) + tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] + + # Check EH exists and belongs to user + if eh_id not in storage.eh_db: + raise HTTPException(status_code=404, detail="Erwartungshorizont not found") + + eh = storage.eh_db[eh_id] + if eh.teacher_id != user["user_id"]: + raise HTTPException(status_code=403, detail="Only the owner can share this EH") + + # Validate role + valid_roles = ['second_examiner', 'third_examiner', 'supervisor', 'department_head'] + if share_request.role not in valid_roles: + raise HTTPException(status_code=400, detail=f"Invalid role. Must be one of: {valid_roles}") + + # Create key share entry + share_id = str(uuid.uuid4()) + key_share = EHKeyShare( + id=share_id, + eh_id=eh_id, + user_id=share_request.user_id, + encrypted_passphrase=share_request.encrypted_passphrase, + passphrase_hint=share_request.passphrase_hint or "", + granted_by=user["user_id"], + granted_at=datetime.now(timezone.utc), + role=share_request.role, + klausur_id=share_request.klausur_id, + active=True + ) + + # Store in memory + if eh_id not in storage.eh_key_shares_db: + storage.eh_key_shares_db[eh_id] = [] + storage.eh_key_shares_db[eh_id].append(key_share) + + # Audit log + log_eh_audit( + tenant_id=tenant_id, + user_id=user["user_id"], + action="share", + eh_id=eh_id, + details={ + "shared_with": share_request.user_id, + "role": share_request.role, + "klausur_id": share_request.klausur_id + } + ) + + return { + "status": "shared", + "share_id": share_id, + "eh_id": eh_id, + "shared_with": share_request.user_id, + "role": share_request.role + } + + +@router.get("/api/v1/eh/{eh_id}/shares") +async def list_eh_shares(eh_id: str, request: Request): + """List all users who have access to an EH.""" + user = get_current_user(request) + + # Check EH exists and belongs to user + if eh_id not in storage.eh_db: + raise HTTPException(status_code=404, detail="Erwartungshorizont not found") + + eh = storage.eh_db[eh_id] + if eh.teacher_id != user["user_id"]: + raise HTTPException(status_code=403, detail="Only the owner can view shares") + + shares = storage.eh_key_shares_db.get(eh_id, []) + return [share.to_dict() for share in shares if share.active] + + +@router.delete("/api/v1/eh/{eh_id}/shares/{share_id}") +async def revoke_eh_share(eh_id: str, share_id: str, request: Request): + """Revoke a shared EH access.""" + user = get_current_user(request) + tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] + + # Check EH exists and belongs to user + if eh_id not in storage.eh_db: + raise HTTPException(status_code=404, detail="Erwartungshorizont not found") + + eh = storage.eh_db[eh_id] + if eh.teacher_id != user["user_id"]: + raise HTTPException(status_code=403, detail="Only the owner can revoke shares") + + # Find and deactivate share + shares = storage.eh_key_shares_db.get(eh_id, []) + for share in shares: + if share.id == share_id: + share.active = False + + log_eh_audit( + tenant_id=tenant_id, + user_id=user["user_id"], + action="revoke_share", + eh_id=eh_id, + details={"revoked_user": share.user_id, "share_id": share_id} + ) + + return {"status": "revoked", "share_id": share_id} + + raise HTTPException(status_code=404, detail="Share not found") + + +# ============================================= +# KLAUSUR LINKING +# ============================================= + +@router.post("/api/v1/eh/{eh_id}/link-klausur") +async def link_eh_to_klausur( + eh_id: str, + link_request: EHLinkKlausurRequest, + request: Request +): + """ + Link an Erwartungshorizont to a Klausur. + + This creates an association between the EH and a specific Klausur. + """ + user = get_current_user(request) + tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] + + # Check EH exists and user has access + if eh_id not in storage.eh_db: + raise HTTPException(status_code=404, detail="Erwartungshorizont not found") + + eh = storage.eh_db[eh_id] + user_has_access = ( + eh.teacher_id == user["user_id"] or + any( + share.user_id == user["user_id"] and share.active + for share in storage.eh_key_shares_db.get(eh_id, []) + ) + ) + + if not user_has_access: + raise HTTPException(status_code=403, detail="No access to this EH") + + # Check Klausur exists + klausur_id = link_request.klausur_id + if klausur_id not in storage.klausuren_db: + raise HTTPException(status_code=404, detail="Klausur not found") + + # Create link + link_id = str(uuid.uuid4()) + link = EHKlausurLink( + id=link_id, + eh_id=eh_id, + klausur_id=klausur_id, + linked_by=user["user_id"], + linked_at=datetime.now(timezone.utc) + ) + + if klausur_id not in storage.eh_klausur_links_db: + storage.eh_klausur_links_db[klausur_id] = [] + storage.eh_klausur_links_db[klausur_id].append(link) + + # Audit log + log_eh_audit( + tenant_id=tenant_id, + user_id=user["user_id"], + action="link_klausur", + eh_id=eh_id, + details={"klausur_id": klausur_id} + ) + + return { + "status": "linked", + "link_id": link_id, + "eh_id": eh_id, + "klausur_id": klausur_id + } + + +@router.get("/api/v1/klausuren/{klausur_id}/linked-eh") +async def get_linked_eh(klausur_id: str, request: Request): + """Get all EH linked to a specific Klausur.""" + user = get_current_user(request) + user_id = user["user_id"] + + # Check Klausur exists + if klausur_id not in storage.klausuren_db: + raise HTTPException(status_code=404, detail="Klausur not found") + + # Get all links for this Klausur + links = storage.eh_klausur_links_db.get(klausur_id, []) + + linked_ehs = [] + for link in links: + if link.eh_id in storage.eh_db: + eh = storage.eh_db[link.eh_id] + + # Check if user has access to this EH + is_owner = eh.teacher_id == user_id + is_shared = any( + share.user_id == user_id and share.active + for share in storage.eh_key_shares_db.get(link.eh_id, []) + ) + + if is_owner or is_shared: + # Find user's share info if shared + share_info = None + if is_shared: + for share in storage.eh_key_shares_db.get(link.eh_id, []): + if share.user_id == user_id and share.active: + share_info = share.to_dict() + break + + linked_ehs.append({ + "eh": eh.to_dict(), + "link": link.to_dict(), + "is_owner": is_owner, + "share": share_info + }) + + return linked_ehs + + +@router.delete("/api/v1/eh/{eh_id}/link-klausur/{klausur_id}") +async def unlink_eh_from_klausur(eh_id: str, klausur_id: str, request: Request): + """Remove the link between an EH and a Klausur.""" + user = get_current_user(request) + tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] + + # Check EH exists and user has access + if eh_id not in storage.eh_db: + raise HTTPException(status_code=404, detail="Erwartungshorizont not found") + + eh = storage.eh_db[eh_id] + if eh.teacher_id != user["user_id"]: + raise HTTPException(status_code=403, detail="Only the owner can unlink") + + # Find and remove link + links = storage.eh_klausur_links_db.get(klausur_id, []) + for i, link in enumerate(links): + if link.eh_id == eh_id: + del links[i] + + log_eh_audit( + tenant_id=tenant_id, + user_id=user["user_id"], + action="unlink_klausur", + eh_id=eh_id, + details={"klausur_id": klausur_id} + ) + + return {"status": "unlinked", "eh_id": eh_id, "klausur_id": klausur_id} + + raise HTTPException(status_code=404, detail="Link not found") + + +# ============================================= +# INVITATION FLOW +# ============================================= + +@router.post("/api/v1/eh/{eh_id}/invite") +async def invite_to_eh( + eh_id: str, + invite_request: EHInviteRequest, + request: Request +): + """ + Invite another user to access an Erwartungshorizont. + + This creates a pending invitation that the recipient must accept. + """ + user = get_current_user(request) + tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] + + # Check EH exists and belongs to user + if eh_id not in storage.eh_db: + raise HTTPException(status_code=404, detail="Erwartungshorizont not found") + + eh = storage.eh_db[eh_id] + if eh.teacher_id != user["user_id"]: + raise HTTPException(status_code=403, detail="Only the owner can invite others") + + # Validate role + valid_roles = ['second_examiner', 'third_examiner', 'supervisor', 'department_head', 'fachvorsitz'] + if invite_request.role not in valid_roles: + raise HTTPException(status_code=400, detail=f"Invalid role. Must be one of: {valid_roles}") + + # Check for existing pending invitation to same user + for inv in storage.eh_invitations_db.values(): + if (inv.eh_id == eh_id and + inv.invitee_email == invite_request.invitee_email and + inv.status == 'pending'): + raise HTTPException( + status_code=409, + detail="Pending invitation already exists for this user" + ) + + # Create invitation + invitation_id = str(uuid.uuid4()) + now = datetime.now(timezone.utc) + expires_at = now + timedelta(days=invite_request.expires_in_days) + + invitation = EHShareInvitation( + id=invitation_id, + eh_id=eh_id, + inviter_id=user["user_id"], + invitee_id=invite_request.invitee_id or "", + invitee_email=invite_request.invitee_email, + role=invite_request.role, + klausur_id=invite_request.klausur_id, + message=invite_request.message, + status='pending', + expires_at=expires_at, + created_at=now, + accepted_at=None, + declined_at=None + ) + + storage.eh_invitations_db[invitation_id] = invitation + + # Audit log + log_eh_audit( + tenant_id=tenant_id, + user_id=user["user_id"], + action="invite", + eh_id=eh_id, + details={ + "invitation_id": invitation_id, + "invitee_email": invite_request.invitee_email, + "role": invite_request.role, + "expires_at": expires_at.isoformat() + } + ) + + return { + "status": "invited", + "invitation_id": invitation_id, + "eh_id": eh_id, + "invitee_email": invite_request.invitee_email, + "role": invite_request.role, + "expires_at": expires_at.isoformat(), + "eh_title": eh.title + } + + +@router.get("/api/v1/eh/invitations/pending") +async def list_pending_invitations(request: Request): + """List all pending invitations for the current user.""" + user = get_current_user(request) + user_email = user.get("email", "") + user_id = user["user_id"] + now = datetime.now(timezone.utc) + + pending = [] + for inv in storage.eh_invitations_db.values(): + # Match by email or user_id + if (inv.invitee_email == user_email or inv.invitee_id == user_id): + if inv.status == 'pending' and inv.expires_at > now: + # Get EH info + eh_info = None + if inv.eh_id in storage.eh_db: + eh = storage.eh_db[inv.eh_id] + eh_info = { + "id": eh.id, + "title": eh.title, + "subject": eh.subject, + "niveau": eh.niveau, + "year": eh.year + } + + pending.append({ + "invitation": inv.to_dict(), + "eh": eh_info + }) + + return pending + + +@router.get("/api/v1/eh/invitations/sent") +async def list_sent_invitations(request: Request): + """List all invitations sent by the current user.""" + user = get_current_user(request) + user_id = user["user_id"] + + sent = [] + for inv in storage.eh_invitations_db.values(): + if inv.inviter_id == user_id: + # Get EH info + eh_info = None + if inv.eh_id in storage.eh_db: + eh = storage.eh_db[inv.eh_id] + eh_info = { + "id": eh.id, + "title": eh.title, + "subject": eh.subject + } + + sent.append({ + "invitation": inv.to_dict(), + "eh": eh_info + }) + + return sent + + +@router.post("/api/v1/eh/invitations/{invitation_id}/accept") +async def accept_eh_invitation( + invitation_id: str, + accept_request: EHAcceptInviteRequest, + request: Request +): + """Accept an invitation and receive access to the EH.""" + user = get_current_user(request) + tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] + user_email = user.get("email", "") + user_id = user["user_id"] + now = datetime.now(timezone.utc) + + # Find invitation + if invitation_id not in storage.eh_invitations_db: + raise HTTPException(status_code=404, detail="Invitation not found") + + invitation = storage.eh_invitations_db[invitation_id] + + # Verify recipient + if invitation.invitee_email != user_email and invitation.invitee_id != user_id: + raise HTTPException(status_code=403, detail="This invitation is not for you") + + # Check status + if invitation.status != 'pending': + raise HTTPException( + status_code=400, + detail=f"Invitation is {invitation.status}, cannot accept" + ) + + # Check expiration + if invitation.expires_at < now: + invitation.status = 'expired' + raise HTTPException(status_code=400, detail="Invitation has expired") + + # Create key share + share_id = str(uuid.uuid4()) + key_share = EHKeyShare( + id=share_id, + eh_id=invitation.eh_id, + user_id=user_id, + encrypted_passphrase=accept_request.encrypted_passphrase, + passphrase_hint="", + granted_by=invitation.inviter_id, + granted_at=now, + role=invitation.role, + klausur_id=invitation.klausur_id, + active=True + ) + + # Store key share + if invitation.eh_id not in storage.eh_key_shares_db: + storage.eh_key_shares_db[invitation.eh_id] = [] + storage.eh_key_shares_db[invitation.eh_id].append(key_share) + + # Update invitation status + invitation.status = 'accepted' + invitation.accepted_at = now + invitation.invitee_id = user_id # Update with actual user ID + + # Audit log + log_eh_audit( + tenant_id=tenant_id, + user_id=user_id, + action="accept_invite", + eh_id=invitation.eh_id, + details={ + "invitation_id": invitation_id, + "share_id": share_id, + "role": invitation.role + } + ) + + return { + "status": "accepted", + "share_id": share_id, + "eh_id": invitation.eh_id, + "role": invitation.role, + "klausur_id": invitation.klausur_id + } + + +@router.post("/api/v1/eh/invitations/{invitation_id}/decline") +async def decline_eh_invitation(invitation_id: str, request: Request): + """Decline an invitation.""" + user = get_current_user(request) + tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] + user_email = user.get("email", "") + user_id = user["user_id"] + now = datetime.now(timezone.utc) + + # Find invitation + if invitation_id not in storage.eh_invitations_db: + raise HTTPException(status_code=404, detail="Invitation not found") + + invitation = storage.eh_invitations_db[invitation_id] + + # Verify recipient + if invitation.invitee_email != user_email and invitation.invitee_id != user_id: + raise HTTPException(status_code=403, detail="This invitation is not for you") + + # Check status + if invitation.status != 'pending': + raise HTTPException( + status_code=400, + detail=f"Invitation is {invitation.status}, cannot decline" + ) + + # Update status + invitation.status = 'declined' + invitation.declined_at = now + + # Audit log + log_eh_audit( + tenant_id=tenant_id, + user_id=user_id, + action="decline_invite", + eh_id=invitation.eh_id, + details={"invitation_id": invitation_id} + ) + + return { + "status": "declined", + "invitation_id": invitation_id, + "eh_id": invitation.eh_id + } + + +@router.delete("/api/v1/eh/invitations/{invitation_id}") +async def revoke_eh_invitation(invitation_id: str, request: Request): + """Revoke a pending invitation (by the inviter).""" + user = get_current_user(request) + tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] + user_id = user["user_id"] + + # Find invitation + if invitation_id not in storage.eh_invitations_db: + raise HTTPException(status_code=404, detail="Invitation not found") + + invitation = storage.eh_invitations_db[invitation_id] + + # Verify inviter + if invitation.inviter_id != user_id: + raise HTTPException(status_code=403, detail="Only the inviter can revoke") + + # Check status + if invitation.status != 'pending': + raise HTTPException( + status_code=400, + detail=f"Invitation is {invitation.status}, cannot revoke" + ) + + # Update status + invitation.status = 'revoked' + + # Audit log + log_eh_audit( + tenant_id=tenant_id, + user_id=user_id, + action="revoke_invite", + eh_id=invitation.eh_id, + details={ + "invitation_id": invitation_id, + "invitee_email": invitation.invitee_email + } + ) + + return { + "status": "revoked", + "invitation_id": invitation_id, + "eh_id": invitation.eh_id + } + + +@router.get("/api/v1/eh/{eh_id}/access-chain") +async def get_eh_access_chain(eh_id: str, request: Request): + """ + Get the complete access chain for an EH. + + Shows the correction chain: EK -> ZK -> DK -> FVL + with their current access status. + """ + user = get_current_user(request) + + # Check EH exists + if eh_id not in storage.eh_db: + raise HTTPException(status_code=404, detail="Erwartungshorizont not found") + + eh = storage.eh_db[eh_id] + + # Check access - owner or shared user + is_owner = eh.teacher_id == user["user_id"] + is_shared = any( + share.user_id == user["user_id"] and share.active + for share in storage.eh_key_shares_db.get(eh_id, []) + ) + + if not is_owner and not is_shared: + raise HTTPException(status_code=403, detail="No access to this EH") + + # Build access chain + chain = { + "eh_id": eh_id, + "eh_title": eh.title, + "owner": { + "user_id": eh.teacher_id, + "role": "erstkorrektor" + }, + "active_shares": [], + "pending_invitations": [], + "revoked_shares": [] + } + + # Active shares + for share in storage.eh_key_shares_db.get(eh_id, []): + share_dict = share.to_dict() + if share.active: + chain["active_shares"].append(share_dict) + else: + chain["revoked_shares"].append(share_dict) + + # Pending invitations (only for owner) + if is_owner: + for inv in storage.eh_invitations_db.values(): + if inv.eh_id == eh_id and inv.status == 'pending': + chain["pending_invitations"].append(inv.to_dict()) + + return chain diff --git a/klausur-service/backend/routes/exams.py b/klausur-service/backend/routes/exams.py new file mode 100644 index 0000000..928cdd8 --- /dev/null +++ b/klausur-service/backend/routes/exams.py @@ -0,0 +1,109 @@ +""" +Klausur-Service Exam Routes + +CRUD endpoints for Klausuren. +""" + +import uuid +from datetime import datetime, timezone + +from fastapi import APIRouter, HTTPException, Request + +from models.exam import Klausur +from models.requests import KlausurCreate, KlausurUpdate +from services.auth_service import get_current_user +import storage + +router = APIRouter() + + +@router.get("/api/v1/klausuren") +async def list_klausuren(request: Request): + """List all Klausuren for current teacher.""" + user = get_current_user(request) + user_klausuren = [ + k.to_dict() for k in storage.klausuren_db.values() + if k.teacher_id == user["user_id"] + ] + return user_klausuren + + +@router.post("/api/v1/klausuren") +async def create_klausur(data: KlausurCreate, request: Request): + """Create a new Klausur.""" + user = get_current_user(request) + + klausur = Klausur( + id=str(uuid.uuid4()), + title=data.title, + subject=data.subject, + modus=data.modus, + class_id=data.class_id, + year=data.year, + semester=data.semester, + erwartungshorizont=None, + students=[], + created_at=datetime.now(timezone.utc), + teacher_id=user["user_id"] + ) + + storage.klausuren_db[klausur.id] = klausur + return klausur.to_dict() + + +@router.get("/api/v1/klausuren/{klausur_id}") +async def get_klausur(klausur_id: str, request: Request): + """Get a specific Klausur.""" + user = get_current_user(request) + + if klausur_id not in storage.klausuren_db: + raise HTTPException(status_code=404, detail="Klausur not found") + + klausur = storage.klausuren_db[klausur_id] + if klausur.teacher_id != user["user_id"] and user.get("role") != "admin": + raise HTTPException(status_code=403, detail="Access denied") + + return klausur.to_dict() + + +@router.put("/api/v1/klausuren/{klausur_id}") +async def update_klausur(klausur_id: str, data: KlausurUpdate, request: Request): + """Update a Klausur.""" + user = get_current_user(request) + + if klausur_id not in storage.klausuren_db: + raise HTTPException(status_code=404, detail="Klausur not found") + + klausur = storage.klausuren_db[klausur_id] + if klausur.teacher_id != user["user_id"] and user.get("role") != "admin": + raise HTTPException(status_code=403, detail="Access denied") + + if data.title: + klausur.title = data.title + if data.subject: + klausur.subject = data.subject + if data.erwartungshorizont: + klausur.erwartungshorizont = data.erwartungshorizont + + return klausur.to_dict() + + +@router.delete("/api/v1/klausuren/{klausur_id}") +async def delete_klausur(klausur_id: str, request: Request): + """Delete a Klausur and all associated student work.""" + user = get_current_user(request) + + if klausur_id not in storage.klausuren_db: + raise HTTPException(status_code=404, detail="Klausur not found") + + klausur = storage.klausuren_db[klausur_id] + if klausur.teacher_id != user["user_id"] and user.get("role") != "admin": + raise HTTPException(status_code=403, detail="Access denied") + + # Remove student records + for student in klausur.students: + if student.id in storage.students_db: + del storage.students_db[student.id] + + del storage.klausuren_db[klausur_id] + return {"success": True} diff --git a/klausur-service/backend/routes/fairness.py b/klausur-service/backend/routes/fairness.py new file mode 100644 index 0000000..2189ddd --- /dev/null +++ b/klausur-service/backend/routes/fairness.py @@ -0,0 +1,248 @@ +""" +Klausur-Service Fairness Routes + +Endpoints for fairness analysis, audit logs, and utility functions. +""" + +from typing import Optional + +import httpx +from fastapi import APIRouter, HTTPException, Request + +from models.grading import GRADE_THRESHOLDS, GRADE_LABELS, DEFAULT_CRITERIA +from services.auth_service import get_current_user +from config import SCHOOL_SERVICE_URL +import storage + +router = APIRouter() + + +@router.get("/api/v1/klausuren/{klausur_id}/fairness") +async def get_fairness_analysis(klausur_id: str, request: Request): + """Analyze grading fairness across all students in a Klausur.""" + user = get_current_user(request) + + if klausur_id not in storage.klausuren_db: + raise HTTPException(status_code=404, detail="Klausur not found") + + klausur = storage.klausuren_db[klausur_id] + students = klausur.students + + if len(students) < 2: + return { + "klausur_id": klausur_id, + "analysis": "Zu wenige Arbeiten fuer Vergleich (mindestens 2 benoetigt)", + "students_count": len(students) + } + + # Calculate statistics + grades = [s.grade_points for s in students if s.grade_points > 0] + raw_points = [s.raw_points for s in students if s.raw_points > 0] + + if not grades: + return { + "klausur_id": klausur_id, + "analysis": "Keine bewerteten Arbeiten vorhanden", + "students_count": len(students) + } + + avg_grade = sum(grades) / len(grades) + avg_raw = sum(raw_points) / len(raw_points) if raw_points else 0 + min_grade = min(grades) + max_grade = max(grades) + spread = max_grade - min_grade + + # Calculate standard deviation + variance = sum((g - avg_grade) ** 2 for g in grades) / len(grades) + std_dev = variance ** 0.5 + + # Identify outliers (more than 2 std deviations from mean) + outliers = [] + for s in students: + if s.grade_points > 0: + deviation = abs(s.grade_points - avg_grade) + if deviation > 2 * std_dev: + outliers.append({ + "student_id": s.id, + "student_name": s.student_name, + "grade_points": s.grade_points, + "deviation": round(deviation, 2), + "direction": "above" if s.grade_points > avg_grade else "below" + }) + + # Criteria comparison + criteria_averages = {} + for criterion in DEFAULT_CRITERIA.keys(): + scores = [] + for s in students: + if criterion in s.criteria_scores: + scores.append(s.criteria_scores[criterion].get("score", 0)) + if scores: + criteria_averages[criterion] = { + "average": round(sum(scores) / len(scores), 1), + "min": min(scores), + "max": max(scores), + "count": len(scores) + } + + # Fairness score (0-100, higher is more consistent) + # Based on: low std deviation, no extreme outliers, reasonable spread + fairness_score = 100 + if std_dev > 3: + fairness_score -= min(30, std_dev * 5) + if len(outliers) > 0: + fairness_score -= len(outliers) * 10 + if spread > 10: + fairness_score -= min(20, spread) + fairness_score = max(0, fairness_score) + + # Warnings + warnings = [] + if spread > 12: + warnings.append("Sehr grosse Notenspreizung - bitte Extremwerte pruefen") + if std_dev > 4: + warnings.append("Hohe Streuung der Noten - moegliche Inkonsistenz") + if len(outliers) > 2: + warnings.append(f"{len(outliers)} Ausreisser erkannt - manuelle Pruefung empfohlen") + if avg_grade < 5: + warnings.append("Durchschnitt unter 5 Punkten - sind die Aufgaben angemessen?") + if avg_grade > 12: + warnings.append("Durchschnitt ueber 12 Punkten - Bewertungsmassstab pruefen") + + return { + "klausur_id": klausur_id, + "students_count": len(students), + "graded_count": len(grades), + "statistics": { + "average_grade": round(avg_grade, 2), + "average_raw_points": round(avg_raw, 2), + "min_grade": min_grade, + "max_grade": max_grade, + "spread": spread, + "standard_deviation": round(std_dev, 2) + }, + "criteria_breakdown": criteria_averages, + "outliers": outliers, + "fairness_score": round(fairness_score), + "warnings": warnings, + "recommendation": "Bewertung konsistent" if fairness_score >= 70 else "Pruefung empfohlen" + } + + +# ============================================= +# AUDIT LOG ENDPOINTS +# ============================================= + +@router.get("/api/v1/audit-log") +async def get_audit_log( + request: Request, + entity_type: Optional[str] = None, + entity_id: Optional[str] = None, + limit: int = 100 +): + """Get audit log entries.""" + user = get_current_user(request) + + # Only admins can see full audit log + if user.get("role") != "admin": + # Regular users only see their own actions + entries = [e for e in storage.audit_log_db if e.user_id == user["user_id"]] + else: + entries = storage.audit_log_db + + # Filter by entity + if entity_type: + entries = [e for e in entries if e.entity_type == entity_type] + if entity_id: + entries = [e for e in entries if e.entity_id == entity_id] + + # Sort by timestamp descending and limit + entries = sorted(entries, key=lambda e: e.timestamp, reverse=True)[:limit] + + return [e.to_dict() for e in entries] + + +@router.get("/api/v1/students/{student_id}/audit-log") +async def get_student_audit_log(student_id: str, request: Request): + """Get audit log for a specific student.""" + user = get_current_user(request) + + if student_id not in storage.students_db: + raise HTTPException(status_code=404, detail="Student work not found") + + entries = [e for e in storage.audit_log_db if e.entity_id == student_id] + entries = sorted(entries, key=lambda e: e.timestamp, reverse=True) + + return [e.to_dict() for e in entries] + + +# ============================================= +# UTILITY ENDPOINTS +# ============================================= + +@router.get("/api/v1/grade-info") +async def get_grade_info(): + """Get grade thresholds and labels.""" + return { + "thresholds": GRADE_THRESHOLDS, + "labels": GRADE_LABELS, + "criteria": DEFAULT_CRITERIA + } + + +# ============================================= +# SCHOOL CLASSES PROXY +# ============================================= + +@router.get("/api/school/classes") +async def get_school_classes(request: Request): + """Proxy to school-service or return demo data.""" + try: + auth_header = request.headers.get("Authorization", "") + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{SCHOOL_SERVICE_URL}/api/v1/school/classes", + headers={"Authorization": auth_header}, + timeout=5.0 + ) + if resp.status_code == 200: + return resp.json() + except Exception as e: + print(f"School service not available: {e}") + + # Return demo classes if school service unavailable + return [ + {"id": "demo-q1", "name": "Q1 Deutsch GK", "student_count": 24}, + {"id": "demo-q2", "name": "Q2 Deutsch LK", "student_count": 18}, + {"id": "demo-q3", "name": "Q3 Deutsch GK", "student_count": 22}, + {"id": "demo-q4", "name": "Q4 Deutsch LK", "student_count": 15} + ] + + +@router.get("/api/school/classes/{class_id}/students") +async def get_class_students(class_id: str, request: Request): + """Proxy to school-service or return demo data.""" + try: + auth_header = request.headers.get("Authorization", "") + async with httpx.AsyncClient() as client: + resp = await client.get( + f"{SCHOOL_SERVICE_URL}/api/v1/school/classes/{class_id}/students", + headers={"Authorization": auth_header}, + timeout=5.0 + ) + if resp.status_code == 200: + return resp.json() + except Exception as e: + print(f"School service not available: {e}") + + # Return demo students + demo_names = [ + "Anna Mueller", "Ben Schmidt", "Clara Weber", "David Fischer", + "Emma Schneider", "Felix Wagner", "Greta Becker", "Hans Hoffmann", + "Ida Schulz", "Jan Koch", "Katharina Bauer", "Leon Richter", + "Maria Braun", "Niklas Lange", "Olivia Wolf", "Paul Krause" + ] + return [ + {"id": f"student-{i}", "name": name, "class_id": class_id} + for i, name in enumerate(demo_names) + ] diff --git a/klausur-service/backend/routes/grading.py b/klausur-service/backend/routes/grading.py new file mode 100644 index 0000000..014462e --- /dev/null +++ b/klausur-service/backend/routes/grading.py @@ -0,0 +1,439 @@ +""" +Klausur-Service Grading Routes + +Endpoints for grading, Gutachten, and examiner workflow. +""" + +from datetime import datetime, timezone + +from fastapi import APIRouter, HTTPException, Request + +from models.enums import StudentKlausurStatus +from models.grading import GRADE_LABELS, DEFAULT_CRITERIA +from models.requests import ( + CriterionScoreUpdate, + GutachtenUpdate, + GutachtenGenerateRequest, + ExaminerAssignment, + ExaminerResult, +) +from services.auth_service import get_current_user +from services.grading_service import calculate_grade_points, calculate_raw_points +from services.eh_service import log_audit, log_eh_audit +from config import OPENAI_API_KEY +import storage + +# BYOEH imports (conditional) +try: + from eh_pipeline import decrypt_text, EncryptionError + from qdrant_service import search_eh + from eh_pipeline import generate_single_embedding + BYOEH_AVAILABLE = True +except ImportError: + BYOEH_AVAILABLE = False + +router = APIRouter() + + +@router.put("/api/v1/students/{student_id}/criteria") +async def update_criteria(student_id: str, data: CriterionScoreUpdate, request: Request): + """Update a criterion score for a student.""" + user = get_current_user(request) + + if student_id not in storage.students_db: + raise HTTPException(status_code=404, detail="Student work not found") + + student = storage.students_db[student_id] + + # Get old value for audit + old_score = student.criteria_scores.get(data.criterion, {}).get("score", 0) + + student.criteria_scores[data.criterion] = { + "score": data.score, + "annotations": data.annotations or [] + } + + # Recalculate points + student.raw_points = calculate_raw_points(student.criteria_scores) + student.grade_points = calculate_grade_points(student.raw_points) + + # Audit log + log_audit( + user_id=user["user_id"], + action="score_update", + entity_type="student", + entity_id=student_id, + field=data.criterion, + old_value=str(old_score), + new_value=str(data.score) + ) + + return student.to_dict() + + +@router.put("/api/v1/students/{student_id}/gutachten") +async def update_gutachten(student_id: str, data: GutachtenUpdate, request: Request): + """Update the Gutachten for a student.""" + user = get_current_user(request) + + if student_id not in storage.students_db: + raise HTTPException(status_code=404, detail="Student work not found") + + student = storage.students_db[student_id] + had_gutachten = student.gutachten is not None + + student.gutachten = { + "einleitung": data.einleitung, + "hauptteil": data.hauptteil, + "fazit": data.fazit, + "staerken": data.staerken or [], + "schwaechen": data.schwaechen or [], + "updated_at": datetime.now(timezone.utc).isoformat() + } + + # Audit log + log_audit( + user_id=user["user_id"], + action="gutachten_update", + entity_type="student", + entity_id=student_id, + field="gutachten", + old_value="existing" if had_gutachten else "none", + new_value="updated", + details={"sections": ["einleitung", "hauptteil", "fazit"]} + ) + + return student.to_dict() + + +@router.post("/api/v1/students/{student_id}/finalize") +async def finalize_student(student_id: str, request: Request): + """Finalize a student's grade.""" + user = get_current_user(request) + + if student_id not in storage.students_db: + raise HTTPException(status_code=404, detail="Student work not found") + + student = storage.students_db[student_id] + old_status = student.status.value if hasattr(student.status, 'value') else student.status + student.status = StudentKlausurStatus.COMPLETED + + # Audit log + log_audit( + user_id=user["user_id"], + action="status_change", + entity_type="student", + entity_id=student_id, + field="status", + old_value=old_status, + new_value="completed" + ) + + return student.to_dict() + + +@router.post("/api/v1/students/{student_id}/gutachten/generate") +async def generate_gutachten(student_id: str, data: GutachtenGenerateRequest, request: Request): + """ + Generate a KI-based Gutachten for a student's work. + + Optionally uses RAG context from the teacher's Erwartungshorizonte + if use_eh=True and eh_passphrase is provided. + """ + user = get_current_user(request) + + if student_id not in storage.students_db: + raise HTTPException(status_code=404, detail="Student work not found") + + student = storage.students_db[student_id] + klausur = storage.klausuren_db.get(student.klausur_id) + + if not klausur: + raise HTTPException(status_code=404, detail="Klausur not found") + + # BYOEH RAG Context (optional) + eh_context = "" + eh_sources = [] + if BYOEH_AVAILABLE and data.use_eh and data.eh_passphrase and OPENAI_API_KEY: + tenant_id = user.get("tenant_id") or user.get("school_id") or user["user_id"] + try: + # Use first part of student's OCR text as query + query_text = "" + if student.ocr_text: + query_text = student.ocr_text[:1000] + else: + query_text = f"Klausur {klausur.subject} {klausur.title}" + + # Generate query embedding + query_embedding = await generate_single_embedding(query_text) + + # Search in Qdrant (tenant-isolated) + results = await search_eh( + query_embedding=query_embedding, + tenant_id=tenant_id, + subject=klausur.subject, + limit=3 + ) + + # Decrypt matching chunks + for r in results: + eh = storage.eh_db.get(r["eh_id"]) + if eh and r.get("encrypted_content"): + try: + decrypted = decrypt_text( + r["encrypted_content"], + data.eh_passphrase, + eh.salt + ) + eh_sources.append({ + "text": decrypted, + "eh_title": eh.title, + "score": r["score"] + }) + except EncryptionError: + pass # Skip chunks that can't be decrypted + + if eh_sources: + eh_context = "\n\n--- Erwartungshorizont-Kontext ---\n" + eh_context += "\n\n".join([s["text"] for s in eh_sources]) + + # Audit log for RAG usage + log_eh_audit( + tenant_id=tenant_id, + user_id=user["user_id"], + action="rag_query_gutachten", + details={ + "student_id": student_id, + "sources_count": len(eh_sources) + } + ) + + except Exception as e: + print(f"BYOEH RAG failed: {e}") + # Continue without RAG context + + # Calculate overall percentage + total_percentage = calculate_raw_points(student.criteria_scores) + grade = calculate_grade_points(total_percentage) + grade_label = GRADE_LABELS.get(grade, "") + + # Analyze criteria for strengths/weaknesses + staerken = [] + schwaechen = [] + + for criterion, score_data in student.criteria_scores.items(): + score = score_data.get("score", 0) + label = DEFAULT_CRITERIA.get(criterion, {}).get("label", criterion) + + if score >= 80: + staerken.append(f"Sehr gute Leistung im Bereich {label} ({score}%)") + elif score >= 60: + staerken.append(f"Solide Leistung im Bereich {label} ({score}%)") + elif score < 40: + schwaechen.append(f"Verbesserungsbedarf im Bereich {label} ({score}%)") + elif score < 60: + schwaechen.append(f"Ausbaufaehiger Bereich: {label} ({score}%)") + + # Generate Gutachten text based on scores + if total_percentage >= 85: + einleitung = f"Die vorliegende Arbeit von {student.student_name} zeigt eine hervorragende Leistung." + hauptteil = f"""Die Arbeit ueberzeugt durch eine durchweg starke Bearbeitung der gestellten Aufgaben. +Die inhaltliche Auseinandersetzung mit dem Thema ist tiefgruendig und zeigt ein fundiertes Verstaendnis. +Die sprachliche Gestaltung ist praezise und dem Anlass angemessen. +Die Argumentation ist schluessig und wird durch treffende Beispiele gestuetzt.""" + fazit = f"Insgesamt eine sehr gelungene Arbeit, die mit {grade} Punkten ({grade_label}) bewertet wird." + + elif total_percentage >= 65: + einleitung = f"Die Arbeit von {student.student_name} zeigt eine insgesamt gute Leistung mit einzelnen Staerken." + hauptteil = f"""Die Bearbeitung der Aufgaben erfolgt weitgehend vollstaendig und korrekt. +Die inhaltliche Analyse zeigt ein solides Verstaendnis des Themas. +Die sprachliche Gestaltung ist ueberwiegend angemessen, mit kleineren Unsicherheiten. +Die Struktur der Arbeit ist nachvollziehbar.""" + fazit = f"Die Arbeit wird mit {grade} Punkten ({grade_label}) bewertet. Es bestehen Moeglichkeiten zur weiteren Verbesserung." + + elif total_percentage >= 45: + einleitung = f"Die Arbeit von {student.student_name} erfuellt die grundlegenden Anforderungen." + hauptteil = f"""Die Aufgabenstellung wird im Wesentlichen bearbeitet, jedoch bleiben einige Aspekte unterbeleuchtet. +Die inhaltliche Auseinandersetzung zeigt grundlegende Kenntnisse, bedarf aber weiterer Vertiefung. +Die sprachliche Gestaltung weist Unsicherheiten auf, die die Klarheit beeintraechtigen. +Die Struktur koennte stringenter sein.""" + fazit = f"Die Arbeit wird mit {grade} Punkten ({grade_label}) bewertet. Empfohlen wird eine intensivere Auseinandersetzung mit der Methodik." + + else: + einleitung = f"Die Arbeit von {student.student_name} weist erhebliche Defizite auf." + hauptteil = f"""Die Bearbeitung der Aufgaben bleibt unvollstaendig oder geht nicht ausreichend auf die Fragestellung ein. +Die inhaltliche Analyse zeigt Luecken im Verstaendnis des Themas. +Die sprachliche Gestaltung erschwert das Verstaendnis erheblich. +Die Arbeit laesst eine klare Struktur vermissen.""" + fazit = f"Die Arbeit wird mit {grade} Punkten ({grade_label}) bewertet. Dringend empfohlen wird eine grundlegende Wiederholung der Thematik." + + generated_gutachten = { + "einleitung": einleitung, + "hauptteil": hauptteil, + "fazit": fazit, + "staerken": staerken if data.include_strengths else [], + "schwaechen": schwaechen if data.include_weaknesses else [], + "generated_at": datetime.now(timezone.utc).isoformat(), + "is_ki_generated": True, + "tone": data.tone, + # BYOEH RAG Integration + "eh_context_used": len(eh_sources) > 0, + "eh_sources": [ + {"title": s["eh_title"], "score": s["score"]} + for s in eh_sources + ] if eh_sources else [] + } + + # Audit log + log_audit( + user_id=user["user_id"], + action="gutachten_generate", + entity_type="student", + entity_id=student_id, + details={ + "tone": data.tone, + "grade": grade, + "eh_context_used": len(eh_sources) > 0, + "eh_sources_count": len(eh_sources) + } + ) + + return generated_gutachten + + +# ============================================= +# EXAMINER WORKFLOW +# ============================================= + +@router.post("/api/v1/students/{student_id}/examiner") +async def assign_examiner(student_id: str, data: ExaminerAssignment, request: Request): + """Assign an examiner (first or second) to a student's work.""" + user = get_current_user(request) + + if student_id not in storage.students_db: + raise HTTPException(status_code=404, detail="Student work not found") + + student = storage.students_db[student_id] + + # Initialize examiner record if not exists + if student_id not in storage.examiner_db: + storage.examiner_db[student_id] = { + "first_examiner": None, + "second_examiner": None, + "first_result": None, + "second_result": None, + "consensus_reached": False, + "final_grade": None + } + + exam_record = storage.examiner_db[student_id] + + if data.examiner_role == "first_examiner": + exam_record["first_examiner"] = { + "id": data.examiner_id, + "assigned_at": datetime.now(timezone.utc).isoformat(), + "notes": data.notes + } + student.status = StudentKlausurStatus.FIRST_EXAMINER + elif data.examiner_role == "second_examiner": + exam_record["second_examiner"] = { + "id": data.examiner_id, + "assigned_at": datetime.now(timezone.utc).isoformat(), + "notes": data.notes + } + student.status = StudentKlausurStatus.SECOND_EXAMINER + else: + raise HTTPException(status_code=400, detail="Invalid examiner role") + + # Audit log + log_audit( + user_id=user["user_id"], + action="examiner_assign", + entity_type="student", + entity_id=student_id, + field=data.examiner_role, + new_value=data.examiner_id + ) + + return { + "success": True, + "student_id": student_id, + "examiner": exam_record + } + + +@router.post("/api/v1/students/{student_id}/examiner/result") +async def submit_examiner_result(student_id: str, data: ExaminerResult, request: Request): + """Submit an examiner's grading result.""" + user = get_current_user(request) + + if student_id not in storage.students_db: + raise HTTPException(status_code=404, detail="Student work not found") + + if student_id not in storage.examiner_db: + raise HTTPException(status_code=400, detail="No examiner assigned") + + exam_record = storage.examiner_db[student_id] + user_id = user["user_id"] + + # Determine which examiner is submitting + if exam_record.get("first_examiner", {}).get("id") == user_id: + exam_record["first_result"] = { + "grade_points": data.grade_points, + "notes": data.notes, + "submitted_at": datetime.now(timezone.utc).isoformat() + } + elif exam_record.get("second_examiner", {}).get("id") == user_id: + exam_record["second_result"] = { + "grade_points": data.grade_points, + "notes": data.notes, + "submitted_at": datetime.now(timezone.utc).isoformat() + } + else: + raise HTTPException(status_code=403, detail="You are not assigned as examiner") + + # Check if both results are in and calculate consensus + if exam_record["first_result"] and exam_record["second_result"]: + first_grade = exam_record["first_result"]["grade_points"] + second_grade = exam_record["second_result"]["grade_points"] + diff = abs(first_grade - second_grade) + + if diff <= 2: + # Automatic consensus: average + exam_record["final_grade"] = round((first_grade + second_grade) / 2) + exam_record["consensus_reached"] = True + storage.students_db[student_id].grade_points = exam_record["final_grade"] + storage.students_db[student_id].status = StudentKlausurStatus.COMPLETED + else: + # Needs discussion + exam_record["consensus_reached"] = False + exam_record["needs_discussion"] = True + + # Audit log + log_audit( + user_id=user_id, + action="examiner_result", + entity_type="student", + entity_id=student_id, + new_value=str(data.grade_points) + ) + + return exam_record + + +@router.get("/api/v1/students/{student_id}/examiner") +async def get_examiner_status(student_id: str, request: Request): + """Get the examiner assignment and results for a student.""" + user = get_current_user(request) + + if student_id not in storage.students_db: + raise HTTPException(status_code=404, detail="Student work not found") + + return storage.examiner_db.get(student_id, { + "first_examiner": None, + "second_examiner": None, + "first_result": None, + "second_result": None, + "consensus_reached": False, + "final_grade": None + }) diff --git a/klausur-service/backend/routes/students.py b/klausur-service/backend/routes/students.py new file mode 100644 index 0000000..1513dda --- /dev/null +++ b/klausur-service/backend/routes/students.py @@ -0,0 +1,131 @@ +""" +Klausur-Service Student Routes + +Endpoints for student work management. +""" + +import os +import uuid +from datetime import datetime, timezone + +from fastapi import APIRouter, HTTPException, Request, UploadFile, File, Form +from fastapi.responses import FileResponse + +from models.exam import StudentKlausur +from models.enums import StudentKlausurStatus +from services.auth_service import get_current_user +from config import UPLOAD_DIR +import storage + +router = APIRouter() + + +@router.post("/api/v1/klausuren/{klausur_id}/students") +async def upload_student_work( + klausur_id: str, + student_name: str = Form(...), + file: UploadFile = File(...), + request: Request = None +): + """Upload a student's work.""" + user = get_current_user(request) + + if klausur_id not in storage.klausuren_db: + raise HTTPException(status_code=404, detail="Klausur not found") + + klausur = storage.klausuren_db[klausur_id] + + # Save file + upload_dir = f"{UPLOAD_DIR}/{klausur_id}" + os.makedirs(upload_dir, exist_ok=True) + + file_ext = os.path.splitext(file.filename)[1] + file_id = str(uuid.uuid4()) + file_path = f"{upload_dir}/{file_id}{file_ext}" + + with open(file_path, "wb") as f: + content = await file.read() + f.write(content) + + # Create student record + student = StudentKlausur( + id=file_id, + klausur_id=klausur_id, + student_name=student_name, + student_id=None, + file_path=file_path, + ocr_text=None, + status=StudentKlausurStatus.UPLOADED, + criteria_scores={}, + gutachten=None, + raw_points=0, + grade_points=0, + created_at=datetime.now(timezone.utc) + ) + + storage.students_db[student.id] = student + klausur.students.append(student) + + return student.to_dict() + + +@router.get("/api/v1/klausuren/{klausur_id}/students") +async def list_students(klausur_id: str, request: Request): + """List all students for a Klausur.""" + user = get_current_user(request) + + if klausur_id not in storage.klausuren_db: + raise HTTPException(status_code=404, detail="Klausur not found") + + klausur = storage.klausuren_db[klausur_id] + return [s.to_dict() for s in klausur.students] + + +@router.get("/api/v1/students/{student_id}/file") +async def get_student_file(student_id: str, request: Request): + """Get the uploaded file for a student.""" + user = get_current_user(request) + + if student_id not in storage.students_db: + raise HTTPException(status_code=404, detail="Student work not found") + + student = storage.students_db[student_id] + + if not student.file_path or not os.path.exists(student.file_path): + raise HTTPException(status_code=404, detail="File not found") + + # Determine media type from file extension + ext = os.path.splitext(student.file_path)[1].lower() + media_types = { + '.pdf': 'application/pdf', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + } + media_type = media_types.get(ext, 'application/octet-stream') + + return FileResponse(student.file_path, media_type=media_type) + + +@router.delete("/api/v1/students/{student_id}") +async def delete_student_work(student_id: str, request: Request): + """Delete a student's work.""" + user = get_current_user(request) + + if student_id not in storage.students_db: + raise HTTPException(status_code=404, detail="Student work not found") + + student = storage.students_db[student_id] + + # Remove from klausur + if student.klausur_id in storage.klausuren_db: + klausur = storage.klausuren_db[student.klausur_id] + klausur.students = [s for s in klausur.students if s.id != student_id] + + # Delete file + if student.file_path and os.path.exists(student.file_path): + os.remove(student.file_path) + + del storage.students_db[student_id] + return {"success": True} diff --git a/klausur-service/backend/self_rag.py b/klausur-service/backend/self_rag.py new file mode 100644 index 0000000..61de02c --- /dev/null +++ b/klausur-service/backend/self_rag.py @@ -0,0 +1,529 @@ +""" +Self-RAG / Corrective RAG Module + +Implements self-reflective RAG that can: +1. Grade retrieved documents for relevance +2. Decide if more retrieval is needed +3. Reformulate queries if initial retrieval fails +4. Filter irrelevant passages before generation +5. Grade answers for groundedness and hallucination + +Based on research: +- Self-RAG (Asai et al., 2023): Learning to retrieve, generate, and critique +- Corrective RAG (Yan et al., 2024): Self-correcting retrieval augmented generation + +This is especially useful for German educational documents where: +- Queries may use informal language +- Documents use formal/technical terminology +- Context must be precisely matched to scoring criteria +""" + +import os +from typing import List, Dict, Optional, Tuple +from enum import Enum +import httpx + +# Configuration +# IMPORTANT: Self-RAG is DISABLED by default for privacy reasons! +# When enabled, search queries and retrieved documents are sent to OpenAI API +# for relevance grading and query reformulation. This exposes user data to third parties. +# Only enable if you have explicit user consent for data processing. +SELF_RAG_ENABLED = os.getenv("SELF_RAG_ENABLED", "false").lower() == "true" +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "") +SELF_RAG_MODEL = os.getenv("SELF_RAG_MODEL", "gpt-4o-mini") + +# Thresholds for self-reflection +RELEVANCE_THRESHOLD = float(os.getenv("SELF_RAG_RELEVANCE_THRESHOLD", "0.6")) +GROUNDING_THRESHOLD = float(os.getenv("SELF_RAG_GROUNDING_THRESHOLD", "0.7")) +MAX_RETRIEVAL_ATTEMPTS = int(os.getenv("SELF_RAG_MAX_ATTEMPTS", "2")) + + +class RetrievalDecision(Enum): + """Decision after grading retrieval.""" + SUFFICIENT = "sufficient" # Context is good, proceed to generation + NEEDS_MORE = "needs_more" # Need to retrieve more documents + REFORMULATE = "reformulate" # Query needs reformulation + FALLBACK = "fallback" # Use fallback (no good context found) + + +class SelfRAGError(Exception): + """Error during Self-RAG processing.""" + pass + + +async def grade_document_relevance( + query: str, + document: str, +) -> Tuple[float, str]: + """ + Grade whether a document is relevant to the query. + + Returns a score between 0 (irrelevant) and 1 (highly relevant) + along with an explanation. + """ + if not OPENAI_API_KEY: + # Fallback: simple keyword overlap + query_words = set(query.lower().split()) + doc_words = set(document.lower().split()) + overlap = len(query_words & doc_words) / max(len(query_words), 1) + return min(overlap * 2, 1.0), "Keyword-based relevance (no LLM)" + + prompt = f"""Bewerte, ob das folgende Dokument relevant für die Suchanfrage ist. + +SUCHANFRAGE: {query} + +DOKUMENT: +{document[:2000]} + +Ist dieses Dokument relevant, um die Anfrage zu beantworten? +Berücksichtige: +- Thematische Übereinstimmung +- Enthält das Dokument spezifische Informationen zur Anfrage? +- Würde dieses Dokument bei der Beantwortung helfen? + +Antworte im Format: +SCORE: [0.0-1.0] +BEGRÜNDUNG: [Kurze Erklärung]""" + + try: + async with httpx.AsyncClient() as client: + response = await client.post( + "https://api.openai.com/v1/chat/completions", + headers={ + "Authorization": f"Bearer {OPENAI_API_KEY}", + "Content-Type": "application/json" + }, + json={ + "model": SELF_RAG_MODEL, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 150, + "temperature": 0.0, + }, + timeout=30.0 + ) + + if response.status_code != 200: + return 0.5, f"API error: {response.status_code}" + + result = response.json()["choices"][0]["message"]["content"] + + import re + score_match = re.search(r'SCORE:\s*([\d.]+)', result) + score = float(score_match.group(1)) if score_match else 0.5 + + reason_match = re.search(r'BEGRÜNDUNG:\s*(.+)', result, re.DOTALL) + reason = reason_match.group(1).strip() if reason_match else result + + return min(max(score, 0.0), 1.0), reason + + except Exception as e: + return 0.5, f"Grading error: {str(e)}" + + +async def grade_documents_batch( + query: str, + documents: List[str], +) -> List[Tuple[float, str]]: + """ + Grade multiple documents for relevance. + + Returns list of (score, reason) tuples. + """ + results = [] + for doc in documents: + score, reason = await grade_document_relevance(query, doc) + results.append((score, reason)) + return results + + +async def filter_relevant_documents( + query: str, + documents: List[Dict], + threshold: float = RELEVANCE_THRESHOLD, +) -> Tuple[List[Dict], List[Dict]]: + """ + Filter documents by relevance, separating relevant from irrelevant. + + Args: + query: The search query + documents: List of document dicts with 'text' field + threshold: Minimum relevance score to keep + + Returns: + Tuple of (relevant_docs, filtered_out_docs) + """ + relevant = [] + filtered = [] + + for doc in documents: + text = doc.get("text", "") + score, reason = await grade_document_relevance(query, text) + + doc_with_grade = doc.copy() + doc_with_grade["relevance_score"] = score + doc_with_grade["relevance_reason"] = reason + + if score >= threshold: + relevant.append(doc_with_grade) + else: + filtered.append(doc_with_grade) + + # Sort relevant by score + relevant.sort(key=lambda x: x.get("relevance_score", 0), reverse=True) + + return relevant, filtered + + +async def decide_retrieval_strategy( + query: str, + documents: List[Dict], + attempt: int = 1, +) -> Tuple[RetrievalDecision, Dict]: + """ + Decide what to do based on current retrieval results. + + Args: + query: The search query + documents: Retrieved documents with relevance scores + attempt: Current retrieval attempt number + + Returns: + Tuple of (decision, metadata) + """ + if not documents: + if attempt >= MAX_RETRIEVAL_ATTEMPTS: + return RetrievalDecision.FALLBACK, {"reason": "No documents found after max attempts"} + return RetrievalDecision.REFORMULATE, {"reason": "No documents retrieved"} + + # Check average relevance + scores = [doc.get("relevance_score", 0.5) for doc in documents] + avg_score = sum(scores) / len(scores) + max_score = max(scores) + + if max_score >= RELEVANCE_THRESHOLD and avg_score >= RELEVANCE_THRESHOLD * 0.7: + return RetrievalDecision.SUFFICIENT, { + "avg_relevance": avg_score, + "max_relevance": max_score, + "doc_count": len(documents), + } + + if attempt >= MAX_RETRIEVAL_ATTEMPTS: + if max_score >= RELEVANCE_THRESHOLD * 0.5: + # At least some relevant context, proceed with caution + return RetrievalDecision.SUFFICIENT, { + "avg_relevance": avg_score, + "warning": "Low relevance after max attempts", + } + return RetrievalDecision.FALLBACK, {"reason": "Max attempts reached, low relevance"} + + if avg_score < 0.3: + return RetrievalDecision.REFORMULATE, { + "reason": "Very low relevance, query reformulation needed", + "avg_relevance": avg_score, + } + + return RetrievalDecision.NEEDS_MORE, { + "reason": "Moderate relevance, retrieving more documents", + "avg_relevance": avg_score, + } + + +async def reformulate_query( + original_query: str, + context: Optional[str] = None, + previous_results_summary: Optional[str] = None, +) -> str: + """ + Reformulate a query to improve retrieval. + + Uses LLM to generate a better query based on: + - Original query + - Optional context (subject, niveau, etc.) + - Summary of why previous retrieval failed + """ + if not OPENAI_API_KEY: + # Simple reformulation: expand abbreviations, add synonyms + reformulated = original_query + expansions = { + "EA": "erhöhtes Anforderungsniveau", + "eA": "erhöhtes Anforderungsniveau", + "GA": "grundlegendes Anforderungsniveau", + "gA": "grundlegendes Anforderungsniveau", + "AFB": "Anforderungsbereich", + "Abi": "Abitur", + } + for abbr, expansion in expansions.items(): + if abbr in original_query: + reformulated = reformulated.replace(abbr, f"{abbr} ({expansion})") + return reformulated + + prompt = f"""Du bist ein Experte für deutsche Bildungsstandards und Prüfungsanforderungen. + +Die folgende Suchanfrage hat keine guten Ergebnisse geliefert: +ORIGINAL: {original_query} + +{f"KONTEXT: {context}" if context else ""} +{f"PROBLEM MIT VORHERIGEN ERGEBNISSEN: {previous_results_summary}" if previous_results_summary else ""} + +Formuliere die Anfrage so um, dass sie: +1. Formellere/technischere Begriffe verwendet (wie in offiziellen Dokumenten) +2. Relevante Synonyme oder verwandte Begriffe einschließt +3. Spezifischer auf Erwartungshorizonte/Bewertungskriterien ausgerichtet ist + +Antworte NUR mit der umformulierten Suchanfrage, ohne Erklärung.""" + + try: + async with httpx.AsyncClient() as client: + response = await client.post( + "https://api.openai.com/v1/chat/completions", + headers={ + "Authorization": f"Bearer {OPENAI_API_KEY}", + "Content-Type": "application/json" + }, + json={ + "model": SELF_RAG_MODEL, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 100, + "temperature": 0.3, + }, + timeout=30.0 + ) + + if response.status_code != 200: + return original_query + + return response.json()["choices"][0]["message"]["content"].strip() + + except Exception: + return original_query + + +async def grade_answer_groundedness( + answer: str, + contexts: List[str], +) -> Tuple[float, List[str]]: + """ + Grade whether an answer is grounded in the provided contexts. + + Returns: + Tuple of (grounding_score, list of unsupported claims) + """ + if not OPENAI_API_KEY: + return 0.5, ["LLM not configured for grounding check"] + + context_text = "\n---\n".join(contexts[:5]) + + prompt = f"""Analysiere, ob die folgende Antwort vollständig durch die Kontexte gestützt wird. + +KONTEXTE: +{context_text} + +ANTWORT: +{answer} + +Identifiziere: +1. Welche Aussagen sind durch die Kontexte belegt? +2. Welche Aussagen sind NICHT belegt (potenzielle Halluzinationen)? + +Antworte im Format: +SCORE: [0.0-1.0] (1.0 = vollständig belegt) +NICHT_BELEGT: [Liste der nicht belegten Aussagen, eine pro Zeile, oder "Keine"]""" + + try: + async with httpx.AsyncClient() as client: + response = await client.post( + "https://api.openai.com/v1/chat/completions", + headers={ + "Authorization": f"Bearer {OPENAI_API_KEY}", + "Content-Type": "application/json" + }, + json={ + "model": SELF_RAG_MODEL, + "messages": [{"role": "user", "content": prompt}], + "max_tokens": 300, + "temperature": 0.0, + }, + timeout=30.0 + ) + + if response.status_code != 200: + return 0.5, [f"API error: {response.status_code}"] + + result = response.json()["choices"][0]["message"]["content"] + + import re + score_match = re.search(r'SCORE:\s*([\d.]+)', result) + score = float(score_match.group(1)) if score_match else 0.5 + + unsupported_match = re.search(r'NICHT_BELEGT:\s*(.+)', result, re.DOTALL) + unsupported_text = unsupported_match.group(1).strip() if unsupported_match else "" + + if unsupported_text.lower() == "keine": + unsupported = [] + else: + unsupported = [line.strip() for line in unsupported_text.split("\n") if line.strip()] + + return min(max(score, 0.0), 1.0), unsupported + + except Exception as e: + return 0.5, [f"Grounding check error: {str(e)}"] + + +async def self_rag_retrieve( + query: str, + search_func, + subject: Optional[str] = None, + niveau: Optional[str] = None, + initial_top_k: int = 10, + final_top_k: int = 5, + **search_kwargs +) -> Dict: + """ + Perform Self-RAG enhanced retrieval with reflection and correction. + + This implements a retrieval loop that: + 1. Retrieves initial documents + 2. Grades them for relevance + 3. Decides if more retrieval is needed + 4. Reformulates query if necessary + 5. Returns filtered, high-quality context + + Args: + query: The search query + search_func: Async function to perform the actual search + subject: Optional subject context + niveau: Optional niveau context + initial_top_k: Number of documents for initial retrieval + final_top_k: Maximum documents to return + **search_kwargs: Additional args for search_func + + Returns: + Dict with results, metadata, and reflection trace + """ + if not SELF_RAG_ENABLED: + # Fall back to simple search + results = await search_func(query=query, limit=final_top_k, **search_kwargs) + return { + "results": results, + "self_rag_enabled": False, + "query_used": query, + } + + trace = [] + current_query = query + attempt = 1 + + while attempt <= MAX_RETRIEVAL_ATTEMPTS: + # Step 1: Retrieve documents + results = await search_func(query=current_query, limit=initial_top_k, **search_kwargs) + + trace.append({ + "attempt": attempt, + "query": current_query, + "retrieved_count": len(results) if results else 0, + }) + + if not results: + attempt += 1 + if attempt <= MAX_RETRIEVAL_ATTEMPTS: + current_query = await reformulate_query( + query, + context=f"Fach: {subject}" if subject else None, + previous_results_summary="Keine Dokumente gefunden" + ) + trace[-1]["action"] = "reformulate" + trace[-1]["new_query"] = current_query + continue + + # Step 2: Grade documents for relevance + relevant, filtered = await filter_relevant_documents(current_query, results) + + trace[-1]["relevant_count"] = len(relevant) + trace[-1]["filtered_count"] = len(filtered) + + # Step 3: Decide what to do + decision, decision_meta = await decide_retrieval_strategy( + current_query, relevant, attempt + ) + + trace[-1]["decision"] = decision.value + trace[-1]["decision_meta"] = decision_meta + + if decision == RetrievalDecision.SUFFICIENT: + # We have good context, return it + return { + "results": relevant[:final_top_k], + "self_rag_enabled": True, + "query_used": current_query, + "original_query": query if current_query != query else None, + "attempts": attempt, + "decision": decision.value, + "trace": trace, + "filtered_out_count": len(filtered), + } + + elif decision == RetrievalDecision.REFORMULATE: + # Reformulate and try again + avg_score = decision_meta.get("avg_relevance", 0) + current_query = await reformulate_query( + query, + context=f"Fach: {subject}" if subject else None, + previous_results_summary=f"Durchschnittliche Relevanz: {avg_score:.2f}" + ) + trace[-1]["action"] = "reformulate" + trace[-1]["new_query"] = current_query + + elif decision == RetrievalDecision.NEEDS_MORE: + # Retrieve more with expanded query + current_query = f"{current_query} Bewertungskriterien Anforderungen" + trace[-1]["action"] = "expand_query" + trace[-1]["new_query"] = current_query + + elif decision == RetrievalDecision.FALLBACK: + # Return what we have, even if not ideal + return { + "results": (relevant or results)[:final_top_k], + "self_rag_enabled": True, + "query_used": current_query, + "original_query": query if current_query != query else None, + "attempts": attempt, + "decision": decision.value, + "warning": "Fallback mode - low relevance context", + "trace": trace, + } + + attempt += 1 + + # Max attempts reached + return { + "results": results[:final_top_k] if results else [], + "self_rag_enabled": True, + "query_used": current_query, + "original_query": query if current_query != query else None, + "attempts": attempt - 1, + "decision": "max_attempts", + "warning": "Max retrieval attempts reached", + "trace": trace, + } + + +def get_self_rag_info() -> dict: + """Get information about Self-RAG configuration.""" + return { + "enabled": SELF_RAG_ENABLED, + "llm_configured": bool(OPENAI_API_KEY), + "model": SELF_RAG_MODEL, + "relevance_threshold": RELEVANCE_THRESHOLD, + "grounding_threshold": GROUNDING_THRESHOLD, + "max_retrieval_attempts": MAX_RETRIEVAL_ATTEMPTS, + "features": [ + "document_grading", + "relevance_filtering", + "query_reformulation", + "answer_grounding_check", + "retrieval_decision", + ], + "sends_data_externally": True, # ALWAYS true when enabled - documents sent to OpenAI + "privacy_warning": "When enabled, queries and documents are sent to OpenAI API for grading", + "default_enabled": False, # Disabled by default for privacy + } diff --git a/klausur-service/backend/services/__init__.py b/klausur-service/backend/services/__init__.py new file mode 100644 index 0000000..0c4b51c --- /dev/null +++ b/klausur-service/backend/services/__init__.py @@ -0,0 +1,80 @@ +# Klausur-Service Services Package + +# Grading Services +from .grading_service import ( + calculate_grade_points, + calculate_raw_points, +) + +# Authentication Services +from .auth_service import ( + get_current_user, +) + +# EH Audit Services +from .eh_service import ( + log_audit, + log_eh_audit, +) + +# OCR Services - Lazy imports (require PIL/cv2 which may not be installed) +# These are imported on-demand when actually used +def __getattr__(name): + """Lazy import for optional image processing modules.""" + _handwriting_exports = { + 'detect_handwriting', 'detect_handwriting_regions', + 'mask_to_png', 'DetectionResult' + } + _inpainting_exports = { + 'inpaint_image', 'inpaint_opencv_telea', 'inpaint_opencv_ns', + 'remove_handwriting', 'check_lama_available', + 'InpaintingMethod', 'InpaintingResult' + } + _layout_exports = { + 'reconstruct_layout', 'layout_to_fabric_json', 'reconstruct_and_clean', + 'LayoutResult', 'TextElement', 'ElementType' + } + + if name in _handwriting_exports: + from . import handwriting_detection + return getattr(handwriting_detection, name) + elif name in _inpainting_exports: + from . import inpainting_service + return getattr(inpainting_service, name) + elif name in _layout_exports: + from . import layout_reconstruction_service + return getattr(layout_reconstruction_service, name) + + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = [ + # Grading + 'calculate_grade_points', + 'calculate_raw_points', + # Authentication + 'get_current_user', + # Audit + 'log_audit', + 'log_eh_audit', + # Handwriting Detection (lazy) + 'detect_handwriting', + 'detect_handwriting_regions', + 'mask_to_png', + 'DetectionResult', + # Inpainting (lazy) + 'inpaint_image', + 'inpaint_opencv_telea', + 'inpaint_opencv_ns', + 'remove_handwriting', + 'check_lama_available', + 'InpaintingMethod', + 'InpaintingResult', + # Layout Reconstruction (lazy) + 'reconstruct_layout', + 'layout_to_fabric_json', + 'reconstruct_and_clean', + 'LayoutResult', + 'TextElement', + 'ElementType', +] diff --git a/klausur-service/backend/services/auth_service.py b/klausur-service/backend/services/auth_service.py new file mode 100644 index 0000000..fc4c9e5 --- /dev/null +++ b/klausur-service/backend/services/auth_service.py @@ -0,0 +1,46 @@ +""" +Klausur-Service Authentication Service + +Functions for JWT authentication and user extraction. +""" + +from typing import Dict +import jwt + +from fastapi import HTTPException, Request + +from config import JWT_SECRET, ENVIRONMENT + + +def get_current_user(request: Request) -> Dict: + """ + Extract user from JWT token. + + Args: + request: FastAPI Request object + + Returns: + User payload dict containing user_id, role, email, etc. + + Raises: + HTTPException: If token is missing, expired, or invalid + """ + auth_header = request.headers.get("Authorization", "") + + if not auth_header.startswith("Bearer "): + if ENVIRONMENT == "development": + return { + "user_id": "demo-teacher", + "role": "admin", + "email": "demo@breakpilot.app" + } + raise HTTPException(status_code=401, detail="Missing authorization header") + + token = auth_header.replace("Bearer ", "") + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"]) + return payload + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=401, detail="Token expired") + except jwt.InvalidTokenError: + raise HTTPException(status_code=401, detail="Invalid token") diff --git a/klausur-service/backend/services/donut_ocr_service.py b/klausur-service/backend/services/donut_ocr_service.py new file mode 100644 index 0000000..24b18ed --- /dev/null +++ b/klausur-service/backend/services/donut_ocr_service.py @@ -0,0 +1,254 @@ +""" +Donut OCR Service + +Document Understanding Transformer (Donut) fuer strukturierte Dokumentenverarbeitung. +Besonders geeignet fuer: +- Tabellen +- Formulare +- Strukturierte Dokumente +- Rechnungen/Quittungen + +Model: naver-clova-ix/donut-base (oder fine-tuned Variante) + +DATENSCHUTZ: Alle Verarbeitung erfolgt lokal. +""" + +import io +import logging +from typing import Tuple, Optional + +logger = logging.getLogger(__name__) + +# Lazy loading for heavy dependencies +_donut_processor = None +_donut_model = None +_donut_available = None + + +def _check_donut_available() -> bool: + """Check if Donut dependencies are available.""" + global _donut_available + if _donut_available is not None: + return _donut_available + + try: + import torch + from transformers import DonutProcessor, VisionEncoderDecoderModel + _donut_available = True + except ImportError as e: + logger.warning(f"Donut dependencies not available: {e}") + _donut_available = False + + return _donut_available + + +def get_donut_model(): + """ + Lazy load Donut model and processor. + + Returns tuple of (processor, model) or (None, None) if unavailable. + """ + global _donut_processor, _donut_model + + if not _check_donut_available(): + return None, None + + if _donut_processor is None or _donut_model is None: + try: + import torch + from transformers import DonutProcessor, VisionEncoderDecoderModel + + model_name = "naver-clova-ix/donut-base" + + logger.info(f"Loading Donut model: {model_name}") + _donut_processor = DonutProcessor.from_pretrained(model_name) + _donut_model = VisionEncoderDecoderModel.from_pretrained(model_name) + + # Use GPU if available + device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu" + _donut_model.to(device) + logger.info(f"Donut model loaded on device: {device}") + + except Exception as e: + logger.error(f"Failed to load Donut model: {e}") + return None, None + + return _donut_processor, _donut_model + + +async def run_donut_ocr(image_data: bytes, task_prompt: str = "") -> Tuple[Optional[str], float]: + """ + Run Donut OCR on an image. + + Donut is an end-to-end document understanding model that can: + - Extract text + - Understand document structure + - Parse forms and tables + + Args: + image_data: Raw image bytes + task_prompt: Donut task prompt (default: CORD for receipts/documents) + - "": Receipt/document parsing + - "": Document Visual QA + - "": Synthetic document generation + + Returns: + Tuple of (extracted_text, confidence) + """ + processor, model = get_donut_model() + + if processor is None or model is None: + logger.error("Donut model not available") + return None, 0.0 + + try: + import torch + from PIL import Image + + # Load image + image = Image.open(io.BytesIO(image_data)).convert("RGB") + + # Prepare input + pixel_values = processor(image, return_tensors="pt").pixel_values + + # Move to same device as model + device = next(model.parameters()).device + pixel_values = pixel_values.to(device) + + # Prepare decoder input + task_prompt_ids = processor.tokenizer( + task_prompt, + add_special_tokens=False, + return_tensors="pt" + ).input_ids.to(device) + + # Generate + with torch.no_grad(): + outputs = model.generate( + pixel_values, + decoder_input_ids=task_prompt_ids, + max_length=model.config.decoder.max_position_embeddings, + early_stopping=True, + pad_token_id=processor.tokenizer.pad_token_id, + eos_token_id=processor.tokenizer.eos_token_id, + use_cache=True, + num_beams=1, + bad_words_ids=[[processor.tokenizer.unk_token_id]], + return_dict_in_generate=True, + ) + + # Decode output + sequence = processor.batch_decode(outputs.sequences)[0] + + # Remove task prompt and special tokens + sequence = sequence.replace(task_prompt, "").replace( + processor.tokenizer.eos_token, "").replace( + processor.tokenizer.pad_token, "") + + # Parse the output (Donut outputs JSON-like structure) + text = _parse_donut_output(sequence) + + # Calculate confidence (rough estimate based on output quality) + confidence = 0.8 if text and len(text) > 10 else 0.5 + + logger.info(f"Donut OCR extracted {len(text)} characters") + return text, confidence + + except Exception as e: + logger.error(f"Donut OCR failed: {e}") + import traceback + logger.error(traceback.format_exc()) + return None, 0.0 + + +def _parse_donut_output(sequence: str) -> str: + """ + Parse Donut output into plain text. + + Donut outputs structured data (JSON-like), we extract readable text. + """ + import re + import json + + # Clean up the sequence + sequence = sequence.strip() + + # Try to parse as JSON + try: + # Find JSON-like content + json_match = re.search(r'\{.*\}', sequence, re.DOTALL) + if json_match: + data = json.loads(json_match.group()) + # Extract text from various possible fields + text_parts = [] + _extract_text_recursive(data, text_parts) + return "\n".join(text_parts) + except json.JSONDecodeError: + pass + + # Fallback: extract text between tags + text_parts = [] + # Match patterns like value or value + pattern = r'<[^>]+>([^<]+)]+>' + matches = re.findall(pattern, sequence) + if matches: + text_parts.extend(matches) + return "\n".join(text_parts) + + # Last resort: return cleaned sequence + # Remove XML-like tags + clean = re.sub(r'<[^>]+>', ' ', sequence) + clean = re.sub(r'\s+', ' ', clean).strip() + return clean + + +def _extract_text_recursive(data, text_parts: list, indent: int = 0): + """Recursively extract text from nested data structure.""" + if isinstance(data, dict): + for key, value in data.items(): + if isinstance(value, str) and value.strip(): + # Skip keys that look like metadata + if not key.startswith('_'): + text_parts.append(f"{value.strip()}") + elif isinstance(value, (dict, list)): + _extract_text_recursive(value, text_parts, indent + 1) + elif isinstance(data, list): + for item in data: + _extract_text_recursive(item, text_parts, indent) + elif isinstance(data, str) and data.strip(): + text_parts.append(data.strip()) + + +# Alternative: Simple text extraction mode +async def run_donut_ocr_simple(image_data: bytes) -> Tuple[Optional[str], float]: + """ + Simplified Donut OCR that just extracts text without structured parsing. + + Uses a more general task prompt for plain text extraction. + """ + return await run_donut_ocr(image_data, task_prompt="") + + +# Test function +async def test_donut_ocr(image_path: str): + """Test Donut OCR on a local image file.""" + with open(image_path, "rb") as f: + image_data = f.read() + + text, confidence = await run_donut_ocr(image_data) + + print(f"\n=== Donut OCR Test ===") + print(f"Confidence: {confidence:.2f}") + print(f"Text:\n{text}") + + return text, confidence + + +if __name__ == "__main__": + import asyncio + import sys + + if len(sys.argv) > 1: + asyncio.run(test_donut_ocr(sys.argv[1])) + else: + print("Usage: python donut_ocr_service.py ") diff --git a/klausur-service/backend/services/eh_service.py b/klausur-service/backend/services/eh_service.py new file mode 100644 index 0000000..3736d30 --- /dev/null +++ b/klausur-service/backend/services/eh_service.py @@ -0,0 +1,97 @@ +""" +Klausur-Service EH Service + +Functions for audit logging and EH-related utilities. +""" + +import uuid +from datetime import datetime, timezone +from typing import Dict, Optional + +from models.grading import AuditLogEntry +from models.eh import EHAuditLogEntry + +# Import storage - will be initialized by main.py +# These imports need to reference the actual storage module +import storage + + +def log_audit( + user_id: str, + action: str, + entity_type: str, + entity_id: str, + field: str = None, + old_value: str = None, + new_value: str = None, + details: Dict = None +) -> AuditLogEntry: + """ + Add an entry to the general audit log. + + Args: + user_id: ID of the user performing the action + action: Type of action (score_update, gutachten_update, etc.) + entity_type: Type of entity (klausur, student) + entity_id: ID of the entity + field: Optional field name that was changed + old_value: Optional old value + new_value: Optional new value + details: Optional additional details + + Returns: + The created AuditLogEntry + """ + entry = AuditLogEntry( + id=str(uuid.uuid4()), + timestamp=datetime.now(timezone.utc), + user_id=user_id, + action=action, + entity_type=entity_type, + entity_id=entity_id, + field=field, + old_value=old_value, + new_value=new_value, + details=details + ) + storage.audit_log_db.append(entry) + return entry + + +def log_eh_audit( + tenant_id: str, + user_id: str, + action: str, + eh_id: str = None, + details: Dict = None, + ip_address: str = None, + user_agent: str = None +) -> EHAuditLogEntry: + """ + Add an entry to the EH audit log. + + Args: + tenant_id: Tenant ID + user_id: ID of the user performing the action + action: Type of action (upload, index, rag_query, etc.) + eh_id: Optional EH ID + details: Optional additional details + ip_address: Optional client IP address + user_agent: Optional client user agent + + Returns: + The created EHAuditLogEntry + """ + entry = EHAuditLogEntry( + id=str(uuid.uuid4()), + eh_id=eh_id, + tenant_id=tenant_id, + user_id=user_id, + action=action, + details=details, + ip_address=ip_address, + user_agent=user_agent, + created_at=datetime.now(timezone.utc) + ) + storage.eh_audit_db.append(entry) + return entry diff --git a/klausur-service/backend/services/grading_service.py b/klausur-service/backend/services/grading_service.py new file mode 100644 index 0000000..1630544 --- /dev/null +++ b/klausur-service/backend/services/grading_service.py @@ -0,0 +1,43 @@ +""" +Klausur-Service Grading Service + +Functions for grade calculation. +""" + +from typing import Dict + +from models.grading import GRADE_THRESHOLDS, DEFAULT_CRITERIA + + +def calculate_grade_points(percentage: float) -> int: + """ + Calculate 15-point grade from percentage. + + Args: + percentage: Score as percentage (0-100) + + Returns: + Grade points (0-15) + """ + for points, threshold in sorted(GRADE_THRESHOLDS.items(), reverse=True): + if percentage >= threshold: + return points + return 0 + + +def calculate_raw_points(criteria_scores: Dict[str, Dict]) -> int: + """ + Calculate weighted raw points from criteria scores. + + Args: + criteria_scores: Dict mapping criterion name to score data + + Returns: + Weighted raw points + """ + total = 0.0 + for criterion, data in criteria_scores.items(): + weight = DEFAULT_CRITERIA.get(criterion, {}).get("weight", 0.2) + score = data.get("score", 0) + total += score * weight + return int(total) diff --git a/klausur-service/backend/services/handwriting_detection.py b/klausur-service/backend/services/handwriting_detection.py new file mode 100644 index 0000000..081f177 --- /dev/null +++ b/klausur-service/backend/services/handwriting_detection.py @@ -0,0 +1,359 @@ +""" +Handwriting Detection Service for Worksheet Cleanup + +Detects handwritten content in scanned worksheets and returns binary masks. +Uses multiple detection methods: +1. Color-based detection (blue/red ink) +2. Stroke analysis (thin irregular strokes) +3. Edge density variance + +DATENSCHUTZ: All processing happens locally on Mac Mini. +""" + +import numpy as np +from PIL import Image +import io +import logging +from typing import Tuple, Optional +from dataclasses import dataclass + +# OpenCV is optional - only required for actual handwriting detection +try: + import cv2 + CV2_AVAILABLE = True +except ImportError: + cv2 = None + CV2_AVAILABLE = False + +logger = logging.getLogger(__name__) + + +@dataclass +class DetectionResult: + """Result of handwriting detection.""" + mask: np.ndarray # Binary mask (255 = handwriting, 0 = background/printed) + confidence: float # Overall confidence score + handwriting_ratio: float # Ratio of handwriting pixels to total + detection_method: str # Which method was primarily used + + +def detect_handwriting(image_bytes: bytes) -> DetectionResult: + """ + Detect handwriting in an image. + + Args: + image_bytes: Image as bytes (PNG, JPG, etc.) + + Returns: + DetectionResult with binary mask where handwriting is white (255) + + Raises: + ImportError: If OpenCV is not available + """ + if not CV2_AVAILABLE: + raise ImportError( + "OpenCV (cv2) is required for handwriting detection. " + "Install with: pip install opencv-python-headless" + ) + + # Load image + img = Image.open(io.BytesIO(image_bytes)) + img_array = np.array(img) + + # Convert to BGR if needed (OpenCV format) + if len(img_array.shape) == 2: + # Grayscale to BGR + img_bgr = cv2.cvtColor(img_array, cv2.COLOR_GRAY2BGR) + elif img_array.shape[2] == 4: + # RGBA to BGR + img_bgr = cv2.cvtColor(img_array, cv2.COLOR_RGBA2BGR) + elif img_array.shape[2] == 3: + # RGB to BGR + img_bgr = cv2.cvtColor(img_array, cv2.COLOR_RGB2BGR) + else: + img_bgr = img_array + + # Run multiple detection methods + color_mask, color_confidence = _detect_by_color(img_bgr) + stroke_mask, stroke_confidence = _detect_by_stroke_analysis(img_bgr) + variance_mask, variance_confidence = _detect_by_variance(img_bgr) + + # Combine masks using weighted average + weights = [color_confidence, stroke_confidence, variance_confidence] + total_weight = sum(weights) + + if total_weight > 0: + # Weighted combination + combined_mask = ( + color_mask.astype(np.float32) * color_confidence + + stroke_mask.astype(np.float32) * stroke_confidence + + variance_mask.astype(np.float32) * variance_confidence + ) / total_weight + + # Threshold to binary + combined_mask = (combined_mask > 127).astype(np.uint8) * 255 + else: + combined_mask = np.zeros(img_bgr.shape[:2], dtype=np.uint8) + + # Post-processing: Remove small noise + combined_mask = _clean_mask(combined_mask) + + # Calculate metrics + total_pixels = combined_mask.size + handwriting_pixels = np.sum(combined_mask > 0) + handwriting_ratio = handwriting_pixels / total_pixels if total_pixels > 0 else 0 + + # Determine primary method + primary_method = "combined" + max_conf = max(color_confidence, stroke_confidence, variance_confidence) + if max_conf == color_confidence: + primary_method = "color" + elif max_conf == stroke_confidence: + primary_method = "stroke" + else: + primary_method = "variance" + + overall_confidence = total_weight / 3.0 # Average confidence + + logger.info(f"Handwriting detection: {handwriting_ratio:.2%} handwriting, " + f"confidence={overall_confidence:.2f}, method={primary_method}") + + return DetectionResult( + mask=combined_mask, + confidence=overall_confidence, + handwriting_ratio=handwriting_ratio, + detection_method=primary_method + ) + + +def _detect_by_color(img_bgr: np.ndarray) -> Tuple[np.ndarray, float]: + """ + Detect handwriting by ink color (blue, red, black pen). + + Blue and red ink are common for corrections and handwriting. + Black pen has different characteristics than printed black. + """ + # Convert to HSV for color detection + hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) + + # Blue ink detection (Hue: 100-130, Saturation: 50-255, Value: 30-200) + blue_lower = np.array([100, 50, 30]) + blue_upper = np.array([130, 255, 200]) + blue_mask = cv2.inRange(hsv, blue_lower, blue_upper) + + # Red ink detection (Hue: 0-10 and 170-180) + red_lower1 = np.array([0, 50, 50]) + red_upper1 = np.array([10, 255, 255]) + red_mask1 = cv2.inRange(hsv, red_lower1, red_upper1) + + red_lower2 = np.array([170, 50, 50]) + red_upper2 = np.array([180, 255, 255]) + red_mask2 = cv2.inRange(hsv, red_lower2, red_upper2) + red_mask = cv2.bitwise_or(red_mask1, red_mask2) + + # Green ink (less common but sometimes used) + green_lower = np.array([35, 50, 50]) + green_upper = np.array([85, 255, 200]) + green_mask = cv2.inRange(hsv, green_lower, green_upper) + + # Combine colored ink masks + color_mask = cv2.bitwise_or(blue_mask, red_mask) + color_mask = cv2.bitwise_or(color_mask, green_mask) + + # Dilate to connect nearby regions + kernel = np.ones((3, 3), np.uint8) + color_mask = cv2.dilate(color_mask, kernel, iterations=1) + + # Calculate confidence based on detected pixels + total_pixels = color_mask.size + colored_pixels = np.sum(color_mask > 0) + ratio = colored_pixels / total_pixels if total_pixels > 0 else 0 + + # High confidence if we found significant colored ink (1-20% of image) + if 0.005 < ratio < 0.3: + confidence = 0.9 + elif ratio > 0: + confidence = 0.5 + else: + confidence = 0.1 + + return color_mask, confidence + + +def _detect_by_stroke_analysis(img_bgr: np.ndarray) -> Tuple[np.ndarray, float]: + """ + Detect handwriting by analyzing stroke characteristics. + + Handwriting typically has: + - Thinner, more variable stroke widths + - More curved lines + - Connected components + """ + # Convert to grayscale + gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) + + # Adaptive thresholding to extract text + binary = cv2.adaptiveThreshold( + gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, + cv2.THRESH_BINARY_INV, 11, 2 + ) + + # Find edges (handwriting has more irregular edges) + edges = cv2.Canny(gray, 50, 150) + + # Morphological gradient for stroke detection + kernel = np.ones((2, 2), np.uint8) + gradient = cv2.morphologyEx(binary, cv2.MORPH_GRADIENT, kernel) + + # Skeleton to analyze stroke width + # Thin strokes (handwriting) will have more skeleton pixels relative to mass + skeleton = _skeletonize(binary) + + # Detect thin strokes by comparing skeleton to original + # Dilate skeleton and XOR with original to find thick regions (printed) + dilated_skeleton = cv2.dilate(skeleton, np.ones((5, 5), np.uint8), iterations=1) + thick_regions = cv2.bitwise_and(binary, cv2.bitwise_not(dilated_skeleton)) + thin_regions = cv2.bitwise_and(binary, dilated_skeleton) + + # Handwriting tends to be in thin regions with irregular edges + handwriting_mask = thin_regions + + # Calculate confidence + total_ink = np.sum(binary > 0) + thin_ink = np.sum(thin_regions > 0) + + if total_ink > 0: + thin_ratio = thin_ink / total_ink + confidence = min(thin_ratio * 1.5, 0.8) # Cap at 0.8 + else: + confidence = 0.1 + + return handwriting_mask, confidence + + +def _detect_by_variance(img_bgr: np.ndarray) -> Tuple[np.ndarray, float]: + """ + Detect handwriting by local variance analysis. + + Handwriting has higher local variance in stroke direction and width + compared to uniform printed text. + """ + gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) + + # Calculate local variance using a sliding window + kernel_size = 15 + mean = cv2.blur(gray.astype(np.float32), (kernel_size, kernel_size)) + sqr_mean = cv2.blur((gray.astype(np.float32))**2, (kernel_size, kernel_size)) + variance = sqr_mean - mean**2 + + # Normalize variance + variance = cv2.normalize(variance, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8) + + # High variance regions might be handwriting + # But also edges of printed text, so we need to filter + + # Get text regions first + binary = cv2.adaptiveThreshold( + gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, + cv2.THRESH_BINARY_INV, 11, 2 + ) + + # High variance within text regions + high_variance_mask = cv2.threshold(variance, 100, 255, cv2.THRESH_BINARY)[1] + handwriting_mask = cv2.bitwise_and(high_variance_mask, binary) + + # Calculate confidence based on variance distribution + text_pixels = np.sum(binary > 0) + high_var_pixels = np.sum(handwriting_mask > 0) + + if text_pixels > 0: + var_ratio = high_var_pixels / text_pixels + # If 5-40% of text has high variance, likely handwriting present + if 0.05 < var_ratio < 0.5: + confidence = 0.7 + else: + confidence = 0.3 + else: + confidence = 0.1 + + return handwriting_mask, confidence + + +def _skeletonize(binary: np.ndarray) -> np.ndarray: + """ + Morphological skeletonization. + """ + skeleton = np.zeros(binary.shape, np.uint8) + element = cv2.getStructuringElement(cv2.MORPH_CROSS, (3, 3)) + + img = binary.copy() + while True: + eroded = cv2.erode(img, element) + temp = cv2.dilate(eroded, element) + temp = cv2.subtract(img, temp) + skeleton = cv2.bitwise_or(skeleton, temp) + img = eroded.copy() + + if cv2.countNonZero(img) == 0: + break + + return skeleton + + +def _clean_mask(mask: np.ndarray, min_area: int = 50) -> np.ndarray: + """ + Clean up the mask by removing small noise regions. + """ + # Find connected components + num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats( + mask, connectivity=8 + ) + + # Create clean mask keeping only components above minimum area + clean = np.zeros_like(mask) + for i in range(1, num_labels): # Skip background (label 0) + area = stats[i, cv2.CC_STAT_AREA] + if area >= min_area: + clean[labels == i] = 255 + + return clean + + +def mask_to_png(mask: np.ndarray) -> bytes: + """ + Convert a mask to PNG bytes. + """ + img = Image.fromarray(mask) + buffer = io.BytesIO() + img.save(buffer, format='PNG') + return buffer.getvalue() + + +def detect_handwriting_regions( + image_bytes: bytes, + min_confidence: float = 0.3 +) -> dict: + """ + High-level function that returns structured detection results. + + Args: + image_bytes: Input image + min_confidence: Minimum confidence to report detection + + Returns: + Dictionary with detection results + """ + result = detect_handwriting(image_bytes) + + has_handwriting = ( + result.confidence >= min_confidence and + result.handwriting_ratio > 0.005 # At least 0.5% handwriting + ) + + return { + "has_handwriting": has_handwriting, + "confidence": result.confidence, + "handwriting_ratio": result.handwriting_ratio, + "detection_method": result.detection_method, + "mask_shape": result.mask.shape, + } diff --git a/klausur-service/backend/services/inpainting_service.py b/klausur-service/backend/services/inpainting_service.py new file mode 100644 index 0000000..7591121 --- /dev/null +++ b/klausur-service/backend/services/inpainting_service.py @@ -0,0 +1,383 @@ +""" +Inpainting Service for Worksheet Cleanup + +Removes handwriting from scanned worksheets using inpainting techniques. +Supports multiple backends: +1. OpenCV (Telea/NS algorithms) - Fast, CPU-based baseline +2. LaMa (Large Mask Inpainting) - Optional, better quality + +DATENSCHUTZ: All processing happens locally on Mac Mini. +""" + +import numpy as np +from PIL import Image +import io +import logging +from typing import Tuple, Optional +from dataclasses import dataclass +from enum import Enum + +# OpenCV is optional - only required for actual inpainting +try: + import cv2 + CV2_AVAILABLE = True +except ImportError: + cv2 = None + CV2_AVAILABLE = False + +logger = logging.getLogger(__name__) + + +class InpaintingMethod(str, Enum): + """Available inpainting methods.""" + OPENCV_TELEA = "opencv_telea" # Fast, good for small regions + OPENCV_NS = "opencv_ns" # Navier-Stokes, slower but smoother + LAMA = "lama" # LaMa deep learning (if available) + AUTO = "auto" # Automatically select best method + + +@dataclass +class InpaintingResult: + """Result of inpainting operation.""" + image: np.ndarray # Cleaned image (BGR) + method_used: str # Which method was actually used + processing_time_ms: float # Processing time in milliseconds + success: bool + error_message: Optional[str] = None + + +# Global LaMa model (lazy loaded) +_lama_model = None +_lama_available = None + + +def check_lama_available() -> bool: + """Check if LaMa inpainting is available.""" + global _lama_available + + if _lama_available is not None: + return _lama_available + + try: + # Try to import lama-cleaner library + from lama_cleaner.model_manager import ModelManager + _lama_available = True + logger.info("LaMa inpainting is available") + except ImportError: + _lama_available = False + logger.info("LaMa not available, will use OpenCV fallback") + except Exception as e: + _lama_available = False + logger.warning(f"LaMa check failed: {e}") + + return _lama_available + + +def inpaint_image( + image_bytes: bytes, + mask_bytes: bytes, + method: InpaintingMethod = InpaintingMethod.AUTO, + inpaint_radius: int = 3 +) -> InpaintingResult: + """ + Inpaint (remove) masked regions from an image. + + Args: + image_bytes: Source image as bytes + mask_bytes: Binary mask where white (255) = regions to remove + method: Inpainting method to use + inpaint_radius: Radius for OpenCV inpainting (default 3) + + Returns: + InpaintingResult with cleaned image + + Raises: + ImportError: If OpenCV is not available + """ + if not CV2_AVAILABLE: + raise ImportError( + "OpenCV (cv2) is required for inpainting. " + "Install with: pip install opencv-python-headless" + ) + + import time + start_time = time.time() + + try: + # Load image + img = Image.open(io.BytesIO(image_bytes)) + img_array = np.array(img) + + # Convert to BGR for OpenCV + if len(img_array.shape) == 2: + img_bgr = cv2.cvtColor(img_array, cv2.COLOR_GRAY2BGR) + elif img_array.shape[2] == 4: + img_bgr = cv2.cvtColor(img_array, cv2.COLOR_RGBA2BGR) + elif img_array.shape[2] == 3: + img_bgr = cv2.cvtColor(img_array, cv2.COLOR_RGB2BGR) + else: + img_bgr = img_array + + # Load mask + mask_img = Image.open(io.BytesIO(mask_bytes)) + mask_array = np.array(mask_img) + + # Ensure mask is single channel + if len(mask_array.shape) == 3: + mask_array = cv2.cvtColor(mask_array, cv2.COLOR_BGR2GRAY) + + # Ensure mask is binary + _, mask_binary = cv2.threshold(mask_array, 127, 255, cv2.THRESH_BINARY) + + # Resize mask if dimensions don't match + if mask_binary.shape[:2] != img_bgr.shape[:2]: + mask_binary = cv2.resize( + mask_binary, + (img_bgr.shape[1], img_bgr.shape[0]), + interpolation=cv2.INTER_NEAREST + ) + + # Select method + if method == InpaintingMethod.AUTO: + # Use LaMa if available and mask is large + mask_ratio = np.sum(mask_binary > 0) / mask_binary.size + if check_lama_available() and mask_ratio > 0.05: + method = InpaintingMethod.LAMA + else: + method = InpaintingMethod.OPENCV_TELEA + + # Perform inpainting + if method == InpaintingMethod.LAMA: + result_img, actual_method = _inpaint_lama(img_bgr, mask_binary) + elif method == InpaintingMethod.OPENCV_NS: + result_img = cv2.inpaint( + img_bgr, mask_binary, inpaint_radius, cv2.INPAINT_NS + ) + actual_method = "opencv_ns" + else: # OPENCV_TELEA (default) + result_img = cv2.inpaint( + img_bgr, mask_binary, inpaint_radius, cv2.INPAINT_TELEA + ) + actual_method = "opencv_telea" + + processing_time = (time.time() - start_time) * 1000 + + logger.info(f"Inpainting completed: method={actual_method}, " + f"time={processing_time:.1f}ms") + + return InpaintingResult( + image=result_img, + method_used=actual_method, + processing_time_ms=processing_time, + success=True + ) + + except Exception as e: + logger.error(f"Inpainting failed: {e}") + import traceback + logger.error(traceback.format_exc()) + + return InpaintingResult( + image=None, + method_used="none", + processing_time_ms=0, + success=False, + error_message=str(e) + ) + + +def _inpaint_lama( + img_bgr: np.ndarray, + mask: np.ndarray +) -> Tuple[np.ndarray, str]: + """ + Inpaint using LaMa (Large Mask Inpainting). + + Falls back to OpenCV if LaMa fails. + """ + global _lama_model + + try: + from lama_cleaner.model_manager import ModelManager + from lama_cleaner.schema import Config, HDStrategy, LDMSampler + + # Initialize model if needed + if _lama_model is None: + logger.info("Loading LaMa model...") + _lama_model = ModelManager( + name="lama", + device="cpu", # Use CPU for Mac Mini compatibility + ) + logger.info("LaMa model loaded") + + # Prepare config + config = Config( + ldm_steps=25, + ldm_sampler=LDMSampler.plms, + hd_strategy=HDStrategy.ORIGINAL, + hd_strategy_crop_margin=32, + hd_strategy_crop_trigger_size=800, + hd_strategy_resize_limit=800, + ) + + # Convert BGR to RGB for LaMa + img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) + + # Run inpainting + result_rgb = _lama_model(img_rgb, mask, config) + + # Convert back to BGR + result_bgr = cv2.cvtColor(result_rgb, cv2.COLOR_RGB2BGR) + + return result_bgr, "lama" + + except Exception as e: + logger.warning(f"LaMa inpainting failed, falling back to OpenCV: {e}") + # Fallback to OpenCV + result = cv2.inpaint(img_bgr, mask, 3, cv2.INPAINT_TELEA) + return result, "opencv_telea_fallback" + + +def inpaint_opencv_telea( + image_bytes: bytes, + mask_bytes: bytes, + radius: int = 3 +) -> bytes: + """ + Simple OpenCV Telea inpainting - fastest option. + + Args: + image_bytes: Source image + mask_bytes: Binary mask (white = remove) + radius: Inpainting radius + + Returns: + Inpainted image as PNG bytes + """ + result = inpaint_image( + image_bytes, + mask_bytes, + method=InpaintingMethod.OPENCV_TELEA, + inpaint_radius=radius + ) + + if not result.success: + raise RuntimeError(f"Inpainting failed: {result.error_message}") + + return image_to_png(result.image) + + +def inpaint_opencv_ns( + image_bytes: bytes, + mask_bytes: bytes, + radius: int = 3 +) -> bytes: + """ + OpenCV Navier-Stokes inpainting - smoother but slower. + """ + result = inpaint_image( + image_bytes, + mask_bytes, + method=InpaintingMethod.OPENCV_NS, + inpaint_radius=radius + ) + + if not result.success: + raise RuntimeError(f"Inpainting failed: {result.error_message}") + + return image_to_png(result.image) + + +def remove_handwriting( + image_bytes: bytes, + mask: Optional[np.ndarray] = None, + method: InpaintingMethod = InpaintingMethod.AUTO +) -> Tuple[bytes, dict]: + """ + High-level function to remove handwriting from an image. + + If no mask is provided, detects handwriting automatically. + + Args: + image_bytes: Source image + mask: Optional pre-computed mask + method: Inpainting method + + Returns: + Tuple of (cleaned image bytes, metadata dict) + """ + from services.handwriting_detection import detect_handwriting, mask_to_png + + # Detect handwriting if no mask provided + if mask is None: + detection_result = detect_handwriting(image_bytes) + mask = detection_result.mask + detection_info = { + "confidence": detection_result.confidence, + "handwriting_ratio": detection_result.handwriting_ratio, + "detection_method": detection_result.detection_method + } + else: + detection_info = {"provided_mask": True} + + # Check if there's anything to inpaint + if np.sum(mask > 0) == 0: + logger.info("No handwriting detected, returning original image") + return image_bytes, { + "inpainting_performed": False, + "reason": "no_handwriting_detected", + **detection_info + } + + # Convert mask to bytes for inpainting + mask_bytes = mask_to_png(mask) + + # Perform inpainting + result = inpaint_image(image_bytes, mask_bytes, method=method) + + if not result.success: + raise RuntimeError(f"Inpainting failed: {result.error_message}") + + # Convert result to PNG + result_bytes = image_to_png(result.image) + + metadata = { + "inpainting_performed": True, + "method_used": result.method_used, + "processing_time_ms": result.processing_time_ms, + **detection_info + } + + return result_bytes, metadata + + +def image_to_png(img_bgr: np.ndarray) -> bytes: + """ + Convert BGR image array to PNG bytes. + """ + img_rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) + img_pil = Image.fromarray(img_rgb) + buffer = io.BytesIO() + img_pil.save(buffer, format='PNG', optimize=True) + return buffer.getvalue() + + +def dilate_mask(mask_bytes: bytes, iterations: int = 2) -> bytes: + """ + Dilate a mask to expand the removal region. + + Useful to ensure complete handwriting removal including edges. + """ + mask_img = Image.open(io.BytesIO(mask_bytes)) + mask_array = np.array(mask_img) + + if len(mask_array.shape) == 3: + mask_array = cv2.cvtColor(mask_array, cv2.COLOR_BGR2GRAY) + + kernel = np.ones((3, 3), np.uint8) + dilated = cv2.dilate(mask_array, kernel, iterations=iterations) + + img = Image.fromarray(dilated) + buffer = io.BytesIO() + img.save(buffer, format='PNG') + return buffer.getvalue() diff --git a/klausur-service/backend/services/layout_reconstruction_service.py b/klausur-service/backend/services/layout_reconstruction_service.py new file mode 100644 index 0000000..61f4a7a --- /dev/null +++ b/klausur-service/backend/services/layout_reconstruction_service.py @@ -0,0 +1,375 @@ +""" +Layout Reconstruction Service for Worksheet Cleanup + +Reconstructs the layout of a worksheet from an image: +1. Uses PaddleOCR to detect text with bounding boxes +2. Groups text into logical elements (headings, paragraphs, tables) +3. Generates Fabric.js compatible JSON for the worksheet editor + +DATENSCHUTZ: All processing happens locally on Mac Mini. +""" + +import numpy as np +from PIL import Image +import io +import json +import logging +from typing import List, Dict, Any, Optional, Tuple +from dataclasses import dataclass, field +from enum import Enum + +# OpenCV is optional - only required for actual layout reconstruction +try: + import cv2 + CV2_AVAILABLE = True +except ImportError: + cv2 = None + CV2_AVAILABLE = False + +logger = logging.getLogger(__name__) + + +class ElementType(str, Enum): + """Types of detected layout elements.""" + HEADING = "heading" + PARAGRAPH = "paragraph" + TEXT_LINE = "text_line" + TABLE = "table" + LIST_ITEM = "list_item" + FORM_FIELD = "form_field" + IMAGE = "image" + + +@dataclass +class TextElement: + """A detected text element with position.""" + text: str + x: float # Left position (pixels) + y: float # Top position (pixels) + width: float + height: float + confidence: float + element_type: ElementType = ElementType.TEXT_LINE + font_size: float = 14.0 + is_bold: bool = False + is_centered: bool = False + + +@dataclass +class LayoutResult: + """Result of layout reconstruction.""" + elements: List[TextElement] + page_width: int + page_height: int + fabric_json: Dict[str, Any] + table_regions: List[Dict[str, Any]] = field(default_factory=list) + + +def reconstruct_layout( + image_bytes: bytes, + detect_tables: bool = True +) -> LayoutResult: + """ + Reconstruct the layout of a worksheet from an image. + + Args: + image_bytes: Image as bytes + detect_tables: Whether to detect table structures + + Returns: + LayoutResult with elements and Fabric.js JSON + + Raises: + ImportError: If OpenCV is not available + """ + if not CV2_AVAILABLE: + raise ImportError( + "OpenCV (cv2) is required for layout reconstruction. " + "Install with: pip install opencv-python-headless" + ) + + # Load image + img = Image.open(io.BytesIO(image_bytes)) + img_array = np.array(img) + page_height, page_width = img_array.shape[:2] + + # Run PaddleOCR to get text with positions + ocr_results = _run_paddle_ocr(image_bytes) + + if not ocr_results: + logger.warning("No text detected by PaddleOCR") + return LayoutResult( + elements=[], + page_width=page_width, + page_height=page_height, + fabric_json={"version": "5.3.0", "objects": []} + ) + + # Convert OCR results to TextElements + elements = _convert_ocr_to_elements(ocr_results, page_width, page_height) + + # Group elements into lines and detect headings + elements = _classify_elements(elements, page_width) + + # Detect table regions if enabled + table_regions = [] + if detect_tables: + table_regions = _detect_tables(img_array, elements) + + # Generate Fabric.js JSON + fabric_json = _generate_fabric_json(elements, page_width, page_height) + + logger.info(f"Layout reconstruction: {len(elements)} elements, " + f"{len(table_regions)} tables") + + return LayoutResult( + elements=elements, + page_width=page_width, + page_height=page_height, + fabric_json=fabric_json, + table_regions=table_regions + ) + + +def _run_paddle_ocr(image_bytes: bytes) -> List[Dict[str, Any]]: + """ + Run PaddleOCR on an image. + + Returns list of {text, confidence, bbox} dicts. + """ + try: + from hybrid_vocab_extractor import run_paddle_ocr as paddle_ocr_func, OCRRegion + + regions, _ = paddle_ocr_func(image_bytes) + + return [ + { + "text": r.text, + "confidence": r.confidence, + "bbox": [r.x1, r.y1, r.x2, r.y2] + } + for r in regions + ] + except ImportError: + logger.error("PaddleOCR not available") + return [] + except Exception as e: + logger.error(f"PaddleOCR failed: {e}") + return [] + + +def _convert_ocr_to_elements( + ocr_results: List[Dict[str, Any]], + page_width: int, + page_height: int +) -> List[TextElement]: + """ + Convert raw OCR results to TextElements. + """ + elements = [] + + for result in ocr_results: + bbox = result["bbox"] + x1, y1, x2, y2 = bbox + + # Calculate dimensions + width = x2 - x1 + height = y2 - y1 + + # Estimate font size from height + font_size = max(8, min(72, height * 0.8)) + + element = TextElement( + text=result["text"], + x=x1, + y=y1, + width=width, + height=height, + confidence=result["confidence"], + font_size=font_size + ) + elements.append(element) + + return elements + + +def _classify_elements( + elements: List[TextElement], + page_width: int +) -> List[TextElement]: + """ + Classify elements as headings, paragraphs, etc. + """ + if not elements: + return elements + + # Calculate average metrics + avg_font_size = sum(e.font_size for e in elements) / len(elements) + avg_y = sum(e.y for e in elements) / len(elements) + + for element in elements: + # Detect headings (larger font, near top, possibly centered) + is_larger = element.font_size > avg_font_size * 1.3 + is_near_top = element.y < avg_y * 0.3 + is_centered = abs((element.x + element.width / 2) - page_width / 2) < page_width * 0.15 + + if is_larger and (is_near_top or is_centered): + element.element_type = ElementType.HEADING + element.is_bold = True + element.is_centered = is_centered + # Detect list items (start with bullet or number) + elif element.text.strip().startswith(('•', '-', '–', '*')) or \ + (len(element.text) > 2 and element.text[0].isdigit() and element.text[1] in '.):'): + element.element_type = ElementType.LIST_ITEM + # Detect form fields (underscores or dotted lines) + elif '_____' in element.text or '.....' in element.text: + element.element_type = ElementType.FORM_FIELD + else: + element.element_type = ElementType.TEXT_LINE + + return elements + + +def _detect_tables( + img_array: np.ndarray, + elements: List[TextElement] +) -> List[Dict[str, Any]]: + """ + Detect table regions in the image. + """ + tables = [] + + # Convert to grayscale + if len(img_array.shape) == 3: + gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY) + else: + gray = img_array + + # Detect horizontal and vertical lines + edges = cv2.Canny(gray, 50, 150) + + # Detect lines using Hough transform + lines = cv2.HoughLinesP( + edges, 1, np.pi/180, threshold=100, + minLineLength=50, maxLineGap=10 + ) + + if lines is None: + return tables + + # Separate horizontal and vertical lines + horizontal_lines = [] + vertical_lines = [] + + for line in lines: + x1, y1, x2, y2 = line[0] + angle = np.abs(np.arctan2(y2 - y1, x2 - x1) * 180 / np.pi) + + if angle < 10: # Horizontal + horizontal_lines.append((x1, y1, x2, y2)) + elif angle > 80: # Vertical + vertical_lines.append((x1, y1, x2, y2)) + + # Find table regions (intersections of horizontal and vertical lines) + if len(horizontal_lines) >= 2 and len(vertical_lines) >= 2: + # Sort lines + horizontal_lines.sort(key=lambda l: l[1]) + vertical_lines.sort(key=lambda l: l[0]) + + # Find bounding box of table + min_x = min(l[0] for l in vertical_lines) + max_x = max(l[2] for l in vertical_lines) + min_y = min(l[1] for l in horizontal_lines) + max_y = max(l[3] for l in horizontal_lines) + + tables.append({ + "x": min_x, + "y": min_y, + "width": max_x - min_x, + "height": max_y - min_y, + "rows": len(horizontal_lines) - 1, + "cols": len(vertical_lines) - 1 + }) + + return tables + + +def _generate_fabric_json( + elements: List[TextElement], + page_width: int, + page_height: int +) -> Dict[str, Any]: + """ + Generate Fabric.js compatible JSON from elements. + """ + fabric_objects = [] + + for i, element in enumerate(elements): + fabric_obj = { + "type": "textbox", + "version": "5.3.0", + "originX": "left", + "originY": "top", + "left": element.x, + "top": element.y, + "width": max(element.width, 100), + "height": element.height, + "fill": "#000000", + "stroke": None, + "strokeWidth": 0, + "text": element.text, + "fontSize": element.font_size, + "fontWeight": "bold" if element.is_bold else "normal", + "fontFamily": "Arial", + "textAlign": "center" if element.is_centered else "left", + "underline": False, + "lineHeight": 1.2, + "charSpacing": 0, + "splitByGrapheme": False, + "editable": True, + "selectable": True, + "data": { + "elementType": element.element_type.value, + "confidence": element.confidence, + "originalIndex": i + } + } + fabric_objects.append(fabric_obj) + + return { + "version": "5.3.0", + "objects": fabric_objects, + "background": "#ffffff" + } + + +def layout_to_fabric_json(layout_result: LayoutResult) -> str: + """ + Convert LayoutResult to JSON string for frontend. + """ + return json.dumps(layout_result.fabric_json, ensure_ascii=False, indent=2) + + +def reconstruct_and_clean( + image_bytes: bytes, + remove_handwriting: bool = True +) -> Tuple[bytes, LayoutResult]: + """ + Full pipeline: clean handwriting and reconstruct layout. + + Args: + image_bytes: Source image + remove_handwriting: Whether to remove handwriting first + + Returns: + Tuple of (cleaned image bytes, layout result) + """ + if remove_handwriting: + from services.inpainting_service import remove_handwriting as clean_hw + cleaned_bytes, _ = clean_hw(image_bytes) + else: + cleaned_bytes = image_bytes + + layout = reconstruct_layout(cleaned_bytes) + + return cleaned_bytes, layout diff --git a/klausur-service/backend/services/trocr_service.py b/klausur-service/backend/services/trocr_service.py new file mode 100644 index 0000000..0715d1c --- /dev/null +++ b/klausur-service/backend/services/trocr_service.py @@ -0,0 +1,586 @@ +""" +TrOCR Service + +Microsoft's Transformer-based OCR for text recognition. +Besonders geeignet fuer: +- Gedruckten Text +- Saubere Scans +- Schnelle Verarbeitung + +Model: microsoft/trocr-base-printed (oder handwritten Variante) + +DATENSCHUTZ: Alle Verarbeitung erfolgt lokal. + +Phase 2 Enhancements: +- Batch processing for multiple images +- SHA256-based caching for repeated requests +- Model preloading for faster first request +- Word-level bounding boxes with confidence +""" + +import io +import hashlib +import logging +import time +import asyncio +from typing import Tuple, Optional, List, Dict, Any +from dataclasses import dataclass, field +from collections import OrderedDict +from datetime import datetime, timedelta + +logger = logging.getLogger(__name__) + +# Lazy loading for heavy dependencies +_trocr_processor = None +_trocr_model = None +_trocr_available = None +_model_loaded_at = None + +# Simple in-memory cache with LRU eviction +_ocr_cache: OrderedDict[str, Dict[str, Any]] = OrderedDict() +_cache_max_size = 100 +_cache_ttl_seconds = 3600 # 1 hour + + +@dataclass +class OCRResult: + """Enhanced OCR result with detailed information.""" + text: str + confidence: float + processing_time_ms: int + model: str + has_lora_adapter: bool = False + char_confidences: List[float] = field(default_factory=list) + word_boxes: List[Dict[str, Any]] = field(default_factory=list) + from_cache: bool = False + image_hash: str = "" + + +@dataclass +class BatchOCRResult: + """Result for batch processing.""" + results: List[OCRResult] + total_time_ms: int + processed_count: int + cached_count: int + error_count: int + + +def _compute_image_hash(image_data: bytes) -> str: + """Compute SHA256 hash of image data for caching.""" + return hashlib.sha256(image_data).hexdigest()[:16] + + +def _cache_get(image_hash: str) -> Optional[Dict[str, Any]]: + """Get cached OCR result if available and not expired.""" + if image_hash in _ocr_cache: + entry = _ocr_cache[image_hash] + if datetime.now() - entry["cached_at"] < timedelta(seconds=_cache_ttl_seconds): + # Move to end (LRU) + _ocr_cache.move_to_end(image_hash) + return entry["result"] + else: + # Expired, remove + del _ocr_cache[image_hash] + return None + + +def _cache_set(image_hash: str, result: Dict[str, Any]) -> None: + """Store OCR result in cache.""" + # Evict oldest if at capacity + while len(_ocr_cache) >= _cache_max_size: + _ocr_cache.popitem(last=False) + + _ocr_cache[image_hash] = { + "result": result, + "cached_at": datetime.now() + } + + +def get_cache_stats() -> Dict[str, Any]: + """Get cache statistics.""" + return { + "size": len(_ocr_cache), + "max_size": _cache_max_size, + "ttl_seconds": _cache_ttl_seconds, + "hit_rate": 0 # Could track this with additional counters + } + + +def _check_trocr_available() -> bool: + """Check if TrOCR dependencies are available.""" + global _trocr_available + if _trocr_available is not None: + return _trocr_available + + try: + import torch + from transformers import TrOCRProcessor, VisionEncoderDecoderModel + _trocr_available = True + except ImportError as e: + logger.warning(f"TrOCR dependencies not available: {e}") + _trocr_available = False + + return _trocr_available + + +def get_trocr_model(handwritten: bool = False): + """ + Lazy load TrOCR model and processor. + + Args: + handwritten: Use handwritten model instead of printed model + + Returns tuple of (processor, model) or (None, None) if unavailable. + """ + global _trocr_processor, _trocr_model + + if not _check_trocr_available(): + return None, None + + if _trocr_processor is None or _trocr_model is None: + try: + import torch + from transformers import TrOCRProcessor, VisionEncoderDecoderModel + + # Choose model based on use case + if handwritten: + model_name = "microsoft/trocr-base-handwritten" + else: + model_name = "microsoft/trocr-base-printed" + + logger.info(f"Loading TrOCR model: {model_name}") + _trocr_processor = TrOCRProcessor.from_pretrained(model_name) + _trocr_model = VisionEncoderDecoderModel.from_pretrained(model_name) + + # Use GPU if available + device = "cuda" if torch.cuda.is_available() else "mps" if torch.backends.mps.is_available() else "cpu" + _trocr_model.to(device) + logger.info(f"TrOCR model loaded on device: {device}") + + except Exception as e: + logger.error(f"Failed to load TrOCR model: {e}") + return None, None + + return _trocr_processor, _trocr_model + + +def preload_trocr_model(handwritten: bool = True) -> bool: + """ + Preload TrOCR model at startup for faster first request. + + Call this from your FastAPI startup event: + @app.on_event("startup") + async def startup(): + preload_trocr_model() + """ + global _model_loaded_at + logger.info("Preloading TrOCR model...") + processor, model = get_trocr_model(handwritten=handwritten) + if processor is not None and model is not None: + _model_loaded_at = datetime.now() + logger.info("TrOCR model preloaded successfully") + return True + else: + logger.warning("TrOCR model preloading failed") + return False + + +def get_model_status() -> Dict[str, Any]: + """Get current model status information.""" + processor, model = get_trocr_model(handwritten=True) + is_loaded = processor is not None and model is not None + + status = { + "status": "available" if is_loaded else "not_installed", + "is_loaded": is_loaded, + "model_name": "trocr-base-handwritten" if is_loaded else None, + "loaded_at": _model_loaded_at.isoformat() if _model_loaded_at else None, + } + + if is_loaded: + import torch + device = next(model.parameters()).device + status["device"] = str(device) + + return status + + +async def run_trocr_ocr( + image_data: bytes, + handwritten: bool = False, + split_lines: bool = True +) -> Tuple[Optional[str], float]: + """ + Run TrOCR on an image. + + TrOCR is optimized for single-line text recognition, so for full-page + images we need to either: + 1. Split into lines first (using line detection) + 2. Process the whole image and get partial results + + Args: + image_data: Raw image bytes + handwritten: Use handwritten model (slower but better for handwriting) + split_lines: Whether to split image into lines first + + Returns: + Tuple of (extracted_text, confidence) + """ + processor, model = get_trocr_model(handwritten=handwritten) + + if processor is None or model is None: + logger.error("TrOCR model not available") + return None, 0.0 + + try: + import torch + from PIL import Image + import numpy as np + + # Load image + image = Image.open(io.BytesIO(image_data)).convert("RGB") + + if split_lines: + # Split image into lines and process each + lines = _split_into_lines(image) + if not lines: + lines = [image] # Fallback to full image + else: + lines = [image] + + all_text = [] + confidences = [] + + for line_image in lines: + # Prepare input + pixel_values = processor(images=line_image, return_tensors="pt").pixel_values + + # Move to same device as model + device = next(model.parameters()).device + pixel_values = pixel_values.to(device) + + # Generate + with torch.no_grad(): + generated_ids = model.generate(pixel_values, max_length=128) + + # Decode + generated_text = processor.batch_decode(generated_ids, skip_special_tokens=True)[0] + + if generated_text.strip(): + all_text.append(generated_text.strip()) + # TrOCR doesn't provide confidence, estimate based on output + confidences.append(0.85 if len(generated_text) > 3 else 0.5) + + # Combine results + text = "\n".join(all_text) + + # Average confidence + confidence = sum(confidences) / len(confidences) if confidences else 0.0 + + logger.info(f"TrOCR extracted {len(text)} characters from {len(lines)} lines") + return text, confidence + + except Exception as e: + logger.error(f"TrOCR failed: {e}") + import traceback + logger.error(traceback.format_exc()) + return None, 0.0 + + +def _split_into_lines(image) -> list: + """ + Split an image into text lines using simple projection-based segmentation. + + This is a basic implementation - for production use, consider using + a dedicated line detection model. + """ + import numpy as np + from PIL import Image + + try: + # Convert to grayscale + gray = image.convert('L') + img_array = np.array(gray) + + # Binarize (simple threshold) + threshold = 200 + binary = img_array < threshold + + # Horizontal projection (sum of dark pixels per row) + h_proj = np.sum(binary, axis=1) + + # Find line boundaries (where projection drops below threshold) + line_threshold = img_array.shape[1] * 0.02 # 2% of width + in_line = False + line_start = 0 + lines = [] + + for i, val in enumerate(h_proj): + if val > line_threshold and not in_line: + # Start of line + in_line = True + line_start = i + elif val <= line_threshold and in_line: + # End of line + in_line = False + # Add padding + start = max(0, line_start - 5) + end = min(img_array.shape[0], i + 5) + if end - start > 10: # Minimum line height + lines.append(image.crop((0, start, image.width, end))) + + # Handle last line if still in_line + if in_line: + start = max(0, line_start - 5) + lines.append(image.crop((0, start, image.width, image.height))) + + logger.info(f"Split image into {len(lines)} lines") + return lines + + except Exception as e: + logger.warning(f"Line splitting failed: {e}") + return [] + + +async def run_trocr_ocr_enhanced( + image_data: bytes, + handwritten: bool = True, + split_lines: bool = True, + use_cache: bool = True +) -> OCRResult: + """ + Enhanced TrOCR OCR with caching and detailed results. + + Args: + image_data: Raw image bytes + handwritten: Use handwritten model + split_lines: Whether to split image into lines first + use_cache: Whether to use caching + + Returns: + OCRResult with detailed information + """ + start_time = time.time() + + # Check cache first + image_hash = _compute_image_hash(image_data) + if use_cache: + cached = _cache_get(image_hash) + if cached: + return OCRResult( + text=cached["text"], + confidence=cached["confidence"], + processing_time_ms=0, + model=cached["model"], + has_lora_adapter=cached.get("has_lora_adapter", False), + char_confidences=cached.get("char_confidences", []), + word_boxes=cached.get("word_boxes", []), + from_cache=True, + image_hash=image_hash + ) + + # Run OCR + text, confidence = await run_trocr_ocr(image_data, handwritten=handwritten, split_lines=split_lines) + + processing_time_ms = int((time.time() - start_time) * 1000) + + # Generate word boxes with simulated confidences + word_boxes = [] + if text: + words = text.split() + for idx, word in enumerate(words): + # Simulate word confidence (slightly varied around overall confidence) + word_conf = min(1.0, max(0.0, confidence + (hash(word) % 20 - 10) / 100)) + word_boxes.append({ + "text": word, + "confidence": word_conf, + "bbox": [0, 0, 0, 0] # Would need actual bounding box detection + }) + + # Generate character confidences + char_confidences = [] + if text: + for char in text: + # Simulate per-character confidence + char_conf = min(1.0, max(0.0, confidence + (hash(char) % 15 - 7) / 100)) + char_confidences.append(char_conf) + + result = OCRResult( + text=text or "", + confidence=confidence, + processing_time_ms=processing_time_ms, + model="trocr-base-handwritten" if handwritten else "trocr-base-printed", + has_lora_adapter=False, # Would check actual adapter status + char_confidences=char_confidences, + word_boxes=word_boxes, + from_cache=False, + image_hash=image_hash + ) + + # Cache result + if use_cache and text: + _cache_set(image_hash, { + "text": result.text, + "confidence": result.confidence, + "model": result.model, + "has_lora_adapter": result.has_lora_adapter, + "char_confidences": result.char_confidences, + "word_boxes": result.word_boxes + }) + + return result + + +async def run_trocr_batch( + images: List[bytes], + handwritten: bool = True, + split_lines: bool = True, + use_cache: bool = True, + progress_callback: Optional[callable] = None +) -> BatchOCRResult: + """ + Process multiple images in batch. + + Args: + images: List of image data bytes + handwritten: Use handwritten model + split_lines: Whether to split images into lines + use_cache: Whether to use caching + progress_callback: Optional callback(current, total) for progress updates + + Returns: + BatchOCRResult with all results + """ + start_time = time.time() + results = [] + cached_count = 0 + error_count = 0 + + for idx, image_data in enumerate(images): + try: + result = await run_trocr_ocr_enhanced( + image_data, + handwritten=handwritten, + split_lines=split_lines, + use_cache=use_cache + ) + results.append(result) + + if result.from_cache: + cached_count += 1 + + # Report progress + if progress_callback: + progress_callback(idx + 1, len(images)) + + except Exception as e: + logger.error(f"Batch OCR error for image {idx}: {e}") + error_count += 1 + results.append(OCRResult( + text=f"Error: {str(e)}", + confidence=0.0, + processing_time_ms=0, + model="error", + has_lora_adapter=False + )) + + total_time_ms = int((time.time() - start_time) * 1000) + + return BatchOCRResult( + results=results, + total_time_ms=total_time_ms, + processed_count=len(images), + cached_count=cached_count, + error_count=error_count + ) + + +# Generator for SSE streaming during batch processing +async def run_trocr_batch_stream( + images: List[bytes], + handwritten: bool = True, + split_lines: bool = True, + use_cache: bool = True +): + """ + Process images and yield progress updates for SSE streaming. + + Yields: + dict with current progress and result + """ + start_time = time.time() + total = len(images) + + for idx, image_data in enumerate(images): + try: + result = await run_trocr_ocr_enhanced( + image_data, + handwritten=handwritten, + split_lines=split_lines, + use_cache=use_cache + ) + + elapsed_ms = int((time.time() - start_time) * 1000) + avg_time_per_image = elapsed_ms / (idx + 1) + estimated_remaining = int(avg_time_per_image * (total - idx - 1)) + + yield { + "type": "progress", + "current": idx + 1, + "total": total, + "progress_percent": ((idx + 1) / total) * 100, + "elapsed_ms": elapsed_ms, + "estimated_remaining_ms": estimated_remaining, + "result": { + "text": result.text, + "confidence": result.confidence, + "processing_time_ms": result.processing_time_ms, + "from_cache": result.from_cache + } + } + + except Exception as e: + logger.error(f"Stream OCR error for image {idx}: {e}") + yield { + "type": "error", + "current": idx + 1, + "total": total, + "error": str(e) + } + + total_time_ms = int((time.time() - start_time) * 1000) + yield { + "type": "complete", + "total_time_ms": total_time_ms, + "processed_count": total + } + + +# Test function +async def test_trocr_ocr(image_path: str, handwritten: bool = False): + """Test TrOCR on a local image file.""" + with open(image_path, "rb") as f: + image_data = f.read() + + text, confidence = await run_trocr_ocr(image_data, handwritten=handwritten) + + print(f"\n=== TrOCR Test ===") + print(f"Mode: {'Handwritten' if handwritten else 'Printed'}") + print(f"Confidence: {confidence:.2f}") + print(f"Text:\n{text}") + + return text, confidence + + +if __name__ == "__main__": + import asyncio + import sys + + handwritten = "--handwritten" in sys.argv + args = [a for a in sys.argv[1:] if not a.startswith("--")] + + if args: + asyncio.run(test_trocr_ocr(args[0], handwritten=handwritten)) + else: + print("Usage: python trocr_service.py [--handwritten]") diff --git a/klausur-service/backend/storage.py b/klausur-service/backend/storage.py new file mode 100644 index 0000000..53b874f --- /dev/null +++ b/klausur-service/backend/storage.py @@ -0,0 +1,61 @@ +""" +Klausur-Service In-Memory Storage + +Centralized storage for all in-memory data. +Note: In production, this should be replaced with a database. +""" + +from typing import Dict, List + +from models.exam import Klausur, StudentKlausur +from models.grading import AuditLogEntry +from models.eh import ( + Erwartungshorizont, + EHRightsConfirmation, + EHAuditLogEntry, + EHKeyShare, + EHKlausurLink, + EHShareInvitation, +) + +# ============================================= +# KLAUSUR STORAGE +# ============================================= + +# Klausur storage: klausur_id -> Klausur +klausuren_db: Dict[str, Klausur] = {} + +# Student work storage: student_id -> StudentKlausur +students_db: Dict[str, StudentKlausur] = {} + +# Examiner assignments: student_id -> examiner info +examiner_db: Dict[str, Dict] = {} + +# ============================================= +# AUDIT LOG STORAGE +# ============================================= + +# General audit log +audit_log_db: List[AuditLogEntry] = [] + +# ============================================= +# BYOEH STORAGE +# ============================================= + +# Erwartungshorizont storage: eh_id -> Erwartungshorizont +eh_db: Dict[str, Erwartungshorizont] = {} + +# Rights confirmations: confirmation_id -> EHRightsConfirmation +eh_rights_db: Dict[str, EHRightsConfirmation] = {} + +# EH audit log +eh_audit_db: List[EHAuditLogEntry] = [] + +# Key shares: eh_id -> list of EHKeyShare +eh_key_shares_db: Dict[str, List[EHKeyShare]] = {} + +# EH-Klausur links: klausur_id -> list of EHKlausurLink +eh_klausur_links_db: Dict[str, List[EHKlausurLink]] = {} + +# Share invitations: invitation_id -> EHShareInvitation +eh_invitations_db: Dict[str, EHShareInvitation] = {} diff --git a/klausur-service/backend/template_sources.py b/klausur-service/backend/template_sources.py new file mode 100644 index 0000000..8d6f892 --- /dev/null +++ b/klausur-service/backend/template_sources.py @@ -0,0 +1,459 @@ +""" +Template Sources Configuration for Legal Templates RAG. + +Defines all source repositories and their license metadata for the +bp_legal_templates collection. Sources are organized by license type +for proper attribution compliance. + +License Types: +- PUBLIC_DOMAIN: German official works (§5 UrhG) - no attribution needed +- CC0: Public Domain Dedication - no attribution needed (recommended) +- UNLICENSE: Public Domain equivalent - no attribution needed +- MIT: Attribution required on redistribution +- CC_BY_4: Attribution + change notices required +- REUSE_NOTICE: May quote with source, no distortion allowed +""" + +from dataclasses import dataclass, field +from enum import Enum +from typing import List, Optional + + +class LicenseType(Enum): + """License types for template sources with compliance requirements.""" + PUBLIC_DOMAIN = "public_domain" # §5 UrhG amtliche Werke + CC0 = "cc0" # CC0 1.0 Universal + UNLICENSE = "unlicense" # Unlicense (public domain) + MIT = "mit" # MIT License + CC_BY_4 = "cc_by_4" # CC BY 4.0 International + REUSE_NOTICE = "reuse_notice" # EU reuse notice (source required) + + +@dataclass +class LicenseInfo: + """Detailed license information for compliance.""" + id: LicenseType + name: str + url: str + attribution_required: bool + share_alike: bool = False + no_derivatives: bool = False + commercial_use: bool = True + training_allowed: bool = True + output_allowed: bool = True + modification_allowed: bool = True + distortion_prohibited: bool = False + attribution_template: Optional[str] = None + + def get_attribution_text(self, source_name: str, source_url: str) -> str: + """Generate attribution text for this license type.""" + if not self.attribution_required: + return "" + if self.attribution_template: + return self.attribution_template.format( + source_name=source_name, + source_url=source_url, + license_name=self.name, + license_url=self.url + ) + return f"Source: {source_name} ({self.name})" + + +# License definitions with full compliance info +LICENSES: dict[LicenseType, LicenseInfo] = { + LicenseType.PUBLIC_DOMAIN: LicenseInfo( + id=LicenseType.PUBLIC_DOMAIN, + name="Public Domain (§5 UrhG)", + url="https://www.gesetze-im-internet.de/urhg/__5.html", + attribution_required=False, + training_allowed=True, + output_allowed=True, + modification_allowed=True, + ), + LicenseType.CC0: LicenseInfo( + id=LicenseType.CC0, + name="CC0 1.0 Universal", + url="https://creativecommons.org/publicdomain/zero/1.0/", + attribution_required=False, # Not required but recommended + training_allowed=True, + output_allowed=True, + modification_allowed=True, + attribution_template="[{source_name}]({source_url}) - CC0 1.0", + ), + LicenseType.UNLICENSE: LicenseInfo( + id=LicenseType.UNLICENSE, + name="Unlicense", + url="https://unlicense.org/", + attribution_required=False, + training_allowed=True, + output_allowed=True, + modification_allowed=True, + ), + LicenseType.MIT: LicenseInfo( + id=LicenseType.MIT, + name="MIT License", + url="https://opensource.org/licenses/MIT", + attribution_required=True, + training_allowed=True, + output_allowed=True, + modification_allowed=True, + attribution_template="Based on [{source_name}]({source_url}) - MIT License", + ), + LicenseType.CC_BY_4: LicenseInfo( + id=LicenseType.CC_BY_4, + name="CC BY 4.0 International", + url="https://creativecommons.org/licenses/by/4.0/", + attribution_required=True, + training_allowed=False, # CC BY 4.0 may restrict training + output_allowed=True, + modification_allowed=True, + attribution_template=( + "Adapted from [{source_name}]({source_url}), " + "licensed under [CC BY 4.0]({license_url}). Changes were made." + ), + ), + LicenseType.REUSE_NOTICE: LicenseInfo( + id=LicenseType.REUSE_NOTICE, + name="EU Reuse Notice", + url="https://commission.europa.eu/legal-notice_en", + attribution_required=True, + training_allowed=False, + output_allowed=True, + modification_allowed=False, + distortion_prohibited=True, + attribution_template="Source: {source_name} ({source_url})", + ), +} + + +@dataclass +class SourceConfig: + """Configuration for a template source repository.""" + name: str + license_type: LicenseType + template_types: List[str] + languages: List[str] + jurisdiction: str + description: str + repo_url: Optional[str] = None + web_url: Optional[str] = None + file_patterns: List[str] = field(default_factory=lambda: ["*.md", "*.txt", "*.html"]) + exclude_patterns: List[str] = field(default_factory=list) + priority: int = 1 # 1 = highest priority (CC0), 5 = lowest (REUSE_NOTICE) + enabled: bool = True + + @property + def license_info(self) -> LicenseInfo: + """Get the full license information for this source.""" + return LICENSES[self.license_type] + + def get_source_url(self) -> str: + """Get the primary URL for this source.""" + return self.repo_url or self.web_url or "" + + +# ============================================================================= +# Phase 1: CC0-Quellen (Höchste Priorität - keine Attribution nötig) +# ============================================================================= + +TEMPLATE_SOURCES: List[SourceConfig] = [ + # GitHub Site Policy (CC0) + SourceConfig( + name="github-site-policy", + repo_url="https://github.com/github/site-policy", + license_type=LicenseType.CC0, + template_types=["terms_of_service", "privacy_policy", "community_guidelines", "acceptable_use"], + languages=["en"], + jurisdiction="US", + description="GitHub's site policies including Terms of Service, Privacy Policy, and Community Guidelines. High-quality, well-structured legal templates.", + file_patterns=["Policies/*.md", "*.md"], + exclude_patterns=["README.md", "CONTRIBUTING.md", "LICENSE.md", "archived/*"], + priority=1, + ), + + # opr.vc DSGVO Muster (CC0) + SourceConfig( + name="opr-vc", + repo_url="https://github.com/oprvc/oprvc.github.io", + web_url="https://opr.vc/", + license_type=LicenseType.CC0, + template_types=["privacy_policy", "impressum"], + languages=["de"], + jurisdiction="DE", + description="Open Privacy Resource - DSGVO-konforme Mustertexte für Datenschutzerklärungen und Impressum. Speziell für deutsche Websites.", + file_patterns=["*.md", "*.html", "_posts/*.md"], + priority=1, + ), + + # Open Gov Foundation (CC0) + SourceConfig( + name="opengovfoundation-site-policy", + repo_url="https://github.com/opengovfoundation/site-policy", + license_type=LicenseType.CC0, + template_types=["terms_of_service", "privacy_policy", "copyright_policy"], + languages=["en"], + jurisdiction="US", + description="OpenGov Foundation's site policies. Clean, reusable templates for open government projects.", + file_patterns=["*.md"], + priority=1, + ), + + # Creative Commons Legal Tools Data (CC0) + SourceConfig( + name="cc-legal-tools-data", + repo_url="https://github.com/creativecommons/cc-legal-tools-data", + license_type=LicenseType.CC0, + template_types=["license_text"], + languages=["de", "en"], + jurisdiction="INTL", + description="Creative Commons license texts in multiple languages. Useful as reference for license templates.", + file_patterns=["legalcode/**/legalcode.de.html", "legalcode/**/legalcode.en.html"], + priority=1, + ), + + # ============================================================================= + # Phase 2: MIT-Quellen (Attribution bei Weitergabe) + # ============================================================================= + + # Webflorist Privacy Policy Text (MIT) + SourceConfig( + name="webflorist-privacy-policy", + repo_url="https://github.com/webflorist/privacy-policy-text", + license_type=LicenseType.MIT, + template_types=["privacy_policy"], + languages=["de", "en"], + jurisdiction="EU", + description="Modular GDPR-compliant privacy policy texts in JSON/PHP format. Highly customizable with variable sections.", + file_patterns=["src/**/*.json", "src/**/*.php", "*.md"], + priority=2, + ), + + # Tempest Privacy Policy Generator (MIT) + SourceConfig( + name="tempest-privacy-policy", + repo_url="https://github.com/Tempest-Solutions-Company/privacy-policy-generator", + license_type=LicenseType.MIT, + template_types=["privacy_policy"], + languages=["en"], + jurisdiction="INTL", + description="Privacy policy generator with templates for various use cases.", + file_patterns=["templates/*.md", "src/**/*.txt", "*.md"], + priority=2, + ), + + # Tempest Terms of Service Generator (MIT) + SourceConfig( + name="tempest-terms-of-service", + repo_url="https://github.com/Tempest-Solutions-Company/terms-of-service-generator", + license_type=LicenseType.MIT, + template_types=["terms_of_service", "dpa"], + languages=["en"], + jurisdiction="INTL", + description="Terms of Service and DPA clause generator templates.", + file_patterns=["templates/*.md", "src/**/*.txt", "*.md"], + priority=2, + ), + + # Tempest Cookie Banner (MIT) + SourceConfig( + name="tempest-cookie-banner", + repo_url="https://github.com/Tempest-Solutions-Company/cookie-banner-consent-solution", + license_type=LicenseType.MIT, + template_types=["cookie_banner", "cookie_policy"], + languages=["en"], + jurisdiction="EU", + description="Cookie consent banner texts and templates for GDPR/ePrivacy compliance.", + file_patterns=["templates/*.md", "src/**/*.txt", "*.md", "locales/*.json"], + priority=2, + ), + + # ============================================================================= + # Phase 3: CC BY 4.0 (Attribution + Änderungskennzeichnung) + # ============================================================================= + + # Common Paper Standards (CC BY 4.0) + SourceConfig( + name="common-paper-standards", + repo_url="https://github.com/CommonPaper/SLA", + web_url="https://commonpaper.com/standards/", + license_type=LicenseType.CC_BY_4, + template_types=["sla", "cloud_service_agreement", "terms_of_service", "nda", "dpa"], + languages=["en"], + jurisdiction="US", + description="Common Paper's standardized B2B SaaS contract templates. Industry-standard agreements for cloud services.", + file_patterns=["*.md", "versions/**/*.md"], + priority=3, + ), + + # Datennutzungsklauseln Muster (CC BY 4.0) + SourceConfig( + name="datennutzungsklauseln-muster", + repo_url="https://gitlab.opencode.de/wernerth/datennutzungsklauseln-muster", + license_type=LicenseType.CC_BY_4, + template_types=["data_usage_clause", "dpa"], + languages=["de"], + jurisdiction="DE", + description="B2B Datennutzungsklauseln für Verträge. Speziell für deutsche Unternehmen.", + file_patterns=["*.md", "klauseln/*.md"], + priority=3, + ), + + # ============================================================================= + # Phase 4: Amtliche Werke (§5 UrhG - urheberrechtsfrei, Referenz) + # ============================================================================= + + # Bundestag Gesetze (Unlicense) + SourceConfig( + name="bundestag-gesetze", + repo_url="https://github.com/bundestag/gesetze", + license_type=LicenseType.UNLICENSE, + template_types=["law_reference"], + languages=["de"], + jurisdiction="DE", + description="Deutsche Bundesgesetze im Markdown-Format. Referenz für DDG, TDDDG, EGBGB Muster.", + file_patterns=["d/ddg/*.md", "t/tdddg/*.md", "e/egbgb/*.md", "b/bgb/*.md"], + priority=4, + ), + + # Gesetze im Internet (Public Domain via §5 UrhG) + SourceConfig( + name="gesetze-im-internet", + web_url="https://www.gesetze-im-internet.de/", + license_type=LicenseType.PUBLIC_DOMAIN, + template_types=["law_reference", "widerruf", "impressum"], + languages=["de"], + jurisdiction="DE", + description="Amtliche Gesetzestexte. DDG §5 (Impressum), TDDDG §25, EGBGB Muster-Widerrufsformular.", + file_patterns=[], # Web scraping required + enabled=False, # Requires custom web crawler + priority=4, + ), + + # EUR-Lex (Public Domain + Reuse Notice) + SourceConfig( + name="eur-lex", + web_url="https://eur-lex.europa.eu/", + license_type=LicenseType.PUBLIC_DOMAIN, + template_types=["scc", "law_reference"], + languages=["de", "en"], + jurisdiction="EU", + description="EU-Recht: DSGVO Artikel, DSA, SCC (Durchführungsbeschluss 2021/914).", + file_patterns=[], # Web scraping required + enabled=False, # Requires custom web crawler + priority=4, + ), + + # ============================================================================= + # Phase 5: Reuse-Notices (Guidance als Referenz) + # ============================================================================= + + # EDPB Guidelines (Reuse Notice) + SourceConfig( + name="edpb-guidelines", + web_url="https://www.edpb.europa.eu/", + license_type=LicenseType.REUSE_NOTICE, + template_types=["guidance"], + languages=["de", "en"], + jurisdiction="EU", + description="EDPB Datenschutz-Guidelines und FAQs. Als Referenz verwendbar, keine Sinnentstellung erlaubt.", + file_patterns=[], # Web scraping required + enabled=False, # Requires custom web crawler + priority=5, + ), + + # EDPS Resources (Reuse Notice) + SourceConfig( + name="edps-resources", + web_url="https://www.edps.europa.eu/", + license_type=LicenseType.REUSE_NOTICE, + template_types=["guidance"], + languages=["de", "en"], + jurisdiction="EU", + description="EDPS Datenschutz-Ressourcen und FAQs. Als Referenz verwendbar.", + file_patterns=[], # Web scraping required + enabled=False, # Requires custom web crawler + priority=5, + ), + + # EU Commission Policies (CC BY 4.0) + SourceConfig( + name="eu-commission-policies", + web_url="https://commission.europa.eu/", + license_type=LicenseType.CC_BY_4, + template_types=["guidance", "policy"], + languages=["de", "en"], + jurisdiction="EU", + description="EU-Kommission Policy-Dokumente. CC BY 4.0 lizenziert.", + file_patterns=[], # Web scraping required + enabled=False, # Requires custom web crawler + priority=5, + ), +] + + +def get_enabled_sources() -> List[SourceConfig]: + """Get all enabled template sources.""" + return [s for s in TEMPLATE_SOURCES if s.enabled] + + +def get_sources_by_priority(max_priority: int = 5) -> List[SourceConfig]: + """Get sources filtered by priority level (lower = higher priority).""" + return sorted( + [s for s in get_enabled_sources() if s.priority <= max_priority], + key=lambda s: s.priority + ) + + +def get_sources_by_license(license_type: LicenseType) -> List[SourceConfig]: + """Get sources filtered by license type.""" + return [s for s in get_enabled_sources() if s.license_type == license_type] + + +def get_sources_by_template_type(template_type: str) -> List[SourceConfig]: + """Get sources that provide a specific template type.""" + return [s for s in get_enabled_sources() if template_type in s.template_types] + + +def get_sources_by_language(language: str) -> List[SourceConfig]: + """Get sources that provide content in a specific language.""" + return [s for s in get_enabled_sources() if language in s.languages] + + +def get_sources_by_jurisdiction(jurisdiction: str) -> List[SourceConfig]: + """Get sources for a specific jurisdiction.""" + return [s for s in get_enabled_sources() if s.jurisdiction == jurisdiction] + + +# Template type definitions for documentation +TEMPLATE_TYPES = { + "privacy_policy": "Datenschutzerklärung / Privacy Policy", + "terms_of_service": "Nutzungsbedingungen / Terms of Service", + "agb": "Allgemeine Geschäftsbedingungen", + "cookie_banner": "Cookie-Banner Text", + "cookie_policy": "Cookie-Richtlinie / Cookie Policy", + "impressum": "Impressum / Legal Notice", + "widerruf": "Widerrufsbelehrung / Cancellation Policy", + "dpa": "Auftragsverarbeitungsvertrag / Data Processing Agreement", + "sla": "Service Level Agreement", + "nda": "Geheimhaltungsvereinbarung / Non-Disclosure Agreement", + "cloud_service_agreement": "Cloud-Dienstleistungsvertrag", + "data_usage_clause": "Datennutzungsklausel", + "acceptable_use": "Acceptable Use Policy", + "community_guidelines": "Community-Richtlinien", + "copyright_policy": "Urheberrechtsrichtlinie", + "license_text": "Lizenztext", + "law_reference": "Gesetzesreferenz (nicht als Vorlage)", + "guidance": "Behördliche Guidance (nur Referenz)", + "policy": "Policy-Dokument", +} + + +# Jurisdiction definitions +JURISDICTIONS = { + "DE": "Deutschland", + "AT": "Österreich", + "CH": "Schweiz", + "EU": "Europäische Union", + "US": "United States", + "INTL": "International", +} diff --git a/klausur-service/backend/tests/__init__.py b/klausur-service/backend/tests/__init__.py new file mode 100644 index 0000000..b86e96b --- /dev/null +++ b/klausur-service/backend/tests/__init__.py @@ -0,0 +1 @@ +# BYOEH Test Suite diff --git a/klausur-service/backend/tests/conftest.py b/klausur-service/backend/tests/conftest.py new file mode 100644 index 0000000..2c1d8bf --- /dev/null +++ b/klausur-service/backend/tests/conftest.py @@ -0,0 +1,14 @@ +""" +Pytest configuration for klausur-service tests. + +Ensures local modules (hyde, hybrid_search, rag_evaluation, etc.) +can be imported by adding the backend directory to sys.path. +""" + +import sys +from pathlib import Path + +# Add the backend directory to sys.path so local modules can be imported +backend_dir = Path(__file__).parent.parent +if str(backend_dir) not in sys.path: + sys.path.insert(0, str(backend_dir)) diff --git a/klausur-service/backend/tests/test_advanced_rag.py b/klausur-service/backend/tests/test_advanced_rag.py new file mode 100644 index 0000000..5d7fedb --- /dev/null +++ b/klausur-service/backend/tests/test_advanced_rag.py @@ -0,0 +1,769 @@ +""" +Tests for Advanced RAG Features + +Tests for the newly implemented RAG quality improvements: +- HyDE (Hypothetical Document Embeddings) +- Hybrid Search (Dense + Sparse/BM25) +- RAG Evaluation (RAGAS-inspired metrics) +- PDF Extraction (Unstructured.io, PyMuPDF, PyPDF2) +- Self-RAG / Corrective RAG + +Run with: pytest tests/test_advanced_rag.py -v +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +import asyncio + + +# ============================================================================= +# HyDE Tests +# ============================================================================= + +class TestHyDE: + """Tests for HyDE (Hypothetical Document Embeddings) module.""" + + def test_hyde_config(self): + """Test HyDE configuration loading.""" + from hyde import HYDE_ENABLED, HYDE_LLM_BACKEND, HYDE_MODEL, get_hyde_info + + info = get_hyde_info() + assert "enabled" in info + assert "llm_backend" in info + assert "model" in info + + def test_hyde_prompt_template(self): + """Test that HyDE prompt template is properly formatted.""" + from hyde import HYDE_PROMPT_TEMPLATE + + assert "{query}" in HYDE_PROMPT_TEMPLATE + assert "Erwartungshorizont" in HYDE_PROMPT_TEMPLATE + assert "Bildungsstandards" in HYDE_PROMPT_TEMPLATE + + @pytest.mark.asyncio + async def test_generate_hypothetical_document_disabled(self): + """Test HyDE returns original query when disabled.""" + from hyde import generate_hypothetical_document + + with patch('hyde.HYDE_ENABLED', False): + result = await generate_hypothetical_document("Test query") + assert result == "Test query" + + @pytest.mark.asyncio + async def test_hyde_search_fallback(self): + """Test hyde_search falls back gracefully without LLM.""" + from hyde import hyde_search + + async def mock_search(query, **kwargs): + return [{"id": "1", "text": "Result for: " + query}] + + with patch('hyde.HYDE_ENABLED', False): + result = await hyde_search( + query="Test query", + search_func=mock_search, + ) + + assert result["hyde_used"] is False + # When disabled, original_query should match the query + assert result["original_query"] == "Test query" + + +# ============================================================================= +# Hybrid Search Tests +# ============================================================================= + +class TestHybridSearch: + """Tests for Hybrid Search (Dense + Sparse) module.""" + + def test_hybrid_search_config(self): + """Test hybrid search configuration.""" + from hybrid_search import HYBRID_ENABLED, DENSE_WEIGHT, SPARSE_WEIGHT, get_hybrid_search_info + + info = get_hybrid_search_info() + assert "enabled" in info + assert "dense_weight" in info + assert "sparse_weight" in info + assert info["dense_weight"] + info["sparse_weight"] == pytest.approx(1.0) + + def test_bm25_tokenization(self): + """Test BM25 German tokenization.""" + from hybrid_search import BM25 + + bm25 = BM25() + tokens = bm25._tokenize("Der Erwartungshorizont für die Abiturprüfung") + + # German stopwords should be removed + assert "der" not in tokens + assert "für" not in tokens + assert "die" not in tokens + + # Content words should remain + assert "erwartungshorizont" in tokens + assert "abiturprüfung" in tokens + + def test_bm25_stopwords(self): + """Test that German stopwords are defined.""" + from hybrid_search import GERMAN_STOPWORDS + + assert "der" in GERMAN_STOPWORDS + assert "die" in GERMAN_STOPWORDS + assert "und" in GERMAN_STOPWORDS + assert "ist" in GERMAN_STOPWORDS + assert len(GERMAN_STOPWORDS) > 50 + + def test_bm25_fit_and_search(self): + """Test BM25 fitting and searching.""" + from hybrid_search import BM25 + + documents = [ + "Der Erwartungshorizont für Mathematik enthält Bewertungskriterien.", + "Die Gedichtanalyse erfordert formale und inhaltliche Aspekte.", + "Biologie Klausur Bewertung nach Anforderungsbereichen.", + ] + + bm25 = BM25() + bm25.fit(documents) + + assert bm25.N == 3 + assert len(bm25.corpus) == 3 + + results = bm25.search("Mathematik Bewertungskriterien", top_k=2) + assert len(results) == 2 + assert results[0][0] == 0 # First document should rank highest + + def test_normalize_scores(self): + """Test score normalization.""" + from hybrid_search import normalize_scores + + scores = [0.5, 1.0, 0.0, 0.75] + normalized = normalize_scores(scores) + + assert min(normalized) == 0.0 + assert max(normalized) == 1.0 + assert len(normalized) == len(scores) + + def test_normalize_scores_empty(self): + """Test normalization with empty list.""" + from hybrid_search import normalize_scores + + assert normalize_scores([]) == [] + + def test_normalize_scores_same_value(self): + """Test normalization when all scores are the same.""" + from hybrid_search import normalize_scores + + scores = [0.5, 0.5, 0.5] + normalized = normalize_scores(scores) + assert all(s == 1.0 for s in normalized) + + +# ============================================================================= +# RAG Evaluation Tests +# ============================================================================= + +class TestRAGEvaluation: + """Tests for RAG Evaluation (RAGAS-inspired) module.""" + + def test_evaluation_config(self): + """Test RAG evaluation configuration.""" + from rag_evaluation import EVALUATION_ENABLED, EVAL_MODEL, get_evaluation_info + + info = get_evaluation_info() + assert "enabled" in info + assert "metrics" in info + assert "context_precision" in info["metrics"] + assert "faithfulness" in info["metrics"] + + def test_text_similarity(self): + """Test Jaccard text similarity.""" + from rag_evaluation import _text_similarity + + # Same text + sim1 = _text_similarity("Hello world", "Hello world") + assert sim1 == 1.0 + + # No overlap + sim2 = _text_similarity("Hello world", "Goodbye universe") + assert sim2 == 0.0 + + # Partial overlap + sim3 = _text_similarity("Hello beautiful world", "Hello cruel world") + assert 0 < sim3 < 1 + + def test_context_precision(self): + """Test context precision calculation.""" + from rag_evaluation import calculate_context_precision + + retrieved = ["Doc A about topic X", "Doc B about topic Y"] + relevant = ["Doc A about topic X"] + + precision = calculate_context_precision("query", retrieved, relevant) + assert precision == 0.5 # 1 out of 2 retrieved is relevant + + def test_context_precision_empty_retrieved(self): + """Test precision with no retrieved documents.""" + from rag_evaluation import calculate_context_precision + + precision = calculate_context_precision("query", [], ["relevant"]) + assert precision == 0.0 + + def test_context_recall(self): + """Test context recall calculation.""" + from rag_evaluation import calculate_context_recall + + retrieved = ["Doc A about topic X"] + relevant = ["Doc A about topic X", "Doc B about topic Y"] + + recall = calculate_context_recall("query", retrieved, relevant) + assert recall == 0.5 # Found 1 out of 2 relevant + + def test_context_recall_no_relevant(self): + """Test recall when there are no relevant documents.""" + from rag_evaluation import calculate_context_recall + + recall = calculate_context_recall("query", ["something"], []) + assert recall == 1.0 # Nothing to miss + + def test_load_save_eval_results(self): + """Test evaluation results file operations.""" + from rag_evaluation import _load_eval_results, _save_eval_results + from pathlib import Path + import tempfile + import os + + # Use temp file + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + temp_path = Path(f.name) # Convert to Path object + + try: + with patch('rag_evaluation.EVAL_RESULTS_FILE', temp_path): + # Save some results + test_results = [{"test": "data", "score": 0.8}] + _save_eval_results(test_results) + + # Load them back + loaded = _load_eval_results() + assert loaded == test_results + finally: + os.unlink(temp_path) + + +# ============================================================================= +# PDF Extraction Tests +# ============================================================================= + +class TestPDFExtraction: + """Tests for PDF Extraction module.""" + + def test_pdf_extraction_config(self): + """Test PDF extraction configuration.""" + from pdf_extraction import PDF_BACKEND, get_pdf_extraction_info + + info = get_pdf_extraction_info() + assert "configured_backend" in info + assert "available_backends" in info + assert "recommended" in info + + def test_detect_available_backends(self): + """Test backend detection.""" + from pdf_extraction import _detect_available_backends + + backends = _detect_available_backends() + assert isinstance(backends, list) + # In Docker container, at least pypdf (BSD) or unstructured (Apache 2.0) should be available + # In local test environment without dependencies, list may be empty + # NOTE: PyMuPDF (AGPL) is NOT installed by default for license compliance + if backends: + # If any backend is found, verify it's one of the license-compliant options + for backend in backends: + assert backend in ["pypdf", "unstructured", "pymupdf"] + + def test_pdf_extraction_result_class(self): + """Test PDFExtractionResult data class.""" + from pdf_extraction import PDFExtractionResult + + result = PDFExtractionResult( + text="Extracted text", + backend_used="pypdf", + pages=5, + elements=[{"type": "paragraph"}], + tables=[{"text": "table data"}], + metadata={"key": "value"}, + ) + + assert result.text == "Extracted text" + assert result.backend_used == "pypdf" + assert result.pages == 5 + assert len(result.elements) == 1 + assert len(result.tables) == 1 + + # Test to_dict + d = result.to_dict() + assert d["text"] == "Extracted text" + assert d["element_count"] == 1 + assert d["table_count"] == 1 + + def test_pdf_extraction_error(self): + """Test PDF extraction error handling.""" + from pdf_extraction import PDFExtractionError + + with pytest.raises(PDFExtractionError): + raise PDFExtractionError("Test error") + + @pytest.mark.xfail(reason="_extract_with_pypdf is internal function not exposed in API") + def test_pypdf_extraction(self): + """Test pypdf extraction with a simple PDF (BSD-3-Clause licensed).""" + from pdf_extraction import _extract_with_pypdf, PDFExtractionError + + # Create a minimal valid PDF + # This is a very simple PDF that PyPDF2 can parse + simple_pdf = b"""%PDF-1.4 +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 44 >> +stream +BT +/F1 12 Tf +100 700 Td +(Hello World) Tj +ET +endstream +endobj +xref +0 5 +0000000000 65535 f +0000000009 00000 n +0000000058 00000 n +0000000115 00000 n +0000000206 00000 n +trailer +<< /Size 5 /Root 1 0 R >> +startxref +300 +%%EOF""" + + # This may fail because the PDF is too minimal, but tests the code path + try: + result = _extract_with_pypdf(simple_pdf) + assert result.backend_used == "pypdf" + except PDFExtractionError: + # Expected for minimal PDF + pass + + +# ============================================================================= +# Self-RAG Tests +# ============================================================================= + +class TestSelfRAG: + """Tests for Self-RAG / Corrective RAG module.""" + + def test_self_rag_config(self): + """Test Self-RAG configuration.""" + from self_rag import ( + SELF_RAG_ENABLED, RELEVANCE_THRESHOLD, GROUNDING_THRESHOLD, + MAX_RETRIEVAL_ATTEMPTS, get_self_rag_info + ) + + info = get_self_rag_info() + assert "enabled" in info + assert "relevance_threshold" in info + assert "grounding_threshold" in info + assert "max_retrieval_attempts" in info + assert "features" in info + + def test_retrieval_decision_enum(self): + """Test RetrievalDecision enum.""" + from self_rag import RetrievalDecision + + assert RetrievalDecision.SUFFICIENT.value == "sufficient" + assert RetrievalDecision.NEEDS_MORE.value == "needs_more" + assert RetrievalDecision.REFORMULATE.value == "reformulate" + assert RetrievalDecision.FALLBACK.value == "fallback" + + @pytest.mark.asyncio + async def test_grade_document_relevance_no_llm(self): + """Test document grading without LLM (keyword-based).""" + from self_rag import grade_document_relevance + + with patch('self_rag.OPENAI_API_KEY', ''): + score, reason = await grade_document_relevance( + "Mathematik Bewertungskriterien", + "Der Erwartungshorizont für Mathematik enthält klare Bewertungskriterien." + ) + + assert 0 <= score <= 1 + assert "Keyword" in reason + + @pytest.mark.asyncio + async def test_decide_retrieval_strategy_empty_docs(self): + """Test retrieval decision with no documents.""" + from self_rag import decide_retrieval_strategy, RetrievalDecision + + decision, meta = await decide_retrieval_strategy("query", [], attempt=1) + assert decision == RetrievalDecision.REFORMULATE + assert "No documents" in meta.get("reason", "") + + @pytest.mark.asyncio + async def test_decide_retrieval_strategy_max_attempts(self): + """Test retrieval decision at max attempts.""" + from self_rag import decide_retrieval_strategy, RetrievalDecision, MAX_RETRIEVAL_ATTEMPTS + + decision, meta = await decide_retrieval_strategy( + "query", [], attempt=MAX_RETRIEVAL_ATTEMPTS + ) + assert decision == RetrievalDecision.FALLBACK + + @pytest.mark.asyncio + async def test_reformulate_query_no_llm(self): + """Test query reformulation without LLM.""" + from self_rag import reformulate_query + + with patch('self_rag.OPENAI_API_KEY', ''): + result = await reformulate_query("EA Mathematik Anforderungen") + + # Should expand abbreviations + assert "erhöhtes Anforderungsniveau" in result or result == "EA Mathematik Anforderungen" + + @pytest.mark.asyncio + async def test_filter_relevant_documents(self): + """Test document filtering by relevance.""" + from self_rag import filter_relevant_documents + + docs = [ + {"text": "Mathematik Bewertungskriterien für Abitur"}, + {"text": "Rezept für Schokoladenkuchen"}, + {"text": "Erwartungshorizont Mathematik eA"}, + ] + + with patch('self_rag.OPENAI_API_KEY', ''): + relevant, filtered = await filter_relevant_documents( + "Mathematik Abitur Bewertung", + docs, + threshold=0.1 # Low threshold for keyword matching + ) + + # All docs should have relevance_score added + for doc in relevant + filtered: + assert "relevance_score" in doc + + @pytest.mark.asyncio + async def test_self_rag_retrieve_disabled(self): + """Test self_rag_retrieve when disabled.""" + from self_rag import self_rag_retrieve + + async def mock_search(query, **kwargs): + return [{"id": "1", "text": "Result"}] + + with patch('self_rag.SELF_RAG_ENABLED', False): + result = await self_rag_retrieve( + query="Test query", + search_func=mock_search, + ) + + assert result["self_rag_enabled"] is False + assert len(result["results"]) == 1 + + +# ============================================================================= +# Integration Tests - Module Availability +# ============================================================================= + +class TestModuleAvailability: + """Test that all advanced RAG modules are properly importable.""" + + def test_hyde_import(self): + """Test HyDE module import.""" + from hyde import ( + generate_hypothetical_document, + hyde_search, + get_hyde_info, + HYDE_ENABLED, + ) + assert callable(generate_hypothetical_document) + assert callable(hyde_search) + + def test_hybrid_search_import(self): + """Test Hybrid Search module import.""" + from hybrid_search import ( + BM25, + hybrid_search, + get_hybrid_search_info, + HYBRID_ENABLED, + ) + assert callable(hybrid_search) + assert BM25 is not None + + def test_rag_evaluation_import(self): + """Test RAG Evaluation module import.""" + from rag_evaluation import ( + calculate_context_precision, + calculate_context_recall, + evaluate_faithfulness, + evaluate_answer_relevancy, + evaluate_rag_response, + get_evaluation_info, + ) + assert callable(calculate_context_precision) + assert callable(evaluate_rag_response) + + def test_pdf_extraction_import(self): + """Test PDF Extraction module import.""" + from pdf_extraction import ( + extract_text_from_pdf, + extract_text_from_pdf_enhanced, + get_pdf_extraction_info, + PDFExtractionResult, + ) + assert callable(extract_text_from_pdf) + assert callable(extract_text_from_pdf_enhanced) + + def test_self_rag_import(self): + """Test Self-RAG module import.""" + from self_rag import ( + grade_document_relevance, + filter_relevant_documents, + self_rag_retrieve, + get_self_rag_info, + RetrievalDecision, + ) + assert callable(self_rag_retrieve) + assert RetrievalDecision is not None + + +# ============================================================================= +# End-to-End Feature Verification +# ============================================================================= + +class TestFeatureVerification: + """Verify that all features are properly configured and usable.""" + + def test_all_features_have_info_endpoints(self): + """Test that all features provide info functions.""" + from hyde import get_hyde_info + from hybrid_search import get_hybrid_search_info + from rag_evaluation import get_evaluation_info + from pdf_extraction import get_pdf_extraction_info + from self_rag import get_self_rag_info + + infos = [ + get_hyde_info(), + get_hybrid_search_info(), + get_evaluation_info(), + get_pdf_extraction_info(), + get_self_rag_info(), + ] + + for info in infos: + assert isinstance(info, dict) + # Each should have an "enabled" or similar status field + assert any(k in info for k in ["enabled", "configured_backend", "available_backends"]) + + def test_environment_variables_documented(self): + """Test that all environment variables are accessible.""" + import os + + # These env vars should be used by the modules + env_vars = [ + "HYDE_ENABLED", + "HYDE_LLM_BACKEND", + "HYBRID_SEARCH_ENABLED", + "HYBRID_DENSE_WEIGHT", + "RAG_EVALUATION_ENABLED", + "PDF_EXTRACTION_BACKEND", + "SELF_RAG_ENABLED", + ] + + # Just verify they're readable (will use defaults if not set) + for var in env_vars: + os.getenv(var, "default") # Should not raise + + +# ============================================================================= +# Admin API Tests (RAG Documentation with HTML rendering) +# ============================================================================= + +class TestRAGAdminAPI: + """Tests for RAG Admin API endpoints.""" + + @pytest.mark.xfail(reason="get_rag_documentation not yet implemented - Backlog item") + @pytest.mark.asyncio + async def test_rag_documentation_markdown_format(self): + """Test RAG documentation endpoint returns markdown.""" + from admin_api import get_rag_documentation + + result = await get_rag_documentation(format="markdown") + + assert result["format"] == "markdown" + assert "content" in result + assert result["status"] in ["success", "inline"] + + @pytest.mark.xfail(reason="get_rag_documentation not yet implemented - Backlog item") + @pytest.mark.asyncio + async def test_rag_documentation_html_format(self): + """Test RAG documentation endpoint returns HTML with tables.""" + from admin_api import get_rag_documentation + + result = await get_rag_documentation(format="html") + + assert result["format"] == "html" + assert "content" in result + + # HTML should contain proper table styling + html = result["content"] + assert "" in html or "" in html + assert " + + +
              +

              BreakPilot Upload

              + DSGVO-konform +
              + +
              + + +
              + +
              + +
              +
              PDF-Dateien hochladen
              +
              Tippen zum Auswaehlen oder hierher ziehen
              +
              Grosse Dateien bis 200 MB werden automatisch in Teilen hochgeladen
              +
              + + + +
              + +
              +

              Hinweise:

              +
                +
              • Die Dateien werden lokal im WLAN uebertragen
              • +
              • Keine Daten werden ins Internet gesendet
              • +
              • Unterstuetzte Formate: PDF
              • +
              +
              + +
              Server: wird ermittelt...
              + + + +''' + return HTMLResponse(content=html_content) diff --git a/klausur-service/backend/vocab_worksheet_api.py b/klausur-service/backend/vocab_worksheet_api.py new file mode 100644 index 0000000..1aeb49e --- /dev/null +++ b/klausur-service/backend/vocab_worksheet_api.py @@ -0,0 +1,2065 @@ +""" +Vocabulary Worksheet API - Extract vocabulary from textbook pages and generate worksheets. + +DATENSCHUTZ/PRIVACY: +- Alle Verarbeitung erfolgt lokal (Mac Mini mit Ollama) +- Keine Daten werden an externe Server gesendet +- DSGVO-konform fuer Schulumgebungen + +Workflow: +1. POST /sessions - Create a vocabulary extraction session +2. POST /sessions/{id}/upload - Upload textbook page image +3. GET /sessions/{id}/vocabulary - Get extracted vocabulary +4. PUT /sessions/{id}/vocabulary - Edit vocabulary (corrections) +5. POST /sessions/{id}/generate - Generate worksheet PDF +6. GET /worksheets/{id}/pdf - Download generated PDF +""" + +from fastapi import APIRouter, HTTPException, UploadFile, File, Form, Query +from fastapi.responses import StreamingResponse +from pydantic import BaseModel +from typing import Optional, List, Dict, Any +from datetime import datetime +from enum import Enum +import uuid +import os +import io +import json +import base64 +import logging + +# PostgreSQL persistence (replaces in-memory storage) +from vocab_session_store import ( + init_vocab_tables, + create_session_db, + get_session_db, + list_sessions_db, + update_session_db, + delete_session_db, + add_vocabulary_db, + get_vocabulary_db, + update_vocabulary_db, + clear_page_vocabulary_db, + create_worksheet_db, + get_worksheet_db, + delete_worksheets_for_session_db, + cache_pdf_data, + get_cached_pdf_data, + clear_cached_pdf_data, +) + +logger = logging.getLogger(__name__) + +# Ollama Configuration - Direct call without external modules +OLLAMA_URL = os.getenv("OLLAMA_URL", "http://host.docker.internal:11434") +VISION_MODEL = os.getenv("OLLAMA_VISION_MODEL", "qwen2.5vl:32b") + +# Try to import MinIO storage +try: + from minio_storage import upload_to_minio, get_from_minio + MINIO_AVAILABLE = True +except ImportError: + MINIO_AVAILABLE = False + logger.warning("MinIO storage not available, using local storage") + +router = APIRouter(prefix="/api/v1/vocab", tags=["Vocabulary Worksheets"]) + +# Local storage path +LOCAL_STORAGE_PATH = os.getenv("VOCAB_STORAGE_PATH", "/app/vocab-worksheets") + + +# ============================================================================= +# Enums and Pydantic Models +# ============================================================================= + +class WorksheetType(str, Enum): + EN_TO_DE = "en_to_de" # English -> German translation + DE_TO_EN = "de_to_en" # German -> English translation + COPY_PRACTICE = "copy" # Write word multiple times + GAP_FILL = "gap_fill" # Fill in the blanks + COMBINED = "combined" # All types combined + + +class SessionStatus(str, Enum): + PENDING = "pending" # Session created, no upload yet + PROCESSING = "processing" # OCR in progress + EXTRACTED = "extracted" # Vocabulary extracted, ready to edit + COMPLETED = "completed" # Worksheet generated + + +class VocabularyEntry(BaseModel): + id: str + english: str + german: str + example_sentence: Optional[str] = None + example_sentence_gap: Optional[str] = None # With ___ for gap-fill + word_type: Optional[str] = None # noun, verb, adjective, etc. + source_page: Optional[int] = None # Page number where entry was found (1-indexed) + # Grid position fields for layout-preserving OCR + source_x: Optional[float] = None # X position as percentage (0-100) + source_y: Optional[float] = None # Y position as percentage (0-100) + source_width: Optional[float] = None # Width as percentage (0-100) + source_height: Optional[float] = None # Height as percentage (0-100) + source_column: Optional[int] = None # 0-indexed column in detected grid + source_row: Optional[int] = None # 0-indexed row in detected grid + confidence: Optional[float] = None # OCR confidence score (0-1) + recognition_status: Optional[str] = None # recognized | manual | unrecognized + + +class OcrPrompts(BaseModel): + filterHeaders: bool = True + filterFooters: bool = True + filterPageNumbers: bool = True + customFilter: str = "" + headerPatterns: List[str] = [] + footerPatterns: List[str] = [] + + +class SessionCreate(BaseModel): + name: str + description: Optional[str] = None + source_language: str = "en" # Source language (default English) + target_language: str = "de" # Target language (default German) + ocr_prompts: Optional[OcrPrompts] = None # OCR filtering settings from frontend + + +class SessionResponse(BaseModel): + id: str + name: str + description: Optional[str] + source_language: str + target_language: str + status: str + vocabulary_count: int + image_path: Optional[str] + created_at: datetime + + +class VocabularyResponse(BaseModel): + session_id: str + vocabulary: List[VocabularyEntry] + extraction_confidence: Optional[float] + + +class VocabularyUpdate(BaseModel): + vocabulary: List[VocabularyEntry] + + +class WorksheetGenerateRequest(BaseModel): + worksheet_types: List[WorksheetType] + title: Optional[str] = None + include_solutions: bool = True + repetitions: int = 3 # For copy practice + line_height: str = "normal" # normal, large, extra-large + + +class WorksheetResponse(BaseModel): + id: str + session_id: str + worksheet_types: List[str] + pdf_path: str + solution_path: Optional[str] + generated_at: datetime + + +# ============================================================================= +# PostgreSQL Storage (persistent across container restarts) +# ============================================================================= + +# Note: In-memory storage removed. All data now persisted in PostgreSQL. +# See vocab_session_store.py for implementation. + +# Startup event to initialize tables +@router.on_event("startup") +async def startup(): + """Initialize vocab session tables on startup.""" + logger.info("Initializing vocab session PostgreSQL tables...") + success = await init_vocab_tables() + if success: + logger.info("Vocab session tables ready") + else: + logger.warning("Failed to initialize vocab tables - storage may not work") + + +# ============================================================================= +# Vision LLM Vocabulary Extraction +# ============================================================================= + +VOCAB_EXTRACTION_PROMPT = """Analysiere dieses Bild einer Vokabelliste aus einem Schulbuch. + +AUFGABE: Extrahiere alle Vokabeleintraege in folgendem JSON-Format: + +{ + "vocabulary": [ + { + "english": "to improve", + "german": "verbessern", + "example": "I want to improve my English." + } + ] +} + +REGELN: +1. Erkenne das typische 3-Spalten-Layout: Englisch | Deutsch | Beispielsatz +2. Behalte die exakte Schreibweise bei +3. Bei fehlenden Beispielsaetzen: "example": null +4. Ignoriere Seitenzahlen, Ueberschriften, Kapitelnummern +5. Gib NUR valides JSON zurueck, keine Erklaerungen +6. Wenn Wortarten angegeben sind (n, v, adj), extrahiere sie als "word_type" + +Beispiel-Output: +{ + "vocabulary": [ + {"english": "achievement", "german": "Leistung, Errungenschaft", "example": "Her achievements were impressive.", "word_type": "n"}, + {"english": "to achieve", "german": "erreichen, erzielen", "example": "She achieved her goals.", "word_type": "v"} + ] +}""" + + +async def extract_vocabulary_from_image( + image_data: bytes, + filename: str, + page_number: int = 0, + ocr_method: str = "tesseract" # Options: "tesseract" (D), "vision_llm" (B), "paddleocr" (C) +) -> tuple[List[VocabularyEntry], float, str]: + """ + Extract vocabulary from an image using different OCR methods. + + OCR Methods (documented in SBOM): + - Loesung A: User's 32B LLM (external) + - Loesung B: Vision LLM (Ollama llama3.2-vision) + - Loesung C: PaddleOCR + LLM (DEAKTIVIERT - funktioniert nicht unter Rosetta 2) + - Loesung D: Tesseract OCR + LLM (ARM64-nativ, Apache 2.0) <- DEFAULT + + Args: + image_data: Image bytes + filename: Original filename for logging + page_number: 0-indexed page number for error messages + ocr_method: OCR method to use ("tesseract", "vision_llm", "paddleocr") + + Returns: + Tuple of (vocabulary_entries, confidence, error_message) + error_message is empty string on success + """ + import httpx + + # ========================================================================== + # LOESUNG D: Tesseract OCR + LLM Gateway (DEFAULT - ARM64-nativ) + # ========================================================================== + if ocr_method == "tesseract": + try: + from tesseract_vocab_extractor import extract_vocabulary_tesseract, is_tesseract_available + + if not is_tesseract_available(): + logger.warning("Tesseract not available, falling back to Vision LLM") + ocr_method = "vision_llm" + else: + logger.info(f"Using TESSERACT OCR for {filename} (Loesung D)") + + vocab_dicts, confidence, error = await extract_vocabulary_tesseract(image_data, filename) + + if error: + logger.warning(f"Tesseract extraction had issues: {error}") + elif vocab_dicts: + vocabulary = [ + VocabularyEntry( + id=str(uuid.uuid4()), + english=v.get("source_word", "") if v.get("source_lang") == "en" else v.get("target_word", ""), + german=v.get("source_word", "") if v.get("source_lang") == "de" else v.get("target_word", ""), + example_sentence=v.get("context"), + source_page=page_number + 1 + ) + for v in vocab_dicts + ] + logger.info(f"Tesseract extraction: {len(vocabulary)} entries from {filename}") + return vocabulary, confidence, "" + + except ImportError as e: + logger.warning(f"Tesseract extractor not available: {e}. Falling back to Vision LLM.") + ocr_method = "vision_llm" + except Exception as e: + logger.warning(f"Tesseract extraction failed: {e}. Falling back to Vision LLM.") + import traceback + logger.debug(traceback.format_exc()) + ocr_method = "vision_llm" + + # ========================================================================== + # LOESUNG C: PaddleOCR + LLM Gateway (DEAKTIVIERT - Rosetta 2 Probleme) + # ========================================================================== + if ocr_method == "paddleocr": + try: + from hybrid_vocab_extractor import extract_vocabulary_hybrid + logger.info(f"Using PADDLEOCR for {filename} (Loesung C - experimentell)") + + vocab_dicts, confidence, error = await extract_vocabulary_hybrid(image_data, page_number) + + if error: + logger.warning(f"PaddleOCR extraction had issues: {error}") + elif vocab_dicts: + vocabulary = [ + VocabularyEntry( + id=str(uuid.uuid4()), + english=v.get("english", ""), + german=v.get("german", ""), + example_sentence=v.get("example"), + source_page=page_number + 1 + ) + for v in vocab_dicts + if v.get("english") and v.get("german") + ] + logger.info(f"PaddleOCR extraction: {len(vocabulary)} entries from {filename}") + return vocabulary, confidence, "" + + except ImportError as e: + logger.warning(f"PaddleOCR not available: {e}. Falling back to Vision LLM.") + except Exception as e: + logger.warning(f"PaddleOCR failed: {e}. Falling back to Vision LLM.") + import traceback + logger.debug(traceback.format_exc()) + + # ========================================================================== + # FALLBACK: Vision LLM (Ollama llama3.2-vision) + # ========================================================================== + logger.info(f"Using VISION LLM extraction for {filename}") + + try: + # First check if Ollama is available + async with httpx.AsyncClient(timeout=10.0) as check_client: + try: + health_response = await check_client.get(f"{OLLAMA_URL}/api/tags") + if health_response.status_code != 200: + logger.error(f"Ollama not available at {OLLAMA_URL}") + return [], 0.0, f"Seite {page_number + 1}: Ollama nicht verfuegbar" + except Exception as e: + logger.error(f"Ollama health check failed: {e}") + return [], 0.0, f"Seite {page_number + 1}: Verbindung zu Ollama fehlgeschlagen" + + image_base64 = base64.b64encode(image_data).decode("utf-8") + + payload = { + "model": VISION_MODEL, + "messages": [ + { + "role": "user", + "content": VOCAB_EXTRACTION_PROMPT, + "images": [image_base64] + } + ], + "stream": False, + "options": { + "temperature": 0.1, + "num_predict": 4096, + } + } + + logger.info(f"Extracting vocabulary from {filename} ({len(image_data)} bytes) using {VISION_MODEL}") + + # Increased timeout for Vision models (they can be slow) + async with httpx.AsyncClient(timeout=600.0) as client: + response = await client.post( + f"{OLLAMA_URL}/api/chat", + json=payload, + timeout=300.0 # 5 minutes per page + ) + response.raise_for_status() + + data = response.json() + extracted_text = data.get("message", {}).get("content", "") + + logger.info(f"Ollama response received: {len(extracted_text)} chars") + + # Parse JSON from response + vocabulary = parse_vocabulary_json(extracted_text) + + # Set source_page for each entry + for v in vocabulary: + v.source_page = page_number + 1 + + # Estimate confidence + confidence = 0.85 if len(vocabulary) > 0 else 0.1 + + logger.info(f"Vision LLM extracted {len(vocabulary)} vocabulary entries from {filename}") + + return vocabulary, confidence, "" + + except httpx.TimeoutException: + logger.error(f"Ollama request timed out for {filename} (model: {VISION_MODEL})") + return [], 0.0, f"Seite {page_number + 1}: Timeout - Verarbeitung dauerte zu lange" + except Exception as e: + logger.error(f"Vocabulary extraction failed for {filename}: {e}") + import traceback + logger.error(traceback.format_exc()) + return [], 0.0, f"Seite {page_number + 1}: Fehler - {str(e)[:50]}" + + +def _get_demo_vocabulary() -> List[VocabularyEntry]: + """Return demo vocabulary for testing when Vision LLM is not available.""" + demo_entries = [ + {"english": "to achieve", "german": "erreichen, erzielen", "example": "She achieved her goals."}, + {"english": "achievement", "german": "Leistung, Errungenschaft", "example": "That was a great achievement."}, + {"english": "improve", "german": "verbessern", "example": "I want to improve my English."}, + {"english": "improvement", "german": "Verbesserung", "example": "There has been a lot of improvement."}, + {"english": "success", "german": "Erfolg", "example": "The project was a success."}, + {"english": "successful", "german": "erfolgreich", "example": "She is a successful businesswoman."}, + {"english": "fail", "german": "scheitern, durchfallen", "example": "Don't be afraid to fail."}, + {"english": "failure", "german": "Misserfolg, Versagen", "example": "Failure is part of learning."}, + ] + return [ + VocabularyEntry( + id=str(uuid.uuid4()), + english=e["english"], + german=e["german"], + example_sentence=e.get("example"), + ) + for e in demo_entries + ] + + +def parse_vocabulary_json(text: str) -> List[VocabularyEntry]: + """Parse vocabulary JSON from LLM response with robust error handling.""" + import re + + def clean_json_string(s: str) -> str: + """Clean a JSON string by removing control characters and fixing common issues.""" + # Remove control characters except newlines and tabs + s = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', '', s) + # Replace unescaped newlines within strings with space + # This is a simplistic approach - replace actual newlines with escaped ones + s = s.replace('\n', '\\n').replace('\r', '\\r').replace('\t', '\\t') + return s + + def try_parse_json(json_str: str) -> dict: + """Try multiple strategies to parse JSON.""" + # Strategy 1: Direct parse + try: + return json.loads(json_str) + except json.JSONDecodeError: + pass + + # Strategy 2: Clean and parse + try: + cleaned = clean_json_string(json_str) + return json.loads(cleaned) + except json.JSONDecodeError: + pass + + # Strategy 3: Try to fix common issues + try: + # Remove trailing commas before } or ] + fixed = re.sub(r',(\s*[}\]])', r'\1', json_str) + # Fix unquoted keys + fixed = re.sub(r'(\{|\,)\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*:', r'\1"\2":', fixed) + return json.loads(fixed) + except json.JSONDecodeError: + pass + + return None + + try: + # Find JSON in response (may have extra text) + start = text.find('{') + end = text.rfind('}') + 1 + + if start == -1 or end == 0: + logger.warning("No JSON found in response") + return [] + + json_str = text[start:end] + data = try_parse_json(json_str) + + if data is None: + # Strategy 4: Extract vocabulary entries using regex as fallback + logger.warning("JSON parsing failed, trying regex extraction") + vocabulary = [] + # Match patterns like {"english": "...", "german": "...", ...} + pattern = r'\{\s*"english"\s*:\s*"([^"]*?)"\s*,\s*"german"\s*:\s*"([^"]*?)"(?:\s*,\s*"example"\s*:\s*(?:"([^"]*?)"|null))?' + matches = re.findall(pattern, text, re.IGNORECASE | re.DOTALL) + + for match in matches: + english = match[0].strip() if match[0] else "" + german = match[1].strip() if match[1] else "" + example = match[2].strip() if len(match) > 2 and match[2] else None + + if english and german: + vocab_entry = VocabularyEntry( + id=str(uuid.uuid4()), + english=english, + german=german, + example_sentence=example, + ) + vocabulary.append(vocab_entry) + + if vocabulary: + logger.info(f"Regex extraction found {len(vocabulary)} entries") + return vocabulary + + # Normal JSON parsing succeeded + vocabulary = [] + for i, entry in enumerate(data.get("vocabulary", [])): + english = entry.get("english", "").strip() + german = entry.get("german", "").strip() + + # Skip entries that look like hallucinations (very long or containing unusual patterns) + if len(english) > 100 or len(german) > 200: + logger.warning(f"Skipping suspicious entry: {english[:50]}...") + continue + + if not english or not german: + continue + + vocab_entry = VocabularyEntry( + id=str(uuid.uuid4()), + english=english, + german=german, + example_sentence=entry.get("example"), + word_type=entry.get("word_type"), + ) + vocabulary.append(vocab_entry) + + return vocabulary + + except Exception as e: + logger.error(f"Failed to parse vocabulary JSON: {e}") + import traceback + logger.error(traceback.format_exc()) + return [] + + +# ============================================================================= +# Worksheet PDF Generation +# ============================================================================= + +def generate_worksheet_html( + vocabulary: List[VocabularyEntry], + worksheet_type: WorksheetType, + title: str, + show_solutions: bool = False, + repetitions: int = 3, + line_height: str = "normal" +) -> str: + """Generate HTML for a worksheet.""" + + # Line height CSS + line_heights = { + "normal": "2.5em", + "large": "3.5em", + "extra-large": "4.5em" + } + lh = line_heights.get(line_height, "2.5em") + + html = f""" + + + + + + +

              {title}

              +
              Name: _________________________ Datum: _____________
              +""" + + if worksheet_type == WorksheetType.EN_TO_DE: + html += '
              Uebersetze ins Deutsche:
              ' + html += '' + for entry in vocabulary: + if show_solutions: + html += f'' + else: + html += f'' + html += '
              {entry.english}{entry.german}
              {entry.english}
              ' + + elif worksheet_type == WorksheetType.DE_TO_EN: + html += '
              Uebersetze ins Englische:
              ' + html += '' + for entry in vocabulary: + if show_solutions: + html += f'' + else: + html += f'' + html += '
              {entry.german}{entry.english}
              {entry.german}
              ' + + elif worksheet_type == WorksheetType.COPY_PRACTICE: + html += '
              Schreibe jedes Wort mehrmals:
              ' + html += '' + for entry in vocabulary: + html += f'' + html += '' + html += '
              {entry.english}' + if show_solutions: + html += f' {entry.english} ' * repetitions + html += '
              ' + + elif worksheet_type == WorksheetType.GAP_FILL: + entries_with_examples = [e for e in vocabulary if e.example_sentence] + if entries_with_examples: + html += '
              Fuege das passende Wort ein:
              ' + for i, entry in enumerate(entries_with_examples, 1): + # Create gap sentence by removing the English word + gap_sentence = entry.example_sentence + for word in entry.english.split(): + if word.lower() in gap_sentence.lower(): + gap_sentence = gap_sentence.replace(word, '') + gap_sentence = gap_sentence.replace(word.capitalize(), '') + gap_sentence = gap_sentence.replace(word.lower(), '') + break + + html += f'

              {i}. {gap_sentence}

              ' + if show_solutions: + html += f'

              Loesung: {entry.english}

              ' + else: + html += f'

              ({entry.german})

              ' + html += '
              ' + + html += '' + return html + + +async def generate_worksheet_pdf(html: str) -> bytes: + """Generate PDF from HTML using WeasyPrint.""" + try: + from weasyprint import HTML + pdf_bytes = HTML(string=html).write_pdf() + return pdf_bytes + except ImportError: + logger.warning("WeasyPrint not available, returning HTML") + return html.encode('utf-8') + except Exception as e: + logger.error(f"PDF generation failed: {e}") + raise + + +# ============================================================================= +# API Endpoints +# ============================================================================= + +@router.post("/sessions", response_model=SessionResponse) +async def create_session(session: SessionCreate): + """Create a new vocabulary extraction session.""" + session_id = str(uuid.uuid4()) + + # Store in PostgreSQL + db_session = await create_session_db( + session_id=session_id, + name=session.name, + description=session.description, + source_language=session.source_language, + target_language=session.target_language, + ocr_prompts=session.ocr_prompts.model_dump() if session.ocr_prompts else None, + ) + + if db_session is None: + raise HTTPException(status_code=500, detail="Failed to create session in database") + + # Create storage directory for files + session_dir = os.path.join(LOCAL_STORAGE_PATH, session_id) + os.makedirs(session_dir, exist_ok=True) + + return SessionResponse( + id=session_id, + name=session.name, + description=session.description, + source_language=session.source_language, + target_language=session.target_language, + status=SessionStatus.PENDING.value, + vocabulary_count=0, + image_path=None, + created_at=db_session.created_at or datetime.utcnow(), + ) + + +@router.get("/sessions", response_model=List[SessionResponse]) +async def list_sessions(limit: int = Query(50, ge=1, le=100)): + """List all vocabulary sessions.""" + sessions = await list_sessions_db(limit=limit) + + return [ + SessionResponse( + id=s.id, + name=s.name, + description=s.description, + source_language=s.source_language, + target_language=s.target_language, + status=s.status, + vocabulary_count=s.vocabulary_count, + image_path=s.image_path, + created_at=s.created_at or datetime.utcnow(), + ) + for s in sessions + ] + + +@router.get("/sessions/{session_id}", response_model=SessionResponse) +async def get_session(session_id: str): + """Get a specific session.""" + s = await get_session_db(session_id) + if s is None: + raise HTTPException(status_code=404, detail="Session not found") + + return SessionResponse( + id=s.id, + name=s.name, + description=s.description, + source_language=s.source_language, + target_language=s.target_language, + status=s.status, + vocabulary_count=s.vocabulary_count, + image_path=s.image_path, + created_at=s.created_at or datetime.utcnow(), + ) + + +def get_pdf_page_count(pdf_data: bytes) -> int: + """Get the number of pages in a PDF.""" + try: + import fitz + pdf_document = fitz.open(stream=pdf_data, filetype="pdf") + count = pdf_document.page_count + pdf_document.close() + return count + except Exception as e: + logger.error(f"Failed to get PDF page count: {e}") + return 0 + + +async def convert_pdf_page_to_image(pdf_data: bytes, page_number: int = 0, thumbnail: bool = False) -> bytes: + """Convert a specific page of PDF to PNG image using PyMuPDF. + + Args: + pdf_data: PDF file as bytes + page_number: 0-indexed page number + thumbnail: If True, return a smaller thumbnail image + """ + import gc + pix = None + pdf_document = None + + try: + import fitz # PyMuPDF + + pdf_document = fitz.open(stream=pdf_data, filetype="pdf") + + if pdf_document.page_count == 0: + raise ValueError("PDF has no pages") + + if page_number >= pdf_document.page_count: + raise ValueError(f"Page {page_number} does not exist (PDF has {pdf_document.page_count} pages)") + + page = pdf_document[page_number] + + # Render page to image + # For thumbnails: lower resolution, for OCR: higher resolution + zoom = 0.5 if thumbnail else 2.0 + mat = fitz.Matrix(zoom, zoom) + pix = page.get_pixmap(matrix=mat) + + png_data = pix.tobytes("png") + + logger.info(f"Converted PDF page {page_number} to PNG: {len(png_data)} bytes (thumbnail={thumbnail})") + return png_data + + except ImportError: + logger.error("PyMuPDF (fitz) not installed") + raise HTTPException(status_code=500, detail="PDF conversion not available - PyMuPDF not installed") + except Exception as e: + logger.error(f"PDF conversion failed: {e}") + raise HTTPException(status_code=400, detail=f"PDF conversion failed: {str(e)}") + finally: + # Explicit cleanup to prevent OOM + if pix is not None: + del pix + if pdf_document is not None: + pdf_document.close() + del pdf_document + gc.collect() + + +async def convert_pdf_to_images(pdf_data: bytes, pages: List[int] = None) -> List[bytes]: + """Convert multiple pages of PDF to PNG images. + + Args: + pdf_data: PDF file as bytes + pages: List of 0-indexed page numbers to convert. If None, convert all pages. + """ + import gc + pdf_document = None + + try: + import fitz + + pdf_document = fitz.open(stream=pdf_data, filetype="pdf") + + if pdf_document.page_count == 0: + raise ValueError("PDF has no pages") + + # If no pages specified, convert all + if pages is None: + pages = list(range(pdf_document.page_count)) + + images = [] + zoom = 2.0 + mat = fitz.Matrix(zoom, zoom) + + for page_num in pages: + if page_num < pdf_document.page_count: + page = pdf_document[page_num] + pix = page.get_pixmap(matrix=mat) + images.append(pix.tobytes("png")) + # Cleanup pixmap immediately to prevent memory buildup + del pix + gc.collect() + + logger.info(f"Converted {len(images)} PDF pages to images") + return images + + except ImportError: + logger.error("PyMuPDF (fitz) not installed") + raise HTTPException(status_code=500, detail="PDF conversion not available") + except Exception as e: + logger.error(f"PDF conversion failed: {e}") + raise HTTPException(status_code=400, detail=f"PDF conversion failed: {str(e)}") + finally: + if pdf_document is not None: + pdf_document.close() + del pdf_document + gc.collect() + + +@router.post("/sessions/{session_id}/upload") +async def upload_image( + session_id: str, + file: UploadFile = File(...), +): + """ + Upload a textbook page image or PDF and extract vocabulary. + + Supported formats: PNG, JPG, JPEG, PDF + """ + logger.info(f"Upload request for session {session_id}") + logger.info(f"File: filename={file.filename}, content_type={file.content_type}") + + session = await get_session_db(session_id) + if session is None: + logger.error(f"Session {session_id} not found") + raise HTTPException(status_code=404, detail="Session not found") + + # Validate file type - check both extension and content type + extension = file.filename.split('.')[-1].lower() if file.filename else '' + content_type = file.content_type or '' + + # Accept images and PDFs + valid_image_extensions = ['png', 'jpg', 'jpeg'] + valid_image_content_types = ['image/png', 'image/jpeg', 'image/jpg'] + is_pdf = extension == 'pdf' or content_type == 'application/pdf' + is_image = extension in valid_image_extensions or content_type in valid_image_content_types + + if not is_pdf and not is_image: + logger.error(f"Invalid file type: extension={extension}, content_type={content_type}") + raise HTTPException( + status_code=400, + detail=f"Only PNG, JPG, JPEG, PDF files are supported. Got: extension={extension}, content_type={content_type}" + ) + + # Determine final extension for saving + if is_pdf: + save_extension = 'png' # PDFs will be converted to PNG + elif extension in valid_image_extensions: + save_extension = extension + elif content_type == 'image/png': + save_extension = 'png' + else: + save_extension = 'jpg' + + # Read file content + content = await file.read() + logger.info(f"Read {len(content)} bytes from uploaded file") + + # Convert PDF to image if needed (first page only for single upload) + if is_pdf: + logger.info("Converting PDF to image...") + content = await convert_pdf_page_to_image(content, page_number=0, thumbnail=False) + logger.info(f"PDF converted, image size: {len(content)} bytes") + + # Save image + session_dir = os.path.join(LOCAL_STORAGE_PATH, session_id) + os.makedirs(session_dir, exist_ok=True) + image_path = os.path.join(session_dir, f"source.{save_extension}") + + with open(image_path, 'wb') as f: + f.write(content) + + # Update session status in DB + await update_session_db(session_id, status=SessionStatus.PROCESSING.value, image_path=image_path) + + # Extract vocabulary using Vision LLM + vocabulary, confidence, error = await extract_vocabulary_from_image(content, file.filename or "image.png", page_number=0) + + # Store vocabulary in DB + vocab_dicts = [v.dict() for v in vocabulary] + await add_vocabulary_db(session_id, vocab_dicts) + + # Update session with extraction results + await update_session_db( + session_id, + status=SessionStatus.EXTRACTED.value, + extraction_confidence=confidence, + vocabulary_count=len(vocabulary), + ) + + result = { + "session_id": session_id, + "filename": file.filename, + "image_path": image_path, + "vocabulary_count": len(vocabulary), + "extraction_confidence": confidence, + "status": SessionStatus.EXTRACTED.value, + } + + if error: + result["error"] = error + + return result + + +@router.get("/sessions/{session_id}/vocabulary", response_model=VocabularyResponse) +async def get_vocabulary(session_id: str): + """Get extracted vocabulary for a session.""" + session = await get_session_db(session_id) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + + vocab_dicts = await get_vocabulary_db(session_id) + vocabulary = [VocabularyEntry(**v) for v in vocab_dicts] + + return VocabularyResponse( + session_id=session_id, + vocabulary=vocabulary, + extraction_confidence=session.extraction_confidence, + ) + + +@router.put("/sessions/{session_id}/vocabulary") +async def update_vocabulary(session_id: str, update: VocabularyUpdate): + """Update vocabulary entries (for manual corrections).""" + session = await get_session_db(session_id) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + + # Replace all vocabulary entries + vocab_dicts = [v.dict() for v in update.vocabulary] + success = await update_vocabulary_db(session_id, vocab_dicts) + + if not success: + raise HTTPException(status_code=500, detail="Failed to update vocabulary") + + return { + "session_id": session_id, + "vocabulary_count": len(update.vocabulary), + "message": "Vocabulary updated successfully", + } + + +@router.post("/sessions/{session_id}/generate", response_model=WorksheetResponse) +async def generate_worksheet(session_id: str, request: WorksheetGenerateRequest): + """Generate worksheet PDF(s) from extracted vocabulary.""" + session = await get_session_db(session_id) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + + vocab_dicts = await get_vocabulary_db(session_id) + vocabulary = [VocabularyEntry(**v) for v in vocab_dicts] + + if not vocabulary: + raise HTTPException(status_code=400, detail="No vocabulary to generate worksheet from") + + worksheet_id = str(uuid.uuid4()) + title = request.title or session.name + + # Generate HTML for each worksheet type + combined_html = "" + for wtype in request.worksheet_types: + html = generate_worksheet_html( + vocabulary=vocabulary, + worksheet_type=wtype, + title=f"{title} - {wtype.value}", + show_solutions=False, + repetitions=request.repetitions, + line_height=request.line_height, + ) + combined_html += html + '
              ' + + # Generate PDF + try: + pdf_bytes = await generate_worksheet_pdf(combined_html) + except Exception as e: + raise HTTPException(status_code=500, detail=f"PDF generation failed: {e}") + + # Save PDF + session_dir = os.path.join(LOCAL_STORAGE_PATH, session_id) + os.makedirs(session_dir, exist_ok=True) + pdf_path = os.path.join(session_dir, f"worksheet_{worksheet_id}.pdf") + with open(pdf_path, 'wb') as f: + f.write(pdf_bytes) + + # Generate solution PDF if requested + solution_path = None + if request.include_solutions: + solution_html = "" + for wtype in request.worksheet_types: + html = generate_worksheet_html( + vocabulary=vocabulary, + worksheet_type=wtype, + title=f"{title} - {wtype.value} (Loesung)", + show_solutions=True, + repetitions=request.repetitions, + line_height=request.line_height, + ) + solution_html += html + '
              ' + + solution_bytes = await generate_worksheet_pdf(solution_html) + solution_path = os.path.join(session_dir, f"solution_{worksheet_id}.pdf") + with open(solution_path, 'wb') as f: + f.write(solution_bytes) + + # Store worksheet info in DB + worksheet = await create_worksheet_db( + worksheet_id=worksheet_id, + session_id=session_id, + worksheet_types=[wt.value for wt in request.worksheet_types], + pdf_path=pdf_path, + solution_path=solution_path, + ) + + if worksheet is None: + raise HTTPException(status_code=500, detail="Failed to save worksheet to database") + + # Update session status + await update_session_db(session_id, status=SessionStatus.COMPLETED.value) + + return WorksheetResponse( + id=worksheet_id, + session_id=session_id, + worksheet_types=worksheet.worksheet_types, + pdf_path=pdf_path, + solution_path=solution_path, + generated_at=worksheet.generated_at or datetime.utcnow(), + ) + + +@router.get("/worksheets/{worksheet_id}/pdf") +async def download_worksheet_pdf(worksheet_id: str): + """Download the generated worksheet PDF.""" + worksheet = await get_worksheet_db(worksheet_id) + if worksheet is None: + raise HTTPException(status_code=404, detail="Worksheet not found") + + pdf_path = worksheet.pdf_path + + if not pdf_path or not os.path.exists(pdf_path): + raise HTTPException(status_code=404, detail="PDF file not found") + + with open(pdf_path, 'rb') as f: + pdf_bytes = f.read() + + return StreamingResponse( + io.BytesIO(pdf_bytes), + media_type="application/pdf", + headers={"Content-Disposition": f"attachment; filename=worksheet_{worksheet_id}.pdf"} + ) + + +@router.get("/worksheets/{worksheet_id}/solution") +async def download_solution_pdf(worksheet_id: str): + """Download the solution PDF.""" + worksheet = await get_worksheet_db(worksheet_id) + if worksheet is None: + raise HTTPException(status_code=404, detail="Worksheet not found") + + solution_path = worksheet.solution_path + + if not solution_path or not os.path.exists(solution_path): + raise HTTPException(status_code=404, detail="Solution PDF not found") + + with open(solution_path, 'rb') as f: + pdf_bytes = f.read() + + return StreamingResponse( + io.BytesIO(pdf_bytes), + media_type="application/pdf", + headers={"Content-Disposition": f"attachment; filename=solution_{worksheet_id}.pdf"} + ) + + +@router.get("/sessions/{session_id}/image") +async def get_session_image(session_id: str): + """Get the uploaded source image for a session.""" + session = await get_session_db(session_id) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + + image_path = session.image_path + + if not image_path or not os.path.exists(image_path): + raise HTTPException(status_code=404, detail="Image not found") + + # Determine content type + extension = image_path.split('.')[-1].lower() + content_type = { + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + }.get(extension, 'application/octet-stream') + + with open(image_path, 'rb') as f: + image_bytes = f.read() + + return StreamingResponse( + io.BytesIO(image_bytes), + media_type=content_type, + ) + + +@router.post("/sessions/{session_id}/upload-pdf-info") +async def upload_pdf_get_info( + session_id: str, + file: UploadFile = File(...), +): + """ + Upload a PDF and get page count and thumbnails for preview. + Use this before processing to let user select pages. + """ + logger.info(f"PDF info request for session {session_id}") + + session = await get_session_db(session_id) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + + # Validate file type + extension = file.filename.split('.')[-1].lower() if file.filename else '' + content_type = file.content_type or '' + + if extension != 'pdf' and content_type != 'application/pdf': + raise HTTPException(status_code=400, detail="Only PDF files supported for this endpoint") + + content = await file.read() + + # Save PDF temporarily + session_dir = os.path.join(LOCAL_STORAGE_PATH, session_id) + os.makedirs(session_dir, exist_ok=True) + pdf_path = os.path.join(session_dir, "source.pdf") + + with open(pdf_path, 'wb') as f: + f.write(content) + + # Get page count + page_count = get_pdf_page_count(content) + + # Cache PDF data for later processing (in-memory for multi-page workflow) + cache_pdf_data(session_id, content) + + # Update session in DB + await update_session_db( + session_id, + pdf_path=pdf_path, + pdf_page_count=page_count, + status="pdf_uploaded", + ) + + return { + "session_id": session_id, + "page_count": page_count, + "filename": file.filename, + } + + +@router.get("/sessions/{session_id}/pdf-thumbnail/{page_number}") +async def get_pdf_thumbnail(session_id: str, page_number: int, hires: bool = False): + """Get a thumbnail image of a specific PDF page. + + Args: + session_id: Session ID + page_number: 0-indexed page number + hires: If True, return high-resolution image (zoom=2.0) instead of thumbnail (zoom=0.5) + """ + session = await get_session_db(session_id) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + + # Try cached PDF data first + pdf_data = get_cached_pdf_data(session_id) + + # If not cached, try to load from file + if not pdf_data and session.pdf_path and os.path.exists(session.pdf_path): + with open(session.pdf_path, 'rb') as f: + pdf_data = f.read() + cache_pdf_data(session_id, pdf_data) + + if not pdf_data: + raise HTTPException(status_code=400, detail="No PDF uploaded for this session") + + # Use thumbnail=False for high-res (zoom=2.0), thumbnail=True for low-res (zoom=0.5) + image_data = await convert_pdf_page_to_image(pdf_data, page_number, thumbnail=not hires) + + return StreamingResponse( + io.BytesIO(image_data), + media_type="image/png", + ) + + +@router.post("/sessions/{session_id}/process-single-page/{page_number}") +async def process_single_page( + session_id: str, + page_number: int, +): + """ + Process a SINGLE page of an uploaded PDF - completely isolated. + + This endpoint processes one page at a time to avoid LLM context issues. + The frontend should call this sequentially for each page. + + Returns the vocabulary for just this one page. + """ + logger.info(f"Processing SINGLE page {page_number + 1} for session {session_id}") + + session = await get_session_db(session_id) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + + # Try cached PDF data first + pdf_data = get_cached_pdf_data(session_id) + + # If not cached, try to load from file + if not pdf_data and session.pdf_path and os.path.exists(session.pdf_path): + with open(session.pdf_path, 'rb') as f: + pdf_data = f.read() + cache_pdf_data(session_id, pdf_data) + + if not pdf_data: + raise HTTPException(status_code=400, detail="No PDF uploaded for this session") + + page_count = session.pdf_page_count or 1 + + if page_number < 0 or page_number >= page_count: + raise HTTPException(status_code=400, detail=f"Invalid page number. PDF has {page_count} pages (0-indexed).") + + # Convert just this ONE page to image + image_data = await convert_pdf_page_to_image(pdf_data, page_number, thumbnail=False) + + # Extract vocabulary from this single page + vocabulary, confidence, error = await extract_vocabulary_from_image( + image_data, + f"page_{page_number + 1}.png", + page_number=page_number + ) + + if error: + logger.warning(f"Page {page_number + 1} failed: {error}") + return { + "session_id": session_id, + "page_number": page_number + 1, + "success": False, + "error": error, + "vocabulary": [], + "vocabulary_count": 0, + } + + # Convert vocabulary entries to dicts with page info + page_vocabulary = [] + for entry in vocabulary: + entry_dict = entry.dict() if hasattr(entry, 'dict') else (entry.__dict__.copy() if hasattr(entry, '__dict__') else dict(entry)) + entry_dict['source_page'] = page_number + 1 + page_vocabulary.append(entry_dict) + + logger.info(f"Page {page_number + 1}: {len(page_vocabulary)} Vokabeln extrahiert") + + # Clear existing entries for this page (in case of re-processing) + await clear_page_vocabulary_db(session_id, page_number + 1) + + # Add new vocabulary entries to DB + await add_vocabulary_db(session_id, page_vocabulary) + + # Update session status + await update_session_db(session_id, status=SessionStatus.EXTRACTED.value) + + # Get total count + all_vocab = await get_vocabulary_db(session_id) + + return { + "session_id": session_id, + "page_number": page_number + 1, + "success": True, + "vocabulary": page_vocabulary, + "vocabulary_count": len(page_vocabulary), + "total_vocabulary_count": len(all_vocab), + "extraction_confidence": confidence, + } + + +@router.post("/sessions/{session_id}/compare-ocr/{page_number}") +async def compare_ocr_methods( + session_id: str, + page_number: int, +): + """ + Compare different OCR methods on a single page. + + Runs available OCR solutions and compares: + - Extraction time + - Vocabulary found + - Confidence scores + + Solutions tested: + - Loesung B: Vision LLM (qwen2.5vl:32b via Ollama) + - Loesung D: Tesseract OCR + LLM structuring + - Loesung E: Claude Vision API (Anthropic) + + Returns comparison data for frontend visualization. + """ + import time + import httpx + + logger.info(f"OCR Comparison for session {session_id}, page {page_number}") + + session = await get_session_db(session_id) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + + # Try cached PDF data first + pdf_data = get_cached_pdf_data(session_id) + + # If not cached, try to load from file + if not pdf_data and session.pdf_path and os.path.exists(session.pdf_path): + with open(session.pdf_path, 'rb') as f: + pdf_data = f.read() + cache_pdf_data(session_id, pdf_data) + + if not pdf_data: + raise HTTPException(status_code=400, detail="No PDF uploaded for this session") + + page_count = session.pdf_page_count or 1 + + if page_number < 0 or page_number >= page_count: + raise HTTPException(status_code=400, detail=f"Invalid page number. PDF has {page_count} pages (0-indexed).") + + # Convert page to image once (shared by all methods) + image_data = await convert_pdf_page_to_image(pdf_data, page_number, thumbnail=False) + + results = { + "session_id": session_id, + "page_number": page_number + 1, + "methods": {} + } + + # ========================================================================== + # LOESUNG B: Vision LLM (qwen2.5vl:32b) + # ========================================================================== + try: + start_time = time.time() + vocab_b, confidence_b, error_b = await extract_vocabulary_from_image( + image_data, f"page_{page_number + 1}.png", page_number, ocr_method="vision_llm" + ) + duration_b = time.time() - start_time + + results["methods"]["vision_llm"] = { + "name": "Loesung B: Vision LLM", + "model": VISION_MODEL, + "duration_seconds": round(duration_b, 2), + "vocabulary_count": len(vocab_b), + "vocabulary": [ + {"english": v.english, "german": v.german, "example": v.example_sentence} + for v in vocab_b + ], + "confidence": confidence_b, + "error": error_b if error_b else None, + "success": len(vocab_b) > 0 + } + logger.info(f"Vision LLM: {len(vocab_b)} entries in {duration_b:.2f}s") + except Exception as e: + results["methods"]["vision_llm"] = { + "name": "Loesung B: Vision LLM", + "error": str(e), + "success": False + } + logger.error(f"Vision LLM comparison failed: {e}") + + # ========================================================================== + # LOESUNG D: Tesseract OCR + LLM + # ========================================================================== + try: + start_time = time.time() + vocab_d, confidence_d, error_d = await extract_vocabulary_from_image( + image_data, f"page_{page_number + 1}.png", page_number, ocr_method="tesseract" + ) + duration_d = time.time() - start_time + + results["methods"]["tesseract"] = { + "name": "Loesung D: Tesseract OCR", + "model": "tesseract + qwen2.5:14b", + "duration_seconds": round(duration_d, 2), + "vocabulary_count": len(vocab_d), + "vocabulary": [ + {"english": v.english, "german": v.german, "example": v.example_sentence} + for v in vocab_d + ], + "confidence": confidence_d, + "error": error_d if error_d else None, + "success": len(vocab_d) > 0 + } + logger.info(f"Tesseract: {len(vocab_d)} entries in {duration_d:.2f}s") + except Exception as e: + results["methods"]["tesseract"] = { + "name": "Loesung D: Tesseract OCR", + "error": str(e), + "success": False + } + logger.error(f"Tesseract comparison failed: {e}") + + # ========================================================================== + # LOESUNG E: Claude Vision API (Anthropic) + # ========================================================================== + try: + from claude_vocab_extractor import extract_vocabulary_claude, is_claude_available + + if is_claude_available(): + start_time = time.time() + vocab_e_raw, confidence_e, error_e = await extract_vocabulary_claude( + image_data, f"page_{page_number + 1}.png" + ) + duration_e = time.time() - start_time + + # Convert to consistent format + vocab_e = [] + for v in vocab_e_raw: + source_word = v.get("source_word", "") + target_word = v.get("target_word", "") + source_lang = v.get("source_lang", "en") + # Determine which is English and which is German + if source_lang == "en": + english = source_word + german = target_word + else: + english = target_word + german = source_word + + vocab_e.append({ + "english": english, + "german": german, + "example": v.get("context", "") + }) + + results["methods"]["claude_vision"] = { + "name": "Loesung E: Claude Vision", + "model": "claude-sonnet-4-20250514", + "duration_seconds": round(duration_e, 2), + "vocabulary_count": len(vocab_e), + "vocabulary": vocab_e, + "confidence": confidence_e, + "error": error_e if error_e else None, + "success": len(vocab_e) > 0 + } + logger.info(f"Claude Vision: {len(vocab_e)} entries in {duration_e:.2f}s") + else: + results["methods"]["claude_vision"] = { + "name": "Loesung E: Claude Vision", + "error": "Anthropic API Key nicht konfiguriert", + "success": False + } + except Exception as e: + results["methods"]["claude_vision"] = { + "name": "Loesung E: Claude Vision", + "error": str(e), + "success": False + } + logger.error(f"Claude Vision comparison failed: {e}") + + # ========================================================================== + # Comparison Analysis + # ========================================================================== + all_vocab = {} + for method_key, method_data in results["methods"].items(): + if method_data.get("success"): + for v in method_data.get("vocabulary", []): + key = f"{v['english']}|{v['german']}" + if key not in all_vocab: + all_vocab[key] = {"english": v["english"], "german": v["german"], "found_by": []} + all_vocab[key]["found_by"].append(method_key) + + # Categorize vocabulary + found_by_all = [] + found_by_some = [] + + num_methods = len([m for m in results["methods"].values() if m.get("success")]) + + for key, data in all_vocab.items(): + entry = {"english": data["english"], "german": data["german"], "methods": data["found_by"]} + if len(data["found_by"]) == num_methods: + found_by_all.append(entry) + else: + found_by_some.append(entry) + + results["comparison"] = { + "found_by_all_methods": found_by_all, + "found_by_some_methods": found_by_some, + "total_unique_vocabulary": len(all_vocab), + "agreement_rate": len(found_by_all) / len(all_vocab) if all_vocab else 0 + } + + # Determine best method + best_method = None + best_count = 0 + for method_key, method_data in results["methods"].items(): + if method_data.get("success") and method_data.get("vocabulary_count", 0) > best_count: + best_count = method_data["vocabulary_count"] + best_method = method_key + + results["recommendation"] = { + "best_method": best_method, + "reason": f"Meiste Vokabeln erkannt ({best_count})" + } + + return results + + +# ============================================================================= +# Grid Detection and Analysis +# ============================================================================= + +@router.post("/sessions/{session_id}/analyze-grid/{page_number}") +async def analyze_grid(session_id: str, page_number: int): + """ + Analyze a page and detect grid structure for layout-preserving OCR. + + This endpoint: + 1. Applies deskewing to straighten the image + 2. Runs OCR with bounding box extraction + 3. Detects row and column structure + 4. Identifies recognized, empty, and problematic cells + + Returns grid structure with cell positions and recognition status. + """ + import numpy as np + from PIL import Image + import io + + logger.info(f"Grid analysis for session {session_id}, page {page_number}") + + session = await get_session_db(session_id) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + + # Get PDF data + pdf_data = get_cached_pdf_data(session_id) + if not pdf_data and session.pdf_path and os.path.exists(session.pdf_path): + with open(session.pdf_path, 'rb') as f: + pdf_data = f.read() + cache_pdf_data(session_id, pdf_data) + + if not pdf_data: + raise HTTPException(status_code=400, detail="No PDF uploaded for this session") + + page_count = session.pdf_page_count or 1 + if page_number < 0 or page_number >= page_count: + raise HTTPException( + status_code=400, + detail=f"Invalid page number. PDF has {page_count} pages (0-indexed)." + ) + + # Convert page to image + image_data = await convert_pdf_page_to_image(pdf_data, page_number, thumbnail=False) + + # Load image as numpy array + img = Image.open(io.BytesIO(image_data)) + img_array = np.array(img) + img_height, img_width = img_array.shape[:2] + + # Step 1: Deskewing + deskew_angle = 0.0 + try: + from services.image_preprocessing import deskew_image + img_array, deskew_angle = deskew_image(img_array) + logger.info(f"Applied deskew correction: {deskew_angle:.2f}°") + except ImportError: + logger.warning("Image preprocessing not available, skipping deskew") + except Exception as e: + logger.warning(f"Deskewing failed: {e}") + + # Step 2: Run OCR with position data + ocr_regions = [] + try: + import pytesseract + from pytesseract import Output + from services.grid_detection_service import convert_tesseract_regions + + # Convert back to PIL Image if we modified it + if deskew_angle != 0: + img = Image.fromarray(img_array) + + ocr_data = pytesseract.image_to_data( + img, + lang='eng+deu', + output_type=Output.DICT + ) + ocr_regions = convert_tesseract_regions(ocr_data, img_width, img_height) + logger.info(f"OCR found {len(ocr_regions)} text regions") + + except ImportError: + logger.warning("Tesseract not available, trying PaddleOCR") + try: + from hybrid_vocab_extractor import call_paddleocr_service + from services.grid_detection_service import convert_paddleocr_regions + + # Convert to bytes for PaddleOCR + buffer = io.BytesIO() + Image.fromarray(img_array).save(buffer, format='PNG') + paddle_regions, _ = await call_paddleocr_service(buffer.getvalue()) + + ocr_regions = convert_paddleocr_regions( + [{"text": r.text, "confidence": r.confidence, + "bbox": [[r.x1, r.y1], [r.x2, r.y1], [r.x2, r.y2], [r.x1, r.y2]]} + for r in paddle_regions], + img_width, img_height + ) + except Exception as e: + logger.error(f"PaddleOCR also failed: {e}") + + if not ocr_regions: + return { + "session_id": session_id, + "page_number": page_number + 1, + "success": False, + "error": "No text regions detected", + "grid": None, + "deskew_angle": deskew_angle, + } + + # Step 3: Detect grid structure + try: + from services.grid_detection_service import GridDetectionService + + grid_service = GridDetectionService() + result = grid_service.detect_grid(ocr_regions, img_array, deskew_angle) + + # Store grid data in session + await update_session_db( + session_id, + grid_data=result.to_dict(), + deskew_angle=deskew_angle + ) + + return { + "session_id": session_id, + "page_number": page_number + 1, + "success": True, + "grid": result.to_dict(), + "deskew_angle": deskew_angle, + "image_dimensions": { + "width": img_width, + "height": img_height + } + } + + except ImportError as e: + logger.error(f"Grid detection service not available: {e}") + raise HTTPException(status_code=500, detail="Grid detection service not available") + except Exception as e: + logger.error(f"Grid detection failed: {e}") + import traceback + logger.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=f"Grid detection failed: {str(e)}") + + +@router.get("/sessions/{session_id}/grid") +async def get_grid(session_id: str): + """ + Get the stored grid structure for a session. + """ + session = await get_session_db(session_id) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + + if not session.grid_data: + raise HTTPException(status_code=404, detail="No grid data found. Run analyze-grid first.") + + return { + "session_id": session_id, + "grid": session.grid_data, + "deskew_angle": session.deskew_angle + } + + +@router.get("/sessions/{session_id}/cell-crop/{page_number}/{row}/{col}") +async def get_cell_crop(session_id: str, page_number: int, row: int, col: int): + """ + Get a cropped image of a specific grid cell. + + Useful for showing the original image content when manually correcting cells. + """ + from PIL import Image + import io + + session = await get_session_db(session_id) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + + if not session.grid_data: + raise HTTPException(status_code=400, detail="No grid data. Run analyze-grid first.") + + # Get cell from grid + cells = session.grid_data.get("cells", []) + if row >= len(cells) or col >= len(cells[row] if row < len(cells) else []): + raise HTTPException(status_code=404, detail="Cell not found") + + cell = cells[row][col] + + # Get PDF image + pdf_data = get_cached_pdf_data(session_id) + if not pdf_data and session.pdf_path and os.path.exists(session.pdf_path): + with open(session.pdf_path, 'rb') as f: + pdf_data = f.read() + + if not pdf_data: + raise HTTPException(status_code=400, detail="No PDF data available") + + # Convert page to image + image_data = await convert_pdf_page_to_image(pdf_data, page_number, thumbnail=False) + img = Image.open(io.BytesIO(image_data)) + img_width, img_height = img.size + + # Crop cell region + x1 = int(img_width * cell["x"] / 100) + y1 = int(img_height * cell["y"] / 100) + x2 = int(img_width * (cell["x"] + cell["width"]) / 100) + y2 = int(img_height * (cell["y"] + cell["height"]) / 100) + + # Add small padding + padding = 5 + x1 = max(0, x1 - padding) + y1 = max(0, y1 - padding) + x2 = min(img_width, x2 + padding) + y2 = min(img_height, y2 + padding) + + cropped = img.crop((x1, y1, x2, y2)) + + # Convert to PNG + buffer = io.BytesIO() + cropped.save(buffer, format='PNG') + buffer.seek(0) + + return StreamingResponse(buffer, media_type="image/png") + + +@router.put("/sessions/{session_id}/cell/{row}/{col}") +async def update_cell(session_id: str, row: int, col: int, text: str = Form(...)): + """ + Manually update the text content of a grid cell. + + Sets recognition_status to 'manual' for the updated cell. + """ + session = await get_session_db(session_id) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + + if not session.grid_data: + raise HTTPException(status_code=400, detail="No grid data. Run analyze-grid first.") + + # Update cell in grid + grid_data = session.grid_data + cells = grid_data.get("cells", []) + + if row >= len(cells) or col >= len(cells[row] if row < len(cells) else []): + raise HTTPException(status_code=404, detail="Cell not found") + + cells[row][col]["text"] = text + cells[row][col]["status"] = "manual" + cells[row][col]["confidence"] = 1.0 + + # Update statistics + recognized = sum(1 for r in cells for c in r if c.get("status") == "recognized") + manual = sum(1 for r in cells for c in r if c.get("status") == "manual") + problematic = sum(1 for r in cells for c in r if c.get("status") == "problematic") + total = len(cells) * len(cells[0]) if cells and cells[0] else 0 + + grid_data["stats"] = { + "recognized": recognized, + "manual": manual, + "problematic": problematic, + "empty": total - recognized - manual - problematic, + "total": total, + "coverage": (recognized + manual) / total if total > 0 else 0 + } + + await update_session_db(session_id, grid_data=grid_data) + + return { + "success": True, + "cell": cells[row][col], + "stats": grid_data["stats"] + } + + +@router.post("/sessions/{session_id}/process-pages") +async def process_pdf_pages( + session_id: str, + pages: List[int] = None, + process_all: bool = False, +): + """ + Process specific pages of an uploaded PDF. + + DEPRECATED: Use /process-single-page/{page_number} instead for better results. + + Args: + pages: List of 0-indexed page numbers to process + process_all: If True, process all pages + """ + logger.info(f"Process pages request for session {session_id}: pages={pages}, process_all={process_all}") + + session = await get_session_db(session_id) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + + # Try cached PDF data first + pdf_data = get_cached_pdf_data(session_id) + + # If not cached, try to load from file + if not pdf_data and session.pdf_path and os.path.exists(session.pdf_path): + with open(session.pdf_path, 'rb') as f: + pdf_data = f.read() + cache_pdf_data(session_id, pdf_data) + + if not pdf_data: + raise HTTPException(status_code=400, detail="No PDF uploaded for this session") + + page_count = session.pdf_page_count or 1 + + # Determine which pages to process + if process_all: + pages = list(range(page_count)) + elif pages is None or len(pages) == 0: + pages = [0] # Default to first page + + # Convert selected pages to images + images = await convert_pdf_to_images(pdf_data, pages) + + # Extract vocabulary from each page SEQUENTIALLY + all_vocabulary = [] + total_confidence = 0.0 + successful_pages = [] + failed_pages = [] + error_messages = [] + + for i, image_data in enumerate(images): + page_num = pages[i] + logger.info(f"Extracting vocabulary from page {page_num + 1} of {len(images)}...") + + vocabulary, confidence, error = await extract_vocabulary_from_image( + image_data, + f"page_{page_num + 1}.png", + page_number=page_num + ) + + if error: + failed_pages.append(page_num + 1) + error_messages.append(error) + logger.warning(f"Page {page_num + 1} failed: {error}") + else: + successful_pages.append(page_num + 1) + total_confidence += confidence + + # Add page info to each entry and convert to dict + for entry in vocabulary: + entry_dict = entry.dict() if hasattr(entry, 'dict') else (entry.__dict__.copy() if hasattr(entry, '__dict__') else dict(entry)) + entry_dict['source_page'] = page_num + 1 + all_vocabulary.append(entry_dict) + + logger.info(f"Page {page_num + 1}: {len(vocabulary)} Vokabeln extrahiert") + + avg_confidence = total_confidence / len(successful_pages) if successful_pages else 0 + + # Store vocabulary in DB (replace existing) + await update_vocabulary_db(session_id, all_vocabulary) + + # Save first page as preview image + image_path = None + if images: + session_dir = os.path.join(LOCAL_STORAGE_PATH, session_id) + os.makedirs(session_dir, exist_ok=True) + image_path = os.path.join(session_dir, "source.png") + with open(image_path, 'wb') as f: + f.write(images[0]) + + # Update session in DB + await update_session_db( + session_id, + status=SessionStatus.EXTRACTED.value, + extraction_confidence=avg_confidence, + processed_pages=pages, + successful_pages=successful_pages, + failed_pages=failed_pages, + image_path=image_path, + ) + + result = { + "session_id": session_id, + "pages_processed": len(pages), + "pages_successful": len(successful_pages), + "pages_failed": len(failed_pages), + "successful_pages": successful_pages, + "failed_pages": failed_pages, + "vocabulary_count": len(all_vocabulary), + "extraction_confidence": avg_confidence, + "status": SessionStatus.EXTRACTED.value, + } + + if error_messages: + result["errors"] = error_messages + + return result + + +@router.delete("/sessions/{session_id}") +async def delete_session(session_id: str): + """Delete a vocabulary session and all associated files.""" + session = await get_session_db(session_id) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + + # Delete session directory + session_dir = os.path.join(LOCAL_STORAGE_PATH, session_id) + if os.path.exists(session_dir): + import shutil + shutil.rmtree(session_dir) + + # Clear cached PDF data + clear_cached_pdf_data(session_id) + + # Delete from database (CASCADE deletes vocab_entries and vocab_worksheets) + success = await delete_session_db(session_id) + + if not success: + raise HTTPException(status_code=500, detail="Failed to delete session from database") + + return {"message": "Session deleted successfully", "session_id": session_id} + + +# ============================================================================= +# NRU Format Worksheet Generation +# ============================================================================= + +class NRUWorksheetRequest(BaseModel): + """Request model for NRU format worksheet generation.""" + title: Optional[str] = "Vokabeltest" + include_solutions: bool = True + specific_pages: Optional[List[int]] = None # 1-indexed page numbers, None = all + + +@router.post("/sessions/{session_id}/generate-nru") +async def generate_nru_worksheet(session_id: str, request: NRUWorksheetRequest): + """ + Generate worksheet PDF in NRU format. + + NRU Format: + - Per scanned page, generates 2 worksheet pages: + 1. Vocabulary table (3 columns: English, German blank, Correction blank) + 2. Sentence practice (German sentence, 2 empty lines for English translation) + + Automatically separates vocabulary entries into: + - Single words/phrases -> Vocabulary table + - Full sentences (end with . ! ? or are long) -> Sentence practice + + Args: + session_id: Session with extracted vocabulary + request: Generation options (title, include_solutions, specific_pages) + + Returns: + Worksheet and solution PDF download info + """ + logger.info(f"Generating NRU worksheet for session {session_id}") + + session = await get_session_db(session_id) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + + vocab_dicts = await get_vocabulary_db(session_id) + if not vocab_dicts: + raise HTTPException(status_code=400, detail="No vocabulary found in session") + + # Generate PDFs using NRU format + try: + from nru_worksheet_generator import generate_nru_pdf, separate_vocab_and_sentences + + # Get statistics + vocab_list, sentence_list = separate_vocab_and_sentences(vocab_dicts) + + worksheet_pdf, solution_pdf = await generate_nru_pdf( + entries=vocab_dicts, + title=request.title or session.name, + include_solutions=request.include_solutions + ) + + # Save PDFs + worksheet_id = str(uuid.uuid4()) + session_dir = os.path.join(LOCAL_STORAGE_PATH, session_id) + os.makedirs(session_dir, exist_ok=True) + + pdf_path = os.path.join(session_dir, f"nru_worksheet_{worksheet_id}.pdf") + with open(pdf_path, 'wb') as f: + f.write(worksheet_pdf) + + solution_path = None + if solution_pdf: + solution_path = os.path.join(session_dir, f"nru_solution_{worksheet_id}.pdf") + with open(solution_path, 'wb') as f: + f.write(solution_pdf) + + # Store worksheet info + await create_worksheet_db( + worksheet_id=worksheet_id, + session_id=session_id, + worksheet_types=["nru_format"], + pdf_path=pdf_path, + solution_path=solution_path, + ) + + # Get unique pages + pages = sorted(set(v.get("source_page", 1) for v in vocab_dicts)) + + return { + "worksheet_id": worksheet_id, + "session_id": session_id, + "format": "nru", + "pdf_path": pdf_path, + "solution_path": solution_path, + "statistics": { + "total_entries": len(vocab_dicts), + "vocabulary_count": len(vocab_list), + "sentence_count": len(sentence_list), + "source_pages": pages, + "worksheet_pages": len(pages) * 2, # 2 pages per source page + }, + "download_url": f"/api/v1/vocab/worksheets/{worksheet_id}/pdf", + "solution_url": f"/api/v1/vocab/worksheets/{worksheet_id}/solution" if solution_path else None, + } + + except ImportError as e: + logger.error(f"NRU generator not available: {e}") + raise HTTPException(status_code=500, detail="NRU worksheet generator not available") + except Exception as e: + logger.error(f"NRU worksheet generation failed: {e}") + import traceback + logger.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=f"Worksheet generation failed: {str(e)}") diff --git a/klausur-service/backend/worksheet_cleanup_api.py b/klausur-service/backend/worksheet_cleanup_api.py new file mode 100644 index 0000000..5035a25 --- /dev/null +++ b/klausur-service/backend/worksheet_cleanup_api.py @@ -0,0 +1,491 @@ +""" +Worksheet Cleanup API - Handschrift-Entfernung und Layout-Rekonstruktion + +Endpoints: +- POST /api/v1/worksheet/detect-handwriting - Erkennt Handschrift und gibt Maske zurueck +- POST /api/v1/worksheet/remove-handwriting - Entfernt Handschrift aus Bild +- POST /api/v1/worksheet/reconstruct - Rekonstruiert Layout als Fabric.js JSON +- POST /api/v1/worksheet/cleanup-pipeline - Vollstaendige Pipeline (Erkennung + Entfernung + Layout) + +DATENSCHUTZ: Alle Verarbeitung erfolgt lokal auf dem Mac Mini. +""" + +import io +import base64 +import logging +from typing import Optional + +from fastapi import APIRouter, HTTPException, UploadFile, File, Form +from fastapi.responses import StreamingResponse, JSONResponse +from pydantic import BaseModel + +from services.handwriting_detection import ( + detect_handwriting, + detect_handwriting_regions, + mask_to_png +) +from services.inpainting_service import ( + inpaint_image, + remove_handwriting, + InpaintingMethod, + check_lama_available +) +from services.layout_reconstruction_service import ( + reconstruct_layout, + layout_to_fabric_json, + reconstruct_and_clean +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/worksheet", tags=["Worksheet Cleanup"]) + + +# ============================================================================= +# Pydantic Models +# ============================================================================= + +class DetectionResponse(BaseModel): + has_handwriting: bool + confidence: float + handwriting_ratio: float + detection_method: str + mask_base64: Optional[str] = None + + +class InpaintingResponse(BaseModel): + success: bool + method_used: str + processing_time_ms: float + image_base64: Optional[str] = None + error: Optional[str] = None + + +class ReconstructionResponse(BaseModel): + success: bool + element_count: int + page_width: int + page_height: int + fabric_json: dict + table_count: int = 0 + + +class PipelineResponse(BaseModel): + success: bool + handwriting_detected: bool + handwriting_removed: bool + layout_reconstructed: bool + cleaned_image_base64: Optional[str] = None + fabric_json: Optional[dict] = None + metadata: dict = {} + + +class CapabilitiesResponse(BaseModel): + opencv_available: bool = True + lama_available: bool = False + paddleocr_available: bool = False + + +# ============================================================================= +# API Endpoints +# ============================================================================= + +@router.get("/capabilities") +async def get_capabilities() -> CapabilitiesResponse: + """ + Get available cleanup capabilities on this server. + """ + # Check PaddleOCR + paddleocr_available = False + try: + from hybrid_vocab_extractor import get_paddle_ocr + ocr = get_paddle_ocr() + paddleocr_available = ocr is not None + except Exception: + pass + + return CapabilitiesResponse( + opencv_available=True, + lama_available=check_lama_available(), + paddleocr_available=paddleocr_available + ) + + +@router.post("/detect-handwriting") +async def detect_handwriting_endpoint( + image: UploadFile = File(...), + return_mask: bool = Form(default=True), + min_confidence: float = Form(default=0.3) +) -> DetectionResponse: + """ + Detect handwriting in an image. + + Args: + image: Input image (PNG, JPG) + return_mask: Whether to return the binary mask as base64 + min_confidence: Minimum confidence threshold + + Returns: + DetectionResponse with detection results and optional mask + """ + logger.info(f"Handwriting detection request: {image.filename}") + + # Validate file type + content_type = image.content_type or "" + if not content_type.startswith("image/"): + raise HTTPException( + status_code=400, + detail="Only image files (PNG, JPG) are supported" + ) + + try: + image_bytes = await image.read() + + # Detect handwriting + result = detect_handwriting(image_bytes) + + has_handwriting = ( + result.confidence >= min_confidence and + result.handwriting_ratio > 0.005 + ) + + response = DetectionResponse( + has_handwriting=has_handwriting, + confidence=result.confidence, + handwriting_ratio=result.handwriting_ratio, + detection_method=result.detection_method + ) + + if return_mask: + mask_bytes = mask_to_png(result.mask) + response.mask_base64 = base64.b64encode(mask_bytes).decode('utf-8') + + logger.info(f"Detection complete: handwriting={has_handwriting}, " + f"confidence={result.confidence:.2f}") + + return response + + except Exception as e: + logger.error(f"Handwriting detection failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/detect-handwriting/mask") +async def get_handwriting_mask( + image: UploadFile = File(...) +) -> StreamingResponse: + """ + Get handwriting detection mask as PNG image. + + Returns binary mask where white (255) = handwriting. + """ + content_type = image.content_type or "" + if not content_type.startswith("image/"): + raise HTTPException( + status_code=400, + detail="Only image files are supported" + ) + + try: + image_bytes = await image.read() + result = detect_handwriting(image_bytes) + mask_bytes = mask_to_png(result.mask) + + return StreamingResponse( + io.BytesIO(mask_bytes), + media_type="image/png", + headers={ + "Content-Disposition": "attachment; filename=handwriting_mask.png" + } + ) + + except Exception as e: + logger.error(f"Mask generation failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/remove-handwriting") +async def remove_handwriting_endpoint( + image: UploadFile = File(...), + mask: Optional[UploadFile] = File(default=None), + method: str = Form(default="auto"), + return_base64: bool = Form(default=False) +): + """ + Remove handwriting from an image. + + Args: + image: Input image with handwriting + mask: Optional pre-computed mask (if not provided, auto-detected) + method: Inpainting method (auto, opencv_telea, opencv_ns, lama) + return_base64: If True, return image as base64, else as file + + Returns: + Cleaned image (as PNG file or base64 in JSON) + """ + logger.info(f"Remove handwriting request: {image.filename}, method={method}") + + content_type = image.content_type or "" + if not content_type.startswith("image/"): + raise HTTPException( + status_code=400, + detail="Only image files are supported" + ) + + try: + image_bytes = await image.read() + + # Get mask if provided + mask_array = None + if mask is not None: + mask_bytes = await mask.read() + from PIL import Image + import numpy as np + mask_img = Image.open(io.BytesIO(mask_bytes)) + mask_array = np.array(mask_img) + + # Select inpainting method + inpainting_method = InpaintingMethod.AUTO + if method == "opencv_telea": + inpainting_method = InpaintingMethod.OPENCV_TELEA + elif method == "opencv_ns": + inpainting_method = InpaintingMethod.OPENCV_NS + elif method == "lama": + inpainting_method = InpaintingMethod.LAMA + + # Remove handwriting + cleaned_bytes, metadata = remove_handwriting( + image_bytes, + mask=mask_array, + method=inpainting_method + ) + + if return_base64: + return JSONResponse({ + "success": True, + "image_base64": base64.b64encode(cleaned_bytes).decode('utf-8'), + "metadata": metadata + }) + else: + return StreamingResponse( + io.BytesIO(cleaned_bytes), + media_type="image/png", + headers={ + "Content-Disposition": "attachment; filename=cleaned.png", + "X-Method-Used": metadata.get("method_used", "unknown"), + "X-Processing-Time-Ms": str(metadata.get("processing_time_ms", 0)) + } + ) + + except Exception as e: + logger.error(f"Handwriting removal failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/reconstruct") +async def reconstruct_layout_endpoint( + image: UploadFile = File(...), + clean_handwriting: bool = Form(default=True), + detect_tables: bool = Form(default=True) +) -> ReconstructionResponse: + """ + Reconstruct worksheet layout and generate Fabric.js JSON. + + Args: + image: Input image (can contain handwriting) + clean_handwriting: Whether to remove handwriting first + detect_tables: Whether to detect table structures + + Returns: + ReconstructionResponse with Fabric.js JSON + """ + logger.info(f"Layout reconstruction request: {image.filename}") + + content_type = image.content_type or "" + if not content_type.startswith("image/"): + raise HTTPException( + status_code=400, + detail="Only image files are supported" + ) + + try: + image_bytes = await image.read() + + # Run reconstruction pipeline + if clean_handwriting: + cleaned_bytes, layout = reconstruct_and_clean(image_bytes) + else: + layout = reconstruct_layout(image_bytes, detect_tables=detect_tables) + + return ReconstructionResponse( + success=True, + element_count=len(layout.elements), + page_width=layout.page_width, + page_height=layout.page_height, + fabric_json=layout.fabric_json, + table_count=len(layout.table_regions) + ) + + except Exception as e: + logger.error(f"Layout reconstruction failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/cleanup-pipeline") +async def full_cleanup_pipeline( + image: UploadFile = File(...), + remove_hw: bool = Form(default=True, alias="remove_handwriting"), + reconstruct: bool = Form(default=True), + inpainting_method: str = Form(default="auto") +) -> PipelineResponse: + """ + Full cleanup pipeline: detect, remove handwriting, reconstruct layout. + + This is the recommended endpoint for processing filled worksheets. + + Args: + image: Input image (scan/photo of filled worksheet) + remove_handwriting: Whether to remove detected handwriting + reconstruct: Whether to reconstruct layout as Fabric.js JSON + inpainting_method: Method for inpainting (auto, opencv_telea, opencv_ns, lama) + + Returns: + PipelineResponse with cleaned image and Fabric.js JSON + """ + logger.info(f"Full cleanup pipeline: {image.filename}") + + content_type = image.content_type or "" + if not content_type.startswith("image/"): + raise HTTPException( + status_code=400, + detail="Only image files are supported" + ) + + try: + image_bytes = await image.read() + metadata = {} + + # Step 1: Detect handwriting + detection = detect_handwriting(image_bytes) + handwriting_detected = ( + detection.confidence >= 0.3 and + detection.handwriting_ratio > 0.005 + ) + + metadata["detection"] = { + "confidence": detection.confidence, + "handwriting_ratio": detection.handwriting_ratio, + "method": detection.detection_method + } + + # Step 2: Remove handwriting if requested and detected + cleaned_bytes = image_bytes + handwriting_removed = False + + if remove_hw and handwriting_detected: + method = InpaintingMethod.AUTO + if inpainting_method == "opencv_telea": + method = InpaintingMethod.OPENCV_TELEA + elif inpainting_method == "opencv_ns": + method = InpaintingMethod.OPENCV_NS + elif inpainting_method == "lama": + method = InpaintingMethod.LAMA + + cleaned_bytes, inpaint_metadata = remove_handwriting( + image_bytes, + mask=detection.mask, + method=method + ) + handwriting_removed = inpaint_metadata.get("inpainting_performed", False) + metadata["inpainting"] = inpaint_metadata + + # Step 3: Reconstruct layout if requested + fabric_json = None + layout_reconstructed = False + + if reconstruct: + layout = reconstruct_layout(cleaned_bytes) + fabric_json = layout.fabric_json + layout_reconstructed = len(layout.elements) > 0 + metadata["layout"] = { + "element_count": len(layout.elements), + "table_count": len(layout.table_regions), + "page_width": layout.page_width, + "page_height": layout.page_height + } + + # Encode cleaned image as base64 + cleaned_base64 = base64.b64encode(cleaned_bytes).decode('utf-8') + + logger.info(f"Pipeline complete: detected={handwriting_detected}, " + f"removed={handwriting_removed}, layout={layout_reconstructed}") + + return PipelineResponse( + success=True, + handwriting_detected=handwriting_detected, + handwriting_removed=handwriting_removed, + layout_reconstructed=layout_reconstructed, + cleaned_image_base64=cleaned_base64, + fabric_json=fabric_json, + metadata=metadata + ) + + except Exception as e: + logger.error(f"Cleanup pipeline failed: {e}") + import traceback + logger.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/preview-cleanup") +async def preview_cleanup( + image: UploadFile = File(...) +) -> JSONResponse: + """ + Quick preview of cleanup results without full processing. + + Returns detection results and estimated processing time. + """ + content_type = image.content_type or "" + if not content_type.startswith("image/"): + raise HTTPException( + status_code=400, + detail="Only image files are supported" + ) + + try: + image_bytes = await image.read() + + # Quick detection only + result = detect_handwriting_regions(image_bytes) + + # Estimate processing time based on image size + from PIL import Image + img = Image.open(io.BytesIO(image_bytes)) + pixel_count = img.width * img.height + + # Rough estimates + est_detection_ms = 100 + (pixel_count / 1000000) * 200 + est_inpainting_ms = 500 + (pixel_count / 1000000) * 1000 + est_reconstruction_ms = 200 + (pixel_count / 1000000) * 300 + + return JSONResponse({ + "has_handwriting": result["has_handwriting"], + "confidence": result["confidence"], + "handwriting_ratio": result["handwriting_ratio"], + "image_width": img.width, + "image_height": img.height, + "estimated_times_ms": { + "detection": est_detection_ms, + "inpainting": est_inpainting_ms if result["has_handwriting"] else 0, + "reconstruction": est_reconstruction_ms, + "total": est_detection_ms + (est_inpainting_ms if result["has_handwriting"] else 0) + est_reconstruction_ms + }, + "capabilities": { + "lama_available": check_lama_available() + } + }) + + except Exception as e: + logger.error(f"Preview failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/klausur-service/backend/worksheet_editor_api.py b/klausur-service/backend/worksheet_editor_api.py new file mode 100644 index 0000000..bc96131 --- /dev/null +++ b/klausur-service/backend/worksheet_editor_api.py @@ -0,0 +1,1305 @@ +""" +Worksheet Editor API - Backend Endpoints for Visual Worksheet Editor + +Provides endpoints for: +- AI Image generation via Ollama/Stable Diffusion +- Worksheet Save/Load +- PDF Export +""" + +import os +import io +import uuid +import json +import base64 +import logging +from datetime import datetime, timezone +from typing import Optional, List, Dict, Any +from enum import Enum +from dataclasses import dataclass, field, asdict + +from fastapi import APIRouter, HTTPException, Request, BackgroundTasks +from fastapi.responses import FileResponse, StreamingResponse +from pydantic import BaseModel, Field +import httpx + +# PDF Generation +try: + from reportlab.lib import colors + from reportlab.lib.pagesizes import A4 + from reportlab.lib.units import mm + from reportlab.pdfgen import canvas + from reportlab.lib.styles import getSampleStyleSheet + REPORTLAB_AVAILABLE = True +except ImportError: + REPORTLAB_AVAILABLE = False + +logger = logging.getLogger(__name__) + +# ============================================= +# CONFIGURATION +# ============================================= + +OLLAMA_URL = os.getenv("OLLAMA_URL", "http://host.docker.internal:11434") +SD_MODEL = os.getenv("SD_MODEL", "stable-diffusion") # or specific SD model +WORKSHEET_STORAGE_DIR = os.getenv("WORKSHEET_STORAGE_DIR", + os.path.join(os.path.dirname(os.path.abspath(__file__)), "worksheet-storage")) + +# Ensure storage directory exists +os.makedirs(WORKSHEET_STORAGE_DIR, exist_ok=True) + +# ============================================= +# ENUMS & MODELS +# ============================================= + +class AIImageStyle(str, Enum): + REALISTIC = "realistic" + CARTOON = "cartoon" + SKETCH = "sketch" + CLIPART = "clipart" + EDUCATIONAL = "educational" + +class WorksheetStatus(str, Enum): + DRAFT = "draft" + PUBLISHED = "published" + ARCHIVED = "archived" + +# Style prompt modifiers +STYLE_PROMPTS = { + AIImageStyle.REALISTIC: "photorealistic, high detail, professional photography", + AIImageStyle.CARTOON: "cartoon style, colorful, child-friendly, simple shapes", + AIImageStyle.SKETCH: "pencil sketch, hand-drawn, black and white, artistic", + AIImageStyle.CLIPART: "clipart style, flat design, simple, vector-like", + AIImageStyle.EDUCATIONAL: "educational illustration, clear, informative, textbook style" +} + +# ============================================= +# REQUEST/RESPONSE MODELS +# ============================================= + +class AIImageRequest(BaseModel): + prompt: str = Field(..., min_length=3, max_length=500) + style: AIImageStyle = AIImageStyle.EDUCATIONAL + width: int = Field(512, ge=256, le=1024) + height: int = Field(512, ge=256, le=1024) + +class AIImageResponse(BaseModel): + image_base64: str + prompt_used: str + error: Optional[str] = None + +class PageData(BaseModel): + id: str + index: int + canvasJSON: str + +class PageFormat(BaseModel): + width: float = 210 + height: float = 297 + orientation: str = "portrait" + margins: Dict[str, float] = {"top": 15, "right": 15, "bottom": 15, "left": 15} + +class WorksheetSaveRequest(BaseModel): + id: Optional[str] = None + title: str + description: Optional[str] = None + pages: List[PageData] + pageFormat: Optional[PageFormat] = None + +class WorksheetResponse(BaseModel): + id: str + title: str + description: Optional[str] + pages: List[PageData] + pageFormat: PageFormat + createdAt: str + updatedAt: str + +# ============================================= +# IN-MEMORY STORAGE (Development) +# ============================================= + +worksheets_db: Dict[str, Dict] = {} + +# ============================================= +# ROUTER +# ============================================= + +router = APIRouter(prefix="/api/v1/worksheet", tags=["Worksheet Editor"]) + +# ============================================= +# AI IMAGE GENERATION +# ============================================= + +@router.post("/ai-image", response_model=AIImageResponse) +async def generate_ai_image(request: AIImageRequest): + """ + Generate an AI image using Ollama with a text-to-image model. + + Supported models: + - stable-diffusion (via Ollama) + - sd3.5-medium + - llava (for image understanding, not generation) + + Falls back to a placeholder if Ollama is not available. + """ + try: + # Build enhanced prompt with style + style_modifier = STYLE_PROMPTS.get(request.style, "") + enhanced_prompt = f"{request.prompt}, {style_modifier}" + + logger.info(f"Generating AI image: {enhanced_prompt[:100]}...") + + # Check if Ollama is available + async with httpx.AsyncClient(timeout=10.0) as check_client: + try: + health_response = await check_client.get(f"{OLLAMA_URL}/api/tags") + if health_response.status_code != 200: + raise HTTPException(status_code=503, detail="Ollama service not available") + except httpx.ConnectError: + logger.warning("Ollama not reachable, returning placeholder") + # Return a placeholder image (simple colored rectangle) + return _generate_placeholder_image(request, enhanced_prompt) + + # Try to generate with Stable Diffusion via Ollama + # Note: Ollama doesn't natively support SD, this is a placeholder for when it does + # or when using a compatible endpoint + + try: + async with httpx.AsyncClient(timeout=300.0) as client: + # Check if SD model is available + tags_response = await client.get(f"{OLLAMA_URL}/api/tags") + available_models = [m.get("name", "") for m in tags_response.json().get("models", [])] + + # Look for SD-compatible model + sd_model = None + for model in available_models: + if "stable" in model.lower() or "sd" in model.lower() or "diffusion" in model.lower(): + sd_model = model + break + + if not sd_model: + logger.warning("No Stable Diffusion model found in Ollama") + return _generate_placeholder_image(request, enhanced_prompt) + + # Generate image (this would need Ollama's image generation API) + # For now, return placeholder + logger.info(f"SD model found: {sd_model}, but image generation API not implemented") + return _generate_placeholder_image(request, enhanced_prompt) + + except Exception as e: + logger.error(f"Image generation failed: {e}") + return _generate_placeholder_image(request, enhanced_prompt) + + except HTTPException: + raise + except Exception as e: + logger.error(f"AI image generation error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +def _generate_placeholder_image(request: AIImageRequest, prompt: str) -> AIImageResponse: + """ + Generate a placeholder image when AI generation is not available. + Creates a simple SVG-based placeholder with the prompt text. + """ + from PIL import Image, ImageDraw, ImageFont + + # Create image + width, height = request.width, request.height + + # Style-based colors + style_colors = { + AIImageStyle.REALISTIC: ("#2563eb", "#dbeafe"), + AIImageStyle.CARTOON: ("#f97316", "#ffedd5"), + AIImageStyle.SKETCH: ("#6b7280", "#f3f4f6"), + AIImageStyle.CLIPART: ("#8b5cf6", "#ede9fe"), + AIImageStyle.EDUCATIONAL: ("#059669", "#d1fae5"), + } + + fg_color, bg_color = style_colors.get(request.style, ("#6366f1", "#e0e7ff")) + + # Create image with Pillow + img = Image.new('RGB', (width, height), bg_color) + draw = ImageDraw.Draw(img) + + # Draw border + draw.rectangle([5, 5, width-6, height-6], outline=fg_color, width=3) + + # Draw icon (simple shapes) + cx, cy = width // 2, height // 2 - 30 + draw.ellipse([cx-40, cy-40, cx+40, cy+40], outline=fg_color, width=3) + draw.line([cx-20, cy-10, cx+20, cy-10], fill=fg_color, width=3) + draw.line([cx, cy-10, cx, cy+20], fill=fg_color, width=3) + + # Draw text + try: + font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14) + except: + font = ImageFont.load_default() + + # Wrap text + max_chars = 40 + lines = [] + words = prompt[:200].split() + current_line = "" + for word in words: + if len(current_line) + len(word) + 1 <= max_chars: + current_line += (" " + word if current_line else word) + else: + if current_line: + lines.append(current_line) + current_line = word + if current_line: + lines.append(current_line) + + text_y = cy + 60 + for line in lines[:4]: # Max 4 lines + bbox = draw.textbbox((0, 0), line, font=font) + text_width = bbox[2] - bbox[0] + draw.text((cx - text_width // 2, text_y), line, fill=fg_color, font=font) + text_y += 20 + + # Draw "AI Placeholder" badge + badge_text = "KI-Bild (Platzhalter)" + try: + badge_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 10) + except: + badge_font = font + draw.rectangle([10, height-30, 150, height-10], fill=fg_color) + draw.text((15, height-27), badge_text, fill="white", font=badge_font) + + # Convert to base64 + buffer = io.BytesIO() + img.save(buffer, format='PNG') + buffer.seek(0) + + image_base64 = f"data:image/png;base64,{base64.b64encode(buffer.getvalue()).decode('utf-8')}" + + return AIImageResponse( + image_base64=image_base64, + prompt_used=prompt, + error="AI image generation not available. Using placeholder." + ) + + +# ============================================= +# WORKSHEET SAVE/LOAD +# ============================================= + +@router.post("/save", response_model=WorksheetResponse) +async def save_worksheet(request: WorksheetSaveRequest): + """ + Save a worksheet document. + + - If id is provided, updates existing worksheet + - If id is not provided, creates new worksheet + """ + try: + now = datetime.now(timezone.utc).isoformat() + + # Generate or use existing ID + worksheet_id = request.id or f"ws_{uuid.uuid4().hex[:12]}" + + # Build worksheet data + worksheet = { + "id": worksheet_id, + "title": request.title, + "description": request.description, + "pages": [p.dict() for p in request.pages], + "pageFormat": (request.pageFormat or PageFormat()).dict(), + "createdAt": worksheets_db.get(worksheet_id, {}).get("createdAt", now), + "updatedAt": now + } + + # Save to in-memory storage + worksheets_db[worksheet_id] = worksheet + + # Also persist to file + filepath = os.path.join(WORKSHEET_STORAGE_DIR, f"{worksheet_id}.json") + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(worksheet, f, ensure_ascii=False, indent=2) + + logger.info(f"Saved worksheet: {worksheet_id}") + + return WorksheetResponse(**worksheet) + + except Exception as e: + logger.error(f"Failed to save worksheet: {e}") + raise HTTPException(status_code=500, detail=f"Failed to save: {str(e)}") + + +@router.get("/{worksheet_id}", response_model=WorksheetResponse) +async def get_worksheet(worksheet_id: str): + """ + Load a worksheet document by ID. + """ + try: + # Try in-memory first + if worksheet_id in worksheets_db: + return WorksheetResponse(**worksheets_db[worksheet_id]) + + # Try file storage + filepath = os.path.join(WORKSHEET_STORAGE_DIR, f"{worksheet_id}.json") + if os.path.exists(filepath): + with open(filepath, 'r', encoding='utf-8') as f: + worksheet = json.load(f) + worksheets_db[worksheet_id] = worksheet # Cache it + return WorksheetResponse(**worksheet) + + raise HTTPException(status_code=404, detail="Worksheet not found") + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to load worksheet {worksheet_id}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to load: {str(e)}") + + +@router.get("/list/all") +async def list_worksheets(): + """ + List all available worksheets. + """ + try: + worksheets = [] + + # Load from file storage + for filename in os.listdir(WORKSHEET_STORAGE_DIR): + if filename.endswith('.json'): + filepath = os.path.join(WORKSHEET_STORAGE_DIR, filename) + try: + with open(filepath, 'r', encoding='utf-8') as f: + worksheet = json.load(f) + worksheets.append({ + "id": worksheet.get("id"), + "title": worksheet.get("title"), + "description": worksheet.get("description"), + "pageCount": len(worksheet.get("pages", [])), + "updatedAt": worksheet.get("updatedAt"), + "createdAt": worksheet.get("createdAt") + }) + except Exception as e: + logger.warning(f"Failed to load {filename}: {e}") + + # Sort by updatedAt descending + worksheets.sort(key=lambda x: x.get("updatedAt", ""), reverse=True) + + return {"worksheets": worksheets, "total": len(worksheets)} + + except Exception as e: + logger.error(f"Failed to list worksheets: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/{worksheet_id}") +async def delete_worksheet(worksheet_id: str): + """ + Delete a worksheet document. + """ + try: + # Remove from memory + if worksheet_id in worksheets_db: + del worksheets_db[worksheet_id] + + # Remove file + filepath = os.path.join(WORKSHEET_STORAGE_DIR, f"{worksheet_id}.json") + if os.path.exists(filepath): + os.remove(filepath) + logger.info(f"Deleted worksheet: {worksheet_id}") + return {"status": "deleted", "id": worksheet_id} + + raise HTTPException(status_code=404, detail="Worksheet not found") + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to delete worksheet {worksheet_id}: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================= +# PDF EXPORT +# ============================================= + +@router.post("/{worksheet_id}/export-pdf") +async def export_worksheet_pdf(worksheet_id: str): + """ + Export worksheet as PDF. + + Note: This creates a basic PDF. For full canvas rendering, + the frontend should use pdf-lib with canvas.toDataURL(). + """ + if not REPORTLAB_AVAILABLE: + raise HTTPException(status_code=501, detail="PDF export not available (reportlab not installed)") + + try: + # Load worksheet + worksheet = worksheets_db.get(worksheet_id) + if not worksheet: + filepath = os.path.join(WORKSHEET_STORAGE_DIR, f"{worksheet_id}.json") + if os.path.exists(filepath): + with open(filepath, 'r', encoding='utf-8') as f: + worksheet = json.load(f) + else: + raise HTTPException(status_code=404, detail="Worksheet not found") + + # Create PDF + buffer = io.BytesIO() + c = canvas.Canvas(buffer, pagesize=A4) + + page_width, page_height = A4 + + for page_data in worksheet.get("pages", []): + # Add title on first page + if page_data.get("index", 0) == 0: + c.setFont("Helvetica-Bold", 18) + c.drawString(50, page_height - 50, worksheet.get("title", "Arbeitsblatt")) + c.setFont("Helvetica", 10) + c.drawString(50, page_height - 70, f"Erstellt: {worksheet.get('createdAt', '')[:10]}") + + # Parse canvas JSON and render basic elements + canvas_json_str = page_data.get("canvasJSON", "{}") + if canvas_json_str: + try: + canvas_data = json.loads(canvas_json_str) + objects = canvas_data.get("objects", []) + + for obj in objects: + obj_type = obj.get("type", "") + + if obj_type in ["text", "i-text", "textbox"]: + # Render text + text = obj.get("text", "") + left = obj.get("left", 50) + top = obj.get("top", 100) + font_size = obj.get("fontSize", 12) + + # Convert from canvas coords to PDF coords + pdf_x = left * 0.75 # Approximate scale + pdf_y = page_height - (top * 0.75) + + c.setFont("Helvetica", min(font_size, 24)) + c.drawString(pdf_x, pdf_y, text[:100]) + + elif obj_type == "rect": + # Render rectangle + left = obj.get("left", 0) * 0.75 + top = obj.get("top", 0) * 0.75 + width = obj.get("width", 50) * 0.75 + height = obj.get("height", 30) * 0.75 + + c.rect(left, page_height - top - height, width, height) + + elif obj_type == "circle": + # Render circle + left = obj.get("left", 0) * 0.75 + top = obj.get("top", 0) * 0.75 + radius = obj.get("radius", 25) * 0.75 + + c.circle(left + radius, page_height - top - radius, radius) + + except json.JSONDecodeError: + pass + + c.showPage() + + c.save() + buffer.seek(0) + + filename = f"{worksheet.get('title', 'worksheet').replace(' ', '_')}.pdf" + + return StreamingResponse( + buffer, + media_type="application/pdf", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"PDF export failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================= +# AI WORKSHEET MODIFICATION +# ============================================= + +class AIModifyRequest(BaseModel): + prompt: str = Field(..., min_length=3, max_length=1000) + canvas_json: str + model: str = "qwen2.5vl:32b" + +class AIModifyResponse(BaseModel): + modified_canvas_json: Optional[str] = None + message: str + error: Optional[str] = None + +@router.post("/ai-modify", response_model=AIModifyResponse) +async def modify_worksheet_with_ai(request: AIModifyRequest): + """ + Modify a worksheet using AI based on natural language prompt. + + Uses Ollama with qwen2.5vl:32b to understand the canvas state + and generate modifications based on the user's request. + """ + try: + logger.info(f"AI modify request: {request.prompt[:100]}...") + + # Parse current canvas state + try: + canvas_data = json.loads(request.canvas_json) + except json.JSONDecodeError: + return AIModifyResponse( + message="Fehler beim Parsen des Canvas", + error="Invalid canvas JSON" + ) + + # Build system prompt for the AI + system_prompt = """Du bist ein Assistent fuer die Bearbeitung von Arbeitsblaettern. +Du erhaeltst den aktuellen Zustand eines Canvas im JSON-Format und eine Anweisung des Nutzers. +Deine Aufgabe ist es, die gewuenschten Aenderungen am Canvas vorzunehmen. + +Der Canvas verwendet Fabric.js. Hier sind die wichtigsten Objekttypen: +- i-text: Interaktiver Text mit fontFamily, fontSize, fill, left, top +- rect: Rechteck mit left, top, width, height, fill, stroke, strokeWidth +- circle: Kreis mit left, top, radius, fill, stroke, strokeWidth +- line: Linie mit x1, y1, x2, y2, stroke, strokeWidth + +Das Canvas ist 794x1123 Pixel (A4 bei 96 DPI). + +Antworte NUR mit einem JSON-Objekt in diesem Format: +{ + "action": "modify" oder "add" oder "delete" oder "info", + "objects": [...], // Neue/modifizierte Objekte (bei modify/add) + "message": "Kurze Beschreibung der Aenderung" +} + +Wenn du Objekte hinzufuegst, generiere eindeutige IDs im Format "obj__". +""" + + user_prompt = f"""Aktueller Canvas-Zustand: +```json +{json.dumps(canvas_data, indent=2)[:5000]} +``` + +Nutzer-Anweisung: {request.prompt} + +Fuehre die Aenderung durch und antworte mit dem JSON-Objekt.""" + + # Call Ollama + try: + async with httpx.AsyncClient(timeout=120.0) as client: + response = await client.post( + f"{OLLAMA_URL}/api/generate", + json={ + "model": request.model, + "prompt": user_prompt, + "system": system_prompt, + "stream": False, + "options": { + "temperature": 0.3, + "num_predict": 4096 + } + } + ) + + if response.status_code != 200: + logger.warning(f"Ollama error: {response.status_code}, trying local fallback") + # Fallback: Try to handle simple requests locally + return _handle_simple_modification(request.prompt, canvas_data) + + result = response.json() + ai_response = result.get("response", "") + + except httpx.ConnectError: + logger.warning("Ollama not reachable") + # Fallback: Try to handle simple requests locally + return _handle_simple_modification(request.prompt, canvas_data) + except httpx.TimeoutException: + logger.warning("Ollama timeout, trying local fallback") + # Fallback: Try to handle simple requests locally + return _handle_simple_modification(request.prompt, canvas_data) + + # Parse AI response + try: + # Find JSON in response + json_start = ai_response.find('{') + json_end = ai_response.rfind('}') + 1 + + if json_start == -1 or json_end <= json_start: + logger.warning(f"No JSON found in AI response: {ai_response[:200]}") + return AIModifyResponse( + message="KI konnte die Anfrage nicht verarbeiten", + error="No JSON in response" + ) + + ai_json = json.loads(ai_response[json_start:json_end]) + action = ai_json.get("action", "info") + message = ai_json.get("message", "Aenderungen angewendet") + new_objects = ai_json.get("objects", []) + + if action == "info": + return AIModifyResponse(message=message) + + if action == "add" and new_objects: + # Add new objects to canvas + existing_objects = canvas_data.get("objects", []) + existing_objects.extend(new_objects) + canvas_data["objects"] = existing_objects + + return AIModifyResponse( + modified_canvas_json=json.dumps(canvas_data), + message=message + ) + + if action == "modify" and new_objects: + # Replace matching objects or add new ones + existing_objects = canvas_data.get("objects", []) + new_ids = {obj.get("id") for obj in new_objects if obj.get("id")} + + # Keep objects that aren't being modified + kept_objects = [obj for obj in existing_objects if obj.get("id") not in new_ids] + kept_objects.extend(new_objects) + canvas_data["objects"] = kept_objects + + return AIModifyResponse( + modified_canvas_json=json.dumps(canvas_data), + message=message + ) + + if action == "delete": + # Delete objects by ID + delete_ids = ai_json.get("delete_ids", []) + if delete_ids: + existing_objects = canvas_data.get("objects", []) + canvas_data["objects"] = [obj for obj in existing_objects if obj.get("id") not in delete_ids] + + return AIModifyResponse( + modified_canvas_json=json.dumps(canvas_data), + message=message + ) + + return AIModifyResponse(message=message) + + except json.JSONDecodeError as e: + logger.error(f"Failed to parse AI JSON: {e}") + return AIModifyResponse( + message="Fehler beim Verarbeiten der KI-Antwort", + error=str(e) + ) + + except Exception as e: + logger.error(f"AI modify error: {e}") + return AIModifyResponse( + message="Ein unerwarteter Fehler ist aufgetreten", + error=str(e) + ) + + +def _handle_simple_modification(prompt: str, canvas_data: dict) -> AIModifyResponse: + """ + Handle simple modifications locally when Ollama is not available. + Supports basic commands like adding headings, lines, etc. + """ + import time + import random + + prompt_lower = prompt.lower() + objects = canvas_data.get("objects", []) + + def generate_id(): + return f"obj_{int(time.time()*1000)}_{random.randint(1000, 9999)}" + + # Add heading + if "ueberschrift" in prompt_lower or "titel" in prompt_lower or "heading" in prompt_lower: + # Extract text if provided in quotes + import re + text_match = re.search(r'"([^"]+)"', prompt) + text = text_match.group(1) if text_match else "Ueberschrift" + + new_text = { + "type": "i-text", + "id": generate_id(), + "text": text, + "left": 397, # Center of A4 + "top": 50, + "originX": "center", + "fontFamily": "Arial", + "fontSize": 28, + "fontWeight": "bold", + "fill": "#000000" + } + objects.append(new_text) + canvas_data["objects"] = objects + + return AIModifyResponse( + modified_canvas_json=json.dumps(canvas_data), + message=f"Ueberschrift '{text}' hinzugefuegt" + ) + + # Add lines for writing + if "linie" in prompt_lower or "line" in prompt_lower or "schreib" in prompt_lower: + # Count how many lines + import re + num_match = re.search(r'(\d+)', prompt) + num_lines = int(num_match.group(1)) if num_match else 5 + num_lines = min(num_lines, 20) # Max 20 lines + + start_y = 150 + line_spacing = 40 + + for i in range(num_lines): + new_line = { + "type": "line", + "id": generate_id(), + "x1": 60, + "y1": start_y + i * line_spacing, + "x2": 734, + "y2": start_y + i * line_spacing, + "stroke": "#cccccc", + "strokeWidth": 1 + } + objects.append(new_line) + + canvas_data["objects"] = objects + + return AIModifyResponse( + modified_canvas_json=json.dumps(canvas_data), + message=f"{num_lines} Schreiblinien hinzugefuegt" + ) + + # Make text bigger + if "groesser" in prompt_lower or "bigger" in prompt_lower or "larger" in prompt_lower: + modified = 0 + for obj in objects: + if obj.get("type") in ["i-text", "text", "textbox"]: + current_size = obj.get("fontSize", 16) + obj["fontSize"] = int(current_size * 1.25) + modified += 1 + + canvas_data["objects"] = objects + + if modified > 0: + return AIModifyResponse( + modified_canvas_json=json.dumps(canvas_data), + message=f"{modified} Texte vergroessert" + ) + + # Center elements + if "zentrier" in prompt_lower or "center" in prompt_lower or "mitte" in prompt_lower: + center_x = 397 + for obj in objects: + if not obj.get("isGrid"): + obj["left"] = center_x + obj["originX"] = "center" + + canvas_data["objects"] = objects + + return AIModifyResponse( + modified_canvas_json=json.dumps(canvas_data), + message="Elemente zentriert" + ) + + # Add numbering + if "nummer" in prompt_lower or "nummerier" in prompt_lower or "1-10" in prompt_lower: + import re + range_match = re.search(r'(\d+)\s*[-bis]+\s*(\d+)', prompt) + if range_match: + start, end = int(range_match.group(1)), int(range_match.group(2)) + else: + start, end = 1, 10 + + y = 100 + for i in range(start, min(end + 1, start + 20)): + new_text = { + "type": "i-text", + "id": generate_id(), + "text": f"{i}.", + "left": 40, + "top": y, + "fontFamily": "Arial", + "fontSize": 14, + "fill": "#000000" + } + objects.append(new_text) + y += 35 + + canvas_data["objects"] = objects + + return AIModifyResponse( + modified_canvas_json=json.dumps(canvas_data), + message=f"Nummerierung {start}-{end} hinzugefuegt" + ) + + # Add rectangle/box + if "rechteck" in prompt_lower or "box" in prompt_lower or "kasten" in prompt_lower: + new_rect = { + "type": "rect", + "id": generate_id(), + "left": 100, + "top": 200, + "width": 200, + "height": 100, + "fill": "transparent", + "stroke": "#000000", + "strokeWidth": 2 + } + objects.append(new_rect) + canvas_data["objects"] = objects + + return AIModifyResponse( + modified_canvas_json=json.dumps(canvas_data), + message="Rechteck hinzugefuegt" + ) + + # Add grid/raster + if "raster" in prompt_lower or "grid" in prompt_lower or "tabelle" in prompt_lower: + import re + # Parse dimensions like "3x4", "3/4", "3 mal 4", "3 by 4" + dim_match = re.search(r'(\d+)\s*[x/×\*mal by]\s*(\d+)', prompt_lower) + if dim_match: + cols = int(dim_match.group(1)) + rows = int(dim_match.group(2)) + else: + # Try single numbers + nums = re.findall(r'(\d+)', prompt) + if len(nums) >= 2: + cols, rows = int(nums[0]), int(nums[1]) + else: + cols, rows = 3, 4 # Default grid + + # Limit grid size + cols = min(max(1, cols), 10) + rows = min(max(1, rows), 15) + + # Canvas dimensions (A4 at 96 DPI) + canvas_width = 794 + canvas_height = 1123 + + # Grid positioning + margin = 60 + available_width = canvas_width - 2 * margin + available_height = canvas_height - 2 * margin - 80 # Leave space for header + + cell_width = available_width / cols + cell_height = min(available_height / rows, 80) # Max cell height + + start_x = margin + start_y = 120 # Below potential header + + # Create grid lines + grid_objects = [] + + # Horizontal lines + for r in range(rows + 1): + y = start_y + r * cell_height + grid_objects.append({ + "type": "line", + "id": generate_id(), + "x1": start_x, + "y1": y, + "x2": start_x + cols * cell_width, + "y2": y, + "stroke": "#666666", + "strokeWidth": 1, + "isGrid": True + }) + + # Vertical lines + for c in range(cols + 1): + x = start_x + c * cell_width + grid_objects.append({ + "type": "line", + "id": generate_id(), + "x1": x, + "y1": start_y, + "x2": x, + "y2": start_y + rows * cell_height, + "stroke": "#666666", + "strokeWidth": 1, + "isGrid": True + }) + + objects.extend(grid_objects) + canvas_data["objects"] = objects + + return AIModifyResponse( + modified_canvas_json=json.dumps(canvas_data), + message=f"{cols}x{rows} Raster hinzugefuegt ({cols} Spalten, {rows} Zeilen)" + ) + + # Default: Ollama needed + return AIModifyResponse( + message="Diese Aenderung erfordert den KI-Service. Bitte stellen Sie sicher, dass Ollama laeuft.", + error="Complex modification requires Ollama" + ) + + +# ============================================= +# HEALTH CHECK +# ============================================= + +@router.get("/health/check") +async def health_check(): + """ + Check worksheet editor API health and dependencies. + """ + status = { + "status": "healthy", + "ollama": False, + "storage": os.path.exists(WORKSHEET_STORAGE_DIR), + "reportlab": REPORTLAB_AVAILABLE, + "worksheets_count": len(worksheets_db) + } + + # Check Ollama + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get(f"{OLLAMA_URL}/api/tags") + status["ollama"] = response.status_code == 200 + except: + pass + + return status + + +# ============================================= +# DOCUMENT RECONSTRUCTION FROM VOCAB SESSION +# ============================================= + +class ReconstructRequest(BaseModel): + session_id: str + page_number: int = 1 + include_images: bool = True + regenerate_graphics: bool = False + +class ReconstructResponse(BaseModel): + canvas_json: str + page_width: int + page_height: int + elements_count: int + vocabulary_matched: int + message: str + error: Optional[str] = None + + +@router.post("/reconstruct-from-session", response_model=ReconstructResponse) +async def reconstruct_document_from_session(request: ReconstructRequest): + """ + Reconstruct a document from a vocab session into Fabric.js canvas format. + + This endpoint: + 1. Loads the original PDF from the vocab session + 2. Runs OCR with position tracking + 3. Uses vision LLM to understand layout (headers, images, columns) + 4. Creates Fabric.js canvas JSON with positioned elements + 5. Maps extracted vocabulary to their positions + + Returns canvas JSON ready to load into the worksheet editor. + """ + try: + # Import vocab session storage + from vocab_worksheet_api import _sessions, convert_pdf_page_to_image + + # Check if session exists + if request.session_id not in _sessions: + raise HTTPException(status_code=404, detail=f"Session {request.session_id} not found") + + session = _sessions[request.session_id] + + # Check if PDF data exists + if not session.get("pdf_data"): + raise HTTPException(status_code=400, detail="Session has no PDF data") + + pdf_data = session["pdf_data"] + page_count = session.get("pdf_page_count", 1) + + if request.page_number < 1 or request.page_number > page_count: + raise HTTPException( + status_code=400, + detail=f"Page {request.page_number} not found. PDF has {page_count} pages." + ) + + # Get extracted vocabulary for this page + vocabulary = session.get("vocabulary", []) + page_vocab = [v for v in vocabulary if v.get("source_page") == request.page_number] + + logger.info(f"Reconstructing page {request.page_number} from session {request.session_id}") + logger.info(f"Found {len(page_vocab)} vocabulary items for this page") + + # Convert PDF page to image (async function) + image_bytes = await convert_pdf_page_to_image(pdf_data, request.page_number) + if not image_bytes: + raise HTTPException(status_code=500, detail="Failed to convert PDF page to image") + + # Get image dimensions + from PIL import Image + img = Image.open(io.BytesIO(image_bytes)) + img_width, img_height = img.size + + # Run OCR with positions + from hybrid_vocab_extractor import run_paddle_ocr, OCRRegion + ocr_regions, raw_text = run_paddle_ocr(image_bytes) + + logger.info(f"OCR found {len(ocr_regions)} text regions") + + # Scale factor: Convert image pixels to A4 canvas pixels (794x1123) + A4_WIDTH = 794 + A4_HEIGHT = 1123 + scale_x = A4_WIDTH / img_width + scale_y = A4_HEIGHT / img_height + + # Build Fabric.js objects + fabric_objects = [] + + # 1. Add white background + fabric_objects.append({ + "type": "rect", + "left": 0, + "top": 0, + "width": A4_WIDTH, + "height": A4_HEIGHT, + "fill": "#ffffff", + "selectable": False, + "evented": False, + "isBackground": True + }) + + # 2. Group OCR regions by Y-coordinate to detect rows + sorted_regions = sorted(ocr_regions, key=lambda r: (r.y1, r.x1)) + + # 3. Detect headers (larger text at top) + headers = [] + body_regions = [] + + for region in sorted_regions: + height = region.y2 - region.y1 + # Headers are typically taller and near the top + if region.y1 < img_height * 0.15 and height > 30: + headers.append(region) + else: + body_regions.append(region) + + # 4. Create text objects for each region + vocab_matched = 0 + + for region in sorted_regions: + # Scale positions to A4 + left = int(region.x1 * scale_x) + top = int(region.y1 * scale_y) + + # Determine if this is a header + is_header = region in headers + + # Determine font size based on region height + region_height = region.y2 - region.y1 + base_font_size = max(10, min(32, int(region_height * scale_y * 0.8))) + + if is_header: + base_font_size = max(base_font_size, 24) + + # Check if this text matches vocabulary + is_vocab = False + vocab_match = None + for v in page_vocab: + if v.get("english", "").lower() in region.text.lower() or \ + v.get("german", "").lower() in region.text.lower(): + is_vocab = True + vocab_match = v + vocab_matched += 1 + break + + # Create Fabric.js text object + text_obj = { + "type": "i-text", + "id": f"text_{uuid.uuid4().hex[:8]}", + "left": left, + "top": top, + "text": region.text, + "fontFamily": "Arial", + "fontSize": base_font_size, + "fontWeight": "bold" if is_header else "normal", + "fill": "#000000", + "originX": "left", + "originY": "top", + } + + # Add metadata for vocabulary items + if is_vocab and vocab_match: + text_obj["isVocabulary"] = True + text_obj["vocabularyId"] = vocab_match.get("id") + text_obj["english"] = vocab_match.get("english") + text_obj["german"] = vocab_match.get("german") + + fabric_objects.append(text_obj) + + # 5. If include_images, try to detect and extract image regions + if request.include_images: + image_regions = await _detect_image_regions(image_bytes, ocr_regions, img_width, img_height) + + for i, img_region in enumerate(image_regions): + # Extract image region from original + img_x1 = int(img_region["x1"]) + img_y1 = int(img_region["y1"]) + img_x2 = int(img_region["x2"]) + img_y2 = int(img_region["y2"]) + + # Crop the region + cropped = img.crop((img_x1, img_y1, img_x2, img_y2)) + + # Convert to base64 + buffer = io.BytesIO() + cropped.save(buffer, format='PNG') + buffer.seek(0) + img_base64 = f"data:image/png;base64,{base64.b64encode(buffer.getvalue()).decode('utf-8')}" + + # Create Fabric.js image object + fabric_objects.append({ + "type": "image", + "id": f"img_{uuid.uuid4().hex[:8]}", + "left": int(img_x1 * scale_x), + "top": int(img_y1 * scale_y), + "width": int((img_x2 - img_x1) * scale_x), + "height": int((img_y2 - img_y1) * scale_y), + "src": img_base64, + "scaleX": 1, + "scaleY": 1, + }) + + # Build canvas JSON + canvas_data = { + "version": "6.0.0", + "objects": fabric_objects, + "background": "#ffffff" + } + + return ReconstructResponse( + canvas_json=json.dumps(canvas_data), + page_width=A4_WIDTH, + page_height=A4_HEIGHT, + elements_count=len(fabric_objects), + vocabulary_matched=vocab_matched, + message=f"Reconstructed page {request.page_number} with {len(fabric_objects)} elements, {vocab_matched} vocabulary items matched" + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Document reconstruction failed: {e}") + import traceback + logger.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail=str(e)) + + +async def _detect_image_regions( + image_bytes: bytes, + ocr_regions: list, + img_width: int, + img_height: int +) -> List[Dict]: + """ + Detect image/graphic regions in the document. + + Uses a simple approach: + 1. Find large gaps between text regions (potential image areas) + 2. Use edge detection to find bounded regions + 3. Filter out text areas + """ + from PIL import Image + import numpy as np + + try: + img = Image.open(io.BytesIO(image_bytes)) + img_array = np.array(img.convert('L')) # Grayscale + + # Create a mask of text regions + text_mask = np.ones_like(img_array, dtype=bool) + for region in ocr_regions: + x1 = max(0, region.x1 - 5) + y1 = max(0, region.y1 - 5) + x2 = min(img_width, region.x2 + 5) + y2 = min(img_height, region.y2 + 5) + text_mask[y1:y2, x1:x2] = False + + # Find contours in non-text areas + # Simple approach: look for rectangular regions with significant content + image_regions = [] + + # Use edge detection + import cv2 + edges = cv2.Canny(img_array, 50, 150) + + # Apply text mask + edges[~text_mask] = 0 + + # Find contours + contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + for contour in contours: + x, y, w, h = cv2.boundingRect(contour) + + # Filter: minimum size for images (at least 50x50 pixels) + if w > 50 and h > 50: + # Filter: not too large (not the whole page) + if w < img_width * 0.9 and h < img_height * 0.9: + # Check if this region has actual content (not just edges) + region_content = img_array[y:y+h, x:x+w] + variance = np.var(region_content) + + if variance > 500: # Has enough visual content + image_regions.append({ + "x1": x, + "y1": y, + "x2": x + w, + "y2": y + h + }) + + # Remove overlapping regions (keep larger ones) + filtered_regions = [] + for region in sorted(image_regions, key=lambda r: (r["x2"]-r["x1"])*(r["y2"]-r["y1"]), reverse=True): + overlaps = False + for existing in filtered_regions: + # Check overlap + if not (region["x2"] < existing["x1"] or region["x1"] > existing["x2"] or + region["y2"] < existing["y1"] or region["y1"] > existing["y2"]): + overlaps = True + break + if not overlaps: + filtered_regions.append(region) + + logger.info(f"Detected {len(filtered_regions)} image regions") + return filtered_regions[:10] # Limit to 10 images max + + except Exception as e: + logger.warning(f"Image region detection failed: {e}") + return [] + + +@router.get("/sessions/available") +async def get_available_sessions(): + """ + Get list of available vocab sessions that can be reconstructed. + """ + try: + from vocab_worksheet_api import _sessions + + available = [] + for session_id, session in _sessions.items(): + if session.get("pdf_data"): # Only sessions with PDF + available.append({ + "id": session_id, + "name": session.get("name", "Unnamed"), + "description": session.get("description"), + "vocabulary_count": len(session.get("vocabulary", [])), + "page_count": session.get("pdf_page_count", 1), + "status": session.get("status", "unknown"), + "created_at": session.get("created_at", "").isoformat() if session.get("created_at") else None + }) + + return {"sessions": available, "total": len(available)} + + except Exception as e: + logger.error(f"Failed to list sessions: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/klausur-service/backend/zeugnis_api.py b/klausur-service/backend/zeugnis_api.py new file mode 100644 index 0000000..4d1618d --- /dev/null +++ b/klausur-service/backend/zeugnis_api.py @@ -0,0 +1,537 @@ +""" +Zeugnis Rights-Aware Crawler - API Endpoints + +FastAPI router for managing zeugnis sources, documents, and crawler operations. +""" + +from datetime import datetime, timedelta +from typing import Optional, List +from fastapi import APIRouter, HTTPException, BackgroundTasks, Query +from pydantic import BaseModel + +from zeugnis_models import ( + ZeugnisSource, ZeugnisSourceCreate, ZeugnisSourceVerify, + SeedUrl, SeedUrlCreate, + ZeugnisDocument, ZeugnisStats, + CrawlerStatus, CrawlRequest, CrawlQueueItem, + UsageEvent, AuditExport, + LicenseType, CrawlStatus, DocType, EventType, + BUNDESLAENDER, TRAINING_PERMISSIONS, + generate_id, get_training_allowed, get_bundesland_name, get_license_for_bundesland, +) +from zeugnis_crawler import ( + start_crawler, stop_crawler, get_crawler_status, +) +from metrics_db import ( + get_zeugnis_sources, upsert_zeugnis_source, + get_zeugnis_documents, get_zeugnis_stats, + log_zeugnis_event, get_pool, +) + + +router = APIRouter(prefix="/api/v1/admin/zeugnis", tags=["Zeugnis Crawler"]) + + +# ============================================================================= +# Sources Endpoints +# ============================================================================= + +@router.get("/sources", response_model=List[dict]) +async def list_sources(): + """Get all zeugnis sources (Bundesländer).""" + sources = await get_zeugnis_sources() + if not sources: + # Return default sources if none exist + return [ + { + "id": None, + "bundesland": code, + "name": info["name"], + "base_url": None, + "license_type": str(get_license_for_bundesland(code).value), + "training_allowed": get_training_allowed(code), + "verified_by": None, + "verified_at": None, + "created_at": None, + "updated_at": None, + } + for code, info in BUNDESLAENDER.items() + ] + return sources + + +@router.post("/sources", response_model=dict) +async def create_source(source: ZeugnisSourceCreate): + """Create or update a zeugnis source.""" + source_id = generate_id() + success = await upsert_zeugnis_source( + id=source_id, + bundesland=source.bundesland, + name=source.name, + license_type=source.license_type.value, + training_allowed=source.training_allowed, + base_url=source.base_url, + ) + if not success: + raise HTTPException(status_code=500, detail="Failed to create source") + return {"id": source_id, "success": True} + + +@router.put("/sources/{source_id}/verify", response_model=dict) +async def verify_source(source_id: str, verification: ZeugnisSourceVerify): + """Verify a source's license status.""" + pool = await get_pool() + if not pool: + raise HTTPException(status_code=503, detail="Database not available") + + try: + async with pool.acquire() as conn: + await conn.execute( + """ + UPDATE zeugnis_sources + SET license_type = $2, + training_allowed = $3, + verified_by = $4, + verified_at = NOW(), + updated_at = NOW() + WHERE id = $1 + """, + source_id, verification.license_type.value, + verification.training_allowed, verification.verified_by + ) + return {"success": True, "source_id": source_id} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/sources/{bundesland}", response_model=dict) +async def get_source_by_bundesland(bundesland: str): + """Get source details for a specific Bundesland.""" + pool = await get_pool() + if not pool: + # Return default info + if bundesland not in BUNDESLAENDER: + raise HTTPException(status_code=404, detail=f"Bundesland not found: {bundesland}") + return { + "bundesland": bundesland, + "name": get_bundesland_name(bundesland), + "training_allowed": get_training_allowed(bundesland), + "license_type": get_license_for_bundesland(bundesland).value, + "document_count": 0, + } + + try: + async with pool.acquire() as conn: + source = await conn.fetchrow( + "SELECT * FROM zeugnis_sources WHERE bundesland = $1", + bundesland + ) + if source: + doc_count = await conn.fetchval( + """ + SELECT COUNT(*) FROM zeugnis_documents d + JOIN zeugnis_seed_urls u ON d.seed_url_id = u.id + WHERE u.source_id = $1 + """, + source["id"] + ) + return {**dict(source), "document_count": doc_count or 0} + + # Return default + return { + "bundesland": bundesland, + "name": get_bundesland_name(bundesland), + "training_allowed": get_training_allowed(bundesland), + "license_type": get_license_for_bundesland(bundesland).value, + "document_count": 0, + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================================================= +# Seed URLs Endpoints +# ============================================================================= + +@router.get("/sources/{source_id}/urls", response_model=List[dict]) +async def list_seed_urls(source_id: str): + """Get all seed URLs for a source.""" + pool = await get_pool() + if not pool: + return [] + + try: + async with pool.acquire() as conn: + rows = await conn.fetch( + "SELECT * FROM zeugnis_seed_urls WHERE source_id = $1 ORDER BY created_at", + source_id + ) + return [dict(r) for r in rows] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/sources/{source_id}/urls", response_model=dict) +async def add_seed_url(source_id: str, seed_url: SeedUrlCreate): + """Add a new seed URL to a source.""" + pool = await get_pool() + if not pool: + raise HTTPException(status_code=503, detail="Database not available") + + url_id = generate_id() + try: + async with pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO zeugnis_seed_urls (id, source_id, url, doc_type, status) + VALUES ($1, $2, $3, $4, 'pending') + """, + url_id, source_id, seed_url.url, seed_url.doc_type.value + ) + return {"id": url_id, "success": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/urls/{url_id}", response_model=dict) +async def delete_seed_url(url_id: str): + """Delete a seed URL.""" + pool = await get_pool() + if not pool: + raise HTTPException(status_code=503, detail="Database not available") + + try: + async with pool.acquire() as conn: + await conn.execute( + "DELETE FROM zeugnis_seed_urls WHERE id = $1", + url_id + ) + return {"success": True} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================================================= +# Documents Endpoints +# ============================================================================= + +@router.get("/documents", response_model=List[dict]) +async def list_documents( + bundesland: Optional[str] = None, + limit: int = Query(100, le=500), + offset: int = 0, +): + """Get all zeugnis documents with optional filtering.""" + documents = await get_zeugnis_documents(bundesland=bundesland, limit=limit, offset=offset) + return documents + + +@router.get("/documents/{document_id}", response_model=dict) +async def get_document(document_id: str): + """Get details for a specific document.""" + pool = await get_pool() + if not pool: + raise HTTPException(status_code=503, detail="Database not available") + + try: + async with pool.acquire() as conn: + doc = await conn.fetchrow( + """ + SELECT d.*, s.bundesland, s.name as source_name + FROM zeugnis_documents d + JOIN zeugnis_seed_urls u ON d.seed_url_id = u.id + JOIN zeugnis_sources s ON u.source_id = s.id + WHERE d.id = $1 + """, + document_id + ) + if not doc: + raise HTTPException(status_code=404, detail="Document not found") + + # Log view event + await log_zeugnis_event(document_id, EventType.VIEWED.value) + + return dict(doc) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/documents/{document_id}/versions", response_model=List[dict]) +async def get_document_versions(document_id: str): + """Get version history for a document.""" + pool = await get_pool() + if not pool: + raise HTTPException(status_code=503, detail="Database not available") + + try: + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT * FROM zeugnis_document_versions + WHERE document_id = $1 + ORDER BY version DESC + """, + document_id + ) + return [dict(r) for r in rows] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================================================= +# Crawler Control Endpoints +# ============================================================================= + +@router.get("/crawler/status", response_model=dict) +async def crawler_status(): + """Get current crawler status.""" + return get_crawler_status() + + +@router.post("/crawler/start", response_model=dict) +async def start_crawl(request: CrawlRequest, background_tasks: BackgroundTasks): + """Start the crawler.""" + success = await start_crawler( + bundesland=request.bundesland, + source_id=request.source_id, + ) + if not success: + raise HTTPException(status_code=409, detail="Crawler already running") + return {"success": True, "message": "Crawler started"} + + +@router.post("/crawler/stop", response_model=dict) +async def stop_crawl(): + """Stop the crawler.""" + success = await stop_crawler() + if not success: + raise HTTPException(status_code=409, detail="Crawler not running") + return {"success": True, "message": "Crawler stopped"} + + +@router.get("/crawler/queue", response_model=List[dict]) +async def get_queue(): + """Get the crawler queue.""" + pool = await get_pool() + if not pool: + return [] + + try: + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT q.*, s.bundesland, s.name as source_name + FROM zeugnis_crawler_queue q + JOIN zeugnis_sources s ON q.source_id = s.id + ORDER BY q.priority DESC, q.created_at + """ + ) + return [dict(r) for r in rows] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/crawler/queue", response_model=dict) +async def add_to_queue(request: CrawlRequest): + """Add a source to the crawler queue.""" + pool = await get_pool() + if not pool: + raise HTTPException(status_code=503, detail="Database not available") + + queue_id = generate_id() + try: + async with pool.acquire() as conn: + # Get source ID if bundesland provided + source_id = request.source_id + if not source_id and request.bundesland: + source = await conn.fetchrow( + "SELECT id FROM zeugnis_sources WHERE bundesland = $1", + request.bundesland + ) + if source: + source_id = source["id"] + + if not source_id: + raise HTTPException(status_code=400, detail="Source not found") + + await conn.execute( + """ + INSERT INTO zeugnis_crawler_queue (id, source_id, priority, status) + VALUES ($1, $2, $3, 'pending') + """, + queue_id, source_id, request.priority + ) + return {"id": queue_id, "success": True} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================================================= +# Statistics Endpoints +# ============================================================================= + +@router.get("/stats", response_model=dict) +async def get_stats(): + """Get zeugnis crawler statistics.""" + stats = await get_zeugnis_stats() + return stats + + +@router.get("/stats/bundesland", response_model=List[dict]) +async def get_bundesland_stats(): + """Get statistics per Bundesland.""" + pool = await get_pool() + + # Build stats from BUNDESLAENDER with DB data if available + stats = [] + for code, info in BUNDESLAENDER.items(): + stat = { + "bundesland": code, + "name": info["name"], + "training_allowed": get_training_allowed(code), + "document_count": 0, + "indexed_count": 0, + "last_crawled": None, + } + + if pool: + try: + async with pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT + COUNT(d.id) as doc_count, + COUNT(CASE WHEN d.indexed_in_qdrant THEN 1 END) as indexed_count, + MAX(u.last_crawled) as last_crawled + FROM zeugnis_sources s + LEFT JOIN zeugnis_seed_urls u ON s.id = u.source_id + LEFT JOIN zeugnis_documents d ON u.id = d.seed_url_id + WHERE s.bundesland = $1 + GROUP BY s.id + """, + code + ) + if row: + stat["document_count"] = row["doc_count"] or 0 + stat["indexed_count"] = row["indexed_count"] or 0 + stat["last_crawled"] = row["last_crawled"].isoformat() if row["last_crawled"] else None + except Exception: + pass + + stats.append(stat) + + return stats + + +# ============================================================================= +# Audit Endpoints +# ============================================================================= + +@router.get("/audit/events", response_model=List[dict]) +async def get_audit_events( + document_id: Optional[str] = None, + event_type: Optional[str] = None, + limit: int = Query(100, le=1000), + days: int = Query(30, le=365), +): + """Get audit events with optional filtering.""" + pool = await get_pool() + if not pool: + return [] + + try: + since = datetime.now() - timedelta(days=days) + async with pool.acquire() as conn: + query = """ + SELECT * FROM zeugnis_usage_events + WHERE created_at >= $1 + """ + params = [since] + + if document_id: + query += " AND document_id = $2" + params.append(document_id) + if event_type: + query += f" AND event_type = ${len(params) + 1}" + params.append(event_type) + + query += f" ORDER BY created_at DESC LIMIT ${len(params) + 1}" + params.append(limit) + + rows = await conn.fetch(query, *params) + return [dict(r) for r in rows] + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/audit/export", response_model=dict) +async def export_audit( + days: int = Query(30, le=365), + requested_by: str = Query(..., description="User requesting the export"), +): + """Export audit data for GDPR compliance.""" + pool = await get_pool() + if not pool: + raise HTTPException(status_code=503, detail="Database not available") + + try: + since = datetime.now() - timedelta(days=days) + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT * FROM zeugnis_usage_events + WHERE created_at >= $1 + ORDER BY created_at DESC + """, + since + ) + + doc_count = await conn.fetchval( + "SELECT COUNT(DISTINCT document_id) FROM zeugnis_usage_events WHERE created_at >= $1", + since + ) + + return { + "export_date": datetime.now().isoformat(), + "requested_by": requested_by, + "events": [dict(r) for r in rows], + "document_count": doc_count or 0, + "date_range_start": since.isoformat(), + "date_range_end": datetime.now().isoformat(), + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================================================= +# Initialization Endpoint +# ============================================================================= + +@router.post("/init", response_model=dict) +async def initialize_sources(): + """Initialize default sources from BUNDESLAENDER.""" + pool = await get_pool() + if not pool: + raise HTTPException(status_code=503, detail="Database not available") + + created = 0 + try: + for code, info in BUNDESLAENDER.items(): + source_id = generate_id() + success = await upsert_zeugnis_source( + id=source_id, + bundesland=code, + name=info["name"], + license_type=get_license_for_bundesland(code).value, + training_allowed=get_training_allowed(code), + ) + if success: + created += 1 + + return {"success": True, "sources_created": created} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/klausur-service/backend/zeugnis_crawler.py b/klausur-service/backend/zeugnis_crawler.py new file mode 100644 index 0000000..09be582 --- /dev/null +++ b/klausur-service/backend/zeugnis_crawler.py @@ -0,0 +1,676 @@ +""" +Zeugnis Rights-Aware Crawler + +Crawls official government documents about school certificates (Zeugnisse) +from all 16 German federal states. Only indexes documents where AI training +is legally permitted. +""" + +import asyncio +import hashlib +import os +import re +import uuid +from datetime import datetime +from typing import Optional, List, Dict, Any, Tuple +from dataclasses import dataclass, field + +import httpx + +# Local imports +from zeugnis_models import ( + CrawlStatus, LicenseType, DocType, EventType, + BUNDESLAENDER, TRAINING_PERMISSIONS, + generate_id, get_training_allowed, get_bundesland_name, +) + + +# ============================================================================= +# Configuration +# ============================================================================= + +QDRANT_URL = os.getenv("QDRANT_URL", "http://localhost:6333") +MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "localhost:9000") +MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "test-access-key") +MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "test-secret-key") +MINIO_BUCKET = os.getenv("MINIO_BUCKET", "breakpilot-rag") +EMBEDDING_BACKEND = os.getenv("EMBEDDING_BACKEND", "local") + +ZEUGNIS_COLLECTION = "bp_zeugnis" +CHUNK_SIZE = 1000 +CHUNK_OVERLAP = 200 +MAX_RETRIES = 3 +RETRY_DELAY = 5 # seconds +REQUEST_TIMEOUT = 30 # seconds +USER_AGENT = "BreakPilot-Zeugnis-Crawler/1.0 (Educational Research)" + + +# ============================================================================= +# Crawler State +# ============================================================================= + +@dataclass +class CrawlerState: + """Global crawler state.""" + is_running: bool = False + current_source_id: Optional[str] = None + current_bundesland: Optional[str] = None + queue: List[Dict] = field(default_factory=list) + documents_crawled_today: int = 0 + documents_indexed_today: int = 0 + errors_today: int = 0 + last_activity: Optional[datetime] = None + + +_crawler_state = CrawlerState() + + +# ============================================================================= +# Text Extraction +# ============================================================================= + +def extract_text_from_pdf(content: bytes) -> str: + """Extract text from PDF bytes.""" + try: + from PyPDF2 import PdfReader + import io + + reader = PdfReader(io.BytesIO(content)) + text_parts = [] + for page in reader.pages: + text = page.extract_text() + if text: + text_parts.append(text) + return "\n\n".join(text_parts) + except Exception as e: + print(f"PDF extraction failed: {e}") + return "" + + +def extract_text_from_html(content: bytes, encoding: str = "utf-8") -> str: + """Extract text from HTML bytes.""" + try: + from bs4 import BeautifulSoup + + html = content.decode(encoding, errors="replace") + soup = BeautifulSoup(html, "html.parser") + + # Remove script and style elements + for element in soup(["script", "style", "nav", "header", "footer"]): + element.decompose() + + # Get text + text = soup.get_text(separator="\n", strip=True) + + # Clean up whitespace + lines = [line.strip() for line in text.splitlines() if line.strip()] + return "\n".join(lines) + except Exception as e: + print(f"HTML extraction failed: {e}") + return "" + + +def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> List[str]: + """Split text into overlapping chunks.""" + if not text: + return [] + + chunks = [] + separators = ["\n\n", "\n", ". ", " "] + + def split_recursive(text: str, sep_index: int = 0) -> List[str]: + if len(text) <= chunk_size: + return [text] if text.strip() else [] + + if sep_index >= len(separators): + # Force split at chunk_size + result = [] + for i in range(0, len(text), chunk_size - overlap): + chunk = text[i:i + chunk_size] + if chunk.strip(): + result.append(chunk) + return result + + sep = separators[sep_index] + parts = text.split(sep) + result = [] + current = "" + + for part in parts: + if len(current) + len(sep) + len(part) <= chunk_size: + current = current + sep + part if current else part + else: + if current.strip(): + result.extend(split_recursive(current, sep_index + 1) if len(current) > chunk_size else [current]) + current = part + + if current.strip(): + result.extend(split_recursive(current, sep_index + 1) if len(current) > chunk_size else [current]) + + return result + + chunks = split_recursive(text) + + # Add overlap + if overlap > 0 and len(chunks) > 1: + overlapped = [] + for i, chunk in enumerate(chunks): + if i > 0: + # Add end of previous chunk + prev_end = chunks[i - 1][-overlap:] + chunk = prev_end + chunk + overlapped.append(chunk) + chunks = overlapped + + return chunks + + +def compute_hash(content: bytes) -> str: + """Compute SHA-256 hash of content.""" + return hashlib.sha256(content).hexdigest() + + +# ============================================================================= +# Embedding Generation +# ============================================================================= + +_embedding_model = None + + +def get_embedding_model(): + """Get or initialize embedding model.""" + global _embedding_model + if _embedding_model is None and EMBEDDING_BACKEND == "local": + try: + from sentence_transformers import SentenceTransformer + _embedding_model = SentenceTransformer("all-MiniLM-L6-v2") + print("Loaded local embedding model: all-MiniLM-L6-v2") + except ImportError: + print("Warning: sentence-transformers not installed") + return _embedding_model + + +async def generate_embeddings(texts: List[str]) -> List[List[float]]: + """Generate embeddings for a list of texts.""" + if not texts: + return [] + + if EMBEDDING_BACKEND == "local": + model = get_embedding_model() + if model: + embeddings = model.encode(texts, show_progress_bar=False) + return [emb.tolist() for emb in embeddings] + return [] + + elif EMBEDDING_BACKEND == "openai": + import openai + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + print("Warning: OPENAI_API_KEY not set") + return [] + + client = openai.AsyncOpenAI(api_key=api_key) + response = await client.embeddings.create( + input=texts, + model="text-embedding-3-small" + ) + return [item.embedding for item in response.data] + + return [] + + +# ============================================================================= +# MinIO Storage +# ============================================================================= + +async def upload_to_minio( + content: bytes, + bundesland: str, + filename: str, + content_type: str = "application/pdf", + year: Optional[int] = None, +) -> Optional[str]: + """Upload document to MinIO.""" + try: + from minio import Minio + + client = Minio( + MINIO_ENDPOINT, + access_key=MINIO_ACCESS_KEY, + secret_key=MINIO_SECRET_KEY, + secure=os.getenv("MINIO_SECURE", "false").lower() == "true" + ) + + # Ensure bucket exists + if not client.bucket_exists(MINIO_BUCKET): + client.make_bucket(MINIO_BUCKET) + + # Build path + year_str = str(year) if year else str(datetime.now().year) + object_name = f"landes-daten/{bundesland}/zeugnis/{year_str}/{filename}" + + # Upload + import io + client.put_object( + MINIO_BUCKET, + object_name, + io.BytesIO(content), + len(content), + content_type=content_type, + ) + + return object_name + except Exception as e: + print(f"MinIO upload failed: {e}") + return None + + +# ============================================================================= +# Qdrant Indexing +# ============================================================================= + +async def index_in_qdrant( + doc_id: str, + chunks: List[str], + embeddings: List[List[float]], + metadata: Dict[str, Any], +) -> int: + """Index document chunks in Qdrant.""" + try: + from qdrant_client import QdrantClient + from qdrant_client.models import VectorParams, Distance, PointStruct + + client = QdrantClient(url=QDRANT_URL) + + # Ensure collection exists + collections = client.get_collections().collections + if not any(c.name == ZEUGNIS_COLLECTION for c in collections): + vector_size = len(embeddings[0]) if embeddings else 384 + client.create_collection( + collection_name=ZEUGNIS_COLLECTION, + vectors_config=VectorParams( + size=vector_size, + distance=Distance.COSINE, + ), + ) + print(f"Created Qdrant collection: {ZEUGNIS_COLLECTION}") + + # Create points + points = [] + for i, (chunk, embedding) in enumerate(zip(chunks, embeddings)): + point_id = str(uuid.uuid4()) + points.append(PointStruct( + id=point_id, + vector=embedding, + payload={ + "document_id": doc_id, + "chunk_index": i, + "chunk_text": chunk[:500], # Store first 500 chars for preview + "bundesland": metadata.get("bundesland"), + "doc_type": metadata.get("doc_type"), + "title": metadata.get("title"), + "source_url": metadata.get("url"), + "training_allowed": metadata.get("training_allowed", False), + "indexed_at": datetime.now().isoformat(), + } + )) + + # Upsert + if points: + client.upsert( + collection_name=ZEUGNIS_COLLECTION, + points=points, + ) + + return len(points) + except Exception as e: + print(f"Qdrant indexing failed: {e}") + return 0 + + +# ============================================================================= +# Crawler Worker +# ============================================================================= + +class ZeugnisCrawler: + """Rights-aware crawler for zeugnis documents.""" + + def __init__(self): + self.http_client: Optional[httpx.AsyncClient] = None + self.db_pool = None + + async def init(self): + """Initialize crawler resources.""" + self.http_client = httpx.AsyncClient( + timeout=REQUEST_TIMEOUT, + follow_redirects=True, + headers={"User-Agent": USER_AGENT}, + ) + + # Initialize database connection + try: + from metrics_db import get_pool + self.db_pool = await get_pool() + except Exception as e: + print(f"Failed to get database pool: {e}") + + async def close(self): + """Close crawler resources.""" + if self.http_client: + await self.http_client.aclose() + + async def fetch_url(self, url: str) -> Tuple[Optional[bytes], Optional[str]]: + """Fetch URL with retry logic.""" + for attempt in range(MAX_RETRIES): + try: + response = await self.http_client.get(url) + response.raise_for_status() + content_type = response.headers.get("content-type", "") + return response.content, content_type + except httpx.HTTPStatusError as e: + print(f"HTTP error {e.response.status_code} for {url}") + if e.response.status_code == 404: + return None, None + except Exception as e: + print(f"Attempt {attempt + 1}/{MAX_RETRIES} failed for {url}: {e}") + if attempt < MAX_RETRIES - 1: + await asyncio.sleep(RETRY_DELAY * (attempt + 1)) + return None, None + + async def crawl_seed_url( + self, + seed_url_id: str, + url: str, + bundesland: str, + doc_type: str, + training_allowed: bool, + ) -> Dict[str, Any]: + """Crawl a single seed URL.""" + global _crawler_state + + result = { + "seed_url_id": seed_url_id, + "url": url, + "success": False, + "document_id": None, + "indexed": False, + "error": None, + } + + try: + # Fetch content + content, content_type = await self.fetch_url(url) + if not content: + result["error"] = "Failed to fetch URL" + return result + + # Determine file type + is_pdf = "pdf" in content_type.lower() or url.lower().endswith(".pdf") + + # Extract text + if is_pdf: + text = extract_text_from_pdf(content) + filename = url.split("/")[-1] or f"document_{seed_url_id}.pdf" + else: + text = extract_text_from_html(content) + filename = f"document_{seed_url_id}.html" + + if not text: + result["error"] = "No text extracted" + return result + + # Compute hash for versioning + content_hash = compute_hash(content) + + # Upload to MinIO + minio_path = await upload_to_minio( + content, + bundesland, + filename, + content_type=content_type or "application/octet-stream", + ) + + # Generate document ID + doc_id = generate_id() + + # Store document in database + if self.db_pool: + async with self.db_pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO zeugnis_documents + (id, seed_url_id, title, url, content_hash, minio_path, + training_allowed, file_size, content_type) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT DO NOTHING + """, + doc_id, seed_url_id, filename, url, content_hash, + minio_path, training_allowed, len(content), content_type + ) + + result["document_id"] = doc_id + result["success"] = True + _crawler_state.documents_crawled_today += 1 + + # Only index if training is allowed + if training_allowed: + chunks = chunk_text(text) + if chunks: + embeddings = await generate_embeddings(chunks) + if embeddings: + indexed_count = await index_in_qdrant( + doc_id, + chunks, + embeddings, + { + "bundesland": bundesland, + "doc_type": doc_type, + "title": filename, + "url": url, + "training_allowed": True, + } + ) + if indexed_count > 0: + result["indexed"] = True + _crawler_state.documents_indexed_today += 1 + + # Update database + if self.db_pool: + async with self.db_pool.acquire() as conn: + await conn.execute( + "UPDATE zeugnis_documents SET indexed_in_qdrant = true WHERE id = $1", + doc_id + ) + else: + result["indexed"] = False + result["error"] = "Training not allowed for this source" + + _crawler_state.last_activity = datetime.now() + + except Exception as e: + result["error"] = str(e) + _crawler_state.errors_today += 1 + + return result + + async def crawl_source(self, source_id: str) -> Dict[str, Any]: + """Crawl all seed URLs for a source.""" + global _crawler_state + + result = { + "source_id": source_id, + "documents_found": 0, + "documents_indexed": 0, + "errors": [], + "started_at": datetime.now(), + "completed_at": None, + } + + if not self.db_pool: + result["errors"].append("Database not available") + return result + + try: + async with self.db_pool.acquire() as conn: + # Get source info + source = await conn.fetchrow( + "SELECT * FROM zeugnis_sources WHERE id = $1", + source_id + ) + if not source: + result["errors"].append(f"Source not found: {source_id}") + return result + + bundesland = source["bundesland"] + training_allowed = source["training_allowed"] + + _crawler_state.current_source_id = source_id + _crawler_state.current_bundesland = bundesland + + # Get seed URLs + seed_urls = await conn.fetch( + "SELECT * FROM zeugnis_seed_urls WHERE source_id = $1 AND status != 'completed'", + source_id + ) + + for seed_url in seed_urls: + # Update status to running + await conn.execute( + "UPDATE zeugnis_seed_urls SET status = 'running' WHERE id = $1", + seed_url["id"] + ) + + # Crawl + crawl_result = await self.crawl_seed_url( + seed_url["id"], + seed_url["url"], + bundesland, + seed_url["doc_type"], + training_allowed, + ) + + # Update status + if crawl_result["success"]: + result["documents_found"] += 1 + if crawl_result["indexed"]: + result["documents_indexed"] += 1 + await conn.execute( + "UPDATE zeugnis_seed_urls SET status = 'completed', last_crawled = NOW() WHERE id = $1", + seed_url["id"] + ) + else: + result["errors"].append(f"{seed_url['url']}: {crawl_result['error']}") + await conn.execute( + "UPDATE zeugnis_seed_urls SET status = 'failed', error_message = $2 WHERE id = $1", + seed_url["id"], crawl_result["error"] + ) + + # Small delay between requests + await asyncio.sleep(1) + + except Exception as e: + result["errors"].append(str(e)) + + finally: + result["completed_at"] = datetime.now() + _crawler_state.current_source_id = None + _crawler_state.current_bundesland = None + + return result + + +# ============================================================================= +# Crawler Control Functions +# ============================================================================= + +_crawler_instance: Optional[ZeugnisCrawler] = None +_crawler_task: Optional[asyncio.Task] = None + + +async def start_crawler(bundesland: Optional[str] = None, source_id: Optional[str] = None) -> bool: + """Start the crawler.""" + global _crawler_state, _crawler_instance, _crawler_task + + if _crawler_state.is_running: + return False + + _crawler_state.is_running = True + _crawler_state.documents_crawled_today = 0 + _crawler_state.documents_indexed_today = 0 + _crawler_state.errors_today = 0 + + _crawler_instance = ZeugnisCrawler() + await _crawler_instance.init() + + async def run_crawler(): + try: + from metrics_db import get_pool + pool = await get_pool() + + if pool: + async with pool.acquire() as conn: + # Get sources to crawl + if source_id: + sources = await conn.fetch( + "SELECT id, bundesland FROM zeugnis_sources WHERE id = $1", + source_id + ) + elif bundesland: + sources = await conn.fetch( + "SELECT id, bundesland FROM zeugnis_sources WHERE bundesland = $1", + bundesland + ) + else: + sources = await conn.fetch( + "SELECT id, bundesland FROM zeugnis_sources ORDER BY bundesland" + ) + + for source in sources: + if not _crawler_state.is_running: + break + await _crawler_instance.crawl_source(source["id"]) + + except Exception as e: + print(f"Crawler error: {e}") + + finally: + _crawler_state.is_running = False + if _crawler_instance: + await _crawler_instance.close() + + _crawler_task = asyncio.create_task(run_crawler()) + return True + + +async def stop_crawler() -> bool: + """Stop the crawler.""" + global _crawler_state, _crawler_task + + if not _crawler_state.is_running: + return False + + _crawler_state.is_running = False + + if _crawler_task: + _crawler_task.cancel() + try: + await _crawler_task + except asyncio.CancelledError: + pass + + return True + + +def get_crawler_status() -> Dict[str, Any]: + """Get current crawler status.""" + global _crawler_state + return { + "is_running": _crawler_state.is_running, + "current_source": _crawler_state.current_source_id, + "current_bundesland": _crawler_state.current_bundesland, + "queue_length": len(_crawler_state.queue), + "documents_crawled_today": _crawler_state.documents_crawled_today, + "documents_indexed_today": _crawler_state.documents_indexed_today, + "errors_today": _crawler_state.errors_today, + "last_activity": _crawler_state.last_activity.isoformat() if _crawler_state.last_activity else None, + } diff --git a/klausur-service/backend/zeugnis_models.py b/klausur-service/backend/zeugnis_models.py new file mode 100644 index 0000000..c616981 --- /dev/null +++ b/klausur-service/backend/zeugnis_models.py @@ -0,0 +1,340 @@ +""" +Zeugnis Rights-Aware Crawler - Data Models + +Pydantic models for API requests/responses and internal data structures. +Database schema is defined in metrics_db.py. +""" + +from datetime import datetime +from enum import Enum +from typing import Optional, List, Dict, Any +from pydantic import BaseModel, Field +import uuid + + +# ============================================================================= +# Enums +# ============================================================================= + +class LicenseType(str, Enum): + """License classification for training permission.""" + PUBLIC_DOMAIN = "public_domain" # Amtliche Werke (§5 UrhG) + CC_BY = "cc_by" # Creative Commons Attribution + CC_BY_SA = "cc_by_sa" # CC Attribution-ShareAlike + CC_BY_NC = "cc_by_nc" # CC NonCommercial - NO TRAINING + CC_BY_NC_SA = "cc_by_nc_sa" # CC NC-SA - NO TRAINING + GOV_STATUTE_FREE_USE = "gov_statute" # Government statutes (gemeinfrei) + ALL_RIGHTS_RESERVED = "all_rights" # Standard copyright - NO TRAINING + UNKNOWN_REQUIRES_REVIEW = "unknown" # Needs manual review + + +class CrawlStatus(str, Enum): + """Status of a crawl job or seed URL.""" + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + PAUSED = "paused" + + +class DocType(str, Enum): + """Type of zeugnis document.""" + VERORDNUNG = "verordnung" # Official regulation + HANDREICHUNG = "handreichung" # Implementation guide + FORMULAR = "formular" # Form template + ERLASS = "erlass" # Decree + SCHULORDNUNG = "schulordnung" # School regulations + SONSTIGES = "sonstiges" # Other + + +class EventType(str, Enum): + """Audit event types.""" + CRAWLED = "crawled" + INDEXED = "indexed" + DOWNLOADED = "downloaded" + VIEWED = "viewed" + EXPORTED = "exported" + TRAINED_ON = "trained_on" + DELETED = "deleted" + + +# ============================================================================= +# Bundesland Definitions +# ============================================================================= + +BUNDESLAENDER = { + "bw": {"name": "Baden-Württemberg", "short": "BW"}, + "by": {"name": "Bayern", "short": "BY"}, + "be": {"name": "Berlin", "short": "BE"}, + "bb": {"name": "Brandenburg", "short": "BB"}, + "hb": {"name": "Bremen", "short": "HB"}, + "hh": {"name": "Hamburg", "short": "HH"}, + "he": {"name": "Hessen", "short": "HE"}, + "mv": {"name": "Mecklenburg-Vorpommern", "short": "MV"}, + "ni": {"name": "Niedersachsen", "short": "NI"}, + "nw": {"name": "Nordrhein-Westfalen", "short": "NW"}, + "rp": {"name": "Rheinland-Pfalz", "short": "RP"}, + "sl": {"name": "Saarland", "short": "SL"}, + "sn": {"name": "Sachsen", "short": "SN"}, + "st": {"name": "Sachsen-Anhalt", "short": "ST"}, + "sh": {"name": "Schleswig-Holstein", "short": "SH"}, + "th": {"name": "Thüringen", "short": "TH"}, +} + + +# Training permission based on Word document analysis +TRAINING_PERMISSIONS = { + "bw": True, # Amtliches Werk + "by": True, # Amtliches Werk + "be": False, # Keine Lizenz + "bb": False, # Keine Lizenz + "hb": False, # Eingeschränkt -> False for safety + "hh": False, # Keine Lizenz + "he": True, # Amtliches Werk + "mv": False, # Eingeschränkt -> False for safety + "ni": True, # Amtliches Werk + "nw": True, # Amtliches Werk + "rp": True, # Amtliches Werk + "sl": False, # Keine Lizenz + "sn": True, # Amtliches Werk + "st": False, # Eingeschränkt -> False for safety + "sh": True, # Amtliches Werk + "th": True, # Amtliches Werk +} + + +# ============================================================================= +# API Models - Sources +# ============================================================================= + +class ZeugnisSourceBase(BaseModel): + """Base model for zeugnis source.""" + bundesland: str = Field(..., description="Bundesland code (e.g., 'ni', 'by')") + name: str = Field(..., description="Full name of the source") + base_url: Optional[str] = Field(None, description="Base URL for the source") + license_type: LicenseType = Field(..., description="License classification") + training_allowed: bool = Field(False, description="Whether AI training is permitted") + + +class ZeugnisSourceCreate(ZeugnisSourceBase): + """Model for creating a new source.""" + pass + + +class ZeugnisSource(ZeugnisSourceBase): + """Full source model with all fields.""" + id: str + verified_by: Optional[str] = None + verified_at: Optional[datetime] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class ZeugnisSourceVerify(BaseModel): + """Model for verifying a source's license.""" + verified_by: str = Field(..., description="User ID who verified") + license_type: LicenseType + training_allowed: bool + notes: Optional[str] = None + + +# ============================================================================= +# API Models - Seed URLs +# ============================================================================= + +class SeedUrlBase(BaseModel): + """Base model for seed URL.""" + url: str = Field(..., description="URL to crawl") + doc_type: DocType = Field(DocType.VERORDNUNG, description="Type of document") + + +class SeedUrlCreate(SeedUrlBase): + """Model for creating a new seed URL.""" + source_id: str + + +class SeedUrl(SeedUrlBase): + """Full seed URL model.""" + id: str + source_id: str + status: CrawlStatus = CrawlStatus.PENDING + last_crawled: Optional[datetime] = None + error_message: Optional[str] = None + created_at: datetime + + class Config: + from_attributes = True + + +# ============================================================================= +# API Models - Documents +# ============================================================================= + +class ZeugnisDocumentBase(BaseModel): + """Base model for zeugnis document.""" + title: Optional[str] = None + url: str + content_type: Optional[str] = None + file_size: Optional[int] = None + + +class ZeugnisDocument(ZeugnisDocumentBase): + """Full document model.""" + id: str + seed_url_id: str + content_hash: Optional[str] = None + minio_path: Optional[str] = None + training_allowed: bool = False + indexed_in_qdrant: bool = False + bundesland: Optional[str] = None + source_name: Optional[str] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class ZeugnisDocumentVersion(BaseModel): + """Document version for history tracking.""" + id: str + document_id: str + version: int + content_hash: str + minio_path: Optional[str] = None + change_summary: Optional[str] = None + created_at: datetime + + class Config: + from_attributes = True + + +# ============================================================================= +# API Models - Crawler +# ============================================================================= + +class CrawlerStatus(BaseModel): + """Current status of the crawler.""" + is_running: bool = False + current_source: Optional[str] = None + current_bundesland: Optional[str] = None + queue_length: int = 0 + documents_crawled_today: int = 0 + documents_indexed_today: int = 0 + last_activity: Optional[datetime] = None + errors_today: int = 0 + + +class CrawlQueueItem(BaseModel): + """Item in the crawl queue.""" + id: str + source_id: str + bundesland: str + source_name: str + priority: int = 5 + status: CrawlStatus = CrawlStatus.PENDING + started_at: Optional[datetime] = None + completed_at: Optional[datetime] = None + documents_found: int = 0 + documents_indexed: int = 0 + error_count: int = 0 + created_at: datetime + + +class CrawlRequest(BaseModel): + """Request to start a crawl.""" + bundesland: Optional[str] = Field(None, description="Specific Bundesland to crawl") + source_id: Optional[str] = Field(None, description="Specific source ID to crawl") + priority: int = Field(5, ge=1, le=10, description="Priority (1=lowest, 10=highest)") + + +class CrawlResult(BaseModel): + """Result of a crawl operation.""" + source_id: str + bundesland: str + documents_found: int + documents_indexed: int + documents_skipped: int + errors: List[str] + duration_seconds: float + + +# ============================================================================= +# API Models - Statistics +# ============================================================================= + +class ZeugnisStats(BaseModel): + """Statistics for the zeugnis crawler.""" + total_sources: int = 0 + total_documents: int = 0 + indexed_documents: int = 0 + training_allowed_documents: int = 0 + active_crawls: int = 0 + per_bundesland: List[Dict[str, Any]] = [] + + +class BundeslandStats(BaseModel): + """Statistics per Bundesland.""" + bundesland: str + name: str + training_allowed: bool + document_count: int + indexed_count: int + last_crawled: Optional[datetime] = None + + +# ============================================================================= +# API Models - Audit +# ============================================================================= + +class UsageEvent(BaseModel): + """Usage event for audit trail.""" + id: str + document_id: str + event_type: EventType + user_id: Optional[str] = None + details: Optional[Dict[str, Any]] = None + created_at: datetime + + class Config: + from_attributes = True + + +class AuditExport(BaseModel): + """GDPR-compliant audit export.""" + export_date: datetime + requested_by: str + events: List[UsageEvent] + document_count: int + date_range_start: datetime + date_range_end: datetime + + +# ============================================================================= +# Helper Functions +# ============================================================================= + +def generate_id() -> str: + """Generate a new UUID.""" + return str(uuid.uuid4()) + + +def get_training_allowed(bundesland: str) -> bool: + """Get training permission for a Bundesland.""" + return TRAINING_PERMISSIONS.get(bundesland.lower(), False) + + +def get_bundesland_name(code: str) -> str: + """Get full Bundesland name from code.""" + info = BUNDESLAENDER.get(code.lower(), {}) + return info.get("name", code) + + +def get_license_for_bundesland(bundesland: str) -> LicenseType: + """Get appropriate license type for a Bundesland.""" + if TRAINING_PERMISSIONS.get(bundesland.lower(), False): + return LicenseType.GOV_STATUTE_FREE_USE + return LicenseType.UNKNOWN_REQUIRES_REVIEW diff --git a/klausur-service/backend/zeugnis_seed_data.py b/klausur-service/backend/zeugnis_seed_data.py new file mode 100644 index 0000000..0d68107 --- /dev/null +++ b/klausur-service/backend/zeugnis_seed_data.py @@ -0,0 +1,415 @@ +""" +Zeugnis Seed Data - Initial URLs from Word Document + +Contains seed URLs for all 16 German federal states (Bundesländer) +based on the "Bundesland URL Zeugnisse.docx" document. + +Training permissions: +- Ja: Amtliches Werk (§5 UrhG) - training allowed +- Nein: Keine Lizenz angegeben - training NOT allowed +- Eingeschränkt: Treated as NOT allowed for safety +""" + +from typing import Dict, List, Any + +# Seed data structure: bundesland -> list of seed URLs +SEED_DATA: Dict[str, Dict[str, Any]] = { + "bw": { + "name": "Baden-Württemberg", + "license": "gov_statute", + "training_allowed": True, + "base_url": "https://www.landesrecht-bw.de", + "urls": [ + { + "url": "https://www.landesrecht-bw.de/jportal/portal/t/cru/page/bsbawueprod.psml?pid=Dokumentanzeige&showdoccase=1&js_peid=Trefferliste&documentnumber=1&numberofresults=1&fromdoctodoc=yes&doc.id=jlr-SchulGBWpP5&doc.part=X&doc.price=0.0&doc.hl=1", + "doc_type": "verordnung", + "title": "Schulgesetz BW - Zeugnisse" + }, + { + "url": "https://www.landesrecht-bw.de/jportal/portal/t/cs9/page/bsbawueprod.psml?pid=Dokumentanzeige&showdoccase=1&js_peid=Trefferliste&documentnumber=1&numberofresults=1&fromdoctodoc=yes&doc.id=jlr-NotenBildVBW2016rahmen&doc.part=X&doc.price=0.0", + "doc_type": "verordnung", + "title": "Notenbildungsverordnung" + } + ] + }, + "by": { + "name": "Bayern", + "license": "gov_statute", + "training_allowed": True, + "base_url": "https://www.gesetze-bayern.de", + "urls": [ + { + "url": "https://www.gesetze-bayern.de/Content/Document/BaySchO2016", + "doc_type": "schulordnung", + "title": "Bayerische Schulordnung" + }, + { + "url": "https://www.gesetze-bayern.de/Content/Document/BayGSO", + "doc_type": "schulordnung", + "title": "Grundschulordnung Bayern" + }, + { + "url": "https://www.gesetze-bayern.de/Content/Document/BayVSO", + "doc_type": "schulordnung", + "title": "Volksschulordnung Bayern" + } + ] + }, + "be": { + "name": "Berlin", + "license": "unknown", + "training_allowed": False, + "base_url": "https://gesetze.berlin.de", + "urls": [ + { + "url": "https://gesetze.berlin.de/bsbe/document/jlr-SchulGBEpP58", + "doc_type": "verordnung", + "title": "Berliner Schulgesetz - Zeugnisse" + }, + { + "url": "https://gesetze.berlin.de/bsbe/document/jlr-SekIVBE2010rahmen", + "doc_type": "verordnung", + "title": "Sekundarstufe I-Verordnung" + } + ] + }, + "bb": { + "name": "Brandenburg", + "license": "unknown", + "training_allowed": False, + "base_url": "https://bravors.brandenburg.de", + "urls": [ + { + "url": "https://bravors.brandenburg.de/verordnungen/vvzeugnis", + "doc_type": "verordnung", + "title": "Verwaltungsvorschriften Zeugnisse" + }, + { + "url": "https://bravors.brandenburg.de/verordnungen/gostv", + "doc_type": "verordnung", + "title": "GOST-Verordnung Brandenburg" + } + ] + }, + "hb": { + "name": "Bremen", + "license": "unknown", + "training_allowed": False, # Eingeschränkt -> False for safety + "base_url": "https://www.transparenz.bremen.de", + "urls": [ + { + "url": "https://www.transparenz.bremen.de/metainformationen/bremisches-schulgesetz-bremschg-vom-28-juni-2005-121009", + "doc_type": "verordnung", + "title": "Bremisches Schulgesetz" + }, + { + "url": "https://www.transparenz.bremen.de/metainformationen/verordnung-ueber-die-sekundarstufe-i-der-oberschule-vom-20-juni-2017-130380", + "doc_type": "verordnung", + "title": "Sekundarstufe I Verordnung Bremen" + } + ] + }, + "hh": { + "name": "Hamburg", + "license": "unknown", + "training_allowed": False, + "base_url": "https://www.landesrecht-hamburg.de", + "urls": [ + { + "url": "https://www.landesrecht-hamburg.de/bsha/document/jlr-SchulGHA2009pP44", + "doc_type": "verordnung", + "title": "Hamburgisches Schulgesetz - Zeugnisse" + }, + { + "url": "https://www.landesrecht-hamburg.de/bsha/document/jlr-AusglLeistVHA2011rahmen", + "doc_type": "verordnung", + "title": "Ausbildungs- und Prüfungsordnung" + } + ] + }, + "he": { + "name": "Hessen", + "license": "gov_statute", + "training_allowed": True, + "base_url": "https://www.rv.hessenrecht.hessen.de", + "urls": [ + { + "url": "https://www.rv.hessenrecht.hessen.de/bshe/document/jlr-SchulGHE2017pP73", + "doc_type": "verordnung", + "title": "Hessisches Schulgesetz - Zeugnisse" + }, + { + "url": "https://www.rv.hessenrecht.hessen.de/bshe/document/jlr-VOBGM11HE2011rahmen", + "doc_type": "verordnung", + "title": "Verordnung zur Gestaltung des Schulverhältnisses" + } + ] + }, + "mv": { + "name": "Mecklenburg-Vorpommern", + "license": "unknown", + "training_allowed": False, # Eingeschränkt -> False for safety + "base_url": "https://www.landesrecht-mv.de", + "urls": [ + { + "url": "https://www.landesrecht-mv.de/bsmv/document/jlr-SchulGMV2010pP63", + "doc_type": "verordnung", + "title": "Schulgesetz MV - Zeugnisse" + }, + { + "url": "https://www.landesrecht-mv.de/bsmv/document/jlr-ZeugnVMVrahmen", + "doc_type": "verordnung", + "title": "Zeugnisverordnung MV" + } + ] + }, + "ni": { + "name": "Niedersachsen", + "license": "gov_statute", + "training_allowed": True, + "base_url": "https://www.nds-voris.de", + "urls": [ + { + "url": "https://www.nds-voris.de/jportal/portal/t/1gxi/page/bsvorisprod.psml?pid=Dokumentanzeige&showdoccase=1&js_peid=Trefferliste&documentnumber=1&numberofresults=1&fromdoctodoc=yes&doc.id=jlr-SchulGNDpP59", + "doc_type": "verordnung", + "title": "Niedersächsisches Schulgesetz - Zeugnisse" + }, + { + "url": "https://www.nds-voris.de/jportal/portal/t/1gxi/page/bsvorisprod.psml?pid=Dokumentanzeige&showdoccase=1&js_peid=Trefferliste&documentnumber=1&numberofresults=1&fromdoctodoc=yes&doc.id=jlr-ErgZeugnErlNDrahmen", + "doc_type": "erlass", + "title": "Ergänzende Bestimmungen für Zeugnisse" + }, + { + "url": "https://www.mk.niedersachsen.de/startseite/schule/unsere_schulen/allgemein_bildende_schulen/zeugnisse_versetzungen/zeugnisse-und-versetzungen-6351.html", + "doc_type": "handreichung", + "title": "Handreichung Zeugnisse NI" + } + ] + }, + "nw": { + "name": "Nordrhein-Westfalen", + "license": "gov_statute", + "training_allowed": True, + "base_url": "https://recht.nrw.de", + "urls": [ + { + "url": "https://recht.nrw.de/lmi/owa/br_text_anzeigen?v_id=10000000000000000521", + "doc_type": "verordnung", + "title": "Schulgesetz NRW" + }, + { + "url": "https://recht.nrw.de/lmi/owa/br_text_anzeigen?v_id=10000000000000000525", + "doc_type": "verordnung", + "title": "Ausbildungs- und Prüfungsordnung Sek I" + }, + { + "url": "https://www.schulministerium.nrw/zeugnisse", + "doc_type": "handreichung", + "title": "Handreichung Zeugnisse NRW" + } + ] + }, + "rp": { + "name": "Rheinland-Pfalz", + "license": "gov_statute", + "training_allowed": True, + "base_url": "https://landesrecht.rlp.de", + "urls": [ + { + "url": "https://landesrecht.rlp.de/bsrp/document/jlr-SchulGRPpP61", + "doc_type": "verordnung", + "title": "Schulgesetz RP - Zeugnisse" + }, + { + "url": "https://landesrecht.rlp.de/bsrp/document/jlr-ZeugnVRPrahmen", + "doc_type": "verordnung", + "title": "Zeugnisverordnung RP" + } + ] + }, + "sl": { + "name": "Saarland", + "license": "unknown", + "training_allowed": False, + "base_url": "https://recht.saarland.de", + "urls": [ + { + "url": "https://recht.saarland.de/bssl/document/jlr-SchulOGSLrahmen", + "doc_type": "schulordnung", + "title": "Schulordnungsgesetz Saarland" + }, + { + "url": "https://recht.saarland.de/bssl/document/jlr-ZeugnVSL2014rahmen", + "doc_type": "verordnung", + "title": "Zeugnisverordnung Saarland" + } + ] + }, + "sn": { + "name": "Sachsen", + "license": "gov_statute", + "training_allowed": True, + "base_url": "https://www.revosax.sachsen.de", + "urls": [ + { + "url": "https://www.revosax.sachsen.de/vorschrift/4192-Schulgesetz-fuer-den-Freistaat-Sachsen", + "doc_type": "verordnung", + "title": "Schulgesetz Sachsen" + }, + { + "url": "https://www.revosax.sachsen.de/vorschrift/13500-Schulordnung-Gymnasien-Abiturpruefung", + "doc_type": "schulordnung", + "title": "Schulordnung Gymnasien Sachsen" + } + ] + }, + "st": { + "name": "Sachsen-Anhalt", + "license": "unknown", + "training_allowed": False, # Eingeschränkt -> False for safety + "base_url": "https://www.landesrecht.sachsen-anhalt.de", + "urls": [ + { + "url": "https://www.landesrecht.sachsen-anhalt.de/bsst/document/jlr-SchulGSTpP27", + "doc_type": "verordnung", + "title": "Schulgesetz Sachsen-Anhalt" + }, + { + "url": "https://www.landesrecht.sachsen-anhalt.de/bsst/document/jlr-VersetzVST2017rahmen", + "doc_type": "verordnung", + "title": "Versetzungsverordnung ST" + } + ] + }, + "sh": { + "name": "Schleswig-Holstein", + "license": "gov_statute", + "training_allowed": True, + "base_url": "https://www.gesetze-rechtsprechung.sh.juris.de", + "urls": [ + { + "url": "https://www.gesetze-rechtsprechung.sh.juris.de/jportal/portal/t/10wx/page/bsshoprod.psml?pid=Dokumentanzeige&showdoccase=1&js_peid=Trefferliste&documentnumber=1&numberofresults=1&fromdoctodoc=yes&doc.id=jlr-SchulGSHpP22", + "doc_type": "verordnung", + "title": "Schulgesetz SH - Zeugnisse" + }, + { + "url": "https://www.gesetze-rechtsprechung.sh.juris.de/jportal/portal/t/10wx/page/bsshoprod.psml?pid=Dokumentanzeige&showdoccase=1&js_peid=Trefferliste&documentnumber=1&numberofresults=1&fromdoctodoc=yes&doc.id=jlr-ZeugnVSHrahmen", + "doc_type": "verordnung", + "title": "Zeugnisverordnung SH" + } + ] + }, + "th": { + "name": "Thüringen", + "license": "gov_statute", + "training_allowed": True, + "base_url": "https://landesrecht.thueringen.de", + "urls": [ + { + "url": "https://landesrecht.thueringen.de/bsth/document/jlr-SchulGTHpP58", + "doc_type": "verordnung", + "title": "Thüringer Schulgesetz - Zeugnisse" + }, + { + "url": "https://landesrecht.thueringen.de/bsth/document/jlr-SchulOTH2018rahmen", + "doc_type": "schulordnung", + "title": "Thüringer Schulordnung" + } + ] + } +} + + +async def populate_seed_data(): + """Populate database with seed data.""" + from metrics_db import get_pool, upsert_zeugnis_source + from zeugnis_models import generate_id + + pool = await get_pool() + if not pool: + print("Database not available") + return False + + try: + async with pool.acquire() as conn: + for bundesland, data in SEED_DATA.items(): + # Create or update source + source_id = generate_id() + await upsert_zeugnis_source( + id=source_id, + bundesland=bundesland, + name=data["name"], + license_type=data["license"], + training_allowed=data["training_allowed"], + base_url=data.get("base_url"), + ) + + # Get the actual source ID (might be existing) + existing = await conn.fetchrow( + "SELECT id FROM zeugnis_sources WHERE bundesland = $1", + bundesland + ) + if existing: + source_id = existing["id"] + + # Add seed URLs + for url_data in data.get("urls", []): + url_id = generate_id() + await conn.execute( + """ + INSERT INTO zeugnis_seed_urls (id, source_id, url, doc_type, status) + VALUES ($1, $2, $3, $4, 'pending') + ON CONFLICT DO NOTHING + """, + url_id, source_id, url_data["url"], url_data["doc_type"] + ) + + print(f"Populated {bundesland}: {len(data.get('urls', []))} URLs") + + print("Seed data population complete!") + return True + + except Exception as e: + print(f"Failed to populate seed data: {e}") + return False + + +def get_training_summary() -> Dict[str, List[str]]: + """Get summary of training permissions.""" + allowed = [] + not_allowed = [] + + for bundesland, data in SEED_DATA.items(): + name = data["name"] + if data["training_allowed"]: + allowed.append(f"{name} ({bundesland})") + else: + not_allowed.append(f"{name} ({bundesland})") + + return { + "training_allowed": sorted(allowed), + "training_not_allowed": sorted(not_allowed), + "total_allowed": len(allowed), + "total_not_allowed": len(not_allowed), + } + + +if __name__ == "__main__": + import asyncio + + print("=" * 60) + print("Zeugnis Seed Data Summary") + print("=" * 60) + + summary = get_training_summary() + print(f"\nTraining ALLOWED ({summary['total_allowed']} Bundesländer):") + for bl in summary["training_allowed"]: + print(f" ✓ {bl}") + + print(f"\nTraining NOT ALLOWED ({summary['total_not_allowed']} Bundesländer):") + for bl in summary["training_not_allowed"]: + print(f" ✗ {bl}") + + print("\n" + "=" * 60) + print("To populate database, run:") + print(" python -c 'import asyncio; from zeugnis_seed_data import populate_seed_data; asyncio.run(populate_seed_data())'") diff --git a/klausur-service/docs/BYOEH-Architecture.md b/klausur-service/docs/BYOEH-Architecture.md new file mode 100644 index 0000000..21cdfb2 --- /dev/null +++ b/klausur-service/docs/BYOEH-Architecture.md @@ -0,0 +1,468 @@ +# BYOEH (Bring-Your-Own-Expectation-Horizon) - Architecture Documentation + +## Overview + +The BYOEH module enables teachers to upload their own Erwartungshorizonte (expectation horizons/grading rubrics) and use them for RAG-assisted grading suggestions. Key design principles: + +- **Tenant Isolation**: Each teacher/school has an isolated namespace +- **No Training Guarantee**: EH content is only used for RAG, never for model training +- **Operator Blindness**: Client-side encryption ensures Breakpilot cannot view plaintext +- **Rights Confirmation**: Required legal acknowledgment at upload time + +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ klausur-service (Port 8086) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ ┌────────────────────┐ ┌─────────────────────────────────────────┐ │ +│ │ BYOEH REST API │ │ BYOEH Service Layer │ │ +│ │ │ │ │ │ +│ │ POST /api/v1/eh │───▶│ - Upload Wizard Logic │ │ +│ │ GET /api/v1/eh │ │ - Rights Confirmation │ │ +│ │ DELETE /api/v1/eh │ │ - Chunking Pipeline │ │ +│ │ POST /rag-query │ │ - Encryption Service │ │ +│ └────────────────────┘ └────────────────────┬────────────────────┘ │ +└─────────────────────────────────────────────────┼────────────────────────┘ + │ + ┌───────────────────────────────────────┼───────────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌──────────────────────┐ ┌──────────────────────────┐ ┌──────────────────────┐ +│ PostgreSQL │ │ Qdrant │ │ Encrypted Storage │ +│ (Metadata + Audit) │ │ (Vector Search) │ │ /app/eh-uploads/ │ +│ │ │ │ │ │ +│ In-Memory Storage: │ │ Collection: bp_eh │ │ {tenant}/{eh_id}/ │ +│ - erwartungshorizonte│ │ - tenant_id (filter) │ │ encrypted.bin │ +│ - eh_chunks │ │ - eh_id │ │ salt.txt │ +│ - eh_key_shares │ │ - embedding[1536] │ │ │ +│ - eh_klausur_links │ │ - encrypted_content │ └──────────────────────┘ +│ - eh_audit_log │ │ │ +└──────────────────────┘ └──────────────────────────┘ +``` + +## Data Flow + +### 1. Upload Flow + +``` +Browser Backend Storage + │ │ │ + │ 1. User selects PDF │ │ + │ 2. User enters passphrase │ │ + │ 3. PBKDF2 key derivation │ │ + │ 4. AES-256-GCM encryption │ │ + │ 5. SHA-256 key hash │ │ + │ │ │ + │──────────────────────────────▶│ │ + │ POST /api/v1/eh/upload │ │ + │ (encrypted blob + key_hash) │ │ + │ │──────────────────────────────▶│ + │ │ Store encrypted.bin + salt │ + │ │◀──────────────────────────────│ + │ │ │ + │ │ Save metadata to DB │ + │◀──────────────────────────────│ │ + │ Return EH record │ │ +``` + +### 2. Indexing Flow (RAG Preparation) + +``` +Browser Backend Qdrant + │ │ │ + │──────────────────────────────▶│ │ + │ POST /api/v1/eh/{id}/index │ │ + │ (passphrase for decryption) │ │ + │ │ │ + │ │ 1. Verify key hash │ + │ │ 2. Decrypt content │ + │ │ 3. Extract text (PDF) │ + │ │ 4. Chunk text │ + │ │ 5. Generate embeddings │ + │ │ 6. Re-encrypt each chunk │ + │ │──────────────────────────────▶│ + │ │ Index vectors + encrypted │ + │ │ chunks with tenant filter │ + │◀──────────────────────────────│ │ + │ Return chunk count │ │ +``` + +### 3. RAG Query Flow + +``` +Browser Backend Qdrant + │ │ │ + │──────────────────────────────▶│ │ + │ POST /api/v1/eh/rag-query │ │ + │ (query + passphrase) │ │ + │ │ │ + │ │ 1. Generate query embedding │ + │ │──────────────────────────────▶│ + │ │ 2. Semantic search │ + │ │ (tenant-filtered) │ + │ │◀──────────────────────────────│ + │ │ 3. Decrypt matched chunks │ + │◀──────────────────────────────│ │ + │ Return decrypted context │ │ +``` + +## Security Architecture + +### Client-Side Encryption + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Browser (Client-Side) │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. User enters passphrase (NEVER sent to server) │ +│ │ │ +│ ▼ │ +│ 2. Key Derivation: PBKDF2-SHA256(passphrase, salt, 100k iter) │ +│ │ │ +│ ▼ │ +│ 3. Encryption: AES-256-GCM(key, iv, file_content) │ +│ │ │ +│ ▼ │ +│ 4. Key-Hash: SHA-256(derived_key) → server verification only │ +│ │ │ +│ ▼ │ +│ 5. Upload: encrypted_blob + key_hash + salt (NOT key!) │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Security Guarantees + +| Guarantee | Implementation | +|-----------|----------------| +| **No Training** | `training_allowed: false` on all Qdrant points | +| **Operator Blindness** | Passphrase never leaves browser; server only sees key hash | +| **Tenant Isolation** | Every query filtered by `tenant_id` | +| **Audit Trail** | All actions logged with timestamps | + +## Key Sharing System + +The key sharing system enables first examiners to grant access to their EH to second examiners and supervisors. + +### Share Flow + +``` +First Examiner Backend Second Examiner + │ │ │ + │ 1. Encrypt passphrase for │ │ + │ recipient (client-side) │ │ + │ │ │ + │─────────────────────────────▶ │ + │ POST /eh/{id}/share │ │ + │ (encrypted_passphrase, role)│ │ + │ │ │ + │ │ Store EHKeyShare │ + │◀───────────────────────────── │ + │ │ │ + │ │ │ + │ │◀────────────────────────────│ + │ │ GET /eh/shared-with-me │ + │ │ │ + │ │─────────────────────────────▶ + │ │ Return shared EH list │ + │ │ │ + │ │◀────────────────────────────│ + │ │ RAG query with decrypted │ + │ │ passphrase │ +``` + +### Data Structures + +```python +@dataclass +class EHKeyShare: + id: str + eh_id: str + user_id: str # Recipient + encrypted_passphrase: str # Client-encrypted for recipient + passphrase_hint: str # Optional hint + granted_by: str # Grantor user ID + granted_at: datetime + role: str # second_examiner, third_examiner, supervisor + klausur_id: Optional[str] # Link to specific Klausur + active: bool + +@dataclass +class EHKlausurLink: + id: str + eh_id: str + klausur_id: str + linked_by: str + linked_at: datetime +``` + +## API Endpoints + +### Core EH Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/v1/eh/upload` | Upload encrypted EH | +| GET | `/api/v1/eh` | List user's EH | +| GET | `/api/v1/eh/{id}` | Get single EH | +| DELETE | `/api/v1/eh/{id}` | Soft delete EH | +| POST | `/api/v1/eh/{id}/index` | Index EH for RAG | +| POST | `/api/v1/eh/rag-query` | Query EH content | + +### Key Sharing Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/v1/eh/{id}/share` | Share EH with examiner | +| GET | `/api/v1/eh/{id}/shares` | List shares (owner) | +| DELETE | `/api/v1/eh/{id}/shares/{shareId}` | Revoke share | +| GET | `/api/v1/eh/shared-with-me` | List EH shared with user | + +### Klausur Integration Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| POST | `/api/v1/eh/{id}/link-klausur` | Link EH to Klausur | +| DELETE | `/api/v1/eh/{id}/link-klausur/{klausurId}` | Unlink EH | +| GET | `/api/v1/klausuren/{id}/linked-eh` | Get linked EH for Klausur | + +### Audit & Admin Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/api/v1/eh/audit-log` | Get audit log | +| GET | `/api/v1/eh/rights-text` | Get rights confirmation text | +| GET | `/api/v1/eh/qdrant-status` | Get Qdrant status (admin) | + +## Frontend Components + +### EHUploadWizard + +5-step wizard for uploading Erwartungshorizonte: + +1. **File Selection** - Choose PDF file +2. **Metadata** - Title, Subject, Niveau, Year +3. **Rights Confirmation** - Legal acknowledgment +4. **Encryption** - Set passphrase (2x confirmation) +5. **Summary** - Review and upload + +### Integration Points + +- **KorrekturPage**: Shows EH prompt after first student upload +- **GutachtenGeneration**: Uses RAG context from linked EH +- **Sidebar Badge**: Shows linked EH count + +## File Structure + +``` +klausur-service/ +├── backend/ +│ ├── main.py # API endpoints + data structures +│ ├── qdrant_service.py # Vector database operations +│ ├── eh_pipeline.py # Chunking, embedding, encryption +│ └── requirements.txt # Python dependencies +├── frontend/ +│ └── src/ +│ ├── components/ +│ │ └── EHUploadWizard.tsx +│ ├── services/ +│ │ ├── api.ts # API client +│ │ └── encryption.ts # Client-side crypto +│ ├── pages/ +│ │ └── KorrekturPage.tsx # EH integration +│ └── styles/ +│ └── eh-wizard.css +└── docs/ + ├── BYOEH-Architecture.md + └── BYOEH-Developer-Guide.md +``` + +## Configuration + +### Environment Variables + +```env +QDRANT_URL=http://qdrant:6333 +OPENAI_API_KEY=sk-... # For embeddings +BYOEH_ENCRYPTION_ENABLED=true +EH_UPLOAD_DIR=/app/eh-uploads +``` + +### Docker Services + +```yaml +# docker-compose.yml +services: + qdrant: + image: qdrant/qdrant:v1.7.4 + ports: + - "6333:6333" + volumes: + - qdrant_data:/qdrant/storage +``` + +## Audit Events + +| Action | Description | +|--------|-------------| +| `upload` | EH uploaded | +| `index` | EH indexed for RAG | +| `rag_query` | RAG query executed | +| `delete` | EH soft deleted | +| `share` | EH shared with examiner | +| `revoke_share` | Share revoked | +| `link_klausur` | EH linked to Klausur | +| `unlink_klausur` | EH unlinked from Klausur | + +--- + +## RBAC Extensions for Zeugnis System + +The RBAC system has been extended to support the Zeugnis (Certificate) workflow. This enables role-based access control for certificate generation, approval, and management. + +### Certificate-Related Roles + +```python +class Role(str, Enum): + # Existing exam roles + ERSTPRUEFER = "erstpruefer" # First examiner + ZWEITPRUEFER = "zweitpruefer" # Second examiner + DRITTPRUEFER = "drittpruefer" # Third examiner + FACHVORSITZ = "fachvorsitz" # Subject chair + + # Certificate workflow roles + FACHLEHRER = "fachlehrer" # Subject teacher - enters grades + KLASSENLEHRER = "klassenlehrer" # Class teacher - approves grades + ZEUGNISBEAUFTRAGTER = "zeugnisbeauftragter" # Certificate coordinator + SCHULLEITUNG = "schulleitung" # Principal - final sign-off + SEKRETARIAT = "sekretariat" # Secretary - printing +``` + +### Certificate Resource Types + +```python +class ResourceType(str, Enum): + # Existing types + KLAUSUR = "klausur" + ERWARTUNGSHORIZONT = "erwartungshorizont" + + # Certificate types + ZEUGNIS = "zeugnis" # Final certificate + ZEUGNIS_VORLAGE = "zeugnis_vorlage" # Certificate template + ZEUGNIS_ENTWURF = "zeugnis_entwurf" # Draft certificate + FACHNOTE = "fachnote" # Subject grade + KOPFNOTE = "kopfnote" # Head grade (Arbeits-/Sozialverhalten) + BEMERKUNG = "bemerkung" # Certificate remarks + STATISTIK = "statistik" # Class/subject statistics + NOTENSPIEGEL = "notenspiegel" # Grade distribution +``` + +### VerfahrenType Extension + +```python +class VerfahrenType(str, Enum): + # Exam types + ABITUR = "abitur" + KLAUSUR = "klausur" + NACHSCHREIBKLAUSUR = "nachschreibklausur" + + # Certificate types (NEW) + HALBJAHRESZEUGNIS = "halbjahreszeugnis" # Mid-year certificate + JAHRESZEUGNIS = "jahreszeugnis" # End-of-year certificate + ABSCHLUSSZEUGNIS = "abschlusszeugnis" # Graduation certificate + ABGANGSZEUGNIS = "abgangszeugnis" # Leaving certificate + + @classmethod + def is_certificate_type(cls, verfahren: "VerfahrenType") -> bool: + """Check if this is a certificate type (not an exam).""" + cert_types = { + cls.HALBJAHRESZEUGNIS, + cls.JAHRESZEUGNIS, + cls.ABSCHLUSSZEUGNIS, + cls.ABGANGSZEUGNIS + } + return verfahren in cert_types +``` + +### Certificate Workflow Permissions + +``` +┌───────────────────┐ ┌───────────────────┐ ┌───────────────────────┐ +│ FACHLEHRER │───▶│ KLASSENLEHRER │───▶│ ZEUGNISBEAUFTRAGTER │ +│ │ │ │ │ │ +│ FACHNOTE: CRUD │ │ ZEUGNIS: CRU │ │ ZEUGNIS: RU │ +│ ZEUGNIS_ENTWURF:R │ │ ZEUGNIS_ENTWURF: │ │ ZEUGNIS_VORLAGE: RUU │ +│ │ │ CRUD │ │ │ +└───────────────────┘ └───────────────────┘ └───────────────────────┘ + │ + ▼ + ┌───────────────────┐ ┌───────────────────────┐ + │ SEKRETARIAT │◀───│ SCHULLEITUNG │ + │ │ │ │ + │ ZEUGNIS: RD │ │ ZEUGNIS: R/SIGN/LOCK │ + │ (Print & Archive) │ │ (Final Approval) │ + └───────────────────┘ └───────────────────────┘ +``` + +### DEFAULT_PERMISSIONS for Certificate Roles + +```python +DEFAULT_PERMISSIONS = { + # ... existing roles ... + + Role.KLASSENLEHRER: { + ResourceType.KLASSE: {Action.READ, Action.UPDATE}, + ResourceType.SCHUELER: {Action.READ, Action.CREATE, Action.UPDATE}, + ResourceType.FACHNOTE: {Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE}, + ResourceType.ZEUGNIS: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.ZEUGNIS_ENTWURF: {Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE}, + ResourceType.ZEUGNIS_VORLAGE: {Action.READ}, + ResourceType.KOPFNOTE: {Action.CREATE, Action.READ, Action.UPDATE}, + ResourceType.BEMERKUNG: {Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE}, + ResourceType.STATISTIK: {Action.READ}, + ResourceType.NOTENSPIEGEL: {Action.READ}, + }, + + Role.ZEUGNISBEAUFTRAGTER: { + ResourceType.KLASSE: {Action.READ}, + ResourceType.ZEUGNIS: {Action.READ, Action.UPDATE}, + ResourceType.ZEUGNIS_ENTWURF: {Action.READ, Action.UPDATE}, + ResourceType.ZEUGNIS_VORLAGE: {Action.READ, Action.UPDATE, Action.UPLOAD}, + ResourceType.STATISTIK: {Action.READ}, + ResourceType.NOTENSPIEGEL: {Action.READ}, + }, + + Role.SEKRETARIAT: { + ResourceType.ZEUGNIS: {Action.READ, Action.DOWNLOAD}, + ResourceType.ZEUGNIS_VORLAGE: {Action.READ}, + }, + + Role.SCHULLEITUNG: { + ResourceType.ZEUGNIS: {Action.READ, Action.SIGN_OFF, Action.LOCK}, + ResourceType.ZEUGNIS_ENTWURF: {Action.READ, Action.UPDATE}, + ResourceType.ZEUGNIS_VORLAGE: {Action.READ, Action.UPDATE}, + ResourceType.STATISTIK: {Action.READ}, + ResourceType.NOTENSPIEGEL: {Action.READ}, + }, + + Role.FACHLEHRER: { + ResourceType.FACHNOTE: {Action.CREATE, Action.READ, Action.UPDATE, Action.DELETE}, + ResourceType.ZEUGNIS: {Action.READ, Action.UPDATE}, + ResourceType.ZEUGNIS_ENTWURF: {Action.READ, Action.UPDATE}, + ResourceType.STATISTIK: {Action.READ}, + ResourceType.NOTENSPIEGEL: {Action.READ}, + }, +} +``` + +### See Also + +For complete Zeugnis system documentation including: +- Full workflow diagrams +- Statistics API endpoints +- Frontend components +- Seed data generator + +See: [docs/architecture/zeugnis-system.md](../../docs/architecture/zeugnis-system.md) diff --git a/klausur-service/docs/BYOEH-Developer-Guide.md b/klausur-service/docs/BYOEH-Developer-Guide.md new file mode 100644 index 0000000..0b70503 --- /dev/null +++ b/klausur-service/docs/BYOEH-Developer-Guide.md @@ -0,0 +1,481 @@ +# BYOEH Developer Guide + +## Quick Start + +### Prerequisites + +- Python 3.10+ +- Node.js 18+ +- Docker & Docker Compose +- OpenAI API Key (for embeddings) + +### Setup + +1. **Start services:** +```bash +docker-compose up -d qdrant +``` + +2. **Configure environment:** +```env +QDRANT_URL=http://localhost:6333 +OPENAI_API_KEY=sk-your-key +BYOEH_ENCRYPTION_ENABLED=true +``` + +3. **Run klausur-service:** +```bash +cd klausur-service/backend +pip install -r requirements.txt +uvicorn main:app --reload --port 8086 +``` + +4. **Run frontend:** +```bash +cd klausur-service/frontend +npm install +npm run dev +``` + +## Client-Side Encryption + +The encryption service (`encryption.ts`) handles all cryptographic operations in the browser: + +### Encrypting a File + +```typescript +import { encryptFile, generateSalt } from '../services/encryption' + +const file = document.getElementById('fileInput').files[0] +const passphrase = 'user-secret-password' + +const encrypted = await encryptFile(file, passphrase) +// Result: +// { +// encryptedData: ArrayBuffer, +// keyHash: string, // SHA-256 hash for verification +// salt: string, // Hex-encoded salt +// iv: string // Hex-encoded initialization vector +// } +``` + +### Decrypting Content + +```typescript +import { decryptText, verifyPassphrase } from '../services/encryption' + +// First verify the passphrase +const isValid = await verifyPassphrase(passphrase, salt, expectedKeyHash) + +if (isValid) { + const decrypted = await decryptText(encryptedBase64, passphrase, salt) +} +``` + +## Backend API Usage + +### Upload an Erwartungshorizont + +```python +# The upload endpoint accepts FormData with: +# - file: encrypted binary blob +# - metadata_json: JSON string with metadata + +POST /api/v1/eh/upload +Content-Type: multipart/form-data + +{ + "file": , + "metadata_json": { + "metadata": { + "title": "Deutsch LK 2025", + "subject": "deutsch", + "niveau": "eA", + "year": 2025, + "aufgaben_nummer": "Aufgabe 1" + }, + "encryption_key_hash": "abc123...", + "salt": "def456...", + "rights_confirmed": true, + "original_filename": "erwartungshorizont.pdf" + } +} +``` + +### Index for RAG + +```python +POST /api/v1/eh/{eh_id}/index +Content-Type: application/json + +{ + "passphrase": "user-secret-password" +} +``` + +The backend will: +1. Verify the passphrase against stored key hash +2. Decrypt the file +3. Extract text from PDF +4. Chunk the text (1000 chars, 200 overlap) +5. Generate OpenAI embeddings +6. Re-encrypt each chunk +7. Index in Qdrant with tenant filter + +### RAG Query + +```python +POST /api/v1/eh/rag-query +Content-Type: application/json + +{ + "query_text": "Wie sollte die Einleitung strukturiert sein?", + "passphrase": "user-secret-password", + "subject": "deutsch", # Optional filter + "limit": 5 # Max results +} +``` + +Response: +```json +{ + "context": "Die Einleitung sollte...", + "sources": [ + { + "text": "Die Einleitung sollte...", + "eh_id": "uuid", + "eh_title": "Deutsch LK 2025", + "chunk_index": 2, + "score": 0.89 + } + ], + "query": "Wie sollte die Einleitung strukturiert sein?" +} +``` + +## Key Sharing Implementation + +### Invitation Flow (Recommended) + +The invitation flow provides a two-phase sharing process: Invite -> Accept + +```typescript +import { ehApi } from '../services/api' + +// 1. First examiner sends invitation to second examiner +const invitation = await ehApi.inviteToEH(ehId, { + invitee_email: 'zweitkorrektor@school.de', + role: 'second_examiner', + klausur_id: 'klausur-uuid', // Optional: link to specific Klausur + message: 'Bitte fuer Zweitkorrektur nutzen', + expires_in_days: 14 // Default: 14 days +}) +// Returns: { invitation_id, eh_id, invitee_email, role, expires_at, eh_title } + +// 2. Second examiner sees pending invitation +const pending = await ehApi.getPendingInvitations() +// [{ invitation: {...}, eh: { id, title, subject, niveau, year } }] + +// 3. Second examiner accepts invitation +const accepted = await ehApi.acceptInvitation( + invitationId, + encryptedPassphrase // Passphrase encrypted for recipient +) +// Returns: { status: 'accepted', share_id, eh_id, role, klausur_id } +``` + +### Invitation Management + +```typescript +// Get invitations sent by current user +const sent = await ehApi.getSentInvitations() + +// Decline an invitation (as invitee) +await ehApi.declineInvitation(invitationId) + +// Revoke a pending invitation (as inviter) +await ehApi.revokeInvitation(invitationId) + +// Get complete access chain for an EH +const chain = await ehApi.getAccessChain(ehId) +// Returns: { eh_id, eh_title, owner, active_shares, pending_invitations, revoked_shares } +``` + +### Direct Sharing (Legacy) + +For immediate sharing without invitation: + +```typescript +// First examiner shares directly with second examiner +await ehApi.shareEH(ehId, { + user_id: 'second-examiner-uuid', + role: 'second_examiner', + encrypted_passphrase: encryptedPassphrase, // Encrypted for recipient + passphrase_hint: 'Das uebliche Passwort', + klausur_id: 'klausur-uuid' // Optional +}) +``` + +### Accessing Shared EH + +```typescript +// Second examiner gets shared EH +const shared = await ehApi.getSharedWithMe() +// [{ eh: {...}, share: {...} }] + +// Query using provided passphrase +const result = await ehApi.ragQuery({ + query_text: 'search query', + passphrase: decryptedPassphrase, + subject: 'deutsch' +}) +``` + +### Revoking Access + +```typescript +// List all shares for an EH +const shares = await ehApi.listShares(ehId) + +// Revoke a share +await ehApi.revokeShare(ehId, shareId) +``` + +## Klausur Integration + +### Automatic EH Prompt + +The `KorrekturPage` shows an EH upload prompt after the first student work is uploaded: + +```typescript +// In KorrekturPage.tsx +useEffect(() => { + if ( + currentKlausur?.students.length === 1 && + linkedEHs.length === 0 && + !ehPromptDismissed + ) { + setShowEHPrompt(true) + } +}, [currentKlausur?.students.length]) +``` + +### Linking EH to Klausur + +```typescript +// After EH upload, auto-link to Klausur +await ehApi.linkToKlausur(ehId, klausurId) + +// Get linked EH for a Klausur +const linked = await klausurEHApi.getLinkedEH(klausurId) +``` + +## Frontend Components + +### EHUploadWizard Props + +```typescript +interface EHUploadWizardProps { + onClose: () => void + onComplete?: (ehId: string) => void + defaultSubject?: string // Pre-fill subject + defaultYear?: number // Pre-fill year + klausurId?: string // Auto-link after upload +} + +// Usage + setShowWizard(false)} + onComplete={(ehId) => console.log('Uploaded:', ehId)} + defaultSubject={klausur.subject} + defaultYear={klausur.year} + klausurId={klausur.id} +/> +``` + +### Wizard Steps + +1. **file** - PDF file selection with drag & drop +2. **metadata** - Form for title, subject, niveau, year +3. **rights** - Rights confirmation checkbox +4. **encryption** - Passphrase input with strength meter +5. **summary** - Review and confirm upload + +## Qdrant Operations + +### Collection Schema + +```python +# Collection: bp_eh +{ + "vectors": { + "size": 1536, # OpenAI text-embedding-3-small + "distance": "Cosine" + } +} + +# Point payload +{ + "tenant_id": "school-uuid", + "eh_id": "eh-uuid", + "chunk_index": 0, + "encrypted_content": "base64...", + "training_allowed": false # ALWAYS false +} +``` + +### Tenant-Isolated Search + +```python +from qdrant_service import search_eh + +results = await search_eh( + query_embedding=embedding, + tenant_id="school-uuid", + subject="deutsch", + limit=5 +) +``` + +## Testing + +### Unit Tests + +```bash +cd klausur-service/backend +pytest tests/test_byoeh.py -v +``` + +### Test Structure + +```python +# tests/test_byoeh.py +class TestBYOEH: + def test_upload_eh(self, client, auth_headers): + """Test EH upload with encryption""" + pass + + def test_index_eh(self, client, auth_headers, uploaded_eh): + """Test EH indexing for RAG""" + pass + + def test_rag_query(self, client, auth_headers, indexed_eh): + """Test RAG query returns relevant chunks""" + pass + + def test_share_eh(self, client, auth_headers, uploaded_eh): + """Test sharing EH with another user""" + pass +``` + +### Frontend Tests + +```typescript +// EHUploadWizard.test.tsx +describe('EHUploadWizard', () => { + it('completes all steps successfully', async () => { + // ... + }) + + it('validates passphrase strength', async () => { + // ... + }) + + it('auto-links to klausur when klausurId provided', async () => { + // ... + }) +}) +``` + +## Error Handling + +### Common Errors + +| Error | Cause | Solution | +|-------|-------|----------| +| `Passphrase verification failed` | Wrong passphrase | Ask user to re-enter | +| `EH not found` | Invalid ID or deleted | Check ID, reload list | +| `Access denied` | User not owner/shared | Check permissions | +| `Qdrant connection failed` | Service unavailable | Check Qdrant container | + +### Error Response Format + +```json +{ + "detail": "Passphrase verification failed" +} +``` + +## Security Considerations + +### Do's + +- Store key hash, never the key itself +- Always filter by tenant_id +- Log all access in audit trail +- Use HTTPS in production + +### Don'ts + +- Never log passphrase or decrypted content +- Never store passphrase in localStorage +- Never send passphrase as URL parameter +- Never return decrypted content without auth + +## Performance Tips + +### Chunking Configuration + +```python +CHUNK_SIZE = 1000 # Characters per chunk +CHUNK_OVERLAP = 200 # Overlap for context continuity +``` + +### Embedding Batching + +```python +# Generate embeddings in batches of 20 +EMBEDDING_BATCH_SIZE = 20 +``` + +### Qdrant Optimization + +```python +# Use HNSW index for fast approximate search +# Collection is automatically optimized on creation +``` + +## Debugging + +### Enable Debug Logging + +```python +import logging +logging.getLogger('byoeh').setLevel(logging.DEBUG) +``` + +### Check Qdrant Status + +```bash +curl http://localhost:6333/collections/bp_eh +``` + +### Verify Encryption + +```typescript +import { isEncryptionSupported } from '../services/encryption' + +if (!isEncryptionSupported()) { + console.error('Web Crypto API not available') +} +``` + +## Migration Notes + +### From v1.0 to v1.1 + +1. Added key sharing system +2. Added Klausur linking +3. EH prompt after student upload + +No database migrations required - all data structures are additive. diff --git a/klausur-service/docs/DSGVO-Audit-OCR-Labeling.md b/klausur-service/docs/DSGVO-Audit-OCR-Labeling.md new file mode 100644 index 0000000..fd66025 --- /dev/null +++ b/klausur-service/docs/DSGVO-Audit-OCR-Labeling.md @@ -0,0 +1,788 @@ +# DSGVO-Audit-Dokumentation: OCR-Labeling-System für Handschrifterkennung + +**Dokumentversion:** 1.0.0 +**Datum:** 21. Januar 2026 +**Klassifizierung:** Vertraulich - Nur für internen Gebrauch und Auditoren +**Nächste Überprüfung:** 21. Januar 2027 + +--- + +## 1. Management Summary + +### 1.1 Systemübersicht + +Das OCR-Labeling-System ist eine **vollständig lokal betriebene** Lösung zur Digitalisierung und Auswertung handschriftlicher Schülerarbeiten (Klausuren, Aufsätze). Das System nutzt: + +- **llama3.2-vision:11b** - Open-Source Vision-Language-Modell für OCR (lokal via Ollama) +- **TrOCR** - Microsoft Transformer OCR für Handschrifterkennung (lokal) +- **qwen2.5:14b** - Open-Source LLM für Korrekturassistenz (lokal via Ollama) + +### 1.2 Datenschutz-Garantien + +| Merkmal | Umsetzung | +|---------|-----------| +| **Verarbeitungsort** | 100% lokal auf schuleigenem Mac Mini | +| **Cloud-Dienste** | Keine - vollständig offline-fähig | +| **Datenübertragung** | Keine Übertragung an externe Server | +| **KI-Modelle** | Open-Source, lokal ausgeführt, kein Telemetrie | +| **Speicherung** | Lokale PostgreSQL-Datenbank, MinIO Object Storage | + +### 1.3 Compliance-Status + +Das System erfüllt die Anforderungen der: +- DSGVO (Verordnung (EU) 2016/679) +- BDSG (Bundesdatenschutzgesetz) +- Niedersächsisches Schulgesetz (NSchG) §31 +- EU AI Act (Verordnung (EU) 2024/1689) + +--- + +## 2. Verzeichnis der Verarbeitungstätigkeiten (Art. 30 DSGVO) + +### 2.1 Verantwortlicher + +| Feld | Inhalt | +|------|--------| +| **Verantwortlicher** | [Schulname], [Schuladresse] | +| **Vertreter** | Schulleitung: [Name] | +| **Kontakt** | [E-Mail], [Telefon] | + +### 2.2 Datenschutzbeauftragter + +| Feld | Inhalt | +|------|--------| +| **Name** | [Name DSB] | +| **Organisation** | [Behördlicher/Externer DSB] | +| **Kontakt** | [E-Mail], [Telefon] | + +### 2.3 Verarbeitungstätigkeiten + +#### 2.3.1 OCR-Verarbeitung von Klausuren + +| Attribut | Beschreibung | +|----------|--------------| +| **Zweck** | Digitalisierung handschriftlicher Prüfungsantworten mittels KI-gestützter Texterkennung zur Unterstützung der Lehrkräfte bei der Korrektur | +| **Rechtsgrundlage** | Art. 6 Abs. 1 lit. e DSGVO i.V.m. §31 NSchG (öffentliche Aufgabe der Leistungsbewertung) | +| **Betroffene Personen** | Schülerinnen und Schüler (Prüfungsarbeiten) | +| **Datenkategorien** | Handschriftproben, Prüfungsantworten, Schülerkennung (optional) | +| **Empfänger** | Ausschließlich berechtigte Lehrkräfte der Schule | +| **Drittlandübermittlung** | Keine | +| **Löschfrist** | Gem. Aufbewahrungspflichten für Prüfungsunterlagen (i.d.R. 2-10 Jahre je nach Bundesland) | + +#### 2.3.2 Labeling für Modell-Training + +| Attribut | Beschreibung | +|----------|--------------| +| **Zweck** | Erstellung von Trainingsdaten für lokales Fine-Tuning der OCR-Modelle zur Verbesserung der Handschrifterkennung | +| **Rechtsgrundlage** | Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse) oder Art. 6 Abs. 1 lit. a DSGVO (Einwilligung) | +| **Betroffene Personen** | Schülerinnen und Schüler (anonymisierte Handschriftproben) | +| **Datenkategorien** | Anonymisierte/pseudonymisierte Handschriftbilder, korrigierter Text | +| **Empfänger** | Lokales ML-System, keine externen Empfänger | +| **Drittlandübermittlung** | Keine | +| **Löschfrist** | Trainingsdaten: Nach Abschluss des Trainings oder auf Widerruf | + +### 2.4 Verweis auf TOM + +Siehe Abschnitt 8: Technisch-Organisatorische Maßnahmen + +--- + +## 3. Rechtsgrundlagen (Art. 6 DSGVO) + +### 3.1 Primäre Rechtsgrundlagen + +| Verarbeitungsschritt | Rechtsgrundlage | Begründung | +|---------------------|-----------------|------------| +| Scan von Klausuren | Art. 6 Abs. 1 lit. e DSGVO | Öffentliche Aufgabe der schulischen Leistungsbewertung | +| OCR-Verarbeitung | Art. 6 Abs. 1 lit. e DSGVO | Teil der Bewertungsaufgabe, Effizienzsteigerung | +| Lehrerkorrektur | Art. 6 Abs. 1 lit. e DSGVO | Kernaufgabe der Leistungsbewertung | +| Export für Training | Art. 6 Abs. 1 lit. f DSGVO | Berechtigtes Interesse an Modellverbesserung | + +### 3.2 Landesrechtliche Grundlagen + +**Niedersachsen:** +- §31 NSchG: Erhebung, Verarbeitung und Nutzung personenbezogener Daten +- Ergänzende Bestimmungen zur VO-DV I + +**Interesse-Abwägung für Training (Art. 6 Abs. 1 lit. f):** + +| Aspekt | Bewertung | +|--------|-----------| +| **Interesse des Verantwortlichen** | Verbesserung der OCR-Qualität für effizientere Klausurkorrektur | +| **Erwartung der Betroffenen** | Schüler erwarten, dass Prüfungsarbeiten für schulische Zwecke verarbeitet werden | +| **Auswirkung auf Betroffene** | Minimal - Daten werden pseudonymisiert, rein lokale Verarbeitung | +| **Schutzmaßnahmen** | Pseudonymisierung, keine Weitergabe, lokale Verarbeitung | +| **Ergebnis** | Berechtigtes Interesse überwiegt | + +### 3.3 Besondere Kategorien (Art. 9 DSGVO) + +**Prüfung auf besondere Kategorien:** + +Handschriftproben könnten theoretisch Rückschlüsse auf Gesundheitszustände ermöglichen (z.B. Tremor). Dies wird wie folgt adressiert: + +- OCR-Modelle analysieren ausschließlich Textinhalt, nicht Handschriftcharakteristiken +- Keine Speicherung von Handschriftanalysen +- Bei Training werden nur Textinhalte verwendet, keine biometrischen Merkmale + +**Ergebnis:** Art. 9 ist nicht anwendbar, da keine Verarbeitung besonderer Kategorien erfolgt. + +--- + +## 4. Datenschutz-Folgenabschätzung (Art. 35 DSGVO) + +### 4.1 Schwellwertanalyse - Erforderlichkeit der DSFA + +| Kriterium | Erfüllt | Begründung | +|-----------|---------|------------| +| Neue Technologien (KI/ML) | ✓ | Vision-LLM für OCR | +| Umfangreiche Verarbeitung | ✗ | Begrenzt auf einzelne Schule | +| Daten von Minderjährigen | ✓ | Schülerarbeiten | +| Systematische Überwachung | ✗ | Keine Überwachung | +| Scoring/Profiling | ✗ | Keine automatische Bewertung | + +**Ergebnis:** DSFA erforderlich aufgrund KI-Einsatz und Verarbeitung von Daten Minderjähriger. + +### 4.2 Systematische Beschreibung der Verarbeitung + +#### Datenfluss-Diagramm + +``` +┌─────────────────────────────────────────────────────────────────────────────────┐ +│ OCR-LABELING DATENFLUSS │ +├─────────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ 1. SCAN │───►│ 2. UPLOAD │───►│ 3. OCR │───►│ 4. LABELING │ │ +│ │ (Lehrkraft) │ │ (MinIO) │ │ (Ollama) │ │ (Lehrkraft) │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ │ │ │ │ +│ ▼ ▼ ▼ ▼ │ +│ Papierdokument Verschlüsselte Lokale LLM- Bestätigung/ │ +│ → digitaler Scan Bildspeicherung Verarbeitung Korrektur │ +│ │ +│ ┌──────────────────────────────────────────────────────────────────────────┐ │ +│ │ SPEICHERUNG (PostgreSQL) │ │ +│ │ • Session-ID (UUID) • Status (pending/confirmed/corrected) │ │ +│ │ • Bild-Hash (SHA256) • Ground Truth (korrigierter Text) │ │ +│ │ • OCR-Text • Zeitstempel │ │ +│ └──────────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────┐ │ +│ │ 5. EXPORT │ Pseudonymisierte Trainingsdaten (JSONL) │ +│ │ (Optional) │ → Lokal gespeichert für Fine-Tuning │ +│ └──────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────────────┘ +``` + +#### Verarbeitungsschritte im Detail + +| Schritt | Beschreibung | Datenschutzmaßnahme | +|---------|--------------|---------------------| +| 1. Scan | Lehrkraft scannt Papierklausur | Physischer Zugang nur für Lehrkräfte | +| 2. Upload | Bild wird in lokales MinIO hochgeladen | SHA256-Deduplizierung, verschlüsselte Speicherung | +| 3. OCR | llama3.2-vision erkennt Text | 100% lokal, kein Internet | +| 4. Labeling | Lehrkraft prüft/korrigiert OCR-Ergebnis | Protokollierung aller Aktionen | +| 5. Export | Optional: Pseudonymisierte Trainingsdaten | Entfernung direkter Identifikatoren | + +### 4.3 Notwendigkeit und Verhältnismäßigkeit + +#### Prüfung der Erforderlichkeit + +| Prinzip | Umsetzung | +|---------|-----------| +| **Zweckbindung** | Ausschließlich für schulische Leistungsbewertung und Modelltraining | +| **Datenminimierung** | Nur Bildausschnitte mit Text, keine vollständigen Klausuren nötig | +| **Speicherbegrenzung** | Automatische Löschung nach definierter Aufbewahrungsfrist | + +#### Alternativenprüfung + +| Alternative | Bewertung | +|-------------|-----------| +| Manuelle Transkription | Zeitaufwändig, fehleranfällig, nicht praktikabel | +| Cloud-OCR (Google, Azure) | Datenschutzrisiken durch Drittlandübermittlung | +| Kommerzielles lokales OCR | Hohe Kosten, Lizenzabhängigkeit | +| **Gewählte Lösung** | Open-Source lokal - optimale Balance | + +### 4.4 Risikobewertung + +#### Identifizierte Risiken + +| Risiko | Eintrittswahrscheinlichkeit | Schwere | Risikostufe | Mitigationsmaßnahme | +|--------|---------------------------|---------|-------------|---------------------| +| R1: Unbefugter Zugriff auf Schülerdaten | Gering | Hoch | Mittel | Rollenbasierte Zugriffskontrolle, MFA | +| R2: Datenleck durch Systemkompromittierung | Gering | Hoch | Mittel | Verschlüsselung, Netzwerkisolation | +| R3: Fehlerhaftes OCR beeinflusst Bewertung | Mittel | Mittel | Mittel | Pflicht-Review durch Lehrkraft | +| R4: Re-Identifizierung aus Handschrift | Gering | Mittel | Gering | Pseudonymisierung, keine Handschriftanalyse | +| R5: Bias im OCR-Modell | Mittel | Mittel | Mittel | Regelmäßige Qualitätsprüfung | + +#### Risikomatrix + +``` + SCHWERE + Gering Mittel Hoch + ┌───────┬───────┬───────┐ +Hoch │ │ │ │ + ├───────┼───────┼───────┤ +Mittel │ │ R3,R5 │ │ WAHRSCHEINLICHKEIT + ├───────┼───────┼───────┤ +Gering │ │ R4 │ R1,R2 │ + └───────┴───────┴───────┘ +``` + +### 4.5 Maßnahmen zur Risikominderung + +| Risiko | Maßnahme | Umsetzungsstatus | +|--------|----------|------------------| +| R1 | RBAC, MFA, Audit-Logging | ✓ Implementiert | +| R2 | FileVault-Verschlüsselung, lokales Netz | ✓ Implementiert | +| R3 | Pflicht-Bestätigung durch Lehrkraft | ✓ Implementiert | +| R4 | Pseudonymisierung bei Export | ✓ Implementiert | +| R5 | Diverse Trainingssamples, manuelle Reviews | ○ In Entwicklung | + +--- + +## 5. Informationspflichten (Art. 13/14 DSGVO) + +### 5.1 Informationen für Betroffene + +Folgende Informationen werden Schülern und Erziehungsberechtigten bereitgestellt: + +#### 5.1.1 Pflichtangaben nach Art. 13 DSGVO + +| Information | Bereitstellung | +|-------------|----------------| +| Identität des Verantwortlichen | Schulwebsite, Datenschutzerklärung | +| Kontakt DSB | Schulwebsite, Aushang | +| Verarbeitungszwecke | Datenschutzinformation bei Einschulung | +| Rechtsgrundlage | Datenschutzinformation | +| Empfänger/Kategorien | Datenschutzinformation | +| Speicherdauer | Datenschutzinformation | +| Betroffenenrechte | Datenschutzinformation, auf Anfrage | +| Beschwerderecht | Datenschutzinformation | + +#### 5.1.2 KI-spezifische Transparenz + +Zusätzlich zu den Standard-Informationspflichten: + +| Information | Inhalt | +|-------------|--------| +| Art der KI | Vision-LLM für Texterkennung, kein automatisches Bewerten | +| Menschliche Aufsicht | Jedes OCR-Ergebnis wird von Lehrkraft geprüft | +| Keine automatische Entscheidung | System macht Vorschläge, Lehrkraft entscheidet | +| Widerspruchsrecht | Opt-out von Training-Verwendung möglich | + +### 5.2 Informationsbereitstellung + +| Kanal | Zeitpunkt | Zielgruppe | +|-------|-----------|------------| +| Einschulungsunterlagen | Bei Schulanmeldung | Erziehungsberechtigte | +| Datenschutzerklärung Website | Dauerhaft | Alle | +| Klausur-Deckblatt (optional) | Bei Prüfung | Schüler | +| Elternabend | Jährlich | Erziehungsberechtigte | + +--- + +## 6. Automatisierte Entscheidungsfindung (Art. 22 DSGVO) + +### 6.1 Anwendbarkeitsprüfung + +**Prüfung der Tatbestandsmerkmale:** + +| Merkmal | Erfüllt | Begründung | +|---------|---------|------------| +| Automatisierte Verarbeitung | Ja | KI-gestützte Texterkennung | +| Entscheidung | Nein | OCR liefert nur Vorschlag | +| Rechtliche Wirkung/erhebliche Beeinträchtigung | Nein | Lehrkraft trifft finale Bewertungsentscheidung | + +**Ergebnis:** Art. 22 DSGVO ist **nicht anwendbar**, da keine automatisierte Entscheidung mit rechtlicher Wirkung erfolgt. + +### 6.2 Teacher-in-the-Loop Garantie + +Das System implementiert obligatorische menschliche Aufsicht: + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ OCR-System │────►│ Lehrkraft │────►│ Bewertung │ +│ (Vorschlag) │ │ (Prüfung) │ │ (Final) │ +└──────────────┘ └──────────────┘ └──────────────┘ + │ │ │ + │ ▼ │ + │ ┌──────────────┐ │ + └───────────►│ Korrektur │◄───────────┘ + │ (Optional) │ + └──────────────┘ +``` + +**Workflow-Garantien:** +1. Kein OCR-Ergebnis wird automatisch als korrekt übernommen +2. Lehrkraft muss explizit bestätigen ODER korrigieren +3. Bewertungsentscheidung liegt ausschließlich bei der Lehrkraft +4. System gibt keine Notenvorschläge + +### 6.3 Dokumentation der menschlichen Aufsicht + +| Metrik | Erhebung | +|--------|----------| +| Bestätigungsrate | % der OCR-Ergebnisse als korrekt bestätigt | +| Korrekturrate | % der OCR-Ergebnisse mit Korrekturen | +| Durchschnittliche Prüfzeit | Zeit pro Item in Sekunden | +| Lehrkraft-ID | Pseudonymisiert für Audit-Trail | + +--- + +## 7. Privacy by Design und Default (Art. 25 DSGVO) + +### 7.1 Design-Prinzipien + +| Prinzip | Implementierung | +|---------|-----------------| +| **Proaktive Maßnahmen** | Datenschutz von Anfang an im System-Design berücksichtigt | +| **Standard-Datenschutz** | Minimale Datenerhebung als Default | +| **Eingebetteter Datenschutz** | Technische Maßnahmen nicht umgehbar | +| **Volle Funktionalität** | Kein Trade-off Datenschutz vs. Funktionalität | +| **End-to-End Sicherheit** | Verschlüsselung vom Upload bis zur Löschung | +| **Sichtbarkeit/Transparenz** | Alle Verarbeitungen protokolliert und nachvollziehbar | +| **Nutzerzentrierung** | Betroffenenrechte einfach ausübbar | + +### 7.2 Umsetzung Datenminimierung + +| Maßnahme | Beschreibung | +|----------|--------------| +| Bildausschnitte | Nur relevante Textbereiche, nicht vollständige Seiten | +| Metadaten-Beschränkung | Keine Speicherung von Geräteinformationen des Scanners | +| Pseudonymisierung | Schüler-IDs durch UUIDs ersetzt bei Export | +| Automatische Löschung | Konfigurierbare Aufbewahrungsfristen | + +### 7.3 Default-Einstellungen + +| Einstellung | Default | Begründung | +|-------------|---------|------------| +| OCR-Ergebnis automatisch übernehmen | Nein | Menschliche Prüfung erforderlich | +| Training-Export aktiviert | Nein | Opt-in erforderlich | +| Metadaten-Speicherung | Minimal | Nur notwendige Daten | +| Zugriffsprotokollierung | Ja | Transparenz und Nachvollziehbarkeit | + +### 7.4 Vendor-Auswahl + +Die verwendeten KI-Modelle wurden nach Datenschutzkriterien ausgewählt: + +| Modell | Anbieter | Lizenz | Lokale Ausführung | Telemetrie | +|--------|----------|--------|-------------------|------------| +| llama3.2-vision:11b | Meta | Llama 3.2 Community | ✓ | Keine | +| qwen2.5:14b | Alibaba | Apache 2.0 | ✓ | Keine | +| TrOCR | Microsoft | MIT | ✓ | Keine | + +--- + +## 8. Technisch-Organisatorische Maßnahmen (Art. 32 DSGVO) + +### 8.1 Vertraulichkeit + +#### 8.1.1 Zutrittskontrolle + +| Maßnahme | Umsetzung | +|----------|-----------| +| Physische Sicherung | Server in abgeschlossenem Raum | +| Zugangsprotokoll | Elektronisches Schloss mit Protokollierung | +| Berechtigte Personen | IT-Administrator, Schulleitung | + +#### 8.1.2 Zugangskontrolle + +| Maßnahme | Umsetzung | +|----------|-----------| +| Authentifizierung | Benutzername + Passwort | +| Passwort-Policy | Min. 12 Zeichen, Komplexitätsanforderungen | +| Session-Timeout | 30 Minuten Inaktivität | +| Fehlversuche | Account-Sperrung nach 5 Fehlversuchen | + +#### 8.1.3 Zugriffskontrolle (RBAC) + +| Rolle | Berechtigungen | +|-------|----------------| +| **Admin** | Vollzugriff, Benutzerverwaltung | +| **Lehrkraft** | Eigene Sessions, Labeling, Export | +| **Viewer** | Nur Lesezugriff auf Statistiken | + +#### 8.1.4 Pseudonymisierung + +| Datenfeld | Maßnahme | +|-----------|----------| +| Schüler-ID | UUID statt Klarname bei Export | +| Lehrkraft-ID | Pseudonymisiert in Logs | +| Session-Name | Keine Schülernamen erlaubt | + +#### 8.1.5 Verschlüsselung + +| Bereich | Maßnahme | +|---------|----------| +| Festplatte | FileVault 2 (AES-256) | +| Datenbank | Transparent Data Encryption | +| MinIO Storage | Server-Side Encryption (SSE) | +| Netzwerk | TLS 1.3 für lokale Verbindungen | + +### 8.2 Integrität + +#### 8.2.1 Weitergabekontrolle + +| Maßnahme | Umsetzung | +|----------|-----------| +| Netzwerkisolation | Lokales Netz, keine Internet-Verbindung erforderlich | +| USB-Ports | Administrativ deaktiviert | +| Firewall | Eingehende Verbindungen blockiert | + +#### 8.2.2 Eingabekontrolle + +| Maßnahme | Umsetzung | +|----------|-----------| +| Audit-Log | Alle Aktionen mit Timestamp und User-ID | +| Unveränderlichkeit | Append-only Logging | +| Log-Retention | 1 Jahr | + +**Protokollierte Aktionen:** +- Session erstellen/löschen +- Bild hochladen +- OCR ausführen +- Label bestätigen/korrigieren/überspringen +- Export durchführen +- Login/Logout + +### 8.3 Verfügbarkeit + +| Maßnahme | Umsetzung | +|----------|-----------| +| Backup | Tägliches inkrementelles Backup | +| USV | Unterbrechungsfreie Stromversorgung | +| RAID | RAID 1 Spiegelung für Datenträger | +| Recovery-Test | Halbjährlich | + +### 8.4 Belastbarkeit + +| Maßnahme | Umsetzung | +|----------|-----------| +| Ressourcen-Monitoring | Prometheus + Grafana | +| Alerts | E-Mail bei kritischen Schwellwerten | +| Kapazitätsplanung | Jährliche Review | + +--- + +## 9. BSI-Anforderungen und Sicherheitsrichtlinien + +### 9.1 Angewandte BSI-Publikationen + +| Publikation | Relevanz | Umsetzung | +|-------------|----------|-----------| +| IT-Grundschutz-Kompendium | Basis-Absicherung | TOM nach Abschnitt 8 | +| BSI TR-03116-4 | Kryptographische Verfahren | AES-256, TLS 1.3 | +| Kriterienkatalog KI (Juni 2025) | KI-Sicherheit | Siehe 9.2 | +| QUAIDAL (Juli 2025) | Trainingsdaten-Qualität | Siehe 9.3 | + +### 9.2 KI-Sicherheitsanforderungen (BSI Kriterienkatalog) + +| Kriterium | Anforderung | Umsetzung | +|-----------|-------------|-----------| +| Modellintegrität | Schutz vor Manipulation | Lokale Modelle, keine Updates ohne Review | +| Eingabevalidierung | Schutz vor Adversarial Attacks | Bildformat-Prüfung, Größenlimits | +| Ausgabevalidierung | Plausibilitätsprüfung | Konfidenz-Schwellwerte | +| Protokollierung | Nachvollziehbarkeit | Vollständiges Audit-Log | +| Incident Response | Reaktion auf Fehlfunktionen | Eskalationsprozess definiert | + +### 9.3 Trainingsdaten-Qualität (QUAIDAL) + +| Qualitätskriterium | Umsetzung | +|--------------------|-----------| +| **Herkunftsdokumentation** | Alle Trainingsdaten aus eigenem Labeling-Prozess | +| **Repräsentativität** | Diverse Handschriften aus verschiedenen Klassenstufen | +| **Qualitätskontrolle** | Lehrkraft-Verifikation jedes Samples | +| **Bias-Prüfung** | Regelmäßige Stichproben-Analyse | +| **Versionierung** | Git-basierte Versionskontrolle für Datasets | + +--- + +## 10. EU AI Act Compliance (KI-Verordnung) + +### 10.1 Risikoklassifizierung + +**Prüfung nach Anhang III der KI-Verordnung:** + +| Hochrisiko-Kategorie | Anwendbar | Begründung | +|---------------------|-----------|------------| +| 3(a) Biometrische Identifizierung | Nein | Keine biometrische Verarbeitung | +| 3(b) Kritische Infrastruktur | Nein | Keine kritische Infrastruktur | +| 3(c) Allgemeine/berufliche Bildung | **Prüfen** | Bildungsbereich | +| 3(d) Beschäftigung | Nein | Nicht anwendbar | + +**Detailprüfung Bildung (Anhang III, Nr. 3c):** + +Das System wird **nicht** für folgende Hochrisiko-Anwendungen genutzt: +- ✗ Entscheidung über Zugang zu Bildungseinrichtungen +- ✗ Zuweisung zu Bildungseinrichtungen oder -programmen +- ✗ Bewertung von Lernergebnissen (nur Unterstützung, keine automatische Bewertung) +- ✗ Überwachung von Prüfungen + +**Ergebnis:** Kein Hochrisiko-KI-System nach aktuellem Stand. + +### 10.2 Allgemeine Anforderungen + +Auch ohne Hochrisiko-Klassifizierung werden folgende Transparenzanforderungen erfüllt: + +| Anforderung | Umsetzung | +|-------------|-----------| +| KI-Literacy (Art. 4) | Schulung der Lehrkräfte | +| Transparenz gegenüber Nutzern | Information über KI-Einsatz | +| Menschliche Aufsicht | Teacher-in-the-Loop | + +### 10.3 Verbotsprüfung (Art. 5) + +| Verbotene Praxis | Geprüft | Ergebnis | +|------------------|---------|----------| +| Unterschwellige Manipulation | ✓ | Nicht vorhanden | +| Ausnutzung von Schwächen | ✓ | Nicht vorhanden | +| Social Scoring | ✓ | Nicht vorhanden | +| Echtzeit-Biometrie | ✓ | Nicht vorhanden | +| Emotionserkennung in Bildung | ✓ | **Nicht vorhanden** | + +--- + +## 11. ML/AI Training Dokumentation + +### 11.1 Trainingsdaten-Quellen + +| Datensatz | Quelle | Rechtsgrundlage | Volumen | +|-----------|--------|-----------------|---------| +| Klausur-Scans | Schulinterne Prüfungen | Art. 6(1)(e) + Einwilligung | Variabel | +| Lehrer-Korrekturen | Labeling-System | Art. 6(1)(e) | Variabel | + +### 11.2 Datenqualitätsmaßnahmen + +| Maßnahme | Beschreibung | +|----------|--------------| +| Deduplizierung | SHA256-Hash zur Vermeidung von Duplikaten | +| Qualitätskontrolle | Jedes Sample von Lehrkraft geprüft | +| Repräsentativität | Samples aus verschiedenen Fächern/Klassenstufen | +| Dokumentation | Metadaten zu jedem Sample | + +### 11.3 Labeling-Prozess + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ LABELING WORKFLOW │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. Bild-Upload 2. OCR-Vorschlag 3. Review │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Scan │─────────►│ LLM-OCR │─────────►│ Lehrkraft │ │ +│ │ Upload │ │ (lokal) │ │ prüft │ │ +│ └─────────────┘ └─────────────┘ └──────┬──────┘ │ +│ │ │ +│ ┌──────────────────────┴─────┐ │ +│ ▼ ▼ │ +│ ┌─────────────┐ ┌─────────┐ │ +│ │ Bestätigt │ │Korrigiert│ │ +│ │ (korrekt) │ │(manuell) │ │ +│ └─────────────┘ └─────────┘ │ +│ │ │ │ +│ └──────────┬─────────────────┘ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ Ground Truth │ │ +│ │ (verifiziert) │ │ +│ └─────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 11.4 Export-Prozeduren + +| Schritt | Beschreibung | Datenschutzmaßnahme | +|---------|--------------|---------------------| +| 1. Auswahl | Sessions/Items für Export wählen | Nur bestätigte/korrigierte Items | +| 2. Pseudonymisierung | Entfernung direkter Identifikatoren | UUID statt Schüler-ID | +| 3. Format-Konvertierung | TrOCR/Llama/Generic Format | Nur notwendige Felder | +| 4. Speicherung | Lokal in /app/ocr-exports/ | Verschlüsselt, zugriffsbeschränkt | + +### 11.5 Modell-Provenienz + +| Modell | Basis | Fine-Tuning Daten | Training-Parameter | +|--------|-------|-------------------|-------------------| +| llama3.2-vision:11b | Meta Llama 3.2 | Lokale gelabelte Daten | Dokumentiert pro Training | +| TrOCR | Microsoft | Lokale gelabelte Daten | Dokumentiert pro Training | + +--- + +## 12. Betroffenenrechte + +### 12.1 Implementierte Rechte + +| Recht | Art. DSGVO | Umsetzung | +|-------|-----------|-----------| +| **Auskunft** | 15 | Schriftliche Anfrage an DSB | +| **Berichtigung** | 16 | Korrektur falscher OCR-Ergebnisse | +| **Löschung** | 17 | Nach Aufbewahrungsfrist oder auf Antrag | +| **Einschränkung** | 18 | Sperrung der Verarbeitung auf Antrag | +| **Datenportabilität** | 20 | Export eigener Daten in JSON | +| **Widerspruch** | 21 | Opt-out von Training-Verwendung | + +### 12.2 Sonderrechte bei KI-Training + +| Recht | Umsetzung | +|-------|-----------| +| Widerspruch gegen Training | Daten werden nicht für Fine-Tuning verwendet | +| Löschung aus Trainingsset | "Machine Unlearning" durch Re-Training ohne betroffene Daten | + +### 12.3 Anfrage-Prozess + +| Schritt | Frist | Verantwortlich | +|---------|-------|----------------| +| Eingang der Anfrage | - | Sekretariat | +| Identitätsprüfung | 3 Werktage | DSB | +| Bearbeitung | 1 Monat | IT + DSB | +| Antwort | 1 Monat | DSB | + +--- + +## 13. Schulung und Awareness + +### 13.1 Schulungskonzept + +| Schulung | Zielgruppe | Frequenz | Dokumentation | +|----------|------------|----------|---------------| +| DSGVO-Grundlagen | Alle Lehrkräfte | Jährlich | Teilnehmerliste | +| OCR-System-Nutzung | Nutzende Lehrkräfte | Bei Einführung | Zertifikat | +| KI-Kompetenz (AI Act Art. 4) | Alle Nutzenden | Jährlich | Nachweis | + +### 13.2 Schulungsinhalte + +**DSGVO-Grundlagen:** +- Prinzipien der Datenverarbeitung +- Betroffenenrechte +- Meldepflichten bei Datenpannen + +**OCR-System-Nutzung:** +- Systemfunktionen und Bedienung +- Datenschutzrelevante Einstellungen +- Dos and Don'ts + +**KI-Kompetenz:** +- Funktionsweise von KI-Systemen +- Grenzen und Risiken +- Verantwortungsvoller Umgang + +--- + +## 14. Review und Audit + +### 14.1 Regelmäßige Überprüfungen + +| Prüfung | Frequenz | Verantwortlich | +|---------|----------|----------------| +| DSFA-Review | Jährlich | DSB | +| TOM-Wirksamkeit | Jährlich | IT-Administrator | +| Zugriffsrechte | Halbjährlich | IT-Administrator | +| Backup-Test | Halbjährlich | IT-Administrator | +| Modell-Bias-Prüfung | Jährlich | IT + Lehrkräfte | + +### 14.2 Audit-Trail + +| Protokollierte Daten | Aufbewahrung | Format | +|---------------------|--------------|--------| +| Benutzeraktionen | 1 Jahr | PostgreSQL | +| Systemereignisse | 1 Jahr | Syslog | +| Sicherheitsvorfälle | 3 Jahre | Incident-Dokumentation | + +--- + +## 15. Vorfallmanagement + +### 15.1 Datenpannen-Prozess + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ INCIDENT RESPONSE │ +├─────────────────────────────────────────────────────────────────────┤ +│ │ +│ Erkennung ──► Bewertung ──► Meldung ──► Eindämmung ──► Behebung │ +│ │ │ │ │ │ │ +│ ▼ ▼ ▼ ▼ ▼ │ +│ Monitoring Risiko- 72h an LfD Isolation Ursachen- │ +│ Audit-Log einschätzung (Art.33) Forensik analyse │ +│ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### 15.2 Meldepflichten + +| Ereignis | Frist | Empfänger | +|----------|-------|-----------| +| Datenpanne mit Risiko | 72 Stunden | Landesbeauftragte/r für Datenschutz | +| Hohes Risiko für Betroffene | Unverzüglich | Betroffene Personen | + +### 15.3 KI-spezifische Vorfälle + +| Vorfall | Reaktion | +|---------|----------| +| Systematisch falsche OCR-Ergebnisse | Modell-Rollback, Analyse | +| Bias-Erkennung | Untersuchung, ggf. Re-Training | +| Adversarial Attack | System-Isolierung, Forensik | + +--- + +## 16. Kontakte + +### 16.1 Interne Kontakte + +| Rolle | Name | Kontakt | +|-------|------|---------| +| Schulleitung | [Name] | [E-Mail] | +| IT-Administrator | [Name] | [E-Mail] | +| Datenschutzbeauftragter | [Name] | [E-Mail] | + +### 16.2 Externe Kontakte + +| Institution | Kontakt | +|-------------|---------| +| LfD Niedersachsen | poststelle@lfd.niedersachsen.de | +| BSI | bsi@bsi.bund.de | + +--- + +## Anhänge + +### Anhang A: Systemarchitektur-Diagramm + +Siehe Abschnitt 4.2 + +### Anhang B: TOM-Checkliste + +| Kategorie | Maßnahme | Status | +|-----------|----------|--------| +| Zutrittskontrolle | Serverraum verschlossen | ✓ | +| Zugangskontrolle | Passwort-Policy | ✓ | +| Zugriffskontrolle | RBAC implementiert | ✓ | +| Weitergabekontrolle | Netzwerkisolation | ✓ | +| Eingabekontrolle | Audit-Logging | ✓ | +| Verfügbarkeit | Backup + USV | ✓ | +| Trennungskontrolle | Mandantentrennung | ✓ | +| Verschlüsselung | FileVault + TLS | ✓ | + +### Anhang C: Muster-Informationsschreiben + +[Zu erstellen für spezifische Schule] + +### Anhang D: Einwilligungserklärung Training + +[Zu erstellen für spezifische Schule] + +### Anhang E: Vendor-Dokumentation + +- llama3.2-vision: https://llama.meta.com/ +- TrOCR: https://github.com/microsoft/unilm/tree/master/trocr +- Ollama: https://ollama.ai/ + +--- + +**Dokumentende** + +*Diese Dokumentation wird jährlich oder bei wesentlichen Änderungen aktualisiert.* + +*Letzte Aktualisierung: 21. Januar 2026* diff --git a/klausur-service/docs/NiBiS-Ingestion-Pipeline.md b/klausur-service/docs/NiBiS-Ingestion-Pipeline.md new file mode 100644 index 0000000..2573014 --- /dev/null +++ b/klausur-service/docs/NiBiS-Ingestion-Pipeline.md @@ -0,0 +1,227 @@ +# NiBiS Ingestion Pipeline + +## Overview + +Die NiBiS Ingestion Pipeline verarbeitet Abitur-Erwartungshorizonte aus Niedersachsen und indexiert sie in Qdrant für RAG-basierte Klausurkorrektur. + +## Unterstützte Daten + +### Verzeichnisse + +| Verzeichnis | Jahre | Namenskonvention | +|-------------|-------|------------------| +| `docs/za-download` | 2024, 2025 | `{Jahr}_{Fach}_{niveau}_{Nr}_EWH.pdf` | +| `docs/za-download-2` | 2016 | `{Jahr}{Fach}{Niveau}Lehrer/{Jahr}{Fach}{Niveau}A{Nr}L.pdf` | +| `docs/za-download-3` | 2017 | `{Jahr}{Fach}{Niveau}Lehrer/{Jahr}{Fach}{Niveau}A{Nr}L.pdf` | + +### Dokumenttypen + +- **EWH** - Erwartungshorizont (Hauptziel) +- **Aufgabe** - Prüfungsaufgaben +- **Material** - Zusatzmaterialien +- **GBU** - Gefährdungsbeurteilung (Chemie/Biologie) +- **Bewertungsbogen** - Standardisierte Bewertungsbögen + +### Fächer + +Deutsch, Englisch, Mathematik, Informatik, Biologie, Chemie, Physik, Geschichte, Erdkunde, Kunst, Musik, Sport, Latein, Griechisch, Französisch, Spanisch, Katholische Religion, Evangelische Religion, Werte und Normen, BRC, BVW, Gesundheit-Pflege + +## Architektur + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ NiBiS Ingestion Pipeline │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. ZIP Extraction │ +│ └── Entpackt 2024.zip, 2025.zip, etc. │ +│ │ +│ 2. Document Discovery │ +│ ├── Parst alte Namenskonvention (2016/2017) │ +│ └── Parst neue Namenskonvention (2024/2025) │ +│ │ +│ 3. PDF Processing │ +│ ├── Text-Extraktion (PyPDF2) │ +│ └── Chunking (1000 chars, 200 overlap) │ +│ │ +│ 4. Embedding Generation │ +│ └── OpenAI text-embedding-3-small (1536 dim) │ +│ │ +│ 5. Qdrant Indexing │ +│ └── Collection: bp_nibis_eh │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Verwendung + +### Via API (empfohlen) + +```bash +# 1. Vorschau der verfügbaren Dokumente +curl http://localhost:8086/api/v1/admin/nibis/discover + +# 2. ZIP-Dateien entpacken +curl -X POST http://localhost:8086/api/v1/admin/nibis/extract-zips + +# 3. Ingestion starten +curl -X POST http://localhost:8086/api/v1/admin/nibis/ingest \ + -H "Content-Type: application/json" \ + -d '{"ewh_only": true}' + +# 4. Status prüfen +curl http://localhost:8086/api/v1/admin/nibis/status + +# 5. Semantische Suche testen +curl -X POST http://localhost:8086/api/v1/admin/nibis/search \ + -H "Content-Type: application/json" \ + -d '{"query": "Analyse literarischer Texte", "subject": "Deutsch", "limit": 5}' +``` + +### Via CLI + +```bash +# Dry-Run (nur analysieren) +cd klausur-service/backend +python nibis_ingestion.py --dry-run + +# Vollständige Ingestion +python nibis_ingestion.py + +# Nur bestimmtes Jahr +python nibis_ingestion.py --year 2024 + +# Nur bestimmtes Fach +python nibis_ingestion.py --subject Deutsch + +# Manifest erstellen +python nibis_ingestion.py --manifest /tmp/nibis_manifest.json +``` + +### Via Shell Script + +```bash +./klausur-service/scripts/run_nibis_ingestion.sh --dry-run +./klausur-service/scripts/run_nibis_ingestion.sh --year 2024 --subject Deutsch +``` + +## Qdrant Schema + +### Collection: `bp_nibis_eh` + +```json +{ + "id": "nibis_2024_deutsch_ea_1_abc123_chunk_0", + "vector": [1536 dimensions], + "payload": { + "doc_id": "nibis_2024_deutsch_ea_1_abc123", + "chunk_index": 0, + "text": "Der Erwartungshorizont...", + "year": 2024, + "subject": "Deutsch", + "niveau": "eA", + "task_number": 1, + "doc_type": "EWH", + "bundesland": "NI", + "variant": null, + "source": "nibis", + "training_allowed": true + } +} +``` + +## API Endpoints + +| Methode | Endpoint | Beschreibung | +|---------|----------|--------------| +| GET | `/api/v1/admin/nibis/status` | Ingestion-Status | +| POST | `/api/v1/admin/nibis/extract-zips` | ZIP-Dateien entpacken | +| GET | `/api/v1/admin/nibis/discover` | Dokumente finden | +| POST | `/api/v1/admin/nibis/ingest` | Ingestion starten | +| POST | `/api/v1/admin/nibis/search` | Semantische Suche | +| GET | `/api/v1/admin/nibis/stats` | Statistiken | +| GET | `/api/v1/admin/nibis/collections` | Qdrant Collections | +| DELETE | `/api/v1/admin/nibis/collection` | Collection löschen | + +## Erweiterung für andere Bundesländer + +Die Pipeline ist so designed, dass sie leicht erweitert werden kann: + +### 1. Neues Bundesland hinzufügen + +```python +# In nibis_ingestion.py + +# Bundesland-Code (ISO 3166-2:DE) +BUNDESLAND_CODES = { + "NI": "Niedersachsen", + "BE": "Berlin", + "BY": "Bayern", + # ... +} + +# Parsing-Funktion für neues Format +def parse_filename_berlin(filename: str, file_path: Path) -> Optional[Dict]: + # Berlin-spezifische Namenskonvention + pass +``` + +### 2. Neues Verzeichnis registrieren + +```python +# docs/za-download-berlin/ hinzufügen +ZA_DOWNLOAD_DIRS = [ + "za-download", + "za-download-2", + "za-download-3", + "za-download-berlin", # NEU +] +``` + +### 3. Dokumenttyp-Erweiterung + +Für Zeugnisgeneration oder andere Dokumenttypen: + +```python +DOC_TYPES = { + "EWH": "Erwartungshorizont", + "ZEUGNIS_VORLAGE": "Zeugnisvorlage", + "NOTENSPIEGEL": "Notenspiegel", + "BEMERKUNG": "Bemerkungstexte", +} +``` + +## Rechtliche Hinweise + +- NiBiS-Daten sind unter den [NiBiS-Nutzungsbedingungen](https://nibis.de) frei nutzbar +- `training_allowed: true` - Strukturelles Wissen darf für KI-Training genutzt werden +- Für Lehrer-eigene Erwartungshorizonte (BYOEH) gilt: `training_allowed: false` + +## Troubleshooting + +### Qdrant nicht erreichbar + +```bash +# Prüfen ob Qdrant läuft +curl http://localhost:6333/health + +# Docker starten +docker-compose up -d qdrant +``` + +### OpenAI API Fehler + +```bash +# API Key setzen +export OPENAI_API_KEY=sk-... +``` + +### PDF-Extraktion fehlgeschlagen + +Einige PDFs können problematisch sein (gescannte Dokumente ohne OCR). Diese werden übersprungen und im Error-Log protokolliert. + +## Performance + +- ~500-1000 Chunks pro Minute (abhängig von OpenAI API) +- ~2-3 GB Qdrant Storage für alle NiBiS-Daten (2016-2025) +- Embeddings werden nur einmal generiert (idempotent via Hash) diff --git a/klausur-service/docs/OCR-Labeling-Spec.md b/klausur-service/docs/OCR-Labeling-Spec.md new file mode 100644 index 0000000..d6e24d1 --- /dev/null +++ b/klausur-service/docs/OCR-Labeling-Spec.md @@ -0,0 +1,446 @@ +# OCR-Labeling System Spezifikation + +**Version:** 1.1.0 +**Datum:** 2026-01-23 +**Status:** In Produktion (Mac Mini) + +## Übersicht + +Das OCR-Labeling System ermöglicht das Erstellen von Trainingsdaten für Handschrift-OCR-Modelle aus eingescannten Klausuren. Es unterstützt folgende OCR-Modelle: + +| Modell | Beschreibung | Geschwindigkeit | Empfohlen für | +|--------|--------------|-----------------|---------------| +| **llama3.2-vision:11b** | Vision-LLM (Standard) | Langsam | Handschrift, beste Qualität | +| **TrOCR** | Microsoft Transformer | Schnell | Gedruckter Text | +| **PaddleOCR + LLM** | Hybrid-Ansatz (NEU) | Sehr schnell (4x) | Gemischte Dokumente | +| **Donut** | Document Understanding (NEU) | Mittel | Tabellen, Formulare | +| **qwen2.5:14b** | Korrektur-LLM | - | Klausurbewertung | + +### Neue OCR-Optionen (v1.1.0) + +#### PaddleOCR + LLM (Empfohlen für Geschwindigkeit) + +PaddleOCR ist ein zweistufiger Ansatz: +1. **PaddleOCR** - Schnelle, präzise Texterkennung mit Bounding-Boxes +2. **qwen2.5:14b** - Semantische Strukturierung des erkannten Texts + +**Vorteile:** +- 4x schneller als Vision-LLM (~7-15 Sek vs 30-60 Sek pro Seite) +- Höhere Genauigkeit bei gedrucktem Text (95-99%) +- Weniger Halluzinationen (LLM korrigiert nur, erfindet nicht) +- Position-basierte Spaltenerkennung möglich + +**Dateien:** +- `/klausur-service/backend/hybrid_vocab_extractor.py` - PaddleOCR Integration + +#### Donut (Document Understanding Transformer) + +Donut ist speziell für strukturierte Dokumente optimiert: +- Tabellen und Formulare +- Rechnungen und Quittungen +- Multi-Spalten-Layouts + +**Dateien:** +- `/klausur-service/backend/services/donut_ocr_service.py` - Donut Service + +## Architektur + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ OCR-Labeling System │ +├──────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────────┐ ┌────────────────────────┐ │ +│ │ Frontend │◄──►│ Klausur-Service │◄──►│ PostgreSQL │ │ +│ │ (Next.js) │ │ (FastAPI) │ │ - ocr_labeling_sessions│ │ +│ │ Port 3000 │ │ Port 8086 │ │ - ocr_labeling_items │ │ +│ └─────────────┘ └────────┬─────────┘ │ - ocr_training_samples │ │ +│ │ └────────────────────────┘ │ +│ │ │ +│ ┌──────────┼──────────┐ │ +│ ▼ ▼ ▼ │ +│ ┌───────────┐ ┌─────────┐ ┌───────────────┐ │ +│ │ MinIO │ │ Ollama │ │ Export Service │ │ +│ │ (Images) │ │ (OCR) │ │ (Training) │ │ +│ │ Port 9000 │ │ :11434 │ │ │ │ +│ └───────────┘ └─────────┘ └───────────────┘ │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +## Datenmodell + +### PostgreSQL Tabellen + +```sql +-- Labeling Sessions (gruppiert zusammengehörige Bilder) +CREATE TABLE ocr_labeling_sessions ( + id VARCHAR(36) PRIMARY KEY, + name VARCHAR(255) NOT NULL, + source_type VARCHAR(50) NOT NULL, -- 'klausur', 'handwriting_sample', 'scan' + description TEXT, + ocr_model VARCHAR(100), -- z.B. 'llama3.2-vision:11b' + total_items INTEGER DEFAULT 0, + labeled_items INTEGER DEFAULT 0, + confirmed_items INTEGER DEFAULT 0, + corrected_items INTEGER DEFAULT 0, + skipped_items INTEGER DEFAULT 0, + teacher_id VARCHAR(100), + created_at TIMESTAMP DEFAULT NOW() +); + +-- Einzelne Labeling Items (Bild + OCR + Ground Truth) +CREATE TABLE ocr_labeling_items ( + id VARCHAR(36) PRIMARY KEY, + session_id VARCHAR(36) REFERENCES ocr_labeling_sessions(id), + image_path TEXT NOT NULL, -- MinIO Pfad oder lokaler Pfad + image_hash VARCHAR(64), -- SHA256 für Deduplizierung + ocr_text TEXT, -- Von LLM erkannter Text + ocr_confidence FLOAT, -- Konfidenz (0-1) + ocr_model VARCHAR(100), + ground_truth TEXT, -- Korrigierter/bestätigter Text + status VARCHAR(20) DEFAULT 'pending', -- pending/confirmed/corrected/skipped + labeled_by VARCHAR(100), + labeled_at TIMESTAMP, + label_time_seconds INTEGER, + metadata JSONB, + created_at TIMESTAMP DEFAULT NOW() +); + +-- Exportierte Training Samples +CREATE TABLE ocr_training_samples ( + id VARCHAR(36) PRIMARY KEY, + item_id VARCHAR(36) REFERENCES ocr_labeling_items(id), + image_path TEXT NOT NULL, + ground_truth TEXT NOT NULL, + export_format VARCHAR(50) NOT NULL, -- 'generic', 'trocr', 'llama_vision' + exported_at TIMESTAMP DEFAULT NOW(), + training_batch VARCHAR(100), + used_in_training BOOLEAN DEFAULT FALSE +); +``` + +## API Referenz + +Base URL: `http://macmini:8086/api/v1/ocr-label` + +### Sessions + +#### POST /sessions +Neue Labeling-Session erstellen. + +**Request:** +```json +{ + "name": "Klausur Deutsch 12a Q1", + "source_type": "klausur", + "description": "Gedichtanalyse Expressionismus", + "ocr_model": "llama3.2-vision:11b" +} +``` + +**Response:** +```json +{ + "id": "abc-123-def", + "name": "Klausur Deutsch 12a Q1", + "source_type": "klausur", + "total_items": 0, + "labeled_items": 0, + "created_at": "2026-01-21T10:30:00Z" +} +``` + +#### GET /sessions +Sessions auflisten. + +**Query Parameter:** +- `limit` (int, default: 50) - Maximale Anzahl + +#### GET /sessions/{session_id} +Einzelne Session abrufen. + +### Upload + +#### POST /sessions/{session_id}/upload +Bilder zu einer Session hochladen. + +**Request:** Multipart Form Data +- `files` (File[]) - PNG/JPG/PDF Dateien +- `run_ocr` (bool, default: true) - OCR direkt ausführen +- `metadata` (JSON string) - Optional: Metadaten + +**Response:** +```json +{ + "session_id": "abc-123-def", + "uploaded_count": 5, + "items": [ + { + "id": "item-1", + "filename": "scan_001.png", + "image_path": "ocr-labeling/abc-123/item-1.png", + "ocr_text": "Die Lösung der Aufgabe...", + "ocr_confidence": 0.87, + "status": "pending" + } + ] +} +``` + +### Labeling Queue + +#### GET /queue +Nächste zu labelnde Items abrufen. + +**Query Parameter:** +- `session_id` (str, optional) - Nach Session filtern +- `status` (str, default: "pending") - Status-Filter +- `limit` (int, default: 10) - Maximale Anzahl + +**Response:** +```json +[ + { + "id": "item-456", + "session_id": "abc-123", + "session_name": "Klausur Deutsch", + "image_path": "/app/ocr-labeling/abc-123/item-456.png", + "image_url": "/api/v1/ocr-label/images/abc-123/item-456.png", + "ocr_text": "Erkannter Text...", + "ocr_confidence": 0.87, + "ground_truth": null, + "status": "pending", + "metadata": {"page": 1} + } +] +``` + +### Labeling Actions + +#### POST /confirm +OCR-Text als korrekt bestätigen. + +**Request:** +```json +{ + "item_id": "item-456", + "label_time_seconds": 5 +} +``` + +**Effect:** `ground_truth = ocr_text`, `status = 'confirmed'` + +#### POST /correct +Ground Truth korrigieren. + +**Request:** +```json +{ + "item_id": "item-456", + "ground_truth": "Korrigierter Text hier", + "label_time_seconds": 15 +} +``` + +**Effect:** `ground_truth = `, `status = 'corrected'` + +#### POST /skip +Item überspringen (unbrauchbar). + +**Request:** +```json +{ + "item_id": "item-456" +} +``` + +**Effect:** `status = 'skipped'` (wird nicht exportiert) + +### Statistiken + +#### GET /stats +Labeling-Statistiken abrufen. + +**Query Parameter:** +- `session_id` (str, optional) - Für Session-spezifische Stats + +**Response:** +```json +{ + "total_items": 100, + "labeled_items": 75, + "confirmed_items": 60, + "corrected_items": 15, + "pending_items": 25, + "accuracy_rate": 0.80, + "avg_label_time_seconds": 8.5 +} +``` + +### Training Export + +#### POST /export +Trainingsdaten exportieren. + +**Request:** +```json +{ + "export_format": "trocr", + "session_id": "abc-123", + "batch_id": "batch_20260121" +} +``` + +**Export Formate:** + +| Format | Beschreibung | Output | +|--------|--------------|--------| +| `generic` | Allgemeines JSONL | `{"id", "image_path", "ground_truth", ...}` | +| `trocr` | Microsoft TrOCR | `{"file_name", "text", "id"}` | +| `llama_vision` | Llama 3.2 Vision | OpenAI-style Messages mit image_url | + +**Response:** +```json +{ + "export_format": "trocr", + "batch_id": "batch_20260121", + "exported_count": 75, + "export_path": "/app/ocr-exports/trocr/batch_20260121", + "manifest_path": "/app/ocr-exports/trocr/batch_20260121/manifest.json", + "samples": [...] +} +``` + +#### GET /exports +Verfügbare Exports auflisten. + +**Query Parameter:** +- `export_format` (str, optional) - Nach Format filtern + +## Export Formate im Detail + +### TrOCR Format + +``` +batch_20260121/ +├── manifest.json +├── train.jsonl +└── images/ + ├── item-1.png + └── item-2.png +``` + +**train.jsonl:** +```jsonl +{"file_name": "images/item-1.png", "text": "Ground truth text", "id": "item-1"} +{"file_name": "images/item-2.png", "text": "Another text", "id": "item-2"} +``` + +### Llama Vision Format + +```jsonl +{ + "id": "item-1", + "messages": [ + {"role": "system", "content": "Du bist ein OCR-Experte für deutsche Handschrift..."}, + {"role": "user", "content": [ + {"type": "image_url", "image_url": {"url": "images/item-1.png"}}, + {"type": "text", "text": "Lies den handgeschriebenen Text in diesem Bild."} + ]}, + {"role": "assistant", "content": "Ground truth text"} + ] +} +``` + +### Generic Format + +```jsonl +{ + "id": "item-1", + "image_path": "images/item-1.png", + "ground_truth": "Ground truth text", + "ocr_text": "OCR recognized text", + "ocr_confidence": 0.87, + "metadata": {"page": 1, "session": "Deutsch 12a"} +} +``` + +## Frontend Integration + +Die OCR-Labeling UI ist unter `/admin/ocr-labeling` verfügbar. + +### Keyboard Shortcuts + +| Taste | Aktion | +|-------|--------| +| `Enter` | Bestätigen (OCR korrekt) | +| `Tab` | Ins Korrekturfeld springen | +| `Escape` | Überspringen | +| `←` / `→` | Navigation (Prev/Next) | + +### Workflow + +1. **Session erstellen** - Name, Typ, OCR-Modell wählen +2. **Bilder hochladen** - Drag & Drop oder File-Browser +3. **Labeling durchführen** - Bild + OCR-Text vergleichen + - Korrekt → Bestätigen (Enter) + - Falsch → Korrigieren + Speichern + - Unbrauchbar → Überspringen +4. **Export** - Format wählen (TrOCR, Llama Vision, Generic) +5. **Training starten** - Export-Ordner für Fine-Tuning nutzen + +## Umgebungsvariablen + +```bash +# PostgreSQL +DATABASE_URL=postgres://user:pass@postgres:5432/breakpilot_db + +# MinIO (S3-kompatibel) +MINIO_ENDPOINT=minio:9000 +MINIO_ACCESS_KEY=breakpilot +MINIO_SECRET_KEY=breakpilot123 +MINIO_BUCKET=breakpilot-rag +MINIO_SECURE=false + +# Ollama (Vision-LLM) +OLLAMA_BASE_URL=http://host.docker.internal:11434 +OLLAMA_VISION_MODEL=llama3.2-vision:11b +OLLAMA_CORRECTION_MODEL=qwen2.5:14b + +# Export +OCR_EXPORT_PATH=/app/ocr-exports +OCR_STORAGE_PATH=/app/ocr-labeling +``` + +## Sicherheit & Datenschutz + +- **100% Lokale Verarbeitung** - Alle Daten bleiben auf dem Mac Mini +- **Keine Cloud-Uploads** - Ollama läuft vollständig offline +- **DSGVO-konform** - Keine Schülerdaten verlassen das Schulnetzwerk +- **Deduplizierung** - SHA256-Hash verhindert doppelte Bilder + +## Dateien + +| Datei | Beschreibung | +|-------|--------------| +| `klausur-service/backend/ocr_labeling_api.py` | FastAPI Router mit OCR Model Dispatcher | +| `klausur-service/backend/training_export_service.py` | Export-Service für TrOCR/Llama | +| `klausur-service/backend/metrics_db.py` | PostgreSQL CRUD Funktionen | +| `klausur-service/backend/minio_storage.py` | MinIO OCR-Image Storage | +| `klausur-service/backend/hybrid_vocab_extractor.py` | PaddleOCR Integration | +| `klausur-service/backend/services/donut_ocr_service.py` | Donut OCR Service (NEU) | +| `klausur-service/backend/services/trocr_service.py` | TrOCR Service (NEU) | +| `website/app/admin/ocr-labeling/page.tsx` | Frontend UI mit Model-Auswahl | +| `website/app/admin/ocr-labeling/types.ts` | TypeScript Interfaces inkl. OCRModel Type | + +## Tests + +```bash +# Backend-Tests ausführen +cd klausur-service/backend +pytest tests/test_ocr_labeling.py -v + +# Mit Coverage +pytest tests/test_ocr_labeling.py --cov=. --cov-report=html +``` diff --git a/klausur-service/docs/RAG-Admin-Spec.md b/klausur-service/docs/RAG-Admin-Spec.md new file mode 100644 index 0000000..76e4eb7 --- /dev/null +++ b/klausur-service/docs/RAG-Admin-Spec.md @@ -0,0 +1,472 @@ +# RAG & Daten-Management Spezifikation + +## Übersicht + +Admin-Frontend für die Verwaltung von Trainingsdaten und RAG-Systemen in BreakPilot. + +**Location**: `/admin/docs` → Tab "Daten & RAG" +**Backend**: `klausur-service` (Port 8086) +**Storage**: MinIO (persistentes Docker Volume `minio_data`) +**Vector DB**: Qdrant (Port 6333) + +## Datenmodell + +### Zwei Datentypen mit unterschiedlichen Regeln + +| Typ | Quelle | Training erlaubt | Isolation | Collection | +|-----|--------|------------------|-----------|------------| +| **Landes-Daten** | NiBiS, andere Bundesländer | ✅ Ja | Pro Bundesland | `bp_{bundesland}_{usecase}` | +| **Lehrer-Daten** | Lehrer-Upload (BYOEH) | ❌ Nein | Pro Tenant (Schule/Lehrer) | `bp_eh` (verschlüsselt) | + +### Bundesland-Codes (ISO 3166-2:DE) + +``` +NI = Niedersachsen BY = Bayern BW = Baden-Württemberg +NW = Nordrhein-Westf. HE = Hessen SN = Sachsen +BE = Berlin HH = Hamburg SH = Schleswig-Holstein +BB = Brandenburg MV = Meckl.-Vorp. ST = Sachsen-Anhalt +TH = Thüringen RP = Rheinland-Pfalz SL = Saarland +HB = Bremen +``` + +### Use Cases (RAG-Sammlungen) + +| Use Case | Collection Pattern | Beschreibung | +|----------|-------------------|--------------| +| Klausurkorrektur | `bp_{bl}_klausur` | Erwartungshorizonte für Abitur | +| Zeugnisgenerator | `bp_{bl}_zeugnis` | Textbausteine für Zeugnisse | +| Lehrplan | `bp_{bl}_lehrplan` | Kerncurricula, Rahmenrichtlinien | + +Beispiel: `bp_ni_klausur` = Niedersachsen Klausurkorrektur + +## MinIO Bucket-Struktur + +``` +breakpilot-rag/ +├── landes-daten/ +│ ├── ni/ # Niedersachsen +│ │ ├── klausur/ +│ │ │ ├── 2016/ +│ │ │ │ ├── manifest.json +│ │ │ │ └── *.pdf +│ │ │ ├── 2017/ +│ │ │ ├── ... +│ │ │ └── 2025/ +│ │ └── zeugnis/ +│ ├── by/ # Bayern +│ └── .../ +│ +└── lehrer-daten/ # BYOEH - verschlüsselt + └── {tenant_id}/ + └── {lehrer_id}/ + └── *.pdf.enc +``` + +## Qdrant Schema + +### Landes-Daten Collection (z.B. `bp_ni_klausur`) + +```json +{ + "id": "uuid-v5-from-string", + "vector": [384 dimensions], + "payload": { + "original_id": "nibis_2024_deutsch_ea_1_abc123_chunk_0", + "doc_id": "nibis_2024_deutsch_ea_1_abc123", + "chunk_index": 0, + "text": "Der Erwartungshorizont...", + "year": 2024, + "subject": "Deutsch", + "niveau": "eA", + "task_number": 1, + "doc_type": "EWH", + "bundesland": "NI", + "source": "nibis", + "training_allowed": true, + "minio_path": "landes-daten/ni/klausur/2024/2024_Deutsch_eA_I_EWH.pdf" + } +} +``` + +### Lehrer-Daten Collection (`bp_eh`) + +```json +{ + "id": "uuid", + "vector": [384 dimensions], + "payload": { + "tenant_id": "schule_123", + "eh_id": "eh_abc", + "chunk_index": 0, + "subject": "deutsch", + "encrypted_content": "base64...", + "training_allowed": false + } +} +``` + +## Frontend-Komponenten + +### 1. Sammlungen-Übersicht (`/admin/rag/collections`) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Daten & RAG │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Sammlungen [+ Neu] │ +│ ───────────────────────────────────────────────────────────── │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 📚 Niedersachsen - Klausurkorrektur │ │ +│ │ bp_ni_klausur | 630 Docs | 4.521 Chunks | 2016-2025 │ │ +│ │ [Suchen] [Indexieren] [Details] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ 📚 Niedersachsen - Zeugnisgenerator │ │ +│ │ bp_ni_zeugnis | 0 Docs | Leer │ │ +│ │ [Suchen] [Indexieren] [Details] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 2. Upload-Bereich (`/admin/rag/upload`) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Dokumente hochladen │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Ziel-Sammlung: [Niedersachsen - Klausurkorrektur ▼] │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ 📁 ZIP-Datei oder Ordner hierher ziehen │ │ +│ │ │ │ +│ │ oder [Dateien auswählen] │ │ +│ │ │ │ +│ │ Unterstützt: .zip, .pdf, Ordner │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ Upload-Queue: │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ ✅ 2018.zip - 45 PDFs erkannt │ │ +│ │ ⏳ 2019.zip - Wird analysiert... │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +│ [Hochladen & Indexieren] │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 3. Ingestion-Status (`/admin/rag/ingestion`) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Ingestion Status │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Aktueller Job: Niedersachsen Klausur 2024 │ +│ ████████████████████░░░░░░░░░░ 65% (412/630 Docs) │ +│ Chunks: 2.891 | Fehler: 3 | ETA: 4:32 │ +│ [Pausieren] [Abbrechen] │ +│ │ +│ ───────────────────────────────────────────────────────────── │ +│ │ +│ Letzte Jobs: │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ ✅ 09.01.2025 15:30 - NI Klausur 2024 - 128 Chunks │ │ +│ │ ✅ 09.01.2025 14:00 - NI Klausur 2017 - 890 Chunks │ │ +│ │ ❌ 08.01.2025 10:15 - BY Klausur - Fehler: Timeout │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 4. Suche & Qualitätstest (`/admin/rag/search`) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ RAG Suche & Qualitätstest │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Sammlung: [Niedersachsen - Klausurkorrektur ▼] │ +│ │ +│ Query: [Analyse eines Gedichts von Rilke ] │ +│ │ +│ Filter: │ +│ Jahr: [Alle ▼] Fach: [Deutsch ▼] Niveau: [eA ▼] │ +│ │ +│ [🔍 Suchen] │ +│ │ +│ ───────────────────────────────────────────────────────────── │ +│ │ +│ Ergebnisse (3): Latenz: 45ms │ +│ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ #1 | Score: 0.847 | 2024 Deutsch eA Aufgabe 2 │ │ +│ │ │ │ +│ │ "...Die Analyse des Rilke-Gedichts soll folgende │ │ +│ │ Aspekte berücksichtigen: Aufbau, Bildsprache..." │ │ +│ │ │ │ +│ │ Relevanz: [⭐⭐⭐⭐⭐] [⭐⭐⭐⭐] [⭐⭐⭐] [⭐⭐] [⭐] │ │ +│ │ Notizen: [Optional: Warum relevant/nicht relevant? ] │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 5. Metriken-Dashboard (`/admin/rag/metrics`) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ RAG Qualitätsmetriken │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ Zeitraum: [Letzte 7 Tage ▼] Sammlung: [Alle ▼] │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Precision@5 │ │ Recall@10 │ │ MRR │ │ +│ │ 0.78 │ │ 0.85 │ │ 0.72 │ │ +│ │ ↑ +5% │ │ ↑ +3% │ │ ↓ -2% │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Avg Latency │ │ Bewertungen │ │ Fehlerrate │ │ +│ │ 52ms │ │ 127 │ │ 0.3% │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +│ ───────────────────────────────────────────────────────────── │ +│ │ +│ Score-Verteilung: │ +│ 0.9+ ████████████████ 23% │ +│ 0.7+ ████████████████████████████ 41% │ +│ 0.5+ ████████████████████ 28% │ +│ <0.5 ██████ 8% │ +│ │ +│ [Export CSV] [Detailbericht] │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## API Endpoints + +### Collections API + +``` +GET /api/v1/admin/rag/collections +POST /api/v1/admin/rag/collections +GET /api/v1/admin/rag/collections/{id} +DELETE /api/v1/admin/rag/collections/{id} +GET /api/v1/admin/rag/collections/{id}/stats +``` + +### Upload API + +``` +POST /api/v1/admin/rag/upload + Content-Type: multipart/form-data + - file: ZIP oder PDF + - collection_id: string + - metadata: JSON (optional) + +POST /api/v1/admin/rag/upload/folder + - Für Ordner-Upload (WebKitDirectory) +``` + +### Ingestion API + +``` +POST /api/v1/admin/rag/ingest + - collection_id: string + - filters: {year?, subject?, doc_type?} + +GET /api/v1/admin/rag/ingest/status +GET /api/v1/admin/rag/ingest/history +POST /api/v1/admin/rag/ingest/cancel +``` + +### Search API + +``` +POST /api/v1/admin/rag/search + - query: string + - collection_id: string + - filters: {year?, subject?, niveau?} + - limit: int + +POST /api/v1/admin/rag/search/feedback + - result_id: string + - rating: 1-5 + - notes: string (optional) +``` + +### Metrics API + +``` +GET /api/v1/admin/rag/metrics + - collection_id?: string + - from_date?: date + - to_date?: date + +GET /api/v1/admin/rag/metrics/export + - format: csv|json +``` + +## Embedding-Konfiguration + +```python +# Default: Lokale Embeddings (kein API-Key nötig) +EMBEDDING_BACKEND = "local" +LOCAL_EMBEDDING_MODEL = "all-MiniLM-L6-v2" +VECTOR_DIMENSIONS = 384 + +# Optional: OpenAI (für Produktion) +EMBEDDING_BACKEND = "openai" +EMBEDDING_MODEL = "text-embedding-3-small" +VECTOR_DIMENSIONS = 1536 +``` + +## Datenpersistenz + +### Docker Volumes (WICHTIG - nicht löschen!) + +```yaml +volumes: + minio_data: # Alle hochgeladenen Dokumente + qdrant_data: # Alle Vektoren und Embeddings + postgres_data: # Metadaten, Bewertungen, History +``` + +### Backup-Strategie + +```bash +# MinIO Backup +docker exec breakpilot-pwa-minio mc mirror /data /backup + +# Qdrant Backup +curl -X POST http://localhost:6333/collections/bp_ni_klausur/snapshots + +# Postgres Backup (bereits implementiert) +# Läuft automatisch täglich um 2 Uhr +``` + +## Implementierungsreihenfolge + +1. ✅ Backend: Basis-Ingestion (nibis_ingestion.py) +2. ✅ Backend: Lokale Embeddings (sentence-transformers) +3. ✅ Backend: MinIO-Integration (minio_storage.py) +4. ✅ Backend: Collections API (admin_api.py) +5. ✅ Backend: Upload API mit ZIP-Support +6. ✅ Backend: Metrics API mit PostgreSQL (metrics_db.py) +7. ✅ Frontend: Sammlungen-Übersicht +8. ✅ Frontend: Upload-Bereich (Drag & Drop) +9. ✅ Frontend: Ingestion-Status +10. ✅ Frontend: Suche & Qualitätstest (mit Stern-Bewertungen) +11. ✅ Frontend: Metriken-Dashboard + +## Technologie-Stack + +- **Frontend**: Next.js 15 (`/website/app/admin/rag/page.tsx`) +- **Backend**: FastAPI (`klausur-service/backend/`) +- **Vector DB**: Qdrant v1.7.4 (384-dim Vektoren) +- **Object Storage**: MinIO (S3-kompatibel) +- **Embeddings**: sentence-transformers `all-MiniLM-L6-v2` +- **Metrics DB**: PostgreSQL 16 + +## Entwickler-Dokumentation + +### Projektstruktur + +``` +klausur-service/ +├── backend/ +│ ├── main.py # FastAPI App + BYOEH Endpoints +│ ├── admin_api.py # RAG Admin API (Upload, Search, Metrics) +│ ├── nibis_ingestion.py # NiBiS Dokument-Ingestion Pipeline +│ ├── eh_pipeline.py # Chunking, Embeddings, Encryption +│ ├── qdrant_service.py # Qdrant Client + Search +│ ├── minio_storage.py # MinIO S3 Storage +│ ├── metrics_db.py # PostgreSQL Metrics +│ ├── requirements.txt # Python Dependencies +│ └── tests/ +│ └── test_rag_admin.py +└── docs/ + └── RAG-Admin-Spec.md # Diese Datei +``` + +### Schnellstart für Entwickler + +```bash +# 1. Services starten +cd /path/to/breakpilot-pwa +docker-compose up -d qdrant minio postgres + +# 2. Dependencies installieren +cd klausur-service/backend +pip install -r requirements.txt + +# 3. Service starten +python -m uvicorn main:app --port 8086 --reload + +# 4. RAG-Services initialisieren (erstellt Bucket + Tabellen) +curl -X POST http://localhost:8086/api/v1/admin/rag/init +``` + +### API-Referenz (Implementiert) + +#### NiBiS Ingestion +``` +GET /api/v1/admin/nibis/discover # Dokumente finden +POST /api/v1/admin/nibis/ingest # Indexierung starten +GET /api/v1/admin/nibis/status # Status abfragen +GET /api/v1/admin/nibis/stats # Statistiken +POST /api/v1/admin/nibis/search # Semantische Suche +GET /api/v1/admin/nibis/collections # Qdrant Collections +``` + +#### RAG Upload & Storage +``` +POST /api/v1/admin/rag/upload # ZIP/PDF hochladen +GET /api/v1/admin/rag/upload/history # Upload-Verlauf +GET /api/v1/admin/rag/storage/stats # MinIO Statistiken +``` + +#### Metrics & Feedback +``` +GET /api/v1/admin/rag/metrics # Qualitätsmetriken +POST /api/v1/admin/rag/search/feedback # Bewertung abgeben +POST /api/v1/admin/rag/init # Services initialisieren +``` + +### Umgebungsvariablen + +```bash +# Qdrant +QDRANT_URL=http://localhost:6333 + +# MinIO +MINIO_ENDPOINT=localhost:9000 +MINIO_ACCESS_KEY=breakpilot +MINIO_SECRET_KEY=breakpilot123 +MINIO_BUCKET=breakpilot-rag + +# PostgreSQL +DATABASE_URL=postgres://breakpilot:breakpilot123@localhost:5432/breakpilot_db + +# Embeddings +EMBEDDING_BACKEND=local +LOCAL_EMBEDDING_MODEL=all-MiniLM-L6-v2 +``` + +### Aktuelle Indexierungs-Statistik + +- **Dokumente**: 579 Erwartungshorizonte (NiBiS) +- **Chunks**: 7.352 +- **Jahre**: 2016, 2017, 2024, 2025 +- **Fächer**: Deutsch, Englisch, Mathematik, Physik, Chemie, Biologie, Geschichte, Politik-Wirtschaft, Erdkunde, Sport, Kunst, Musik, Latein, Informatik, Ev. Religion, Kath. Religion, Werte und Normen, etc. +- **Collection**: `bp_nibis_eh` +- **Vektor-Dimensionen**: 384 diff --git a/klausur-service/docs/Vocab-Worksheet-Architecture.md b/klausur-service/docs/Vocab-Worksheet-Architecture.md new file mode 100644 index 0000000..1e85c64 --- /dev/null +++ b/klausur-service/docs/Vocab-Worksheet-Architecture.md @@ -0,0 +1,293 @@ +# Vokabel-Arbeitsblatt Generator - Architektur + +**Version:** 1.0.0 +**Datum:** 2026-01-23 +**Status:** Produktiv + +--- + +## 1. Uebersicht + +Der Vokabel-Arbeitsblatt Generator ist ein DSGVO-konformes Tool fuer Lehrer, das Vokabeln aus Schulbuchseiten extrahiert und druckfertige Arbeitsblaetter generiert. + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Studio v2 (Next.js) │ +│ Port 3001 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ /vocab-worksheet │ │ +│ │ - Session-Management (erstellen, fortsetzen, loeschen) │ │ +│ │ - PDF-Upload mit Seitenauswahl │ │ +│ │ - Vokabel-Bearbeitung (Grid-Editor) │ │ +│ │ - Arbeitsblatt-Konfiguration │ │ +│ │ - PDF-Export │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ HTTP/REST +┌─────────────────────────────────────────────────────────────────────────┐ +│ Klausur-Service (FastAPI) │ +│ Port 8086 │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ /api/v1/vocab/* │ │ +│ │ - Session CRUD │ │ +│ │ - PDF-Verarbeitung (PyMuPDF) │ │ +│ │ - Vokabel-Extraktion (Vision LLM / Hybrid OCR) │ │ +│ │ - Arbeitsblatt-Generierung (WeasyPrint) │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ┌───────────────┴───────────────┐ + ▼ ▼ +┌───────────────────────────────┐ ┌───────────────────────────────────┐ +│ Ollama Vision LLM │ │ LLM Gateway │ +│ Port 11434 │ │ Port 8002 │ +│ ┌─────────────────────────┐ │ │ ┌─────────────────────────────┐ │ +│ │ qwen2.5vl:32b │ │ │ │ qwen2.5:14b │ │ +│ │ (Bild → Vokabeln) │ │ │ │ (OCR-Text → strukturiert) │ │ +│ └─────────────────────────┘ │ │ └─────────────────────────────┘ │ +└───────────────────────────────┘ └───────────────────────────────────┘ +``` + +--- + +## 2. Komponenten + +### 2.1 Frontend (studio-v2) + +**Datei:** `/studio-v2/app/vocab-worksheet/page.tsx` + +| Aspekt | Details | +|--------|---------| +| Framework | Next.js 16.1.4 mit React 19.0.0 | +| Styling | Tailwind CSS 3.4.17 | +| Sprache | TypeScript 5.7.0 | +| State | React Hooks (useState, useRef, useEffect) | + +**Tab-basierter Workflow:** + +1. **Upload** - Session benennen, Datei auswaehlen (Bild/PDF) +2. **Pages** - Bei PDFs: Seiten mit Thumbnails auswaehlen +3. **Vocabulary** - Extrahierte Vokabeln pruefen/bearbeiten +4. **Worksheet** - Arbeitsblatt-Typ und Format waehlen +5. **Export** - PDF herunterladen + +**Datenstrukturen:** + +```typescript +interface VocabularyEntry { + id: string + english: string + german: string + example_sentence?: string + word_type?: string + source_page?: number +} + +interface Session { + id: string + name: string + status: 'pending' | 'processing' | 'extracted' | 'completed' + vocabulary_count: number +} + +type WorksheetType = 'en_to_de' | 'de_to_en' | 'copy' | 'gap_fill' +``` + +### 2.2 Backend API + +**Datei:** `/klausur-service/backend/vocab_worksheet_api.py` + +| Aspekt | Details | +|--------|---------| +| Framework | FastAPI (async) | +| Router-Prefix | `/api/v1/vocab` | +| Storage | In-Memory (Dict) + Filesystem | + +**Endpoints:** + +| Methode | Pfad | Beschreibung | +|---------|------|--------------| +| POST | `/sessions` | Session erstellen | +| GET | `/sessions` | Sessions auflisten | +| GET | `/sessions/{id}` | Session-Details | +| DELETE | `/sessions/{id}` | Session loeschen | +| POST | `/sessions/{id}/upload` | Bild/PDF hochladen | +| POST | `/sessions/{id}/upload-pdf-info` | PDF-Info abrufen | +| GET | `/sessions/{id}/pdf-thumbnail/{page}` | Seiten-Thumbnail | +| POST | `/sessions/{id}/process-single-page/{page}` | Einzelne Seite verarbeiten | +| GET | `/sessions/{id}/vocabulary` | Vokabeln abrufen | +| PUT | `/sessions/{id}/vocabulary` | Vokabeln aktualisieren | +| POST | `/sessions/{id}/generate` | Arbeitsblatt generieren | +| GET | `/worksheets/{id}/pdf` | Arbeitsblatt-PDF | +| GET | `/worksheets/{id}/solution` | Loesungs-PDF | + +### 2.3 Vokabel-Extraktion + +**Zwei Modi verfuegbar:** + +#### A. Vision LLM (Standard) + +```python +OLLAMA_URL = "http://host.docker.internal:11434" +VISION_MODEL = "qwen2.5vl:32b" +``` + +- Bild wird Base64-kodiert an Ollama gesendet +- Prompt in Deutsch fuer bessere Erkennung +- Timeout: 5 Minuten pro Seite +- Confidence: ~85% + +#### B. Hybrid OCR + LLM (Optional) + +**Datei:** `/klausur-service/backend/hybrid_vocab_extractor.py` + +``` +Bild → PaddleOCR → Text-Regionen → LLM Gateway → Strukturiertes JSON +``` + +- PaddleOCR 3.x fuer Text-Erkennung +- Automatische Spalten-Erkennung (2 oder 3 Spalten) +- qwen2.5:14b fuer Strukturierung +- ~4x schneller als Vision LLM + +### 2.4 PDF-Verarbeitung + +| Aufgabe | Bibliothek | +|---------|------------| +| PDF → PNG | PyMuPDF (fitz) | +| Thumbnails | PyMuPDF mit Zoom 0.5 | +| OCR-Bilder | PyMuPDF mit Zoom 2.0 | +| PDF-Generierung | WeasyPrint | + +--- + +## 3. Datenfluss + +``` +┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ +│ Upload │───►│ OCR/ │───►│ Edit │───►│ Export │ +│ PDF │ │ Extract │ │ Vocab │ │ PDF │ +└──────────┘ └──────────┘ └──────────┘ └──────────┘ + │ │ │ │ + ▼ ▼ ▼ ▼ + /upload /process- /vocabulary /generate + single-page +``` + +**Session-Status-Workflow:** + +``` +PENDING → PROCESSING → EXTRACTED → COMPLETED + │ │ │ │ + Upload Extraktion Bereit zum Worksheet + erfolgt laeuft Bearbeiten generiert +``` + +--- + +## 4. Arbeitsblatt-Typen + +| Typ | Beschreibung | +|-----|--------------| +| `en_to_de` | Englisch → Deutsch uebersetzen | +| `de_to_en` | Deutsch → Englisch uebersetzen | +| `copy` | Woerter mehrfach abschreiben | +| `gap_fill` | Lueckentext mit Beispielsaetzen | + +**Optionen:** + +- Zeilenhoehe: normal / large / extra-large +- Loesungen: ja / nein +- Wiederholungen (bei Copy): 1-5 + +--- + +## 5. Datenschutz (DSGVO) + +| Aspekt | Umsetzung | +|--------|-----------| +| Verarbeitung | 100% lokal (Mac Mini) | +| Externe APIs | Keine | +| LLM | Ollama (lokal) | +| Speicherung | Lokales Filesystem | +| Datentransfer | Nur innerhalb LAN | + +**Keine Daten werden an externe Server gesendet.** + +--- + +## 6. Konfiguration + +**Umgebungsvariablen:** + +```bash +# Ollama Vision LLM +OLLAMA_URL=http://host.docker.internal:11434 +OLLAMA_VISION_MODEL=qwen2.5vl:32b + +# LLM Gateway (Hybrid Mode) +LLM_GATEWAY_URL=http://host.docker.internal:8002 +LLM_MODEL=qwen2.5:14b + +# Storage +VOCAB_STORAGE_PATH=/app/vocab-worksheets +``` + +--- + +## 7. Abhaengigkeiten + +### Backend (Python) + +| Paket | Version | Zweck | +|-------|---------|-------| +| FastAPI | 0.123.9 | Web Framework | +| PyMuPDF | 1.25.4 | PDF-Verarbeitung | +| WeasyPrint | 66.0 | PDF-Generierung | +| Pillow | 11.3.0 | Bildverarbeitung | +| httpx | 0.28.1 | Async HTTP Client | +| PaddleOCR | 3.x | OCR (optional) | + +### Frontend (Node.js) + +| Paket | Version | Zweck | +|-------|---------|-------| +| Next.js | 16.1.4 | Framework | +| React | 19.0.0 | UI Library | +| Tailwind CSS | 3.4.17 | Styling | +| TypeScript | 5.7.0 | Type Safety | + +--- + +## 8. Deployment + +**Docker-Container:** + +- `klausur-service` (Port 8086) - Backend API +- `studio-v2` (Port 3001) - Frontend + +**URLs:** + +- Frontend: `http://macmini:3001/vocab-worksheet` +- API: `http://macmini:8086/api/v1/vocab/` + +--- + +## 9. Erweiterungsmoeglichkeiten + +| Feature | Status | +|---------|--------| +| Weitere Sprachen (FR, ES) | Geplant | +| Datenbank-Persistenz | Geplant | +| Batch-Verarbeitung | Geplant | +| Woerterbuch-Integration | Idee | +| Audio-Ausspracheuebungen | Idee | + +--- + +## 10. Verwandte Dokumentation + +- [BYOEH-Architecture.md](./BYOEH-Architecture.md) +- [OCR-Labeling-Spec.md](./OCR-Labeling-Spec.md) +- [DSGVO-Audit-OCR-Labeling.md](./DSGVO-Audit-OCR-Labeling.md) diff --git a/klausur-service/docs/Vocab-Worksheet-Developer-Guide.md b/klausur-service/docs/Vocab-Worksheet-Developer-Guide.md new file mode 100644 index 0000000..3a9fed0 --- /dev/null +++ b/klausur-service/docs/Vocab-Worksheet-Developer-Guide.md @@ -0,0 +1,425 @@ +# Vokabel-Arbeitsblatt Generator - Entwicklerhandbuch + +**Version:** 1.0.0 +**Datum:** 2026-01-23 + +--- + +## 1. Schnellstart + +### 1.1 Lokale Entwicklung + +```bash +# Backend starten (klausur-service) +cd /Users/benjaminadmin/Projekte/breakpilot-pwa/klausur-service/backend +source venv/bin/activate +uvicorn main:app --host 0.0.0.0 --port 8086 --reload + +# Frontend starten (studio-v2) +cd /Users/benjaminadmin/Projekte/breakpilot-pwa/studio-v2 +npm run dev +``` + +### 1.2 URLs + +| Umgebung | Frontend | Backend API | +|----------|----------|-------------| +| Lokal | http://localhost:3001/vocab-worksheet | http://localhost:8086/api/v1/vocab/ | +| Mac Mini | http://macmini:3001/vocab-worksheet | http://macmini:8086/api/v1/vocab/ | + +--- + +## 2. Projektstruktur + +``` +breakpilot-pwa/ +├── klausur-service/ +│ ├── backend/ +│ │ ├── main.py # FastAPI App (inkl. Vocab-Router) +│ │ ├── vocab_worksheet_api.py # Vocab-Worksheet Endpoints +│ │ ├── hybrid_vocab_extractor.py # PaddleOCR + LLM Pipeline +│ │ └── tests/ +│ │ └── test_vocab_worksheet.py # Unit Tests +│ └── docs/ +│ ├── Vocab-Worksheet-Architecture.md +│ └── Vocab-Worksheet-Developer-Guide.md +│ +└── studio-v2/ + └── app/ + └── vocab-worksheet/ + └── page.tsx # Frontend (React/Next.js) +``` + +--- + +## 3. Backend API + +### 3.1 Endpoints-Uebersicht + +``` +POST /api/v1/vocab/sessions # Session erstellen +GET /api/v1/vocab/sessions # Sessions auflisten +GET /api/v1/vocab/sessions/{id} # Session abrufen +DELETE /api/v1/vocab/sessions/{id} # Session loeschen + +POST /api/v1/vocab/sessions/{id}/upload # Bild/PDF hochladen +POST /api/v1/vocab/sessions/{id}/upload-pdf-info # PDF-Info abrufen +GET /api/v1/vocab/sessions/{id}/pdf-thumbnail/{p} # Seiten-Thumbnail +POST /api/v1/vocab/sessions/{id}/process-single-page/{p} # Seite verarbeiten + +GET /api/v1/vocab/sessions/{id}/vocabulary # Vokabeln abrufen +PUT /api/v1/vocab/sessions/{id}/vocabulary # Vokabeln aktualisieren + +POST /api/v1/vocab/sessions/{id}/generate # Arbeitsblatt generieren +GET /api/v1/vocab/worksheets/{id}/pdf # PDF herunterladen +GET /api/v1/vocab/worksheets/{id}/solution # Loesungs-PDF +``` + +### 3.2 Session erstellen + +```bash +curl -X POST http://localhost:8086/api/v1/vocab/sessions \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Englisch Klasse 7 - Unit 3", + "description": "Vokabeln aus Green Line", + "source_language": "en", + "target_language": "de" + }' +``` + +**Response:** +```json +{ + "id": "15dce1f4-f587-4b80-8c3d-62b20e7b845c", + "name": "Englisch Klasse 7 - Unit 3", + "status": "pending", + "vocabulary_count": 0, + "created_at": "2026-01-23T10:00:00Z" +} +``` + +### 3.3 Bild hochladen + +```bash +curl -X POST http://localhost:8086/api/v1/vocab/sessions/{session_id}/upload \ + -F "file=@vokabeln.png" +``` + +### 3.4 PDF verarbeiten + +```bash +# 1. PDF hochladen und Info abrufen +curl -X POST http://localhost:8086/api/v1/vocab/sessions/{id}/upload-pdf-info \ + -F "file=@schulbuch.pdf" + +# Response: {"session_id": "...", "page_count": 5} + +# 2. Einzelne Seiten verarbeiten (empfohlen) +curl -X POST http://localhost:8086/api/v1/vocab/sessions/{id}/process-single-page/0 +curl -X POST http://localhost:8086/api/v1/vocab/sessions/{id}/process-single-page/1 +``` + +### 3.5 Vokabeln aktualisieren + +```bash +curl -X PUT http://localhost:8086/api/v1/vocab/sessions/{id}/vocabulary \ + -H "Content-Type: application/json" \ + -d '{ + "vocabulary": [ + { + "id": "uuid-1", + "english": "achieve", + "german": "erreichen", + "example_sentence": "She achieved her goals." + } + ] + }' +``` + +### 3.6 Arbeitsblatt generieren + +```bash +curl -X POST http://localhost:8086/api/v1/vocab/sessions/{id}/generate \ + -H "Content-Type: application/json" \ + -d '{ + "worksheet_types": ["en_to_de", "de_to_en"], + "include_solutions": true, + "line_height": "large" + }' +``` + +--- + +## 4. Frontend-Entwicklung + +### 4.1 Komponenten-Struktur + +Die gesamte UI ist in einer Datei (`page.tsx`) organisiert: + +```typescript +// Hauptkomponente +export default function VocabWorksheetPage() { + const [activeTab, setActiveTab] = useState('upload') + const [sessions, setSessions] = useState([]) + const [currentSession, setCurrentSession] = useState(null) + const [vocabulary, setVocabulary] = useState([]) + + // ... +} + +// Tabs +type TabType = 'upload' | 'pages' | 'vocabulary' | 'worksheet' | 'export' +``` + +### 4.2 API-Aufrufe + +```typescript +// API Base URL automatisch ermitteln +const getApiBase = () => { + if (typeof window === 'undefined') return 'http://localhost:8086' + const host = window.location.hostname + return `http://${host}:8086` +} + +// Session erstellen +const createSession = async (name: string) => { + const response = await fetch(`${getApiBase()}/api/v1/vocab/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }) + }) + return response.json() +} +``` + +### 4.3 Styling + +Tailwind CSS mit Dark/Light Theme: + +```typescript +// Theme-aware Klassen +className="bg-white dark:bg-gray-800 text-gray-900 dark:text-white" + +// Gradient-Buttons +className="bg-gradient-to-r from-purple-600 to-blue-600 hover:from-purple-700 hover:to-blue-700" +``` + +--- + +## 5. Vokabel-Extraktion + +### 5.1 Vision LLM Modus (Standard) + +```python +# vocab_worksheet_api.py + +OLLAMA_URL = os.getenv("OLLAMA_URL", "http://host.docker.internal:11434") +VISION_MODEL = os.getenv("OLLAMA_VISION_MODEL", "qwen2.5vl:32b") + +async def extract_vocabulary_from_image(image_data: bytes, filename: str): + # Base64-kodiertes Bild an Ollama senden + image_base64 = base64.b64encode(image_data).decode("utf-8") + + payload = { + "model": VISION_MODEL, + "messages": [{ + "role": "user", + "content": VOCAB_EXTRACTION_PROMPT, + "images": [image_base64] + }], + "stream": False + } + + response = await client.post(f"{OLLAMA_URL}/api/chat", json=payload) + # Parse JSON response... +``` + +### 5.2 Hybrid OCR + LLM Modus (Optional) + +```python +# hybrid_vocab_extractor.py + +async def extract_vocabulary_hybrid(image_bytes: bytes, page_number: int): + # 1. PaddleOCR fuer Text-Erkennung + regions, raw_text = run_paddle_ocr(image_bytes) + + # 2. Text fuer LLM formatieren + formatted_text = format_ocr_for_llm(regions) + + # 3. LLM strukturiert die Daten + vocabulary = await structure_vocabulary_with_llm(formatted_text) + + return vocabulary, confidence, error +``` + +### 5.3 Prompt Engineering + +Der Extraktions-Prompt ist auf Deutsch fuer bessere Ergebnisse: + +```python +VOCAB_EXTRACTION_PROMPT = """Analysiere dieses Bild einer Vokabelliste aus einem Schulbuch. + +AUFGABE: Extrahiere alle Vokabeleintraege in folgendem JSON-Format: + +{ + "vocabulary": [ + { + "english": "to improve", + "german": "verbessern", + "example": "I want to improve my English." + } + ] +} + +REGELN: +1. Erkenne das typische 3-Spalten-Layout: Englisch | Deutsch | Beispielsatz +2. Behalte die exakte Schreibweise bei +3. Bei fehlenden Beispielsaetzen: "example": null +4. Ignoriere Seitenzahlen, Ueberschriften +5. Gib NUR valides JSON zurueck +""" +``` + +--- + +## 6. Tests + +### 6.1 Tests ausfuehren + +```bash +cd /Users/benjaminadmin/Projekte/breakpilot-pwa/klausur-service/backend +source venv/bin/activate + +# Alle Tests +pytest tests/test_vocab_worksheet.py -v + +# Mit Coverage +pytest tests/test_vocab_worksheet.py --cov=vocab_worksheet_api --cov-report=html + +# Einzelne Testklasse +pytest tests/test_vocab_worksheet.py::TestSessionCRUD -v +``` + +### 6.2 Test-Kategorien + +| Klasse | Beschreibung | +|--------|--------------| +| `TestSessionCRUD` | Session erstellen, lesen, loeschen | +| `TestVocabulary` | Vokabeln abrufen, aktualisieren | +| `TestWorksheetGeneration` | Arbeitsblatt-Generierung | +| `TestJSONParsing` | LLM-Response parsing | +| `TestFileUpload` | Bild/PDF-Upload | +| `TestSessionStatus` | Status-Workflow | +| `TestEdgeCases` | Randfaelle | + +--- + +## 7. Deployment + +### 7.1 Docker Build + +```bash +# Klausur-Service neu bauen +docker compose build klausur-service + +# Studio-v2 neu bauen +docker compose build studio-v2 +``` + +### 7.2 Sync zum Mac Mini + +```bash +# Source-Files synchronisieren +rsync -avz --exclude 'node_modules' --exclude '.next' --exclude '__pycache__' \ + /Users/benjaminadmin/Projekte/breakpilot-pwa/ \ + macmini:/Users/benjaminadmin/Projekte/breakpilot-pwa/ + +# Container neu starten +ssh macmini "cd /Users/benjaminadmin/Projekte/breakpilot-pwa && docker compose up -d" +``` + +--- + +## 8. Troubleshooting + +### 8.1 Haeufige Probleme + +| Problem | Loesung | +|---------|---------| +| Ollama nicht erreichbar | `docker exec -it ollama ollama list` pruefen | +| PDF-Konvertierung schlaegt fehl | PyMuPDF installiert? `pip install PyMuPDF` | +| Vision LLM Timeout | Timeout auf 300s erhoehen | +| Leere Vokabel-Liste | Bild-Qualitaet pruefen, anderen LLM-Modus testen | + +### 8.2 Logs pruefen + +```bash +# Backend-Logs +docker logs klausur-service -f --tail 100 + +# Ollama-Logs +docker logs ollama -f --tail 100 +``` + +### 8.3 Debug-Modus + +```python +# In vocab_worksheet_api.py +import logging +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) + +# Zeigt detaillierte OCR/LLM-Ausgaben +``` + +--- + +## 9. Erweiterung + +### 9.1 Neue Sprache hinzufuegen + +1. `source_language` und `target_language` in Session-Model erweitern +2. Prompt anpassen fuer neue Sprachkombination +3. Frontend-Dropdown erweitern + +### 9.2 Neuer Arbeitsblatt-Typ + +1. `WorksheetType` Enum erweitern: + ```python + class WorksheetType(str, Enum): + # ... + CROSSWORD = "crossword" # Neu + ``` + +2. `generate_worksheet_html()` erweitern + +3. Frontend-Checkbox hinzufuegen + +### 9.3 Datenbank-Persistenz + +Aktuell: In-Memory (`_sessions` Dict) + +Fuer Produktion PostgreSQL hinzufuegen: + +```python +# models.py +class VocabSession(Base): + __tablename__ = "vocab_sessions" + id = Column(UUID, primary_key=True) + name = Column(String) + status = Column(String) + vocabulary = Column(JSON) + # ... +``` + +--- + +## 10. API-Referenz + +Vollstaendige OpenAPI-Dokumentation verfuegbar unter: + +- **Swagger UI:** http://macmini:8086/docs +- **ReDoc:** http://macmini:8086/redoc + +Filter nach Tag `Vocabulary Worksheets` fuer alle Vocab-Endpoints. diff --git a/klausur-service/docs/Worksheet-Editor-Architecture.md b/klausur-service/docs/Worksheet-Editor-Architecture.md new file mode 100644 index 0000000..6ffc911 --- /dev/null +++ b/klausur-service/docs/Worksheet-Editor-Architecture.md @@ -0,0 +1,410 @@ +# Visual Worksheet Editor - Architecture Documentation + +**Version:** 1.0 +**Datum:** 2026-01-23 +**Status:** Implementiert + +## 1. Übersicht + +Der Visual Worksheet Editor ist ein Canvas-basierter Editor für die Erstellung und Bearbeitung von Arbeitsblättern. Er ermöglicht Lehrern, eingescannte Arbeitsblätter originalgetreu zu rekonstruieren oder neue Arbeitsblätter visuell zu gestalten. + +### 1.1 Hauptfunktionen + +- **Canvas-basiertes Editieren** mit Fabric.js +- **Freie Positionierung** von Text, Bildern und Formen +- **Typografie-Steuerung** (Schriftarten, Größen, Stile) +- **Bilder & Grafiken** hochladen und einfügen +- **KI-generierte Bilder** via Ollama/Stable Diffusion +- **PDF/Bild-Export** für Druck und digitale Nutzung +- **Mehrseitige Dokumente** mit Seitennavigation + +### 1.2 Technologie-Stack + +| Komponente | Technologie | Lizenz | +|------------|-------------|--------| +| Canvas-Bibliothek | Fabric.js 6.x | MIT | +| PDF-Export | pdf-lib 1.17.x | MIT | +| Frontend | Next.js / React | MIT | +| Backend API | FastAPI | MIT | +| KI-Bilder | Ollama + Stable Diffusion | Apache 2.0 / MIT | + +## 2. Architektur + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ Frontend (studio-v2 / Next.js) │ +│ /studio-v2/app/worksheet-editor/page.tsx │ +│ │ +│ ┌─────────────┐ ┌────────────────────────────┐ ┌────────────────┐ │ +│ │ Toolbar │ │ Fabric.js Canvas │ │ Properties │ │ +│ │ (Links) │ │ (Mitte - 60%) │ │ Panel │ │ +│ │ │ │ │ │ (Rechts) │ │ +│ │ - Select │ │ ┌──────────────────────┐ │ │ │ │ +│ │ - Text │ │ │ │ │ │ - Schriftart │ │ +│ │ - Formen │ │ │ A4 Arbeitsfläche │ │ │ - Größe │ │ +│ │ - Bilder │ │ │ mit Grid │ │ │ - Farbe │ │ +│ │ - KI-Bild │ │ │ │ │ │ - Position │ │ +│ │ - Tabelle │ │ └──────────────────────┘ │ │ - Ebene │ │ +│ └─────────────┘ └────────────────────────────┘ └────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────────────────────┐ │ +│ │ Seiten-Navigation | Zoom | Grid | Export PDF │ │ +│ └────────────────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ klausur-service (FastAPI - Port 8086) │ +│ POST /api/v1/worksheet/ai-image → Bild via Ollama generieren │ +│ POST /api/v1/worksheet/save → Worksheet speichern │ +│ GET /api/v1/worksheet/{id} → Worksheet laden │ +│ POST /api/v1/worksheet/export-pdf → PDF generieren │ +└──────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ Ollama (Port 11434) │ +│ Model: stable-diffusion oder kompatibles Text-to-Image Modell │ +│ Text-to-Image für KI-generierte Grafiken │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +## 3. Dateistruktur + +### 3.1 Frontend (studio-v2) + +``` +/studio-v2/ +├── app/ +│ └── worksheet-editor/ +│ ├── page.tsx # Haupt-Editor-Seite +│ └── types.ts # TypeScript Interfaces +│ +├── components/ +│ └── worksheet-editor/ +│ ├── index.ts # Exports +│ ├── FabricCanvas.tsx # Fabric.js Canvas Wrapper +│ ├── EditorToolbar.tsx # Werkzeugleiste (links) +│ ├── PropertiesPanel.tsx # Eigenschaften-Panel (rechts) +│ ├── AIImageGenerator.tsx # KI-Bild Generator Modal +│ ├── CanvasControls.tsx # Zoom, Grid, Seiten +│ ├── ExportPanel.tsx # PDF/Bild Export +│ └── PageNavigator.tsx # Mehrseitige Dokumente +│ +├── lib/ +│ └── worksheet-editor/ +│ ├── index.ts # Exports +│ └── WorksheetContext.tsx # State Management +``` + +### 3.2 Backend (klausur-service) + +``` +/klausur-service/backend/ +├── worksheet_editor_api.py # API Endpoints +└── main.py # Router-Registrierung +``` + +## 4. API Endpoints + +### 4.1 KI-Bild generieren + +```http +POST /api/v1/worksheet/ai-image +Content-Type: application/json + +{ + "prompt": "Ein freundlicher Cartoon-Hund der ein Buch liest", + "style": "cartoon", + "width": 512, + "height": 512 +} +``` + +**Response:** +```json +{ + "image_base64": "data:image/png;base64,...", + "prompt_used": "...", + "error": null +} +``` + +**Styles:** +- `realistic` - Fotorealistisch +- `cartoon` - Cartoon/Comic +- `sketch` - Handgezeichnete Skizze +- `clipart` - Einfache Clipart-Grafiken +- `educational` - Bildungs-Illustrationen + +### 4.2 Worksheet speichern + +```http +POST /api/v1/worksheet/save +Content-Type: application/json + +{ + "id": "optional-existing-id", + "title": "Englisch Vokabeln Unit 3", + "pages": [ + { "id": "page_1", "index": 0, "canvasJSON": "{...}" } + ], + "pageFormat": { + "width": 210, + "height": 297, + "orientation": "portrait" + } +} +``` + +### 4.3 Worksheet laden + +```http +GET /api/v1/worksheet/{id} +``` + +### 4.4 PDF exportieren + +```http +POST /api/v1/worksheet/{id}/export-pdf +``` + +**Response:** PDF-Datei als Download + +### 4.5 Worksheets auflisten + +```http +GET /api/v1/worksheet/list/all +``` + +## 5. Komponenten + +### 5.1 FabricCanvas + +Die Kernkomponente für den Canvas-Bereich: + +- **A4-Format**: 794 x 1123 Pixel (96 DPI) +- **Grid-Overlay**: Optionales Raster mit Snap-Funktion +- **Zoom/Pan**: Mausrad und Controls +- **Selection**: Einzel- und Mehrfachauswahl +- **Keyboard Shortcuts**: Del, Ctrl+C/V/Z/D + +### 5.2 EditorToolbar + +Werkzeuge für die Bearbeitung: + +| Icon | Tool | Beschreibung | +|------|------|--------------| +| 🖱️ | Select | Elemente auswählen/verschieben | +| T | Text | Text hinzufügen (IText) | +| ▭ | Rechteck | Rechteck zeichnen | +| ○ | Kreis | Kreis/Ellipse zeichnen | +| ― | Linie | Linie zeichnen | +| → | Pfeil | Pfeil zeichnen | +| 🖼️ | Bild | Bild hochladen | +| ✨ | KI-Bild | Bild mit KI generieren | +| ⊞ | Tabelle | Tabelle einfügen | + +### 5.3 PropertiesPanel + +Eigenschaften-Editor für ausgewählte Objekte: + +**Text-Eigenschaften:** +- Schriftart (Arial, Times, Georgia, OpenDyslexic, Schulschrift) +- Schriftgröße (8-120pt) +- Schriftstil (Normal, Fett, Kursiv) +- Zeilenhöhe, Zeichenabstand +- Textausrichtung +- Textfarbe + +**Form-Eigenschaften:** +- Füllfarbe +- Rahmenfarbe und -stärke +- Eckenradius + +**Allgemein:** +- Deckkraft +- Löschen-Button + +### 5.4 WorksheetContext + +React Context für globalen State: + +```typescript +interface WorksheetContextType { + canvas: Canvas | null + document: WorksheetDocument | null + activeTool: EditorTool + selectedObjects: FabricObject[] + zoom: number + showGrid: boolean + snapToGrid: boolean + currentPageIndex: number + canUndo: boolean + canRedo: boolean + isDirty: boolean + // ... Methoden +} +``` + +## 6. Datenmodelle + +### 6.1 WorksheetDocument + +```typescript +interface WorksheetDocument { + id: string + title: string + description?: string + pages: WorksheetPage[] + pageFormat: PageFormat + createdAt: string + updatedAt: string +} +``` + +### 6.2 WorksheetPage + +```typescript +interface WorksheetPage { + id: string + index: number + canvasJSON: string // Serialisierter Fabric.js Canvas + thumbnail?: string +} +``` + +### 6.3 PageFormat + +```typescript +interface PageFormat { + width: number // in mm (Standard: 210) + height: number // in mm (Standard: 297) + orientation: 'portrait' | 'landscape' + margins: { top, right, bottom, left: number } +} +``` + +## 7. Features + +### 7.1 Undo/Redo + +- History-Stack mit max. 50 Einträgen +- Automatische Speicherung bei jeder Änderung +- Keyboard: Ctrl+Z (Undo), Ctrl+Y (Redo) + +### 7.2 Grid & Snap + +- Konfigurierbares Raster (5mm, 10mm, 15mm, 20mm) +- Snap-to-Grid beim Verschieben +- Ein-/Ausblendbar + +### 7.3 Export + +- **PDF**: Mehrseitig, A4-Format +- **PNG**: Hochauflösend (2x Multiplier) +- **JPG**: Mit Qualitätseinstellung + +### 7.4 Speicherung + +- **Backend**: REST API mit JSON-Persistierung +- **Fallback**: localStorage bei Offline-Betrieb + +## 8. KI-Bildgenerierung + +### 8.1 Ollama Integration + +Der Editor nutzt Ollama für die KI-Bildgenerierung: + +```python +OLLAMA_URL = "http://host.docker.internal:11434" +``` + +### 8.2 Placeholder-System + +Falls Ollama nicht verfügbar ist, wird ein Placeholder-Bild generiert: +- Farbcodiert nach Stil +- Prompt-Text als Beschreibung +- "KI-Bild (Platzhalter)"-Badge + +### 8.3 Stil-Prompts + +Jeder Stil fügt automatisch Modifikatoren zum Prompt hinzu: + +```python +STYLE_PROMPTS = { + "realistic": "photorealistic, high detail", + "cartoon": "cartoon style, colorful, child-friendly", + "sketch": "pencil sketch, hand-drawn", + "clipart": "clipart style, flat design", + "educational": "educational illustration, textbook style" +} +``` + +## 9. Glassmorphism Design + +Der Editor folgt dem Glassmorphism-Design des Studio v2: + +```typescript +// Dark Theme +'backdrop-blur-xl bg-white/10 border border-white/20' + +// Light Theme +'backdrop-blur-xl bg-white/70 border border-black/10 shadow-xl' +``` + +## 10. Internationalisierung + +Unterstützte Sprachen: +- 🇩🇪 Deutsch +- 🇬🇧 English +- 🇹🇷 Türkçe +- 🇸🇦 العربية (RTL) +- 🇷🇺 Русский +- 🇺🇦 Українська +- 🇵🇱 Polski + +Translation Key: `nav_worksheet_editor` + +## 11. Sicherheit + +### 11.1 Bild-Upload + +- Nur Bildformate (image/*) +- Client-seitige Validierung +- Base64-Konvertierung + +### 11.2 CORS + +Aktiviert für lokale Entwicklung und Docker-Umgebung. + +## 12. Deployment + +### 12.1 Frontend + +```bash +cd studio-v2 +npm install +npm run dev # Port 3001 +``` + +### 12.2 Backend + +Der klausur-service läuft auf Port 8086: + +```bash +cd klausur-service/backend +python main.py +``` + +### 12.3 Docker + +Der Service ist Teil des docker-compose.yml. + +## 13. Zukünftige Erweiterungen + +- [ ] Tabellen-Tool mit Zellbearbeitung +- [ ] Vorlagen-Bibliothek +- [ ] Kollaboratives Editieren +- [ ] Drag & Drop aus Dokumentenbibliothek +- [ ] Integration mit Vocab-Worksheet diff --git a/klausur-service/docs/Worksheet-Editor-Developer-Guide.md b/klausur-service/docs/Worksheet-Editor-Developer-Guide.md new file mode 100644 index 0000000..f615331 --- /dev/null +++ b/klausur-service/docs/Worksheet-Editor-Developer-Guide.md @@ -0,0 +1,480 @@ +# Visual Worksheet Editor - Developer Guide + +**Version:** 1.0 +**Datum:** 2026-01-23 + +## 1. Schnellstart + +### 1.1 Dependencies installieren + +```bash +cd studio-v2 +npm install fabric@^6.0.0 pdf-lib@^1.17.1 +``` + +### 1.2 Entwicklungsserver starten + +```bash +# Frontend (Port 3001) +cd studio-v2 +npm run dev + +# Backend (Port 8086) +cd klausur-service/backend +source venv/bin/activate +python main.py +``` + +### 1.3 Editor öffnen + +``` +http://localhost:3001/worksheet-editor +``` + +## 2. Komponenten-Entwicklung + +### 2.1 Neues Werkzeug hinzufügen + +1. **Tool-Typ definieren** in `types.ts`: + +```typescript +export type EditorTool = + | 'select' + | 'text' + // ... existierende Tools + | 'neues-tool' // NEU +``` + +2. **Button hinzufügen** in `EditorToolbar.tsx`: + +```tsx + handleToolClick('neues-tool')} + isDark={isDark} + label="Neues Tool" + icon={...} +/> +``` + +3. **Handler implementieren** in `FabricCanvas.tsx`: + +```typescript +case 'neues-tool': { + // Canvas-Objekt erstellen + const obj = new fabric.CustomObject({...}) + fabricCanvas.add(obj) + fabricCanvas.setActiveObject(obj) + setActiveTool('select') + break +} +``` + +### 2.2 Eigenschaften-Panel erweitern + +In `PropertiesPanel.tsx` neue Eigenschaften für einen Objekttyp hinzufügen: + +```tsx +{isMyType && ( +
              +
              + + { + setMyProperty(e.target.value) + updateProperty('myProperty', e.target.value) + }} + className={`w-full px-3 py-2 rounded-xl border text-sm ${inputStyle}`} + /> +
              +
              +)} +``` + +## 3. Context API + +### 3.1 State abrufen + +```tsx +import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext' + +function MyComponent() { + const { + canvas, + activeTool, + setActiveTool, + selectedObjects, + zoom, + setZoom + } = useWorksheet() + + // Verwenden... +} +``` + +### 3.2 Canvas-Operationen + +```typescript +// Objekt hinzufügen +canvas.add(newObject) +canvas.setActiveObject(newObject) +canvas.renderAll() + +// Objekt entfernen +canvas.remove(selectedObject) + +// Alle Objekte abrufen (ohne Grid) +const objects = canvas.getObjects().filter(obj => !obj.isGrid) + +// Canvas exportieren +const json = canvas.toJSON() +const dataUrl = canvas.toDataURL({ format: 'png', multiplier: 2 }) + +// Canvas laden +canvas.loadFromJSON(jsonData, () => { + canvas.renderAll() +}) +``` + +## 4. API-Integration + +### 4.1 Worksheet speichern + +```typescript +const saveWorksheet = async () => { + const host = window.location.hostname + const apiBase = `http://${host}:8086` + + const response = await fetch(`${apiBase}/api/v1/worksheet/save`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + id: worksheetId, + title: 'Mein Arbeitsblatt', + pages: [{ + id: 'page_1', + index: 0, + canvasJSON: JSON.stringify(canvas.toJSON()) + }] + }) + }) + + const result = await response.json() + console.log('Gespeichert:', result.id) +} +``` + +### 4.2 KI-Bild generieren + +```typescript +const generateAIImage = async (prompt: string) => { + const host = window.location.hostname + const apiBase = `http://${host}:8086` + + const response = await fetch(`${apiBase}/api/v1/worksheet/ai-image`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt, + style: 'educational', + width: 512, + height: 512 + }) + }) + + const { image_base64, error } = await response.json() + + if (image_base64) { + // Bild zum Canvas hinzufügen + fabric.Image.fromURL(image_base64, (img) => { + canvas.add(img) + canvas.setActiveObject(img) + canvas.renderAll() + }) + } +} +``` + +## 5. Fabric.js Patterns + +### 5.1 Text-Objekt erstellen + +```typescript +const text = new fabric.IText('Text eingeben', { + left: 100, + top: 100, + fontFamily: 'Arial', + fontSize: 16, + fill: '#000000', +}) +canvas.add(text) +text.enterEditing() // Bearbeitungsmodus +``` + +### 5.2 Form erstellen + +```typescript +// Rechteck +const rect = new fabric.Rect({ + left: 100, + top: 100, + width: 200, + height: 100, + fill: 'transparent', + stroke: '#000000', + strokeWidth: 2, + rx: 5, // Eckenradius + ry: 5, +}) + +// Kreis +const circle = new fabric.Circle({ + left: 100, + top: 100, + radius: 50, + fill: '#ff6b6b', + stroke: '#000000', + strokeWidth: 2, +}) + +// Linie +const line = new fabric.Line([50, 50, 200, 50], { + stroke: '#000000', + strokeWidth: 2, +}) +``` + +### 5.3 Bild laden + +```typescript +fabric.Image.fromURL(imageUrl, (img) => { + // Skalierung auf max. Größe + const maxWidth = 400 + const maxHeight = 300 + const scale = Math.min(maxWidth / img.width, maxHeight / img.height, 1) + + img.set({ + left: 100, + top: 100, + scaleX: scale, + scaleY: scale, + }) + + canvas.add(img) +}, { crossOrigin: 'anonymous' }) +``` + +### 5.4 Events + +```typescript +// Selection Events +canvas.on('selection:created', (e) => { + const selected = canvas.getActiveObjects() + console.log('Ausgewählt:', selected.length) +}) + +canvas.on('selection:cleared', () => { + console.log('Auswahl aufgehoben') +}) + +// Object Events +canvas.on('object:modified', (e) => { + console.log('Objekt geändert:', e.target) + saveToHistory('modified') +}) + +canvas.on('object:added', (e) => { + console.log('Objekt hinzugefügt:', e.target) +}) + +// Mouse Events +canvas.on('mouse:down', (e) => { + const pointer = canvas.getPointer(e.e) + console.log('Klick bei:', pointer.x, pointer.y) +}) +``` + +## 6. Testing + +### 6.1 Unit Tests für Context + +```typescript +// __tests__/worksheet-context.test.tsx +import { renderHook, act } from '@testing-library/react' +import { WorksheetProvider, useWorksheet } from '../WorksheetContext' + +describe('useWorksheet', () => { + it('should initialize with default values', () => { + const { result } = renderHook(() => useWorksheet(), { + wrapper: WorksheetProvider, + }) + + expect(result.current.activeTool).toBe('select') + expect(result.current.zoom).toBe(1) + expect(result.current.showGrid).toBe(true) + }) + + it('should change tool', () => { + const { result } = renderHook(() => useWorksheet(), { + wrapper: WorksheetProvider, + }) + + act(() => { + result.current.setActiveTool('text') + }) + + expect(result.current.activeTool).toBe('text') + }) +}) +``` + +### 6.2 API Tests + +```python +# tests/test_worksheet_editor_api.py +import pytest +from fastapi.testclient import TestClient +from main import app + +client = TestClient(app) + +def test_save_worksheet(): + response = client.post("/api/v1/worksheet/save", json={ + "title": "Test Worksheet", + "pages": [{ + "id": "page_1", + "index": 0, + "canvasJSON": "{}" + }] + }) + assert response.status_code == 200 + assert "id" in response.json() + +def test_get_worksheet(): + # Erst erstellen + create_response = client.post("/api/v1/worksheet/save", json={ + "title": "Test", + "pages": [{"id": "p1", "index": 0, "canvasJSON": "{}"}] + }) + worksheet_id = create_response.json()["id"] + + # Dann laden + response = client.get(f"/api/v1/worksheet/{worksheet_id}") + assert response.status_code == 200 + assert response.json()["title"] == "Test" + +def test_ai_image_generation(): + response = client.post("/api/v1/worksheet/ai-image", json={ + "prompt": "A friendly dog", + "style": "cartoon", + "width": 256, + "height": 256 + }) + # Kann 200 (Bild) oder 503 (Ollama nicht verfügbar) sein + assert response.status_code in [200, 503] +``` + +## 7. Styling + +### 7.1 Glassmorphism Utilities + +```typescript +// Für Theme-aware Styling +const glassCard = isDark + ? 'backdrop-blur-xl bg-white/10 border border-white/20' + : 'backdrop-blur-xl bg-white/70 border border-black/10 shadow-xl' + +const glassInput = isDark + ? 'bg-white/10 border-white/20 text-white placeholder-white/40' + : 'bg-white/50 border-black/10 text-slate-900 placeholder-slate-400' + +const labelStyle = isDark ? 'text-white/70' : 'text-slate-600' +``` + +### 7.2 Button States + +```typescript +const buttonStyle = (active: boolean) => isDark + ? active + ? 'bg-purple-500/30 text-purple-300' + : 'text-white/70 hover:bg-white/10 hover:text-white' + : active + ? 'bg-purple-100 text-purple-700' + : 'text-slate-600 hover:bg-slate-100 hover:text-slate-900' +``` + +## 8. Best Practices + +### 8.1 Performance + +- Grid-Objekte mit `isGrid: true` markieren und vom Export ausschließen +- Canvas-JSON nur bei tatsächlichen Änderungen speichern +- History auf 50 Einträge limitieren +- Bilder mit `multiplier: 2` für Retina-Export + +### 8.2 Fehlerbehandlung + +```typescript +try { + const response = await fetch(apiUrl) + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + const data = await response.json() + return data +} catch (error) { + console.error('API Error:', error) + // Fallback auf localStorage + return JSON.parse(localStorage.getItem(key) || '{}') +} +``` + +### 8.3 Hydration Safety + +```typescript +const [mounted, setMounted] = useState(false) + +useEffect(() => { + setMounted(true) +}, []) + +if (!mounted) { + return +} +``` + +## 9. Debugging + +### 9.1 Canvas-State inspizieren + +```typescript +// Im Browser DevTools Console +const canvas = document.querySelector('canvas')?.__fabric +console.log('Objects:', canvas?.getObjects()) +console.log('Active:', canvas?.getActiveObject()) +console.log('JSON:', canvas?.toJSON()) +``` + +### 9.2 API-Calls überwachen + +```bash +# Backend-Logs +tail -f /var/log/klausur-service.log + +# Oder im Terminal wo main.py läuft +``` + +## 10. Deployment Checklist + +- [ ] Dependencies in package.json +- [ ] Fabric.js und pdf-lib installiert +- [ ] Backend-Router registriert +- [ ] i18n-Übersetzungen vorhanden +- [ ] Sidebar-Navigation aktualisiert +- [ ] CORS für Produktions-Domain konfiguriert +- [ ] Ollama-URL in Umgebungsvariablen diff --git a/klausur-service/docs/legal_corpus/EPRIVACY.txt b/klausur-service/docs/legal_corpus/EPRIVACY.txt new file mode 100644 index 0000000..0f12c5c --- /dev/null +++ b/klausur-service/docs/legal_corpus/EPRIVACY.txt @@ -0,0 +1,604 @@ +ePrivacy-Richtlinie (Richtlinie 2002/58/EG) +Datenschutz in der elektronischen Kommunikation + +======================================== +GRUNDLAGEN +======================================== + +Was ist die ePrivacy-Richtlinie? + +Die ePrivacy-Richtlinie (Richtlinie 2002/58/EG) ist eine EU-Richtlinie, die spezifische Datenschutzregeln fuer den Bereich der elektronischen Kommunikation festlegt. Sie ergaenzt die DSGVO als "lex specialis" fuer diesen Bereich. + +Offizieller Titel: "Richtlinie 2002/58/EG des Europaeischen Parlaments und des Rates vom 12. Juli 2002 ueber die Verarbeitung personenbezogener Daten und den Schutz der Privatsphaere in der elektronischen Kommunikation" + +Die Richtlinie wurde mehrfach geaendert: +- 2006 durch Richtlinie 2006/24/EG (Vorratsdatenspeicherung, spaeter aufgehoben) +- 2009 durch Richtlinie 2009/136/EG ("Cookie-Richtlinie") + +WICHTIG: Die ePrivacy-Verordnung (ePVO) soll die Richtlinie ersetzen, ist aber Stand 2026 noch nicht in Kraft getreten. + +Anwendungsbereich der ePrivacy-Richtlinie + +Die ePrivacy-Richtlinie gilt fuer: + +1. ANBIETER OEFFENTLICHER KOMMUNIKATIONSDIENSTE + - Telekommunikationsunternehmen + - Internet Service Provider + - E-Mail-Dienste + - Messenger-Dienste (umstritten) + +2. BETREIBER VON WEBSITES UND APPS + - Cookies und aehnliche Technologien + - Online-Tracking + - Direktwerbung per E-Mail + +3. JEDE VERARBEITUNG VON + - Verkehrsdaten + - Standortdaten + - Kommunikationsinhalten + +NICHT anwendbar auf: +- Rein unternehmensinterne Kommunikationssysteme +- Nationale Sicherheit und Strafverfolgung (Ausnahmen) + +Verhaeltnis zur DSGVO + +Die ePrivacy-Richtlinie steht in einem besonderen Verhaeltnis zur DSGVO: + +GRUNDSATZ (Art. 95 DSGVO): +Die DSGVO erlegt Anbietern oeffentlicher Kommunikationsdienste keine zusaetzlichen Pflichten auf, soweit die ePrivacy-Richtlinie dieselbe Zielsetzung verfolgt. + +PRAKTISCHE BEDEUTUNG: + +1. ePrivacy als "lex specialis" + - Fuer elektronische Kommunikation gelten primaer ePrivacy-Regeln + - DSGVO gilt ergaenzend, wo ePrivacy keine Regelung trifft + +2. Cookie-Consent + - Art. 5 Abs. 3 ePrivacy regelt Cookies VORRANGIG + - DSGVO-Einwilligung gilt ZUSAETZLICH fuer personenbezogene Daten + +3. Sanktionen + - DSGVO-Bussgelder (bis 20 Mio. / 4% Umsatz) gelten NICHT direkt + - Nationale Umsetzungsgesetze haben eigene Sanktionen + +WICHTIG: Bei Cookies ist BEIDES erforderlich: +- ePrivacy-Einwilligung (fuer Zugriff auf Geraet) +- DSGVO-Rechtsgrundlage (fuer Verarbeitung personenbezogener Daten) + +======================================== +COOKIES UND TRACKING (Art. 5 Abs. 3) +======================================== + +Cookie-Einwilligungsregel (Art. 5 Abs. 3) + +Art. 5 Abs. 3 der ePrivacy-Richtlinie regelt den Zugriff auf Endgeraete: + +GRUNDSATZ: +Die Speicherung von Informationen oder der Zugriff auf bereits gespeicherte Informationen im Endgeraet eines Nutzers ist NUR zulaessig, wenn: + +1. Der Nutzer VORHER informiert wurde (Transparenz) +2. Der Nutzer seine EINWILLIGUNG gegeben hat (Opt-In) + +AUSNAHMEN (KEINE Einwilligung erforderlich): + +a) TECHNISCH NOTWENDIGE COOKIES + - Fuer die Uebertragung einer Nachricht erforderlich + - Beispiel: Load Balancer Cookies + +b) UNBEDINGT ERFORDERLICHE COOKIES + - Vom Nutzer ausdruecklich gewuenscht + - Fuer einen Dienst, den der Nutzer ausdruecklich angefordert hat + - Beispiele: + * Warenkorb-Cookies + * Login-Session-Cookies + * Spracheinstellungen + * Cookie-Consent-Cookie selbst + +WICHTIG: Die Ausnahmen sind ENG auszulegen! +- Analytics-Cookies: KEINE Ausnahme, Einwilligung erforderlich +- Marketing-Cookies: KEINE Ausnahme, Einwilligung erforderlich +- Social Media Plugins: KEINE Ausnahme, Einwilligung erforderlich + +Anforderungen an Cookie-Einwilligung + +Die Einwilligung nach Art. 5 Abs. 3 ePrivacy muss den DSGVO-Standards entsprechen (Verweis auf Definition in DSGVO): + +ANFORDERUNGEN: + +1. FREIWILLIG + - Keine Nachteile bei Ablehnung + - Kein "Cookie Wall" (umstritten, nationale Unterschiede) + - Gleichwertige Ablehnungsoption + +2. INFORMIERT + - Klare Information VORHER + - Welche Cookies, welcher Zweck + - Wer erhaelt Zugriff (Dritte) + - Speicherdauer + +3. AKTIVE HANDLUNG + - Opt-In erforderlich (EuGH Planet49) + - Vorausgewaehlte Checkboxen sind UNGUELTIG + - Weitersurfen ist KEINE Einwilligung + +4. SPEZIFISCH + - Getrennte Einwilligung pro Zweck + - "Alle akzeptieren" muss gleichwertig zu "Alle ablehnen" sein + +5. WIDERRUFBAR + - Jederzeitiger Widerruf muss moeglich sein + - So einfach wie die Erteilung + +CONSENT MANAGEMENT PLATFORM (CMP): +Professionelle Cookie-Banner muessen: +- Alle Kategorien einzeln anwaehlbar machen +- "Ablehnen" gleichwertig prominent anbieten +- Consent dokumentieren (Nachweis) +- Widerruf ermoeglichen + +Cookie-Kategorien und Einwilligungspflicht + +Uebersicht der Cookie-Kategorien und Einwilligungspflicht: + +KATEGORIE 1: TECHNISCH NOTWENDIG (KEINE Einwilligung) +- Session-Cookies fuer Login +- Warenkorb-Cookies +- Load-Balancer-Cookies +- CSRF-Token-Cookies +- Cookie-Consent-Cookie +- Spracheinstellungs-Cookies +- Barrierefreiheits-Cookies + +KATEGORIE 2: FUNKTIONAL (Einwilligung ERFORDERLICH) +- Praeferenz-Cookies (Design, Layout) +- Video-Player-Einstellungen +- Chat-Widget-Cookies +- Formular-Autofill-Cookies + +KATEGORIE 3: ANALYTICS (Einwilligung ERFORDERLICH) +- Google Analytics +- Matomo/Piwik +- Hotjar, Crazy Egg +- Performance-Messung + +SONDERFALL: Analytics ohne Einwilligung (UMSTRITTEN!) +- Matomo ohne Cookies und mit IP-Anonymisierung +- Serverseitige Analytics +- Aggregierte Statistiken +- Nationale Behoerden haben unterschiedliche Meinungen! + +KATEGORIE 4: MARKETING/WERBUNG (Einwilligung ERFORDERLICH) +- Retargeting-Cookies +- Google Ads/Meta Pixel +- Affiliate-Tracking +- Cross-Site-Tracking + +KATEGORIE 5: SOCIAL MEDIA (Einwilligung ERFORDERLICH) +- Facebook Like Button +- Twitter Widgets +- LinkedIn Plugins +- Embedded Content von Dritten + +Szenario: Lokale KI-Anwendung (On-Premises) + +Bei einer lokalen KI-Anwendung wie BreakPilot (On-Premises auf Mac Studio): + +GRUNDSAETZLICH: + +1. KEIN externer Cookie-Zugriff + - Alle Verarbeitung lokal auf Schulserver + - Keine Cookies an Dritte + - Keine Tracking-Pixel + +2. TECHNISCH NOTWENDIGE COOKIES + - Session-Cookies fuer Login: KEINE Einwilligung + - CSRF-Schutz: KEINE Einwilligung + - Benutzereinstellungen (Sprache): Grauzone, besser Einwilligung + +3. ANALYTICS + - Interne Nutzungsstatistiken (serverseitig): Kein ePrivacy-Problem + - Falls Cookie-basiert: Einwilligung erforderlich + - Empfehlung: Serverseitige Logs statt Cookies + +EMPFEHLUNG FUER BREAKPILOT: +- Nur Session-Cookies fuer Login verwenden +- Keine Analytics-Cookies +- Keine Third-Party-Einbindungen +- Einfaches Cookie-Banner mit Hinweis auf notwendige Cookies +- Datenschutzerklaerung mit Cookie-Informationen + +VORTEIL: +Durch rein lokale Verarbeitung entfallen die meisten ePrivacy-Probleme automatisch! + +======================================== +VERKEHRSDATEN (Art. 6) +======================================== + +Was sind Verkehrsdaten? + +Verkehrsdaten (Art. 2 lit. b) sind Daten, die zum Zwecke der Weiterleitung einer Nachricht oder zum Zwecke der Fakturierung verarbeitet werden: + +BEISPIELE: + +1. Bei TELEFONIE + - Rufnummern (Anrufer und Angerufener) + - Datum und Uhrzeit + - Dauer des Gespraechs + - Art des Dienstes (Sprache, SMS) + +2. Bei INTERNET + - IP-Adressen (dynamisch und statisch) + - Zeitpunkt der Verbindung + - Datenvolumen + - Geraetekennungen (MAC-Adresse, IMEI) + +3. Bei E-MAIL + - E-Mail-Adressen (Sender, Empfaenger) + - Zeitstempel + - Betreffzeile (umstritten - eher Inhaltsdaten) + +ABGRENZUNG: +- INHALTSDATEN: Der eigentliche Inhalt der Kommunikation (strenger Schutz) +- VERKEHRSDATEN: Metadaten der Kommunikation (weniger streng) +- STANDORTDATEN: Geografische Position (gesondert geregelt) + +Verarbeitung von Verkehrsdaten (Art. 6) + +Die Verarbeitung von Verkehrsdaten ist streng geregelt: + +GRUNDSATZ (Art. 6 Abs. 1): +Verkehrsdaten muessen GELOESCHT oder ANONYMISIERT werden, sobald sie fuer die Uebertragung nicht mehr benoetigt werden. + +AUSNAHMEN: + +1. ABRECHNUNG (Art. 6 Abs. 2) + - Verarbeitung fuer Rechnungsstellung zulaessig + - Nur bis Ende der Frist fuer Rechnungsanfechtung + - In Deutschland: 6 Monate + +2. VERMARKTUNG VON DIENSTEN (Art. 6 Abs. 3) + - Nur mit EINWILLIGUNG des Teilnehmers + - Nur fuer Vermarktung von Telekommunikationsdiensten + - Jederzeit widerrufbar + +3. MEHRWERTDIENSTE (Art. 6 Abs. 4) + - Mit Einwilligung fuer elektronische Mehrwertdienste + - Nutzer muss informiert werden + - Zeitlicher Rahmen definiert + +WICHTIG FUER ANBIETER: +- Technische Vorkehrungen zur automatischen Loeschung +- Dokumentation der Loeschfristen +- Keine Speicherung "auf Vorrat" ohne Rechtsgrundlage + +======================================== +STANDORTDATEN (Art. 9) +======================================== + +Verarbeitung von Standortdaten (Art. 9) + +Standortdaten, die ueber Verkehrsdaten hinausgehen, unterliegen besonderen Regeln nach Art. 9: + +DEFINITION (Art. 2 lit. c): +Daten, die den geografischen Standort des Endgeraets eines Nutzers angeben. + +GRUNDSATZ: +Verarbeitung von Standortdaten NUR zulaessig wenn: +- Anonymisiert, ODER +- Mit EINWILLIGUNG des Nutzers + +ANFORDERUNGEN BEI EINWILLIGUNG: + +1. VOR der Verarbeitung einzuholen +2. Umfang und Dauer der Verarbeitung angeben +3. Zweck der Verarbeitung angeben +4. Ob Daten an Dritte weitergegeben werden +5. Widerruf jederzeit moeglich + +PRAKTISCHE ANWENDUNG: + +- Navigationsdienste: Einwilligung erforderlich +- Standortbasierte Werbung: Einwilligung erforderlich +- Flottenmanagement: Einwilligung der Fahrer +- Find-my-Device: Einwilligung (oft Teil der Nutzungsbedingungen) + +SONDERFALL: Notrufe +Standortdaten duerfen fuer Notrufdienste ohne Einwilligung verarbeitet werden (Art. 10). + +======================================== +UNERBETENE NACHRICHTEN - SPAM (Art. 13) +======================================== + +E-Mail-Marketing und Direktwerbung (Art. 13) + +Art. 13 regelt die Verwendung elektronischer Kommunikation fuer Direktwerbung: + +GRUNDSATZ (Opt-In): +Die Verwendung von E-Mail, SMS, Fax oder automatischen Anrufsystemen fuer Direktwerbung ist NUR zulaessig mit VORHERIGER EINWILLIGUNG. + +AUSNAHME - BESTANDSKUNDEN (Art. 13 Abs. 2): +E-Mail-Werbung OHNE Einwilligung ist zulaessig wenn ALLE Bedingungen erfuellt: + +1. Der Absender hat die E-Mail-Adresse vom Kunden selbst erhalten +2. Im Zusammenhang mit einem KAUF von Waren/Dienstleistungen +3. Die Werbung bezieht sich auf AEHNLICHE Produkte/Dienstleistungen +4. Der Kunde hatte bei Erhebung die Moeglichkeit zu widersprechen +5. Bei JEDER weiteren Nachricht: Widerspruchsmoeglichkeit (Opt-Out) + +WICHTIG: Die Ausnahme ist ENG auszulegen! +- Newsletter: Einwilligung erforderlich (kein "aehnliches Produkt") +- Werbung fuer Dritte: Einwilligung erforderlich +- B2B-Kaltakquise per E-Mail: Umstritten, nationale Unterschiede + +TELEFON-WERBUNG: +- Automatische Anrufsysteme: Immer Einwilligung +- Manuelle Anrufe: Nationale Regelung (in D: Einwilligung erforderlich) + +ABSENDERKENNUNG: +Die Identitaet des Absenders darf NICHT verschleiert werden! +Eine gueltige Antwortadresse muss vorhanden sein. + +Double Opt-In fuer Newsletter + +Das Double Opt-In Verfahren ist Best Practice fuer Newsletter-Anmeldungen: + +ABLAUF: + +1. Nutzer gibt E-Mail-Adresse ein (Single Opt-In) +2. System sendet Bestaetigungs-E-Mail mit Link +3. Nutzer klickt Link zur Bestaetigung (Double Opt-In) +4. Erst dann: Eintrag in Newsletter-Liste + +VORTEILE: +- Nachweis der Einwilligung +- Schutz vor Missbrauch (fremde E-Mail-Adressen) +- Reduziert Spam-Beschwerden +- Bessere Zustellraten + +ANFORDERUNGEN AN BESTAETIGUNGS-E-MAIL: +- KEINE Werbung enthalten (nur Bestaetigung) +- Klarer Hinweis auf den Zweck +- Bestaetigung muss aktiv erfolgen +- Protokollierung: IP, Zeitstempel, User-Agent + +RECHTLICHE EINORDNUNG: +- Die Bestaetigungs-E-Mail selbst ist KEINE Werbung +- Aber: Nur EINE Erinnerung zulaessig +- Nach Nicht-Bestaetigung: Adresse loeschen + +SPEICHERDAUER NACHWEIS: +- Einwilligungsnachweis aufbewahren +- Mindestens bis Widerruf + Verjaehrungsfrist +- In Deutschland: 3 Jahre empfohlen + +======================================== +KOMMUNIKATIONSGEHEIMNIS (Art. 5) +======================================== + +Vertraulichkeit der Kommunikation (Art. 5 Abs. 1) + +Art. 5 Abs. 1 schuetzt die Vertraulichkeit elektronischer Kommunikation: + +GRUNDSATZ: +Die Mitgliedstaaten stellen die Vertraulichkeit der mit oeffentlichen Kommunikationsnetzen uebertragenen Nachrichten sicher. + +VERBOTEN IST: +- Abhoeren von Nachrichten +- Anzapfen von Leitungen +- Speicherung von Kommunikation durch Unbefugte +- Jede andere Art des Abfangens + +AUSNAHMEN: +- Mit Einwilligung der betroffenen Nutzer +- Gesetzlich erlaubte Ueberwachung (Strafverfolgung) +- Technische Speicherung fuer Uebertragungszwecke + +PRAKTISCHE BEDEUTUNG: + +1. ARBEITGEBER + - Abhoeren von Mitarbeiter-E-Mails problematisch + - Private Nutzung verboten = mehr Spielraum + - Betriebsvereinbarung empfohlen + +2. E-MAIL-PROVIDER + - Automatische Spam-Filter: Zulaessig (technisch notwendig) + - Werbefinanzierte Analyse: Einwilligung erforderlich + +3. MESSENGER-DIENSTE + - Ende-zu-Ende-Verschluesselung schuetzt Vertraulichkeit + - "Client-Side Scanning" (geplant) hochumstritten + +======================================== +NATIONALE UMSETZUNG (DEUTSCHLAND) +======================================== + +Umsetzung in Deutschland: TTDSG + +In Deutschland wurde die ePrivacy-Richtlinie durch das Telekommunikation-Telemedien-Datenschutz-Gesetz (TTDSG) umgesetzt. + +TTDSG (seit 01.12.2021): + +Paragraph 25 TTDSG - COOKIES UND AEHNLICHE TECHNOLOGIEN: +Entspricht Art. 5 Abs. 3 ePrivacy-Richtlinie +- Einwilligung erforderlich fuer nicht-notwendige Cookies +- Ausnahme: Technisch notwendige Speicherung/Zugriff + +Paragraph 26 TTDSG - ANERKANNTE DIENSTE (PIMS): +Personal Information Management Services +- Nutzer kann zentral Einstellungen verwalten +- Websites muessen PIMS-Signale beachten +- Noch kaum praktische Umsetzung + +WEITERE RELEVANTE GESETZE: + +TKG (Telekommunikationsgesetz): +- Paragraph 88 TKG: Fernmeldegeheimnis +- Paragraph 96ff TKG: Verkehrsdaten + +UWG (Gesetz gegen unlauteren Wettbewerb): +- Paragraph 7 UWG: Unzumutbare Belaestigungen +- Spam-Verbot, Telefon-Werbung + +SANKTIONEN (Paragraph 28 TTDSG): +- Verstoss gegen Paragraph 25: Bussgeld bis 300.000 EUR +- Verstoss gegen Paragraph 26: Bussgeld bis 50.000 EUR + +DSK Orientierungshilfe zu Telemedien + +Die Datenschutzkonferenz (DSK) hat eine Orientierungshilfe fuer Anbieter von Telemedien veroeffentlicht: + +KERNAUSSAGEN: + +1. EINWILLIGUNG + - Muss VOR dem Setzen von Cookies eingeholt werden + - Vorausgewaehlte Checkboxen sind unwirksam + - "Nur notwendige akzeptieren" muss gleichwertig sein + +2. TECHNISCH NOTWENDIG + - Enger Auslegung + - Session-Cookies: Ja + - Persistente Praeferenz-Cookies: Nein + +3. INFORMATIONSPFLICHTEN + - Zweck jedes Cookies angeben + - Speicherdauer angeben + - Dritte benennen + +4. DOKUMENTATION + - Einwilligungen dokumentieren + - Mindestens: Zeitstempel, Umfang, Version + +5. WIDERRUF + - Jederzeit moeglich + - So einfach wie Erteilung + - Link im Footer oder Cookie-Banner + +PRAXISTIPP: +Die DSK-Orientierungshilfe ist nicht rechtlich bindend, wird aber von Aufsichtsbehoerden als Massstab herangezogen. + +======================================== +EPRIVACY-VERORDNUNG (AUSBLICK) +======================================== + +ePrivacy-Verordnung (ePVO) - Ausblick + +Die ePrivacy-Verordnung (ePVO) soll die Richtlinie 2002/58/EG ersetzen: + +STATUS (Stand 2026): +- Kommissionsvorschlag: Januar 2017 +- Rat: Kein Konsens erreicht +- Mehrere Kompromissvorschlaege gescheitert +- Inkrafttreten: Weiterhin unklar + +GEPLANTE AENDERUNGEN: + +1. VERORDNUNG STATT RICHTLINIE + - Direkt anwendbar in allen Mitgliedstaaten + - Keine Umsetzung erforderlich + - Einheitliche Regeln in der EU + +2. ERWEITERTER ANWENDUNGSBEREICH + - Auch OTT-Dienste (WhatsApp, Zoom, etc.) + - Auch Maschine-zu-Maschine-Kommunikation (IoT) + +3. COOKIE-WALLS + - Verschiedene Positionen + - Evtl. unter bestimmten Bedingungen zulaessig + +4. BROWSER-EINSTELLUNGEN + - "Privacy by Default" im Browser + - Zentrale Einwilligungsverwaltung + +5. HARMONISIERTE SANKTIONEN + - DSGVO-aehnliche Bussgelder + +BIS ZUR EPVO: +Die Richtlinie 2002/58/EG und nationale Umsetzungen bleiben in Kraft! + +======================================== +PRAKTISCHE CHECKLISTEN +======================================== + +ePrivacy-Checkliste fuer Websites + +COOKIES: +- Cookie-Audit durchgefuehrt (alle Cookies identifiziert) +- Kategorisierung (technisch notwendig vs. einwilligungspflichtig) +- Cookie-Banner implementiert +- Opt-In vor Setzen von nicht-notwendigen Cookies +- "Ablehnen" gleichwertig zu "Akzeptieren" +- Granulare Auswahlmoeglichkeit (Kategorien) +- Speicherdauer dokumentiert +- Einwilligungen protokolliert +- Widerruf jederzeit moeglich + +DATENSCHUTZERKLAERUNG: +- Cookie-Informationen enthalten +- Alle Cookies mit Zweck aufgelistet +- Drittanbieter benannt +- Speicherdauer angegeben + +E-MAIL-MARKETING: +- Double Opt-In implementiert +- Abmelde-Link in jeder E-Mail +- Einwilligungen dokumentiert +- Bei Bestandskunden: Widerspruchsmoeglichkeit + +TRACKING/ANALYTICS: +- Nur mit Einwilligung aktiv +- Oder: Einwilligungsfreie Alternative (z.B. serverseitig) +- IP-Anonymisierung aktiviert +- Datenverarbeitung dokumentiert + +ePrivacy-Checkliste fuer lokale Anwendungen + +GRUNDSAETZE: +- Alle Daten bleiben lokal +- Keine Cloud-Anbindung fuer Nutzerdaten +- Keine Third-Party-Tracker + +COOKIES/LOKALE SPEICHERUNG: +- Nur Session-Cookies fuer Login +- Keine persistenten Tracking-Cookies +- Keine Local Storage fuer Tracking +- Kein Fingerprinting + +ANALYTICS: +- Serverseitige Logs statt Cookies +- Keine personenbezogenen Daten in Logs +- Oder: Einwilligung fuer Cookie-Analytics + +KOMMUNIKATION: +- Keine automatisierten Werbe-E-Mails ohne Einwilligung +- Abmelde-Moeglichkeit bei Benachrichtigungen +- Push-Benachrichtigungen nur mit Zustimmung + +DOKUMENTATION: +- Datenschutzerklaerung aktuell +- Cookie-Informationen (falls Cookies) +- Technische Dokumentation der Datenverarbeitung + +VORTEIL LOKALER VERARBEITUNG: +Die meisten ePrivacy-Anforderungen entfallen bei rein lokaler Verarbeitung ohne externe Dienste! + +======================================== +RECHTLICHE REFERENZEN +======================================== + +EU-Recht: +- Richtlinie 2002/58/EG (ePrivacy-Richtlinie) +- Richtlinie 2009/136/EG (Cookie-Richtlinie) +- DSGVO Art. 95 (Verhaeltnis zu ePrivacy) +- EuGH Planet49 (C-673/17) + +Deutsches Recht: +- TTDSG (Telekommunikation-Telemedien-Datenschutz-Gesetz) +- Paragraph 25 TTDSG (Cookies) +- Paragraph 7 UWG (Unzumutbare Belaestigungen) +- Paragraph 88 TKG (Fernmeldegeheimnis) + +Behoerdenpraxis: +- DSK Orientierungshilfe fuer Anbieter von Telemedien (2022) +- DSK Beschluss zu Tracking (2021) +- CNIL Guidelines on Cookies (2020) diff --git a/klausur-service/embedding-service/Dockerfile b/klausur-service/embedding-service/Dockerfile new file mode 100644 index 0000000..5d6750a --- /dev/null +++ b/klausur-service/embedding-service/Dockerfile @@ -0,0 +1,36 @@ +# Embedding Service Dockerfile +# Handles ML-heavy operations: embeddings, re-ranking, PDF extraction + +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies for PDF extraction +RUN apt-get update && apt-get install -y --no-install-recommends \ + libmagic1 \ + poppler-utils \ + tesseract-ocr \ + tesseract-ocr-deu \ + && rm -rf /var/lib/apt/lists/* + +# Install PyTorch CPU-only (smaller image) +RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu + +# Copy and install requirements +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Note: Models are downloaded on first startup (not during build) +# This makes the build faster but first startup slower +# To pre-download models, mount a persistent volume for /root/.cache/huggingface + +# Copy application code +COPY . . + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD python -c "import httpx; httpx.get('http://localhost:8087/health').raise_for_status()" + +# Run the service +EXPOSE 8087 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8087"] diff --git a/klausur-service/embedding-service/config.py b/klausur-service/embedding-service/config.py new file mode 100644 index 0000000..e2cfe01 --- /dev/null +++ b/klausur-service/embedding-service/config.py @@ -0,0 +1,86 @@ +""" +Embedding Service Configuration + +Environment variables for embedding generation, re-ranking, and PDF extraction. +""" + +import os + +# ============================================================================= +# Embedding Configuration +# ============================================================================= + +# Backend: "local" (sentence-transformers) or "openai" +EMBEDDING_BACKEND = os.getenv("EMBEDDING_BACKEND", "local") +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "") +OPENAI_EMBEDDING_MODEL = os.getenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") + +# Local embedding model +# Recommended: BAAI/bge-m3 (MIT, 1024 dim, multilingual) +LOCAL_EMBEDDING_MODEL = os.getenv("LOCAL_EMBEDDING_MODEL", "BAAI/bge-m3") + +# Chunking configuration +CHUNK_SIZE = int(os.getenv("CHUNK_SIZE", "1000")) +CHUNK_OVERLAP = int(os.getenv("CHUNK_OVERLAP", "200")) +CHUNKING_STRATEGY = os.getenv("CHUNKING_STRATEGY", "semantic") + +# ============================================================================= +# Re-Ranker Configuration +# ============================================================================= + +# Backend: "local" (sentence-transformers CrossEncoder) or "cohere" +RERANKER_BACKEND = os.getenv("RERANKER_BACKEND", "local") +COHERE_API_KEY = os.getenv("COHERE_API_KEY", "") + +# Local re-ranker model +# Recommended: BAAI/bge-reranker-v2-m3 (Apache 2.0, multilingual) +LOCAL_RERANKER_MODEL = os.getenv("LOCAL_RERANKER_MODEL", "BAAI/bge-reranker-v2-m3") + +# ============================================================================= +# PDF Extraction Configuration +# ============================================================================= + +# Backend: "auto", "unstructured", "pypdf" +PDF_EXTRACTION_BACKEND = os.getenv("PDF_EXTRACTION_BACKEND", "auto") +UNSTRUCTURED_API_KEY = os.getenv("UNSTRUCTURED_API_KEY", "") +UNSTRUCTURED_API_URL = os.getenv("UNSTRUCTURED_API_URL", "") + +# ============================================================================= +# Service Configuration +# ============================================================================= + +SERVICE_PORT = int(os.getenv("EMBEDDING_SERVICE_PORT", "8087")) +LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") + +# Model dimensions lookup +MODEL_DIMENSIONS = { + # Multilingual / German-optimized + "BAAI/bge-m3": 1024, + "deepset/mxbai-embed-de-large-v1": 1024, + "jinaai/jina-embeddings-v2-base-de": 768, + "intfloat/multilingual-e5-large": 1024, + # English-focused (smaller, faster) + "all-MiniLM-L6-v2": 384, + "all-mpnet-base-v2": 768, + # OpenAI + "text-embedding-3-small": 1536, + "text-embedding-3-large": 3072, +} + + +def get_model_dimensions(model_name: str) -> int: + """Get embedding dimensions for a model.""" + if model_name in MODEL_DIMENSIONS: + return MODEL_DIMENSIONS[model_name] + for key, dim in MODEL_DIMENSIONS.items(): + if key in model_name or model_name in key: + return dim + return 384 # Default fallback + + +def get_current_dimensions() -> int: + """Get dimensions for the currently configured model.""" + if EMBEDDING_BACKEND == "local": + return get_model_dimensions(LOCAL_EMBEDDING_MODEL) + else: + return get_model_dimensions(OPENAI_EMBEDDING_MODEL) diff --git a/klausur-service/embedding-service/main.py b/klausur-service/embedding-service/main.py new file mode 100644 index 0000000..8b033ab --- /dev/null +++ b/klausur-service/embedding-service/main.py @@ -0,0 +1,696 @@ +""" +Embedding Service - FastAPI Application + +Provides REST endpoints for: +- Embedding generation (local sentence-transformers or OpenAI) +- Re-ranking (local CrossEncoder or Cohere) +- PDF text extraction (Unstructured or pypdf) +- Text chunking (semantic or recursive) + +This service handles all ML-heavy operations, keeping the main klausur-service lightweight. +""" + +import os +import logging +from typing import List, Optional +from contextlib import asynccontextmanager + +from fastapi import FastAPI, HTTPException, UploadFile, File +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field + +import config + +# Configure logging +logging.basicConfig( + level=getattr(logging, config.LOG_LEVEL.upper(), logging.INFO), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger("embedding-service") + +# ============================================================================= +# Lazy-loaded models +# ============================================================================= + +_embedding_model = None +_reranker_model = None + + +def get_embedding_model(): + """Lazy-load the sentence-transformers embedding model.""" + global _embedding_model + if _embedding_model is None: + from sentence_transformers import SentenceTransformer + logger.info(f"Loading embedding model: {config.LOCAL_EMBEDDING_MODEL}") + _embedding_model = SentenceTransformer(config.LOCAL_EMBEDDING_MODEL) + logger.info(f"Model loaded (dim={_embedding_model.get_sentence_embedding_dimension()})") + return _embedding_model + + +def get_reranker_model(): + """Lazy-load the CrossEncoder reranker model.""" + global _reranker_model + if _reranker_model is None: + from sentence_transformers import CrossEncoder + logger.info(f"Loading reranker model: {config.LOCAL_RERANKER_MODEL}") + _reranker_model = CrossEncoder(config.LOCAL_RERANKER_MODEL) + logger.info("Reranker loaded") + return _reranker_model + + +# ============================================================================= +# Request/Response Models +# ============================================================================= + +class EmbedRequest(BaseModel): + texts: List[str] = Field(..., description="List of texts to embed") + + +class EmbedResponse(BaseModel): + embeddings: List[List[float]] + model: str + dimensions: int + + +class EmbedSingleRequest(BaseModel): + text: str = Field(..., description="Single text to embed") + + +class EmbedSingleResponse(BaseModel): + embedding: List[float] + model: str + dimensions: int + + +class RerankRequest(BaseModel): + query: str = Field(..., description="Search query") + documents: List[str] = Field(..., description="Documents to re-rank") + top_k: int = Field(default=5, description="Number of top results to return") + + +class RerankResult(BaseModel): + index: int + score: float + text: str + + +class RerankResponse(BaseModel): + results: List[RerankResult] + model: str + + +class ChunkRequest(BaseModel): + text: str = Field(..., description="Text to chunk") + chunk_size: int = Field(default=1000, description="Target chunk size") + overlap: int = Field(default=200, description="Overlap between chunks") + strategy: str = Field(default="semantic", description="Chunking strategy: semantic or recursive") + + +class ChunkResponse(BaseModel): + chunks: List[str] + count: int + strategy: str + + +class ExtractPDFResponse(BaseModel): + text: str + backend_used: str + pages: int + table_count: int + + +class HealthResponse(BaseModel): + status: str + embedding_model: str + embedding_dimensions: int + reranker_model: str + pdf_backends: List[str] + + +class ModelsResponse(BaseModel): + embedding_backend: str + embedding_model: str + embedding_dimensions: int + reranker_backend: str + reranker_model: str + pdf_backend: str + available_pdf_backends: List[str] + + +# ============================================================================= +# Embedding Functions +# ============================================================================= + +def generate_local_embeddings(texts: List[str]) -> List[List[float]]: + """Generate embeddings using local model.""" + if not texts: + return [] + model = get_embedding_model() + embeddings = model.encode(texts, show_progress_bar=len(texts) > 10) + return [emb.tolist() for emb in embeddings] + + +async def generate_openai_embeddings(texts: List[str]) -> List[List[float]]: + """Generate embeddings using OpenAI API.""" + import httpx + + if not config.OPENAI_API_KEY: + raise HTTPException(status_code=500, detail="OPENAI_API_KEY not configured") + + async with httpx.AsyncClient() as client: + response = await client.post( + "https://api.openai.com/v1/embeddings", + headers={ + "Authorization": f"Bearer {config.OPENAI_API_KEY}", + "Content-Type": "application/json" + }, + json={ + "model": config.OPENAI_EMBEDDING_MODEL, + "input": texts + }, + timeout=60.0 + ) + + if response.status_code != 200: + raise HTTPException( + status_code=response.status_code, + detail=f"OpenAI API error: {response.text}" + ) + + data = response.json() + return [item["embedding"] for item in data["data"]] + + +# ============================================================================= +# Re-ranking Functions +# ============================================================================= + +def rerank_local(query: str, documents: List[str], top_k: int = 5) -> List[RerankResult]: + """Re-rank documents using local CrossEncoder.""" + if not documents: + return [] + + model = get_reranker_model() + pairs = [(query, doc) for doc in documents] + scores = model.predict(pairs) + + results = [ + RerankResult(index=i, score=float(score), text=doc) + for i, (score, doc) in enumerate(zip(scores, documents)) + ] + results.sort(key=lambda x: x.score, reverse=True) + return results[:top_k] + + +async def rerank_cohere(query: str, documents: List[str], top_k: int = 5) -> List[RerankResult]: + """Re-rank documents using Cohere API.""" + import httpx + + if not config.COHERE_API_KEY: + raise HTTPException(status_code=500, detail="COHERE_API_KEY not configured") + + async with httpx.AsyncClient() as client: + response = await client.post( + "https://api.cohere.ai/v2/rerank", + headers={ + "Authorization": f"Bearer {config.COHERE_API_KEY}", + "Content-Type": "application/json" + }, + json={ + "model": "rerank-multilingual-v3.0", + "query": query, + "documents": documents, + "top_n": top_k, + "return_documents": False, + }, + timeout=30.0 + ) + + if response.status_code != 200: + raise HTTPException( + status_code=response.status_code, + detail=f"Cohere API error: {response.text}" + ) + + data = response.json() + return [ + RerankResult( + index=item["index"], + score=item["relevance_score"], + text=documents[item["index"]] + ) + for item in data.get("results", []) + ] + + +# ============================================================================= +# Chunking Functions +# ============================================================================= + +# German abbreviations that don't end sentences +GERMAN_ABBREVIATIONS = { + 'bzw', 'ca', 'chr', 'd.h', 'dr', 'etc', 'evtl', 'ggf', 'inkl', 'max', + 'min', 'mio', 'mrd', 'nr', 'prof', 's', 'sog', 'u.a', 'u.ä', 'usw', + 'v.a', 'vgl', 'vs', 'z.b', 'z.t', 'zzgl' +} + + +def chunk_text_recursive(text: str, chunk_size: int, overlap: int) -> List[str]: + """Recursive character-based chunking.""" + import re + + if not text or len(text) <= chunk_size: + return [text] if text else [] + + separators = ["\n\n", "\n", ". ", " ", ""] + + def split_recursive(text: str, sep_idx: int = 0) -> List[str]: + if len(text) <= chunk_size: + return [text] + + if sep_idx >= len(separators): + return [text[i:i+chunk_size] for i in range(0, len(text), chunk_size - overlap)] + + sep = separators[sep_idx] + if not sep: + parts = list(text) + else: + parts = text.split(sep) + + result = [] + current = "" + + for part in parts: + test_chunk = current + sep + part if current else part + + if len(test_chunk) <= chunk_size: + current = test_chunk + else: + if current: + result.append(current) + if len(part) > chunk_size: + result.extend(split_recursive(part, sep_idx + 1)) + current = "" + else: + current = part + + if current: + result.append(current) + + return result + + raw_chunks = split_recursive(text) + + # Add overlap + final_chunks = [] + for i, chunk in enumerate(raw_chunks): + if i > 0 and overlap > 0: + prev_chunk = raw_chunks[i-1] + overlap_text = prev_chunk[-min(overlap, len(prev_chunk)):] + chunk = overlap_text + chunk + final_chunks.append(chunk.strip()) + + return [c for c in final_chunks if c] + + +def chunk_text_semantic(text: str, chunk_size: int, overlap_sentences: int = 1) -> List[str]: + """Semantic sentence-aware chunking.""" + import re + + if not text: + return [] + + if len(text) <= chunk_size: + return [text.strip()] + + # Split into sentences (simplified for German) + text = re.sub(r'\s+', ' ', text).strip() + + # Protect abbreviations + protected = text + for abbrev in GERMAN_ABBREVIATIONS: + pattern = re.compile(r'\b' + re.escape(abbrev) + r'\.', re.IGNORECASE) + protected = pattern.sub(abbrev.replace('.', '') + '', protected) + + # Protect decimals and ordinals + protected = re.sub(r'(\d)\.(\d)', r'\1\2', protected) + protected = re.sub(r'(\d+)\.(\s)', r'\1\2', protected) + + # Split on sentence endings + sentence_pattern = r'(?<=[.!?])\s+(?=[A-ZÄÖÜ])|(?<=[.!?])$' + raw_sentences = re.split(sentence_pattern, protected) + + # Restore protected characters + sentences = [] + for s in raw_sentences: + s = s.replace('', '.').replace('', '.').replace('', '.').replace('', '.') + s = s.strip() + if s: + sentences.append(s) + + # Build chunks + chunks = [] + current_parts = [] + current_length = 0 + overlap_buffer = [] + + for sentence in sentences: + sentence_len = len(sentence) + + if sentence_len > chunk_size: + if current_parts: + chunks.append(' '.join(current_parts)) + overlap_buffer = current_parts[-overlap_sentences:] if overlap_sentences > 0 else [] + current_parts = list(overlap_buffer) + current_length = sum(len(s) + 1 for s in current_parts) + + if overlap_buffer: + chunks.append(' '.join(overlap_buffer) + ' ' + sentence) + else: + chunks.append(sentence) + overlap_buffer = [sentence] + current_parts = list(overlap_buffer) + current_length = len(sentence) + 1 + continue + + if current_length + sentence_len + 1 > chunk_size and current_parts: + chunks.append(' '.join(current_parts)) + overlap_buffer = current_parts[-overlap_sentences:] if overlap_sentences > 0 else [] + current_parts = list(overlap_buffer) + current_length = sum(len(s) + 1 for s in current_parts) + + current_parts.append(sentence) + current_length += sentence_len + 1 + + if current_parts: + chunks.append(' '.join(current_parts)) + + return [re.sub(r'\s+', ' ', c).strip() for c in chunks if c.strip()] + + +# ============================================================================= +# PDF Extraction Functions +# ============================================================================= + +def detect_pdf_backends() -> List[str]: + """Detect available PDF backends.""" + available = [] + + try: + from unstructured.partition.pdf import partition_pdf + available.append("unstructured") + except ImportError: + pass + + try: + from pypdf import PdfReader + available.append("pypdf") + except ImportError: + pass + + return available + + +def extract_pdf_unstructured(pdf_content: bytes) -> ExtractPDFResponse: + """Extract PDF using Unstructured.""" + import tempfile + from unstructured.partition.pdf import partition_pdf + from unstructured.documents.elements import Title, ListItem, Table, Header, Footer + + with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp: + tmp.write(pdf_content) + tmp_path = tmp.name + + try: + elements = partition_pdf( + filename=tmp_path, + strategy="auto", + include_page_breaks=True, + infer_table_structure=True, + languages=["deu", "eng"], + ) + + text_parts = [] + tables = [] + page_count = 1 + + for element in elements: + if hasattr(element, "metadata") and hasattr(element.metadata, "page_number"): + page_count = max(page_count, element.metadata.page_number or 1) + + if isinstance(element, (Header, Footer)): + continue + + element_text = str(element) + + if isinstance(element, Table): + tables.append(element_text) + text_parts.append(f"\n[TABELLE]\n{element_text}\n[/TABELLE]\n") + elif isinstance(element, Title): + text_parts.append(f"\n## {element_text}\n") + elif isinstance(element, ListItem): + text_parts.append(f"• {element_text}") + else: + text_parts.append(element_text) + + return ExtractPDFResponse( + text="\n".join(text_parts), + backend_used="unstructured", + pages=page_count, + table_count=len(tables) + ) + finally: + import os as os_module + try: + os_module.unlink(tmp_path) + except: + pass + + +def extract_pdf_pypdf(pdf_content: bytes) -> ExtractPDFResponse: + """Extract PDF using pypdf.""" + import io + from pypdf import PdfReader + + pdf_file = io.BytesIO(pdf_content) + reader = PdfReader(pdf_file) + + text_parts = [] + for page in reader.pages: + text = page.extract_text() + if text: + text_parts.append(text) + + return ExtractPDFResponse( + text="\n\n".join(text_parts), + backend_used="pypdf", + pages=len(reader.pages), + table_count=0 + ) + + +# ============================================================================= +# Application Lifecycle +# ============================================================================= + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Preload models on startup.""" + logger.info("Starting Embedding Service...") + + if config.EMBEDDING_BACKEND == "local": + try: + get_embedding_model() + logger.info("Embedding model preloaded") + except Exception as e: + logger.warning(f"Failed to preload embedding model: {e}") + + if config.RERANKER_BACKEND == "local": + try: + get_reranker_model() + logger.info("Reranker model preloaded") + except Exception as e: + logger.warning(f"Failed to preload reranker model: {e}") + + logger.info("Embedding Service ready") + yield + logger.info("Shutting down Embedding Service") + + +# ============================================================================= +# FastAPI Application +# ============================================================================= + +app = FastAPI( + title="Embedding Service", + description="ML service for embeddings, re-ranking, and PDF extraction", + version="1.0.0", + lifespan=lifespan +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# ============================================================================= +# Endpoints +# ============================================================================= + +@app.get("/health", response_model=HealthResponse) +async def health_check(): + """Health check endpoint.""" + return HealthResponse( + status="healthy", + embedding_model=config.LOCAL_EMBEDDING_MODEL if config.EMBEDDING_BACKEND == "local" else config.OPENAI_EMBEDDING_MODEL, + embedding_dimensions=config.get_current_dimensions(), + reranker_model=config.LOCAL_RERANKER_MODEL if config.RERANKER_BACKEND == "local" else "cohere-rerank", + pdf_backends=detect_pdf_backends() + ) + + +@app.get("/models", response_model=ModelsResponse) +async def get_models(): + """Get information about configured models.""" + return ModelsResponse( + embedding_backend=config.EMBEDDING_BACKEND, + embedding_model=config.LOCAL_EMBEDDING_MODEL if config.EMBEDDING_BACKEND == "local" else config.OPENAI_EMBEDDING_MODEL, + embedding_dimensions=config.get_current_dimensions(), + reranker_backend=config.RERANKER_BACKEND, + reranker_model=config.LOCAL_RERANKER_MODEL if config.RERANKER_BACKEND == "local" else "cohere-rerank-multilingual-v3.0", + pdf_backend=config.PDF_EXTRACTION_BACKEND, + available_pdf_backends=detect_pdf_backends() + ) + + +@app.post("/embed", response_model=EmbedResponse) +async def embed_texts(request: EmbedRequest): + """Generate embeddings for multiple texts.""" + if not request.texts: + return EmbedResponse( + embeddings=[], + model=config.LOCAL_EMBEDDING_MODEL if config.EMBEDDING_BACKEND == "local" else config.OPENAI_EMBEDDING_MODEL, + dimensions=config.get_current_dimensions() + ) + + try: + if config.EMBEDDING_BACKEND == "local": + embeddings = generate_local_embeddings(request.texts) + else: + embeddings = await generate_openai_embeddings(request.texts) + + return EmbedResponse( + embeddings=embeddings, + model=config.LOCAL_EMBEDDING_MODEL if config.EMBEDDING_BACKEND == "local" else config.OPENAI_EMBEDDING_MODEL, + dimensions=config.get_current_dimensions() + ) + except Exception as e: + logger.error(f"Embedding error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/embed-single", response_model=EmbedSingleResponse) +async def embed_single_text(request: EmbedSingleRequest): + """Generate embedding for a single text.""" + try: + if config.EMBEDDING_BACKEND == "local": + embeddings = generate_local_embeddings([request.text]) + else: + embeddings = await generate_openai_embeddings([request.text]) + + return EmbedSingleResponse( + embedding=embeddings[0] if embeddings else [], + model=config.LOCAL_EMBEDDING_MODEL if config.EMBEDDING_BACKEND == "local" else config.OPENAI_EMBEDDING_MODEL, + dimensions=config.get_current_dimensions() + ) + except Exception as e: + logger.error(f"Embedding error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/rerank", response_model=RerankResponse) +async def rerank_documents(request: RerankRequest): + """Re-rank documents based on query relevance.""" + if not request.documents: + return RerankResponse( + results=[], + model=config.LOCAL_RERANKER_MODEL if config.RERANKER_BACKEND == "local" else "cohere-rerank" + ) + + try: + if config.RERANKER_BACKEND == "local": + results = rerank_local(request.query, request.documents, request.top_k) + else: + results = await rerank_cohere(request.query, request.documents, request.top_k) + + return RerankResponse( + results=results, + model=config.LOCAL_RERANKER_MODEL if config.RERANKER_BACKEND == "local" else "cohere-rerank-multilingual-v3.0" + ) + except Exception as e: + logger.error(f"Rerank error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/chunk", response_model=ChunkResponse) +async def chunk_text(request: ChunkRequest): + """Chunk text into smaller pieces.""" + if not request.text: + return ChunkResponse(chunks=[], count=0, strategy=request.strategy) + + try: + if request.strategy == "semantic": + overlap_sentences = max(1, request.overlap // 100) + chunks = chunk_text_semantic(request.text, request.chunk_size, overlap_sentences) + else: + chunks = chunk_text_recursive(request.text, request.chunk_size, request.overlap) + + return ChunkResponse( + chunks=chunks, + count=len(chunks), + strategy=request.strategy + ) + except Exception as e: + logger.error(f"Chunking error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@app.post("/extract-pdf", response_model=ExtractPDFResponse) +async def extract_pdf(file: UploadFile = File(...)): + """Extract text from PDF file.""" + pdf_content = await file.read() + available = detect_pdf_backends() + + if not available: + raise HTTPException( + status_code=500, + detail="No PDF backend available. Install: pip install pypdf unstructured" + ) + + backend = config.PDF_EXTRACTION_BACKEND + if backend == "auto": + backend = "unstructured" if "unstructured" in available else "pypdf" + + try: + if backend == "unstructured" and "unstructured" in available: + return extract_pdf_unstructured(pdf_content) + elif "pypdf" in available: + return extract_pdf_pypdf(pdf_content) + else: + raise HTTPException(status_code=500, detail=f"Backend {backend} not available") + except Exception as e: + logger.error(f"PDF extraction error: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +# ============================================================================= +# Main +# ============================================================================= + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=config.SERVICE_PORT) diff --git a/klausur-service/embedding-service/requirements.txt b/klausur-service/embedding-service/requirements.txt new file mode 100644 index 0000000..2d11c24 --- /dev/null +++ b/klausur-service/embedding-service/requirements.txt @@ -0,0 +1,23 @@ +# Embedding Service Dependencies +# This service handles ML-heavy operations (embeddings, re-ranking, PDF extraction) + +# Web Framework +fastapi>=0.109.0 +uvicorn[standard]>=0.27.0 +pydantic>=2.0.0 +python-multipart>=0.0.6 + +# ML / Embeddings +torch>=2.0.0 +sentence-transformers>=2.2.0 + +# PDF Extraction +unstructured>=0.12.0 +pypdf>=4.0.0 +python-magic>=0.4.27 + +# HTTP Client (for OpenAI/Cohere API calls) +httpx>=0.26.0 + +# Utilities +python-dotenv>=1.0.0 diff --git a/klausur-service/frontend/index.html b/klausur-service/frontend/index.html new file mode 100644 index 0000000..a83bb26 --- /dev/null +++ b/klausur-service/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + BreakPilot Klausur-Korrektur + + +
              + + + diff --git a/klausur-service/frontend/package-lock.json b/klausur-service/frontend/package-lock.json new file mode 100644 index 0000000..af9b8f1 --- /dev/null +++ b/klausur-service/frontend/package-lock.json @@ -0,0 +1,1774 @@ +{ + "name": "klausur-service-frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "klausur-service-frontend", + "version": "1.0.0", + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.21.0" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "^5.3.3", + "vite": "^5.0.10" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz", + "integrity": "sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz", + "integrity": "sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz", + "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.6.tgz", + "integrity": "sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.6.tgz", + "integrity": "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz", + "integrity": "sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/generator": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.6", + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz", + "integrity": "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", + "integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz", + "integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz", + "integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz", + "integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz", + "integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz", + "integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz", + "integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz", + "integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz", + "integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz", + "integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz", + "integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz", + "integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz", + "integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz", + "integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz", + "integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz", + "integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz", + "integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz", + "integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz", + "integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz", + "integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz", + "integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz", + "integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz", + "integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz", + "integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz", + "integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", + "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001764", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001764.tgz", + "integrity": "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.55.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz", + "integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.55.1", + "@rollup/rollup-android-arm64": "4.55.1", + "@rollup/rollup-darwin-arm64": "4.55.1", + "@rollup/rollup-darwin-x64": "4.55.1", + "@rollup/rollup-freebsd-arm64": "4.55.1", + "@rollup/rollup-freebsd-x64": "4.55.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", + "@rollup/rollup-linux-arm-musleabihf": "4.55.1", + "@rollup/rollup-linux-arm64-gnu": "4.55.1", + "@rollup/rollup-linux-arm64-musl": "4.55.1", + "@rollup/rollup-linux-loong64-gnu": "4.55.1", + "@rollup/rollup-linux-loong64-musl": "4.55.1", + "@rollup/rollup-linux-ppc64-gnu": "4.55.1", + "@rollup/rollup-linux-ppc64-musl": "4.55.1", + "@rollup/rollup-linux-riscv64-gnu": "4.55.1", + "@rollup/rollup-linux-riscv64-musl": "4.55.1", + "@rollup/rollup-linux-s390x-gnu": "4.55.1", + "@rollup/rollup-linux-x64-gnu": "4.55.1", + "@rollup/rollup-linux-x64-musl": "4.55.1", + "@rollup/rollup-openbsd-x64": "4.55.1", + "@rollup/rollup-openharmony-arm64": "4.55.1", + "@rollup/rollup-win32-arm64-msvc": "4.55.1", + "@rollup/rollup-win32-ia32-msvc": "4.55.1", + "@rollup/rollup-win32-x64-gnu": "4.55.1", + "@rollup/rollup-win32-x64-msvc": "4.55.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/klausur-service/frontend/package.json b/klausur-service/frontend/package.json new file mode 100644 index 0000000..37e9600 --- /dev/null +++ b/klausur-service/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "klausur-service-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.21.0" + }, + "devDependencies": { + "@types/react": "^18.2.43", + "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.2.1", + "typescript": "^5.3.3", + "vite": "^5.0.10" + } +} diff --git a/klausur-service/frontend/src/App.tsx b/klausur-service/frontend/src/App.tsx new file mode 100644 index 0000000..2d678b6 --- /dev/null +++ b/klausur-service/frontend/src/App.tsx @@ -0,0 +1,23 @@ +import { BrowserRouter, Routes, Route } from 'react-router-dom' +import { KlausurProvider } from './hooks/useKlausur' +import Layout from './components/Layout' +import OnboardingPage from './pages/OnboardingPage' +import KorrekturPage from './pages/KorrekturPage' + +function App() { + return ( + + + + + } /> + } /> + } /> + + + + + ) +} + +export default App diff --git a/klausur-service/frontend/src/components/EHUploadWizard.tsx b/klausur-service/frontend/src/components/EHUploadWizard.tsx new file mode 100644 index 0000000..2691a6d --- /dev/null +++ b/klausur-service/frontend/src/components/EHUploadWizard.tsx @@ -0,0 +1,591 @@ +/** + * BYOEH Upload Wizard Component + * + * 5-step wizard for uploading Erwartungshorizonte with client-side encryption: + * 1. File Selection - Choose PDF file + * 2. Metadata - Title, Subject, Niveau, Year + * 3. Rights Confirmation - Legal acknowledgment (required) + * 4. Encryption - Set passphrase (2x confirmation) + * 5. Summary & Upload - Review and confirm + */ + +import { useState, useEffect, useCallback } from 'react' +import { + encryptFile, + generateSalt, + isEncryptionSupported +} from '../services/encryption' +import { ehApi } from '../services/api' + +interface EHMetadata { + title: string + subject: string + niveau: 'eA' | 'gA' + year: number + aufgaben_nummer?: string +} + +interface EHUploadWizardProps { + onClose: () => void + onComplete?: (ehId: string) => void + onSuccess?: (ehId: string) => void // Legacy alias for onComplete + klausurSubject?: string + klausurYear?: number + defaultSubject?: string // Alias for klausurSubject + defaultYear?: number // Alias for klausurYear + klausurId?: string // If provided, automatically link EH to this Klausur +} + +type WizardStep = 'file' | 'metadata' | 'rights' | 'encryption' | 'summary' + +const WIZARD_STEPS: WizardStep[] = ['file', 'metadata', 'rights', 'encryption', 'summary'] + +const STEP_LABELS: Record = { + file: 'Datei', + metadata: 'Metadaten', + rights: 'Rechte', + encryption: 'Verschluesselung', + summary: 'Zusammenfassung' +} + +const SUBJECTS = [ + 'deutsch', 'englisch', 'mathematik', 'physik', 'chemie', 'biologie', + 'geschichte', 'politik', 'erdkunde', 'kunst', 'musik', 'sport', + 'informatik', 'latein', 'franzoesisch', 'spanisch' +] + +const RIGHTS_TEXT = `Ich bestaetige hiermit, dass: + +1. Ich das Urheberrecht oder die notwendigen Nutzungsrechte an diesem + Erwartungshorizont besitze. + +2. Breakpilot diesen Erwartungshorizont NICHT fuer KI-Training verwendet, + sondern ausschliesslich fuer RAG-gestuetzte Korrekturvorschlaege + in meinem persoenlichen Arbeitsbereich. + +3. Der Inhalt verschluesselt gespeichert wird und Breakpilot-Mitarbeiter + keinen Zugriff auf den Klartext haben. + +4. Ich diesen Erwartungshorizont jederzeit loeschen kann.` + +function EHUploadWizard({ + onClose, + onComplete, + onSuccess, + klausurSubject, + klausurYear, + defaultSubject, + defaultYear, + klausurId +}: EHUploadWizardProps) { + // Resolve aliases + const effectiveSubject = klausurSubject || defaultSubject || 'deutsch' + const effectiveYear = klausurYear || defaultYear || new Date().getFullYear() + const handleComplete = onComplete || onSuccess || (() => {}) + + // Step state + const [currentStep, setCurrentStep] = useState('file') + + // File step + const [selectedFile, setSelectedFile] = useState(null) + const [fileError, setFileError] = useState(null) + + // Metadata step + const [metadata, setMetadata] = useState({ + title: '', + subject: effectiveSubject, + niveau: 'eA', + year: effectiveYear, + aufgaben_nummer: '' + }) + + // Rights step + const [rightsConfirmed, setRightsConfirmed] = useState(false) + + // Encryption step + const [passphrase, setPassphrase] = useState('') + const [passphraseConfirm, setPassphraseConfirm] = useState('') + const [showPassphrase, setShowPassphrase] = useState(false) + const [passphraseStrength, setPassphraseStrength] = useState<'weak' | 'medium' | 'strong'>('weak') + + // Upload state + const [uploading, setUploading] = useState(false) + const [uploadProgress, setUploadProgress] = useState(0) + const [uploadError, setUploadError] = useState(null) + + // Check encryption support + const encryptionSupported = isEncryptionSupported() + + // Calculate passphrase strength + useEffect(() => { + if (passphrase.length < 8) { + setPassphraseStrength('weak') + } else if (passphrase.length < 12 || !/\d/.test(passphrase) || !/[A-Z]/.test(passphrase)) { + setPassphraseStrength('medium') + } else { + setPassphraseStrength('strong') + } + }, [passphrase]) + + // Step navigation + const currentStepIndex = WIZARD_STEPS.indexOf(currentStep) + const isFirstStep = currentStepIndex === 0 + const isLastStep = currentStepIndex === WIZARD_STEPS.length - 1 + + const goNext = useCallback(() => { + if (!isLastStep) { + setCurrentStep(WIZARD_STEPS[currentStepIndex + 1]) + } + }, [currentStepIndex, isLastStep]) + + const goBack = useCallback(() => { + if (!isFirstStep) { + setCurrentStep(WIZARD_STEPS[currentStepIndex - 1]) + } + }, [currentStepIndex, isFirstStep]) + + // Validation + const isStepValid = useCallback((step: WizardStep): boolean => { + switch (step) { + case 'file': + return selectedFile !== null && fileError === null + case 'metadata': + return metadata.title.trim().length > 0 && metadata.subject.length > 0 + case 'rights': + return rightsConfirmed + case 'encryption': + return passphrase.length >= 8 && passphrase === passphraseConfirm + case 'summary': + return true + default: + return false + } + }, [selectedFile, fileError, metadata, rightsConfirmed, passphrase, passphraseConfirm]) + + // File handling + const handleFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + if (file.type !== 'application/pdf') { + setFileError('Nur PDF-Dateien sind erlaubt') + setSelectedFile(null) + return + } + if (file.size > 50 * 1024 * 1024) { // 50MB limit + setFileError('Datei ist zu gross (max. 50MB)') + setSelectedFile(null) + return + } + setFileError(null) + setSelectedFile(file) + // Auto-fill title from filename + if (!metadata.title) { + const name = file.name.replace(/\.pdf$/i, '').replace(/[_-]/g, ' ') + setMetadata(prev => ({ ...prev, title: name })) + } + } + } + + // Upload handler + const handleUpload = async () => { + if (!selectedFile || !encryptionSupported) return + + setUploading(true) + setUploadProgress(10) + setUploadError(null) + + try { + // Step 1: Generate salt (used via encrypted.salt below) + generateSalt() + setUploadProgress(20) + + // Step 2: Encrypt file client-side + const encrypted = await encryptFile(selectedFile, passphrase) + setUploadProgress(50) + + // Step 3: Create form data + const formData = new FormData() + const encryptedBlob = new Blob([encrypted.encryptedData], { type: 'application/octet-stream' }) + formData.append('file', encryptedBlob, 'encrypted.bin') + + const metadataJson = JSON.stringify({ + metadata: { + title: metadata.title, + subject: metadata.subject, + niveau: metadata.niveau, + year: metadata.year, + aufgaben_nummer: metadata.aufgaben_nummer || null + }, + encryption_key_hash: encrypted.keyHash, + salt: encrypted.salt, + rights_confirmed: true, + original_filename: selectedFile.name + }) + formData.append('metadata_json', metadataJson) + + setUploadProgress(70) + + // Step 4: Upload to server + const response = await ehApi.uploadEH(formData) + setUploadProgress(90) + + // Step 5: Link to Klausur if klausurId provided + if (klausurId && response.id) { + try { + await ehApi.linkToKlausur(response.id, klausurId) + } catch (linkError) { + console.warn('Failed to auto-link EH to Klausur:', linkError) + // Don't fail the whole upload if linking fails + } + } + + setUploadProgress(100) + + // Success! + handleComplete(response.id) + } catch (error) { + console.error('Upload failed:', error) + setUploadError(error instanceof Error ? error.message : 'Upload fehlgeschlagen') + } finally { + setUploading(false) + } + } + + // Render step content + const renderStepContent = () => { + switch (currentStep) { + case 'file': + return ( +
              +

              Erwartungshorizont hochladen

              +

              + Waehlen Sie die PDF-Datei Ihres Erwartungshorizonts aus. + Die Datei wird verschluesselt und kann nur von Ihnen entschluesselt werden. +

              + +
              + + +
              + + {fileError &&

              {fileError}

              } + + {!encryptionSupported && ( +

              + Ihr Browser unterstuetzt keine Verschluesselung. + Bitte verwenden Sie einen modernen Browser (Chrome, Firefox, Safari, Edge). +

              + )} +
              + ) + + case 'metadata': + return ( +
              +

              Metadaten

              +

              + Geben Sie Informationen zum Erwartungshorizont ein. +

              + +
              + + setMetadata(prev => ({ ...prev, title: e.target.value }))} + placeholder="z.B. Deutsch Abitur 2024 Aufgabe 1" + /> +
              + +
              +
              + + +
              + +
              + + +
              +
              + +
              +
              + + setMetadata(prev => ({ ...prev, year: parseInt(e.target.value) }))} + /> +
              + +
              + + setMetadata(prev => ({ ...prev, aufgaben_nummer: e.target.value }))} + placeholder="z.B. 1a, 2.1" + /> +
              +
              +
              + ) + + case 'rights': + return ( +
              +

              Rechte-Bestaetigung

              +

              + Bitte lesen und bestaetigen Sie die folgenden Bedingungen. +

              + +
              +
              {RIGHTS_TEXT}
              +
              + +
              + setRightsConfirmed(e.target.checked)} + /> + +
              + +
              + Wichtig: Ihr Erwartungshorizont wird niemals fuer + KI-Training verwendet. Er dient ausschliesslich als Referenz fuer + Ihre persoenlichen Korrekturvorschlaege. +
              +
              + ) + + case 'encryption': + return ( +
              +

              Verschluesselung

              +

              + Waehlen Sie ein sicheres Passwort fuer Ihren Erwartungshorizont. + Dieses Passwort wird niemals an den Server gesendet. +

              + +
              + +
              + setPassphrase(e.target.value)} + placeholder="Mindestens 8 Zeichen" + /> + +
              +
              + Staerke: {passphraseStrength === 'weak' ? 'Schwach' : passphraseStrength === 'medium' ? 'Mittel' : 'Stark'} +
              +
              + +
              + + setPassphraseConfirm(e.target.value)} + placeholder="Passwort wiederholen" + /> + {passphraseConfirm && passphrase !== passphraseConfirm && ( +

              Passwoerter stimmen nicht ueberein

              + )} +
              + +
              + Achtung: Merken Sie sich dieses Passwort gut! + Ohne das Passwort kann der Erwartungshorizont nicht fuer + Korrekturvorschlaege verwendet werden. Breakpilot kann Ihr + Passwort nicht wiederherstellen. +
              +
              + ) + + case 'summary': + return ( +
              +

              Zusammenfassung

              +

              + Pruefen Sie Ihre Eingaben und starten Sie den Upload. +

              + +
              +
              + Datei: + {selectedFile?.name} +
              +
              + Titel: + {metadata.title} +
              +
              + Fach: + + {metadata.subject.charAt(0).toUpperCase() + metadata.subject.slice(1)} + +
              +
              + Niveau: + {metadata.niveau} +
              +
              + Jahr: + {metadata.year} +
              +
              + Verschluesselung: + AES-256-GCM +
              +
              + Rechte bestaetigt: + Ja +
              +
              + + {uploading && ( +
              +
              + {uploadProgress}% +
              + )} + + {uploadError && ( +

              {uploadError}

              + )} +
              + ) + + default: + return null + } + } + + return ( +
              +
              + {/* Header */} +
              +

              Erwartungshorizont hochladen

              + +
              + + {/* Progress */} +
              + {WIZARD_STEPS.map((step, index) => ( +
              +
              + {index < currentStepIndex ? '\u2713' : index + 1} +
              + {STEP_LABELS[step]} +
              + ))} +
              + + {/* Content */} +
              + {renderStepContent()} +
              + + {/* Footer */} +
              + + + {isLastStep ? ( + + ) : ( + + )} +
              +
              +
              + ) +} + +export default EHUploadWizard diff --git a/klausur-service/frontend/src/components/Layout.tsx b/klausur-service/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..b1b1f98 --- /dev/null +++ b/klausur-service/frontend/src/components/Layout.tsx @@ -0,0 +1,38 @@ +import { ReactNode } from 'react' + +interface LayoutProps { + children: ReactNode +} + +export default function Layout({ children }: LayoutProps) { + const handleBackToStudio = () => { + // Navigate back to Studio + if (window.parent !== window) { + window.parent.postMessage({ type: 'CLOSE_KLAUSUR_SERVICE' }, '*') + } else { + window.location.href = '/studio' + } + } + + return ( +
              +
              +
              +
              +
              BP
              +
              + BreakPilot + Klausur-Korrektur +
              +
              +
              + +
              +
              + {children} +
              +
              + ) +} diff --git a/klausur-service/frontend/src/components/RAGSearchPanel.tsx b/klausur-service/frontend/src/components/RAGSearchPanel.tsx new file mode 100644 index 0000000..c2552f4 --- /dev/null +++ b/klausur-service/frontend/src/components/RAGSearchPanel.tsx @@ -0,0 +1,255 @@ +/** + * RAGSearchPanel Component + * + * Enhanced RAG search panel for teachers to query their Erwartungshorizonte. + * Features: + * - search_info display (which RAG features were active) + * - Confidence indicator based on re-ranking scores + * - Toggle for "Enhanced Search" with advanced options + */ + +import { useState, useCallback } from 'react' +import { ehApi, EHRAGResult } from '../services/api' + +interface RAGSearchPanelProps { + onClose: () => void + defaultSubject?: string + passphrase: string +} + +interface SearchOptions { + rerank: boolean + limit: number +} + +// Confidence level based on score +type ConfidenceLevel = 'high' | 'medium' | 'low' + +function getConfidenceLevel(score: number): ConfidenceLevel { + if (score >= 0.8) return 'high' + if (score >= 0.5) return 'medium' + return 'low' +} + +function getConfidenceColor(level: ConfidenceLevel): string { + switch (level) { + case 'high': return 'var(--bp-success, #22c55e)' + case 'medium': return 'var(--bp-warning, #f59e0b)' + case 'low': return 'var(--bp-danger, #ef4444)' + } +} + +function getConfidenceLabel(level: ConfidenceLevel): string { + switch (level) { + case 'high': return 'Hohe Relevanz' + case 'medium': return 'Mittlere Relevanz' + case 'low': return 'Geringe Relevanz' + } +} + +export default function RAGSearchPanel({ onClose, defaultSubject, passphrase }: RAGSearchPanelProps) { + const [query, setQuery] = useState('') + const [searching, setSearching] = useState(false) + const [results, setResults] = useState(null) + const [error, setError] = useState(null) + + // Enhanced search options + const [showAdvancedOptions, setShowAdvancedOptions] = useState(false) + const [options, setOptions] = useState({ + rerank: true, // Default: enabled for better results + limit: 5 + }) + + const handleSearch = useCallback(async () => { + if (!query.trim() || !passphrase) return + + setSearching(true) + setError(null) + + try { + const result = await ehApi.ragQuery({ + query_text: query, + passphrase: passphrase, + subject: defaultSubject, + limit: options.limit, + rerank: options.rerank + }) + setResults(result) + } catch (err) { + console.error('RAG search failed:', err) + setError(err instanceof Error ? err.message : 'Suche fehlgeschlagen') + } finally { + setSearching(false) + } + }, [query, passphrase, defaultSubject, options]) + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + handleSearch() + } + } + + return ( +
              +
              + {/* Header */} +
              +

              Erwartungshorizont durchsuchen

              + +
              + + {/* Search Input */} +
              +